/* ============================================================================ * YT Player — frontend logic * Talks to the native (Zig) side over the zero-native bridge: window.zero.invoke * Handlers (implemented in src/bridge.zig): * yt.search { query } -> { ok, results:[{id,title,channel,duration,thumbnail}] } * yt.streams { videoId } -> { ok, data:{ meta, audioUrl, qualities:[{label,height,url,hasAudio,ext}] } } * store.load {} -> { playlists, history, settings } * store.save { data } -> { ok } * ========================================================================== */ // ---------- Native bridge adapter ---------- async function invoke(command, payload = {}) { if (window.zero && typeof window.zero.invoke === 'function') { return await window.zero.invoke(command, payload); } throw new Error('Native bridge unavailable — run this inside zero-native (zig build run).'); } const API = { search: (query) => invoke('yt.search', { query }), getStreams: (videoId) => invoke('yt.streams', { videoId }), loadData: () => invoke('store.load', {}), // data is sent pre-stringified so the native side can write it verbatim. saveData: (data) => invoke('store.save', { data: JSON.stringify(data) }), }; // ---------- State ---------- let data = { playlists: [], history: [], settings: { quality: 'auto', volume: 1, audioOnly: false } }; let view = { type: 'search' }; // 'search' | 'history' | 'playlist' let searchResults = []; let queue = []; // list of video objects for autoplay let queueIndex = -1; let current = null; // { meta, qualities, audioUrl } let saveTimer = null; // ---------- DOM ---------- const $ = (id) => document.getElementById(id); const els = { video: $('video'), audio: $('audio'), art: $('artFallback'), artImg: $('artImg'), placeholder: $('playerPlaceholder'), spinner: $('bufferSpinner'), controls: $('controls'), playerPane: $('playerPane'), nowMeta: $('nowPlayingMeta'), npTitle: $('npTitle'), npChannel: $('npChannel'), seek: $('seek'), curTime: $('curTime'), durTime: $('durTime'), playBtn: $('playBtn'), prevBtn: $('prevBtn'), nextBtn: $('nextBtn'), muteBtn: $('muteBtn'), volume: $('volume'), speed: $('speedSelect'), quality: $('qualitySelect'), fsBtn: $('fsBtn'), cards: $('cards'), listTitle: $('listTitle'), listActions: $('listActions'), status: $('status'), searchForm: $('searchForm'), searchInput: $('searchInput'), playlistList: $('playlistList'), newPlaylistBtn: $('newPlaylistBtn'), audioOnlyToggle: $('audioOnlyToggle'), }; // ---------- Persistence ---------- function persist() { clearTimeout(saveTimer); saveTimer = setTimeout(() => API.saveData(data).catch(() => {}), 400); } // ---------- Helpers ---------- function fmtTime(sec) { if (!sec || !isFinite(sec)) return '0:00'; sec = Math.floor(sec); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; const mm = h ? String(m).padStart(2, '0') : String(m); return (h ? h + ':' : '') + mm + ':' + String(s).padStart(2, '0'); } function toast(msg) { const t = $('toast'); t.textContent = msg; t.classList.remove('hidden'); clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.add('hidden'), 2200); } function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); } // ============================================================================ // Player engine — single video, or video+audio synced (adaptive), or audio-only // ============================================================================ const Player = { mode: 'progressive', // 'progressive' | 'dual' | 'audio' master: els.video, secondary: null, // synced audio element in dual mode driftTimer: null, get soundEl() { return this.mode === 'dual' ? els.audio : this.master; }, async loadVideo(videoObj) { showSpinner(true); els.placeholder.classList.add('hidden'); try { const res = await API.getStreams(videoObj.id); if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams'); current = res.data; // Merge richer metadata we may already have from the list card. current.meta = { ...videoObj, ...current.meta }; addToHistory(current.meta); buildQualityMenu(); els.playerPane.classList.remove('empty'); els.controls.classList.remove('hidden'); els.nowMeta.classList.remove('hidden'); els.npTitle.textContent = current.meta.title; els.npChannel.textContent = current.meta.channel || ''; markPlayingCard(); const q = chooseQuality(); this.attach(q); } catch (err) { showSpinner(false); toast('⚠ ' + err.message); } }, attach(quality) { const V = els.video, A = els.audio; const audioOnly = data.settings.audioOnly; // Reset V.pause(); A.pause(); this.stopDrift(); if (audioOnly) { // Play only audio; show cover art. this.mode = 'audio'; this.master = A; this.secondary = null; A.src = current.audioUrl || (quality && quality.url) || ''; V.removeAttribute('src'); V.load(); els.art.classList.remove('hidden'); els.artImg.src = current.meta.thumbnail || ''; } else if (quality && quality.hasAudio) { // Progressive single stream (video already carries audio). this.mode = 'progressive'; this.master = V; this.secondary = null; V.muted = false; V.src = quality.url; A.removeAttribute('src'); A.load(); els.art.classList.add('hidden'); } else if (quality && current.audioUrl) { // Adaptive: muted video + synced audio for high quality without muxing. this.mode = 'dual'; this.master = V; this.secondary = A; V.muted = true; V.src = quality.url; A.src = current.audioUrl; els.art.classList.add('hidden'); } else { showSpinner(false); toast('No playable stream found for this video.'); return; } this.applyVolume(); this.applySpeed(); this.master.load(); if (this.secondary) this.secondary.load(); const onReady = () => { this.play(); this.master.removeEventListener('canplay', onReady); }; this.master.addEventListener('canplay', onReady); this.startDrift(); }, play() { this.master.play().catch(() => {}); if (this.secondary) { this.secondary.currentTime = this.master.currentTime; this.secondary.play().catch(() => {}); } }, pause() { this.master.pause(); if (this.secondary) this.secondary.pause(); }, toggle() { if (this.master.paused) this.play(); else this.pause(); }, seek(t) { this.master.currentTime = t; if (this.secondary) this.secondary.currentTime = t; }, applyVolume() { const v = data.settings.volume; this.soundEl.volume = v; this.soundEl.muted = v === 0; // make sure the non-sound elements stay muted if (this.mode === 'dual') els.video.muted = true; }, applySpeed() { const r = parseFloat(els.speed.value) || 1; this.master.playbackRate = r; if (this.secondary) this.secondary.playbackRate = r; }, startDrift() { this.stopDrift(); this.driftTimer = setInterval(() => { if (this.mode !== 'dual' || this.master.paused) return; const drift = Math.abs(this.secondary.currentTime - this.master.currentTime); if (drift > 0.3) this.secondary.currentTime = this.master.currentTime; }, 1000); }, stopDrift() { if (this.driftTimer) clearInterval(this.driftTimer); this.driftTimer = null; }, }; function showSpinner(on) { els.spinner.classList.toggle('hidden', !on); } function chooseQuality() { const qs = current.qualities || []; if (!qs.length) return null; const pref = data.settings.quality; if (pref && pref !== 'auto') { const match = qs.find((q) => q.label === pref); if (match) return match; } // auto: best quality at or below 720p, else the lowest available. const sorted = [...qs].sort((a, b) => b.height - a.height); return sorted.find((q) => q.height <= 720) || sorted[sorted.length - 1]; } function buildQualityMenu() { const qs = current.qualities || []; els.quality.innerHTML = ''; const auto = document.createElement('option'); auto.value = 'auto'; auto.textContent = 'Auto'; els.quality.appendChild(auto); const seen = new Set(); for (const q of qs) { if (seen.has(q.label)) continue; seen.add(q.label); const o = document.createElement('option'); o.value = q.label; o.textContent = q.label + (q.hasAudio ? '' : ''); els.quality.appendChild(o); } els.quality.value = data.settings.quality || 'auto'; } // ---------- Player media events (master mirrors to secondary) ---------- function wirePlayerEvents() { const V = els.video, A = els.audio; const masterIs = (el) => Player.master === el; function bind(el) { el.addEventListener('play', () => { if (masterIs(el) && Player.secondary && Player.secondary.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } updatePlayBtn(); }); el.addEventListener('pause', () => { if (masterIs(el) && Player.secondary) Player.secondary.pause(); updatePlayBtn(); }); el.addEventListener('seeking', () => { if (masterIs(el) && Player.secondary) Player.secondary.currentTime = el.currentTime; }); el.addEventListener('waiting', () => { if (masterIs(el)) { showSpinner(true); if (Player.secondary) Player.secondary.pause(); } }); el.addEventListener('playing', () => { if (masterIs(el)) { showSpinner(false); if (Player.secondary && !el.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } } }); el.addEventListener('canplay', () => { if (masterIs(el)) showSpinner(false); }); el.addEventListener('timeupdate', () => { if (masterIs(el)) updateProgress(); }); el.addEventListener('loadedmetadata', () => { if (masterIs(el)) updateProgress(); }); el.addEventListener('ended', () => { if (masterIs(el)) playNext(); }); el.addEventListener('error', () => { if (masterIs(el)) { showSpinner(false); } }); } bind(V); bind(A); } function updatePlayBtn() { els.playBtn.textContent = Player.master.paused ? '▶' : '⏸'; } function updateProgress() { const cur = Player.master.currentTime || 0; const dur = Player.master.duration || current?.meta?.duration || 0; els.curTime.textContent = fmtTime(cur); els.durTime.textContent = fmtTime(dur); if (dur) els.seek.value = String((cur / dur) * 1000); } // ============================================================================ // History // ============================================================================ function addToHistory(meta) { data.history = data.history.filter((v) => v.id !== meta.id); data.history.unshift({ id: meta.id, title: meta.title, channel: meta.channel, duration: meta.duration, thumbnail: meta.thumbnail, }); if (data.history.length > 200) data.history.length = 200; persist(); if (view.type === 'history') renderList(); } // ============================================================================ // Queue / navigation // ============================================================================ function playFromList(list, index) { queue = list; queueIndex = index; Player.loadVideo(list[index]); } function playNext() { if (queueIndex >= 0 && queueIndex < queue.length - 1) { queueIndex++; Player.loadVideo(queue[queueIndex]); } else { updatePlayBtn(); } } function playPrev() { if (Player.master.currentTime > 3) { Player.seek(0); return; } if (queueIndex > 0) { queueIndex--; Player.loadVideo(queue[queueIndex]); } } // ============================================================================ // Rendering // ============================================================================ function renderSidebar() { els.playlistList.innerHTML = ''; for (const pl of data.playlists) { const item = document.createElement('div'); item.className = 'playlist-item' + (view.type === 'playlist' && view.id === pl.id ? ' active' : ''); item.innerHTML = `${pl.videos.length}`; item.querySelector('.pl-name').textContent = pl.name; item.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); }); els.playlistList.appendChild(item); } document.querySelectorAll('.nav-item').forEach((b) => { b.classList.toggle('active', b.dataset.view === view.type); }); } function currentList() { if (view.type === 'search') return searchResults; if (view.type === 'history') return data.history; if (view.type === 'playlist') { const pl = data.playlists.find((p) => p.id === view.id); return pl ? pl.videos : []; } return []; } function renderList() { const list = currentList(); els.cards.innerHTML = ''; els.listActions.innerHTML = ''; if (view.type === 'search') { els.listTitle.textContent = 'Search results'; } else if (view.type === 'history') { els.listTitle.textContent = 'History'; if (list.length) { const clear = document.createElement('button'); clear.textContent = 'Clear'; clear.onclick = () => { data.history = []; persist(); renderList(); }; els.listActions.appendChild(clear); } } else if (view.type === 'playlist') { const pl = data.playlists.find((p) => p.id === view.id); els.listTitle.textContent = pl ? pl.name : 'Playlist'; if (pl) { const playAll = document.createElement('button'); playAll.textContent = '▶ Play all'; playAll.onclick = () => { if (pl.videos.length) playFromList(pl.videos, 0); }; const rename = document.createElement('button'); rename.textContent = 'Rename'; rename.onclick = () => renamePlaylist(pl); const del = document.createElement('button'); del.textContent = 'Delete'; del.onclick = () => deletePlaylist(pl); els.listActions.append(playAll, rename, del); } } if (!list.length) { els.status.classList.remove('hidden'); els.status.textContent = view.type === 'search' ? 'Search for something to begin.' : view.type === 'history' ? 'Nothing watched yet.' : 'This playlist is empty. Add videos from search.'; return; } els.status.classList.add('hidden'); list.forEach((v, i) => els.cards.appendChild(renderCard(v, i, list))); markPlayingCard(); } function renderCard(v, index, list) { const card = document.createElement('div'); card.className = 'card'; card.dataset.id = v.id; card.innerHTML = `
${v.duration ? `${fmtTime(v.duration)}` : ''}
`; card.querySelector('.card-title').textContent = v.title; card.querySelector('.card-channel').textContent = v.channel || ''; card.addEventListener('click', (e) => { if (e.target.closest('.card-menu')) return; playFromList(list, index); }); card.querySelector('.card-menu').addEventListener('click', (e) => { e.stopPropagation(); openCardMenu(v); }); return card; } function markPlayingCard() { document.querySelectorAll('.card').forEach((c) => { c.classList.toggle('playing', current && c.dataset.id === current.meta.id); }); } function render() { renderSidebar(); renderList(); } // ============================================================================ // Playlists // ============================================================================ function openCardMenu(video) { const inPlaylistView = view.type === 'playlist'; const body = document.createElement('div'); body.className = 'modal-list'; data.playlists.forEach((pl) => { const has = pl.videos.some((x) => x.id === video.id); const btn = document.createElement('button'); btn.textContent = (has ? '✓ ' : '+ ') + pl.name; btn.onclick = () => { if (has) pl.videos = pl.videos.filter((x) => x.id !== video.id); else pl.videos.push(slim(video)); persist(); closeModal(); toast(has ? `Removed from ${pl.name}` : `Added to ${pl.name}`); render(); }; body.appendChild(btn); }); const newBtn = document.createElement('button'); newBtn.textContent = '+ New playlist…'; newBtn.onclick = () => { closeModal(); newPlaylist(video); }; body.appendChild(newBtn); const actions = [{ label: 'Close', onClick: closeModal }]; if (inPlaylistView) { actions.unshift({ label: 'Remove from this playlist', danger: true, onClick: () => { const pl = data.playlists.find((p) => p.id === view.id); if (pl) { pl.videos = pl.videos.filter((x) => x.id !== video.id); persist(); } closeModal(); render(); }, }); } showModal('Add to playlist', body, actions); } function slim(v) { return { id: v.id, title: v.title, channel: v.channel, duration: v.duration, thumbnail: v.thumbnail }; } function newPlaylist(addVideo) { const input = document.createElement('input'); input.type = 'text'; input.placeholder = 'Playlist name'; showModal('New playlist', input, [ { label: 'Cancel', onClick: closeModal }, { label: 'Create', primary: true, onClick: () => { const name = input.value.trim(); if (!name) return; const pl = { id: uid(), name, videos: addVideo ? [slim(addVideo)] : [] }; data.playlists.push(pl); persist(); closeModal(); view = { type: 'playlist', id: pl.id }; render(); toast(`Created “${name}”`); }, }, ]); setTimeout(() => input.focus(), 50); } function renamePlaylist(pl) { const input = document.createElement('input'); input.type = 'text'; input.value = pl.name; showModal('Rename playlist', input, [ { label: 'Cancel', onClick: closeModal }, { label: 'Save', primary: true, onClick: () => { const name = input.value.trim(); if (name) { pl.name = name; persist(); } closeModal(); render(); }, }, ]); setTimeout(() => { input.focus(); input.select(); }, 50); } function deletePlaylist(pl) { showModal(`Delete “${pl.name}”?`, document.createTextNode('This cannot be undone.'), [ { label: 'Cancel', onClick: closeModal }, { label: 'Delete', danger: true, onClick: () => { data.playlists = data.playlists.filter((p) => p.id !== pl.id); persist(); closeModal(); view = { type: 'search' }; render(); }, }, ]); } // ---------- Modal ---------- function showModal(title, bodyNode, actions) { $('modalTitle').textContent = title; const body = $('modalBody'); body.innerHTML = ''; body.appendChild(bodyNode); const act = $('modalActions'); act.innerHTML = ''; actions.forEach((a) => { const b = document.createElement('button'); b.className = 'btn' + (a.primary ? ' primary' : '') + (a.danger ? ' danger' : ''); b.textContent = a.label; b.onclick = a.onClick; act.appendChild(b); }); $('modal').classList.remove('hidden'); } function closeModal() { $('modal').classList.add('hidden'); } // ============================================================================ // Events // ============================================================================ function wireUI() { els.searchForm.addEventListener('submit', async (e) => { e.preventDefault(); const q = els.searchInput.value.trim(); if (!q) return; view = { type: 'search' }; render(); els.status.classList.remove('hidden'); els.status.textContent = 'Searching…'; els.cards.innerHTML = ''; try { const res = await API.search(q); if (!res || !res.ok) throw new Error(res?.error || 'Search failed'); searchResults = res.results || []; renderList(); } catch (err) { els.status.textContent = '⚠ ' + err.message; } }); document.querySelectorAll('.nav-item').forEach((b) => { b.addEventListener('click', () => { view = { type: b.dataset.view }; render(); }); }); els.newPlaylistBtn.addEventListener('click', () => newPlaylist(null)); // Controls els.playBtn.addEventListener('click', () => Player.toggle()); els.nextBtn.addEventListener('click', playNext); els.prevBtn.addEventListener('click', playPrev); els.fsBtn.addEventListener('click', () => { const stage = els.video.parentElement; if (document.fullscreenElement) document.exitFullscreen(); else stage.requestFullscreen?.(); }); els.seek.addEventListener('input', () => { const dur = Player.master.duration || 0; if (dur) Player.seek((parseFloat(els.seek.value) / 1000) * dur); }); els.volume.addEventListener('input', () => { data.settings.volume = parseFloat(els.volume.value); Player.applyVolume(); els.muteBtn.textContent = data.settings.volume === 0 ? '🔇' : '🔊'; persist(); }); els.muteBtn.addEventListener('click', () => { if (data.settings.volume > 0) { els.muteBtn.dataset.prev = data.settings.volume; data.settings.volume = 0; } else { data.settings.volume = parseFloat(els.muteBtn.dataset.prev || '1'); } els.volume.value = String(data.settings.volume); Player.applyVolume(); els.muteBtn.textContent = data.settings.volume === 0 ? '🔇' : '🔊'; persist(); }); els.speed.addEventListener('change', () => Player.applySpeed()); els.quality.addEventListener('change', () => { data.settings.quality = els.quality.value; persist(); if (!current) return; // reload at the new quality, preserving position + play state const t = Player.master.currentTime; const wasPlaying = !Player.master.paused; const q = chooseQuality(); Player.attach(q); const restore = () => { Player.seek(t); if (!wasPlaying) Player.pause(); Player.master.removeEventListener('canplay', restore); }; Player.master.addEventListener('canplay', restore); }); els.audioOnlyToggle.addEventListener('change', () => { data.settings.audioOnly = els.audioOnlyToggle.checked; persist(); if (!current) return; const t = Player.master.currentTime; const q = chooseQuality(); Player.attach(q); const restore = () => { Player.seek(t); Player.master.removeEventListener('canplay', restore); }; Player.master.addEventListener('canplay', restore); }); // Keyboard document.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT') return; if (e.code === 'Space') { e.preventDefault(); Player.toggle(); } else if (e.code === 'ArrowRight') Player.seek(Player.master.currentTime + 5); else if (e.code === 'ArrowLeft') Player.seek(Math.max(0, Player.master.currentTime - 5)); else if (e.key === 'f') els.fsBtn.click(); else if (e.key === 'm') els.muteBtn.click(); }); $('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); }); } // ============================================================================ // Boot // ============================================================================ async function boot() { wirePlayerEvents(); wireUI(); try { const loaded = await API.loadData(); if (loaded && typeof loaded === 'object') { data = { playlists: loaded.playlists || [], history: loaded.history || [], settings: { quality: 'auto', volume: 1, audioOnly: false, ...(loaded.settings || {}) }, }; } } catch { // first run / bridge not ready — start with defaults } els.volume.value = String(data.settings.volume ?? 1); els.quality.value = data.settings.quality || 'auto'; els.audioOnlyToggle.checked = !!data.settings.audioOnly; render(); els.searchInput.focus(); } document.addEventListener('DOMContentLoaded', boot);