// iPhone / iPad only: no page zoom (stray pinches and double-taps kept zooming // the installed app awkwardly). PCs keep normal browser zoom. The player's own // pinch-to-zoom uses pointer events and is unaffected. (() => { const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); if (!ios) return; document.documentElement.classList.add('ios-nozoom'); const vp = document.querySelector('meta[name="viewport"]'); if (vp) vp.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'); for (const t of ['gesturestart', 'gesturechange', 'gestureend']) { document.addEventListener(t, (e) => e.preventDefault(), { passive: false }); } })(); /* ============================================================================ * 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'; // Timing marks for the speed work (plans/). performance.measure entries are // visible in DevTools → Performance; __ytpPerf() prints the latest ones. function perfMark(name) { try { performance.mark(name); } catch { /* old browser */ } } function perfMeasure(name, start) { try { performance.measure(name, start); } catch { /* start mark missing */ } } window.__ytpPerf = () => { const out = {}; try { for (const m of performance.getEntriesByType('measure')) if (m.name.startsWith('ytp:')) out[m.name] = Math.round(m.duration); } catch { /* none */ } return out; }; // 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: d.profile?.name ? profileHeaders(d.profile.name, { 'Content-Type': 'application/json' }) : { 'Content-Type': 'application/json' }, body: JSON.stringify({ fingerprint: window.getFingerprint ? window.getFingerprint() : 'unknown', playlists: d.playlists || [], videos: [...(d.history || []), ...(d.queue || [])].slice(0, 500), stats: d.stats || null, profileName: d.profile?.name || '', appVersion: APP_VERSION, }), }).catch(() => {}); } catch { /* ignore parse errors */ } } // Can this device decode the server's HEVC copies? The compression lane // re-encodes cached videos to HEVC (8-bit, hvc1) when that is much smaller; // the server hands those out only to clients that send hevc=1. canPlayType // is the browser's own claim; if a copy still fails to play here, the device // is remembered (NO_HEVC_KEY) and stops asking — it gets H.264 as before. const NO_HEVC_KEY = 'ytpNoHevc'; function hevcCapable() { try { if (localStorage.getItem(NO_HEVC_KEY) === '1') return false; } catch { /* storage blocked */ } try { return document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"') !== ''; } catch { return false; } } // Recent searches — device-local only (localStorage), never synced to the // server or another device. Most-recent-first, case-insensitive de-dupe. // A paired store (RESULTS_KEY) caches the actual result cards for each // recent query, so re-running one from the dropdown can paint instantly // instead of waiting on a fresh /api/search round-trip (yt-dlp, several // seconds). Cards are already slim ({id,title,channel,channelId, // channelUrl,duration,thumbnail}, server's slimEntry()) so no field // trimming is needed; entries are pruned to whatever's still in the query // list so the two stores never drift apart. const RecentSearches = (() => { const KEY = 'ytpRecentSearches'; const RESULTS_KEY = 'ytpRecentSearchResults'; const MAX = 10; const RESULTS_PER_QUERY_MAX = 50; // defensive cap; server returns ~45 const norm = (q) => (q || '').trim().toLowerCase(); const load = () => { try { const l = JSON.parse(localStorage.getItem(KEY) || '[]'); return Array.isArray(l) ? l : []; } catch { return []; } }; const save = (list) => { try { localStorage.setItem(KEY, JSON.stringify(list)); } catch { /* storage full / blocked */ } }; const loadResults = () => { try { const o = JSON.parse(localStorage.getItem(RESULTS_KEY) || '{}'); return o && typeof o === 'object' ? o : {}; } catch { return {}; } }; const saveResults = (obj) => { try { localStorage.setItem(RESULTS_KEY, JSON.stringify(obj)); } catch { // Quota exceeded — drop the (non-essential) results cache rather than // risk the query-list save above also failing later. try { localStorage.removeItem(RESULTS_KEY); } catch { /* storage blocked */ } } }; // Drop any cached result set whose query is no longer in `list`. const pruneResults = (list) => { const keep = new Set(list.map(norm)); const r = loadResults(); let changed = false; for (const k of Object.keys(r)) if (!keep.has(k)) { delete r[k]; changed = true; } if (changed) saveResults(r); }; return { list: load, add(q) { q = (q || '').trim(); if (!q) return; const list = load().filter((x) => x.toLowerCase() !== q.toLowerCase()); list.unshift(q); const trimmed = list.slice(0, MAX); save(trimmed); }, remove(q) { const list = load().filter((x) => x.toLowerCase() !== (q || '').toLowerCase()); save(list); }, clear() { save([]); }, // saved results stay in SearchLibrary (cleared only from Settings) // Cache the just-fetched result set for `q` (called on every successful search). cacheResults(q, results, ts) { SearchLibrary.put(q, results, ts); }, // Promise of the cached {query, results, ts} for `q`, or null. getResults(q) { return SearchLibrary.get(q); }, // Normalized queries that have a cached result set — one localStorage // read for the whole dropdown, rather than one per row. cachedQueries() { return new Set(SearchLibrary.list().map((x) => x.key)); }, }; })(); // ============================================================================ // Search library — every result set (with thumbnail URLs) is kept on this device // ============================================================================ // Clearing the search box or the recent-searches dropdown never deletes these; // they are removed only from Settings → Saved searches (all, or a selection). // The thumbnails themselves live in the service worker's `ytplayer-thumbs` // cache (warmed below), so a saved search renders with its pictures offline. const SearchLibrary = (() => { const INDEX_KEY = 'ytpSearchIndex'; // small, synchronous: who/when/how big const OLD_KEYS = ['ytpSearchLibrary', 'ytpRecentSearchResults']; // earlier localStorage stores const MAX_QUERIES = 1000; const PER_QUERY = 100; const FRESH_MS = 15 * 24 * 3600_000; // a saved copy is trusted for 15 days const norm = (q) => String(q || '').trim().toLowerCase(); // Metadata index { key: { query, count, ts, bytes, thumb } } lives in memory and // localStorage (≈250 KB at 1000 searches); the result lists themselves — up to // 100 cards each — live in IndexedDB and are read one search at a time. let index = {}; try { index = JSON.parse(localStorage.getItem(INDEX_KEY) || '{}') || {}; } catch { index = {}; } let persistTimer = 0; const persistIndex = () => { clearTimeout(persistTimer); persistTimer = setTimeout(() => { try { localStorage.setItem(INDEX_KEY, JSON.stringify(index)); } catch { /* quota */ } }, 300); }; // IndexedDB, with a memory fallback when it is blocked (private windows). const fallback = new Map(); let dbp = null; const open = () => dbp || (dbp = new Promise((resolve) => { try { const req = indexedDB.open('ytpSearchLibrary', 1); req.onupgradeneeded = () => req.result.createObjectStore('r', { keyPath: 'key' }); req.onsuccess = () => resolve(req.result); req.onerror = req.onblocked = () => resolve(null); } catch { resolve(null); } })); async function tx(mode, fn) { const d = await open(); if (!d) return fn(null); return new Promise((resolve) => { let out; try { const t = d.transaction('r', mode); out = fn(t.objectStore('r')); t.oncomplete = () => resolve(out && out.result !== undefined ? out.result : undefined); t.onerror = t.onabort = () => resolve(undefined); } catch { resolve(undefined); } }); } const idbPut = (rec) => tx('readwrite', (st) => st ? st.put(rec) : void fallback.set(rec.key, rec)); const idbGet = (key) => tx('readonly', (st) => st ? st.get(key) : { result: fallback.get(key) }); const idbDel = (keys) => tx('readwrite', (st) => { if (st) keys.forEach((k) => st.delete(k)); else keys.forEach((k) => fallback.delete(k)); }); const idbClear = () => tx('readwrite', (st) => st ? st.clear() : void fallback.clear()); // One-time import of the earlier localStorage stores. (async () => { for (const k of OLD_KEYS) { let o = null; try { o = JSON.parse(localStorage.getItem(k) || 'null'); } catch { o = null; } if (o && typeof o === 'object') { for (const [key, v] of Object.entries(o)) { if (!v || !Array.isArray(v.results) || !v.results.length || index[key]) continue; put(v.query || key, v.results, v.ts); } } try { localStorage.removeItem(k); } catch { /* blocked */ } } })(); function put(q, results, ts) { q = String(q || '').trim(); if (!q || !Array.isArray(results) || !results.length) return; const key = norm(q); const list = results.slice(0, PER_QUERY); const bytes = JSON.stringify(list).length; index[key] = { query: q, count: list.length, ts: ts || Date.now(), bytes, thumb: (list[0] || {}).thumbnail || '' }; idbPut({ key, query: q, results: list, ts: index[key].ts }).then(() => collectSearchMetadata(q, list)); const keys = Object.keys(index); if (keys.length > MAX_QUERIES) { const drop = keys.sort((a, b) => (index[a].ts || 0) - (index[b].ts || 0)).slice(0, keys.length - MAX_QUERIES); drop.forEach((k) => delete index[k]); idbDel(drop); } persistIndex(); } return { FRESH_MS, PER_QUERY, put, // Promise of { query, results, ts } or null. async get(q) { const key = norm(q); if (!index[key]) return null; const rec = await idbGet(key); if (rec && Array.isArray(rec.results) && rec.results.length) return rec; delete index[key]; persistIndex(); // index entry without data return null; }, has(q) { return !!index[norm(q)]; }, isFresh(ts) { return Date.now() - (ts || 0) < FRESH_MS; }, // Newest first: [{ key, query, count, ts, thumb }] list() { return Object.keys(index).map((k) => ({ key: k, query: index[k].query || k, count: index[k].count || 0, ts: index[k].ts || 0, thumb: index[k].thumb || '' })) .sort((a, b) => b.ts - a.ts); }, remove(keys) { (keys || []).forEach((k) => delete index[k]); idbDel(keys || []); persistIndex(); }, clear() { index = {}; idbClear(); persistIndex(); }, bytes() { return Object.values(index).reduce((n, x) => n + (x.bytes || 0), 0); }, count() { return Object.keys(index).length; }, }; })(); // OPFS bridge wrappers — return the same shape as the Tauri cache_* commands // so the rest of app.js works without changes. // ---------- Save slots: how many videos save at once (Settings, default 4) ---------- const SaveSlots = (() => { let active = 0; const waiters = []; const limit = () => Math.max(1, Math.min(8, Number(data.settings.parallelSaves) || 4)); function pump() { while (waiters.length && active < limit()) { active++; waiters.shift()(); } } return { acquire(signal) { return new Promise((resolve, reject) => { const abort = () => { const i = waiters.indexOf(ready); if (i >= 0) waiters.splice(i, 1); reject(new DOMException('Paused', 'AbortError')); }; const ready = () => { signal?.removeEventListener('abort', abort); resolve(); }; if (signal?.aborted) return abort(); signal?.addEventListener('abort', abort, { once: true }); waiters.push(ready); pump(); }); }, release() { active = Math.max(0, active - 1); pump(); }, pump, }; })(); // ---------- Save queue: resumable saves survive reloads and dropped links ---------- // Every save is recorded here when it starts and removed when it finishes or // fails for good. A save that only paused (partial kept on the device) stays // listed and is resumed when the app opens, comes back online or returns to // the foreground — and every 2 minutes while the app is visible. const saveControllers = new Map(); const SaveQueue = (() => { const KEY = 'ytpSaveQueue'; const MAX_AGE = 7 * 24 * 3600 * 1000; const prog = new Map(); let running = false; const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}') || {}; } catch { return {}; } }; const store = (q) => { try { localStorage.setItem(KEY, JSON.stringify(q)); } catch { /* storage blocked */ } }; function add(v) { if (!v || !v.id || v.custom) return; const q = load(); q[v.id] = { id: v.id, title: v.title || '', channel: v.channel || '', thumbnail: v.thumbnail || '', duration: v.duration || 0, channelUrl: v.channelUrl || '', channelId: v.channelId || '', at: (q[v.id] && q[v.id].at) || Date.now() }; store(q); } function state(id, status, error = '') { const q = load(); if (q[id]) { Object.assign(q[id], { status, error }); store(q); } } function pause(id) { state(id, 'paused'); saveControllers.get(id)?.abort(); } function remove(id) { const q = load(); if (q[id]) { delete q[id]; store(q); } } const pending = () => Object.values(load()); const paused = () => pending().filter((v) => !downloading.has(v.id) && !cachedIds.has(v.id)); const get = (id) => prog.get(id) || null; function label(p) { if (!p) return 'Saving for offline…'; if (p.phase === 'waiting') return 'Waiting for a free slot…'; const mb = (n) => (n / 1048576).toFixed(n >= 1048576 * 100 ? 0 : 1); if (p.phase === 'preparing') return `Preparing on server… ${Math.round((p.elapsed || 0) / 1000)} s`; if (p.phase === 'verifying') return 'Verifying…'; if (p.total) return `${mb(p.received)} / ${mb(p.total)} MB`; return 'Saving for offline…'; } function paint(id) { // Only the Downloads view's own rows: search/playlist cards also carry // .downloading while a save runs, and their channel line must not be touched. const row = document.querySelector(`.card.dl-row[data-id="${CSS.escape(id)}"]`); if (!row) return; const p = get(id); const ch = row.querySelector('.card-channel'); if (ch) ch.textContent = label(p); const bar = row.querySelector('.dl-bar'); if (bar) { const pct = p && p.total ? Math.max(1, Math.min(100, (p.received / p.total) * 100)) : null; bar.classList.toggle('determinate', pct !== null); bar.style.width = pct !== null ? pct + '%' : ''; } } function progress(id, p) { if (p) prog.set(id, p); else prog.delete(id); paint(id); } async function resumeAll() { if (running || !navigator.onLine) return; running = true; try { const jobs = []; for (const v of pending()) { if (cachedIds.has(v.id)) { remove(v.id); continue; } if (downloading.has(v.id) || v.status === 'paused' || v.status === 'failed') continue; if (Date.now() - v.at > MAX_AGE) { cancel(v.id); continue; } jobs.push(preload(v, { quiet: true })); // SaveSlots caps how many run at once } await Promise.all(jobs); } finally { running = false; } if (view.type === 'downloads') renderList(); } function cancel(id) { saveControllers.get(id)?.abort(); remove(id); prog.delete(id); if (window.OPFS && window.OPFS.discardPartial) window.OPFS.discardPartial(id); } function start() { setTimeout(resumeAll, 4000); window.addEventListener('online', () => resumeAll()); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') resumeAll(); }); setInterval(() => { if (document.visibilityState === 'visible' && paused().length) resumeAll(); }, 120000); } return { add, remove, pending, paused, progress, get, label, resumeAll, cancel, pause, state, start }; })(); // Resumable save (docs/resumable-downloads-plan.md): ask the server to get // the video first (never blocks), then fetch its finished copy in byte ranges // inside the worker. Returns null when this video must use the legacy // single-request save (HEVC copy this device can't play, too long for the // server cache, no worker/OPFS sync access), otherwise the save result — // { ok:false, paused:true } means the partial is kept and will resume. async function resumableOpfsSave(videoId, qs, hevc, onProgress, signal) { if (typeof window.OPFS.downloadVideo !== 'function' || typeof Worker === 'undefined') return null; const enc = encodeURIComponent(videoId); const prepUrl = `/api/download/${enc}/prepare${hevc ? '?hevc=1' : ''}`; const dlUrl = `/api/download/${enc}${qs ? '?' + qs : ''}`; const report = (info) => { if (onProgress) { try { onProgress(info); } catch { /* UI only */ } } }; const started = Date.now(); for (let round = 0; round < 2; round++) { let prep = null; for (;;) { if (signal?.aborted) return { ok: false, paused: true }; let j; try { const r = await fetch(prepUrl, { cache: 'no-store', signal }); j = await r.json(); } catch (err) { return { ok: false, paused: true, error: 'connection lost' }; } if (j.state === 'ready') { prep = j; break; } if (j.state === 'legacy') return null; if (!j.ok || j.state === 'failed') return { ok: false, error: j.reason || j.error || 'the server could not get this video' }; report({ phase: 'preparing', elapsed: Date.now() - started }); if (Date.now() - started > 3 * 3600 * 1000) return { ok: false, paused: true, error: 'the server is still preparing it' }; await new Promise((r) => setTimeout(r, 3000)); } const w = await window.OPFS.downloadVideo(videoId, dlUrl, { signal, resumable: { etag: prep.etag, size: prep.size, sha256: prep.sha256 || null, ext: prep.ext || 'mp4' }, onProgress: (received, total, m) => report({ phase: m && m.verifying ? 'verifying' : 'downloading', received, total }), }); if (w.ok) return { ok: true, cached: true, sha256: w.sha256 || null, expectedSha: w.expectedSha || null, size: w.size || 0 }; if (w.changed) continue; // the server copy was replaced — start over on the new one if (w.paused) return { ok: false, paused: true, error: w.error || 'connection lost', received: w.received || 0 }; if (w.fallback) return null; return { ok: false, error: w.error || 'save failed' }; } return { ok: false, error: 'the server copy kept changing — try again' }; } async function opfsDownload(videoId, { mux = false, retry = false, onProgress = null, signal } = {}) { 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'); if (hevcCapable()) params.set('hevc', '1'); const qs = params.toString(); const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`; if (retry) { try { const response = await fetch(`/api/download/${encodeURIComponent(videoId)}/retry`, { method: 'POST', cache: 'no-store', signal }); const result = await response.json(); if (!response.ok || !result.ok) return { ok: false, error: result.error || 'Could not restart this save.' }; } catch (error) { return { ok: false, paused: true, error: error.name === 'AbortError' ? 'Save paused' : 'Connection lost' }; } } const resumed = await resumableOpfsSave(videoId, qs, params.get('hevc') === '1', onProgress, signal); if (resumed) return resumed; // 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). ANY worker failure — unsupported API or a mid-download error — // falls back to the legacy main-thread streaming below; the worker's error // is kept so it can be reported if the fallback fails too. let workerError = null; if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') { const w = await window.OPFS.downloadVideo(videoId, url, { signal }); if (w.ok) return { ok: true, cached: true, sha256: w.sha256 || null, expectedSha: w.expectedSha || null, size: w.size || 0 }; if (w.paused) return w; workerError = w.error || null; } try { const res = await fetch(url, { signal }); 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, paused: signal?.aborted || false, error: workerError || err.message }; } } // "Edit & download": ask the server to trim `sourceId` to the given keep // segments and store the resulting custom cut in OPFS under `customId` (which // is NOT a real YouTube id — it's edit__). Same worker-first / // main-thread-fallback strategy as opfsDownload, but the URL targets the // source video with ?edit=1&keep=… while the file is written under customId. async function opfsDownloadEdited(customId, sourceId, keepParam) { 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(); params.set('edit', '1'); params.set('keep', keepParam); if (fp) params.set('fp', fp); const url = `/api/download/${encodeURIComponent(sourceId)}?${params.toString()}`; let workerError = null; if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') { const w = await window.OPFS.downloadVideo(customId, url); if (w.ok) return { ok: true, cached: true }; workerError = w.error || null; } try { const res = await fetch(url); if (!res.ok) { const j = await res.json().catch(() => ({})); return { ok: false, error: j.error || `HTTP ${res.status}` }; } const ct = res.headers.get('content-type') || 'video/mp4'; const ext = ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : 'mp4'; await window.OPFS.writeFromResponse(customId, ext, res); return { ok: true, cached: true }; } catch (err) { return { ok: false, error: workerError || 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 */ } if (window.DeviceDB) { await window.DeviceDB.deleteFile(videoId); if (window.P2PClient) window.P2PClient.changed(); } return { ok: true }; } async function opfsClear() { if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true }; try { await window.OPFS.clearAll(); } catch { /* ignore */ } if (window.DeviceDB) { await window.DeviceDB.clear(); if (window.P2PClient) window.P2PClient.changed(); } return { ok: true }; } // Blob URL for the currently playing OPFS video — revoked on next video load. let _currentBlobUrl = null; const API = { search: (query, { refresh = false } = {}) => WEB ? webFetch(`/api/search?q=${encodeURIComponent(query)}${refresh ? '&refresh=1' : ''}`) : call('yt.search', 'yt_search', { query }), // Videos the server already knows — answers fast while the real search loads. searchLocal: (query) => WEB ? webFetch(`/api/search/local?q=${encodeURIComponent(query)}`) : Promise.resolve({ ok: false }), getChannel: (channel) => WEB ? webFetch(`/api/channel?c=${encodeURIComponent(channel)}`) : call('yt.channel', 'yt_channel', { channel }), expandPlaylist: (url) => WEB ? webFetch(`/api/playlist/expand?url=${encodeURIComponent(url)}`) : call('yt.expand_playlist', 'yt_expand_playlist', { url }), // nocache=1 skips the server's cached copy and resolves fresh YouTube // streams (used when the cached copy won't play on this device). getStreams: (videoId, { nocache = false } = {}) => WEB ? webFetch(`/api/streams?v=${encodeURIComponent(videoId)}${nocache ? '&nocache=1' : ''}${!nocache && hevcCapable() ? '&hevc=1' : ''}`) : 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) }), // Download a server-edited cut of `sourceId` into the cache under a custom // id. WEB (OPFS) only — the native shells have no ffmpeg edit pipeline. cacheDownloadEdited: (customId, sourceId, keepParam) => WEB ? opfsDownloadEdited(sanitizeId(customId), sanitizeId(sourceId), keepParam) : Promise.resolve({ ok: false, error: 'Editing is only available in the web app' }), 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', {}), }; // Pre-resolve streams for videos the user is likely to play next (server // /api/streams/warm). Each id is warmed at most once per 20 min per tab. const warmedAt = new Map(); function warmStreams(ids) { if (!WEB || navigator.onLine === false) return; const now = Date.now(); for (const id of ids) { if (!id || !/^[A-Za-z0-9_-]{11}$/.test(id) || cachedIds.has(id)) continue; if (now - (warmedAt.get(id) || 0) < 20 * 60_000) continue; warmedAt.set(id, now); fetch(`/api/streams/warm?v=${encodeURIComponent(id)}`, { priority: 'low' }).catch(() => {}); } } // 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, p2pShare: true, // P2P (docs/p2p-architecture.md): share my saved videos with other devices — ON by default p2pReceive: true, // fetch from other devices when YouTube and the server can't — ON by default 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' levelVolume: null, // loudness levelling; null = on (on iPhone: applied to saved songs' rendered copies) liveAudioIOS: false, // iPhone: live Web Audio EQ/levelling for streamed songs (stops when the screen locks) parallelSaves: 4, // offline saves running at once (Settings → Offline cache) density: 'comfortable', // 'comfortable' | 'compact' perfMode: false, // disable heavy visual effects for speed reduceMotion: false, // disable animations layout: 'classic', // 'classic'|'glass-stage'|'sanctuary-stage'|'liturgical'|'living-hymnal'|'broadcaster'|'contemplative'|'bento-hub' autoBackupEnabled: false, autoBackupIntervalDays: 7, serviceVideo: false, // show the video panel inside service mode (lyric videos) }; let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, bookmarks: {}, lyricOffsets: {}, stats: null, lastAutoBackup: 0, profile: null }; let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel' let searchResults = []; // Distinguishes "never searched" (hide the list pane, show the landing) from // "searched and got nothing back" (keep the pane so the user sees why). let searchAttempted = false; // Bumped on every dispatched search so a slower, superseded /api/search // response (or background refresh — see runSearchQuery) can't clobber a // newer one that already landed. let searchSeq = 0; 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 playFullMode = false; // "play in full" session: ignore resume positions, // A-B markers, shuffle, loop and repeat — play the // list start-to-finish, then stop. let current = null; // { meta, qualities, audioUrl, localUrl?, eqAudioUrl? } const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPhone: object URL of the playing saved song's rendered EQ/levelled sound. let _currentEqUrl = null; async function useEqRender(id) { if (_currentEqUrl) { URL.revokeObjectURL(_currentEqUrl); _currentEqUrl = null; } if (typeof EqRender === 'undefined' || !WEB) return null; try { _currentEqUrl = await EqRender.urlFor(id); } catch { _currentEqUrl = null; } return _currentEqUrl; } 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) || window.LyricsWindow?.element(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'), searchRecent: $('searchRecent'), playlistList: $('playlistList'), newPlaylistBtn: $('newPlaylistBtn'), audioOnlyToggle: $('audioOnlyToggle'), saveBtn: $('saveBtn'), editBtn: $('editBtn'), brokenBtn: $('brokenBtn'), addPlaylistBtn: $('addPlaylistBtn'), }; // ---------- Persistence ---------- function persist() { clearTimeout(saveTimer); saveTimer = setTimeout(() => API.saveData(data).catch(() => {}), 400); scheduleProfilePush(); } // ---------- Online profile sync (WEB mode) ---------- // The profile NAME acts as the passkey: any device that knows it can load // and update the same server-side copy of playlists/settings/etc. Sync is // last-write-wins: every local change pushes (debounced); every app launch // pulls when the server copy is newer than what this device last synced. function profilePayload() { return { playlists: data.playlists, history: data.history, customVideos: data.customVideos, pinnedPlaylists: data.pinnedPlaylists || [], settings: data.settings, resumePositions: data.resumePositions, rememberPos: data.rememberPos, playCount: data.playCount, abMarkers: data.abMarkers, bookmarks: data.bookmarks || {}, lyricOffsets: data.lyricOffsets || {}, stats: data.stats || null, }; } // ---- Optional profile protection ---------------------------------------- // The profile's password / key lives only in this device's localStorage (never // in the synced data blob) and rides along as X-Profile-Secret. const ProfileSecret = (() => { const KEY = 'ytpProfileSecret'; const read = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } }; return { getFor(name) { const j = read(); return j && name && j.name === String(name).toLowerCase() ? j.secret : null; }, kindFor(name) { const j = read(); return j && name && j.name === String(name).toLowerCase() ? j.kind : null; }, set(name, secret, kind) { try { localStorage.setItem(KEY, JSON.stringify({ name: String(name).toLowerCase(), secret, kind })); } catch { /* blocked */ } }, clear() { try { localStorage.removeItem(KEY); } catch { /* blocked */ } }, }; })(); function profileHeaders(name, extra = {}) { const s = ProfileSecret.getFor(name); return s ? { ...extra, 'X-Profile-Secret': s } : extra; } // Ask for a protected profile's password or key. Resolves the text or null. function askProfileSecret(name, kind, wrong) { return new Promise((resolve) => { const body = document.createElement('div'); const what = kind === 'key' ? 'profile key' : 'password'; body.innerHTML = `

