/* ============================================================================ * 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 ---------- // Works against two shells from the same frontend: // • Tauri (Windows / WebView2): window.__TAURI__.core.invoke, snake_case commands // • zero-native (Linux / macOS): window.zero.invoke, dotted commands const TAURI = window.__TAURI__ && window.__TAURI__.core ? window.__TAURI__.core : null; const ZERO = window.zero && typeof window.zero.invoke === 'function' ? window.zero : null; // call(zeroName, tauriName, payload) — routes to whichever shell is present. async function call(zeroName, tauriName, payload = {}) { if (TAURI) return await TAURI.invoke(tauriName, payload); if (ZERO) return await ZERO.invoke(zeroName, payload); throw new Error('No native bridge available — run inside the YT Player app.'); } const API = { search: (query) => call('yt.search', 'yt_search', { query }), getStreams: (videoId) => call('yt.streams', 'yt_streams', { videoId }), loadData: () => call('store.load', 'store_load', {}), // data is sent pre-stringified so the native side can write it verbatim. saveData: (data) => call('store.save', 'store_save', { data: JSON.stringify(data) }), // Offline cache (Tauri shell). Calls are wrapped where used so the Linux // shell — which doesn't implement these yet — degrades gracefully. cacheDownload: (videoId) => call('cache.download', 'cache_download', { videoId }), cacheStatus: (videoId) => call('cache.status', 'cache_status', { videoId }), cacheList: () => call('cache.list', 'cache_list', {}), cacheDelete: (videoId) => call('cache.delete', 'cache_delete', { videoId }), cacheClear: () => call('cache.clear', 'cache_clear', {}), }; // Resolve a native file path to a URL the WebView can load (Tauri asset // protocol). Returns null on shells without it. function toAssetUrl(path) { if (TAURI && typeof TAURI.convertFileSrc === 'function') return TAURI.convertFileSrc(path); return null; } // ---------- State ---------- let data = { playlists: [], history: [], settings: { quality: 'auto', volume: 1, audioOnly: false, autoPreload: true } }; let view = { type: 'search' }; // 'search' | 'history' | 'playlist' | 'settings' let searchResults = []; let queue = []; // list of video objects for autoplay let queueIndex = -1; let current = null; // { meta, qualities, audioUrl, localUrl? } let saveTimer = null; const cachedIds = new Set(); // video ids that exist in the offline cache const downloading = new Set(); // video ids with an in-flight download // ---------- 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'), saveBtn: $('saveBtn'), addPlaylistBtn: $('addPlaylistBtn'), }; // ---------- 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); } function fmtBytes(n) { if (!n) return '0 B'; const u = ['B', 'KB', 'MB', 'GB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return (i ? n.toFixed(1) : n) + ' ' + u[i]; } // ============================================================================ // Offline cache (preload) // ============================================================================ async function refreshCachedIds() { cachedIds.clear(); try { const res = await API.cacheList(); if (res && res.ok) for (const it of res.items || []) cachedIds.add(it.id); } catch { /* shell without cache support — leave empty */ } } // Download a video into the permanent offline cache. Safe to call repeatedly. async function preload(video, { quiet = false } = {}) { const id = video.id; if (!id || cachedIds.has(id) || downloading.has(id)) return; downloading.add(id); markCardCacheState(id, 'downloading'); if (!quiet) toast(`Saving “${video.title}” for offline…`); try { const res = await API.cacheDownload(id); if (res && res.ok && res.cached) { cachedIds.add(id); if (!quiet) toast(`Saved “${video.title}” ✓`); } else if (!quiet) { toast('⚠ ' + ((res && res.error) || 'Could not save video')); } } catch (e) { if (!quiet) toast('⚠ Saving not supported in this build.'); } finally { downloading.delete(id); markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none'); if (current && current.meta && current.meta.id === id) updateNowPlayingActions(); if (view.type === 'settings') renderList(); } } // Auto-preload every video in a playlist (respecting the setting). function preloadPlaylist(pl) { if (!data.settings.autoPreload || !pl) return; pl.videos.forEach((v) => preload(v, { quiet: true })); } // Update a single card's saved badge without a full re-render. function markCardCacheState(id, state) { document.querySelectorAll(`.card[data-id="${CSS.escape(id)}"]`).forEach((c) => { c.classList.toggle('cached', state === 'cached'); c.classList.toggle('downloading', state === 'downloading'); }); } // ============================================================================ // 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, { preferStream = false } = {}) { showSpinner(true); els.placeholder.classList.add('hidden'); try { // Play from the offline cache when available — instant and works offline. if (!preferStream && cachedIds.has(videoObj.id)) { let localUrl = null; try { const st = await API.cacheStatus(videoObj.id); if (st && st.ok && st.cached) localUrl = toAssetUrl(st.path); } catch { /* fall through to streaming */ } if (localUrl) { current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl }; this.afterLoad(); this.fallbackQueue = []; this.fbIndex = 0; this.attach(null); return; } } 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 }; this.afterLoad(); const q = chooseQuality(); this.buildFallbackQueue(q); this.attach(q); } catch (err) { showSpinner(false); toast('⚠ ' + err.message); } }, // Shared UI updates once `current` is populated (cached or streamed). afterLoad() { 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 || ''; updateNowPlayingActions(); markPlayingCard(); }, // Ordered list of qualities to try if playback errors out. Progressive // (single-file, has audio) goes near the front because it's the most reliable; // then we walk from the lowest resolution up. fallbackQueue: [], fbIndex: 0, buildFallbackQueue(chosen) { const qs = (current.qualities || []).slice().sort((a, b) => a.height - b.height); const seen = new Set(); const queue = []; const push = (x) => { if (x && !seen.has(x.label)) { seen.add(x.label); queue.push(x); } }; push(chosen); qs.filter((q) => q.hasAudio).forEach(push); // progressive = sturdiest qs.forEach(push); // then everything, low → high this.fallbackQueue = queue; this.fbIndex = 0; }, onMediaError() { // A cached file failed to play — fall back to live streaming. if (current && current.localUrl) { const meta = current.meta; current.localUrl = null; toast('Cached copy unavailable — streaming instead…'); this.loadVideo(meta, { preferStream: true }); return; } // Advance to the next candidate stream; give up with a clear message at the end. if (this.fbIndex < this.fallbackQueue.length - 1) { this.fbIndex++; const next = this.fallbackQueue[this.fbIndex]; toast('Playback hiccup — trying ' + next.label + '…'); this.attach(next); } else { showSpinner(false); toast('⚠ This video couldn’t be played. Try another.'); } }, attach(quality) { const V = els.video, A = els.audio; const audioOnly = data.settings.audioOnly; // Reset V.pause(); A.pause(); this.stopDrift(); if (current.localUrl) { // Cached single file (carries its own audio). Play it directly. if (audioOnly) { this.mode = 'audio'; this.master = A; this.secondary = null; A.src = current.localUrl; V.removeAttribute('src'); V.load(); els.art.classList.remove('hidden'); els.artImg.src = current.meta.thumbnail || ''; } else { this.mode = 'progressive'; this.master = V; this.secondary = null; V.muted = false; V.src = current.localUrl; A.removeAttribute('src'); A.load(); els.art.classList.add('hidden'); } this.applyVolume(); this.applySpeed(); this.master.load(); const onReadyLocal = () => { this.play(); this.master.removeEventListener('canplay', onReadyLocal); }; this.master.addEventListener('canplay', onReadyLocal); return; } 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', () => { // A failed master stream, or a failed synced-audio track in dual mode, // both warrant falling back to the next candidate. if (masterIs(el) || el === Player.secondary) Player.onMediaError(); }); } bind(V); bind(A); } function updatePlayBtn() { els.playBtn.textContent = Player.master.paused ? '▶' : '⏸'; } // Reflect cache state on the now-playing Save button. function updateNowPlayingActions() { if (!current || !current.meta) return; const id = current.meta.id; const btn = els.saveBtn; if (!btn) return; if (cachedIds.has(id)) { btn.textContent = '✓ Saved'; btn.classList.add('done'); btn.disabled = false; btn.title = 'Saved for offline — click to remove from cache'; } else if (downloading.has(id)) { btn.textContent = '⏳ Saving…'; btn.classList.remove('done'); btn.disabled = true; } else { btn.textContent = '⬇ Save'; btn.classList.remove('done'); btn.disabled = false; btn.title = 'Save this video for offline playback'; } } 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 showSearchSkeletons() { els.status.classList.add('hidden'); els.cards.innerHTML = ''; for (let i = 0; i < 8; i++) { const s = document.createElement('div'); s.className = 'card skeleton-card'; s.innerHTML = `
`; els.cards.appendChild(s); } } function renderList() { if (view.type === 'settings') { renderSettings(); return; } 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.add('hidden'); els.cards.innerHTML = ''; const empty = document.createElement('div'); empty.className = 'empty-state'; if (view.type === 'search') { // Hero landing is already in the player pane — list pane stays bare. els.status.classList.remove('hidden'); els.status.textContent = 'Search for something to begin.'; return; } if (view.type === 'history') { empty.innerHTML = `
🕘

