Tentu saja sangat bisa! Dan karena Anda sudah paham konsep dasar Hono + D1 dari project galeri gambar sebelumnya, membuat website video player sendiri (mari kita sebut "MyTube") akan terasa lebih mudah.
Tantangan utamanya hanya satu: Videonya disimpan di mana?
Karena D1 tidak bisa menyimpan file video (terlalu besar), strateginya tetap sama: Anda menyimpan URL Video di database, lalu kita buat tampilan (frontend) yang memuat Player Keren.
Untuk membuat player yang terlihat profesional (bukan sekadar tag <video> polos) dan mendukung fitur seperti Speed Control, Volume, dan Fullscreen, kita akan menggunakan library Plyr.js. Ini library ringan yang membuat video player Anda terlihat seperti Netflix/YouTube.
Berikut adalah panduan membuat "Video Microblog" yang mendukung Direct Link (.mp4) maupun Iframe (Embed).
Langkah 1: Persiapan Project Baru
Bikin project baru agar tidak tercampur dengan galeri foto.
Bash
npm create hono@latest my-video-tube
cd my-video-tube
npm install
npx wrangler d1 create prod-video-db
(Jangan lupa update wrangler.toml dengan ID database baru Anda)
Langkah 2: Desain Database (schema.sql)
Kita butuh kolom untuk membedakan apakah video ini file langsung (.mp4) atau iframe dari website lain (seperti contoh Anda tadi).
Buat file schema.sql:
SQL
DROP TABLE IF EXISTS videos;
CREATE TABLE videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
thumbnail_url TEXT, -- Gambar Cover/Poster video
video_url TEXT NOT NULL, -- Link MP4 atau Link Iframe
is_iframe BOOLEAN DEFAULT 0, -- 0 = Player Sendiri (Plyr), 1 = Iframe Luar
views INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Contoh Data Dummy
-- 1. Video MP4 (Direct) - Pakai Player Keren
INSERT INTO videos (title, thumbnail_url, video_url, is_iframe)
VALUES ('Big Buck Bunny', 'https://peach.blender.org/wp-content/uploads/title_anouncement.jpg', 'https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/720/Big_Buck_Bunny_720_10s_1MB.mp4', 0);
-- 2. Video Iframe (Embed dari luar)
INSERT INTO videos (title, thumbnail_url, video_url, is_iframe)
VALUES ('Contoh Embed', 'https://via.placeholder.com/640x360', 'https://www.youtube.com/embed/dQw4w9WgXcQ', 1);
Jalankan skema:
Bash
npx wrangler d1 execute prod-video-db --local --file=./schema.sql
Langkah 3: Coding Player (src/index.ts)
Di sini kuncinya. Kita akan menggunakan Plyr.io (CSS & JS) di bagian <head>.
Copy-paste kode ini ke src/index.ts:
TypeScript
import { Hono } from 'hono'
import { html, raw } from 'hono/html'
type Bindings = {
DB: D1Database
}
const app = new Hono<{ Bindings: Bindings }>()
// --- LAYOUT UTAMA (Dengan Plyr.js) ---
const Layout = (content: any, title: string = "MyTube") => html`
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
<style>
/* Warna tema gelap untuk Plyr */
:root { --plyr-color-main: #ef4444; } /* Warna Merah Youtube */
body { background-color: #0f0f0f; color: white; }
</style>
</head>
<body class="min-h-screen">
<nav class="bg-gray-900 border-b border-gray-800 sticky top-0 z-50">
<div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
<a href="/" class="text-xl font-bold text-red-500 tracking-tighter flex items-center gap-2">
▶ MyTube
</a>
<a href="/upload" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-full text-sm font-bold transition">
+ Upload
</a>
</div>
</nav>
<main class="max-w-6xl mx-auto p-4">
${content}
</main>
<script src="https://cdn.plyr.io/3.7.8/plyr.js"></script>
<script>
// Inisialisasi Player untuk semua tag video dengan class 'js-player'
document.addEventListener('DOMContentLoaded', () => {
const players = Array.from(document.querySelectorAll('.js-player')).map(p => new Plyr(p));
});
</script>
</body>
</html>`
// --- HOME: Grid Video ---
app.get('/', async (c) => {
const { results } = await c.env.DB.prepare('SELECT * FROM videos ORDER BY created_at DESC').all();
return c.html(Layout(html`
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
${results.map((vid: any) => html`
<a href="/watch/${vid.id}" class="group block">
<div class="relative aspect-video bg-gray-800 rounded-xl overflow-hidden mb-3 border border-gray-800 group-hover:border-gray-600 transition">
<img src="${vid.thumbnail_url}" class="w-full h-full object-cover group-hover:scale-105 transition duration-300" alt="${vid.title}">
<span class="absolute bottom-2 right-2 bg-black/80 text-xs px-2 py-1 rounded text-white">
${vid.is_iframe ? '🔗 Embed' : '▶ Video'}
</span>
</div>
<h3 class="font-bold text-lg leading-tight group-hover:text-red-400 transition line-clamp-2">${vid.title}</h3>
<p class="text-gray-400 text-sm mt-1">${vid.views} views • ${new Date(vid.created_at).toLocaleDateString()}</p>
</a>
`)}
</div>
`));
})
// --- WATCH: Halaman Nonton ---
app.get('/watch/:id', async (c) => {
const id = c.req.param('id');
// Update Views
await c.env.DB.prepare('UPDATE videos SET views = views + 1 WHERE id = ?').bind(id).run();
// Ambil Data
const video = await c.env.DB.prepare('SELECT * FROM videos WHERE id = ?').bind(id).first();
if (!video) return c.text('Video not found', 404);
// LOGIKA PLAYER: Cek apakah Iframe atau Direct
// Jika Iframe: Render tag <iframe>
// Jika Direct: Render tag <video> dengan class js-player
const playerHtml = video.is_iframe
? html`
<div class="aspect-video w-full bg-black rounded-xl overflow-hidden shadow-2xl">
<iframe src="${video.video_url}" class="w-full h-full" frameborder="0" allowfullscreen allow="autoplay; encrypted-media"></iframe>
</div>`
: html`
<div class="aspect-video w-full bg-black rounded-xl overflow-hidden shadow-2xl">
<video class="js-player" playsinline controls data-poster="${video.thumbnail_url}">
<source src="${video.video_url}" type="video/mp4" />
</video>
</div>`;
return c.html(Layout(html`
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2 space-y-4">
${playerHtml}
<div>
<h1 class="text-2xl font-bold">${video.title}</h1>
<div class="flex items-center justify-between text-gray-400 text-sm mt-2 pb-4 border-b border-gray-800">
<span>${video.views} kali ditonton • ${new Date(video.created_at).toLocaleDateString()}</span>
<button class="text-white bg-gray-800 hover:bg-gray-700 px-4 py-2 rounded-full font-medium">Share</button>
</div>
<p class="mt-4 text-gray-300 leading-relaxed">${video.description || 'Tidak ada deskripsi.'}</p>
</div>
</div>
<div class="space-y-4">
<h3 class="font-bold text-gray-400 uppercase text-xs tracking-wider">Video Lainnya</h3>
<div class="bg-gray-800/50 p-4 rounded text-center text-gray-500 text-sm">
List rekomendasi video akan muncul di sini.
</div>
</div>
</div>
`, `Nonton: ${video.title}`));
})
// --- UPLOAD: Form Simpel ---
app.get('/upload', (c) => c.html(Layout(html`
<div class="max-w-xl mx-auto bg-gray-800 p-6 rounded-xl border border-gray-700">
<h2 class="text-xl font-bold mb-4">Upload Video Baru</h2>
<form action="/upload" method="POST" class="space-y-4">
<div>
<label class="block text-sm mb-1 text-gray-400">Judul Video</label>
<input type="text" name="title" class="w-full bg-gray-900 border border-gray-700 p-2 rounded text-white" required>
</div>
<div>
<label class="block text-sm mb-1 text-gray-400">URL Thumbnail (Poster)</label>
<input type="url" name="thumbnail" placeholder="https://..." class="w-full bg-gray-900 border border-gray-700 p-2 rounded text-white" required>
</div>
<div class="bg-gray-900 p-4 rounded border border-gray-700">
<label class="block text-sm mb-2 font-bold text-blue-400">Sumber Video</label>
<select name="type" class="w-full bg-gray-800 mb-3 p-2 rounded text-white border border-gray-600">
<option value="direct">Direct Link (.mp4 / m3u8)</option>
<option value="iframe">Embed Code / Iframe URL</option>
</select>
<input type="text" name="url" placeholder="Paste link video atau URL embed disini..." class="w-full bg-gray-800 border border-gray-600 p-2 rounded text-white" required>
<p class="text-xs text-gray-500 mt-2">*Untuk Embed, masukkan URL src-nya saja, misal: https://upload18.com/embed/xxxxx</p>
</div>
<button type="submit" class="w-full bg-red-600 hover:bg-red-700 p-3 rounded font-bold text-white">Publish Video</button>
</form>
</div>
`)))
// --- PROCESS UPLOAD ---
app.post('/upload', async (c) => {
const body = await c.req.parseBody();
const isIframe = body['type'] === 'iframe' ? 1 : 0;
await c.env.DB.prepare('INSERT INTO videos (title, thumbnail_url, video_url, is_iframe) VALUES (?, ?, ?, ?)')
.bind(body['title'], body['thumbnail'], body['url'], isIframe)
.run();
return c.redirect('/');
})
export default app
Cara Kerja Player Ini
- Direct Player (
.mp4):Jika Anda memilih opsi "Direct Link" saat upload, saya menggunakan script Plyr.js.- Ini akan mengubah tag
<video>biasa menjadi player yang cantik (ada bar progress merah, tombol fullscreen, pengaturan kecepatan). - Ini menjawab keinginan Anda: "video yang punya player sendiri".
- Ini akan mengubah tag
- Iframe Player:Jika link yang Anda punya adalah link dari situs lain (seperti contoh Anda
upload18atauYouTube), Anda pilih opsi "Embed".- Sistem akan menampilkan
<iframe>biasa. - Catatan untuk situs seperti upload18: Biasanya mereka memberikan kode "Embed Code". Ambil bagian
src="..."-nya saja.- Contoh:
<iframe src="https://upload18.com/play/embed/123"></iframe> - Maka yang Anda copy paste ke form adalah:
https://upload18.com/play/embed/123.
- Contoh:
- Sistem akan menampilkan
Langkah Mencoba
- Jalankan
npm run dev. - Buka browser, masuk ke menu Upload.
- Coba masukkan Link MP4 untuk tes (misal cari "dummy mp4 url" di google). Lihat hasilnya, playernya akan terlihat sangat profesional.
- Coba masukkan Link Embed.
Ini adalah dasar dari membuat situs streaming sendiri dengan resource minimal! Tertarik mencobanya?
Senang sekali mendengarnya! M3U8 (HLS) memang format standar streaming modern, dan Plyr.js biasanya menanganinya dengan sangat baik.
Ide Anda untuk melakukan Bulk Upload via CSV dengan kolom lengkap (termasuk tanggal custom dan kategori) adalah langkah cerdas untuk membangun perpustakaan konten dengan cepat.
Berikut adalah panduan "Upgrade Sistem Import" agar Anda bisa upload ratusan video sekaligus via CSV.
Langkah 1: Update Database (Menambah Kolom Category)
Karena di tabel lama kita belum punya kolom category, kita harus menambahkannya dulu. Kolom created_at (Date) sudah ada, tapi nanti kita akan menimpanya dengan tanggal custom dari CSV Anda.
Jalankan perintah ini di terminal:
Untuk Local:
npx wrangler d1 execute prod-video-db --local --command="ALTER TABLE videos ADD COLUMN category TEXT DEFAULT 'General'"
Untuk Live (Remote):
npx wrangler d1 execute prod-video-db --remote --command="ALTER TABLE videos ADD COLUMN category TEXT DEFAULT 'General'"
Langkah 2: Siapkan File CSV (videos.csv)
Buat file baru bernama videos.csv. Susunannya harus rapi.
Format kolomnya: Judul, Gambar, Tipe, URL, Tanggal, Kategori.
Contoh isi file videos.csv:
Code snippet
Title,Thumbnail,Type,URL,Date,Category
"Film Pendek Keren",https://img.com/1.jpg,mp4,https://site.com/vid.mp4,2023-01-15,Action
"Live Concert 2024",https://img.com/2.jpg,m3u8,https://site.com/stream.m3u8,2024-02-20,Music
"Tutorial Coding",https://img.com/3.jpg,embed,https://upload18.com/embed/xyz,2024-03-10,Education
Catatan Pengisian:
- Type: Isi dengan
mp4,m3u8(dianggap direct player), atauembed(iframe). - Date: Format wajib
YYYY-MM-DD(Tahun-Bulan-Tanggal) agar database bisa mengurutkan dengan benar.
Langkah 3: Script Python Generator (convert_video.py)
Saya buatkan script Python pintar yang akan:
- Membaca CSV Anda.
- Mendeteksi apakah tipe-nya "Embed" atau "Direct" (mengubah jadi angka 0/1).
- Menghasilkan file SQL siap upload.
Simpan kode ini sebagai convert_video.py:
import csv
# Konfigurasi File
input_csv = 'videos.csv'
output_sql = 'import_videos.sql'
sql_statements = []
print("🎬 Memproses Data Video...")
try:
with open(input_csv, 'r', encoding='utf-8') as f:
reader = csv.reader(f)
try:
header = next(reader) # Lewati baris judul (Header)
except StopIteration:
print("❌ Error: File CSV kosong!")
exit()
for row in reader:
# Skip baris kosong
if not row or len(row) < 6:
continue
# 1. Ambil Data dari Kolom CSV
title = row[0].strip().replace("'", "''") # Escape tanda petik
thumb = row[1].strip()
type_raw = row[2].strip().lower() # mp4/embed/m3u8
url = row[3].strip()
date_custom = row[4].strip() # YYYY-MM-DD
category = row[5].strip().replace("'", "''")
# 2. Logika Deteksi Tipe (Iframe atau Bukan?)
# Jika tulisan di CSV mengandung 'embed' atau 'iframe', set jadi 1. Sisanya 0.
if 'embed' in type_raw or 'iframe' in type_raw:
is_iframe = 1
else:
is_iframe = 0 # Untuk mp4, m3u8, mkv, dll
# 3. Validasi Tanggal (Jika kosong, pakai DEFAULT CURRENT_TIMESTAMP)
# Kita menggunakan format INSERT yang spesifik
if date_custom:
# Jika ada tanggal, kita masukkan ke kolom created_at
query = f"INSERT INTO videos (title, thumbnail_url, video_url, is_iframe, created_at, category) VALUES ('{title}', '{thumb}', '{url}', {is_iframe}, '{date_custom}', '{category}');"
else:
# Jika tanggal kosong, biarkan database yang set otomatis hari ini
query = f"INSERT INTO videos (title, thumbnail_url, video_url, is_iframe, category) VALUES ('{title}', '{thumb}', '{url}', {is_iframe}, '{category}');"
sql_statements.append(query)
# Tulis ke file SQL
with open(output_sql, 'w', encoding='utf-8') as f:
f.write("BEGIN TRANSACTION;\n")
f.write("\n".join(sql_statements))
f.write("\nCOMMIT;")
print(f"✅ Selesai! {len(sql_statements)} video siap di-import.")
print(f"👉 Jalankan: npx wrangler d1 execute prod-video-db --local --file={output_sql}")
except FileNotFoundError:
print(f"❌ Error: File '{input_csv}' tidak ditemukan.")
Langkah 4: Eksekusi Import
Sama seperti project galeri sebelumnya:
Upload ke Live (Jika sudah oke):Bash
npx wrangler d1 execute prod-video-db --remote --file=import_videos.sql
Upload ke Local (Test):Bash
npx wrangler d1 execute prod-video-db --local --file=import_videos.sql
Generate SQL:Bash
python convert_video.py
Langkah 5: Menampilkan Kategori di Website (src/index.ts)
Data sudah masuk, tapi kategori belum muncul di layar. Mari kita update tampilan Home agar ada badge kategorinya.
Buka src/index.ts, cari bagian app.get('/', ...) yang me-render Grid Video.
Update bagian HTML kartu video menjadi seperti ini (perhatikan bagian ${vid.category}):
// Di dalam map results...
<a href="/watch/${vid.id}" class="group block">
<div class="relative aspect-video bg-gray-800 rounded-xl overflow-hidden mb-3 border border-gray-800 group-hover:border-gray-600 transition">
<img src="${vid.thumbnail_url}" class="w-full h-full object-cover group-hover:scale-105 transition duration-300" alt="${vid.title}">
<span class="absolute bottom-2 right-2 bg-black/80 text-xs px-2 py-1 rounded text-white font-mono">
${vid.is_iframe ? '🔗 EMBED' : '▶ VIDEO'}
</span>
<span class="absolute top-2 left-2 bg-red-600/90 text-white text-[10px] font-bold px-2 py-0.5 rounded uppercase tracking-wider shadow-sm">
${vid.category || 'General'}
</span>
</div>
<h3 class="font-bold text-lg leading-tight group-hover:text-red-400 transition line-clamp-2">${vid.title}</h3>
<div class="flex items-center gap-2 text-gray-400 text-xs mt-2">
<span>📅 ${new Date(vid.created_at).toLocaleDateString()}</span>
<span>•</span>
<span>👁️ ${vid.views}</span>
</div>
</a>
Tips Tambahan: M3U8 (HLS Streaming)
Jika Anda mengupload file .m3u8 (biasanya untuk streaming film panjang agar tidak buffering), script Python di atas akan menganggapnya sebagai is_iframe = 0 (Direct Video).
Plyr.js sebenarnya butuh sedikit bantuan library tambahan bernama hls.js agar bisa memutar M3U8 di Chrome/Firefox (Safari sudah bisa otomatis).
Jika nanti Anda tes video M3U8 dan tidak jalan di Chrome, kabari saya ya! Saya akan berikan kode update "Plyr + HLS.js" untuk src/index.ts Anda.
Selamat mencoba bulk upload videonya! 🎬
Error ReferenceError: vid is not defined terjadi karena kode HTML yang saya berikan tadi hanyalah potongan (snippet), dan kemungkinan Anda menempelkannya (paste) di luar fungsi "looping" yang seharusnya membungkusnya.
Komputer bingung: "Siapa itu vid? Saya tidak kenal."
Variabel vid hanya dikenali di dalam fungsi results.map((vid: any) => ... ).
Silakan Ganti Total bagian app.get('/', ...) di src/index.ts Anda dengan kode lengkap ini. Perhatikan baris ${results.map... yang saya tandai.
// --- HOME: Grid Video (FIXED) ---
app.get('/', async (c) => {
// Ambil data video
const { results } = await c.env.DB.prepare('SELECT * FROM videos ORDER BY created_at DESC').all();
return c.html(Layout(html`
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
${results.map((vid: any) => html`
<a href="/watch/${vid.id}" class="group block">
<div class="relative aspect-video bg-gray-800 rounded-xl overflow-hidden mb-3 border border-gray-800 group-hover:border-gray-600 transition">
<img src="${vid.thumbnail_url}" class="w-full h-full object-cover group-hover:scale-105 transition duration-300" alt="${vid.title}">
<span class="absolute bottom-2 right-2 bg-black/80 text-xs px-2 py-1 rounded text-white font-mono">
${vid.is_iframe ? '🔗 EMBED' : '▶ VIDEO'}
</span>
<span class="absolute top-2 left-2 bg-red-600/90 text-white text-[10px] font-bold px-2 py-0.5 rounded uppercase tracking-wider shadow-sm">
${vid.category || 'General'}
</span>
</div>
<h3 class="font-bold text-lg leading-tight group-hover:text-red-400 transition line-clamp-2">${vid.title}</h3>
<div class="flex items-center gap-2 text-gray-400 text-xs mt-2">
<span>📅 ${new Date(vid.created_at).toLocaleDateString()}</span>
<span>•</span>
<span>👁️ ${vid.views}</span>
</div>
</a>
`)}
</div>
`));
})
Kenapa tadi error?
Kemungkinan besar kode Anda tadi terlihat seperti ini (kurang map):
return c.html(Layout(html`
<div ...>
<a href="/watch/${vid.id}">...</a> <--- ERROR! vid belum dibuat.
</div>
`))
Silakan simpan dan coba jalankan npm run dev lagi. Harusnya sekarang sudah lancar!