${escapeHtml(name)} is protected. ${wrong ? 'That was wrong. ' : ''} Enter its ${what}${kind === 'key' ? ', or import its credentials file' : ''}.

`; const done = (v) => { closeModal(); resolve(v); }; showModal('🔒 Protected profile', body, [ { label: 'Cancel', onClick: () => done(null) }, { label: 'Import file', onClick: () => { closeModal(); resolve(null); importProfileCredentials(); } }, { label: 'Open', primary: true, onClick: () => { const v = ($('profileSecretInput').value || '').trim(); if (v) done(v); } }, ]); setTimeout(() => $('profileSecretInput') && $('profileSecretInput').focus(), 50); $('profileSecretInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const v = e.target.value.trim(); if (v) done(v); } }); }); } // Load a profile, asking for its password/key when it is protected. // Resolves the server reply ({ ok, name, data, … }) or null when cancelled. async function fetchProfile(name, { prompt = true, secret = null } = {}) { let tryWith = secret || ProfileSecret.getFor(name); let wrong = false; for (let i = 0; i < 6; i++) { const headers = tryWith ? { 'X-Profile-Secret': tryWith } : {}; const res = await fetch(`/api/profile/load?name=${encodeURIComponent(name)}`, { headers }); const j = await res.json().catch(() => null); if (res.status === 401 && j && j.needSecret) { if (!prompt) return j; wrong = !!tryWith; tryWith = await askProfileSecret(name, j.kind, wrong); if (!tryWith) return null; continue; } if (j && j.ok && j.protected && tryWith) ProfileSecret.set(j.name, tryWith, j.kind); return j; } return null; } let profilePushTimer = null; function scheduleProfilePush() { if (!WEB || !data.profile || !data.profile.name) return; clearTimeout(profilePushTimer); profilePushTimer = setTimeout(pushProfile, 1500); } async function pushProfile() { if (!WEB || !data.profile || !data.profile.name) return; try { const res = await fetch('/api/profile/save', { method: 'POST', headers: profileHeaders(data.profile.name, { 'Content-Type': 'application/json' }), body: JSON.stringify({ name: data.profile.name, data: profilePayload() }), }); const j = await res.json().catch(() => null); if (res.status === 401 && j && j.needSecret) { if (!pushProfile._warned) { pushProfile._warned = true; toast('🔒 This profile is protected — enter its password in Settings → Profile to keep syncing', { duration: 6000 }); } return; } if (j && j.ok) { data.profile.syncedAt = j.updatedAt || 0; // Record syncedAt directly — going through persist() would re-schedule // another push forever. API.saveData(data).catch(() => {}); } } catch { /* offline — the next persist() retries */ } } // Replace the synced slice of local state with a profile's server copy. function applyProfileData(name, payload, updatedAt) { payload = payload || {}; if (Array.isArray(payload.playlists)) data.playlists = payload.playlists; if (Array.isArray(payload.history)) data.history = payload.history; if (Array.isArray(payload.customVideos)) data.customVideos = payload.customVideos; if (Array.isArray(payload.pinnedPlaylists)) data.pinnedPlaylists = payload.pinnedPlaylists; if (payload.resumePositions && typeof payload.resumePositions === 'object') data.resumePositions = payload.resumePositions; if (payload.rememberPos && typeof payload.rememberPos === 'object') data.rememberPos = payload.rememberPos; if (payload.playCount && typeof payload.playCount === 'object') data.playCount = payload.playCount; if (payload.abMarkers && typeof payload.abMarkers === 'object') data.abMarkers = payload.abMarkers; if (payload.bookmarks && typeof payload.bookmarks === 'object') data.bookmarks = payload.bookmarks; if (payload.lyricOffsets && typeof payload.lyricOffsets === 'object') data.lyricOffsets = payload.lyricOffsets; if (payload.stats && typeof payload.stats === 'object') data.stats = payload.stats; if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings }; data.profile = { name, syncedAt: updatedAt || 0, protected: !!payload.__protected, kind: payload.__kind || null }; API.saveData(data).catch(() => {}); } // ---- "syncing" indicator ------------------------------------------------- // Playlists paint from localStorage on the first frame, so the network pass // needs to say it is still working. Counted, because several things sync at // once and the last one to finish should be the one that hides it. let _syncBusy = 0; function setSyncing(on) { _syncBusy = Math.max(0, _syncBusy + (on ? 1 : -1)); const el = document.getElementById('syncSpinner'); if (el) el.classList.toggle('hidden', _syncBusy === 0); } /** Runs the launch network pass without blocking the first paint. */ async function syncOnLaunch() { setSyncing(true); const before = playlistFingerprint(); try { await pullProfileIfNewer(); // A delivery is addressed to a profile, so this runs once we know which // one (if any) this device is linked to. await checkPlaylistInbox(); } catch { /* offline — the local copy is already on screen */ } finally { setSyncing(false); } // Only repaint when the server actually gave us something different; // a needless render would drop the sidebar's scroll position. if (playlistFingerprint() !== before) { renderSmartSidebar(); render(); } updateProfileStatus(); } // Cheap "did the synced slice change?" check — names, ids and lengths. function playlistFingerprint() { try { return JSON.stringify((data.playlists || []).map((p) => [p.id, p.name, (p.videos || []).length])); } catch { return String(Math.random()); } } // On launch: adopt the server copy when it's newer than this device's last // sync (another device pushed since); otherwise push local state up. async function pullProfileIfNewer() { if (!WEB || !data.profile || !data.profile.name) return; try { const res = await fetch(`/api/profile/load?name=${encodeURIComponent(data.profile.name)}`, { headers: profileHeaders(data.profile.name) }); if (res.status === 404) return; // profile gone server-side; keep local data const j = await res.json().catch(() => null); if (res.status === 401 && j && j.needSecret) { // Protected since this device last synced (or the password changed): // keep the local copy and say how to get back in. data.profile.protected = true; toast('🔒 Your profile is now protected — enter its password in Settings → Profile to sync', { duration: 6000 }); return; } if (!j || !j.ok) return; data.profile.protected = !!j.protected; data.profile.kind = j.kind || null; if ((j.updatedAt || 0) > (data.profile.syncedAt || 0)) { applyProfileData(j.name, { ...j.data, __protected: j.protected, __kind: j.kind }, j.updatedAt); } else { scheduleProfilePush(); } } catch { /* offline — stay on local data */ } } // The full URL that links a fresh device to this profile. Opening it runs // adoptProfileFromUrl() on the other device. Null when nothing is linked, or // outside WEB mode where there is no origin worth sharing. function profileShareUrl() { if (!WEB || !data.profile || !data.profile.name) return null; return `${location.origin}/?profile=${encodeURIComponent(data.profile.name)}`; } async function copyProfileLink() { const url = profileShareUrl(); if (!url) return; try { await navigator.clipboard.writeText(url); toast('Profile link copied ✓'); } catch { // Clipboard blocked (insecure context / permission) — fall back to a // selectable modal so the link is still recoverable by hand. const body = document.createElement('div'); body.innerHTML = `