Nothing watched yet

Your viewing history shows up here once you start playing videos.

`; const cta = document.createElement('button'); cta.className = 'empty-cta'; cta.textContent = '🔍 Search videos'; cta.addEventListener('click', () => { view = { type: 'search' }; render(); }); empty.appendChild(cta); } else { // Playlist view — empty empty.innerHTML = `
🎵

This playlist is empty

Add videos from search results or use the + Playlist button while playing.

`; const cta = document.createElement('button'); cta.className = 'empty-cta'; cta.textContent = '🔍 Browse videos'; cta.addEventListener('click', () => { view = { type: 'search' }; render(); }); empty.appendChild(cta); } els.cards.appendChild(empty); return; } els.status.classList.add('hidden'); list.forEach((v, i) => els.cards.appendChild(renderCard(v, i, list))); markPlayingCard(); } // ============================================================================ // Settings page // ============================================================================ function videoTitleById(id) { for (const pl of data.playlists) { const v = pl.videos.find((x) => x.id === id); if (v) return v.title; } const h = data.history.find((x) => x.id === id); if (h) return h.title; return id; } async function renderSettings() { els.listTitle.textContent = 'Settings'; els.listActions.innerHTML = ''; const c = els.cards; els.status.classList.add('hidden'); c.innerHTML = ''; const wrap = document.createElement('div'); wrap.className = 'settings'; // ---- Playback ---- const qualities = ['auto', '1080p', '720p', '480p', '360p', '240p']; const qOptions = qualities .map((q) => ``) .join(''); wrap.innerHTML = `
Playback
Offline cache
Storage used
`; c.appendChild(wrap); // ---- Wire playback controls ---- $('setQuality').addEventListener('change', (e) => { data.settings.quality = e.target.value; els.quality.value = e.target.value; persist(); }); $('setVolume').addEventListener('input', (e) => { data.settings.volume = parseFloat(e.target.value); els.volume.value = e.target.value; Player.applyVolume(); els.muteBtn.textContent = data.settings.volume === 0 ? '🔇' : '🔊'; persist(); }); $('setAudioOnly').addEventListener('change', (e) => { data.settings.audioOnly = e.target.checked; els.audioOnlyToggle.checked = e.target.checked; persist(); }); $('setAutoPreload').addEventListener('change', (e) => { data.settings.autoPreload = e.target.checked; persist(); if (e.target.checked) data.playlists.forEach(preloadPlaylist); }); // ---- Cache management ---- $('clearCacheBtn').addEventListener('click', () => { showModal('Clear all cached videos?', document.createTextNode('This frees disk space. Playlists keep their entries and will re-download on demand.'), [ { label: 'Cancel', onClick: closeModal }, { label: 'Clear all', danger: true, onClick: async () => { try { await API.cacheClear(); } catch {} cachedIds.clear(); closeModal(); toast('Cache cleared'); if (current) updateNowPlayingActions(); renderList(); }, }, ]); }); // ---- Populate live cache stats ---- try { const res = await API.cacheList(); if (view.type !== 'settings') return; // user navigated away const total = (res && res.total) || 0; const items = (res && res.items) || []; $('cacheTotal').textContent = `${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'}`; const listEl = $('cacheList'); if (!items.length) { listEl.innerHTML = '
No videos saved offline yet.
'; } else { items.sort((a, b) => b.size - a.size); items.forEach((it) => { const row = document.createElement('div'); row.className = 'cache-item'; row.innerHTML = `${fmtBytes(it.size)}`; row.querySelector('.ci-title').textContent = videoTitleById(it.id); row.querySelector('.ci-del').addEventListener('click', async () => { try { await API.cacheDelete(it.id); } catch {} cachedIds.delete(it.id); if (current && current.meta && current.meta.id === it.id) updateNowPlayingActions(); toast('Removed from cache'); renderList(); }); listEl.appendChild(row); }); } } catch { const t = $('cacheTotal'); if (t) t.textContent = 'Offline cache not available in this build'; } } function renderCard(v, index, list) { const card = document.createElement('div'); card.className = 'card' + (cachedIds.has(v.id) ? ' cached' : '') + (downloading.has(v.id) ? ' downloading' : ''); 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)); preload(video); // auto-cache for offline playback } 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); if (addVideo) preload(addVideo); // auto-cache for offline playback 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(); }, }, ]); } // ============================================================================ // Keyboard shortcut help overlay // ============================================================================ function toggleShortcutHelp() { const overlay = $('shortcutHelp'); overlay.classList.toggle('hidden'); } function wireShortcutHelp() { $('shortcutClose').addEventListener('click', () => $('shortcutHelp').classList.add('hidden')); $('shortcutHelp').addEventListener('click', (e) => { if (e.target.id === 'shortcutHelp') $('shortcutHelp').classList.add('hidden'); }); } // ============================================================================ // 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(); showSearchSkeletons(); 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.cards.innerHTML = ''; els.status.classList.remove('hidden'); 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)); // Landing-hero quick-search chips document.querySelectorAll('.chip').forEach((c) => { c.addEventListener('click', () => { els.searchInput.value = c.dataset.q || c.textContent.trim(); els.searchForm.requestSubmit(); }); }); // Now-playing: Save (preload) + Add to playlist els.saveBtn.addEventListener('click', async () => { if (!current || !current.meta) return; const id = current.meta.id; if (cachedIds.has(id)) { // Already saved → remove from cache. try { await API.cacheDelete(id); } catch {} cachedIds.delete(id); toast('Removed from offline cache'); updateNowPlayingActions(); markCardCacheState(id, 'none'); } else { await preload(current.meta); } }); els.addPlaylistBtn.addEventListener('click', () => { if (current && current.meta) openCardMenu(current.meta); }); // 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(); else if (e.key === '?') toggleShortcutHelp(); else if (e.key === 'Escape' && !$('shortcutHelp').classList.contains('hidden')) { $('shortcutHelp').classList.add('hidden'); } }); $('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); }); } // ============================================================================ // Boot // ============================================================================ async function boot() { wirePlayerEvents(); wireUI(); wireShortcutHelp(); 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, autoPreload: true, ...(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; // Learn what's already cached, then top up any playlist videos that aren't. await refreshCachedIds(); data.playlists.forEach(preloadPlaylist); render(); els.searchInput.focus(); } document.addEventListener('DOMContentLoaded', boot);