/* ============================================================================ * 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 // Sanitize video_id: strip path separators and dangerous chars function sanitizeId(id) { if (!id || typeof id !== 'string') return ''; return id.replace(/[/\\:?<>|*"]/g, '').trim(); } const TAURI = window.__TAURI__ && window.__TAURI__.core ? window.__TAURI__.core : null; const ZERO = window.zero && typeof window.zero.invoke === 'function' ? window.zero : null; // WEB mode: running as a PWA served from the Hono server (no native bridge) const WEB = !TAURI && !ZERO; const APP_VERSION = '1.0.0'; // call(zeroName, tauriName, payload) — routes to whichever native 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.'); } // ---------- WEB-mode helpers ---------- // Generic fetch wrapper — returns parsed JSON or throws with a human message. async function webFetch(path, opts = {}) { const res = await fetch(path, opts); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const j = await res.json(); msg = j.error || msg; } catch { /* non-JSON */ } throw new Error(msg); } return res.json(); } // Persist data to localStorage and fire-and-forget sync to the server. function loadDataFromStorage() { try { const raw = localStorage.getItem('_ytpdata'); return raw ? JSON.parse(raw) : null; } catch { return null; } } function saveDataToStorage(jsonStr) { try { localStorage.setItem('_ytpdata', jsonStr); } catch { /* storage full / blocked */ } // Sync playlists to the server (non-blocking — failures are silent) try { const d = JSON.parse(jsonStr); fetch('/api/user/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fingerprint: window.getFingerprint ? window.getFingerprint() : 'unknown', playlists: d.playlists || [], appVersion: APP_VERSION, }), }).catch(() => {}); } catch { /* ignore parse errors */ } } // OPFS bridge wrappers — return the same shape as the Tauri cache_* commands // so the rest of app.js works without changes. async function opfsDownload(videoId, { mux = false } = {}) { if (!window.OPFS || !window.OPFS.isSupported()) { return { ok: false, error: 'OPFS not supported in this browser' }; } const fp = window.getFingerprint ? window.getFingerprint() : ''; const params = new URLSearchParams(); if (fp) params.set('fp', fp); // mux=1 asks the server to compile bestvideo+bestaudio into a single mp4 // (used by "Save before playing"); default saves keep the progressive // stream exactly as before. if (mux) params.set('mux', '1'); const qs = params.toString(); const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`; // Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes, // so a big save never touches the main thread (no UI jank, no audio // stutter). Falls back to the legacy main-thread streaming below only when // the worker path is unsupported. if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') { const w = await window.OPFS.downloadVideo(videoId, url); if (w.ok) return { ok: true, cached: true }; if (!w.fallback) return { ok: false, error: w.error || 'download failed' }; } try { const res = await fetch(url); if (!res.ok) { const j = await res.json().catch(() => ({})); return { ok: false, error: j.error || `HTTP ${res.status}` }; } // Determine file extension from Content-Type const ct = res.headers.get('content-type') || 'video/mp4'; const ext = ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : 'mp4'; await window.OPFS.writeFromResponse(videoId, ext, res); return { ok: true, cached: true }; } catch (err) { return { ok: false, error: err.message }; } } async function opfsStatus(videoId) { if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true, cached: false }; try { const cached = await window.OPFS.hasVideo(videoId); return { ok: true, cached }; } catch { return { ok: true, cached: false }; } } async function opfsList() { if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true, items: [], total: 0 }; try { const items = await window.OPFS.listVideos(); const total = items.reduce((s, i) => s + (i.size || 0), 0); return { ok: true, items, total }; } catch { return { ok: true, items: [], total: 0 }; } } async function opfsDelete(videoId) { if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true }; try { await window.OPFS.deleteVideo(videoId); } catch { /* ignore */ } return { ok: true }; } async function opfsClear() { if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true }; try { await window.OPFS.clearAll(); } catch { /* ignore */ } return { ok: true }; } // Blob URL for the currently playing OPFS video — revoked on next video load. let _currentBlobUrl = null; const API = { search: (query) => WEB ? webFetch(`/api/search?q=${encodeURIComponent(query)}`) : call('yt.search', 'yt_search', { query }), getChannel: (channel) => WEB ? webFetch(`/api/channel?c=${encodeURIComponent(channel)}`) : call('yt.channel', 'yt_channel', { channel }), getStreams: (videoId) => WEB ? webFetch(`/api/streams?v=${encodeURIComponent(videoId)}`) : call('yt.streams', 'yt_streams', { videoId }), loadData: () => WEB ? Promise.resolve(loadDataFromStorage()) : call('store.load', 'store_load', {}), // data is sent pre-stringified so the native side can write it verbatim. saveData: (d) => WEB ? Promise.resolve(saveDataToStorage(typeof d === 'string' ? d : JSON.stringify(d))) : call('store.save', 'store_save', { data: JSON.stringify(d) }), // Offline cache — routes to OPFS (WEB) or native cache (Tauri/Zig) cacheDownload: (videoId, opts) => WEB ? opfsDownload(sanitizeId(videoId), opts) : call('cache.download', 'cache_download', { videoId: sanitizeId(videoId) }), cacheStatus: (videoId) => WEB ? opfsStatus(sanitizeId(videoId)) : call('cache.status', 'cache_status', { videoId: sanitizeId(videoId) }), cacheList: () => WEB ? opfsList() : call('cache.list', 'cache_list', {}), cacheDelete: (videoId) => WEB ? opfsDelete(sanitizeId(videoId)) : call('cache.delete', 'cache_delete', { videoId: sanitizeId(videoId) }), cacheClear: () => WEB ? opfsClear() : 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 ---------- const DEFAULT_SETTINGS = { quality: 'auto', volume: 1, audioOnly: false, autoPreload: true, saveBeforePlay: false, // download (server-muxed single file) before playing instead of streaming repeatMode: 'off', // 'off' | 'all' — repeat the playing list when it ends loopOne: false, // repeat the single current video shuffle: false, // shuffle upcoming tracks when playing a list theme: 'dark', // 'dark' | 'light' | 'contrast' fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl' density: 'comfortable', // 'comfortable' | 'compact' perfMode: false, // disable heavy visual effects for speed reduceMotion: false, // disable animations autoBackupEnabled: false, autoBackupIntervalDays: 7, }; let data = { playlists: [], history: [], queue: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0 }; let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel' let searchResults = []; let channelData = { name: '', url: '', key: '', results: [], loading: false }; let queue = []; // list of video objects for autoplay let queueIndex = -1; let queueSource = ''; // label of what's playing ('queue','playlist:',…) let current = null; // { meta, qualities, audioUrl, localUrl? } let dragSource = -1; // index of card being dragged let listFilter = ''; let selectMode = false; let selectedIds = new Set(); let sleepTimerRemaining = 0; let sleepTimerTick = null; let abA = null; let abB = null; let relatedVideos = []; let relatedCollapsed = false; 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 const downloadMeta = new Map(); // id -> video object, for the Downloads page const removing = new Set(); // video ids with an in-flight cache removal const playlistOps = new Set(); // `${playlistId}:${videoId}` add/remove in flight // Concurrency helper (frontend/async-guard.js) — keeps the save / playlist // operations from being triggered twice at once for the same target. const runExclusive = (window.AsyncGuard && window.AsyncGuard.runExclusive) || (async (set, key, fn) => { if (set.has(key)) return undefined; set.add(key); try { return await fn(); } finally { set.delete(key); } }); // ---------- 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'), shuffleBtn: $('shuffleBtn'), loopBtn: $('loopBtn'), repeatBtn: $('repeatBtn'), queueBtn: $('queueBtn'), 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); } // Apply theme / font size / density / performance settings to the document root. // Driven entirely by data-* attributes that styles.css keys off of. function applyAppearance() { const s = data.settings; const root = document.documentElement; root.dataset.theme = s.theme || 'dark'; root.dataset.font = s.fontScale || 'normal'; root.dataset.density = s.density || 'comfortable'; root.dataset.perf = s.perfMode ? 'on' : 'off'; root.dataset.motion = s.reduceMotion ? 'reduced' : 'full'; } 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, mux = false } = {}) { const id = video.id; if (!id || cachedIds.has(id) || downloading.has(id)) return; downloading.add(id); downloadMeta.set(id, slim(video)); markCardCacheState(id, 'downloading'); if (view.type === 'downloads') renderList(); updateDownloadBadge(); // Reflect the busy state on the now-playing Save button immediately, so the // UI responds the moment the (long) download begins rather than only when it // finishes in the `finally` below. if (current && current.meta && current.meta.id === id) updateNowPlayingActions(); if (!quiet) toast(`Saving “${video.title}” for offline…`); try { const res = await API.cacheDownload(id, { mux }); 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); downloadMeta.delete(id); markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none'); if (current && current.meta && current.meta.id === id) updateNowPlayingActions(); updateDownloadBadge(); if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') renderList(); } } // Sidebar badge showing how many downloads are in flight. function updateDownloadBadge() { const n = downloading.size; const badge = $('navDlCount'); if (badge) { badge.textContent = String(n); badge.classList.toggle('hidden', n === 0); } // Mirror to bottom-nav badge const bb = $('bnDlCount'); if (bb) { bb.textContent = String(n); bb.classList.toggle('hidden', n === 0); } } // 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, bufferGraceTimer: null, // pending "pause audio after a stall that outlasts the grace window" timer _wantsPlaying: false, // tracks user/app *intent* to be playing, independent // of what the underlying