Copy this link:

`; showModal('🔗 Profile link', body, [{ label: 'Done', primary: true, onClick: closeModal }]); const inp = $('profileLinkInput'); inp.focus(); inp.select(); } } async function shareProfileLink() { const url = profileShareUrl(); if (!url) return; if (!navigator.share) return copyProfileLink(); // desktop / no Web Share API try { await navigator.share({ title: 'YT Player profile', text: `Open my YT Player profile “${data.profile.name}”`, url, }); } catch { // User dismissed the share sheet, or the target rejected it — nothing to do. } } // Refresh the Settings rows and the sidebar block without a full re-render. function updateProfileStatus() { const linked = !!(data.profile && data.profile.name); const url = profileShareUrl(); const el = document.getElementById('profileStatus'); if (el) el.textContent = (linked && data.profile.name) || 'Not linked'; const unlink = document.getElementById('profileUnlinkBtn'); if (unlink) unlink.style.display = linked ? '' : 'none'; const prot = linked && !!data.profile.protected; const haveSecret = linked && !!ProfileSecret.getFor(data.profile.name); if (el && prot) el.textContent += ' · 🔒 protected'; const pb = document.getElementById('profileProtectBtn'); if (pb) { pb.style.display = linked ? '' : 'none'; pb.textContent = prot ? '🔑 Protection…' : '🔒 Protect…'; } // The credentials file only exists for a protected profile whose secret this device knows. const eb = document.getElementById('profileExportCredBtn'); if (eb) eb.style.display = prot && haveSecret ? '' : 'none'; // Settings → share-link row (only rendered on the settings view) const linkRow = document.getElementById('profileLinkRow'); if (linkRow) linkRow.classList.toggle('hidden', !url); const linkVal = document.getElementById('profileLinkValue'); if (linkVal) linkVal.textContent = url || ''; ['profileCopyLinkBtn', 'profileShareBtn'].forEach((id) => { const b = document.getElementById(id); if (b) b.style.display = url ? '' : 'none'; }); // Sidebar footer block const sb = document.getElementById('sidebarProfile'); if (sb) sb.classList.toggle('hidden', !url); const sbName = document.getElementById('sidebarProfileName'); if (sbName) sbName.textContent = (linked && data.profile.name) || ''; } // ---------- Shared playlist link (WEB mode) ---------- function showSharedPlaylistModal(name, url, code) { const body = document.createElement('div'); body.innerHTML = `

