/* ============================================================================ * 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: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fingerprint: window.getFingerprint ? window.getFingerprint() : 'unknown', playlists: d.playlists || [], 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); pruneResults(trimmed); }, remove(q) { const list = load().filter((x) => x.toLowerCase() !== (q || '').toLowerCase()); save(list); pruneResults(list); }, clear() { save([]); saveResults({}); }, // Cache the just-fetched result set for `q` (called on every successful search). cacheResults(q, results) { q = (q || '').trim(); if (!q || !Array.isArray(results) || !results.length) return; const r = loadResults(); r[norm(q)] = { query: q, results: results.slice(0, RESULTS_PER_QUERY_MAX), ts: Date.now() }; saveResults(r); pruneResults(load()); }, // Cached {query, results, ts} for `q`, or null. getResults(q) { const hit = loadResults()[norm(q)]; return hit && Array.isArray(hit.results) && hit.results.length ? hit : null; }, // Normalized queries that have a cached result set — one localStorage // read for the whole dropdown, rather than one per row. cachedQueries() { return new Set(Object.keys(loadResults())); }, }; })(); // 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'); if (hevcCapable()) params.set('hevc', '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). 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); if (w.ok) return { ok: true, cached: true, sha256: w.sha256 || null, expectedSha: w.expectedSha || null, size: w.size || 0 }; 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}` }; } // 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: 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) => 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 }), 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' density: 'comfortable', // 'comfortable' | 'compact' perfMode: false, // disable heavy visual effects for speed reduceMotion: false, // disable animations layout: 'classic', // 'classic'|'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? } 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'), 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, }; } 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: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: data.profile.name, data: profilePayload() }), }); const j = await res.json().catch(() => null); 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 }; 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)}`); if (res.status === 404) return; // profile gone server-side; keep local data const j = await res.json().catch(() => null); if (!j || !j.ok) return; if ((j.updatedAt || 0) > (data.profile.syncedAt || 0)) { applyProfileData(j.name, j.data, 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'; // 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)}`); 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: { '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 res = await fetch(`/api/profile/load?name=${encodeURIComponent(name)}`); const j = await res.json().catch(() => null); if (!j || !j.ok) { toast('⚠ ' + ((j && j.error) || 'Profile not found')); return; } applyProfileData(j.name, j.data, j.updatedAt); toast(`Profile “${j.name}” loaded ✓ — this device now syncs to it`, { duration: 4000 }); } 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'); } } 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 { const res = await fetch(`/api/profile/load?name=${encodeURIComponent(name)}`); const j = await res.json().catch(() => null); if (!j || !j.ok) { toast('⚠ ' + ((j && j.error) || 'Profile not found')); return; // keep the modal open } closeModal(); applyProfileData(j.name, j.data, j.updatedAt); // Re-apply everything the loaded data drives. 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 }); } 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, '''); } 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. 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 } = {}) { 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…`); try { const res = await API.cacheDownload(id, { mux }); if (res && res.ok && res.cached) { cachedIds.add(id); cacheMutations++; recordDeviceFile(id, res); warmThumb(thumbUrlFor(id, video)); 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(); 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