/* ============================================================================ * 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 }, resumePositions: {} }; 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 dragSource = -1; // index of card being dragged 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, { duration = 2200 } = {}) { const t = document.createElement('div'); t.className = 'toast'; t.textContent = msg; const container = $('toastContainer'); container.appendChild(t); // Trigger layout for animation t.style.animation = 'none'; t.offsetHeight; // force reflow t.style.animation = ''; // Auto-remove after duration setTimeout(() => { t.classList.add('toast-exit'); setTimeout(() => t.remove(), 280); }, duration); // Cap at 3 toasts — remove oldest while (container.children.length > 3) { const first = container.firstChild; first.classList.add('toast-exit'); setTimeout(() => first.remove(), 280); } } 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'); const thumb = c.querySelector('.thumb'); if (state === 'downloading' && !thumb.querySelector('.dl-progress')) { const bar = document.createElement('div'); bar.className = 'dl-progress'; bar.innerHTML = '
'; thumb.appendChild(bar); } else if (state !== 'downloading') { const bar = thumb.querySelector('.dl-progress'); if (bar) bar.remove(); } }); } // ============================================================================ // 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(); // Restore saved playback position const id = current.meta.id; if (data.resumePositions[id] && data.resumePositions[id] > 1) { const saved = data.resumePositions[id]; const restore = () => { Player.seek(saved); toast('Resumed from ' + fmtTime(saved)); Player.master.removeEventListener('canplay', restore); Player.master.removeEventListener('loadedmetadata', restore); }; Player.master.addEventListener('canplay', restore, { once: true }); Player.master.addEventListener('loadedmetadata', restore, { once: true }); } }, // 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(); // Persist playback position every 10s if (current && current.meta) { const t = Player.master.currentTime; if (t > 5 && Math.floor(t) % 10 === 0) { data.resumePositions[current.meta.id] = t; persist(); } } } }); el.addEventListener('pause', () => { if (masterIs(el) && current && current.meta) { const t = Player.master.currentTime; if (t > 1) { data.resumePositions[current.meta.id] = t; persist(); } } }); 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]); renderUpNext(); } function renderUpNext() { const upcoming = queue.slice(queueIndex + 1); if (!upcoming.length) { $('upnext').classList.add('hidden'); return; } $('upnext').classList.remove('hidden'); $('upnextCount').textContent = String(upcoming.length); const list = $('upnextList'); list.innerHTML = ''; upcoming.forEach((v) => { const item = document.createElement('div'); item.className = 'upnext-item'; item.innerHTML = `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 = `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 = `