Anyone with this link can add a copy of “${escapeHtml(name)}” to their playlists.

`; const copyAction = async () => { try { await navigator.clipboard.writeText(url); toast('Playlist link copied ✓'); } catch { const inp = $('sharedPlaylistUrlInput'); if (inp) { inp.focus(); inp.select(); } } }; // Deliver to another profile by name. The recipient is prompted on their next // launch (there is no push channel), so this queues rather than pushes. const sendAction = async () => { const input = $('shareSendName'); const btn = $('shareSendBtn'); const msg = $('shareSendMsg'); if (!input || !btn || !msg) return; const to = input.value.trim(); if (!to) { msg.className = 'share-send-msg err'; msg.textContent = 'Enter a username first.'; input.focus(); return; } btn.disabled = true; msg.className = 'share-send-msg'; msg.textContent = 'Sending\u2026'; try { const res = await fetch('/api/playlist/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to, from: (data.profile && data.profile.name) || '', code }), }); const j = await res.json().catch(() => null); if (!j || !j.ok) { msg.className = 'share-send-msg err'; msg.textContent = '\u26a0 ' + ((j && j.error) || 'Could not send'); } else { msg.className = 'share-send-msg ok'; msg.textContent = `Sent to \u201c${to}\u201d \u2014 they'll get it the next time YT Player is open on their device.`; input.value = ''; } } catch { msg.className = 'share-send-msg err'; msg.textContent = '\u26a0 Network error \u2014 could not send'; } finally { btn.disabled = false; } }; const actions = [ { label: 'Done', onClick: closeModal }, { label: 'Copy link', primary: !navigator.share, onClick: copyAction }, ]; if (navigator.share) { actions.push({ label: 'Share', primary: true, onClick: async () => { try { await navigator.share({ title: `Playlist: ${name}`, text: `Open playlist “${name}” in YT Player`, url, }); } catch { // User dismissed share sheet or target rejected } }, }); } showModal('🔗 Share playlist', body, actions); // showModal moves the body into #modalBody, so the send controls only exist // in the document from here on — wire them after it, not before. const sendBtn = $('shareSendBtn'); if (sendBtn) sendBtn.onclick = sendAction; const sendInput = $('shareSendName'); if (sendInput) { sendInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); sendAction(); } }); } setTimeout(() => { const inp = $('sharedPlaylistUrlInput'); if (inp) { inp.focus(); inp.select(); } }, 50); } async function sharePlaylist(pl) { if (!pl || !Array.isArray(pl.videos) || !pl.videos.length) return; try { const res = await fetch('/api/playlist/share', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playlist: { name: pl.name, videos: pl.videos } }), }); const j = await res.json().catch(() => null); if (!j || !j.ok) { toast('⚠ ' + ((j && j.error) || 'Failed to share playlist')); return; } const url = `${location.origin}/?list=${encodeURIComponent(j.code)}`; showSharedPlaylistModal(pl.name, url, j.code); } catch { toast('⚠ Network error — could not share playlist'); } } // A shared playlist link (?list=code) arriving on this device. // Adds the playlist to the device's playlists without touching existing data or profiles. async function adoptSharedPlaylistFromUrl() { if (!WEB) return; let code = ''; try { code = (new URLSearchParams(location.search).get('list') || '').trim(); } catch { return; } if (!code) return; // Strip the param FIRST so a reload can't re-trigger the prompt. try { const params = new URLSearchParams(location.search); params.delete('list'); const qs = params.toString(); history.replaceState(null, '', location.pathname + (qs ? `?${qs}` : '') + location.hash); } catch {} await adoptSharedPlaylistByCode(code); } // Fetch a shared playlist by code, confirm, and add it alongside the existing // playlists. Shared by the ?list= link path and by inbox deliveries, so both // routes get identical dedupe, naming and confirm behaviour. // Returns true only when the playlist was actually added. async function adoptSharedPlaylistByCode(code, { fromName = '' } = {}) { let shared; try { const res = await fetch(`/api/playlist/shared?code=${encodeURIComponent(code)}`); const j = await res.json().catch(() => null); if (!j || !j.ok || !j.playlist) { toast('⚠ ' + ((j && j.error) || 'Shared playlist not found')); return false; } shared = j.playlist; } catch { toast('⚠ Network error — could not load shared playlist'); return false; } // Dedupe BEFORE the prompt so the count the user agrees to is the count they get. const seenIds = new Set(); const dedupedVideos = []; for (const v of (Array.isArray(shared.videos) ? shared.videos : [])) { if (!v || !v.id || seenIds.has(v.id)) continue; seenIds.add(v.id); dedupedVideos.push(slim(v)); } const count = dedupedVideos.length; if (!count) { toast('⚠ That shared playlist has no playable videos'); return false; } // fromName comes from another user, so it is escaped like any untrusted text. const senderLine = fromName ? `${escapeHtml(fromName)} sent you this playlist.
` : ''; const ok = await new Promise((resolve) => { const body = document.createElement('div'); body.innerHTML = `

${senderLine} Add “${escapeHtml(shared.name)}” (${count} video${count === 1 ? '' : 's'}) to your playlists? This playlist is added alongside your existing playlists and nothing is replaced.

`; showModal(fromName ? '🎵 Playlist sent to you' : '⬇ Add shared playlist', body, [ { label: fromName ? 'No thanks' : 'Cancel', onClick: () => { closeModal(); resolve(false); } }, { label: 'Add playlist', primary: true, onClick: () => { closeModal(); resolve(true); } }, ]); }); if (!ok) return false; // Ensure unique name: if name already exists, suffix (2), (3)... const baseName = (shared.name || 'Shared Playlist').trim() || 'Shared Playlist'; let finalName = baseName; let counter = 2; const existingNames = new Set((data.playlists || []).map((p) => p.name)); while (existingNames.has(finalName)) { finalName = `${baseName} (${counter})`; counter++; } const pl = { id: uid(), name: finalName, videos: dedupedVideos }; if (!Array.isArray(data.playlists)) data.playlists = []; data.playlists.push(pl); persist(); renderSidebar(); render(); toast(`Added “${finalName}” (${dedupedVideos.length} video${dedupedVideos.length === 1 ? '' : 's'}) ✓`); return true; } // Playlists other users sent to this device's linked profile. Offered one at a // time on launch; each is cleared from the server inbox once answered, whether // the user kept it or not, so it is never offered twice. // // Launch alone is not enough: an installed PWA is usually resumed from the // background rather than relaunched, so it also re-checks when it returns to // the foreground and on a timer while visible (see startPlaylistInboxWatch). let inboxCheckRunning = false; let inboxLastCheck = 0; async function checkPlaylistInbox() { if (!WEB || inboxCheckRunning) return; const me = data.profile && data.profile.name; if (!me) return; // deliveries are addressed to a profile, not a device inboxCheckRunning = true; inboxLastCheck = Date.now(); try { await drainPlaylistInbox(me); } finally { inboxCheckRunning = false; } } const INBOX_POLL_MS = 2 * 60 * 1000; const INBOX_MIN_GAP_MS = 20 * 1000; function startPlaylistInboxWatch() { if (!WEB) return; const maybeCheck = () => { if (document.visibilityState !== 'visible') return; if (Date.now() - inboxLastCheck < INBOX_MIN_GAP_MS) return; // Never pop the prompt over a dialog the user is already in. const modal = $('modal'); if (modal && !modal.classList.contains('hidden')) return; checkPlaylistInbox(); }; document.addEventListener('visibilitychange', maybeCheck); window.addEventListener('focus', maybeCheck); setInterval(maybeCheck, INBOX_POLL_MS); } async function drainPlaylistInbox(me) { let items = []; try { const res = await fetch(`/api/playlist/inbox?name=${encodeURIComponent(me)}`, { headers: profileHeaders(me) }); const j = await res.json().catch(() => null); if (!j || !j.ok || !Array.isArray(j.items)) return; items = j.items; } catch { return; // offline — they stay queued for next launch } for (const item of items) { if (!item || !item.code) continue; await adoptSharedPlaylistByCode(item.code, { fromName: item.from || '' }); try { await fetch('/api/playlist/inbox/dismiss', { method: 'POST', headers: profileHeaders(me, { 'Content-Type': 'application/json' }), body: JSON.stringify({ name: me, id: item.id }), }); } catch { /* leave it queued; a duplicate prompt beats losing the playlist */ } } } // A shared link (?profile=name) arriving on this device. Adopting replaces the // synced slice of local state, so anything already on the device gets a // confirm first; a link for the profile already linked here is a no-op. async function adoptProfileFromUrl() { if (!WEB) return; let name = ''; try { name = (new URLSearchParams(location.search).get('profile') || '').trim(); } catch { return; } if (!name) return; // Drop the param either way so a reload can't re-trigger the prompt. try { history.replaceState(null, '', location.pathname + location.hash); } catch {} if (data.profile && data.profile.name && data.profile.name.toLowerCase() === name.toLowerCase()) return; const hasLocalData = (data.playlists && data.playlists.length) || (data.history && data.history.length) || !!(data.profile && data.profile.name); if (hasLocalData) { const ok = await new Promise((resolve) => { const body = document.createElement('div'); body.innerHTML = `

This link opens the online profile ${escapeHtml(name)}. Loading it replaces the playlists, history and settings on this device, and future changes here sync to that profile.

`; showModal('⬇ Open shared profile', body, [ { label: 'Cancel', onClick: () => { closeModal(); resolve(false); } }, { label: 'Load profile', primary: true, onClick: () => { closeModal(); resolve(true); } }, ]); }); if (!ok) return; } try { const j = await fetchProfile(name); if (!j) return; // cancelled at the password prompt if (!j.ok) { toast('⚠ ' + (j.error || 'Profile not found')); return; } finishProfileLoad(j); } catch { toast('⚠ Network error — could not open that profile'); } } function createProfileFlow() { const body = document.createElement('div'); body.innerHTML = `

Pick a name (3–40 characters: letters, digits, - or _). The name is the key — anyone who knows it can load this profile on their device, so use something hard to guess or go random.

`; showModal('+ Create online profile', body, [ { label: 'Cancel', onClick: closeModal }, { label: '🎲 Random name', onClick: () => requestCreateProfile(null) }, { label: 'Create', primary: true, onClick: () => { const name = ($('profileNameInput').value || '').trim(); if (!/^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$/.test(name)) { toast('⚠ Name must be 3–40 characters: letters, digits, - or _'); return; // keep the modal open for another attempt } requestCreateProfile(name); } }, ]); $('profileNameInput').focus(); } // name === null → let the server generate a unique random one. async function requestCreateProfile(name) { try { const res = await fetch('/api/profile/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, data: profilePayload() }), }); const j = await res.json().catch(() => null); if (!j || !j.ok) { toast('⚠ ' + ((j && j.error) || 'Could not create profile')); return; // modal stays open — user can adjust the name } closeModal(); data.profile = { name: j.name, syncedAt: j.updatedAt || 0 }; persist(); updateProfileStatus(); toast(`Profile “${j.name}” created ✓ — load it by name on any device`, { duration: 4500 }); } catch { toast('⚠ Network error — try again'); } } // ---- Protect / credentials file ---- function randomProfileKey() { const b = new Uint8Array(32); crypto.getRandomValues(b); return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } async function setProfileSecretOnServer(secret, kind) { const name = data.profile.name; const res = await fetch('/api/profile/secret', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, current: ProfileSecret.getFor(name), secret, kind }), }); const j = await res.json().catch(() => ({})); if (!res.ok || !j.ok) throw new Error(j.error || 'could not change protection'); if (secret) ProfileSecret.set(name, secret, kind); else ProfileSecret.clear(); data.profile.protected = !!secret; data.profile.kind = secret ? kind : null; persist(); updateProfileStatus(); } function protectProfileFlow() { if (!data.profile || !data.profile.name) return; const prot = !!data.profile.protected; if (prot && !ProfileSecret.getFor(data.profile.name)) { toast('🔒 Load this profile with its password or credentials file first'); return; } const body = document.createElement('div'); body.innerHTML = `

${prot ? 'Change or remove the protection on' : 'Protect'} ${escapeHtml(data.profile.name)}. Once protected, the name alone no longer opens it — a device also needs the password, the profile key, or the credentials file. If you lose all of them the profile can't be opened.

`; const actions = [ { label: 'Cancel', onClick: closeModal }, { label: '🔑 Generate key', onClick: async () => { try { await setProfileSecretOnServer(randomProfileKey(), 'key'); closeModal(); toast('Profile key set ✓ — export the credentials file now so other devices can open it', { duration: 5000 }); exportProfileCredentials(); } catch (e) { toast('⚠ ' + e.message); } } }, { label: 'Set password', primary: true, onClick: async () => { const a = $('profilePwInput').value, b = $('profilePwInput2').value; if (a.length < 8) { toast('⚠ Use at least 8 characters'); return; } if (a !== b) { toast('⚠ The two passwords differ'); return; } try { await setProfileSecretOnServer(a, 'password'); closeModal(); toast('Password set ✓ — other devices will ask for it', { duration: 4000 }); } catch (e) { toast('⚠ ' + e.message); } } }, ]; if (prot) actions.splice(1, 0, { label: 'Remove', danger: true, onClick: async () => { try { await setProfileSecretOnServer(null, null); closeModal(); toast('Protection removed — the name alone opens this profile again'); } catch (e) { toast('⚠ ' + e.message); } } }); showModal(prot ? '🔑 Profile protection' : '🔒 Protect profile', body, actions); setTimeout(() => $('profilePwInput') && $('profilePwInput').focus(), 50); } // A small JSON file that signs another device into this profile. async function exportProfileCredentials() { const name = data.profile && data.profile.name; const secret = name && ProfileSecret.getFor(name); if (!secret) { toast('⚠ Protect the profile first — the credentials file needs its password or key'); return; } const cred = { type: 'ytplayer-profile-credentials', version: 1, origin: location.origin, name, kind: ProfileSecret.kindFor(name) || 'password', secret, exportedAt: new Date().toISOString() }; const fileName = `ytplayer-${name}.credentials.json`; const blob = new Blob([JSON.stringify(cred, null, 2) + '\n'], { type: 'application/json' }); const file = new File([blob], fileName, { type: 'application/json' }); if (navigator.canShare && navigator.canShare({ files: [file] }) && /iPad|iPhone|iPod|Android/.test(navigator.userAgent)) { try { await navigator.share({ files: [file], title: fileName }); return; } catch { /* fall back to a download */ } } const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = fileName; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); toast('🔑 Credentials file saved — anyone with it can open your profile, keep it private', { duration: 5000 }); } function importProfileCredentials() { const input = $('profileCredFileInput') || Object.assign(document.createElement('input'), { type: 'file', accept: '.json,application/json' }); input.value = ''; input.onchange = async () => { const f = input.files && input.files[0]; if (!f) return; let cred; try { cred = JSON.parse(await f.text()); } catch { toast('⚠ That is not a credentials file'); return; } if (!cred || cred.type !== 'ytplayer-profile-credentials' || !cred.name || !cred.secret) { toast('⚠ That is not a YT Player credentials file'); return; } if (cred.origin && cred.origin !== location.origin) toast(`Note: this file was made on ${cred.origin}`, { duration: 4000 }); try { const j = await fetchProfile(String(cred.name).toLowerCase(), { prompt: false, secret: cred.secret }); if (!j || !j.ok) { toast('⚠ ' + ((j && j.error) || 'Could not open that profile')); return; } ProfileSecret.set(j.name, cred.secret, cred.kind || j.kind || 'password'); finishProfileLoad(j); } catch { toast('⚠ Network error — try again'); } }; input.click(); } // Adopt a loaded profile on this device and re-apply everything it drives. function finishProfileLoad(j) { applyProfileData(j.name, { ...j.data, __protected: j.protected, __kind: j.kind }, j.updatedAt); applyAppearance(); updateLoopRepeatButtons(); updateQueueBadge(); els.volume.value = String(data.settings.volume ?? 1); els.quality.value = data.settings.quality || 'auto'; els.audioOnlyToggle.checked = !!data.settings.audioOnly; renderSidebar(); renderSmartSidebar(); render(); updateProfileStatus(); data.playlists.forEach(preloadPlaylist); preloadPinnedPlaylists(); toast(`Profile “${j.name}” loaded ✓ — this device now syncs to it`, { duration: 4000 }); } function loadProfileFlow() { const body = document.createElement('div'); body.innerHTML = `

Enter a profile name to pull its playlists, history and settings onto this device. The synced data on this device is replaced, and future changes here sync back to that profile.

`; showModal('⬇ Load online profile', body, [ { label: 'Cancel', onClick: closeModal }, { label: 'Load', primary: true, onClick: async () => { const name = ($('profileNameInput').value || '').trim(); if (!name) return; try { closeModal(); const j = await fetchProfile(name); if (!j) return; // cancelled at the password prompt if (!j.ok) { toast('⚠ ' + (j.error || 'Profile not found')); return; } finishProfileLoad(j); } catch { toast('⚠ Network error — try again'); } } }, ]); $('profileNameInput').focus(); } // ---------- Helpers ---------- // Only for values interpolated into innerHTML/attribute strings (profile names // off a URL are attacker-controlled). function escapeHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } const ICON_PLAY = ''; const ICON_PAUSE = ''; // Speaker icon follows the volume (muted / low / high); the level before a mute // is remembered so un-muting returns to it. let lastVolume = 1; function syncMuteIcon() { const b = els.muteBtn; if (!b) return; const v = parseFloat(els.volume.value); if (v > 0) lastVolume = v; const lvl = v === 0 ? 0 : v < 0.5 ? 1 : 2; const waves = lvl === 0 ? '' : lvl === 1 ? '' : ''; b.innerHTML = ``; b.classList.toggle('muted', lvl === 0); const pct = $('volPct'); if (pct) pct.textContent = Math.round(v * 100) + '%'; b.title = lvl === 0 ? 'Unmute' : 'Mute'; b.setAttribute('aria-label', b.title); } 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 — fade out the oldest. Removal waits for the exit // animation, so only count toasts that aren't already leaving: the old // `while (children.length > 3)` never removed anything synchronously and // froze the page for good the moment a 4th toast appeared. const live = [...container.children].filter((c) => !c.classList.contains('toast-exit')); for (const old of live.slice(0, Math.max(0, live.length - 3))) { old.classList.add('toast-exit'); setTimeout(() => old.remove(), 280); } return t; } 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'; root.dataset.layout = s.layout || 'classic'; } 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) // ============================================================================ // An empty file (a save that never got its bytes) is not a saved video. function isUsableCacheItem(it) { return !!(it && it.id) && !(typeof it.size === 'number' && it.size <= 0); } async function refreshCachedIds() { const next = new Set(); try { const res = await API.cacheList(); if (res && res.ok) for (const it of res.items || []) if (isUsableCacheItem(it)) next.add(it.id); } catch { /* shell without cache support — leave empty */ } cachedIds.clear(); for (const id of next) cachedIds.add(id); } // The badges trust cachedIds, which is otherwise only read from storage at // launch — a PWA resumed from the background would keep showing videos the // browser has since evicted (iOS does this under storage pressure). Re-read // the device store when the app returns to the foreground and repaint only // what changed. Bumped on every add so a save landing mid-listing is never // undone by the older listing. let cacheMutations = 0; let cacheResyncRunning = false; async function resyncCachedIds() { if (!WEB || !window.OPFS || !window.OPFS.isSupported() || cacheResyncRunning) return; cacheResyncRunning = true; try { const before = cacheMutations; let items; try { items = await window.OPFS.listVideos({ strict: true }); } catch { return; } if (before !== cacheMutations) return; // a save landed meanwhile — next time const listed = new Set(items.filter(isUsableCacheItem).map((it) => it.id)); const changed = []; for (const id of [...cachedIds]) { if (!listed.has(id)) { cachedIds.delete(id); changed.push(id); } } for (const id of listed) { if (!cachedIds.has(id) && !downloading.has(id)) { cachedIds.add(id); changed.push(id); } } if (!changed.length) return; for (const id of changed) markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none'); renderSidebar(); if (view.type === 'playlist') { const pl = data.playlists.find((p) => p.id === view.id); const offlineBtn = $('plOfflineBtn'); if (offlineBtn && pl) updatePlaylistOfflineButton(offlineBtn, pl); } else if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') { renderList(); } } finally { cacheResyncRunning = false; } } function startCacheResyncWatch() { if (!WEB) return; let last = Date.now(); const maybe = () => { if (document.visibilityState !== 'visible') return; if (Date.now() - last < 5000) return; last = Date.now(); resyncCachedIds(); }; document.addEventListener('visibilitychange', maybe); window.addEventListener('pageshow', maybe); } // ---------- Offline thumbnail warm-up ---------- // Saving a video stores only its media; its artwork lives on i.ytimg.com and // is only cached once something has actually requested it. Pull it through the // service worker (which caches thumbnail responses) so a video saved for // offline still has its art when the network is gone. // Pull a result set's artwork into the service worker's thumbnail cache, a few at // a time, so a saved search still has pictures offline. function warmSearchThumbs(results) { const urls = (results || []).map((r) => r && r.thumbnail).filter(Boolean).slice(0, 100); let i = 0; const next = () => { if (i >= urls.length) return; warmThumb(urls[i++]); setTimeout(next, 40); }; next(); next(); next(); } function warmThumb(url) { if (!WEB || !url || !/^https?:/i.test(url)) return; fetch(url, { mode: 'cors', credentials: 'omit' }).catch(() => {}); } // A YouTube id, or one of the server's own uploads (server/uploads.js). const YT_ID_RE = /^[A-Za-z0-9_-]{11}$/; const UPLOAD_ID_RE = /^upl_[a-f0-9]{12}$/; const isUploadId = (id) => UPLOAD_ID_RE.test(String(id || '')); const isMediaId = (id) => YT_ID_RE.test(String(id || '')) || isUploadId(id); function thumbUrlFor(id, known) { if (known && known.thumbnail) return known.thumbnail; if (isUploadId(id)) return `/api/uploads/${id}/art`; // 404s when the file has no cover if (String(id).startsWith('edit_')) return ''; // edited copies have no YouTube art return `https://i.ytimg.com/vi/${id}/mqdefault.jpg`; } // One-shot backfill on launch: everything already saved offline, plus every // playlist video (the home screen and sidebar render those). Cached thumbs are // served by the SW without touching the network, so only the first run costs // anything. Bounded and lightly throttled so a big library can't burst. const THUMB_WARM_MAX = 400; async function warmOfflineThumbs() { if (!WEB || navigator.onLine === false) return; const urls = new Set(); for (const id of cachedIds) { const u = thumbUrlFor(id, videoById(id)); if (u) urls.add(u); } for (const pl of data.playlists || []) { for (const v of pl.videos || []) if (v && v.thumbnail) urls.add(v.thumbnail); } const list = [...urls].slice(0, THUMB_WARM_MAX); for (let i = 0; i < list.length; i += 4) { await Promise.all(list.slice(i, i + 4).map((u) => fetch(u, { mode: 'cors', credentials: 'omit' }).catch(() => {}))); } } // This device's record of what it holds and each file's content id // (docs/p2p-architecture.md flow 2). The P2P client reports these. function recordDeviceFile(id, res) { if (!WEB || !window.DeviceDB) return; const cid = res && window.Sha256 && window.Sha256.isHex(res.sha256) ? res.sha256 : null; const state = cid ? (res.expectedSha === cid ? 'verified' : 'unverified') : 'unhashed'; const now = Date.now(); window.DeviceDB.putFile({ videoId: id, cid, size: (res && res.size) || 0, savedAt: now, lastCheckedAt: now, state }) .then(() => { if (window.P2PClient) window.P2PClient.changed(); }) .catch(() => {}); } // Download a video into the permanent offline cache. Safe to call repeatedly. async function preload(video, { quiet = false, mux = false, retry = false } = {}) { const id = video.id; // Custom (edited) videos have no YouTube source to (re)download — their // media is produced once by the editor. Never route them through the normal // cache-download path (a fake edit_… id would 404 on /api/download). if (video.custom) return; 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…`); SaveQueue.add(video); const controller = new AbortController(); saveControllers.set(id, controller); SaveQueue.progress(id, { phase: 'waiting' }); let slot = false; try { await SaveSlots.acquire(controller.signal); slot = true; SaveQueue.progress(id, null); const res = await API.cacheDownload(id, { mux, retry, signal: controller.signal, onProgress: (p) => SaveQueue.progress(id, p) }); if (res && res.paused) { // The partial stays on the device; SaveQueue picks it up again. if (!quiet) toast(`Paused “${video.title}” — it will continue when the connection is back`); } else if (res?.ok) { SaveQueue.remove(id); } else { SaveQueue.state(id, 'failed', res?.error || 'Save failed'); } if (res && res.ok && res.cached) { if (typeof EqRender !== 'undefined') EqRender.schedule(id); cachedIds.add(id); cacheMutations++; recordDeviceFile(id, res); warmThumb(thumbUrlFor(id, video)); if (!quiet) toast(`Saved “${video.title}” ✓`); } else if (!quiet && !(res && res.paused)) { toast('⚠ ' + ((res && res.error) || 'Could not save video')); } } catch (e) { if (e.name !== 'AbortError') SaveQueue.state(id, 'failed', e.message || 'Save failed'); if (!quiet && e.name !== 'AbortError') toast('⚠ Saving not supported in this build.'); } finally { saveControllers.delete(id); if (controller.signal.aborted && !SaveQueue.pending().some(v => v.id === id)) await window.OPFS?.discardPartial?.(id); if (slot) SaveSlots.release(); SaveQueue.progress(id, null); 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(); if (view.type === 'playlist') { const pl = data.playlists.find((p) => p.id === view.id); const offlineBtn = $('plOfflineBtn'); if (offlineBtn && pl) updatePlaylistOfflineButton(offlineBtn, pl); } renderSidebar(); } } // A device copy that failed to play. Left in place it stays badged "cached" // and fails on every tap, so drop it and save a fresh one in the background. // An HEVC copy means canPlayType over-promised: stop asking for HEVC first so // the re-save comes back as H.264. Edited cuts exist only on the device (no // source to re-save from), so they are never deleted. One re-save per video // per session keeps a copy that is bad for another reason from looping. const resavedAfterFailure = new Set(); async function dropBrokenLocalCopy(meta) { const id = meta && meta.id; if (!id || meta.custom || !cachedIds.has(id)) return; try { if (WEB && window.OPFS && typeof window.OPFS.sniffCodec === 'function' && (await window.OPFS.sniffCodec(id)) === 'hevc') { try { localStorage.setItem(NO_HEVC_KEY, '1'); } catch { /* storage blocked */ } } } catch { /* sniffing is best-effort */ } try { await API.cacheDelete(id); } catch { /* best-effort */ } cachedIds.delete(id); markCardCacheState(id, 'none'); renderSidebar(); if (view.type === 'playlist') { const pl = data.playlists.find((p) => p.id === view.id); const offlineBtn = $('plOfflineBtn'); if (offlineBtn && pl) updatePlaylistOfflineButton(offlineBtn, pl); } if (resavedAfterFailure.has(id)) return; resavedAfterFailure.add(id); preload(meta, { quiet: true }); } // 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); } } // Helper: count how many videos in a playlist are cached offline. // Custom (edited) videos count as cached (produced by the editor, not downloaded). function playlistOfflineStats(pl) { if (!pl || !Array.isArray(pl.videos)) return { cached: 0, total: 0 }; let cached = 0; for (const v of pl.videos) { if (v && (v.custom || cachedIds.has(v.id))) cached++; } return { cached, total: pl.videos.length }; } // 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 })); } // Preload every pinned playlist's videos even when autoPreload is off. function preloadPinnedPlaylists() { const pinnedSet = new Set(data.pinnedPlaylists || []); if (!pinnedSet.size) return; for (const pl of data.playlists || []) { if (pinnedSet.has(pl.id) && Array.isArray(pl.videos)) { 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(); } }); } // ============================================================================ // Video editor — cut parts out of a video and save a custom offline copy // // The editor works on a SOURCE video (any card / the now-playing video). The // user marks one or more CUT ranges; everything outside those ranges survives. // On confirm we compute the keep segments (VideoEdit.invertCuts), ask the // server to trim+concat the source into one continuous mp4 (?edit=1&keep=…), // store it in OPFS under a fresh custom id, and register a custom video object // in data.customVideos so it plays offline and can be added to playlists just // like a normal video. // ============================================================================ // Make a stable-ish unique id for a custom cut. Not a YouTube id — the // `edit_` prefix is how the rest of the app recognises an offline-only video. function customVideoId(sourceId) { return 'edit_' + sanitizeId(sourceId) + '_' + uid(); } // Kick off the server-side edit + OPFS save for a custom video object, driving // the same download/cache UI state (badges, toasts) as a normal save. async function downloadEdited(customVideo) { const id = customVideo.id; if (!id || cachedIds.has(id) || downloading.has(id)) return; downloading.add(id); downloadMeta.set(id, slim(customVideo)); markCardCacheState(id, 'downloading'); if (view.type === 'downloads') renderList(); updateDownloadBadge(); toast(`Rendering “${customVideo.title}”…`); try { const res = await API.cacheDownloadEdited(id, customVideo.sourceId, customVideo.keep); if (res && res.ok && res.cached) { cachedIds.add(id); cacheMutations++; // An edited copy has no art of its own — warm its source's thumbnail, // which is what the Saved list falls back to. warmThumb(thumbUrlFor(customVideo.sourceId, videoById(customVideo.sourceId))); // Only persist the custom video once its media is actually stored, so a // failed render never leaves a dangling entry the user can't play. if (!(data.customVideos || []).some((v) => v.id === id)) { data.customVideos = data.customVideos || []; data.customVideos.push(customVideo); persist(); } toast(`Saved edited “${customVideo.title}” ✓`); if (view.type === 'downloads' || view.type === 'saved') renderList(); } else { toast('⚠ ' + ((res && res.error) || 'Could not render edited video')); } } catch (e) { toast('⚠ ' + (e && e.message ? e.message : 'Editing failed')); } finally { downloading.delete(id); downloadMeta.delete(id); markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none'); updateDownloadBadge(); if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') renderList(); if (view.type === 'playlist') { const pl = data.playlists.find((p) => p.id === view.id); const offlineBtn = $('plOfflineBtn'); if (offlineBtn && pl) updatePlaylistOfflineButton(offlineBtn, pl); } renderSidebar(); } } // Remove a custom (edited) video entirely: its cached media file, its cache // membership, its registry entry, and any playlist references. Unlike a normal // "remove from cache", the media can't be re-fetched, so this is a true delete. async function deleteCustomVideo(id) { try { await API.cacheDelete(id); } catch { /* best-effort */ } cachedIds.delete(id); data.customVideos = (data.customVideos || []).filter((v) => v.id !== id); data.playlists.forEach((pl) => { pl.videos = pl.videos.filter((x) => x.id !== id); }); data.queue = (data.queue || []).filter((x) => x.id !== id); persist(); markCardCacheState(id, 'none'); if (current && current.meta && current.meta.id === id) updateNowPlayingActions(); toast('Deleted edited video'); if (view.type === 'saved' || view.type === 'downloads' || view.type === 'playlist') renderList(); } // Open the editor modal for a source video. `duration` seconds is needed to // compute keep segments; we take it from the live player when the video is // currently playing, else from the card metadata. function openVideoEditor(source) { if (!(WEB && window.OPFS && window.OPFS.isSupported())) { toast('⚠ Editing needs offline storage, which this browser doesn’t support'); return; } // Prefer the precise live duration when editing the now-playing video. let duration = 0; if (current && current.meta && current.meta.id === source.id && Player.master && Player.master.duration) { duration = Player.master.duration; } if (!duration) duration = Number(source.duration) || 0; if (!duration || !isFinite(duration)) { toast('⚠ Play the video first so its length is known, then edit'); return; } const cuts = []; // [{start,end}] the user is removing let lastMarker = null; // 'A' or 'B' const body = document.createElement('div'); body.className = 'video-editor'; body.innerHTML = `

Mark the parts to remove. Everything else is kept and saved as a new offline video.

A
B
`; const fromEl = body.querySelector('.ve-from'); const toEl = body.querySelector('.ve-to'); const addBtn = body.querySelector('.ve-add'); const moveBtn = body.querySelector('.ve-move'); const errEl = body.querySelector('.ve-error'); const cutsEl = body.querySelector('.ve-cuts'); const titleEl = body.querySelector('.ve-title'); const sumEl = body.querySelector('.ve-summary'); const trackEl = body.querySelector('.ve-scrubber-track'); const markerA = body.querySelector('.ve-marker-a'); const markerB = body.querySelector('.ve-marker-b'); titleEl.value = (source.title || 'Video') + ' (edit)'; // Update markers based on duration function updateScrubber() { const a = VideoEdit.parseTime(fromEl.value); const b = VideoEdit.parseTime(toEl.value); if (a !== null) markerA.style.left = (a / duration * 100) + '%'; if (b !== null) markerB.style.left = (b / duration * 100) + '%'; } trackEl.onclick = (e) => { const rect = trackEl.getBoundingClientRect(); const percent = (e.clientX - rect.left) / rect.width; const time = Math.round(percent * duration); if (!fromEl.value || lastMarker === 'B') { fromEl.value = VideoEdit.fmtTime(time); lastMarker = 'A'; } else { toEl.value = VideoEdit.fmtTime(time); lastMarker = 'B'; } updateScrubber(); }; markerA.onclick = (e) => { e.stopPropagation(); lastMarker = 'A'; }; markerB.onclick = (e) => { e.stopPropagation(); lastMarker = 'B'; }; moveBtn.onclick = () => { if (lastMarker === 'A' && fromEl.value) { Player.seek(VideoEdit.parseTime(fromEl.value)); } else if (lastMarker === 'B' && toEl.value) { Player.seek(VideoEdit.parseTime(toEl.value)); } }; fromEl.oninput = updateScrubber; toEl.oninput = updateScrubber; function showErr(msg) { errEl.textContent = msg; errEl.hidden = !msg; } function refresh() { cutsEl.innerHTML = ''; const sorted = cuts.slice().sort((a, b) => a.start - b.start); sorted.forEach((cut) => { const row = document.createElement('div'); row.className = 've-cut'; row.innerHTML = `✂ ${VideoEdit.fmtTime(cut.start)} – ${VideoEdit.fmtTime(cut.end)}`; const del = document.createElement('button'); del.className = 've-cut-del'; del.type = 'button'; del.textContent = '✕'; del.title = 'Remove this cut'; del.onclick = () => { const i = cuts.indexOf(cut); if (i > -1) cuts.splice(i, 1); refresh(); }; row.appendChild(del); cutsEl.appendChild(row); }); const keep = VideoEdit.invertCuts(cuts, duration); const finalLen = VideoEdit.keepDuration(keep); sumEl.innerHTML = cuts.length ? `Final length: ${VideoEdit.fmtTime(finalLen)} of ${VideoEdit.fmtTime(duration)}` : `No cuts yet — the whole ${VideoEdit.fmtTime(duration)} video would be saved.`; } addBtn.onclick = () => { showErr(''); const a = VideoEdit.parseTime(fromEl.value); const b = VideoEdit.parseTime(toEl.value); if (a === null || b === null) { showErr('Enter valid times, e.g. 0:30 and 1:15.'); return; } if (b <= a) { showErr('“To” must be after “From”.'); return; } if (a >= duration) { showErr(`Times must be within the video (0 – ${VideoEdit.fmtTime(duration)}).`); return; } cuts.push({ start: a, end: Math.min(b, duration) }); fromEl.value = ''; toEl.value = ''; fromEl.focus(); refresh(); }; refresh(); showModal('Edit & download', body, [ { label: 'Cancel', onClick: closeModal }, { label: 'Save edited copy', primary: true, onClick: () => { if (!VideoEdit.hasEdits(cuts, duration)) { showErr('Add at least one cut, or use ⬇ Save for the full video.'); return; } const keep = VideoEdit.invertCuts(cuts, duration); if (!keep.length) { showErr('That would remove the entire video — leave something to keep.'); return; } const custom = { id: customVideoId(source.id), title: (titleEl.value || '').trim() || ((source.title || 'Video') + ' (edit)'), channel: source.channel || '', channelId: source.channelId || '', channelUrl: source.channelUrl || '', thumbnail: source.thumbnail || '', duration: Math.round(VideoEdit.keepDuration(keep)), custom: true, sourceId: source.id, keep: VideoEdit.keepToParam(keep), }; closeModal(); downloadEdited(custom); }, }, ]); setTimeout(() => fromEl.focus(), 50); } // ============================================================================ // 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 _bgHandoff: false, // sound temporarily moved to