Files
ytplayer/frontend/app.js

9123 lines
399 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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_<src>_<uid>). 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 }),
related: (meta, { refresh = false } = {}) => WEB && /^[\w-]{11}$/.test(meta.id)
? fetch(`/api/related?${new URLSearchParams({ videoId: meta.id, title: meta.title || '', channel: meta.channel || meta.artist || '', ...(refresh ? { refresh: '1' } : {}) })}`).then(response => response.json())
: API.search(meta.title || meta.channel, { refresh }),
// 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();
const knownServerCachedIds = new Set();
function warmStreams(ids) {
if (!WEB || navigator.onLine === false || navigator.connection?.saveData) 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(() => {});
}
}
function warmNextQueue() {
if (!window.PrewarmNext || queueIndex < 0) return;
warmStreams(window.PrewarmNext.candidates(queue, queueIndex, data.settings, 2, {
offline: cachedIds,
serverCached: knownServerCachedIds,
}));
}
// 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'|'f7-swipe'
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:<id>',…)
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 = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
<strong>${escapeHtml(name)}</strong> is protected. ${wrong ? '<strong style="color:#ff6b5f">That was wrong.</strong> ' : ''}
Enter its ${what}${kind === 'key' ? ', or import its credentials file' : ''}.
</p>
<input id="profileSecretInput" type="password" autocomplete="current-password" placeholder="${what}" />`;
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 = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px">Copy this link:</p>
<input id="profileLinkInput" type="text" readonly value="${escapeHtml(url)}" />`;
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 = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
Anyone with this link can add a copy of “<strong>${escapeHtml(name)}</strong>” to their playlists.
</p>
<input id="sharedPlaylistUrlInput" type="text" readonly value="${escapeHtml(url)}" />
<div class="share-send">
<label class="share-send-label" for="shareSendName">Or send it straight to someone</label>
<div class="share-send-row">
<input id="shareSendName" type="text" autocomplete="off" autocapitalize="none"
spellcheck="false" placeholder="Their username" maxlength="40" />
<button id="shareSendBtn" class="btn" type="button">Send</button>
</div>
<div id="shareSendMsg" class="share-send-msg"></div>
</div>`;
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
? `<strong>${escapeHtml(fromName)}</strong> sent you this playlist.<br />`
: '';
const ok = await new Promise((resolve) => {
const body = document.createElement('div');
body.innerHTML = `
<p style="margin:0;color:var(--text-2);font-size:13px;line-height:1.5">
${senderLine}
Add “<strong>${escapeHtml(shared.name)}</strong>” (${count} video${count === 1 ? '' : 's'}) to your playlists?
This playlist is added alongside your existing playlists and nothing is replaced.
</p>`;
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 = `
<p style="margin:0;color:var(--text-2);font-size:13px;line-height:1.5">
This link opens the online profile <strong>${escapeHtml(name)}</strong>.
Loading it replaces the playlists, history and settings on this device,
and future changes here sync to that profile.
</p>`;
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 = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
Pick a name (3–40 characters: letters, digits, - or _).
<strong>The name is the key</strong> — anyone who knows it can load this
profile on their device, so use something hard to guess or go random.
</p>
<input id="profileNameInput" type="text" placeholder="e.g. crimson-falcon-8317" autocomplete="off" />`;
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 = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
${prot ? 'Change or remove the protection on' : 'Protect'} <strong>${escapeHtml(data.profile.name)}</strong>.
Once protected, the name alone no longer opens it — a device also needs the
password, the profile key, or the credentials file. <strong>If you lose all of
them the profile can't be opened.</strong>
</p>
<input id="profilePwInput" type="password" autocomplete="new-password" placeholder="New password (8+ characters)" style="margin-bottom:8px" />
<input id="profilePwInput2" type="password" autocomplete="new-password" placeholder="Repeat the password" />`;
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 = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
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.
</p>
<input id="profileNameInput" type="text" placeholder="profile name" autocomplete="off" />`;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
const ICON_PLAY = '<svg viewBox="0 0 24 24" aria-hidden="true" class="fill"><path d="M9 5.5v13l10-6.5z"/></svg>';
const ICON_PAUSE = '<svg viewBox="0 0 24 24" aria-hidden="true" class="fill"><path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z"/></svg>';
// 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 ? '<path d="M16 9l5 6M21 9l-5 6"/>' : lvl === 1 ? '<path d="M15.5 9.5a4 4 0 0 1 0 5"/>' : '<path d="M15.5 9.5a4 4 0 0 1 0 5M18.5 6.5a8 8 0 0 1 0 11"/>';
b.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5z" class="spk"/>${waves}</svg>`;
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';
const desiredLayout=s.layout || 'classic';
if(root.dataset.layout!==desiredLayout && !Lazy.loaded('layout:'+desiredLayout)) {
Lazy.load('layout:'+desiredLayout).then(()=>{if((data.settings.layout||'classic')===desiredLayout)applyAppearance();}).catch(error=>toast(error.message));
} else {
root.dataset.layout=desiredLayout;
if(Lazy.layout!==desiredLayout) { Lazy.layout=desiredLayout; Lazy.reportLayout?.(); }
}
}
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(); window.DirectMedia?.publish(); })
.catch(() => {});
}
// Download a video into the permanent offline cache. Safe to call repeatedly.
async function preload(video, { quiet = false, mux = false, retry = false, directFallback = 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;
if (!directFallback && window.DirectMedia?.hasSource(id)) {
const received = await DirectMedia.obtain(id);
if (received) return;
toast('Direct transfer did not complete. Choose Use server copy in the transfer message to download through the server.');
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('⚠ ' + (window.DownloadErrors ? DownloadErrors.message(res?.error) : 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 = '<div class="dl-bar"></div>';
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.
async function openVideoEditor(source) {
try { await Lazy.load('feature:video-edit'); } catch (error) { toast(error.message); return; }
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 = `
<p class="ve-intro">Mark the parts to <strong>remove</strong>. Everything else is kept and saved as a new offline video.</p>
<div class="ve-scrubber-track">
<div class="ve-scrubber-bar"></div>
<div class="ve-marker ve-marker-a">A</div>
<div class="ve-marker ve-marker-b">B</div>
</div>
<div class="ve-add-row">
<label>From <input type="text" class="ve-from" placeholder="0:30" inputmode="numeric" /></label>
<label>To <input type="text" class="ve-to" placeholder="1:15" inputmode="numeric" /></label>
<button type="button" class="btn ve-add">Add cut</button>
</div>
<div class="ve-move-row" style="margin-top:10px">
<button type="button" class="btn ve-move">Move video to last clicked marker</button>
</div>
<div class="ve-error" hidden></div>
<div class="ve-cuts"></div>
<label class="ve-title-row">Name <input type="text" class="ve-title" maxlength="120" /></label>
<div class="ve-summary"></div>`;
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 = `<span>✂ ${VideoEdit.fmtTime(cut.start)} – ${VideoEdit.fmtTime(cut.end)}</span>`;
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: <strong>${VideoEdit.fmtTime(finalLen)}</strong> 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 <audio> for background play
_wantsPlaying: false, // tracks user/app *intent* to be playing, independent
// of what the underlying <audio>/<video> elements
// report — iOS Safari (esp. PWA/standalone on iPhone)
// can silently pause a background audio element on
// screen-lock or app-switch without ever pausing the
// still-visible muted video, and without always
// firing a 'pause' event we react to. Comparing
// intent against actual element state is how we
// detect and recover from that.
get soundEl() {
return this.mode === 'dual' ? els.audio : this.master;
},
async loadVideo(videoObj, { preferStream = false, resume = true, reveal = true, nocache = false } = {}) {
perfMark('ytp:tap');
if (!this._handoff) Transition.cancel();
// When false (auto-advance / prev), skip resuming the saved timestamp and
// start from the beginning (or the A marker, if an A-B loop is set).
this._resumeOnLoad = resume;
// When false (auto-advance / prev), don't scroll the portrait view back
// to the player — the user may be browsing another page while listening.
this._revealOnLoad = reveal;
// Monotonic load token: any await below may resolve after the user has
// already started a different video — stale loads must stand down
// instead of hijacking playback (longest window: save-before-playing).
const seq = (this._loadSeq = (this._loadSeq || 0) + 1);
showSpinner(true);
setHomeOverPlayer(false);
els.placeholder.classList.add('hidden');
// Revoke any previous OPFS blob URL to free memory
if (_currentBlobUrl) {
if (window.OPFS) window.OPFS.revokeUrl(_currentBlobUrl);
else URL.revokeObjectURL(_currentBlobUrl);
_currentBlobUrl = null;
}
try {
if (WEB && !cachedIds.has(videoObj.id) && window.DirectMedia?.hasSource(videoObj.id)) {
const received = await DirectMedia.obtain(videoObj.id);
if (seq !== this._loadSeq) return;
if (!received) { showSpinner(false); return; }
}
// Play from the offline cache when available — instant and works offline.
if (!preferStream && cachedIds.has(videoObj.id)) {
let localUrl = null;
try {
if (TAURI) {
const st = await API.cacheStatus(videoObj.id);
if (st && st.ok && st.cached) localUrl = toAssetUrl(st.path);
} else if (WEB && window.OPFS) {
localUrl = await window.OPFS.getFileUrl(videoObj.id);
if (localUrl) _currentBlobUrl = localUrl;
}
} catch { /* fall through to streaming */ }
if (localUrl) {
const eqAudioUrl = await useEqRender(videoObj.id);
if (seq !== this._loadSeq) return;
current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl, eqAudioUrl };
this.afterLoad();
this.fallbackQueue = []; this.fbIndex = 0;
this.attach(null);
return;
}
}
// Custom (edited) videos exist ONLY in the offline cache — there is no
// YouTube stream to fall back to. If the cached file is missing (e.g.
// cleared, or synced from another device that never had the media),
// surface a clear error instead of trying to stream a fake video id.
if (videoObj.custom) {
throw new Error('This edited video isn’t available offline on this device.');
}
// Opt-in "Save before playing" (Settings → Playback, off by default):
// download the server-compiled single file into the offline cache
// first, then play the local copy — one data stream instead of the
// dual video+audio streams, and the video is kept for offline reuse.
// Any failure falls through to normal streaming playback.
if (!preferStream && data.settings.saveBeforePlay && !data.settings.audioOnly &&
WEB && window.OPFS && window.OPFS.isSupported() && !cachedIds.has(videoObj.id)) {
await preload(videoObj, { mux: true });
if (seq !== this._loadSeq) return;
if (cachedIds.has(videoObj.id)) {
const localUrl = await window.OPFS.getFileUrl(videoObj.id);
if (seq !== this._loadSeq) return;
if (localUrl) {
_currentBlobUrl = localUrl;
const eqAudioUrl = await useEqRender(videoObj.id);
current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl, eqAudioUrl };
this.afterLoad();
this.fallbackQueue = []; this.fbIndex = 0;
this.attach(null);
return;
}
}
}
const res = await API.getStreams(videoObj.id, { nocache });
if (seq !== this._loadSeq) return; // a newer load took over meanwhile
if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams');
current = res.data;
if (current.serverCached && current.meta?.id) knownServerCachedIds.add(current.meta.id);
// Merge richer metadata we may already have from the list card.
current.meta = { ...videoObj, ...current.meta };
this.afterLoad();
const q = chooseQuality();
this.buildFallbackQueue(q);
this.attach(q);
} catch (err) {
showSpinner(false);
toast('⚠ ' + err.message);
// Show retry button in the player pane
const retry = els.playerPane.querySelector('.retry-btn');
if (retry) retry.remove();
const btn = document.createElement('button');
btn.className = 'retry-btn';
btn.textContent = '↻ Retry';
btn.addEventListener('click', () => {
els.playerPane.querySelectorAll('.retry-btn').forEach((b) => b.remove());
Player.loadVideo(videoObj, { preferStream, resume, reveal, nocache });
});
els.playerPane.appendChild(btn);
offerPeerDownload(videoObj);
}
},
// Shared UI updates once `current` is populated (cached or streamed).
afterLoad() {
addToHistory(current.meta);
Notes.load(current.meta);
Wave.load(current.meta);
Level.onTrack(current.meta);
Gestures.reset();
Party.afterLoad();
Remote.changed();
buildQualityMenu();
els.playerPane.classList.remove('empty');
els.controls.classList.remove('hidden');
els.nowMeta.classList.remove('hidden');
els.npTitle.textContent = current.meta.title;
els.npChannel.textContent = '';
{ const name = document.createElement('span');
name.className = 'np-ch-name';
name.textContent = current.meta.channel || '';
els.npChannel.appendChild(name);
const linkable = !!channelKeyOf(current.meta);
els.npChannel.classList.toggle('link', linkable);
if (linkable) { els.npChannel.setAttribute('role', 'link'); els.npChannel.tabIndex = 0; els.npChannel.title = 'Open this channel'; }
else { els.npChannel.removeAttribute('role'); els.npChannel.removeAttribute('tabindex'); } }
updateNowPlayingActions();
markPlayingCard();
showMiniBar();
updateServiceMode();
// Media Session API — OS media keys + lock screen
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: current.meta.title || '',
artist: current.meta.channel || '',
artwork: current.meta.thumbnail ? [{ src: current.meta.thumbnail, sizes: '480x360', type: 'image/jpeg' }] : [],
});
navigator.mediaSession.setActionHandler('play', () => Player.play());
navigator.mediaSession.setActionHandler('pause', () => Player.pause());
navigator.mediaSession.setActionHandler('previoustrack', () => playPrev());
navigator.mediaSession.setActionHandler('nexttrack', () => playNext());
navigator.mediaSession.setActionHandler('seekbackward', (d) => Player.seek(Math.max(0, Player.master.currentTime - (d.seekOffset || 10))));
navigator.mediaSession.setActionHandler('seekforward', (d) => Player.seek(Player.master.currentTime + (d.seekOffset || 10)));
}
// In portrait PWA mode, scroll the player into view so it's immediately
// visible without the user having to swipe up manually — but only for
// user-picked tracks; auto-advance must not hijack the scroll position.
if (this._revealOnLoad) scrollPlayerIntoViewPortrait();
// Restore A-B markers and load related (non-blocking)
restoreAbMarkers();
updateRememberPosUI();
loadRelated();
exitSelectMode();
// Restore saved playback position — only when this song's "remember
// position" flag is on (per-playlist copy first, global fallback), and
// never during a "play in full" session.
// On auto-advance (or prev/next) we do NOT resume the previous timestamp —
// a freshly selected track should start at the beginning, or at the A point
// when an A-B loop is set for it.
const id = current.meta.id;
if (playFullMode || this._handoff) {
// Play-in-full ignores resume positions and A markers alike; a
// crossfade/gapless handoff continues from the bridge's position.
} else if (this._resumeOnLoad && rememberPosEnabled() && data.resumePositions[id] && data.resumePositions[id] > 1) {
const saved = data.resumePositions[id];
const restore = () => {
Player.seek(saved);
toast('Resumed from ' + fmtTime(saved));
Player.master.removeEventListener('canplay', restore);
Player.master.removeEventListener('loadedmetadata', restore);
};
Player.master.addEventListener('canplay', restore, { once: true });
Player.master.addEventListener('loadedmetadata', restore, { once: true });
} else if (abA !== null && abA > 1) {
// Not resuming, but an A marker exists — start the loop from A.
const seekToA = () => {
Player.seek(abA);
Player.master.removeEventListener('canplay', seekToA);
Player.master.removeEventListener('loadedmetadata', seekToA);
};
Player.master.addEventListener('canplay', seekToA, { once: true });
Player.master.addEventListener('loadedmetadata', seekToA, { once: true });
}
},
// Ordered list of qualities to try if playback errors out. Adaptive
// (video-only + separate audio via /api/play) goes near the front: those
// streams proxy reliably, whereas progressive single-file formats (itag 18
// and friends) are increasingly SABR-gated and 403 even server-side, so they
// now go LAST. We then walk from the lowest resolution up.
fallbackQueue: [],
fbIndex: 0,
buildFallbackQueue(chosen) {
const qs = (current.qualities || []).slice().sort((a, b) => a.height - b.height);
const seen = new Set();
const queue = [];
const push = (x) => { if (x && !seen.has(x.label)) { seen.add(x.label); queue.push(x); } };
push(chosen);
qs.filter((q) => !q.hasAudio).forEach(push); // adaptive = sturdiest (proxied)
qs.forEach(push); // then everything, incl. progressive
this.fallbackQueue = queue;
this.fbIndex = 0;
},
onMediaError(el) {
// A cached file failed to play — fall back to live streaming.
if (current && current.localUrl) {
// An error from the PREVIOUS track's blob (revoked by the load that
// replaced it) must not condemn the new track's good copy.
if (el && el.src && el.src !== current.localUrl) return;
const meta = current.meta;
current.localUrl = null;
dropBrokenLocalCopy(meta);
toast('Saved copy won’t play here — streaming and re-saving it…', { duration: 4000 });
// Preserve the resume intent of the load that just failed.
this.loadVideo(meta, { preferStream: true, resume: this._resumeOnLoad, reveal: this._revealOnLoad });
return;
}
// The server's cached copy failed on this device — stream from YouTube
// instead. Never auto-reported: a decode failure can be specific to one
// device, so the ⚠ Broken button is how a user flags a bad server copy.
if (current && current.serverCached) {
const meta = current.meta;
// An HEVC copy that won't play means canPlayType over-promised on this
// device: stop asking for HEVC here (the server re-checks its copy).
if ((current.qualities || []).some((q) => q.codec === 'hevc')) {
try { localStorage.setItem(NO_HEVC_KEY, '1'); } catch { /* storage blocked */ }
}
current.serverCached = false;
toast('Server copy failed — streaming from YouTube. Tap ⚠ Broken to re-download it.', { duration: 5000 });
this.loadVideo(meta, { preferStream: true, nocache: true, resume: this._resumeOnLoad, reveal: this._revealOnLoad });
return;
}
// Advance to the next candidate stream; give up with a clear message at the end.
if (this.fbIndex < this.fallbackQueue.length - 1) {
this.fbIndex++;
const next = this.fallbackQueue[this.fbIndex];
toast('Playback hiccup — trying ' + next.label + '…');
this.attach(next);
} else {
showSpinner(false);
toast('⚠ This video couldn’t be played. Try another.');
}
},
// Use one uninterrupted audio decoder on iPhone from the first play. Locking
// then promotes that existing clock; it never loads/seeks a new sound source.
prepareBackgroundAudio() {
if (!IS_IOS || this.mode !== 'progressive') return;
const source = AudioContinuity.progressiveSource(current, els.video.getAttribute('src'));
if (!source) return;
this.mode = 'dual';
this.secondary = els.audio;
els.video.muted = true;
els.audio.src = source;
els.audio.load();
},
attach(quality) {
const V = els.video, A = els.audio;
// An uploaded audio file has no picture track: play it like audio-only and
// show its cover art where the video would be.
// iOS won't start a <video> while the app is in the background (screen
// locked, app switched). A track that starts there — auto-advance, the
// lock-screen Next button — plays as audio and gets its picture back when
// the app returns (restoreFromBackgroundAudio).
const bgStart = IS_IOS && document.visibilityState === 'hidden' && !data.settings.audioOnly;
this._bgAudioMode = bgStart;
const audioOnly = data.settings.audioOnly || (current && current.kind === 'audio') || bgStart;
// Reset
audioContinuity.reset(V); audioContinuity.reset(A);
V.pause(); A.pause();
this.stopDrift();
this._bgHandoff = false;
if (current.localUrl && current.eqAudioUrl && !audioOnly) {
// iPhone: the saved video's picture (muted) + its rendered EQ/levelled
// sound, kept in sync like an adaptive stream.
this.mode = 'dual';
this.master = V;
this.secondary = A;
V.muted = true;
V.src = current.localUrl;
A.src = current.eqAudioUrl;
els.art.classList.add('hidden');
this.applyVolume();
this.applySpeed();
this.master.load();
this.secondary.load();
const onReadyEq = () => { this.master.removeEventListener('canplay', onReadyEq); this._startPlayback(); };
this.master.addEventListener('canplay', onReadyEq);
this.startDrift();
return;
}
if (current.localUrl) {
// Cached single file (carries its own audio). Play it directly.
if (audioOnly) {
this.mode = 'audio';
this.master = A;
this.secondary = null;
A.src = current.eqAudioUrl || current.localUrl;
V.removeAttribute('src'); V.load();
els.art.classList.remove('hidden');
els.artImg.src = current.meta.thumbnail || '';
} else {
this.mode = 'progressive';
this.master = V;
this.secondary = null;
V.muted = false;
V.src = current.localUrl;
A.removeAttribute('src'); A.load();
els.art.classList.add('hidden');
}
this.prepareBackgroundAudio();
this.applyVolume();
this.applySpeed();
this.master.load();
const onReadyLocal = () => { this.master.removeEventListener('canplay', onReadyLocal); this._startPlayback(); };
this.master.addEventListener('canplay', onReadyLocal);
// Cached/offline playback is just as susceptible to a silently-paused
// background audio element as streamed playback (audio-only mode still
// routes through <audio>), so it needs the same watchdog.
this.startDrift();
return;
}
if (audioOnly) {
// Play only audio; show cover art.
this.mode = 'audio';
this.master = A;
this.secondary = null;
A.src = current.audioUrl || (quality && quality.url) || '';
V.removeAttribute('src'); V.load();
els.art.classList.remove('hidden');
els.artImg.src = current.meta.thumbnail || '';
} else if (quality && quality.hasAudio) {
// Progressive single stream (video already carries audio).
this.mode = 'progressive';
this.master = V;
this.secondary = null;
V.muted = false;
V.src = quality.url;
A.removeAttribute('src'); A.load();
els.art.classList.add('hidden');
} else if (quality && current.audioUrl) {
// Adaptive: muted video + synced audio for high quality without muxing.
this.mode = 'dual';
this.master = V;
this.secondary = A;
V.muted = true;
V.src = quality.url;
A.src = current.audioUrl;
els.art.classList.add('hidden');
} else {
showSpinner(false);
toast('No playable stream found for this video.');
return;
}
this.prepareBackgroundAudio();
this.applyVolume();
this.applySpeed();
this.master.load();
if (this.secondary) this.secondary.load();
const onReady = () => {
this.master.removeEventListener('canplay', onReady);
this._startPlayback();
};
this.master.addEventListener('canplay', onReady);
this.startDrift();
},
// Start the freshly attached track. A crossfade/gapless handoff (see
// Transition) has the next song already sounding on a bridge <audio>; pick
// up from the bridge's position and release it once we are really playing.
_handoff: null,
_startPlayback() {
if (this._resumeAt != null) {
const t = this._resumeAt, paused = this._resumePaused;
this._resumeAt = null; this._resumePaused = false;
try { if (t > 0.05) this.seek(t); } catch { /* not seekable yet */ }
if (!paused) this.play();
return;
}
const h = this._handoff;
this._handoff = null;
if (!h) { this.play(); return; }
try { const t = h.at(); if (t > 0.05) this.seek(t); } catch { /* bridge gone */ }
this.play();
let done = false;
const release = () => {
if (done) return;
done = true;
this.master.removeEventListener('playing', release);
h.release();
};
this.master.addEventListener('playing', release);
setTimeout(release, 5000); // never leave two songs playing
},
play() {
this._wantsPlaying = true;
audioContinuity.play(this.master);
audioContinuity.resume(this.secondary, this.master);
},
pause() {
this._wantsPlaying = false;
this.clearBufferGrace();
audioContinuity.reset(this.master);
if (this.secondary) audioContinuity.reset(this.secondary);
this.master.pause();
if (this.secondary) this.secondary.pause();
},
toggle() {
if (this.master.paused) this.play(); else this.pause();
},
seek(t) {
this.master.currentTime = t;
if (this.secondary) this.secondary.currentTime = t;
},
applyVolume() {
const v = data.settings.volume;
this.soundEl.volume = v;
this.soundEl.muted = v === 0;
// make sure the non-sound elements stay muted
if (this.mode === 'dual') els.video.muted = true;
},
applySpeed() {
const r = parseFloat(els.speed.value) || 1;
this.master.playbackRate = r;
if (this.secondary) this.secondary.playbackRate = r;
},
// Drift-correction thresholds for dual mode (muted video + synced audio).
// A hard `currentTime` jump on a *playing* <audio> element causes an
// audible pop/glitch — every earlier version of the watchdog snapped audio
// back into sync any time drift exceeded 0.3s, which is exactly what read
// as "audio stutter" (video stayed smooth because it was never touched).
// We now use a tiered response instead of one hard threshold:
// < SOFT_SYNC_THRESHOLD — inaudible, ignore (avoids constant micro-corrections)
// < HARD_SYNC_THRESHOLD — gently slew playbackRate so the audio eases back
// into sync over ~1s with no audible artifact
// >= HARD_SYNC_THRESHOLD — snap (only reached from seeks, buffer flushes,
// or background-resume, where a single correction
// is unavoidable and rare)
SOFT_SYNC_THRESHOLD: 0.08,
HARD_SYNC_THRESHOLD: 0.3,
SLEW_RATE: 0.04, // playbackRate offset applied while easing back into sync
SNAP_COOLDOWN_MS: 3000, // after a hard snap, only slew for this long
_seekLag: 0.25, // seconds the last video seek took (measured), used as the snap lead
// Seek the muted video to where the sounding clock WILL be once the seek lands,
// measuring how long seeks take on this device so the lead adapts.
snapVideoTo(V, clock) {
const rate = (clock && clock.playbackRate) || 1;
const lead = clock && !clock.paused ? Math.min(1.5, this._seekLag) * rate : 0;
const target = ((clock && clock.currentTime) || 0) + lead;
const started = performance.now();
this._internalSeek = true;
this._lastSnapAt = Date.now();
V.addEventListener('seeked', () => {
const took = (performance.now() - started) / 1000;
this._seekLag = Math.max(0.05, Math.min(1.5, this._seekLag * 0.5 + took * 0.5));
}, { once: true });
V.currentTime = target;
},
// Shared by the 1s watchdog tick and the master 'playing' handler so both
// paths get the same no-pop behavior.
//
// AUDIO IS THE CLOCK. Corrections are applied to the muted <video>, never
// to the playing <audio>: seeking or rate-shifting the silent element is
// invisible, while the same operation on the sounding element is exactly
// the "stutter" users hear. On a phone that can't sustain the video
// bitrate, the video rebuffers in a loop — under the old master-is-clock
// scheme every one of those cycles seeked the live audio (and backgrounding
// the app, which halts video decode and this timer, made playback smooth —
// the reported iPhone symptom).
correctDrift() {
if (this.mode !== 'dual' || !this.secondary || this.master.paused) return;
if (this.secondary.paused) return; // resume paths own this case
// A seek still in flight is not drift: re-snapping now restarts the seek and,
// on iPhone, loops "seek → land behind → seek again" (stutter after resuming).
if (this.master.seeking) return;
const drift = this.secondary.currentTime - this.master.currentTime;
const abs = Math.abs(drift);
if (abs >= this.HARD_SYNC_THRESHOLD && Date.now() - (this._lastSnapAt || 0) < this.SNAP_COOLDOWN_MS) {
// Just snapped: ease in instead of snapping again.
const base = parseFloat(els.speed.value) || 1;
this.master.playbackRate = drift > 0 ? base + this.SLEW_RATE * 2 : base - this.SLEW_RATE * 2;
} else if (abs >= this.HARD_SYNC_THRESHOLD) {
// Snap the muted video onto the audio clock (inaudible).
this.snapVideoTo(this.master, this.secondary);
this._resyncSpeed();
} else if (abs >= this.SOFT_SYNC_THRESHOLD) {
// Video behind audio -> speed the video up; ahead -> slow it down.
const base = parseFloat(els.speed.value) || 1;
this.master.playbackRate = drift > 0 ? base + this.SLEW_RATE : base - this.SLEW_RATE;
} else {
this._resyncSpeed();
}
},
// Restore playbackRates to the user-selected speed once drift is within
// tolerance (or after a hard snap) so a slew correction never lingers and
// overshoots.
_resyncSpeed() {
const base = parseFloat(els.speed.value) || 1;
if (this.master && this.master.playbackRate !== base) this.master.playbackRate = base;
if (this.secondary && this.secondary.playbackRate !== base) this.secondary.playbackRate = base;
},
// Runs for every mode (not just 'dual') as a watchdog: iOS Safari — most
// visibly in PWA/standalone mode on iPhone — can silently pause the element
// actually producing sound (the <audio> tag in 'dual'/'audio' mode) while
// the screen is locked or the app is backgrounded, without necessarily
// firing a 'pause' event we react to. The muted <video> element in 'dual'
// mode is unaffected and keeps rolling, which is why video looks fine while
// audio silently drops. Comparing `_wantsPlaying` (our intent) against the
// element's actual `.paused` state lets us detect and resume from that.
startDrift() {
this.stopDrift();
this.driftTimer = setInterval(() => {
if (!this._wantsPlaying) return;
// Dual-mode: keep the secondary audio track in sync with the master,
// via the tiered corrector above (no hard snap for small, normal drift).
this.correctDrift();
// The element that's actually producing sound in the current mode.
const sounder = this.soundEl;
if (sounder && sounder.paused) {
audioContinuity.resume(sounder, this.master);
}
}, 1000);
},
stopDrift() {
if (this.driftTimer) clearInterval(this.driftTimer);
this.driftTimer = null;
this.clearBufferGrace();
},
// Cancel a pending "pause synced audio after a stall" timer — used once the
// video recovers (playing/canplay), on an explicit pause, and when tearing
// down the current element (attach()/stopDrift()) so a stale timer never
// fires against a track that's already moved on.
clearBufferGrace() {
if (this.bufferGraceTimer) {
clearTimeout(this.bufferGraceTimer);
this.bufferGraceTimer = null;
}
},
// iOS suspends a backgrounded <video> outright, so in 'progressive' mode
// (where the video element IS the sound source) locking the screen kills the
// audio with nothing left playing. An <audio> element is allowed to continue,
// so hand the sound over to it on the way out and hand it back on return.
// 'dual' and 'audio' modes already play through <audio> and need no handoff.
handoffToBackgroundAudio() {
if (!this._wantsPlaying || this._bgHandoff) return;
if (this.mode === 'dual' && this.secondary === els.audio) {
// Dual mode: the sound already comes from <audio>, but the paused video
// was still the master — the clock, and the source of "ended". Make the
// audio the master so the progress, Now Playing and the next song keep
// going while the video is suspended.
this.master = els.audio;
this.secondary = null;
this._bgHandoff = 'dual';
return;
}
if (this.mode !== 'progressive') return;
const src = (current && (current.eqAudioUrl || current.localUrl || current.audioUrl)) || '';
if (!src) return; // nothing to continue with — leave playback alone
const V = els.video, A = els.audio;
try {
this._bgHandoff = true;
this.master = A;
if (A.getAttribute('src') !== src) A.src = src;
if (AudioContinuity.needsAlignment(A.currentTime, V.currentTime)) A.currentTime = V.currentTime || 0;
A.volume = V.volume;
A.muted = V.muted;
audioContinuity.play(A).then(() => {
if (this._bgHandoff && this.master === A && !A.paused) V.pause();
});
// Master ownership is set before play/pause can dispatch another handler.
} catch {
this._bgHandoff = false;
this.master = V;
}
},
// Undo handoffToBackgroundAudio, putting the video back in charge at the
// position the audio reached while the app was away.
restoreFromBackgroundAudio() {
if (document.visibilityState !== 'visible') return;
// A track that STARTED in the background is audio-only: re-attach its
// picture at the position the audio reached.
if (this._bgAudioMode && current && !data.settings.audioOnly) {
const t = (this.master && this.master.currentTime) || 0;
const wasPlaying = this._wantsPlaying;
this._bgAudioMode = false;
this._resumeAt = t;
this._resumePaused = !wasPlaying;
this.attach(current.localUrl ? null : chooseQuality());
return;
}
if (!this._bgHandoff) return;
const V = els.video, A = els.audio;
if (this._bgHandoff === 'dual') {
const t = A.currentTime || 0;
this._bgHandoff = false;
this.master = V;
this.secondary = A;
try {
if (AudioContinuity.needsAlignment(V.currentTime, t, 0.12)) this.snapVideoTo(V, A);
if (this._wantsPlaying) audioContinuity.play(V);
} catch { /* the watchdog picks up whatever is left */ }
return;
}
const t = A.currentTime || 0;
this._bgHandoff = false;
this.master = V;
try {
this.mode = 'dual';
this.secondary = A;
V.muted = true;
if (AudioContinuity.needsAlignment(V.currentTime, t, 0.12)) this.snapVideoTo(V, A);
if (this._wantsPlaying) audioContinuity.play(V);
} catch { /* the watchdog picks up whatever is left */ }
},
// Called when the page/tab regains foreground focus (screen unlock, app
// switch back). This is the single most common trigger for iOS silently
// pausing background audio, so we react immediately here instead of
// waiting up to 1s for the next startDrift() tick.
resumeIfNeeded() {
if (!this._wantsPlaying) return;
const sounder = this.soundEl;
if (sounder && sounder.paused) {
audioContinuity.resume(sounder, this.master);
}
if (this.mode === 'dual' && this.secondary && this.secondary.paused && !this.master.paused) {
audioContinuity.resume(this.secondary, this.master);
}
},
};
// Recover from iOS silently pausing background audio the moment the app
// returns to the foreground (screen unlock, switching back from another app,
// or the PWA being restored from a frozen/backgrounded state). This fires far
// faster than the 1s watchdog interval, which matters for a jarring "audio
// paused, video kept playing" UX.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
Player.restoreFromBackgroundAudio();
Player.resumeIfNeeded();
if (isServiceModeOpen()) acquireWakeLock();
} else {
// Fires while the page can still start media, which is what makes the
// handoff possible at all — once iOS has fully suspended us, play() is
// rejected.
Player.handoffToBackgroundAudio();
}
});
window.addEventListener('pagehide', () => Player.handoffToBackgroundAudio());
window.addEventListener('pageshow', () => {
Player.restoreFromBackgroundAudio();
Player.resumeIfNeeded();
if (isServiceModeOpen()) acquireWakeLock();
});
window.addEventListener('focus', () => {
Player.restoreFromBackgroundAudio();
Player.resumeIfNeeded();
if (isServiceModeOpen()) acquireWakeLock();
});
function showSpinner(on) {
els.spinner.classList.toggle('hidden', !on);
}
function chooseQuality() {
const qs = current.qualities || [];
if (!qs.length) return null;
const pref = data.settings.quality;
if (pref && pref !== 'auto') {
const match = qs.find((q) => q.label === pref);
if (match) return match;
}
// auto: best quality at or below 720p, else the lowest available.
const sorted = [...qs].sort((a, b) => b.height - a.height);
return sorted.find((q) => q.height <= 720) || sorted[sorted.length - 1];
}
function buildQualityMenu() {
const qs = current.qualities || [];
els.quality.innerHTML = '';
const auto = document.createElement('option');
auto.value = 'auto';
auto.textContent = 'Auto';
els.quality.appendChild(auto);
const seen = new Set();
for (const q of qs) {
if (seen.has(q.label)) continue;
seen.add(q.label);
const o = document.createElement('option');
o.value = q.label;
o.textContent = q.label + (q.hasAudio ? '' : '');
els.quality.appendChild(o);
}
els.quality.value = data.settings.quality || 'auto';
}
// ---------- Player media events (master mirrors to secondary) ----------
function wirePlayerEvents() {
const V = els.video, A = els.audio;
const masterIs = (el) => Player.master === el;
function bind(el) {
el.addEventListener('play', () => { if (masterIs(el)) audioContinuity.resume(Player.secondary, el); updatePlayBtn(); });
el.addEventListener('pause', () => {
// iOS paused the master video because the app went to the background
// (sometimes before visibilitychange arrives): hand the sound over now.
if (el === V && masterIs(el) && Player._wantsPlaying && document.visibilityState === 'hidden') {
Player.handoffToBackgroundAudio();
}
if (masterIs(el) && Player.secondary) {
Player.clearBufferGrace();
// ONLY a deliberate pause stops the synced audio. iOS pauses a
// backgrounded <video> by itself on screen-lock or app-switch, and
// propagating that to the audio element is what silenced playback in
// the background — the audio is allowed to keep running there, so it
// should. Player.pause() clears _wantsPlaying before pausing the
// master, so intent is what separates the two cases.
if (!Player._wantsPlaying) Player.secondary.pause();
}
updatePlayBtn();
});
el.addEventListener('seeking', () => {
if (!masterIs(el) || !Player.secondary) return;
// Drift correction seeks the muted video onto the audio clock; mirroring
// that back onto the audio would re-create the audible snap it exists to
// avoid. Only user/programmatic seeks propagate to the audio.
if (Player._internalSeek) { Player._internalSeek = false; return; }
Player.secondary.currentTime = el.currentTime;
});
// Video re-buffering must NEVER interrupt the audio: the audio element is
// the playback clock (see correctDrift). Earlier versions paused the
// synced audio when a video stall outlasted a 250ms grace window and
// snapped its clock on recovery — on a phone that can't sustain the video
// bitrate that cycle repeats indefinitely and is heard as constant
// stuttering. Now a stalling video just shows the spinner and catches up
// to the audio clock (or gets snapped forward by the watchdog) when ready.
el.addEventListener('waiting', () => {
if (masterIs(el)) showSpinner(true);
});
el.addEventListener('playing', () => {
if (!masterIs(el)) return;
if (WEB && current?.meta) window.ServerBackfill?.report(current.meta);
if (performance.getEntriesByName('ytp:tap').length) {
perfMeasure('ytp:tap-to-play', 'ytp:tap');
try { performance.clearMarks('ytp:tap'); } catch { /* ignore */ }
}
warmNextQueue();
showSpinner(false);
Player.clearBufferGrace();
if (Player.secondary && !el.paused && Player.secondary.paused) {
// Resume-only path (audio silently paused by iOS or a real stall):
// seeking a *paused* audio element is inaudible, so sync then play.
audioContinuity.resume(Player.secondary, el);
} else if (Player.secondary && !el.paused) {
// Audio kept playing through the video stall — realign the video.
Player.correctDrift();
}
});
el.addEventListener('canplay', () => { if (masterIs(el)) showSpinner(false); });
el.addEventListener('timeupdate', () => {
if (masterIs(el)) {
updateProgress();
// Persist playback position every 10s. timeupdate fires ~4×/s, so
// gate on the 10s bucket actually changing — otherwise persist()
// (full JSON.stringify + synchronous localStorage write) ran ~4
// times back-to-back within each matching second, a periodic
// main-thread stall on phones.
if (current && current.meta) {
const t = Player.master.currentTime;
const bucket = Math.floor(t / 10);
if (t > 5 && bucket !== Player._lastPersistBucket) {
Player._lastPersistBucket = bucket;
if (!playFullMode && rememberPosEnabled()) {
data.resumePositions[current.meta.id] = t;
persist();
}
}
}
}
});
el.addEventListener('pause', () => {
if (masterIs(el) && current && current.meta && !playFullMode && rememberPosEnabled()) {
const t = Player.master.currentTime;
if (t > 1) {
data.resumePositions[current.meta.id] = t;
persist();
}
}
});
el.addEventListener('loadedmetadata', () => { if (masterIs(el)) updateProgress(); });
el.addEventListener('ended', () => { if (masterIs(el)) onTrackEnded(); });
el.addEventListener('error', () => {
// A failed master stream, or a failed synced-audio track in dual mode,
// both warrant falling back to the next candidate.
if (masterIs(el) || el === Player.secondary) Player.onMediaError(el);
});
}
bind(V);
bind(A);
}
function updatePlayBtn() {
Remote.changed();
els.playBtn.innerHTML = Player.master.paused ? ICON_PLAY : ICON_PAUSE;
const sp = $('stgPlay');
if (sp) sp.innerHTML = Player.master.paused ? ICON_PLAY : ICON_PAUSE;
const stage = document.querySelector('.player-stage');
if (stage) stage.classList.toggle('is-paused', !!Player.master.paused);
$('miniPlayBtn').textContent = Player.master.paused ? '▶' : '⏸';
const spBtn = $('servicePlayBtn');
if (spBtn) spBtn.textContent = Player.master.paused ? '▶' : '⏸';
}
// "⚠ Broken": ask the server to drop its cached copy and fetch a fresh one.
// Playback switches to live YouTube streams meanwhile (resuming where it
// was), and a device copy — most likely made from the same bad file — is
// replaced: the re-save waits on the server's refetch job.
const reportingBroken = new Set();
async function reportBroken(video) {
const id = video.id;
if (!id || video.custom || reportingBroken.has(id)) return;
reportingBroken.add(id);
updateNowPlayingActions();
try {
const res = await fetch(`/api/media/${encodeURIComponent(id)}/redownload`, { method: 'POST' });
const j = await res.json().catch(() => ({}));
if (!res.ok || !j.ok) throw new Error(j.error || `HTTP ${res.status}`);
toast('Re-downloading on the server…');
const hadLocal = cachedIds.has(id);
if (hadLocal) {
try { await API.cacheDelete(id); } catch { /* best-effort */ }
cachedIds.delete(id);
markCardCacheState(id, 'none');
}
if (current && current.meta && current.meta.id === id) {
const at = Player.master ? Player.master.currentTime || 0 : 0;
await Player.loadVideo(current.meta, { preferStream: true, nocache: true, resume: false, reveal: false });
if (at > 1 && current && current.meta && current.meta.id === id) {
Player.master.addEventListener('loadedmetadata', () => Player.seek(at), { once: true });
}
}
if (hadLocal) preload(video);
} catch (err) {
toast('⚠ Could not report: ' + err.message);
} finally {
reportingBroken.delete(id);
updateNowPlayingActions();
}
}
// P2P availability under the title (docs/p2p-architecture.md flow 5).
// Cached 60 s per video so the frequent updateNowPlayingActions() calls
// don't hammer the server.
const availCache = new Map(); // videoId -> { at, payload }
async function renderAvailability(id) {
const el = $('npAvail');
if (!el) return;
const off = data.settings.p2pShare === false && data.settings.p2pReceive === false;
if (WEB && !off && navigator.onLine !== false && id && !window.P2PCore) { try { await Lazy.load('feature:p2p'); } catch {} }
if (!WEB || !window.P2PCore || off || navigator.onLine === false || String(id).startsWith('edit_')) {
el.classList.add('hidden');
return;
}
let hit = availCache.get(id);
if (!hit || Date.now() - hit.at > 60_000) {
hit = { at: Date.now(), payload: null };
availCache.set(id, hit);
try {
const r = await fetch(`/api/p2p/holders?v=${encodeURIComponent(id)}`);
hit.payload = r.ok ? await r.json() : null;
} catch { /* offline / disabled — no line */ }
if (availCache.size > 200) availCache.delete(availCache.keys().next().value);
}
if (!current || !current.meta || current.meta.id !== id) return; // moved on meanwhile
const f = window.P2PCore.formatAvailability(hit.payload, Date.now());
if (!f) { el.classList.add('hidden'); return; }
el.textContent = f.text;
el.title = f.title;
el.classList.toggle('stale', f.stale);
el.classList.remove('hidden');
}
// Fetch a verified copy from another device (docs/p2p-architecture.md flow 6).
// Download-then-play: on success the file is saved offline like any save.
async function getFromPeers(videoObj, c, peers, onProgress) {
try { await Lazy.load('feature:direct'); } catch (error) { toast(error.message); return false; }
if (window.DirectMedia && !DirectMedia.enabled()) { toast('Direct device transfer is off. Enable it in Settings or use a server copy.'); return false; }
if (window.DirectMedia?.enabled()) { const ok = await DirectMedia.obtain(videoObj.id); if (!ok) toast('Pair the source device through Remote or use the same profile to receive its saved copy.'); return !!ok; }
const id = videoObj.id;
downloading.add(id);
markCardCacheState(id, 'downloading');
updateDownloadBadge();
try {
const res = await window.P2PTransfer.download({ videoId: id, cid: c.cid, size: c.size, peers, onProgress });
if (res.ok) {
cachedIds.add(id);
cacheMutations++;
recordDeviceFile(id, { sha256: res.sha256, expectedSha: c.cid, size: res.size });
toast(`Saved "${videoObj.title || id}" from another device ✓`);
}
return res;
} finally {
downloading.delete(id);
markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none');
updateDownloadBadge();
renderSidebar();
}
}
// Offered when a video won't load: only if some device holding it is online now.
async function offerPeerDownload(videoObj) {
if (WEB && data.settings.p2pReceive !== false) { try { await Lazy.load('feature:p2p'); } catch { return; } }
if (!WEB || !window.P2PTransfer || !window.P2PCore || data.settings.p2pReceive === false) return;
const id = videoObj && videoObj.id;
if (!id || videoObj.custom || cachedIds.has(id)) return;
let payload = null;
try {
const r = await fetch(`/api/p2p/holders?v=${encodeURIComponent(id)}`);
payload = r.ok ? await r.json() : null;
} catch { return; }
const c = payload && Array.isArray(payload.cids) ? payload.cids[0] : null;
const peers = c ? window.P2PCore.pickPeers(payload, c.cid) : [];
if (!peers.length || !els.playerPane.querySelector('.retry-btn')) return; // nobody online / user moved on
const old = els.playerPane.querySelector('.peer-btn');
if (old) old.remove();
const btn = document.createElement('button');
btn.className = 'retry-btn peer-btn';
btn.textContent = `📡 Get it from a device (${peers.length} online)`;
btn.addEventListener('click', async () => {
btn.disabled = true;
const res = await getFromPeers(videoObj, c, peers, (got, total) => {
btn.textContent = `📡 ${Math.min(99, Math.round((got / total) * 100))}%…`;
});
if (res.ok) {
els.playerPane.querySelectorAll('.retry-btn').forEach((b) => b.remove());
Player.loadVideo(videoObj);
} else {
btn.disabled = false;
btn.textContent = '📡 Try again';
toast('⚠ ' + res.error);
}
});
els.playerPane.appendChild(btn);
}
// "More controls" sheet for the player deck. Toggles drive the original (hidden)
// buttons; speed and quality are mirrored selects that write back to the real
// ones. Rows marked `stay` keep the sheet open so A then B can be set in a row.
function openMoreControls() {
const toggles = [
['abABtn', '🅰', 'Loop start (A)', true],
['abBBtn', '🅱', 'Loop end (B)', true],
['abClearBtn', '↺', 'Clear A-B loop', true],
['rememberPosBtn', '📍', 'Remember position', true],
['sleepTimerBtn', '⏱', 'Sleep timer', false],
['transitionBtn', '↦', 'Between songs', true],
['eqBtn', '🎚', 'Equalizer', false],
['pipBtn', '⧉', 'Picture-in-picture', false],
];
const selects = [['speedSelect', '⏩', 'Speed'], ['qualitySelect', '🎞', 'Quality']];
const list = document.createElement('div');
list.className = 'more-list';
const draw = () => {
list.innerHTML = '';
for (const [id, icon, title] of selects) {
const src = $(id);
if (!src || !src.options.length) continue;
const row = document.createElement('label');
row.className = 'more-row';
row.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b></span>';
row.querySelector('.more-ico').textContent = icon;
row.querySelector('b').textContent = title;
const sel = document.createElement('select');
sel.className = 'more-sel';
sel.innerHTML = src.innerHTML;
sel.value = src.value;
sel.onchange = () => { src.value = sel.value; src.dispatchEvent(new Event('change', { bubbles: true })); };
row.appendChild(sel);
list.appendChild(row);
}
for (const [id, icon, title, stay] of toggles) {
const src = $(id);
if (!src || src.classList.contains('hidden') || src.hidden) continue;
const on = src.classList.contains('active') || src.classList.contains('sleep-active');
const row = document.createElement('button');
row.type = 'button';
row.className = 'more-row' + (on ? ' on' : '');
row.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b><small></small></span><span class="more-go">' + (on ? '●' : '›') + '</span>';
row.querySelector('.more-ico').textContent = icon;
row.querySelector('b').textContent = title;
row.querySelector('small').textContent = String(src.title || '').replace(/\s*\[[A-Za-z]\]\s*$/, '');
row.onclick = () => {
if (stay) { src.click(); setTimeout(draw, 60); return; }
closeModal();
setTimeout(() => src.click(), 0);
};
list.appendChild(row);
}
};
draw();
showModal('More controls', list, [{ label: 'Done', onClick: closeModal }]);
}
// "+ Add": one button, then a small choice between the queue and a playlist.
function openAddChoice() {
if (!current || !current.meta) return;
const video = current.meta;
const list = document.createElement('div');
list.className = 'more-list';
const row = (icon, title, sub, fn) => {
const r = document.createElement('button');
r.type = 'button';
r.className = 'more-row';
r.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b><small></small></span><span class="more-go">›</span>';
r.querySelector('.more-ico').textContent = icon;
r.querySelector('b').textContent = title;
r.querySelector('small').textContent = sub;
r.onclick = () => { closeModal(); setTimeout(fn, 0); };
list.appendChild(r);
};
row('▶', 'Add to queue', 'Play it next in this session', () => addToQueue(video));
row('☰', 'Add to playlist', 'Save it to one of your playlists', () => openCardMenu(video));
showModal('Add “' + (video.title || 'video').slice(0, 40) + '”', list, [{ label: 'Cancel', onClick: closeModal }]);
}
// "More actions" sheet: the everyday four stay on the page, the rest live here.
// Each row drives the original (hidden) button, so handlers and the hidden /
// disabled state those buttons already manage keep working.
function openMoreActions() {
const defs = [
['editBtn', '✂', 'Edit and download', 'Trim a clip, adjust quality and export'],
['shareMomentBtn', '🔗', 'Share moment', 'Link, GIF or soundbite of this exact second'],
['externalBtn', '📺', 'Open in…', 'VLC, MX Player or another app'],
['brokenBtn', '⚠', 'Report broken video', 'Won’t play, out of sync or glitchy — re-download it', true],
];
const list = document.createElement('div');
list.className = 'more-list';
for (const [id, icon, title, sub, danger] of defs) {
const src = $(id);
if (!src || src.hidden) continue;
const row = document.createElement('button');
row.type = 'button';
row.className = 'more-row' + (danger ? ' danger' : '');
row.disabled = !!src.disabled;
row.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b><small></small></span><span class="more-go">›</span>';
row.querySelector('.more-ico').textContent = icon;
row.querySelector('b').textContent = title;
row.querySelector('small').textContent = sub;
row.onclick = () => { closeModal(); setTimeout(() => src.click(), 0); };
list.appendChild(row);
}
if (!list.children.length) { toast('No more actions for this video'); return; }
showModal('More actions', list, [{ label: 'Close', onClick: closeModal }]);
}
// Reflect cache state on the now-playing Save button.
// Tiles are [icon][label]; only the label text changes with state.
// Smallest font (px) the sung line may be fitted to in service mode; longer lines wrap instead.
const SERVICE_LYRIC_MIN_PX = 30;
// ============================================================================
// Shared: size each lyric row (.sl-line) to the largest font that fits ONE row
// ============================================================================
// Used by service-mode lyrics and the presenter view. Measured with a hidden
// real .sl-line (same font rules) in two passes: the display font has an
// optical-size axis, so glyphs get relatively WIDER as the size drops and a
// single measurement at 100px over-fits on small screens. Sets --fit per row;
// with `uniform` every row gets the smallest fit, so all lines share one size.
function fitLyricLines(list, { maxShare = 0.17, maxPx = 160, minPx = 18, uniform = false } = {}) {
if (!list) return;
const rows = [...list.querySelectorAll('.sl-line')];
if (!rows.length || !list.clientWidth) return;
const width = list.clientWidth * 0.92 * 0.97; // 4% side padding + slack
const cap = Math.max(30, Math.min(list.clientHeight * maxShare, maxPx));
const clamp = (px) => Math.max(minPx, Math.min(cap, px));
// Text widths come from a canvas (no page layout at all) using the line's
// own font; the in-list DOM probe stays as the fallback.
const cs = getComputedStyle(rows[0]);
const ctx2d = (fitLyricLines._c || (fitLyricLines._c = document.createElement('canvas'))).getContext('2d');
let probe = null;
let widthAt;
if (ctx2d && cs.fontFamily) {
const lsEm = (parseFloat(cs.letterSpacing) || 0) / (parseFloat(cs.fontSize) || 1); // letter-spacing in em
widthAt = (px) => {
ctx2d.font = `${cs.fontStyle} ${cs.fontWeight} ${px}px ${cs.fontFamily}`;
const t = widthAt.text;
return ctx2d.measureText(t).width + lsEm * px * Math.max(0, t.length - 1);
};
} else {
probe = document.createElement('div');
probe.className = 'sl-line';
probe.style.cssText = 'position:absolute;left:0;top:0;visibility:hidden;white-space:nowrap;width:auto;padding:0;font-size:100px;transition:none';
list.appendChild(probe);
widthAt = (px) => { probe.style.fontSize = px + 'px'; probe.textContent = widthAt.text; return probe.getBoundingClientRect().width; };
}
// Measure every line first and write the sizes afterwards: writing --fit
// inside the loop dirtied the list's layout, so each following measurement
// re-laid-out every line (O(n²) — seconds on a phone for a long sermon).
const fits = [];
for (const el of rows) {
// Size a grouped cue by its widest visual line, preserving tight breaks.
const parts = (el.querySelector('.lyric-primary') || el).textContent.split('\n');
widthAt.text = parts.reduce((widest, part) => {
widthAt.text = part; const width = widthAt(100);
return width > widest.width ? { text: part, width } : widest;
}, { text: '', width: -1 }).text;
const w100 = widthAt(100);
let px = cap;
if (w100) {
const est = clamp((width / w100) * 100);
const wEst = widthAt(est);
px = Math.floor(clamp(wEst ? est * (width / wEst) : est));
}
fits.push(px);
}
if (probe) probe.remove();
const same = uniform ? Math.min(...fits) : 0;
rows.forEach((el, i) => el.style.setProperty('--fit', (uniform ? same : fits[i]) + 'px'));
}
// ============================================================================
// Lyrics · chapters · bookmarks
// ============================================================================
// Lyrics and chapters are shared per video by every user (server/notes.js
// keeps each save as a revision). Bookmarks and the sync offset are personal
// and ride along with the profile sync (data.bookmarks / data.lyricOffsets).
// The last copy seen of each video's shared notes is kept in localStorage so
// saved songs still show their lyrics offline.
const Notes = (() => {
const LC = window.LyricsCore;
const panel = $('notesPanel');
const noop = { load() {}, tick() {}, addBookmark() {}, handleSyncKey() { return false; }, serviceRefresh() {}, chapterAt() { return null; }, jumpChapter() {}, openChapterPicker() {} };
if (!panel || !LC) return noop;
const CACHE_KEY = 'ytpNotesCache';
const CACHE_MAX = 200;
const st = {
videoId: null, lyrics: null, chapters: null, tab: 'lyrics',
activeIdx: -1, lastUserScroll: 0, sync: null, loadSeq: 0,
statusMsg: '', sectionText: '', lastDur: 0, chipKey: '', addLabel: '',
};
// ---- small helpers ----
const supported = (meta) => WEB && meta && !meta.custom && isMediaId(meta.id || '');
const profileName = () => (data.profile && data.profile.name) || '';
const lines = () => (st.sync ? st.sync.lines : (st.lyrics && st.lyrics.data && st.lyrics.data.lines) || []);
// Shared chapters win; with none, YouTube's own chapters (description
// timestamps) stand in, read-only (auto: true).
const ownChapters = () => (st.chapters && st.chapters.data && st.chapters.data.items) || [];
const chapters = () => { const own = ownChapters(); return own.length ? own : (st.ytChapters || []); };
const autoChapters = () => !ownChapters().length && (st.ytChapters || []).length > 0;
const bookmarks = () => {
if (!st.videoId || !data.bookmarks) return [];
return Array.isArray(data.bookmarks[st.videoId]) ? data.bookmarks[st.videoId] : [];
};
const docOffset = () => (st.lyrics && Number(st.lyrics.data.offset)) || 0;
const personalOffset = () => (st.videoId && data.lyricOffsets && Number(data.lyricOffsets[st.videoId])) || 0;
const offset = () => docOffset() + personalOffset();
const now = () => (Player.master && Player.master.currentTime) || 0;
const round2 = (n) => Math.round(n * 100) / 100;
function readCache() {
try { return JSON.parse(localStorage.getItem(CACHE_KEY) || '{}') || {}; } catch { return {}; }
}
function writeCache(id, entry) {
try {
const c = readCache();
c[id] = { ...(c[id] || {}), ...entry, at: Date.now() };
const ids = Object.keys(c);
if (ids.length > CACHE_MAX) {
ids.sort((a, b) => (c[a].at || 0) - (c[b].at || 0))
.slice(0, ids.length - CACHE_MAX).forEach((k) => delete c[k]);
}
localStorage.setItem(CACHE_KEY, JSON.stringify(c));
} catch { /* storage full or blocked — lyrics just won't be offline */ }
}
function requireProfile() {
if (profileName()) return true;
toast('Link an online profile (Settings → Online profile) to edit shared lyrics and chapters.', { duration: 5000 });
return false;
}
function setStatus(msg) { st.statusMsg = msg || ''; renderStatus(); }
function renderStatus() {
const el = $('notesStatus');
if (el) el.textContent = st.statusMsg || st.sectionText || '';
}
// ---- loading ----
async function load(meta) {
if (st.sync && meta && meta.id === st.videoId) return; // a fallback reload mid-sync
endSync();
if (!supported(meta)) {
st.videoId = null;
st.lyrics = null;
panel.classList.add('hidden');
renderSeekMarkers();
renderService(true);
renderStage(true);
return;
}
const id = meta.id;
const seq = ++st.loadSeq;
st.videoId = id;
$('tsResults').innerHTML = '';
$('tsInfo').textContent = '';
delete $('tsSource').dataset.touched;
$('tsSource').value = 'lyrics';
st.activeIdx = -1;
st.lastDur = 0;
const cached = readCache()[id];
st.lyrics = cached ? cached.lyrics || null : null;
st.chapters = cached ? cached.chapters || null : null;
st.ytChapters = cached && Array.isArray(cached.ytChapters) ? cached.ytChapters : [];
loadYtChapters(id, seq);
panel.classList.remove('hidden');
setStatus(cached ? '' : 'Loading…');
renderAll();
try {
const res = await fetch(`/api/notes/${encodeURIComponent(id)}`);
const j = await res.json();
if (seq !== st.loadSeq) return;
if (!j || !j.ok) throw new Error((j && j.error) || 'failed');
st.lyrics = j.lyrics;
st.chapters = j.chapters;
writeCache(id, { lyrics: j.lyrics, chapters: j.chapters });
setStatus('');
if (!st.sync) renderAll();
} catch {
if (seq !== st.loadSeq) return;
setStatus(cached ? 'Offline copy' : 'Offline');
}
}
function applyNote(id, kind, note) {
writeCache(id, { [kind]: note });
if (id !== st.videoId) return;
st[kind] = note;
renderAll();
}
// ---- rendering ----
function renderAll() {
renderLyrics();
renderService(true);
renderStage(true);
renderMarkers();
renderSeekMarkers();
renderOffset();
}
function renderLyrics() {
const list = $('lyricsList');
const tagsEl = $('lyricsTags');
const ls = lines();
const tags = (st.lyrics && st.lyrics.data && st.lyrics.data.tags) || [];
tagsEl.innerHTML = tags.map((t) => `<span class="lyr-tag">${escapeHtml(t)}</span>`).join('');
tagsEl.classList.toggle('hidden', !tags.length);
$('lyricsSyncBtn').disabled = !ls.length || !!st.sync;
$('lyricsHistoryBtn').disabled = !st.lyrics;
$('lyricsEditBtn').disabled = !!st.sync;
st.activeIdx = -1;
if (!ls.length) {
list.innerHTML = `
<div class="notes-empty">
<div>No lyrics yet for this song.</div>
<div class="notes-empty-actions">
<button class="np-btn" type="button" data-act="web">🌐 Find lyrics online</button>
<button class="np-btn" type="button" data-act="paste">✎ Add lyrics</button>
${isUploadId(st.videoId) ? '' : '<button class="np-btn" type="button" data-act="captions">⤓ Import YouTube captions</button>'}
</div>
<div class="notes-empty-hint">Lyrics and chapters are shared with everyone who plays this song.</div>
</div>`;
st.sectionText = '';
renderStatus();
return;
}
list.innerHTML = ls.map((l, i) => {
const time = l.t === null || l.t === undefined ? '' : LC.fmtClock(l.t);
if (l.kind === 'section') {
return `<div class="lyr-section" data-i="${i}">${escapeHtml(l.text)}${time ? `<span class="lyr-section-time"> · ${time}</span>` : ''}</div>`;
}
if (l.kind === 'cue') {
return `<div class="lyr-cue" data-i="${i}"><span>🥁 ${escapeHtml(l.text)}</span><span class="lyr-cue-in"></span></div>`;
}
return `<div class="lyr-line${time ? '' : ' untimed'}" data-i="${i}"><span class="lyr-time">${time}</span><span class="lyr-text">${LyricsDisplay.html(l)}</span></div>`;
}).join('');
if (st.sync) renderSyncPointer();
else tick(now(), true);
paintFlags();
loadFlags();
setPanelFlagMode(sv.panelFlag && flagsEnabled());
}
function renderOffset() {
const p = personalOffset();
$('lyrOffVal').textContent = `${p > 0 ? '+' : ''}${p.toFixed(1)}s`;
$('lyrOffVal').classList.toggle('nonzero', p !== 0);
const auto = data.settings.lyricsAutoscroll !== false;
$('lyrAutoscroll').checked = auto;
}
function markerItems() {
const out = [];
chapters().forEach((c, i) => out.push({ ...c, kind: 'chapter', i }));
bookmarks().forEach((b, i) => out.push({ ...b, kind: 'bookmark', i }));
return out.sort((a, b) => a.t - b.t);
}
// YouTube's chapters arrive on their own (never blocks the notes load).
async function loadYtChapters(id, seq) {
if (isUploadId(id) || String(id).startsWith('edit_')) return;
try {
const j = await (await fetch(`/api/chapters?v=${encodeURIComponent(id)}`)).json();
if (seq !== st.loadSeq || st.videoId !== id || !j || !j.ok) return;
st.ytChapters = (j.chapters || []).map((c) => ({ t: c.t, title: c.title, auto: true }));
const entry = readCache()[id] || {};
writeCache(id, { lyrics: entry.lyrics || st.lyrics, chapters: entry.chapters || st.chapters, ytChapters: st.ytChapters });
if (!st.sync) renderAll();
} catch { /* offline: keep the cached copy */ }
}
function renderMarkers() {
const chs = chapters();
const bms = bookmarks();
const count = chs.length + bms.length;
$('markersCount').textContent = count ? String(count) : '';
$('chaptersHistoryBtn').disabled = !st.chapters;
const chips = markerItems();
$('markerChips').innerHTML = chips.map((m) => `
<button class="marker-chip ${m.kind}" type="button" data-t="${m.t}" title="${escapeHtml(m.note || m.title)}">
<span class="marker-chip-time">${LC.fmtClock(m.t)}</span> ${m.kind === 'bookmark' ? '📌 ' : ''}${escapeHtml(m.title)}
</button>`).join('');
$('markerChips').classList.toggle('hidden', !chips.length);
const row = (m, kind, i) => `
<div class="marker-row" data-kind="${kind}" data-i="${i}">
<button class="mk-time" type="button" data-act="jump" title="Jump here">${LC.fmtClock(m.t)}</button>
<div class="mk-body">
<div class="mk-title">${m.kind === 'note' ? '📝 ' : ''}${escapeHtml(m.title)}</div>
${m.note ? `<div class="mk-note${m.kind === 'note' ? ' long' : ''}">${escapeHtml(m.note)}</div>` : ''}
</div>
<button class="mk-btn" type="button" data-act="jump" title="Play from here">▶</button>
${m.auto ? '' : `<button class="mk-btn" type="button" data-act="edit" title="Edit">✎</button>
<button class="mk-btn" type="button" data-act="del" title="Delete">🗑</button>`}
</div>`;
$('chapterList').innerHTML = chs.length
? (autoChapters() ? '<div class="notes-empty small">Chapters from YouTube · + Chapter starts your own list from these</div>' : '') +
chs.map((c, i) => row(c, 'chapter', i)).join('')
: '<div class="notes-empty small">No chapters yet — tap + Chapter while the song plays.</div>';
$('bookmarkList').innerHTML = bms.length
? bms.map((b, i) => row(b, 'bookmark', i)).join('')
: '<div class="notes-empty small">No bookmarks — tap 📌 or press P.</div>';
st.chipKey = '';
}
function renderSeekMarkers() {
const host = $('seekMarkers');
if (!host) return;
const dur = (Player.master && Player.master.duration) || (current && current.meta && current.meta.duration) || 0;
st.lastDur = dur;
if (!st.videoId || !dur || !isFinite(dur)) { host.innerHTML = ''; return; }
host.innerHTML = markerItems()
.filter((m) => m.t <= dur)
.map((m) => `<span class="seek-tick ${m.kind}" style="left:${((m.t / dur) * 100).toFixed(3)}%"></span>`)
.join('');
}
// ---- playback tick (timeupdate) ----
function scrollToRow(list, el) {
const top = el.offsetTop - list.clientHeight / 2 + el.clientHeight / 2;
list.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
function tick(cur, force = false) {
tickService(cur, force);
tickStage(cur, force);
if (!st.videoId || panel.classList.contains('hidden')) return;
const dur = (Player.master && Player.master.duration) || 0;
if (dur && isFinite(dur) && Math.abs(dur - st.lastDur) > 0.5) renderSeekMarkers();
// "+ Chapter at 2:41" follows the playhead.
const label = `+ Chapter at ${LC.fmtClock(cur)}`;
if (label !== st.addLabel) { st.addLabel = label; $('addChapterBtn').textContent = label; }
// Active chip.
const active = LC.activeMarker(markerItems(), cur);
const key = active ? `${active.kind}:${active.i}` : '';
if (key !== st.chipKey) {
st.chipKey = key;
$('markerChips').querySelectorAll('.marker-chip.active').forEach((el) => el.classList.remove('active'));
if (active) {
const chip = $('markerChips').querySelector(`.marker-chip[data-t="${active.t}"]`);
if (chip) chip.classList.add('active');
}
}
if (st.sync) return;
const ls = lines();
if (!ls.length) return;
const off = offset();
const idx = LC.activeIndex(ls, cur, off);
const list = $('lyricsList');
if (idx !== st.activeIdx || force) {
const prev = list.querySelector('.lyr-line.active');
if (prev) prev.classList.remove('active');
const el = idx >= 0 ? list.querySelector(`[data-i="${idx}"]`) : null;
if (el) {
el.classList.add('active');
const auto = data.settings.lyricsAutoscroll !== false;
if (auto && st.tab === 'lyrics' && Date.now() - st.lastUserScroll > 4000) scrollToRow(list, el);
}
st.activeIdx = idx;
const sec = LC.activeSection(ls, cur, off);
st.sectionText = sec ? `${sec.text} · now` : '';
renderStatus();
}
// Band cues count down over their last minute.
list.querySelectorAll('.lyr-cue').forEach((el) => {
const l = ls[Number(el.dataset.i)];
const inEl = el.querySelector('.lyr-cue-in');
if (!l || l.t === null || l.t === undefined || !inEl) return;
const left = l.t + off - cur;
const txt = left > 0 && left <= 60 ? `in ${LC.fmtClock(Math.ceil(left))}` : '';
if (inEl.textContent !== txt) inEl.textContent = txt;
el.classList.toggle('soon', !!txt);
});
}
// ---- saving shared notes ----
async function putNote(id, kind, payload, baseRev) {
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/${kind}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: payload, baseRev: baseRev || 0, profile: profileName() }),
});
const j = await res.json().catch(() => null);
if (res.status === 409 && j && j.conflict) return { conflict: true, current: j.current };
if (!j || !j.ok) throw new Error((j && j.error) || `HTTP ${res.status}`);
return j;
}
function askConflict(kind, current) {
return new Promise((resolve) => {
const body = document.createElement('div');
const who = current && current.updatedBy ? escapeHtml(current.updatedBy) : 'Someone';
body.innerHTML = `<p class="modal-text">${who} saved a newer version of these ${kind} while you were editing.
Keep yours (theirs stays in the history) or load theirs?</p>`;
showModal('Newer version on the server', body, [
{ label: 'Load theirs', onClick: () => { closeModal(); resolve(false); } },
{ label: 'Keep mine', primary: true, onClick: () => { closeModal(); resolve(true); } },
]);
});
}
// 'saved' | 'theirs' | 'failed'
async function saveShared(kind, payload, baseRev) {
const id = st.videoId;
if (!id || !requireProfile()) return 'failed';
try {
let r = await putNote(id, kind, payload, baseRev);
if (r.conflict) {
if (!(await askConflict(kind, r.current))) {
applyNote(id, kind, r.current);
return 'theirs';
}
r = await putNote(id, kind, payload, r.current ? r.current.rev : 0);
if (r.conflict) throw new Error('someone saved again — try once more');
}
applyNote(id, kind, {
data: r.data, rev: r.rev, source: 'user', updatedBy: profileName(),
updatedAt: Math.floor(Date.now() / 1000),
});
toast(`Saved ✓ — shared with everyone`);
return 'saved';
} catch (err) {
toast('⚠ Could not save: ' + err.message, { duration: 4000 });
return 'failed';
}
}
// ---- lyrics editor ----
async function captionsText() {
const id = st.videoId;
if (!id) return null;
toast('Fetching YouTube captions…');
try {
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/captions`);
const j = await res.json().catch(() => null);
if (!j || !j.ok) throw new Error((j && j.error) || `HTTP ${res.status}`);
toast(`Imported ${j.lines.length} lines from ${j.auto ? 'auto-generated ' : ''}captions (${j.lang}) — check the words before saving.`, { duration: 5000 });
return LC.toText({ lines: j.lines.map((l) => ({ ...l, kind: 'line' })), tags: [] });
} catch (err) {
toast('⚠ ' + err.message, { duration: 4000 });
return null;
}
}
function openEditor(prefill, { importCaptions = false, note = '' } = {}) {
if (!st.videoId || !requireProfile()) return;
const baseRev = st.lyrics ? st.lyrics.rev : 0;
const body = document.createElement('div');
body.className = 'lyr-editor';
body.innerHTML = `
<p class="lyr-editor-help">One lyric line per line. Optional: <code>[1:23.45]</code> times a line
(or time them later with Tap-sync), <code># Chorus</code> is a section, <code>! Drums in</code> a band cue,
<code>@ Key G, Capo 2, 70 BPM</code> adds tags. Use Shift+Enter or Line break to group words under the same timestamp (<code>| continuation</code> in text). Pasted .lrc files work as-is.</p>
${note ? `<p class="lyr-editor-note">${escapeHtml(note)}</p>` : ''}
<textarea class="lyr-editor-text" spellcheck="false" placeholder="Paste or type the lyrics…"></textarea>
<div class="lyr-editor-tools">
<button type="button" class="np-btn" data-act="captions">⤓ Import YouTube captions</button>
<button type="button" class="np-btn" data-act="break">↵ Line break (same time)</button>
<button type="button" class="np-btn" data-act="stamp">⏱ Stamp current time on this line</button>
</div>`;
const ta = body.querySelector('textarea');
ta.value = prefill != null ? prefill : (st.lyrics ? LC.toText(st.lyrics.data) : '');
const insertBreak = () => { ta.setRangeText('\n| ', ta.selectionStart, ta.selectionEnd, 'end'); ta.focus(); };
body.querySelector('[data-act="break"]').addEventListener('click', insertBreak);
ta.addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); insertBreak(); } });
const runCaptions = async () => {
const text = await captionsText();
if (text != null) ta.value = text;
};
body.querySelector('[data-act="captions"]').addEventListener('click', runCaptions);
body.querySelector('[data-act="stamp"]').addEventListener('click', () => {
const pos = ta.selectionStart;
let start = ta.value.lastIndexOf('\n', pos - 1) + 1;
while (start > 0 && /^\s*\| /.test(ta.value.slice(start).split('\n')[0])) start = ta.value.lastIndexOf('\n', start - 2) + 1;
const endNl = ta.value.indexOf('\n', start);
const end = endNl < 0 ? ta.value.length : endNl;
const lineText = ta.value.slice(start, end).replace(/^(\[[^\]]*\]\s*)+/, '');
const stamp = `[${LC.fmtStamp(Math.max(0, now() - docOffset()))}] `;
ta.value = ta.value.slice(0, start) + stamp + lineText + ta.value.slice(end);
// Move to the next line so repeated taps walk down the sheet.
const next = ta.value.indexOf('\n', start);
let caret = next < 0 ? ta.value.length : next + 1;
while (/^\s*\| /.test(ta.value.slice(caret).split('\n')[0])) {
const following = ta.value.indexOf('\n', caret);
caret = following < 0 ? ta.value.length : following + 1;
}
ta.focus();
ta.setSelectionRange(caret, caret);
});
showModal(st.lyrics ? 'Edit lyrics' : 'Add lyrics', body, [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Save for everyone', primary: true, onClick: async () => {
const text = ta.value;
const doc = LC.fromText(text);
const payload = {
lines: doc.lines,
tags: doc.tags,
offset: doc.offset !== null ? doc.offset : docOffset(),
};
closeModal();
const r = await saveShared('lyrics', payload, baseRev);
if (r === 'failed') openEditor(text, { note: 'Not saved yet — your text is still here.' });
} },
]);
if (importCaptions) runCaptions();
else setTimeout(() => ta.focus(), 50);
}
// Look the song up on LRCLIB (published, often synced lyrics) and save it.
async function fetchWebLyrics(overwrite = false) {
const id = st.videoId;
if (!id || !requireProfile()) return;
setStatus('Looking up lyrics online…');
try {
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/lyrics/web`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify((() => {
const m = current && current.meta && current.meta.id === id ? current.meta : {};
return { profile: profileName(), overwrite, title: m.title || undefined,
artist: m.artist || m.channel || m.uploader || undefined, duration: m.duration || undefined };
})()),
});
const j = await res.json().catch(() => null);
if (!j || !j.ok) { toast('⚠ ' + ((j && j.error) || `HTTP ${res.status}`), { duration: 5000 }); setStatus(''); return; }
toast(`🌐 ${j.lines} ${j.synced ? 'timed' : 'untimed'} lines from LRCLIB (${j.match.artist} – ${j.match.track})`, { duration: 5000 });
setStatus('');
load(current && current.meta ? current.meta : { id });
} catch (err) {
toast('⚠ ' + err.message);
setStatus('');
}
}
// ---- tap-sync ----
function startSync() {
if (!st.videoId || !requireProfile()) return;
const src = (st.lyrics && st.lyrics.data && st.lyrics.data.lines) || [];
if (!src.length) return;
st.sync = {
lines: src.map((l) => ({ ...l })),
ptr: 0,
history: [],
baseRev: st.lyrics.rev,
videoId: st.videoId,
};
panel.classList.add('syncing');
$('lyricsSyncBar').classList.remove('hidden');
st.tab = 'lyrics';
showTab('lyrics');
renderLyrics();
toast('Play the song · tap Stamp line (or Enter) as each line starts', { duration: 4000 });
}
function renderSyncPointer() {
const s = st.sync;
if (!s) return;
const list = $('lyricsList');
list.querySelectorAll('.sync-ptr').forEach((el) => el.classList.remove('sync-ptr'));
const el = list.querySelector(`[data-i="${s.ptr}"]`);
if (el) { el.classList.add('sync-ptr'); scrollToRow(list, el); }
const total = s.lines.length;
const next = s.lines[s.ptr];
$('lyricsSyncHint').textContent = next
? `${s.ptr + 1}/${total} · next: “${next.text.slice(0, 60)}”`
: `All ${total} lines stamped — save when ready.`;
$('lyricsUndoBtn').disabled = !s.history.length;
}
function stamp() {
const s = st.sync;
if (!s || s.ptr >= s.lines.length) return;
s.history.push({ i: s.ptr, prev: s.lines[s.ptr].t });
s.lines[s.ptr].t = round2(Math.max(0, now() - docOffset()));
s.ptr++;
renderLyrics();
}
function undoStamp() {
const s = st.sync;
const h = s && s.history.pop();
if (!h) return;
s.lines[h.i].t = h.prev;
s.ptr = h.i;
renderLyrics();
}
function endSync() {
if (!st.sync) return;
st.sync = null;
panel.classList.remove('syncing');
$('lyricsSyncBar').classList.add('hidden');
renderLyrics();
}
async function finishSync() {
const s = st.sync;
if (!s) return;
const payload = { ...(st.lyrics ? st.lyrics.data : {}), lines: s.lines };
const r = await saveShared('lyrics', payload, s.baseRev);
if (r !== 'failed') endSync();
}
function handleSyncKey(e) {
if (!st.sync) return false;
if (e.key === 'Enter' || e.key === 't') { e.preventDefault(); stamp(); return true; }
if (e.key === 'Backspace') { e.preventDefault(); undoStamp(); return true; }
if (e.key === 'Escape') { e.preventDefault(); endSync(); return true; }
return false;
}
// ---- history (both kinds) ----
async function openHistory(kind) {
const id = st.videoId;
if (!id) return;
let revs = [];
try {
const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}/${kind}/revs`)).json();
if (!j.ok) throw new Error(j.error || 'failed');
revs = j.revs;
} catch (err) {
toast('⚠ Could not load history: ' + err.message);
return;
}
const live = st[kind] ? st[kind].rev : 0;
const body = document.createElement('div');
body.className = 'notes-history';
body.innerHTML = revs.length ? revs.map((r) => `
<div class="hist-row">
<div class="hist-main">
<span class="hist-rev">v${r.rev}</span>
<span class="hist-who">${escapeHtml(r.updatedBy || 'unknown')}</span>
<span class="hist-src">${escapeHtml(r.source)}</span>
<span class="hist-when">${new Date(r.createdAt * 1000).toLocaleString()}</span>
</div>
${r.rev === live ? '<span class="hist-live">current</span>' : `<button class="np-btn" type="button" data-rev="${r.rev}">Restore</button>`}
</div>`).join('') : '<div class="notes-empty small">No history yet.</div>';
body.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-rev]');
if (!btn) return;
try {
const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}/${kind}/revs/${btn.dataset.rev}`)).json();
if (!j.ok) throw new Error(j.error || 'failed');
closeModal();
await saveShared(kind, j.data, live);
} catch (err) {
toast('⚠ Could not restore: ' + err.message);
}
});
showModal(kind === 'lyrics' ? '🕘 Lyrics history' : '🕘 Chapters history', body, [
{ label: 'Close', primary: true, onClick: closeModal },
]);
}
// ---- chapters & bookmarks ----
// longNote: a multi-line personal note (study notes) instead of the short
// one-line note shared chapters are limited to.
function markerForm(title, init, { longNote = false, titleOptional = false } = {}) {
return new Promise((resolve) => {
const body = document.createElement('div');
body.className = 'marker-form';
body.innerHTML = `
<label>Time <input type="text" class="mf-time" inputmode="decimal" placeholder="m:ss" /></label>
<label>${titleOptional ? 'Label (optional)' : 'Title'} <input type="text" class="mf-title" maxlength="100" placeholder="${titleOptional ? 'e.g. Key idea' : 'e.g. Key change → D'}" /></label>
<label>Note ${longNote
? '<textarea class="mf-note" maxlength="2000" rows="6" placeholder="Write your note… (Ctrl/⌘+Enter saves)"></textarea>'
: '<input type="text" class="mf-note" maxlength="200" placeholder="optional" />'}</label>`;
const $t = body.querySelector('.mf-time');
const $ti = body.querySelector('.mf-title');
const $n = body.querySelector('.mf-note');
$t.value = LC.fmtClock(init.t);
$ti.value = init.title || '';
$n.value = init.note || '';
const submit = () => {
const t = LC.parseStamp($t.value);
const ttl = $ti.value.trim();
if (t === null) { toast('Time must look like 1:23'); return; }
const note = $n.value.trim();
const label = ttl || (titleOptional ? (note.split('\n')[0].slice(0, 60) || 'Note') : '');
if (!label) { toast('Give it a title'); return; }
closeModal();
resolve({ t, title: label, note });
};
body.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key !== 'Enter') return;
if (e.target.tagName === 'TEXTAREA' && !(e.ctrlKey || e.metaKey)) return; // newline
e.preventDefault();
submit();
});
showModal(title, body, [
{ label: 'Cancel', onClick: () => { closeModal(); resolve(null); } },
{ label: 'Save', primary: true, onClick: submit },
]);
setTimeout(() => (longNote && titleOptional ? $n : $ti).focus(), 50);
});
}
async function addChapter() {
if (!st.videoId || !requireProfile()) return;
const baseRev = st.chapters ? st.chapters.rev : 0;
const v = await markerForm('+ Add chapter', { t: Math.floor(now()), title: '' });
if (!v) return;
// Starting from YouTube's chapters copies them in as ordinary ones.
await saveShared('chapters', { items: [...chapters().map((c) => ({ t: c.t, title: c.title })), v] }, baseRev);
}
// Chapter picker: every chapter with its number and start time; the one
// playing is marked and scrolled into view. Tapping a row jumps there.
function openChapterPicker() {
const cs = chapters().slice().sort((a, b) => a.t - b.t);
if (!cs.length) { toast('No chapters for this video'); return; }
const cur = (Player.master && Player.master.currentTime) || 0;
let active = -1;
cs.forEach((c, i) => { if (c.t <= cur + 0.5) active = i; });
const body = document.createElement('div');
body.className = 'ch-picker';
if (autoChapters()) {
const note = document.createElement('p');
note.className = 'ch-picker-src';
note.textContent = 'Chapters from YouTube';
body.appendChild(note);
}
const list = document.createElement('div');
list.className = 'ch-picker-list';
list.setAttribute('role', 'listbox');
cs.forEach((c, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'ch-pick' + (i === active ? ' on' : '');
b.setAttribute('role', 'option');
b.setAttribute('aria-selected', String(i === active));
b.innerHTML = `<span class="ch-pick-n">${String(i + 1).padStart(2, '0')}</span><span class="ch-pick-title"></span><span class="ch-pick-t">${LC.fmtClock(c.t)}</span>`;
b.querySelector('.ch-pick-title').textContent = c.title || `Chapter ${i + 1}`;
b.onclick = () => { Player.seek(c.t); if (Player.master && Player.master.paused) Player.toggle(); closeModal(); };
list.appendChild(b);
});
body.appendChild(list);
showModal(`Chapters · ${cs.length}`, body, [{ label: 'Close', onClick: closeModal }]);
const on = list.querySelector('.ch-pick.on');
if (on) requestAnimationFrame(() => { on.scrollIntoView({ block: 'center' }); on.focus({ preventScroll: true }); });
}
// [ / ] — previous / next chapter. "Previous" restarts the current chapter
// first when more than 3 s into it, like a CD player.
function jumpChapter(dir) {
const cs = chapters().slice().sort((a, b) => a.t - b.t);
if (!cs.length || !Player.master) { toast('No chapters for this video'); return; }
const cur = Player.master.currentTime || 0;
let target = null;
if (dir > 0) target = cs.find((c) => c.t > cur + 0.5);
else {
const before = cs.filter((c) => c.t <= cur + 0.5);
const here = before[before.length - 1];
target = here && cur - here.t > 3 ? here : before[before.length - 2] || cs[0];
}
if (!target) { toast('Last chapter'); return; }
Player.seek(target.t);
toast(`${cs.indexOf(target) + 1}/${cs.length} · ${target.title}`, { duration: 1500 });
}
async function editMarker(kind, i) {
if (kind === 'chapter' && autoChapters()) { toast('YouTube’s chapters can’t be edited — + Chapter starts your own list'); return; }
const list = kind === 'chapter' ? chapters() : bookmarks();
const item = list[i];
if (!item) return;
if (kind === 'chapter' && !requireProfile()) return;
const baseRev = st.chapters ? st.chapters.rev : 0;
const v = await markerForm(kind === 'chapter' ? '✎ Edit chapter' : '✎ Edit note / bookmark', item,
kind === 'chapter' ? {} : { longNote: true, titleOptional: true });
if (!v) return;
const next = list.slice();
next[i] = v;
if (kind === 'chapter') await saveShared('chapters', { items: next }, baseRev);
else setBookmarks(next);
}
async function deleteMarker(kind, i) {
if (kind === 'chapter' && autoChapters()) { toast('YouTube’s chapters can’t be deleted — + Chapter starts your own list'); return; }
if (kind === 'chapter') {
if (!requireProfile()) return;
const next = chapters().filter((_, j) => j !== i);
await saveShared('chapters', { items: next }, st.chapters ? st.chapters.rev : 0);
} else {
setBookmarks(bookmarks().filter((_, j) => j !== i));
toast('Bookmark removed');
}
}
function setBookmarks(list) {
if (!st.videoId) return;
if (!data.bookmarks) data.bookmarks = {};
// Each entry carries its video's title/channel so the 📝 Notes page can
// list notes for videos that are no longer in history.
const meta = current && current.meta && current.meta.id === st.videoId ? current.meta : null;
if (meta) list = list.map((b) => ({ ...b, vt: b.vt || meta.title || '', vc: b.vc || meta.channel || '' }));
const sorted = list.slice().sort((a, b) => a.t - b.t);
if (sorted.length) data.bookmarks[st.videoId] = sorted;
else delete data.bookmarks[st.videoId];
persist();
renderMarkers();
renderSeekMarkers();
}
function addBookmark() {
if (!st.videoId) { toast('Bookmarks need a YouTube video'); return; }
const t = round2(now());
const n = bookmarks().length + 1;
setBookmarks([...bookmarks(), { t, title: `Bookmark ${n}`, note: '' }]);
toast(`📌 Bookmarked ${LC.fmtClock(t)} — rename it under Chapters & bookmarks`);
}
async function addNote() {
if (!st.videoId) { toast('Notes need a YouTube video'); return; }
const v = await markerForm('📝 Note at this moment', { t: round2(now()), title: '', note: '' }, { longNote: true, titleOptional: true });
if (!v) return;
setBookmarks([...bookmarks(), { ...v, kind: 'note' }]);
toast(`📝 Note saved at ${LC.fmtClock(v.t)}`);
}
function openExport() {
const chs = chapters();
const bms = bookmarks();
if (!chs.length && !bms.length) { toast('Nothing to export yet'); return; }
const title = (current && current.meta && current.meta.title) || st.videoId;
const build = (withBm) => LC.toYouTubeTimestamps(withBm ? [...chs, ...bms] : chs);
const body = document.createElement('div');
body.className = 'marker-export';
body.innerHTML = `
<p class="modal-text">YouTube timestamp format — paste into a description, setlist or chat.</p>
<label class="mx-inc"><input type="checkbox" class="mx-bm" ${chs.length ? '' : 'checked'} /> Include my bookmarks</label>
<textarea class="mx-text" readonly spellcheck="false"></textarea>`;
const ta = body.querySelector('textarea');
const inc = body.querySelector('.mx-bm');
const refresh = () => { ta.value = build(inc.checked); };
inc.addEventListener('change', refresh);
refresh();
showModal('⤓ Export chapters', body, [
{ label: 'Download .txt', onClick: () => {
const blob = new Blob([`${title}\nhttps://youtu.be/${st.videoId}\n\n${ta.value}\n`], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${String(title).replace(/[^\w\- ]+/g, '').trim().slice(0, 60) || st.videoId}-chapters.txt`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
} },
{ label: 'Copy', primary: true, onClick: async () => {
try { await navigator.clipboard.writeText(ta.value); toast('Copied ✓'); }
catch { ta.select(); toast('Select-all done — copy with Ctrl/⌘+C'); }
} },
]);
}
// ---- service mode: lyrics only ----
// The sung line sits in the middle of the screen at the largest size that
// still fits on ONE row (measured per line with a canvas), highlighted; its
// neighbours shrink and fade with distance. Auto-scroll keeps it centred.
const sv = { key: '', idx: -2, timer: null, flags: new Map(), flagsKey: '', flagMode: false, panelFlag: false, pressed: 0 };
const flagsEnabled = () => data.settings.lyricFlags !== false && !!window.FlagUI;
const serviceOn = () => isServiceModeOpen() && !!data.settings.serviceLyrics;
const serviceAuto = () => data.settings.serviceLyricsAutoscroll !== false;
const sungLines = () => lines().map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line');
// Plain (untimed) lyrics have no sung line to spotlight, so every line gets
// the same size — the largest at which the longest line still fits one row.
const plainLyrics = () => { const l = sungLines(); return l.length > 0 && l.every((x) => x.t === null || x.t === undefined); };
function applyFits() {
const list = $('serviceLyricsList');
const plain = plainLyrics();
list.classList.toggle('plain', plain);
fitLyricLines(list, { uniform: plain, minPx: SERVICE_LYRIC_MIN_PX });
}
function renderServiceButtons() {
const on = !!data.settings.serviceLyrics;
updateServiceViewBtn();
const fb = $('serviceFlagBtn');
if (fb) {
const show = on && flagsEnabled();
fb.classList.toggle('hidden', !show);
if (!show) sv.flagMode = false;
fb.setAttribute('aria-pressed', String(sv.flagMode));
$('serviceFlagHint').classList.toggle('hidden', !(show && sv.flagMode));
$('serviceLyrics').classList.toggle('flagging', show && sv.flagMode);
}
const ab = $('serviceAutoscrollBtn');
ab.classList.toggle('hidden', !on);
ab.setAttribute('aria-pressed', String(serviceAuto()));
}
function renderService(force = false) {
renderServiceButtons();
const on = serviceOn();
$('serviceMode').classList.toggle('lyrics-on', on);
$('serviceLyrics').classList.toggle('hidden', !on);
if (!on) { sv.key = ''; return; }
const list = $('serviceLyricsList');
const items = sungLines();
const key = `${st.videoId}|${st.lyrics ? st.lyrics.rev : 0}|${items.length}|${list.clientWidth}x${list.clientHeight}`;
if (key === sv.key && !force) return;
sv.key = key;
sv.idx = -2;
// Half-height spacers let the first and last line reach the centre.
const pad = Math.round(list.clientHeight / 2);
list.style.paddingTop = pad + 'px';
list.style.paddingBottom = pad + 'px';
if (!items.length) {
const title = (current && current.meta && current.meta.title) || '';
list.innerHTML = `<div class="sl-empty">${st.videoId ? 'No lyrics for this song yet' : 'Nothing playing'}${title ? `<small>${escapeHtml(title)}</small>` : ''}</div>`;
return;
}
list.innerHTML = items.map((l) =>
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${LyricsDisplay.html(l)}</div>`).join('');
paintFlags();
loadFlags();
applyFits();
tickService(now(), true);
// One more fit once the view has settled (fonts, first layout).
clearTimeout(sv.refit);
sv.refit = setTimeout(() => { if (serviceOn()) applyFits(); }, 900);
}
// ---- reporting wrong lines (per-profile setting: settings.lyricFlags) ----
function paintFlags() {
document.querySelectorAll('#serviceLyricsList .sl-line, #lyricsList .lyr-line').forEach((el) => {
const l = lines()[Number(el.dataset.i)];
const f = flagsEnabled() && l ? sv.flags.get(l.text) : null;
el.classList.toggle('flagged', !!f);
el.classList.toggle('mine', !!(f && f.mine));
if (f) el.dataset.n = f.count > 1 ? String(f.count) : ''; else delete el.dataset.n;
});
}
async function loadFlags() {
if (!flagsEnabled() || !st.videoId || !st.lyrics) return;
const key = `${st.videoId}|${st.lyrics.rev}|${profileName()}`;
if (key === sv.flagsKey) return;
sv.flagsKey = key;
const vid = st.videoId;
const got = await FlagUI.list(vid, profileName());
if (vid !== st.videoId) return; // moved on while the request was out
sv.flags = new Map(got.map((f) => [f.text, f]));
paintFlags();
}
async function openFlag(row) {
const l = row && lines()[Number(row.dataset.i)];
if (!l || !st.videoId) return;
if (!requireProfile()) return;
const vid = st.videoId;
const res = await FlagUI.open({ videoId: vid, text: l.text, existing: sv.flags.get(l.text), profile: profileName() });
if (!res) return;
sv.flagsKey = ''; // force a refresh from the server (counts, ids)
if (vid === st.videoId) await loadFlags();
toast(res.action === 'withdrawn' ? 'Report withdrawn' : 'Thanks — reported ✓');
if (res.action === 'saved') { sv.flagMode = false; renderServiceButtons(); setPanelFlagMode(false); }
}
function centreOn(list, el) {
const go = () => list.scrollTo({ top: Math.max(0, el.offsetTop - list.clientHeight / 2 + el.offsetHeight / 2), behavior: 'smooth' });
go();
// Font sizes animate, so re-centre once the line has grown.
clearTimeout(sv.timer);
sv.timer = setTimeout(go, 380);
}
function tickService(cur, force = false) {
if (!serviceOn()) return;
const list = $('serviceLyricsList');
const idx = LC.activeIndex(lines(), cur, offset());
if (idx === sv.idx && !force) return;
sv.idx = idx;
const rows = [...list.querySelectorAll('.sl-line')];
const pos = rows.findIndex((el) => Number(el.dataset.i) === idx);
rows.forEach((el, k) => {
const d = pos < 0 ? 99 : Math.abs(k - pos);
el.classList.toggle('active', d === 0);
el.classList.toggle('near', d === 1);
el.classList.toggle('past', pos >= 0 && k < pos);
});
if (serviceAuto() && rows.length) centreOn(list, pos >= 0 ? rows[pos] : rows[0]);
}
function serviceRefresh() { renderService(true); }
// ---- Lyrics in place of the video (watch party / sing-along) ------------
// Same machinery as service mode, but inside the player stage, so playback
// and party sync carry on untouched: the <video> is only hidden.
const stg = { key: '', idx: -2, timer: null, refit: null };
const stageOn = () => !!data.settings.stageLyrics;
const stageAuto = () => data.settings.stageLyricsAutoscroll !== false;
function renderStageButtons() {
const a = $('stageLyricsAuto');
if (!a) return;
a.textContent = stageAuto() ? '⇅ Auto-scroll on' : '⇅ Auto-scroll off';
a.classList.toggle('on', stageAuto());
a.setAttribute('aria-pressed', String(stageAuto()));
}
function renderStage(force = false) {
const box = $('stageLyrics');
if (!box) return;
const stage = document.querySelector('.player-stage');
const on = stageOn();
box.classList.toggle('hidden', !on);
if (stage) stage.classList.toggle('lyrics-stage', on);
if (typeof Party !== 'undefined' && Party.renderLyricsToggle) Party.renderLyricsToggle();
if (!on) { stg.key = ''; return; }
renderStageButtons();
const list = $('stageLyricsList');
const items = sungLines();
const key = `${st.videoId}|${st.lyrics ? st.lyrics.rev : 0}|${items.length}|${list.clientWidth}x${list.clientHeight}`;
if (key === stg.key && !force) return;
stg.key = key;
stg.idx = -2;
const pad = Math.round(list.clientHeight / 2);
list.style.paddingTop = pad + 'px';
list.style.paddingBottom = pad + 'px';
if (!items.length) {
const title = (current && current.meta && current.meta.title) || '';
list.innerHTML = `<div class="sl-empty">${st.videoId ? 'No lyrics for this song yet' : 'Nothing playing'}${title ? `<small>${escapeHtml(title)}</small>` : ''}</div>`;
return;
}
list.innerHTML = items.map((l) =>
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${LyricsDisplay.html(l)}</div>`).join('');
fitLyricLines(list, { maxShare: 0.15, maxPx: 96 });
tickStage(now(), true);
clearTimeout(stg.refit);
stg.refit = setTimeout(() => { if (stageOn()) fitLyricLines(list, { maxShare: 0.15, maxPx: 96 }); }, 900);
}
function tickStage(cur, force = false) {
if (!stageOn()) return;
const list = $('stageLyricsList');
if (!list) return;
const idx = LC.activeIndex(lines(), cur, offset());
if (idx === stg.idx && !force) return;
stg.idx = idx;
const rows = [...list.querySelectorAll('.sl-line')];
const pos = rows.findIndex((el) => Number(el.dataset.i) === idx);
rows.forEach((el, k) => {
const d = pos < 0 ? 99 : Math.abs(k - pos);
el.classList.toggle('active', d === 0);
el.classList.toggle('near', d === 1);
el.classList.toggle('past', pos >= 0 && k < pos);
});
if (stageAuto() && rows.length) {
const target = pos >= 0 ? rows[pos] : rows[0];
const go = () => list.scrollTo({ top: Math.max(0, target.offsetTop - list.clientHeight / 2 + target.offsetHeight / 2), behavior: 'smooth' });
go();
clearTimeout(stg.timer);
stg.timer = setTimeout(go, 380);
}
}
function setStageLyrics(on) {
data.settings.stageLyrics = !!on;
persist();
renderStage(true);
}
if ($('stageLyricsOff')) {
$('stageLyricsOff').addEventListener('click', () => setStageLyrics(false));
$('stageLyricsAuto').addEventListener('click', () => {
data.settings.stageLyricsAutoscroll = !stageAuto();
persist();
renderStageButtons();
if (stageAuto()) tickStage(now(), true);
});
// Tapping a line seeks, exactly as in service mode. A party guest seeking
// is handled by Party (it either drives the host or goes "off sync").
$('stageLyricsList').addEventListener('click', (e) => {
const row = e.target.closest('.sl-line');
const l = row && lines()[Number(row.dataset.i)];
if (l && l.t !== null && l.t !== undefined) Player.seek(Math.max(0, l.t + offset()));
});
window.addEventListener('resize', () => { if (stageOn()) renderStage(true); });
}
$('serviceAutoscrollBtn').addEventListener('click', () => {
data.settings.serviceLyricsAutoscroll = !serviceAuto();
persist();
renderServiceButtons();
if (serviceAuto()) tickService(now(), true);
});
$('serviceFlagBtn').addEventListener('click', () => {
sv.flagMode = !sv.flagMode;
renderServiceButtons();
});
{
// Tap = seek (or report, in report mode). Holding a line reports it too, so
// it works without switching modes.
const list = $('serviceLyricsList');
let timer = null, sx = 0, sy = 0;
const cancel = () => { clearTimeout(timer); timer = null; };
list.addEventListener('pointerdown', (e) => {
if (!flagsEnabled() || !e.target.closest('.sl-line')) return;
sx = e.clientX; sy = e.clientY;
const row = e.target.closest('.sl-line');
cancel();
timer = setTimeout(() => { timer = null; sv.pressed = Date.now(); if (navigator.vibrate) navigator.vibrate(12); openFlag(row); }, 550);
});
list.addEventListener('pointermove', (e) => { if (timer && Math.hypot(e.clientX - sx, e.clientY - sy) > 8) cancel(); });
['pointerup', 'pointercancel', 'pointerleave', 'scroll'].forEach((t) => list.addEventListener(t, cancel, { passive: true }));
list.addEventListener('contextmenu', (e) => { if (flagsEnabled()) e.preventDefault(); });
list.addEventListener('click', (e) => {
if (Date.now() - sv.pressed < 700) return; // that was the long-press
const row = e.target.closest('.sl-line');
if (!row) return;
if (sv.flagMode) { openFlag(row); return; }
const l = lines()[Number(row.dataset.i)];
if (l && l.t !== null && l.t !== undefined) Player.seek(Math.max(0, l.t + offset()));
});
}
window.addEventListener('resize', () => { if (serviceOn()) renderService(); });
// Sizes are measured with the web font; a measurement taken while it was
// still loading used the narrower fallback, so re-fit once fonts land.
if (document.fonts) {
document.fonts.ready.then(() => { if (serviceOn()) applyFits(); });
document.fonts.addEventListener('loadingdone', () => { if (serviceOn()) applyFits(); });
}
// ---- transcript search ----
// Lyrics (shared) or the video's YouTube captions, fetched on demand and
// kept per video. Empty query = the whole transcript, to read along.
const ts = { captions: new Map(), loading: null };
async function captionLines(id) {
if (ts.captions.has(id)) return ts.captions.get(id);
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/captions`);
const j = await res.json().catch(() => null);
if (!j || !j.ok) throw new Error((j && j.error) || `HTTP ${res.status}`);
const v = { lines: j.lines.map((l) => ({ ...l, kind: 'line' })), label: `YouTube ${j.auto ? 'auto-captions' : 'subtitles'} (${j.lang})` };
ts.captions.set(id, v);
if (ts.captions.size > 30) ts.captions.delete(ts.captions.keys().next().value);
return v;
}
async function runSearch() {
if (!st.videoId) return;
const q = $('tsQuery').value.trim().toLowerCase();
const srcSel = $('tsSource');
const haveLyrics = !!(st.lyrics && st.lyrics.data && st.lyrics.data.lines.length);
const ytOnly = !isUploadId(st.videoId);
srcSel.querySelector('option[value="captions"]').disabled = !ytOnly;
if (!ytOnly) srcSel.value = 'lyrics';
else if (!haveLyrics && srcSel.value === 'lyrics' && !srcSel.dataset.touched) srcSel.value = 'captions';
const info = $('tsInfo');
const out = $('tsResults');
let src, label, shift = 0;
if (srcSel.value === 'lyrics') {
src = haveLyrics ? st.lyrics.data.lines.filter((l) => l.kind === 'line') : [];
label = haveLyrics ? 'Shared lyrics' : 'No lyrics for this video — switch to YouTube transcript';
shift = offset();
} else {
const id = st.videoId;
info.textContent = 'Loading the YouTube transcript…';
try {
const r = await captionLines(id);
if (id !== st.videoId) return;
src = r.lines;
label = r.label;
} catch (err) {
info.textContent = `⚠ ${err.message}`;
out.innerHTML = '';
return;
}
}
const esc = (t) => escapeHtml(t);
const hits = src.filter((l) => l.t !== null && l.t !== undefined && (!q || l.text.toLowerCase().includes(q))).slice(0, 400);
info.textContent = `${label} · ${q ? `${hits.length} match${hits.length === 1 ? '' : 'es'}` : `${hits.length} lines`}`;
const mark = (text) => {
if (!q) return esc(text);
const i = text.toLowerCase().indexOf(q);
return i < 0 ? esc(text) : `${esc(text.slice(0, i))}<mark>${esc(text.slice(i, i + q.length))}</mark>${esc(text.slice(i + q.length))}`;
};
out.innerHTML = hits.map((l) => `<button class="ts-hit" type="button" data-t="${l.t + shift}"><span class="ts-time">${LC.fmtClock(l.t + shift)}</span><span>${mark(l.text)}</span></button>`).join('')
|| `<div class="notes-empty small">${q ? 'No matches.' : 'Nothing to show.'}</div>`;
}
let tsTimer = null;
$('tsQuery').addEventListener('input', () => { clearTimeout(tsTimer); tsTimer = setTimeout(runSearch, 150); });
$('tsQuery').addEventListener('keydown', (e) => e.stopPropagation());
$('tsSource').addEventListener('change', (e) => { e.target.dataset.touched = '1'; runSearch(); });
$('tsResults').addEventListener('click', (e) => {
const b = e.target.closest('.ts-hit');
if (b) { Player.seek(Math.max(0, Number(b.dataset.t))); if (Player.master.paused) Player.play(); }
});
// ---- tabs + events ----
function showTab(tab) {
st.tab = tab;
panel.querySelectorAll('.notes-tab').forEach((b) => b.classList.toggle('active', b.dataset.tab === tab));
$('lyricsPane').classList.toggle('hidden', tab !== 'lyrics');
$('markersPane').classList.toggle('hidden', tab !== 'markers');
$('searchPane').classList.toggle('hidden', tab !== 'search');
if (tab === 'search') runSearch();
if (tab === 'lyrics') tick(now(), true);
}
panel.querySelectorAll('.notes-tab').forEach((b) => b.addEventListener('click', () => showTab(b.dataset.tab)));
$('lyricsEditBtn').addEventListener('click', () => openEditor(null));
$('lyricsSyncBtn').addEventListener('click', startSync);
$('lyricsHistoryBtn').addEventListener('click', () => openHistory('lyrics'));
$('chaptersHistoryBtn').addEventListener('click', () => openHistory('chapters'));
$('lyricsStampBtn').addEventListener('click', (e) => { e.currentTarget.blur(); stamp(); });
$('lyricsUndoBtn').addEventListener('click', undoStamp);
$('lyricsSyncCancel').addEventListener('click', endSync);
$('lyricsSyncDone').addEventListener('click', finishSync);
const nudge = (d) => {
if (!st.videoId) return;
if (!data.lyricOffsets) data.lyricOffsets = {};
const v = Math.round((personalOffset() + d) * 10) / 10;
if (v) data.lyricOffsets[st.videoId] = v;
else delete data.lyricOffsets[st.videoId];
persist();
renderOffset();
tick(now(), true);
};
function setPanelFlagMode(on) {
sv.panelFlag = !!on;
const b = $('lyricsFlagBtn');
if (b) {
b.setAttribute('aria-pressed', String(sv.panelFlag));
b.classList.toggle('hidden', !flagsEnabled());
b.querySelector('span').textContent = sv.panelFlag ? 'Tap the line that is wrong…' : 'Report a wrong line';
}
$('lyricsList').classList.toggle('flagging', sv.panelFlag);
}
if ($('lyricsFlagBtn')) $('lyricsFlagBtn').addEventListener('click', () => setPanelFlagMode(!sv.panelFlag));
{
// Hold a lyric line to report it without switching modes.
const pl = $('lyricsList');
let timer = null, sx = 0, sy = 0;
const cancel = () => { clearTimeout(timer); timer = null; };
pl.addEventListener('pointerdown', (e) => {
const row = e.target.closest('.lyr-line');
if (!row || !flagsEnabled() || st.sync) return;
sx = e.clientX; sy = e.clientY; cancel();
timer = setTimeout(() => { timer = null; sv.pressed = Date.now(); if (navigator.vibrate) navigator.vibrate(12); openFlag(row); }, 550);
});
pl.addEventListener('pointermove', (e) => { if (timer && Math.hypot(e.clientX - sx, e.clientY - sy) > 8) cancel(); });
['pointerup', 'pointercancel', 'pointerleave', 'scroll'].forEach((t) => pl.addEventListener(t, cancel, { passive: true }));
}
$('lyrOffMinus').addEventListener('click', () => nudge(-0.5));
$('lyrOffPlus').addEventListener('click', () => nudge(0.5));
$('lyrAutoscroll').addEventListener('change', (e) => {
data.settings.lyricsAutoscroll = e.target.checked;
persist();
if (e.target.checked) { st.lastUserScroll = 0; tick(now(), true); }
});
const list = $('lyricsList');
['wheel', 'touchstart', 'touchmove'].forEach((ev) =>
list.addEventListener(ev, () => { st.lastUserScroll = Date.now(); }, { passive: true }));
list.addEventListener('click', (e) => {
const act = e.target.closest('[data-act]');
if (act) {
if (act.dataset.act === 'paste') openEditor('');
else if (act.dataset.act === 'captions') openEditor('', { importCaptions: true });
else if (act.dataset.act === 'web') fetchWebLyrics();
return;
}
const row = e.target.closest('[data-i]');
if (!row) return;
if (Date.now() - sv.pressed < 700) return; // that was the long-press
const i = Number(row.dataset.i);
if (st.sync) { st.sync.ptr = i; renderSyncPointer(); return; }
if (sv.panelFlag && row.classList.contains('lyr-line')) { openFlag(row); return; }
const l = lines()[i];
if (l && l.t !== null && l.t !== undefined) {
st.lastUserScroll = 0;
Player.seek(Math.max(0, l.t + offset()));
}
});
$('markerChips').addEventListener('click', (e) => {
const chip = e.target.closest('.marker-chip');
if (chip) Player.seek(Number(chip.dataset.t));
});
$('addChapterBtn').addEventListener('click', addChapter);
$('addBookmarkBtn').addEventListener('click', addBookmark);
$('addNoteBtn').addEventListener('click', addNote);
$('exportMarkersBtn').addEventListener('click', openExport);
$('markersPane').addEventListener('click', (e) => {
const btn = e.target.closest('.marker-row [data-act]');
if (!btn) return;
const row = btn.closest('.marker-row');
const kind = row.dataset.kind;
const i = Number(row.dataset.i);
const item = (kind === 'chapter' ? chapters() : bookmarks())[i];
if (btn.dataset.act === 'jump' && item) Player.seek(item.t);
else if (btn.dataset.act === 'edit') editMarker(kind, i);
else if (btn.dataset.act === 'del') deleteMarker(kind, i);
});
return {
load, tick, addBookmark, addNote, handleSyncKey, serviceRefresh, refreshFlags: () => { sv.flagsKey = ''; renderServiceButtons(); setPanelFlagMode(sv.panelFlag && flagsEnabled()); paintFlags(); loadFlags(); },
stageLyrics: { render: renderStage, set: setStageLyrics, on: stageOn },
jumpChapter,
openChapterPicker,
repeatSection: () => { const cs = chapters().filter(c => c.t <= now()).sort((a, b) => a.t - b.t); Player.seek(abA ?? cs.at(-1)?.t ?? 0); },
chapterAt: (t) => { const c = chapters().slice().sort((a, b) => a.t - b.t); let k = -1; c.forEach((x, i) => { if (x.t <= t) k = i; }); return k < 0 ? null : { n: k + 1, of: c.length, title: c[k].title || '' }; },
};
})();
// ---- Glass Stage layout: stage overlay, 10 s skips, quick-setting chips ----
(function glassStage() {
const skip = (d) => { try { Player.seek(Math.max(0, Player.master.currentTime + d)); } catch { /* not ready */ } };
const on = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('click', fn); };
on('back10Btn', () => skip(-10)); on('fwd10Btn', () => skip(10));
on('upnextClear', () => clearQueue());
on('stgBack', () => skip(-10)); on('stgFwd', () => skip(10));
on('stgPlay', () => $('playBtn').click());
const setAudio = (v) => { const t = $('audioOnlyToggle'); if (t && t.checked !== v) { t.checked = v; t.dispatchEvent(new Event('change', { bubbles: true })); } };
on('stgVideo', () => setAudio(false)); on('stgAudio', () => setAudio(true));
const stage = document.querySelector('.player-stage');
let hideT = null;
if (stage) stage.addEventListener('click', (e) => {
if (e.target.closest('.stg-overlay button')) return;
stage.classList.add('stg-show');
clearTimeout(hideT);
hideT = setTimeout(() => stage.classList.remove('stg-show'), 3000);
});
function chips() {
if (document.documentElement.dataset.layout !== 'glass-stage') return;
const box = $('deckChips');
if (!box) return;
const after = (id) => { const t = String(($(id) || {}).title || ''); const m = t.match(/:\s*([^—(]+?)\s*(?:—|\(|$)/); return m ? m[1].trim() : ''; };
const list = [['Speed ' + (($('speedSelect') || {}).value || '1') + '×', true]];
const eq = after('eqBtn'); if (eq) list.push(['EQ: ' + eq, false]);
const tr = after('transitionBtn'); if (tr) list.push([tr === 'Normal' ? 'Gapless: off' : tr, false]);
const pip = $('pipBtn'); if (pip && !pip.classList.contains('hidden')) list.push(['PiP ready', false]);
const key = list.map((x) => x[0]).join('|');
if (box.dataset.key !== key) {
box.dataset.key = key;
box.innerHTML = list.map(([t, a]) => `<button type="button" class="dchip${a ? ' on' : ''}"></button>`).join('');
[...box.children].forEach((b, i) => { b.textContent = list[i][0]; b.onclick = openMoreControls; });
}
const ch = $('chBadge');
if (ch) {
const c = Notes.chapterAt ? Notes.chapterAt((Player.master && Player.master.currentTime) || 0) : null;
ch.hidden = !c;
if (c) ch.textContent = `CH ${String(c.n).padStart(2, '0')}: ${String(c.title).toUpperCase().slice(0, 18)}`;
}
const V = $('video');
const zoom = $('stgZoom'), buf = $('stgBuf');
if (zoom && V) { const m = /scale\(([\d.]+)\)/.exec(V.style.transform || ''); zoom.textContent = 'Pinch ' + (m ? Number(m[1]).toFixed(1) : '1.0') + '×'; }
if (buf) {
const M = Player.master || V;
if (current && current.localUrl) {
// A device copy is all here — buffering progress means nothing.
buf.textContent = 'Saved on device';
} else if (M && M.duration && M.buffered && M.buffered.length) {
// The range around the playhead (the last range jumps to wherever
// the user last seeked), shown as how far ahead playback is covered.
const t = M.currentTime || 0;
let end = 0;
for (let i = 0; i < M.buffered.length; i++) {
if (M.buffered.start(i) <= t + 0.5 && M.buffered.end(i) >= t) { end = M.buffered.end(i); break; }
}
buf.textContent = end >= M.duration - 1 ? 'Fully buffered'
: end > t ? 'Buffered ' + Math.round((end / M.duration) * 100) + '%' : 'Buffering…';
}
}
const q = $('qualitySelect'), sq = $('stgQuality');
if (sq && q && q.options.length) sq.textContent = q.options[q.selectedIndex] ? q.options[q.selectedIndex].text : 'Auto';
const ao = !!data.settings.audioOnly;
const v = $('stgVideo'), a = $('stgAudio');
if (v) v.classList.toggle('on', !ao); if (a) a.classList.toggle('on', ao);
}
VisibleTimer.setIntervalWhenVisible(chips, 1500, { immediate: true });
})();
// ============================================================================
// Phone remote (server/remote.js relays both ends over /ws/remote)
// ============================================================================
// Host = this tab plays to the TV / speakers and obeys a paired phone.
// Remote = this phone drives a host. Hosting survives reloads (the secret and
// "enabled" flag live in localStorage), so a TV PC that restarts its browser
// keeps its phones paired.
/* Remote facade */
const Remote = {
...Lazy.proxy('feature:remote-client', ['openChooser', 'openHostModal'], 'Remote'),
changed: () => window.YT?.Remote?.changed(),
boot() {
if (!WEB) return;
$('navRemoteBtn').addEventListener('click', () => { closeSidebar(); Remote.openChooser().catch(error => toast(error.message)); });
$('remoteChip').addEventListener('click', () => Remote.openHostModal().catch(error => toast(error.message)));
Lazy.intent($('navRemoteBtn'), 'feature:remote-client');
Lazy.intent($('remoteChip'), 'feature:remote-client');
let configured = false;
try { configured = !!JSON.parse(localStorage.getItem('ytpRemoteHost') || 'null')?.enabled; } catch {}
if (configured || new URLSearchParams(location.search).has('pair')) Lazy.load('feature:remote-client').catch(error => toast(error.message));
},
};
Lazy.onLoad('feature:remote-client', () => window.YT.Remote.boot());
/* End remote facade */
// ============================================================================
// Playlist transitions: normal · gapless · crossfade
// ============================================================================
// A hidden bridge <audio> preloads the NEXT song's audio ~25 s before the end.
// Gapless starts it the instant the current song ends; crossfade starts it
// `crossfadeSec` early and ramps the two volumes. Then the queue advances as
// usual and the main player, once loaded, picks up at the bridge's position
// (Player._startPlayback) and the bridge is released — so the switch never
// has the load-and-buffer silence of a normal advance.
// iOS ignores element.volume, so there crossfade degrades to gapless.
const TRANSITION_MODES = ['off', 'gapless', 'crossfade'];
const Transition = (() => {
const bridge = new Audio();
bridge.preload = 'auto';
bridge.setAttribute('playsinline', '');
const st = { forId: null, nextId: null, src: '', ready: false, phase: 'idle', ramp: null, gapTimer: null, blob: null, preparing: false };
const volumeWorks = (() => {
try { const a = new Audio(); a.volume = 0.5; return Math.abs(a.volume - 0.5) < 0.01; } catch { return false; }
})();
const mode = () => {
const m = data.settings.transition || 'off';
return m === 'crossfade' && !volumeWorks ? 'gapless' : m;
};
const fadeSec = () => Math.max(2, Math.min(12, Number(data.settings.crossfadeSec) || 6));
function nextIndex() {
if (queueIndex >= 0 && queueIndex < queue.length - 1) return queueIndex + 1;
if (!playFullMode && data.settings.repeatMode === 'all' && queue.length > 1) return 0;
return -1;
}
function eligible() {
if (mode() === 'off' || !current || !current.meta) return false;
if (!(queueSource.startsWith('playlist:') || queueSource === 'queue')) return false;
if (data.settings.loopOne && !playFullMode) return false;
if (abA !== null || abB !== null) return false;
if (sleepAtTrackEnd) return false; // the sleep timer stops after this song
if (Party.guestActive()) return false; // party guests follow the host
if (Player.master.paused || Player._handoff) return false;
return nextIndex() >= 0;
}
function clearBridge() {
clearInterval(st.ramp);
clearTimeout(st.gapTimer);
st.ramp = null;
st.gapTimer = null;
try { bridge.pause(); } catch { /* nothing loaded */ }
bridge.removeAttribute('src');
try { bridge.load(); } catch { /* fine */ }
if (st.blob) { try { if (window.OPFS) window.OPFS.revokeUrl(st.blob); else URL.revokeObjectURL(st.blob); } catch { /* gone */ } st.blob = null; }
st.forId = st.nextId = null;
st.src = '';
st.ready = false;
st.preparing = false;
}
function cancel() {
if (st.phase === 'handoff') return; // the new track is taking over
if (st.phase === 'fading') Player.applyVolume();
st.phase = 'idle';
clearBridge();
}
// Audio for the next song: the offline copy if this device has one, else
// the same stream resolution the player uses (server copy or proxy).
async function audioUrlFor(v) {
if (v.custom || cachedIds.has(v.id)) {
if (WEB && window.OPFS) {
const u = await window.OPFS.getFileUrl(v.id);
if (u) { st.blob = u; return u; }
}
if (v.custom) return '';
}
const res = await API.getStreams(v.id, {});
if (!res || !res.ok || !res.data) return '';
const d = res.data;
if (d.audioUrl) return d.audioUrl;
const prog = (d.qualities || []).find((q) => q.hasAudio);
return prog ? prog.url : '';
}
async function prepare() {
const i = nextIndex();
const v = queue[i];
if (!v || st.preparing) return;
st.preparing = true;
st.forId = current.meta.id;
st.nextId = v.id;
try {
const url = await audioUrlFor(v);
if (!url || st.forId !== (current && current.meta && current.meta.id)) { st.preparing = false; return; }
st.src = url;
bridge.src = url;
bridge.volume = 0;
bridge.muted = false;
bridge.playbackRate = parseFloat(els.speed.value) || 1;
bridge.load();
bridge.addEventListener('canplay', () => { st.ready = true; }, { once: true });
} catch { /* stays not ready → a normal advance happens */ }
st.preparing = false;
}
function startBridge(volume) {
try { bridge.currentTime = 0; } catch { /* not seekable yet */ }
bridge.volume = volume;
return bridge.play().catch(() => false);
}
// Advance the queue with the bridge carrying the sound.
function handoff() {
clearInterval(st.ramp);
st.ramp = null;
st.phase = 'handoff';
const h = {
at: () => bridge.currentTime || 0,
release: () => {
if (Player._handoff === h) Player._handoff = null;
st.phase = 'idle';
// A short fade-out hides any overlap while the main element settles.
const v0 = bridge.volume;
let k = 0;
const t = setInterval(() => {
k++;
try { bridge.volume = Math.max(0, v0 * (1 - k / 4)); } catch { /* fine */ }
if (k >= 4) { clearInterval(t); clearBridge(); }
}, 40);
},
};
Player._handoff = h;
// Safety: if the next track never starts, don't leave the bridge playing.
setTimeout(() => { if (Player._handoff === h) h.release(); }, 20000);
Player.soundEl.volume = data.settings.volume ?? 1;
if (!advanceQueue()) { Player._handoff = null; st.phase = 'idle'; clearBridge(); }
}
function startCrossfade(remaining) {
st.phase = 'fading';
const target = data.settings.volume ?? 1;
const total = Math.max(0.5, remaining);
const t0 = performance.now();
startBridge(0);
st.ramp = setInterval(() => {
const p = Math.min(1, (performance.now() - t0) / 1000 / total);
try { bridge.volume = target * p; } catch { /* fine */ }
try { Player.soundEl.volume = target * (1 - p); } catch { /* fine */ }
if (p >= 1 || Player.master.ended) handoff();
}, 50);
}
function startGapless(remaining) {
st.phase = 'gap-armed';
const rate = Player.master.playbackRate || 1;
st.gapTimer = setTimeout(() => {
startBridge(data.settings.volume ?? 1);
Player.master.pause();
handoff();
}, Math.max(0, (remaining / rate) * 1000 - 40));
}
// Called on every timeupdate (~4×/s) from updateProgress.
function tick(cur, dur) {
if (st.phase === 'handoff') return;
// The final timeupdate arrives with the element already paused by its own
// end — that is the crossfade finishing, not the user pausing.
if (st.phase === 'fading' && (Player.master.ended || (dur && cur >= dur - 0.25))) { handoff(); return; }
if (!eligible() || !dur || !isFinite(dur)) {
if (st.phase === 'fading' || st.phase === 'gap-armed') cancel();
else if (st.forId && (!current || st.forId !== current.meta.id)) cancel();
return;
}
if (st.forId && st.forId !== current.meta.id) clearBridge();
const remaining = dur - cur;
const lead = mode() === 'crossfade' ? fadeSec() : 0;
if (!st.forId && remaining < lead + 25) prepare();
if (st.phase !== 'idle' || !st.ready) return;
if (mode() === 'crossfade' && remaining <= lead) startCrossfade(remaining);
else if (mode() === 'gapless' && remaining <= 0.6) startGapless(remaining);
}
function label(m = mode()) {
return m === 'crossfade' ? `Crossfade (${fadeSec()} s)` : m === 'gapless' ? 'Gapless' : 'Normal';
}
function renderButton() {
const btn = $('transitionBtn');
if (!btn) return;
const m = data.settings.transition || 'off';
btn.textContent = m === 'crossfade' ? '⤨' : m === 'gapless' ? '⇥' : '↦';
btn.classList.toggle('active', m !== 'off');
btn.title = `Between songs in a playlist: ${label(m === 'crossfade' && !volumeWorks ? 'gapless' : m)} — click to change`;
}
function cycle() {
const cur = data.settings.transition || 'off';
const next = TRANSITION_MODES[(TRANSITION_MODES.indexOf(cur) + 1) % TRANSITION_MODES.length];
data.settings.transition = next;
persist();
cancel();
renderButton();
const text = next === 'crossfade'
? (volumeWorks ? `⤨ Playback mode: Crossfade — songs in a playlist blend over ${fadeSec()} s` : '⤨ Crossfade needs volume control this device lacks — playing gapless instead')
: next === 'gapless' ? '⇥ Playback mode: Gapless — the next song starts the instant this one ends'
: '↦ Playback mode: Normal — a short pause while the next song loads';
toast(text, { duration: 6000 });
}
// Read-only snapshot for diagnostics and tests.
const state = () => ({ phase: st.phase, ready: st.ready, nextId: st.nextId, bridgeT: bridge.currentTime, bridgePaused: bridge.paused, bridgeVol: bridge.volume });
// The song ended: during a crossfade the fade timer can lose the race with
// 'ended' (the fade is timed to finish exactly then) — hand off now instead
// of letting a normal advance restart the next song from 0 and kill the
// bridge. Returns true when a transition owns this end.
function onEnded() {
if (st.phase === 'fading') { handoff(); return true; }
return st.phase === 'handoff' || st.phase === 'gap-armed';
}
return { tick, cancel, cycle, renderButton, busy: () => st.phase === 'handoff' || st.phase === 'gap-armed', onEnded, mode, label, state };
})();
// ============================================================================
// Waveform seek bar (audio-only playback)
// ============================================================================
// Loudness envelope from GET /api/media/:id/peaks (server-cached songs only),
// drawn as bars behind the (now transparent) range input, so tap/drag/keyboard
// seeking are the same control as before. Offline, envelopes seen earlier come
// from localStorage.
const Wave = (() => {
const wrap = document.querySelector('.seek-wrap');
const canvas = $('waveCanvas');
if (!wrap || !canvas) return { load() {}, draw() {}, refresh() {} };
const ctx = canvas.getContext('2d');
const CACHE_KEY = 'ytpPeaks';
const st = { id: null, peaks: null, seq: 0, hoverX: null, lastKey: '' };
const readCache = () => { try { return JSON.parse(localStorage.getItem(CACHE_KEY) || '{}') || {}; } catch { return {}; } };
function writeCache(id, peaks) {
try {
const c = readCache();
c[id] = { p: peaks.map((v) => String.fromCharCode(48 + Math.round(v * 0.74))).join(''), at: Date.now() };
const ids = Object.keys(c);
if (ids.length > 80) ids.sort((a, b) => c[a].at - c[b].at).slice(0, ids.length - 80).forEach((k) => delete c[k]);
localStorage.setItem(CACHE_KEY, JSON.stringify(c));
} catch { /* storage full */ }
}
const fromCache = (id) => {
const e = readCache()[id];
return e && e.p ? [...e.p].map((ch) => (ch.charCodeAt(0) - 48) / 0.74) : null;
};
const active = () => !!(st.peaks && data.settings.audioOnly && current && current.meta && current.meta.id === st.id);
async function load(meta) {
const seq = ++st.seq;
st.id = meta && meta.id;
st.peaks = null;
refresh();
if (!WEB || !st.id || meta.custom || !isMediaId(st.id)) return;
const cached = fromCache(st.id);
if (cached) { st.peaks = cached; refresh(); }
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(`/api/media/${encodeURIComponent(st.id)}/peaks`);
if (seq !== st.seq) return;
if (res.status === 503) { await new Promise((r) => setTimeout(r, 1500)); continue; }
const j = await res.json();
if (!j.ok || !Array.isArray(j.peaks)) return; // not cached on the server → plain seek bar
st.peaks = j.peaks;
writeCache(st.id, j.peaks);
refresh();
return;
} catch { return; } // offline — keep the cached envelope if any
}
}
function loudest() {
if (!st.peaks) return null;
// Loudest ~5 s window, not a single spike.
const n = st.peaks.length, w = Math.max(1, Math.round(n / 60));
let best = -1, at = 0;
for (let i = 0; i + w <= n; i++) {
let s = 0;
for (let k = i; k < i + w; k++) s += st.peaks[k];
if (s > best) { best = s; at = i; }
}
return at / n;
}
function refresh() {
const on = active();
wrap.classList.toggle('wave-on', on);
$('waveLoud').classList.toggle('hidden', !on);
st.lastKey = '';
if (on) draw();
}
function draw() {
if (!active()) return;
const dur = Player.master.duration || (current.meta && current.meta.duration) || 0;
const cur = Player.master.currentTime || 0;
const p = dur ? Math.min(1, cur / dur) : 0;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth, h = canvas.clientHeight;
const key = `${w}x${h}|${Math.round(p * 1000)}|${st.hoverX}`;
if (key === st.lastKey) return;
st.lastKey = key;
if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const cs = getComputedStyle(document.documentElement);
const played = cs.getPropertyValue('--accent').trim() || '#ff4b32';
const rest = cs.getPropertyValue('--line').trim() || '#333';
const hover = cs.getPropertyValue('--text-dim').trim() || '#777';
const barW = 3, gap = 2;
const bars = Math.max(20, Math.floor(w / (barW + gap)));
const n = st.peaks.length;
for (let b = 0; b < bars; b++) {
const a = Math.floor((b / bars) * n), z = Math.max(a + 1, Math.floor(((b + 1) / bars) * n));
let v = 0;
for (let k = a; k < z; k++) v = Math.max(v, st.peaks[k]);
const bh = Math.max(2, (v / 100) * (h - 4));
const x = b * (barW + gap);
const frac = (b + 0.5) / bars;
ctx.fillStyle = frac <= p ? played : (st.hoverX !== null && x <= st.hoverX ? hover : rest);
ctx.fillRect(x, (h - bh) / 2, barW, bh);
}
// Playhead
ctx.fillStyle = cs.getPropertyValue('--text').trim() || '#fff';
ctx.fillRect(Math.round(p * w) - 1, 0, 2, h);
}
// Hover tooltip with the time under the pointer.
const tip = $('waveTip');
wrap.addEventListener('pointermove', (e) => {
if (!active()) return;
const r = canvas.getBoundingClientRect();
const x = Math.max(0, Math.min(r.width, e.clientX - r.left));
st.hoverX = x;
const dur = Player.master.duration || current.meta.duration || 0;
tip.textContent = fmtTime((x / r.width) * dur);
tip.style.left = `${x}px`;
tip.classList.remove('hidden');
draw();
});
wrap.addEventListener('pointerleave', () => { st.hoverX = null; tip.classList.add('hidden'); draw(); });
$('waveLoud').addEventListener('click', () => {
const f = loudest();
const dur = Player.master.duration || current.meta.duration || 0;
if (f !== null && dur) Player.seek(f * dur);
});
window.addEventListener('resize', () => { st.lastKey = ''; draw(); });
return { load, draw, refresh };
})();
// Retry searches discovered from IndexedDB, including searches made offline.
// Store query keys rather than duplicating the full result sets in localStorage.
const CatalogSync = (() => {
const key = 'ytpCatalogPending';
let pending = new Set();
try { pending = new Set(JSON.parse(localStorage.getItem(key) || '[]')); } catch { /* empty */ }
const busy = new Set();
const save = () => { try { localStorage.setItem(key, JSON.stringify([...pending])); } catch { /* blocked */ } };
async function send(q, cards) {
if (!WEB || busy.has(q)) return;
pending.add(q); save();
busy.add(q);
try {
await webFetch('/api/catalog/collect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cards: cards.slice(0, 500) }) });
pending.delete(q); save();
} catch { /* next online event / timer / launch retries from IndexedDB */ }
finally { busy.delete(q); }
}
async function flush() {
if (!WEB || !navigator.onLine) return;
for (const q of [...pending].slice(0, 20)) {
const rec = await SearchLibrary.get(q);
if (rec) await send(q, rec.results);
else { pending.delete(q); save(); }
}
}
window.addEventListener('online', flush);
if (WEB) VisibleTimer.setIntervalWhenVisible(flush, 60000, { immediate: true });
return { send, flush };
})();
function collectSearchMetadata(q, cards) { if (WEB) CatalogSync.send(q, cards); }
// ============================================================================
// Listening stats — tracker (data.stats, see frontend/stats-core.js)
// ============================================================================
// Counts seconds actually heard (media-time deltas while playing, so seeks and
// pauses don't count) and one play per load once 30 s have been heard; a
// looped song that restarts counts again. Written every 60 s, on each counted
// play, and when the page is hidden, so profile sync isn't spammed.
const StatsTrack = (() => {
const st = { id: null, meta: null, lastT: null, heard: 0, counted: false, pending: 0 };
function commit(play) {
if (!st.meta || (!play && st.pending < 1)) return;
data.stats = StatsCore.record(data.stats, st.meta, st.pending, play);
st.pending = 0;
persist();
}
function tick(cur) {
const meta = current && current.meta;
if (!meta || !meta.id) return;
if (!window.StatsCore) { Lazy.load('feature:stats').catch(() => {}); return; }
if (meta.id !== st.id) {
commit(false);
st.id = meta.id;
st.meta = { id: meta.id, title: meta.title, channel: meta.channel };
st.lastT = null; st.heard = 0; st.counted = false;
}
if (Player.master.paused) { st.lastT = null; return; }
if (st.lastT !== null) {
const d = cur - st.lastT;
if (d > 0 && d < 3) {
const real = d / (Player.master.playbackRate || 1);
st.pending += real;
st.heard += real;
} else if (d < -5 && cur < 5) {
st.heard = 0; st.counted = false; // looped back to the start
}
}
st.lastT = cur;
if (!st.counted && st.heard >= 30) { st.counted = true; commit(true); }
else if (st.pending >= 60) commit(false);
}
const flush = () => commit(false);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flush(); });
window.addEventListener('pagehide', flush);
return { tick, flush };
})();
// ============================================================================
// Stats view
// ============================================================================
function renderStats() {
return Lazy.proxy('feature:stats-view', ['render'], 'StatsView').render().catch(error => toast(error.message));
}
// ============================================================================
// Presenter / projector view (/?present=<code>)
// ============================================================================
// A lyrics-only display for a projector or TV that follows the screen actually
// playing. It pairs through the phone-remote relay (server/remote.js) exactly
// like a phone, but only listens: the host's state messages carry the song id,
// position, speed and lyric offset, and this view extrapolates the position
// between updates. Lyrics come from the shared /api/notes store.
const Presenter = {
async boot() {
if (!new URLSearchParams(location.search).has('present') && !LowerThird.active()) return;
return Lazy.proxy('feature:presenter', ['boot'], 'Presenter').boot();
},
active: () => !document.getElementById('presenterView')?.classList.contains('hidden'),
};
// Open a video and jump to `t` seconds once it can play (deep links, notes).
async function playVideoAt(v, t = 0) {
if (current && current.meta && current.meta.id === v.id && Player.master) {
Player.seek(t);
if (Player.master.paused) Player.play();
return;
}
await Player.loadVideo(v, { resume: false });
if (t > 0 && Player.master) {
const seekOnce = () => { Player.master.removeEventListener('canplay', seekOnce); Player.seek(t); toast(`Jumped to ${LyricsCore.fmtClock(t)}`); };
if (Player.master.readyState >= 3) seekOnce();
else Player.master.addEventListener('canplay', seekOnce);
}
}
// ============================================================================
// 📁 Library — video/audio uploaded to this server
// ============================================================================
// ============================================================================
// 📝 Notes view — every video's personal notes & bookmarks
// ============================================================================
// ============================================================================
// Timestamp sharing — deep links (/?v=ID&t=SEC) and GIFs of a moment
// ============================================================================
const Share = Lazy.proxy('feature:share-external', ['open', 'bootFromUrl'], 'Share');
// ============================================================================
// Loudness levelling (EBU R128) — every track toward -14 LUFS
// ============================================================================
// Loudness is measured on the device (loudness.js: WebAssembly K-weighting),
// from audio it already has or can get cheaply (the saved copy, or the server
// cache's small audio sidecar), then shared through /api/loudness so each song
// is measured once for everyone. The server never processes audio for this.
const Level = (() => {
const KEY = 'ytpLoudness';
const MAX_SECONDS = 12 * 60, MAX_BYTES = 60 * 1024 * 1024, RATE = 22050;
let seq = 0;
const read = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { return {}; } };
const remember = (id, lufs) => {
if (typeof EqRender !== 'undefined') setTimeout(() => EqRender.schedule(id), 0);
try {
const m = read(); m[id] = Math.round(lufs * 10) / 10;
const ids = Object.keys(m); if (ids.length > 3000) ids.slice(0, ids.length - 3000).forEach((k) => delete m[k]);
localStorage.setItem(KEY, JSON.stringify(m));
} catch { /* storage blocked */ }
};
const enabled = () => {
const v = data.settings.levelVolume;
if (v === true || v === false) return v;
return true; // on by default; on iPhone it reaches saved songs via EqRender
};
const gainFor = (lufs) => Math.max(-12, Math.min(9, Loudness.TARGET - lufs));
function apply(lufs) {
if (!enabled() || lufs == null) { EQ.setLevel(0); return; }
EQ.setLevel(gainFor(lufs));
}
async function audioBytes(meta) {
const id = meta.id;
if (cachedIds.has(id) && window.OPFS) {
const f = await window.OPFS.getFileObject(id).catch(() => null);
if (f && f.size <= MAX_BYTES * 4) return f.arrayBuffer(); // decode reads the audio track only
}
if (isUploadId(id)) return null;
const st = await fetch(`/api/media/${encodeURIComponent(id)}/status`).then((r) => r.json()).catch(() => null);
if (!st || st.status !== 'ready') return null; // stream-only: no extra YouTube traffic for this
const r = await fetch(`/api/media/${encodeURIComponent(id)}?a=1`);
if (!r.ok) return null;
const len = Number(r.headers.get('content-length')) || 0;
if (len > MAX_BYTES) return null;
return r.arrayBuffer();
}
async function measure(meta) {
if (!window.Loudness || typeof OfflineAudioContext === 'undefined' && typeof webkitOfflineAudioContext === 'undefined') return null;
if ((meta.duration || 0) > MAX_SECONDS) return null;
const buf = await audioBytes(meta);
if (!buf) return null;
const OAC = window.OfflineAudioContext || window.webkitOfflineAudioContext;
const ctx = new OAC(2, RATE, RATE);
const audio = await new Promise((res, rej) => { const p = ctx.decodeAudioData(buf, res, rej); if (p && p.then) p.then(res, rej); });
if (!audio || audio.duration > MAX_SECONDS + 5) return null;
const ch = [audio.getChannelData(0)];
if (audio.numberOfChannels > 1) ch.push(audio.getChannelData(1));
return Loudness.measureChannels(ch, audio.sampleRate);
}
async function onTrack(meta) {
const my = ++seq;
if (!meta || !meta.id || meta.custom) { apply(null); return; }
const id = meta.id;
const local = read()[id];
if (local != null) { apply(local); return; }
apply(null);
if (!enabled()) return;
try {
const j = await fetch(`/api/loudness?v=${encodeURIComponent(id)}`).then((r) => r.json());
if (my !== seq) return;
if (j && j.lufs != null) { remember(id, j.lufs); apply(j.lufs); return; }
} catch { /* offline: measure below */ }
// Unknown: measure once the track has played a few seconds, when idle.
setTimeout(() => {
if (my !== seq) return;
const run = async () => {
try {
const lufs = await measure(meta);
if (lufs == null) return;
remember(id, lufs);
fetch('/api/loudness', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, lufs }) }).catch(() => {});
if (my === seq) apply(lufs);
} catch { /* undecodable — leave it at 0 dB */ }
};
if (window.requestIdleCallback) requestIdleCallback(run, { timeout: 4000 }); else run();
}, 6000);
}
return { onTrack, enabled, apply, gainFor, remembered: (id) => read()[id] };
})();
// ============================================================================
// iPhone: EQ + levelling rendered into saved songs
// ============================================================================
// iOS stops Web Audio when the screen locks, so live processing can't reach a
// locked phone. For songs saved on the device, the same chain as the live EQ
// (5 bands → level gain → limiter) is rendered offline into a WAV copy in OPFS
// (eq-renders/<id>.wav, 32 kHz stereo). Playback then takes the picture from
// the saved video (muted) and the sound from that copy — plain media playback,
// so it keeps going when locked. One render at a time, in the background.
const EqRender = (() => {
const DIR = 'eq-renders', KEY = 'ytpEqRenders', RATE = 32000, MAX_SECONDS = 12 * 60;
const active = () => EQ.isIOS() && WEB && !!window.OPFS && window.OPFS.isSupported() && typeof OfflineAudioContext !== 'undefined';
const readMap = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { return {}; } };
const writeMap = (m) => { try { localStorage.setItem(KEY, JSON.stringify(m)); } catch { /* blocked */ } };
const queue = new Set();
let running = false;
// What a song's copy should carry right now, or null when the original is fine.
function wanted(id) {
const g = EQ.gains();
const lufs = Level.enabled() ? Level.remembered(id) : null;
const levelDb = lufs == null ? 0 : Math.round(Level.gainFor(lufs) * 2) / 2;
const vocal = EqParameters.amount(data.settings.vocalLevel ?? 100);
const width = EqParameters.width(data.settings.stereoWidth);
if (g.every((v) => v === 0) && Math.abs(levelDb) < 0.5 && vocal === 100 && width === 0) return null;
return { gains: g, levelDb, vocal, width, sig: g.join(',') + '|' + levelDb + '|vocal:' + vocal + '|width:' + width };
}
async function dir() { return (await navigator.storage.getDirectory()).getDirectoryHandle(DIR, { create: true }); }
async function drop(id) {
try { await (await dir()).removeEntry(id + '.wav'); } catch { /* none */ }
const m = readMap(); if (m[id]) { delete m[id]; writeMap(m); }
}
// Object URL of an up-to-date copy, or null (then the original plays as is).
async function urlFor(id) {
if (!active()) return null;
const w = wanted(id);
if (!w) return null;
if (readMap()[id] !== w.sig) { schedule(id); return null; }
try { return URL.createObjectURL(await (await (await dir()).getFileHandle(id + '.wav')).getFile()); }
catch { schedule(id); return null; }
}
function wav(buffer) {
const ch = Math.min(2, buffer.numberOfChannels), n = buffer.length, rate = buffer.sampleRate;
const out = new ArrayBuffer(44 + n * ch * 2), v = new DataView(out);
const str = (o, t) => { for (let i = 0; i < t.length; i++) v.setUint8(o + i, t.charCodeAt(i)); };
str(0, 'RIFF'); v.setUint32(4, 36 + n * ch * 2, true); str(8, 'WAVE'); str(12, 'fmt ');
v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, ch, true); v.setUint32(24, rate, true);
v.setUint32(28, rate * ch * 2, true); v.setUint16(32, ch * 2, true); v.setUint16(34, 16, true);
str(36, 'data'); v.setUint32(40, n * ch * 2, true);
const data = [buffer.getChannelData(0), buffer.getChannelData(ch > 1 ? 1 : 0)];
let o = 44;
for (let i = 0; i < n; i++) for (let c = 0; c < ch; c++) {
const x = Math.max(-1, Math.min(1, data[c][i]));
v.setInt16(o, x < 0 ? x * 0x8000 : x * 0x7fff, true); o += 2;
}
return out;
}
function writeViaWorker(name, buffer) {
return new Promise((resolve, reject) => {
const w = Lazy.worker('/opfs-worker.js');
w.onmessage = (e) => { w.terminate(); (e.data && e.data.type === 'written') ? resolve() : reject(new Error((e.data && e.data.error) || 'write failed')); };
w.onerror = () => { w.terminate(); reject(new Error('worker failed')); };
w.postMessage({ op: 'write', dir: DIR, name, buffer }, [buffer]);
});
}
async function render(id) {
if (!cachedIds.has(id)) { await drop(id); return; }
const w = wanted(id);
if (!w) { await drop(id); return; }
if (readMap()[id] === w.sig) return;
await loadSoundFeatures();
const file = await window.OPFS.getFileObject(id);
if (!file) return;
const buf = await file.arrayBuffer();
const decoded = await new Promise((res, rej) => {
// Safari's callback form and the promise form both resolve here.
const p = new OfflineAudioContext(2, RATE, RATE).decodeAudioData(buf, res, rej);
if (p && p.then) p.then(res, rej);
}).catch(() => null);
if (!decoded || decoded.duration > MAX_SECONDS) return;
const off = new OfflineAudioContext(2, decoded.length, RATE);
const src = off.createBufferSource(); src.buffer = decoded;
let node = src;
EQ.bands.forEach((b, i) => {
const f = off.createBiquadFilter(); f.type = b.type; f.frequency.value = b.f; f.Q.value = 1.0; f.gain.value = w.gains[i];
node.connect(f); node = f;
});
const gain = off.createGain(); gain.gain.value = Math.pow(10, w.levelDb / 20);
const lim = off.createDynamicsCompressor();
lim.threshold.value = -1; lim.knee.value = 0; lim.ratio.value = 20; lim.attack.value = 0.003; lim.release.value = 0.25;
const vocal = VocalReducer.create(off, node, w.vocal);
const widener = EqCore.createWidener(off, vocal.output, w.width);
widener.output.connect(gain); gain.connect(lim); lim.connect(off.destination);
src.start();
const out = await off.startRendering();
if (wanted(id)?.sig !== w.sig) return; // settings changed meanwhile — the queue re-runs it
await writeViaWorker(id + '.wav', wav(out));
const m = readMap(); m[id] = w.sig; writeMap(m);
}
async function pump() {
if (running) return;
running = true;
try {
while (queue.size) {
const id = queue.values().next().value;
queue.delete(id);
try { await render(id); } catch { /* skip this one; it is retried next time it plays */ }
await new Promise((r) => setTimeout(r, 300));
}
} finally { running = false; }
}
function schedule(id) { if (!active() || !id) return; queue.add(id); setTimeout(pump, 1500); }
// Every saved song (EQ changed); also prunes copies of songs no longer saved.
function scheduleAll() {
if (!active()) return;
cachedIds.forEach((id) => queue.add(id));
Object.keys(readMap()).forEach((id) => { if (!cachedIds.has(id)) queue.add(id); });
setTimeout(pump, 1500);
}
return { urlFor, schedule, scheduleAll, active, wav };
})();
// ============================================================================
// Picture-in-picture
// ============================================================================
// PiP floats the <video>. In dual mode the sound is on a separate <audio>, so
// the PiP window's own play/pause is mirrored onto the whole player.
const PiP = (() => {
const V = els.video;
const supported = () => (document.pictureInPictureEnabled && !V.disablePictureInPicture) || typeof V.webkitSetPresentationMode === 'function';
const inPip = () => document.pictureInPictureElement === V || V.webkitPresentationMode === 'picture-in-picture';
function render() {
const btn = $('pipBtn');
if (!btn) return;
btn.classList.toggle('hidden', !supported());
btn.classList.toggle('active', inPip());
}
async function toggle() {
if (!supported()) { toast('This browser has no picture-in-picture'); return; }
if (!current) { toast('Play a video first'); return; }
if (Player.mode === 'audio') { toast('Picture-in-picture needs video — turn off audio-only mode'); return; }
try {
if (inPip()) {
if (document.pictureInPictureElement) await document.exitPictureInPicture();
else V.webkitSetPresentationMode('inline');
} else if (V.requestPictureInPicture) {
await V.requestPictureInPicture();
} else {
V.webkitSetPresentationMode('picture-in-picture');
}
} catch (e) {
toast('⚠ Picture-in-picture: ' + e.message);
}
render();
}
V.addEventListener('pause', () => {
if (inPip() && Player.mode === 'dual' && Player._wantsPlaying && !V.ended) Player.pause();
});
V.addEventListener('play', () => {
if (inPip() && Player.mode === 'dual' && !Player._wantsPlaying) Player.play();
});
['enterpictureinpicture', 'leavepictureinpicture', 'webkitpresentationmodechanged'].forEach((ev) => V.addEventListener(ev, render));
try { navigator.mediaSession && navigator.mediaSession.setActionHandler('enterpictureinpicture', toggle); } catch { /* unsupported action */ }
return { toggle, render };
})();
// ============================================================================
// Equalizer — 10-band graphic EQ (Web Audio)
// ============================================================================
// Media elements are routed through BiquadFilters only once a non-flat curve
// is chosen (createMediaElementSource can't be undone; afterwards "Flat"
// simply zeroes the gains). Disabled on iOS: Safari suspends Web Audio when
// the screen locks, which would silence background playback.
const loadSoundFeatures=()=>Promise.all([Lazy.load('feature:eq-core'),Lazy.load('feature:vocal-reducer')]);
const EQ_BANDS = EqParameters.bands;
const EQ_PRESETS = EqParameters.presets;
const EQ = (() => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const AC = window.AudioContext || window.webkitAudioContext;
const st = { ctx: null, filters: null, sources: new Map() };
// Web Audio is off on iOS (it silences playback when the screen locks)
// unless the user opted in through "Level volume" (Settings → Playback).
// Live Web Audio is off on iOS (it silences playback when the screen locks)
// unless the user opted into "Live sound processing". Saved songs on iPhone
// get EQ + levelling rendered into a copy instead (EqRender).
const available = () => !!AC && (!isIOS || data.settings.liveAudioIOS === true);
// A saved song playing its rendered copy already carries EQ and levelling.
const rendered = () => !!(current && current.eqAudioUrl);
const gains = () => {
const g = data.settings.eq && Array.isArray(data.settings.eq.g) ? data.settings.eq.g : EQ_PRESETS.flat.g;
return EqParameters.migrate(g);
};
const isFlat = () => gains().every((v) => v === 0);
function build() {
if (st.ctx) return true;
if (!Lazy.loaded('feature:eq-core') || !Lazy.loaded('feature:vocal-reducer')) {
loadSoundFeatures().then(()=>{apply(); Level.apply();}).catch(error=>toast(error.message));
return false;
}
try {
st.ctx = new AC();
st.filters = EQ_BANDS.map((b) => {
const f = st.ctx.createBiquadFilter();
f.type = b.type;
f.frequency.value = b.f;
f.Q.value = 1.0;
return f;
});
for (let i = 0; i < st.filters.length - 1; i++) st.filters[i].connect(st.filters[i + 1]);
// Loudness levelling: gain toward -14 LUFS, then a limiter so a boosted
// quiet track can never clip.
st.level = st.ctx.createGain();
st.limiter = st.ctx.createDynamicsCompressor();
st.limiter.threshold.value = -1; st.limiter.knee.value = 0; st.limiter.ratio.value = 20;
st.limiter.attack.value = 0.003; st.limiter.release.value = 0.25;
st.vocal = VocalReducer.create(st.ctx, st.filters[st.filters.length - 1]);
st.widener = EqCore.createWidener(st.ctx, st.vocal.output);
st.widener.output.connect(st.level);
st.level.connect(st.limiter);
st.limiter.connect(st.ctx.destination);
return true;
} catch { st.ctx = null; return false; }
}
function attach(el) {
if (!el || st.sources.has(el)) return;
try {
const src = st.ctx.createMediaElementSource(el);
src.connect(st.filters[0]);
st.sources.set(el, src);
} catch { /* already connected elsewhere */ }
}
// Apply the stored curve; wires the graph up the first time it's non-flat.
function apply() {
if (!available()) return;
if (!st.ctx && isFlat() && EqParameters.amount(data.settings.vocalLevel ?? 100) === 100 && EqParameters.width(data.settings.stereoWidth) === 0) return;
if (!build()) return;
attach(els.video);
attach(els.audio);
const g = rendered() ? EQ_BANDS.map(() => 0) : gains();
st.filters.forEach((f, i) => { f.gain.value = g[i]; });
st.vocal.set(rendered() ? 100 : data.settings.vocalLevel ?? 100);
st.widener.set(rendered() ? 0 : data.settings.stereoWidth);
if (st.ctx.state === 'suspended') st.ctx.resume().catch(() => {});
renderButton();
}
function renderButton() {
const btn = $('eqBtn');
if (!btn) return;
btn.classList.toggle('active', !isFlat());
const p = data.settings.eq && data.settings.eq.preset;
btn.title = `Equalizer: ${p && EQ_PRESETS[p] ? EQ_PRESETS[p].name : isFlat() ? 'Flat' : 'Custom'} (E)`;
}
function set(preset, g) {
data.settings.eq = { preset, g };
persist();
apply();
renderButton();
if (typeof EqRender !== 'undefined') EqRender.scheduleAll();
}
async function open() {
try { await loadSoundFeatures(); } catch(error) { toast(error.message); return; }
const body = document.createElement('div');
body.className = 'eq-panel';
if (!AC) {
body.innerHTML = '<p class="modal-text">This browser has no Web Audio support.</p>';
showModal('🎚 Equalizer', body, [{ label: 'Close', primary: true, onClick: closeModal }]);
return;
}
const draw = () => {
const g = gains();
const cur = data.settings.eq && data.settings.eq.preset;
body.innerHTML = `
<div class="eq-presets">${Object.entries(EQ_PRESETS).map(([k, p]) => `<button type="button" class="sp-chip${cur === k || (!cur && k === 'flat' && isFlat()) ? ' on' : ''}" data-preset="${k}">${p.name}</button>`).join('')}</div>
<div class="eq-bands">${EQ_BANDS.map((b, i) => `
<label class="eq-band">
<span class="eq-val">${g[i] > 0 ? '+' : ''}${g[i]} dB</span>
<input type="range" min="-12" max="12" step="1" value="${g[i]}" data-band="${i}" aria-label="${b.label} Hz" />
<span class="eq-f">${b.label}</span>
</label>`).join('')}</div>
<p class="sp-note">${isIOS && !available()
? 'On iPhone the equalizer is applied to <strong>saved songs</strong> (a processed copy is made in the background, so they keep playing with the screen locked). For streamed songs, turn on Live sound processing in Settings → Playback.'
: 'Changes apply instantly. Hz bands from bass (left) to treble (right).'}</p>`;
};
draw();
body.addEventListener('click', (e) => {
const b = e.target.closest('[data-preset]');
if (!b) return;
set(b.dataset.preset, EQ_PRESETS[b.dataset.preset].g.slice());
draw();
toast(`🎚 Equalizer: ${EQ_PRESETS[b.dataset.preset].name}`);
});
body.addEventListener('input', (e) => {
const r = e.target.closest('[data-band]');
if (!r) return;
const g = gains();
g[Number(r.dataset.band)] = Number(r.value);
set('custom', g);
r.parentElement.querySelector('.eq-val').textContent = `${g[r.dataset.band] > 0 ? '+' : ''}${g[r.dataset.band]} dB`;
body.querySelectorAll('[data-preset]').forEach((x) => x.classList.remove('on'));
});
showModal('🎚 Equalizer', body, [{ label: 'Done', primary: true, onClick: closeModal }]);
}
// A context created before any user gesture starts suspended.
document.addEventListener('pointerdown', () => { if (st.ctx && st.ctx.state === 'suspended') st.ctx.resume().catch(() => {}); }, true);
// Tap the same graph for the service-mode visualizer. Wiring is the same
// one-way step the EQ takes, so it is only offered where the EQ is (never
// on iOS, where Web Audio would silence background playback).
function analyser() {
if (!available() || !build()) return null;
attach(els.video);
attach(els.audio);
if (!st.analyser) {
st.analyser = st.ctx.createAnalyser();
st.analyser.fftSize = 128;
st.analyser.smoothingTimeConstant = 0.78;
st.filters[st.filters.length - 1].connect(st.analyser);
}
if (st.ctx.state === 'suspended') st.ctx.resume().catch(() => {});
return st.analyser;
}
// Set the levelling gain (dB). 0 dB never wires the graph on its own.
function setLevel(db) {
if (!available()) return false;
if (rendered()) db = 0;
if (!st.ctx && Math.abs(db) < 0.3) return true;
if (!build()) return false;
attach(els.video);
attach(els.audio);
if (st.ctx.state === 'suspended') st.ctx.resume().catch(() => {});
const g = Math.pow(10, db / 20);
st.level.gain.setTargetAtTime(g, st.ctx.currentTime, 0.4);
return true;
}
return { open, apply, renderButton, available, analyser, setLevel, gains, bands: EQ_BANDS, isIOS: () => isIOS, state: () => ({ wired: !!st.ctx, ctx: st.ctx && st.ctx.state, gains: st.filters ? st.filters.map((f) => f.gain.value) : null,
levelDb: st.level ? Math.round(200 * Math.log10(Math.max(1e-6, st.level.gain.value))) / 10 : null }) };
})();
// ============================================================================
// Touch gestures on the video
// ============================================================================
// Double-tap: left third −10 s, right third +10 s, centre play/pause (or reset
// zoom when zoomed). Vertical swipe (fullscreen only, so inline the page still
// scrolls): right half = volume, left half = brightness (a CSS filter — web
// pages can't set the screen's backlight). Pinch = zoom 1–3× with two-finger pan.
const Gestures = (() => {
const stage = document.querySelector('.player-stage');
const hud = $('gestureHud');
if (!stage || !hud) return { reset() {} };
const V = els.video;
const pts = new Map();
const st = { lastTap: 0, lastX: 0, tapTimer: null, swipe: null, pinch: null, zoom: 1, panX: 0, panY: 0, bright: 1, hudTimer: null, moved: false };
const isFs = () => !!(document.fullscreenElement || document.webkitFullscreenElement) || document.body.classList.contains('landscape-fs');
function showHud(html) {
hud.innerHTML = html;
hud.classList.remove('hidden');
clearTimeout(st.hudTimer);
st.hudTimer = setTimeout(() => hud.classList.add('hidden'), 700);
}
const bar = (label, frac) => `<div class="gh-label">${label}</div><div class="gh-bar"><span style="width:${Math.round(frac * 100)}%"></span></div>`;
function applyView() {
V.style.transform = st.zoom > 1.001 ? `translate(${st.panX}px, ${st.panY}px) scale(${st.zoom})` : '';
V.style.filter = Math.abs(st.bright - 1) > 0.01 ? `brightness(${st.bright})` : '';
}
function reset() { st.zoom = 1; st.panX = st.panY = 0; applyView(); }
function seekBy(d, side) {
if (!current) return;
Player.seek(Math.max(0, Math.min((Player.master.duration || 1e9) - 0.5, Player.master.currentTime + d)));
showHud(`<div class="gh-seek ${side}">${d < 0 ? '⟲' : '⟳'} ${Math.abs(d)} s</div>`);
}
const point = e => window.playerFullscreen?.point(e) || FullscreenOrientation.coordinates(e.clientX, e.clientY, stage.getBoundingClientRect());
function onTap(e) {
const p = point(e);
const r = stage.getBoundingClientRect();
const x = point(e).x / point(e).width;
const now = performance.now();
if (now - st.lastTap < 320 && Math.abs(p.x - st.lastX) < 60) {
st.lastTap = 0;
if (x < 1 / 3) seekBy(-10, 'left');
else if (x > 2 / 3) seekBy(10, 'right');
else if (st.zoom > 1.001) { reset(); showHud('<div class="gh-label">Zoom reset</div>'); }
else if (current) { Player.toggle(); updatePlayBtn(); showHud(`<div class="gh-seek">${Player.master.paused ? '⏸' : '▶'}</div>`); }
return;
}
st.lastTap = now;
st.lastX = p.x;
}
stage.addEventListener('pointerdown', (e) => {
if (e.pointerType !== 'touch') return;
pts.set(e.pointerId, { x: point(e).x, y: point(e).y });
st.moved = false;
if (pts.size === 2) {
const [a, b] = [...pts.values()];
st.pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), zoom: st.zoom, cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2, panX: st.panX, panY: st.panY };
st.swipe = null;
} else if (pts.size === 1 && isFs()) {
const r = stage.getBoundingClientRect();
const right = point(e).x / point(e).width > 0.5;
st.swipe = { y0: point(e).y, right, v0: data.settings.volume ?? 1, b0: st.bright, h: point(e).height, active: false };
}
});
stage.addEventListener('pointermove', (e) => {
if (!pts.has(e.pointerId)) return;
const p = pts.get(e.pointerId);
if (Math.hypot(point(e).x - p.x, point(e).y - p.y) > 10) st.moved = true;
pts.set(e.pointerId, { ...p, cx: point(e).x, cy: point(e).y });
if (st.pinch && pts.size >= 2) {
const [a, b] = [...pts.values()].map((q) => ({ x: q.cx ?? q.x, y: q.cy ?? q.y }));
const d = Math.hypot(a.x - b.x, a.y - b.y);
st.zoom = Math.max(1, Math.min(3, st.pinch.zoom * (d / (st.pinch.d || 1))));
const cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2;
const r = stage.getBoundingClientRect();
const maxX = (point(e).width * (st.zoom - 1)) / 2, maxY = (point(e).height * (st.zoom - 1)) / 2;
st.panX = Math.max(-maxX, Math.min(maxX, st.pinch.panX + (cx - st.pinch.cx)));
st.panY = Math.max(-maxY, Math.min(maxY, st.pinch.panY + (cy - st.pinch.cy)));
applyView();
showHud(`<div class="gh-label">Zoom ${st.zoom.toFixed(1)}×</div>`);
e.preventDefault();
return;
}
const sw = st.swipe;
if (!sw) return;
const dy = sw.y0 - point(e).y;
if (!sw.active && Math.abs(dy) < 16) return;
sw.active = true;
const delta = dy / (sw.h * 0.8);
if (sw.right) {
const v = Math.max(0, Math.min(1, sw.v0 + delta));
data.settings.volume = Math.round(v * 100) / 100;
els.volume.value = String(data.settings.volume);
Player.applyVolume();
syncMuteIcon();
showHud(bar(`🔊 Volume ${Math.round(v * 100)}%`, v));
} else {
st.bright = Math.max(0.3, Math.min(1.6, sw.b0 + delta));
applyView();
showHud(bar(`☀ Brightness ${Math.round(st.bright * 100)}%`, (st.bright - 0.3) / 1.3));
}
e.preventDefault();
}, { passive: false });
const end = (e) => {
if (!pts.has(e.pointerId)) return;
const wasPinch = !!st.pinch;
pts.delete(e.pointerId);
if (pts.size < 2) st.pinch = null;
if (st.swipe && st.swipe.active && pts.size === 0) persist();
if (pts.size === 0) st.swipe = null;
if (!wasPinch && !st.moved && e.type === 'pointerup') onTap(e);
if (st.zoom < 1.05) reset();
};
stage.addEventListener('pointerup', end);
stage.addEventListener('pointercancel', end);
return { reset, state: () => ({ zoom: st.zoom, bright: st.bright }) };
})();
// ============================================================================
// Open in an external player (VLC, MX Player, …)
// ============================================================================
// Hands over the server-cached mp4 (/api/media/<id>, a public, Range-capable
// URL with audio+video in one file — the in-app split streams are useless to
// other players). Android: intent: links; iOS: VLC's x-callback scheme;
// desktop: vlc:// or an .m3u playlist file.
/* ============================================================================
* SectionRail — floating jump-to-card rail for phones.
*
* The player page is a tall stack of cards on a phone (player, now playing,
* lyrics, related, the list). The rail gives each visible card an icon:
* • it follows the scroll and highlights whichever card you are looking at,
* • tapping an icon scrolls to that card,
* • tapping the icon of the card you are ALREADY on folds/unfolds it
* (the "double tap" of the request — the first tap takes you there, the
* second one collapses it).
* A folded card keeps its header, so it stays a landmark you can scroll to
* and reopen. Fold state is per device and rides in settings.
* ========================================================================== */
const External = Lazy.proxy('feature:share-external', ['open', 'm3u', 'intent'], 'External');
// ============================================================================
// 🎉 Watch party — synced playback + chat + voice (server/party.js relays)
// ============================================================================
// The host's player drives; guests follow it. Guests extrapolate the host's
// position from the server-stamped state (clock offset measured at join),
// seek when > 1.5 s off and mirror play/pause. A guest acting on their own
// player either sends a command (when the host allows control) or goes
// "off sync" until they tap Re-sync. Voice chat is a WebRTC mesh between
// members who switch it on (STUN only — works on most home/mobile networks).
/* Party facade */
const Party = {
...Lazy.proxy('feature:watch-party', ['open', '_join', '_start'], 'Party'),
guestActive: () => window.YT?.Party?.guestActive() || false,
intercept: action => window.YT?.Party?.intercept(action) || false,
nextDJ: () => window.YT?.Party?.nextDJ() || false,
afterLoad: () => window.YT?.Party?.afterLoad(),
renderLyricsToggle: () => window.YT?.Party?.renderLyricsToggle(),
state: () => window.YT?.Party?.state() || { role: null },
boot() {
if (!WEB) return;
$('navPartyBtn').addEventListener('click', () => Party.open().catch(error => toast(error.message)));
Lazy.intent($('navPartyBtn'), 'feature:watch-party');
let resume = false;
try { const host = JSON.parse(localStorage.getItem('ytpPartyHost') || 'null'); resume = !!(host?.code && host?.secret); } catch {}
if (resume || new URLSearchParams(location.search).has('party')) Lazy.load('feature:watch-party').catch(error => toast(error.message));
},
};
Lazy.onLoad('feature:watch-party', () => window.YT.Party.boot());
/* End party facade */
function updateProgress() {
const cur = Player.master.currentTime || 0;
const dur = Player.master.duration || current?.meta?.duration || 0;
els.curTime.textContent = fmtTime(cur);
els.durTime.textContent = fmtTime(dur);
if (dur) els.seek.value = String((cur / dur) * 1000);
updateMiniBar();
updateServiceProgress(cur, dur);
Notes.tick(cur);
Transition.tick(cur, dur);
if (sleepAtTrackEnd && dur) applySleepFade(dur - cur);
StatsTrack.tick(cur);
Wave.draw();
// A-B loop: passing B behaves like the track ending. Loop the A-B segment only
// when "loop current" is on; otherwise B is the effective end of the track and
// we advance per the repeat settings (next track, or wrap when "repeat list").
if (!playFullMode && abA !== null && abB !== null && abB > abA && cur >= abB) {
if (data.settings.loopOne) {
Player.seek(abA);
} else if (!advanceQueue()) {
// Nothing to advance to (repeat off, last track) — stop at B like a real end.
Player.pause();
Player.seek(abA);
updatePlayBtn();
$('upnext').classList.add('hidden');
}
}
}
// ============================================================================
// Mini now-playing bar
// ============================================================================
// Height of the fixed bars at the bottom (bottom nav, mini player) as CSS
// --bottom-chrome, so lists can scroll their last card clear of them. Measured,
// never guessed: the mini player comes and goes, and phones differ.
window.addEventListener('resize', () => requestAnimationFrame(updateBottomChrome));
window.addEventListener('orientationchange', () => setTimeout(updateBottomChrome, 300));
// Home while playing: the Search tab brings back the home screen (playlist
// board / hero) over the player pane, using the same look as "nothing playing"
// (.empty). The media keeps playing — it is only made invisible, never removed
// or paused — and the mini bar stays up to get back to it.
// ============================================================================
// History
// ============================================================================
function addToHistory(meta) {
data.playCount = data.playCount || {};
data.playCount[meta.id] = (data.playCount[meta.id] || 0) + 1;
data.history = data.history.filter((v) => v.id !== meta.id);
data.history.unshift({
id: meta.id, title: meta.title, channel: meta.channel,
channelId: meta.channelId || '', channelUrl: meta.channelUrl || '',
duration: meta.duration, thumbnail: meta.thumbnail,
});
if (data.history.length > 200) data.history.length = 200;
persist();
if (view.type === 'history') renderList();
}
// ============================================================================
// Queue / navigation
// ============================================================================
function playFromList(list, index, source = '', { playFull = false } = {}) {
queue = list;
queueIndex = index;
queueSource = source;
playFullMode = playFull;
unshuffledQueue = null;
// Play-in-full keeps the list order — a service set plays as arranged.
if (data.settings.shuffle && !playFull) applyShuffle();
warmNextQueue();
Player.loadVideo(queue[queueIndex]);
renderUpNext();
}
// ---------- Shuffle ----------
// Shuffles only the UPCOMING part of the live queue (Fisher-Yates); the
// already-played prefix and the current track stay where they are. The
// pre-shuffle order is kept so turning shuffle off restores it.
let unshuffledQueue = null;
function applyShuffle() {
if (queue.length <= queueIndex + 2) return; // nothing meaningful to shuffle
if (!unshuffledQueue) unshuffledQueue = queue.slice();
const upcoming = queue.slice(queueIndex + 1);
for (let i = upcoming.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]];
}
queue = queue.slice(0, queueIndex + 1).concat(upcoming);
}
function toggleShuffle({ quiet = false } = {}) {
data.settings.shuffle = !data.settings.shuffle;
if (data.settings.shuffle) {
applyShuffle();
} else if (unshuffledQueue) {
const playingId = current && current.meta && current.meta.id;
queue = unshuffledQueue;
unshuffledQueue = null;
const idx = playingId ? queue.findIndex((v) => v.id === playingId) : -1;
if (idx >= 0) queueIndex = idx;
}
persist();
updateLoopRepeatButtons();
renderUpNext();
if (!quiet) toast(data.settings.shuffle ? 'Shuffle on' : 'Shuffle off');
}
// ---------- Temporary queue ----------
function updateQueueBadge() {
const count = data.queue.length;
const b = $('navQueueCount');
if (b) { b.textContent = String(count); b.classList.toggle('hidden', count === 0); }
// Mirror to bottom-nav badge
const bb = $('bnQueueCount');
if (bb) { bb.textContent = String(count); bb.classList.toggle('hidden', count === 0); }
}
function addToQueue(video, { quiet = false } = {}) {
if (!video || !video.id) return;
if (data.queue.some((v) => v.id === video.id)) { if (!quiet) toast('Already in queue'); return; }
data.queue.push(slim(video));
persist();
updateQueueBadge();
if (view.type === 'queue') renderList();
if (!quiet) toast('Added to queue');
}
function removeFromQueue(id) {
data.queue = data.queue.filter((v) => v.id !== id);
// Keep an in-progress queue playback in sync if it was sourced from the queue.
if (queueSource === 'queue') {
const playingId = current && current.meta && current.meta.id;
queue = data.queue.slice();
queueIndex = playingId ? queue.findIndex((v) => v.id === playingId) : -1;
renderUpNext();
}
persist();
updateQueueBadge();
if (view.type === 'queue') renderList();
}
function clearQueue() {
data.queue = [];
persist();
updateQueueBadge();
if (view.type === 'queue') renderList();
toast('Queue cleared');
}
function playQueue(index = 0) {
if (!data.queue.length) return;
playFromList(data.queue.slice(), index, 'queue');
}
// Browsing replaces the shared list pane independently of the playing queue.
// Rebuild it whenever the player is revealed, including when home-over was
// already false: merely removing that class leaves Search's hidden pane stale.
function restoreNowPlayingList({ remember = true } = {}) {
if (!current?.meta) return;
if (remember && !history.state?.ytpNowPlaying) {
history.replaceState({ ...history.state, ytpBrowseView: view, ytpHomeOver: els.playerPane.classList.contains('home-over') }, '', location.href);
history.pushState({ ...history.state, ytpNowPlaying: true, ytpSettingsSection: null }, '', location.href);
}
setHomeOverPlayer(false);
els.playerPane.classList.remove('empty');
els.placeholder.classList.add('hidden');
const playlistId = queueSource.startsWith('playlist:') ? queueSource.slice(9) : null;
view = playlistId && data.playlists.some(p => p.id === playlistId)
? { type: 'playlist', id: playlistId }
: { type: 'nowPlaying' };
_lastViewKey = [view.type, view.id, view.smartType].join('|');
render();
renderUpNext();
}
function renderUpNext() {
updateServiceUpNext();
Remote.changed();
const upcoming = queue.slice(queueIndex + 1);
if (!upcoming.length) { $('upnext').classList.add('hidden'); return; }
$('upnext').classList.remove('hidden');
$('upnextCount').textContent = String(upcoming.length);
const list = $('upnextList');
list.innerHTML = '';
upcoming.forEach((v) => {
const item = document.createElement('div');
item.className = 'upnext-item';
item.innerHTML = `
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" decoding="async" />
<div class="upnext-info">
<div class="upnext-title"></div>
<div class="upnext-channel"></div>
</div>`;
item.querySelector('.upnext-title').textContent = v.title;
const upChEl = item.querySelector('.upnext-channel');
upChEl.textContent = v.channel || '';
if (channelKeyOf(v)) {
upChEl.classList.add('link');
upChEl.title = 'View channel';
upChEl.addEventListener('click', (e) => { e.stopPropagation(); openChannel(channelKeyOf(v), v.channel); });
}
item.addEventListener('click', () => {
const idx = queue.findIndex((x) => x.id === v.id);
if (idx >= 0) { queueIndex = idx - 1; playNext(); }
});
list.appendChild(item);
});
}
// Advance to the next track. Wraps to the start when "repeat list" is on.
function advanceQueue() {
if (Party.nextDJ()) return true;
if (queueIndex >= 0 && queueIndex < queue.length - 1) {
queueIndex++;
} else if (!playFullMode && data.settings.repeatMode === 'all' && queue.length) {
queueIndex = 0;
} else {
return false;
}
// Advancing to a new track always starts from the beginning (or its A point),
// never the previous resume timestamp — and never scrolls the user away
// from whatever page they are browsing while listening.
Player.loadVideo(queue[queueIndex], { resume: false, reveal: false });
renderUpNext();
return true;
}
function playNext() {
if (Party.intercept('next')) return;
if (!advanceQueue()) { updatePlayBtn(); $('upnext').classList.add('hidden'); }
}
// Fired when a track finishes on its own — honors single-video loop first.
function onTrackEnded() {
if (Transition.onEnded()) return; // crossfade/gapless owns this end
if (sleepAtTrackEnd) { finishSleep(); return; } // sleep timer: "end of this song"
if (Party.guestActive()) return; // the host's player decides what's next
if (!playFullMode && data.settings.loopOne) { Player.seek(0); Player.play(); return; }
if (!advanceQueue()) {
if (playFullMode) { playFullMode = false; toast('Finished playing in full'); }
// Nothing left: drop the intent to play, or the watchdog "resumes" the ended
// <audio> from 0 and the last song repeats while the lyrics stay frozen.
Player.pause();
updatePlayBtn();
$('upnext').classList.add('hidden');
}
}
function toggleLoopOne() {
data.settings.loopOne = !data.settings.loopOne;
persist();
updateLoopRepeatButtons();
toast(data.settings.loopOne ? 'Looping current video' : 'Loop off');
}
function toggleRepeat() {
data.settings.repeatMode = data.settings.repeatMode === 'all' ? 'off' : 'all';
persist();
updateLoopRepeatButtons();
toast(data.settings.repeatMode === 'all' ? 'Repeating list' : 'Repeat off');
}
// One button for shuffle / repeat list / loop one. Cycle:
// off → repeat list → shuffle → shuffle + repeat → loop this video → off.
const PLAY_MODES = [
{ name: 'In order', sub: 'Plays the list once, then stops' },
{ name: 'Repeat list', sub: 'Starts over when the list ends' },
{ name: 'Shuffle', sub: 'Random order' },
{ name: 'Shuffle + repeat', sub: 'Random order, forever' },
{ name: 'Loop this video', sub: 'Plays the same video again and again' },
];
const MODE_ICONS = [
'<path d="M17 2l4 4-4 4M3 12v-2a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 12v2a4 4 0 0 1-4 4H3"/>',
'<path d="M17 2l4 4-4 4M3 12v-2a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 12v2a4 4 0 0 1-4 4H3"/>',
'<path d="M16 3h5v5M4 20 21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/>',
'<path d="M16 3h5v5M4 20 21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/>',
'<path d="M17 2l4 4-4 4M3 12v-2a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 12v2a4 4 0 0 1-4 4H3M11 10.2l1.6-.9v5.4"/>',
];
function playModeIndex() {
const st = data.settings;
if (st.loopOne) return 4;
const rep = st.repeatMode === 'all';
return st.shuffle ? (rep ? 3 : 2) : (rep ? 1 : 0);
}
function cyclePlayMode() {
const next = (playModeIndex() + 1) % PLAY_MODES.length;
const wantShuffle = next === 2 || next === 3;
data.settings.loopOne = next === 4;
data.settings.repeatMode = next === 1 || next === 3 ? 'all' : 'off';
if (!!data.settings.shuffle !== wantShuffle) toggleShuffle({ quiet: true });
persist();
updateLoopRepeatButtons();
toast(PLAY_MODES[next].name, { duration: 1800 });
}
function updateLoopRepeatButtons() {
if (els.loopBtn) els.loopBtn.classList.toggle('active', !!data.settings.loopOne);
if (els.repeatBtn) els.repeatBtn.classList.toggle('active', data.settings.repeatMode === 'all');
if (els.shuffleBtn) els.shuffleBtn.classList.toggle('active', !!data.settings.shuffle);
const mb = $('modeBtn');
if (mb) {
const i = playModeIndex();
mb.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${MODE_ICONS[i]}</svg><span class="mode-name">${PLAY_MODES[i].name}</span>`;
mb.classList.toggle('active', i > 0);
mb.dataset.badge = i === 3 ? '∞' : '';
mb.title = `${PLAY_MODES[i].name} — ${PLAY_MODES[i].sub}. Tap to change.`;
mb.setAttribute('aria-label', 'Playback mode: ' + PLAY_MODES[i].name);
}
}
function playPrev() {
if (Party.intercept('prev')) return;
if (Player.master.currentTime > 3) { Player.seek(0); return; }
if (queueIndex > 0) {
queueIndex--;
Player.loadVideo(queue[queueIndex], { resume: false, reveal: false });
renderUpNext();
}
}
// ============================================================================
// Service mode (set-list view) with screen wake lock
// ============================================================================
let serviceWakeLock = null;
function isServiceModeOpen() {
const el = $('serviceMode');
return !!(el && !el.classList.contains('hidden'));
}
async function acquireWakeLock() {
if (!isServiceModeOpen()) return;
if (!('wakeLock' in navigator) || typeof navigator.wakeLock.request !== 'function') {
serviceWakeLock = null;
updateWakeStateUI();
return;
}
try {
if (serviceWakeLock && !serviceWakeLock.released) {
try { await serviceWakeLock.release(); } catch {}
}
const lock = await navigator.wakeLock.request('screen');
serviceWakeLock = lock;
lock.addEventListener('release', () => {
if (serviceWakeLock === lock) {
serviceWakeLock = null;
updateWakeStateUI();
}
});
} catch {
serviceWakeLock = null;
}
updateWakeStateUI();
}
async function releaseWakeLock() {
if (serviceWakeLock) {
try {
if (!serviceWakeLock.released) {
await serviceWakeLock.release();
}
} catch {}
serviceWakeLock = null;
}
updateWakeStateUI();
}
function updateWakeStateUI() {
const el = $('serviceWakeState');
if (!el) return;
const isAwake = !!(serviceWakeLock && !serviceWakeLock.released);
el.textContent = isAwake ? 'Screen stays awake' : 'Screen may sleep';
el.classList.toggle('awake', isAwake);
}
function updateServiceUpNext() {
const upNextEl = $('serviceUpNext');
if (!upNextEl) return;
const upcoming = (queue && queueIndex >= 0) ? queue.slice(queueIndex + 1) : [];
if (upcoming.length > 0) {
upNextEl.textContent = 'Up next: ' + (upcoming[0].title || 'Untitled');
} else {
upNextEl.textContent = 'Last song';
}
}
function updateServiceProgress(cur, dur) {
const curEl = $('serviceCurTime');
const durEl = $('serviceDurTime');
if (curEl) curEl.textContent = fmtTime(cur || 0);
if (durEl) durEl.textContent = fmtTime(dur || 0);
const fill = $('serviceProgressFill');
if (fill) fill.style.width = (dur > 0 ? Math.min(100, Math.max(0, (cur || 0) / dur * 100)) : 0).toFixed(1) + '%';
}
function updateServiceMode() {
if (!isServiceModeOpen()) return;
const meta = current ? current.meta : null;
const titleEl = $('serviceTitle');
const chanEl = $('serviceChannel');
if (titleEl) titleEl.textContent = meta ? (meta.title || '') : 'No track playing';
if (chanEl) chanEl.textContent = meta ? (meta.channel || '') : '';
// Current track note (Section 5): read entry.notes, degrade gracefully if absent
const currentEntry = (queue && queueIndex >= 0 && queueIndex < queue.length)
? queue[queueIndex]
: (currentPlaylistEntry() || meta);
const note = (currentEntry && currentEntry.notes)
? String(currentEntry.notes).trim()
: ((meta && meta.notes) ? String(meta.notes).trim() : '');
const noteEl = $('serviceNote');
if (noteEl) {
if (note) {
noteEl.textContent = note;
noteEl.classList.remove('hidden');
} else {
noteEl.textContent = '';
noteEl.classList.add('hidden');
}
}
updateServiceUpNext();
ServiceVisual.update();
const cur = Player.master ? (Player.master.currentTime || 0) : 0;
const dur = Player.master ? (Player.master.duration || (meta ? meta.duration : 0) || 0) : 0;
updateServiceProgress(cur, dur);
const spBtn = $('servicePlayBtn');
if (spBtn && Player.master) {
spBtn.textContent = Player.master.paused ? '▶' : '⏸';
}
updateWakeStateUI();
}
// ---------- Service mode video panel ----------
// Lyric videos carry the words in the picture, so service mode can show the
// video instead of hiding it. The <video> element is NEVER reparented — moving
// a playing video breaks playback on iOS (and drops the stream on some
// Androids). Instead .player-stage is pinned over a placeholder slot inside the
// overlay with inline fixed coordinates, which are cleared when it closes.
function serviceVideoOn() {
return !!data.settings.serviceVideo && !data.settings.audioOnly;
}
// The overlay normally sits at body level with z-index 320, and nothing inside
// .app can paint above it: .app is `position: relative; z-index: 1`, which makes
// it a stacking context, so a raised z-index on .player-stage is capped by
// .app's own layer. (On iOS the backdrop-filter on .main compounds this by also
// making .main the containing block for fixed descendants.) An earlier attempt
// pinned .player-stage over a slot in the overlay; the geometry was exact but
// the video stayed buried behind it.
//
// So to show the video we move the OVERLAY into the player pane and render it
// inline beneath the stage, instead of trying to lift the video out. Only divs
// and buttons move — the <video> element never does, because reparenting a
// playing video breaks playback on iOS.
function applyServiceVideoLayout() {
const overlay = $('serviceMode');
const pane = els.playerPane;
if (!overlay || !pane) return;
const on = isServiceModeOpen() && serviceVideoOn();
const host = on ? pane : document.body;
if (overlay.parentNode !== host) host.appendChild(overlay);
document.body.classList.toggle('service-video', on);
}
function updateServiceVideoUI() {
applyServiceVideoLayout();
updateServiceViewBtn();
}
// ---------- Service view: one button cycles Default → Lyrics → Video → Visualizer → Album art ----------
const SERVICE_VIEWS = [
{ id: 'default', label: 'Default', icon: '<rect x="4" y="5" width="16" height="14" rx="3"/><path d="M8 10h8M8 14h5"/>' },
{ id: 'lyrics', label: 'Lyrics', icon: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>' },
{ id: 'video', label: 'Video', icon: '<rect x="3" y="6" width="13" height="12" rx="2.5"/><path d="M16 10.5l5-3v9l-5-3"/>' },
{ id: 'visual', label: 'Visualizer', icon: '<path d="M4 14v4M8 9v9M12 5v13M16 10v8M20 13v5"/>' },
{ id: 'art', label: 'Album art', icon: '<rect x="4" y="4" width="16" height="16" rx="3"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r="0.8"/>' },
];
function serviceView() {
if (data.settings.serviceLyrics) return 'lyrics';
if (serviceVideoOn()) return 'video';
const v = data.settings.serviceView;
return v === 'visual' || v === 'art' ? v : 'default';
}
function updateServiceViewBtn() {
const btn = $('serviceViewBtn');
if (!btn) return;
const v = SERVICE_VIEWS.find((x) => x.id === serviceView()) || SERVICE_VIEWS[0];
btn.querySelector('.svc-ico').innerHTML = v.icon;
btn.querySelector('.svc-label').textContent = v.label;
btn.setAttribute('aria-pressed', String(v.id !== 'default'));
btn.setAttribute('aria-label', `Service view: ${v.label} — tap to change`);
btn.title = `View: ${v.label} (tap for the next view)`;
}
function setServiceView(id) {
data.settings.serviceLyrics = id === 'lyrics';
data.settings.serviceVideo = id === 'video';
data.settings.serviceView = id;
persist();
updateServiceVideoUI();
Notes.serviceRefresh();
updateServiceViewBtn();
ServiceVisual.update();
}
function cycleServiceView() {
const order = SERVICE_VIEWS.map((v) => v.id).filter((id) => id !== 'video' || !data.settings.audioOnly);
const i = order.indexOf(serviceView());
const next = order[(i + 1) % order.length];
setServiceView(next);
toast(SERVICE_VIEWS.find((v) => v.id === next).label, { duration: 1200 });
}
// ---------- Service text size: −2 … +2 steps, in every theme ----------
const SERVICE_FONT_SCALES = { '-2': 0.72, '-1': 0.86, '0': 1, '1': 1.16, '2': 1.32 };
function serviceFontStep() {
const n = Math.round(Number(data.settings.serviceFontStep) || 0);
return Math.max(-2, Math.min(2, n));
}
function applyServiceFont() {
const step = serviceFontStep();
const el = $('serviceMode');
if (el) el.style.setProperty('--svc-fs', String(SERVICE_FONT_SCALES[step]));
const val = $('svcFontVal');
if (val) val.textContent = step > 0 ? '+' + step : String(step);
const down = $('svcFontDown'), up = $('svcFontUp');
if (down) down.disabled = step <= -2;
if (up) up.disabled = step >= 2;
}
function stepServiceFont(d) {
data.settings.serviceFontStep = Math.max(-2, Math.min(2, serviceFontStep() + d));
persist();
applyServiceFont();
}
// ---------- Album art + visualizer ----------
const ServiceVisual = (() => {
let raf = 0, an = null, buf = null, lastId = '';
const reduced = () => window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function update() {
const box = $('serviceVisual');
if (!box) return;
const v = isServiceModeOpen() ? serviceView() : 'default';
const on = v === 'visual' || v === 'art';
box.classList.toggle('hidden', !on);
$('serviceMode').classList.toggle('visual-on', on);
box.dataset.view = on ? v : '';
const meta = current ? current.meta : null;
const img = $('serviceArt');
if (on && meta && meta.id !== lastId) {
lastId = meta.id;
const src = thumbUrlFor(meta.id, meta);
img.style.visibility = src ? '' : 'hidden';
// hqdefault is 4:3 with the 16:9 frame letterboxed inside; .yt-art zooms the bars away.
const big = src ? src.replace('/mqdefault.jpg', '/hqdefault.jpg') : '';
img.classList.toggle('yt-art', /ytimg\.com\/vi(_webp)?\/[^/]+\/(hq|sd)default\.(jpg|webp)/.test(big));
if (big) img.src = big;
img.onerror = () => { if (src && img.src !== new URL(src, location.href).href) { img.classList.remove('yt-art'); img.src = src; } };
box.style.setProperty('--art', src ? `url("${src}")` : 'none');
}
if (v === 'visual') start(); else stop();
}
function start() {
if (raf) return;
an = EQ.analyser ? EQ.analyser() : null;
buf = an ? new Uint8Array(an.frequencyBinCount) : null;
const tick = (t) => { draw(t); raf = requestAnimationFrame(tick); };
raf = requestAnimationFrame(tick);
}
function stop() {
if (raf) cancelAnimationFrame(raf);
raf = 0;
}
function draw(t) {
const c = $('serviceViz');
if (!c) return;
const w = c.clientWidth, h = c.clientHeight;
if (!w || !h) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); }
const g = c.getContext('2d');
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.clearRect(0, 0, w, h);
const bars = 32;
const playing = Player.master && !Player.master.paused;
if (an && buf) an.getByteFrequencyData(buf);
const css = getComputedStyle(document.documentElement);
const c1 = css.getPropertyValue('--accent-bright').trim() || '#ff7a5c';
const c2 = css.getPropertyValue('--accent-deep').trim() || '#d6362a';
const grad = g.createLinearGradient(0, h, 0, 0);
grad.addColorStop(0, c2); grad.addColorStop(1, c1);
g.fillStyle = grad;
const gap = Math.max(3, w / bars * 0.28);
const bw = (w - gap * (bars - 1)) / bars;
for (let i = 0; i < bars; i++) {
let v;
if (an && buf) {
// Log-ish spread over the lower bins, where the music lives.
const idx = Math.min(buf.length - 1, Math.floor(Math.pow(i / bars, 1.6) * buf.length * 0.85));
v = buf[idx] / 255;
} else {
// iOS / no Web Audio: an ambient pulse that follows play/pause, not the audio.
const s = reduced() ? 0 : t / 1000;
v = playing ? 0.25 + 0.55 * Math.abs(Math.sin(s * 1.7 + i * 0.55) * Math.cos(s * 0.9 + i * 0.21)) : 0.06;
}
const bh = Math.max(4, v * h * 0.92);
const x = i * (bw + gap);
const r = Math.min(bw / 2, 8);
g.beginPath();
if (g.roundRect) g.roundRect(x, h - bh, bw, bh, r); else g.rect(x, h - bh, bw, bh);
g.fill();
}
}
return { update, stop };
})();
function enterServiceMode(pl) {
if (pl && pl.videos && pl.videos.length) {
const source = 'playlist:' + pl.id;
const playingId = (current && current.meta) ? current.meta.id : null;
if (queueSource === source && playingId) {
// Already running this set — service mode is only a view, so keep the
// live queue, its position, shuffle order and playback exactly as they
// are instead of jumping back to the first song.
} else {
// Not on this set: open on the playing song if it happens to be in the
// playlist, otherwise start from the top.
const idx = playingId ? pl.videos.findIndex((v) => v && v.id === playingId) : -1;
playFromList(pl.videos, idx >= 0 ? idx : 0, source);
}
}
const el = $('serviceMode');
if (el) el.classList.remove('hidden');
// Hides the mini-bar / bottom-nav / sidebar for as long as the overlay is up
// (see .service-open in styles.css).
document.body.classList.add('service-open');
updateServiceVideoUI();
applyServiceFont();
updateServiceMode();
Notes.serviceRefresh();
ServiceVisual.update();
acquireWakeLock();
}
function exitServiceMode() {
const el = $('serviceMode');
if (el) el.classList.add('hidden');
document.body.classList.remove('service-open');
// Runs after the overlay is hidden, so this clears the pinned video styles
// and returns .player-stage to the player pane.
updateServiceVideoUI();
ServiceVisual.update();
releaseWakeLock();
}
// ============================================================================
// Channel view — list a channel's uploads with quick actions
// ============================================================================
async function openChannel(channelKey, displayName) {
if (!channelKey) { toast('No channel info for this video'); return; }
view = { type: 'channel' };
channelData = { name: displayName || 'Channel', url: channelKey, key: channelKey, results: [], loading: true };
render();
try {
const res = await API.getChannel(channelKey);
if (view.type !== 'channel' || channelData.key !== channelKey) return; // navigated away
if (!res || !res.ok) throw new Error(res?.error || 'Could not load channel');
channelData.results = res.results || [];
channelData.name = res.channel || displayName || 'Channel';
channelData.url = res.channelUrl || channelKey;
channelData.loading = false;
renderList();
} catch (err) {
channelData.loading = false;
if (view.type === 'channel') {
els.cards.innerHTML = '';
els.status.classList.remove('hidden');
els.status.textContent = '⚠ ' + err.message;
}
}
}
// The identifier we hand the backend to look a channel up (URL preferred).
function channelKeyOf(v) {
// Older saved entries may only have the channel's name; the server resolves
// a name (e.g. "Petra - Topic") to its channel id.
return (v && (v.channelUrl || v.channelId || v.channel)) || '';
}
// ============================================================================
// Rendering
// ============================================================================
// The empty player pane (nothing playing yet) shows the branding hero. Once
// this device has playlists, they take that space instead — the hero stays as
// the fallback so a first-time visitor still gets the landing.
// Driven from renderSidebar() so every playlist mutation refreshes both.
// With enough playlists the grid still has to scroll. Flag when it genuinely
// overflows (and whether it's at the end) so the CSS fades the bottom edge
// instead of slicing the last row against a hard border.
function currentList() {
if (view.type === 'nowPlaying') return queue;
if (view.type === 'search') return searchResults;
if (view.type === 'history') return data.history;
if (view.type === 'queue') return data.queue;
if (view.type === 'channel') return channelData.results;
if (view.type === 'smart') return getSmartList(view.smartType);
if (view.type === 'playlist') {
const pl = data.playlists.find((p) => p.id === view.id);
return pl ? pl.videos : [];
}
return [];
}
// Before the first search the list pane has nothing to say but "Search for
// something to begin" — hide it outright so the playlist board (or the hero)
// owns the full width on a fresh launch. Any search or view change brings it
// back. Must run before the early returns below, which skip the rest.
// Small icon-only button for the playlist title row and the filter row.
// Drop the playlist-only title/filter icons so they never linger on other views.
// ============================================================================
// Saved videos page — every offline-cached file with sizes + totals
// ============================================================================
// ============================================================================
// Save to this device (gallery / Files / Downloads) — docs: export.js
// ============================================================================
// Sources, best first: the copy already saved in this browser; the server's copy
// (direct download on desktop, via the saved copy on phones so the share sheet
// can put it in Photos); another device that holds it; finally a normal save
// through the server. Buttons appear only when the file is ready so each one
// runs inside its own tap (the share sheet needs a fresh user gesture).
async function exportToDevice(video) {
try { await Lazy.load('feature:export'); } catch(error) { toast(error.message); return; }
if (!video || !video.id || !window.DeviceExport) return;
const id = video.id;
const title = video.title || videoTitleById(id) || id;
const box = document.createElement('div');
box.className = 'export-box';
box.innerHTML = '<div class="export-status" role="status"></div><div class="export-bar"><i></i></div><div class="export-actions"></div>';
const status = box.querySelector('.export-status');
const bar = box.querySelector('.export-bar');
const fill = bar.firstChild;
const actions = box.querySelector('.export-actions');
const say = (t) => { status.textContent = t; };
const pct = (p) => { bar.classList.remove('busy'); fill.style.width = Math.max(0, Math.min(100, p)) + '%'; };
const busy = () => { bar.classList.add('busy'); fill.style.width = ''; };
const button = (label, primary, fn) => {
const b = document.createElement('button');
b.className = 'btn' + (primary ? ' primary' : '');
b.textContent = label;
b.onclick = fn;
actions.appendChild(b);
return b;
};
let open = true;
showModal('Save to this device', box, [{ label: 'Close', onClick: () => { open = false; closeModal(); } }]);
const DE = window.DeviceExport;
const done = (msg) => { open = false; closeModal(); toast(msg, { duration: 4500 }); };
const ready = (file) => {
const ext = (String(file.name || '').match(/\.([a-z0-9]{2,5})$/i) || [, 'mp4'])[1];
const name = DE.fileName(title, ext, id);
pct(100);
// A full progress bar above the buttons read as a squashed, broken button.
bar.hidden = true;
say(`${name} · ${fmtBytes(file.size)}`);
actions.innerHTML = '';
if (DE.canShareFile(file)) {
button('Save to Photos / Files', true, async () => {
try { if (await DE.share(file, name, title) === 'shared') done('Shared ✓ — choose “Save Video” or “Save to Files”'); }
catch (e) { say('Could not open the share sheet: ' + (e && e.message ? e.message : e)); }
});
}
if (DE.canPickLocation()) {
button('Choose where to save…', !DE.canShareFile(file), async () => {
try { if (await DE.pick(file, name) === 'saved') done('Saved to your device ✓'); }
catch (e) { say('Could not save: ' + (e && e.message ? e.message : e)); }
});
}
button('Download file', !DE.canShareFile(file) && !DE.canPickLocation(), () => {
DE.download(file, name);
done('Download started — see your Downloads folder');
});
};
const localFile = async () => (cachedIds.has(id) && window.OPFS ? window.OPFS.getFileObject(id) : null);
try {
say('Looking for the video…');
busy();
let file = await localFile();
if (file) return ready(file);
// Is it on the server? (a 1-byte ranged probe; nothing is downloaded)
let serverUrl = '';
if (WEB && !video.custom) {
const url = `/api/export/${encodeURIComponent(id)}?name=${encodeURIComponent(title)}${hevcCapable() ? '&hevc=1' : ''}`;
const r = await fetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null);
if (r && (r.status === 206 || r.ok)) serverUrl = url;
}
const phone = DE.canShareFile(new File([], 'x.mp4', { type: 'video/mp4' }));
if (serverUrl && !phone) {
say('The server has this video. It downloads straight to your Downloads folder.');
pct(100);
actions.innerHTML = '';
button('Download file', true, () => { DE.download(serverUrl, DE.fileName(title, 'mp4', id)); done('Download started — see your Downloads folder'); });
return;
}
// Other devices, only when the server can't help.
if (!serverUrl && WEB && window.P2PTransfer && window.P2PCore && data.settings.p2pReceive !== false && !video.custom) {
let payload = null;
try { const r = await fetch(`/api/p2p/holders?v=${encodeURIComponent(id)}`); payload = r.ok ? await r.json() : null; } catch { /* offline */ }
const c = payload && Array.isArray(payload.cids) ? payload.cids[0] : null;
const peers = c ? window.P2PCore.pickPeers(payload, c.cid) : [];
if (peers.length) {
say(`Getting it from another device (${peers.length} online)…`);
pct(0);
const res = await getFromPeers(video, c, peers, (got, total) => { if (open) { pct((got / total) * 100); say(`Getting it from another device… ${Math.round((got / total) * 100)}%`); } });
if (res.ok) { file = await localFile(); if (file) return ready(file); }
say('That device could not send it — saving through the server instead…');
}
}
// Normal save (server copy or YouTube via the server), then export it.
say(serverUrl ? 'Copying it to this device…' : 'Saving the video first — long videos take a while…');
busy();
await preload(video, { quiet: true });
file = await localFile();
if (!file) { say('Could not get this video. Check your connection and try again.'); pct(0); return; }
ready(file);
} catch (e) {
say('Something went wrong: ' + (e && e.message ? e.message : e));
}
}
// ============================================================================
// "On other devices" — videos held by sharing devices (P2P), as a playlist
// ============================================================================
// Glass Stage: a compact "On other devices" card under a playlist (same data as the 📡 view).
let p2pStripAt = 0, p2pStripData = null;
// ============================================================================
// Saved searches — browse (read-only here; deleting happens in Settings)
// ============================================================================
// ============================================================================
// Downloads page — videos currently being saved
// ============================================================================
// ============================================================================
// Settings page
// ============================================================================
function videoById(id) {
for (const pl of data.playlists) {
const v = pl.videos.find((x) => x.id === id);
if (v) return v;
}
const q = data.queue.find((x) => x.id === id);
if (q) return q;
const h = data.history.find((x) => x.id === id);
if (h) return h;
// Custom (edited) videos live only in data.customVideos — their media is in
// the offline cache, never on YouTube, so nothing else references them.
const cv = (data.customVideos || []).find((x) => x.id === id);
if (cv) return cv;
return null;
}
function videoTitleById(id) {
const v = videoById(id);
return v ? v.title : id;
}
// Only entries created by returning to the player participate here; leave
// unrelated history (including Settings section navigation) to its owner.
window.addEventListener('popstate', event => {
const state = event.state;
if (state?.ytpSettingsSection) return;
if (state?.ytpNowPlaying) {
restoreNowPlayingList({ remember: false });
if (isStackedPlayerLayout()) document.querySelector('.body')?.scrollTo({ top: 0 });
}
else if (state?.ytpBrowseView) {
view = state.ytpBrowseView;
setHomeOverPlayer(!!state.ytpHomeOver);
render();
}
});
let _lastViewKey = null;
function render() {
const key = [view.type, view.id, view.smartType].join('|');
if (history.state?.ytpNowPlaying && _lastViewKey !== null && key !== _lastViewKey) {
history.pushState({ ...history.state, ytpNowPlaying: false, ytpBrowseView: view, ytpHomeOver: els.playerPane.classList.contains('home-over'), ytpSettingsSection: null }, '', location.href);
}
listFilter = '';
const fi = $('listFilterInput');
if (fi) fi.value = '';
// On mobile, navigating (nav item / playlist / search) dismisses the drawer.
if (typeof closeSidebar === 'function') closeSidebar();
exitSelectMode();
renderSidebar();
renderSmartSidebar();
renderList();
// Portrait PWA: reveal the freshly selected view. Only on actual view
// changes — same-view re-renders (deletes, modal confirms, profile load)
// must not hijack the scroll position. Skipped on the very first render.
const viewKey = [view.type, view.id, view.smartType].join('|');
if (_lastViewKey !== null && viewKey !== _lastViewKey) scrollListIntoViewPortrait();
_lastViewKey = viewKey;
// Cards come and go with the view, so the rail is rebuilt from what is
// actually on screen.
SectionRail.refresh();
}
// ============================================================================
// Playlists
// ============================================================================
function exportPlaylists() {
const json = JSON.stringify(data.playlists, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ytplayer-playlists-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
toast('Playlists exported');
}
function importPlaylists(e) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const imported = JSON.parse(ev.target.result);
if (!Array.isArray(imported)) throw new Error('Invalid format');
// Merge: skip duplicates by id, add new ones
const existingIds = new Set(data.playlists.map((p) => p.id));
for (const pl of imported) {
if (pl.id && pl.name && Array.isArray(pl.videos) && !existingIds.has(pl.id)) {
data.playlists.push(pl);
existingIds.add(pl.id);
}
}
persist();
renderSidebar();
toast(`Imported ${imported.length} playlist(s)`);
} catch (err) {
toast('⚠ Failed to import: ' + err.message);
}
};
reader.readAsText(file);
e.target.value = ''; // allow re-import of same file
}
function openImportYouTubeModal() {
const body = document.createElement('div');
body.innerHTML = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
Enter a YouTube playlist URL to import its videos into a new playlist.
</p>
<input id="ytPlaylistUrlInput" type="text" placeholder="https://www.youtube.com/playlist?list=..." autocomplete="off" />
<div id="ytImportStatus" class="hidden" style="margin-top:10px;color:var(--text-dim);font-size:13px">
Fetching playlist from YouTube…
</div>
`;
async function handleImport() {
const input = $('ytPlaylistUrlInput');
if (!input) return;
const url = input.value.trim();
if (!url) {
toast('⚠ Please enter a YouTube URL');
input.focus();
return;
}
const modalActions = $('modalActions');
const importBtn = modalActions?.querySelector('button.primary');
const cancelBtn = modalActions?.querySelector('button:not(.primary)');
const statusEl = $('ytImportStatus');
if (importBtn) {
importBtn.disabled = true;
importBtn.textContent = 'Importing…';
}
if (cancelBtn) cancelBtn.disabled = true;
input.disabled = true;
if (statusEl) statusEl.classList.remove('hidden');
try {
const res = await fetch(`/api/playlist/expand?url=${encodeURIComponent(url)}`);
const j = await res.json().catch(() => null);
if (!j || !j.ok) {
throw new Error((j && j.error) || `Failed to fetch playlist (HTTP ${res.status})`);
}
const entries = Array.isArray(j.entries) ? j.entries : [];
if (!entries.length) {
throw new Error('No videos found in that playlist');
}
const rawTitle = (j.title || '').trim();
const playlistName = rawTitle || 'Imported playlist';
// Dedupe up front so the count offered is the count actually created —
// YouTube playlists routinely repeat a video.
const seenIds = new Set();
const dedupedVideos = [];
for (const v of entries) {
if (!v || !v.id || seenIds.has(v.id)) continue;
seenIds.add(v.id);
dedupedVideos.push(slim(v));
}
const count = dedupedVideos.length;
if (!count) throw new Error('No usable videos in that playlist');
const successBody = document.createElement('div');
successBody.innerHTML = `
<p style="margin:0;color:var(--text-2);font-size:13px;line-height:1.5">
Found ${count} video${count === 1 ? '' : 's'} in “<strong>${escapeHtml(playlistName)}</strong>”${j.truncated ? ' <small style="color:var(--text-dim)">(capped at 200)</small>' : ''}.
</p>
`;
showModal('⬇ Import from YouTube', successBody, [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Create playlist',
primary: true,
onClick: () => {
closeModal();
const baseName = playlistName;
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(`Created “${finalName}” (${dedupedVideos.length} video${dedupedVideos.length === 1 ? '' : 's'}) ✓`);
},
},
]);
} catch (err) {
toast('⚠ ' + (err.message || 'Failed to import playlist'));
if (importBtn) {
importBtn.disabled = false;
importBtn.textContent = 'Import';
}
if (cancelBtn) cancelBtn.disabled = false;
input.disabled = false;
if (statusEl) statusEl.classList.add('hidden');
input.focus();
}
}
showModal('⬇ Import from YouTube', body, [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Import', primary: true, onClick: handleImport },
]);
setTimeout(() => {
const input = $('ytPlaylistUrlInput');
if (input) {
input.focus();
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleImport();
}
});
}
}, 50);
}
function openCardMenu(video) {
const inPlaylistView = view.type === 'playlist';
const body = document.createElement('div');
body.className = 'modal-list';
// Quick: add to queue
const queueBtn = document.createElement('button');
queueBtn.textContent = '▶ Add to queue';
queueBtn.onclick = () => { addToQueue(video); closeModal(); };
body.appendChild(queueBtn);
const isCustom = !!video.custom;
const exportBtn = document.createElement('button');
exportBtn.textContent = '🖼 Save to gallery';
exportBtn.title = 'Put the video file in your gallery, Files app or Downloads folder';
exportBtn.onclick = () => { closeModal(); exportToDevice(video); };
body.appendChild(exportBtn);
// Quick: save / remove offline. For a custom (edited) video there's no
// source to re-download, so "remove" deletes the edit entirely.
const saveBtn = document.createElement('button');
const isSaved = cachedIds.has(video.id);
if (isCustom) {
saveBtn.textContent = '🗑 Delete edited video';
saveBtn.className = 'danger';
saveBtn.onclick = () => { closeModal(); deleteCustomVideo(video.id); };
} else {
saveBtn.textContent = isSaved ? '✓ Saved offline — remove' : '⬇ Save for offline';
saveBtn.onclick = async () => {
closeModal();
if (cachedIds.has(video.id)) {
try { await API.cacheDelete(video.id); } catch {}
cachedIds.delete(video.id);
markCardCacheState(video.id, 'none');
if (current && current.meta && current.meta.id === video.id) updateNowPlayingActions();
toast('Removed from offline cache');
} else {
preload(video);
}
};
}
body.appendChild(saveBtn);
// Quick: edit & download (only for real source videos — a custom cut can't
// be re-cut server-side because its media lives only in the browser cache).
if (!isCustom) {
const editBtn = document.createElement('button');
editBtn.textContent = '✂ Edit & download';
editBtn.onclick = () => { closeModal(); openVideoEditor(video); };
body.appendChild(editBtn);
}
const divider = document.createElement('div');
divider.className = 'modal-divider';
divider.textContent = 'Playlists';
body.appendChild(divider);
data.playlists.forEach((pl) => {
const btn = document.createElement('button');
btn.textContent = (pl.videos.some((x) => x.id === video.id) ? '✓ ' : '+ ') + pl.name;
btn.onclick = () => {
const key = pl.id + ':' + video.id;
// Guard against rapid double-clicks: the op is keyed by playlist+video,
// so a second click while the first is in flight is ignored (prevents the
// same video being pushed twice / state corruption).
runExclusive(playlistOps, key, async () => {
btn.disabled = true;
btn.textContent = '⏳ ' + pl.name;
// Recompute membership now (not from a flag captured at menu-open),
// so the decision reflects current state.
const has = pl.videos.some((x) => x.id === video.id);
try {
if (has) {
pl.videos = pl.videos.filter((x) => x.id !== video.id);
} else {
pl.videos.push(slim(video));
// Auto-cache for offline playback (fire-and-forget; its own guard
// prevents duplicate downloads).
preload(video);
}
persist();
toast(has ? `Removed from ${pl.name}` : `Added to ${pl.name}`);
} catch {
toast('⚠ Could not update playlist');
}
closeModal();
render();
});
};
body.appendChild(btn);
});
const newBtn = document.createElement('button');
newBtn.textContent = '+ New playlist…';
newBtn.onclick = () => { closeModal(); newPlaylist(video); };
body.appendChild(newBtn);
const actions = [{ label: 'Close', onClick: closeModal }];
if (inPlaylistView) {
actions.unshift({
label: 'Remove from this playlist', danger: true, onClick: () => {
const pl = data.playlists.find((p) => p.id === view.id);
if (pl) { pl.videos = pl.videos.filter((x) => x.id !== video.id); persist(); }
closeModal(); render();
},
});
}
showModal('Add to playlist', body, actions);
}
function slim(v) {
const s = {
id: v.id, title: v.title, channel: v.channel,
channelId: v.channelId || '', channelUrl: v.channelUrl || '',
duration: v.duration, thumbnail: v.thumbnail,
};
// Preserve the markers that make a custom (edited) video self-contained, so
// a slimmed copy sitting in a playlist still knows it's an offline-only cut.
if (v.custom) {
s.custom = true;
s.sourceId = v.sourceId || '';
if (v.keep) s.keep = v.keep;
}
return s;
}
function newPlaylist(addVideo) {
const input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Playlist name';
showModal('New playlist', input, [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Create', primary: true, onClick: () => {
const name = input.value.trim();
if (!name) return;
const pl = { id: uid(), name, videos: addVideo ? [slim(addVideo)] : [] };
data.playlists.push(pl);
if (addVideo) preload(addVideo); // auto-cache for offline playback
persist();
closeModal();
view = { type: 'playlist', id: pl.id };
render();
toast(`Created “${name}”`);
},
},
]);
setTimeout(() => input.focus(), 50);
}
function renamePlaylist(pl) {
// A wrapping box so a long name is shown in full (tapping the title opens
// this too). Names stay single-line: Enter saves, pasted newlines collapse.
const input = document.createElement('textarea');
input.className = 'rename-input';
input.rows = 1;
input.value = pl.name;
const fit = () => { input.style.height = 'auto'; input.style.height = input.scrollHeight + 'px'; };
input.addEventListener('input', () => {
if (/\n/.test(input.value)) input.value = input.value.replace(/\s*\n+\s*/g, ' ');
fit();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); const save = $('modalActions').querySelector('.btn.primary'); if (save) save.click(); }
});
showModal('Rename playlist', input, [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Save', primary: true, onClick: () => {
const name = input.value.trim();
if (name) { pl.name = name; persist(); }
closeModal(); render();
},
},
]);
setTimeout(() => { fit(); input.focus(); input.select(); }, 50);
}
function openSongNoteModal(entry) {
const body = document.createElement('div');
body.className = 'song-note-modal';
if (entry.title) {
const sub = document.createElement('div');
sub.className = 'modal-subhead';
sub.textContent = entry.title;
body.appendChild(sub);
}
const ta = document.createElement('textarea');
ta.placeholder = 'Key, capo, arrangement cues…';
ta.value = entry.notes || '';
ta.maxLength = 500;
ta.rows = 4;
const count = document.createElement('div');
count.className = 'modal-char-count';
const updateCount = () => {
count.textContent = `${ta.value.length} / 500`;
};
ta.addEventListener('input', updateCount);
updateCount();
body.append(ta, count);
const save = () => {
const text = (ta.value || '').trim().slice(0, 500);
if (text) {
entry.notes = text;
} else {
delete entry.notes;
}
// Keep live playing metadata in sync if this song is playing from this playlist
if (current && current.meta && current.meta.id === entry.id) {
if (queueSource && queueSource.startsWith('playlist:')) {
if (text) current.meta.notes = text;
else delete current.meta.notes;
updateServiceMode();
}
}
persist();
closeModal();
renderList();
toast(text ? 'Note saved' : 'Note cleared');
};
ta.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
save();
}
});
showModal('Song note', body, [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Save', primary: true, onClick: save },
]);
setTimeout(() => {
ta.focus();
ta.select();
}, 50);
}
function updatePlaylistOfflineButton(btn, pl) {
if (!btn || !pl) return;
const stats = playlistOfflineStats(pl);
const isDownloading = (pl.videos || []).some((v) => v && downloading.has(v.id));
const iconOnly = btn.classList.contains('list-icon-btn');
let label;
btn.classList.remove('done', 'busy');
if (stats.total > 0 && stats.cached === stats.total) {
if (iconOnly) { setListIcon(btn, 'offlineDone'); btn.classList.add('done'); } else btn.textContent = '✓ Available offline';
btn.disabled = true;
btn.classList.add('disabled');
label = 'All videos in this playlist are available offline';
} else if (isDownloading) {
if (iconOnly) { btn.innerHTML = `<span class="lib-progress">${stats.cached}/${stats.total}</span>`; btn.classList.add('busy'); } else btn.textContent = `Saving ${stats.cached}/${stats.total}…`;
btn.disabled = true;
btn.classList.add('disabled');
label = `${stats.cached} of ${stats.total} videos saved offline`;
} else {
if (iconOnly) setListIcon(btn, 'offline'); else btn.textContent = '⬇ Make available offline';
btn.disabled = stats.total === 0;
btn.classList.toggle('disabled', stats.total === 0);
label = stats.total === 0 ? 'Playlist is empty' : 'Make available offline';
}
btn.title = label;
btn.setAttribute('aria-label', label);
}
async function makePlaylistOffline(pl) {
if (!pl || !Array.isArray(pl.videos) || !pl.videos.length) return;
const uncollected = pl.videos.filter((v) => v && !v.custom && !cachedIds.has(v.id));
if (!uncollected.length) return;
const btn = $('plOfflineBtn');
if (btn) {
const stats = playlistOfflineStats(pl);
btn.innerHTML = `<span class="lib-progress">${stats.cached}/${stats.total}</span>`;
btn.classList.add('busy');
btn.disabled = true;
btn.classList.add('disabled');
}
toast(`Saving ${uncollected.length} video${uncollected.length === 1 ? '' : 's'} for offline…`);
const promises = uncollected.map(async (v) => {
await preload(v, { quiet: true });
const b = $('plOfflineBtn');
if (b && view.type === 'playlist' && view.id === pl.id) {
updatePlaylistOfflineButton(b, pl);
}
renderSidebar();
});
await Promise.all(promises);
const finalBtn = $('plOfflineBtn');
if (finalBtn && view.type === 'playlist' && view.id === pl.id) {
updatePlaylistOfflineButton(finalBtn, pl);
}
renderSidebar();
}
function togglePinPlaylist(pl) {
if (!pl) return;
data.pinnedPlaylists = data.pinnedPlaylists || [];
const isPinned = data.pinnedPlaylists.includes(pl.id);
if (isPinned) {
data.pinnedPlaylists = data.pinnedPlaylists.filter((id) => id !== pl.id);
persist();
toast(`Unpinned “${pl.name}”`);
} else {
data.pinnedPlaylists.push(pl.id);
persist();
(pl.videos || []).forEach((v) => preload(v, { quiet: true }));
toast(`Pinned “${pl.name}” — keeping offline`);
}
const pinBtn = $('plPinBtn');
if (pinBtn) {
const nowPinned = data.pinnedPlaylists.includes(pl.id);
const label = nowPinned ? 'Pinned for offline keep — tap to unpin' : 'Pin to keep this playlist available offline';
pinBtn.classList.toggle('pinned', nowPinned);
pinBtn.setAttribute('aria-pressed', String(nowPinned));
pinBtn.title = label;
pinBtn.setAttribute('aria-label', label);
}
const offlineBtn = $('plOfflineBtn');
if (offlineBtn) updatePlaylistOfflineButton(offlineBtn, pl);
renderSidebar();
}
function deletePlaylist(pl) {
showModal(`Delete “${pl.name}”?`, document.createTextNode('This cannot be undone.'), [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Delete', danger: true, onClick: () => {
data.playlists = data.playlists.filter((p) => p.id !== pl.id);
data.pinnedPlaylists = (data.pinnedPlaylists || []).filter((id) => id !== pl.id);
persist();
closeModal();
view = { type: 'search' };
render();
},
},
]);
}
// ============================================================================
// Keyboard shortcut help overlay
// ============================================================================
function toggleShortcutHelp() {
const overlay = $('shortcutHelp');
overlay.classList.toggle('hidden');
}
function wireShortcutHelp() {
$('shortcutClose').addEventListener('click', () => $('shortcutHelp').classList.add('hidden'));
$('shortcutHelp').addEventListener('click', (e) => {
if (e.target.id === 'shortcutHelp') $('shortcutHelp').classList.add('hidden');
});
}
// ============================================================================
// Modal
// ============================================================================
function showModal(title, bodyNode, actions) {
$('modalTitle').textContent = title;
const body = $('modalBody');
body.innerHTML = '';
body.appendChild(bodyNode);
const act = $('modalActions');
act.innerHTML = '';
actions.forEach((a) => {
const b = document.createElement('button');
b.className = 'btn' + (a.primary ? ' primary' : '') + (a.danger ? ' danger' : '');
b.textContent = a.label;
b.onclick = a.onClick;
act.appendChild(b);
});
$('modal').classList.remove('hidden');
}
function closeModal() { $('modal').classList.add('hidden'); }
// Tapping the dimmed backdrop dismisses the modal (same as Cancel/Later).
// Guarantees a modal can never permanently swallow every tap on the page —
// the full-screen backdrop sits above the bottom nav, so a modal the user
// doesn't notice (e.g. the update prompt popping under their thumb) used to
// read as "all navigation buttons stopped working".
$('modal').addEventListener('click', (e) => {
if (e.target === $('modal')) closeModal();
});
// ============================================================================
// Portrait PWA detection & behavioral layer
// Mirrors the CSS media query: (display-mode: standalone) and (orientation: portrait)
// The JS layer adds:
// • A `portrait-pwa` class on .app for any JS-driven conditional logic.
// • Auto-close the sidebar when rotating into portrait-standalone mode.
// • Auto-scroll the player into view when a video starts in portrait mode.
// ============================================================================
const _portraitPwaMQ = window.matchMedia
? window.matchMedia('(display-mode: standalone) and (orientation: portrait)')
: { matches: false, addEventListener: () => {} };
function isPortraitPWA() {
return _portraitPwaMQ.matches;
}
// Classic uses one scroll container at phone and intermediate widths in both
// browser and installed mode. Read CSS so navigation follows the actual layout.
function isStackedPlayerLayout() {
if (document.documentElement.dataset.layout === 'classic') {
return getComputedStyle(document.documentElement).getPropertyValue('--classic-stacked').trim() === '1';
}
if (document.documentElement.dataset.layout === 'bento-hub') {
return getComputedStyle(document.documentElement).getPropertyValue('--bento-stacked').trim() === '1';
}
return isPortraitPWA();
}
function playerScrollBehavior() {
return data.settings.reduceMotion || window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth';
}
function setupClassicLayout() {
const root = document.documentElement;
const search = $('searchInput');
const originalPlaceholder = search.getAttribute('placeholder');
const bars = [$('bottomNav'), $('miniBar'), $('sectionRail')].filter(Boolean);
const heightVars = ['--classic-nav-height', '--classic-mini-height', '--classic-rail-height'];
let pending = 0;
function measure() {
pending = 0;
const classic = root.dataset.layout === 'classic';
search.placeholder = classic ? 'Search YouTube…' : originalPlaceholder;
if (!classic) return;
for (const [index, el] of bars.entries()) {
const height = el.getClientRects().length ? el.offsetHeight : 0;
root.style.setProperty(heightVars[index], height + 'px');
}
}
function schedule() {
if (!pending) pending = requestAnimationFrame(measure);
}
const observer = new MutationObserver(schedule);
observer.observe(root, { attributes: true, attributeFilter: ['data-layout', 'data-font', 'data-density'] });
bars.forEach(el => observer.observe(el, { attributes: true, attributeFilter: ['class', 'style'] }));
if (typeof ResizeObserver !== 'undefined') {
const resize = new ResizeObserver(schedule);
bars.forEach(el => resize.observe(el));
}
window.addEventListener('resize', schedule);
if (window.visualViewport) window.visualViewport.addEventListener('resize', schedule);
// Use the actual clipped control deck, not document scroll position: desktop
// panes and the mobile body have different scroll owners.
if (typeof IntersectionObserver !== 'undefined') {
const mini = $('miniBar');
const visibility = new IntersectionObserver(([entry]) => {
if (root.dataset.layout !== 'classic' || !current || !current.meta) return;
if (entry.isIntersecting) {
if (!mini.classList.contains('hidden')) hideMiniBar();
} else if (mini.classList.contains('hidden')) showMiniBar();
}, { threshold: 0 });
visibility.observe($('controls'));
}
measure();
}
function applyPortraitPwaClass() {
const app = document.querySelector('.app');
if (!app) return;
if (isPortraitPWA()) {
app.classList.add('portrait-pwa');
} else {
app.classList.remove('portrait-pwa');
}
}
// In portrait mode, .body is the single scroll container (player pane on top,
// list pane below). Scrolling it to 0 brings the player fully into view.
function scrollPlayerIntoViewPortrait() {
restoreNowPlayingList();
if (!isStackedPlayerLayout()) return;
const body = document.querySelector('.body');
if (body) {
body.scrollTo({ top: 0, behavior: playerScrollBehavior() });
}
}
// Bring the list pane to the top of the portrait scroll container so a newly
// rendered view is actually on-screen. While a track is playing, the player
// pane above it is taller than the whole viewport — without this scroll the
// fresh view sits invisibly below the fold and every nav tap looks dead
// (the mobile "can't navigate while playing" bug).
function scrollListIntoViewPortrait() {
if (!isStackedPlayerLayout()) return;
const body = document.querySelector('.body');
const listPane = document.querySelector('.list-pane');
// offsetParent is null while the pane is hidden (pre-first-search) — its
// rect would be all zeros and scroll the body to a bogus offset.
if (!body || !listPane || !listPane.offsetParent) return;
const top = body.scrollTop + listPane.getBoundingClientRect().top - body.getBoundingClientRect().top;
body.scrollTo({ top, behavior: playerScrollBehavior() });
}
function setupPortraitPwaWatcher() {
applyPortraitPwaClass();
_portraitPwaMQ.addEventListener('change', () => {
applyPortraitPwaClass();
// Rotating into portrait-standalone: close any open sidebar drawer so the
// full-width layout isn't obscured.
if (isPortraitPWA()) closeSidebar();
});
}
// ============================================================================
// Layout-viewport anchor guard (iOS Safari / standalone PWA)
//
// The app is a fixed-viewport layout: body is overflow:hidden and only inner
// panes scroll, so the document itself must always sit at scroll position 0.
// iOS WebKit can still scroll the *layout viewport* behind our back — exiting
// native video fullscreen (webkitEnterFullscreen is the only fullscreen path
// on iPhone, and iOS enters it by itself when the phone rotates while a video
// plays), the on-screen keyboard revealing a focused input, or any
// scrollIntoView() walking up into <html>. Once that happens, fixed elements
// (bottom nav, mini-bar) are still *drawn* in place but their hit-testing
// regions are offset by the stray scroll amount, so taps on the nav buttons
// silently do nothing — and because the body isn't user-scrollable there is
// no gesture that can undo it. That is the "nav buttons stop working after
// playing a video" iPhone bug. Snap the document back to 0 whenever it ends
// up scrolled; skipped while an input is focused so we never fight the
// keyboard auto-scroll, then re-anchored once focus leaves the field.
function setupViewportAnchorGuard() {
const editing = () => {
const el = document.activeElement;
return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
};
const reanchor = () => {
if (editing()) return;
const doc = document.scrollingElement || document.documentElement;
if (window.scrollY || doc.scrollTop) window.scrollTo(0, 0);
};
window.addEventListener('scroll', reanchor);
document.addEventListener('focusout', () => setTimeout(reanchor, 50));
// Exiting native video fullscreen is the most reliable reproducer of the
// stray-scroll state; the scroll event alone doesn't always fire for it.
els.video.addEventListener('webkitendfullscreen', () => setTimeout(reanchor, 50));
// iOS can offset the layout viewport WITHOUT firing a window scroll event
// (keyboard dismissal animations, rotation mid-playback, in-app browser
// chrome changes). visualViewport does report those; hook it when present.
if (window.visualViewport) {
window.visualViewport.addEventListener('scroll', () => setTimeout(reanchor, 50));
window.visualViewport.addEventListener('resize', () => setTimeout(reanchor, 50));
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') setTimeout(reanchor, 50);
});
// Last-resort safety net for offsets none of the events above report:
// a 2s tick that reads scrollY (cheap) and snaps back only when displaced,
// so "nav taps silently do nothing" can never persist longer than a beat.
VisibleTimer.setIntervalWhenVisible(reanchor, 2000);
}
// ============================================================================
// Events
// ============================================================================
// ============================================================================
// Responsive sidebar drawer (mobile)
// ============================================================================
function openSidebar() {
const app = document.querySelector('.app');
if (!app) return;
app.classList.add('sidebar-open');
const t = $('sidebarToggle');
if (t) t.setAttribute('aria-expanded', 'true');
}
function closeSidebar() {
const app = document.querySelector('.app');
if (!app) return;
app.classList.remove('sidebar-open');
const t = $('sidebarToggle');
if (t) t.setAttribute('aria-expanded', 'false');
}
function setupSidebarDrawer() {
const toggle = $('sidebarToggle');
const backdrop = $('sidebarBackdrop');
if (toggle) {
toggle.addEventListener('click', () => {
const app = document.querySelector('.app');
if (app && app.classList.contains('sidebar-open')) closeSidebar();
else openSidebar();
});
}
if (backdrop) backdrop.addEventListener('click', closeSidebar);
}
function wireUI() {
setupSidebarDrawer();
// ---- Sidebar: linked-profile share block (static markup, wired once) ----
$('sidebarProfileCopy').addEventListener('click', copyProfileLink);
$('sidebarProfileShare').addEventListener('click', shareProfileLink);
// ---- Empty-home playlist grid ----
$('homePlNewBtn').addEventListener('click', () => newPlaylist(null));
const importSetlist = document.createElement('button'); importSetlist.type = 'button'; importSetlist.className = 'icon-btn'; importSetlist.textContent = '⇥'; importSetlist.title = 'Import setlist'; importSetlist.setAttribute('aria-label', 'Import setlist'); importSetlist.onclick = () => SetlistImport.open(); els.newPlaylistBtn.after(importSetlist); Lazy.intent(importSetlist,'feature:setlist-import');
$('homePlGrid').addEventListener('scroll', updateHomeGridScrollState, { passive: true });
window.addEventListener('resize', updateHomeGridScrollState);
// Runs a search, optionally painting a cached result set immediately
// (`instant`, from RecentSearches.getResults) before the network reply
// lands — the perceived-speed win for re-running a recent search.
// `searchSeq` guards against a slower response overwriting a newer one
// (the instant paint above can itself be "in flight" behind a background
// refresh when the user searches something else right after).
// The saved copy of `q` is read from IndexedDB. A copy younger than 15 days is
// shown as is (no network); an older one is shown at once and refreshed behind
// it. `refresh` (the "Update search" button) always asks the server for fresh
// results, bypassing its caches too.
async function runSearchQuery(q, { refresh = false } = {}) {
q = (q || '').trim();
if (!q) return;
const mySeq = ++searchSeq;
perfMark('ytp:search-start');
RecentSearches.add(q);
hideRecentSearches();
view = { type: 'search' };
const instant = refresh ? null : await RecentSearches.getResults(q);
if (mySeq !== searchSeq) return;
if (instant) {
searchResults = instant.results;
searchAttempted = true;
render();
showSearchMeta(q, instant.ts);
// Even on a fresh copy, make sure thumbnails are in the SW cache.
warmSearchThumbs(instant.results);
collectSearchMetadata(q, instant.results);
if (SearchLibrary.isFresh(instant.ts)) { scrollListIntoViewPortrait(); return; }
} else {
render();
showSearchSkeletons();
}
// Even when already on the search view (no view change for render() to
// detect), a new query means the user wants to see the results.
scrollListIntoViewPortrait();
const metaBtn = $('searchUpdateBtn');
if (metaBtn) { metaBtn.disabled = true; metaBtn.textContent = 'Updating…'; }
let networkDone = false, paintedLocal = false;
if (!instant && !refresh) {
// Known videos from the server's metadata store, painted until YouTube answers.
API.searchLocal(q).then((lr) => {
if (networkDone || mySeq !== searchSeq || !lr || !lr.ok || !(lr.results || []).length) return;
paintedLocal = true;
searchResults = lr.results;
searchAttempted = true;
renderList();
}).catch(() => {});
}
try {
const res = await API.search(q, { refresh });
networkDone = true;
if (mySeq !== searchSeq) return; // superseded by a newer search
if (!res || !res.ok) throw new Error(res?.error || 'Search failed');
const results = res.results || [];
searchResults = results;
searchAttempted = true;
renderList();
const ts = Date.now();
RecentSearches.cacheResults(q, results, ts);
showSearchMeta(q, ts);
warmSearchThumbs(results);
warmStreams(results.slice(0, 3).map((r) => r.id));
if (refresh) toast('Search updated');
perfMeasure('ytp:search', 'ytp:search-start');
} catch (err) {
networkDone = true;
if (mySeq !== searchSeq) return;
if (paintedLocal) return; // keep the server's known videos
if (instant) { showSearchMeta(q, instant.ts); if (refresh) toast('Could not update: ' + err.message); return; } // keep the saved copy
els.cards.innerHTML = '';
els.status.classList.remove('hidden');
els.status.textContent = '⚠ ' + err.message;
}
}
// "Saved copy · 3 d ago [↻ Update search]" above the results.
function showSearchMeta(q, ts) {
const m = $('searchMeta');
if (!m || view.type !== 'search' || !searchResults.length) return;
const left = Math.max(0, Math.ceil((SearchLibrary.FRESH_MS - (Date.now() - ts)) / 86400000));
m.innerHTML = `<span class="sm-info">Updated ${fmtAgo(ts)} · ${searchResults.length} results${SearchLibrary.isFresh(ts) ? ` · kept ${left} more day${left === 1 ? '' : 's'}` : ' · refreshing'}</span>`
+ '<button type="button" id="searchUpdateBtn" class="sm-btn">↻ Update search</button>';
m.classList.remove('hidden');
m.dataset.q = q;
}
$('searchMeta').addEventListener('click', (e) => {
if (e.target.closest('#searchUpdateBtn')) runSearchQuery($('searchMeta').dataset.q || '', { refresh: true });
});
els.runSearch = runSearchQuery;
els.searchForm.addEventListener('submit', (e) => {
e.preventDefault();
runSearchQuery(els.searchInput.value);
});
// ---- Recent searches dropdown (device-local; see RecentSearches above) ----
function renderRecentSearches() {
const list = RecentSearches.list();
if (!list.length && !SearchLibrary.count()) { els.searchRecent.innerHTML = ''; return; }
const cachedQueries = RecentSearches.cachedQueries();
els.searchRecent.innerHTML = list.map((q) => {
const isCached = cachedQueries.has(q.trim().toLowerCase());
return `
<div class="search-recent-row${isCached ? ' is-cached' : ''}" data-q="${escapeHtml(q)}" role="option">
<span class="sr-icon" title="${isCached ? 'Loads instantly, then refreshes' : 'Recent search'}">${isCached ? '⚡' : '🕐'}</span><span class="sr-text">${escapeHtml(q)}</span>
<button type="button" class="sr-remove" data-remove="${escapeHtml(q)}" aria-label="Remove ‘${escapeHtml(q)}’ from recent searches">✕</button>
</div>`;
}).join('') + `<button type="button" class="search-recent-clear" data-clear-all>Clear this list <small>(saved results are kept)</small></button>`
+ (SearchLibrary.count() ? `<button type="button" class="search-recent-clear" data-open-saved>📚 Saved searches (${SearchLibrary.count()})</button>` : '');
}
function showRecentSearches() {
renderRecentSearches();
if (!RecentSearches.list().length && !SearchLibrary.count()) return;
els.searchRecent.classList.remove('hidden');
}
function hideRecentSearches() { els.searchRecent.classList.add('hidden'); }
els.searchInput.addEventListener('focus', showRecentSearches);
els.searchInput.addEventListener('input', () => {
if (els.searchInput.value.trim()) hideRecentSearches(); else showRecentSearches();
});
els.searchInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideRecentSearches(); });
// A click on a row/remove/clear fires before the input's blur-driven hide
// would run, so use mousedown (fires first) rather than losing the target.
els.searchRecent.addEventListener('mousedown', (e) => {
const removeBtn = e.target.closest('[data-remove]');
if (removeBtn) {
e.preventDefault();
RecentSearches.remove(removeBtn.dataset.remove);
showRecentSearches();
return;
}
if (e.target.closest('[data-open-saved]')) {
e.preventDefault();
hideRecentSearches();
view = { type: 'savedSearches' };
render();
return;
}
if (e.target.closest('[data-clear-all]')) {
e.preventDefault();
RecentSearches.clear();
hideRecentSearches();
return;
}
const row = e.target.closest('.search-recent-row');
if (row) {
e.preventDefault();
const q = row.dataset.q;
els.searchInput.value = q;
runSearchQuery(q);
}
});
document.addEventListener('click', (e) => {
if (!els.searchForm.contains(e.target)) hideRecentSearches();
});
document.querySelectorAll('.nav-item').forEach((b) => {
if (!b.dataset.view) return; // e.g. 📱 Remote opens a panel, not a view
b.addEventListener('click', () => { view = { type: b.dataset.view }; if (b.dataset.view === 'search') setHomeOverPlayer(true); render(); });
});
// Bottom-nav (portrait PWA) — same view switching as sidebar nav. Search
// while something plays also brings back the home screen (see setHomeOverPlayer).
document.querySelectorAll('.bottom-nav-btn').forEach((b) => {
b.addEventListener('click', () => {
view = { type: b.dataset.view };
if (b.dataset.view === 'search') setHomeOverPlayer(true);
render();
});
});
els.newPlaylistBtn.addEventListener('click', () => newPlaylist(null));
// Landing-hero quick-search chips — rotate periodically
const CHIP_SETS = [
[{ q: 'lofi hip hop radio', label: 'lofi beats' }, { q: 'live news', label: 'live news' }, { q: 'relaxing music', label: 'relaxing music' }, { q: 'podcast highlights', label: 'podcasts' }],
[{ q: 'ambient jazz', label: 'ambient jazz' }, { q: 'tech talk coding', label: 'tech talks' }, { q: 'street food', label: 'street food' }, { q: 'travel vlog', label: 'travel vlogs' }],
[{ q: 'synthwave mix', label: 'synthwave' }, { q: 'asmr rain', label: 'rain sounds' }, { q: 'documentary', label: 'documentaries' }, { q: 'workout music', label: 'workout' }],
[{ q: '60s rock classics', label: 'classic rock' }, { q: 'nature sounds', label: 'nature' }, { q: 'data science', label: 'data science' }, { q: 'city walks', label: 'city walks' }],
];
const chipsEl = document.querySelector('.hero-suggests');
let chipRotation = 0;
function rotateChips() {
chipRotation = (chipRotation + 1) % CHIP_SETS.length;
const set = CHIP_SETS[chipRotation];
const buttons = chipsEl.querySelectorAll('.chip');
buttons.forEach((btn, i) => {
if (i < set.length) {
btn.dataset.q = set[i].q;
btn.textContent = set[i].label;
}
});
}
VisibleTimer.setIntervalWhenVisible(rotateChips, 8000);
document.querySelectorAll('.chip').forEach((c) => {
c.addEventListener('click', () => {
els.searchInput.value = c.dataset.q || c.textContent.trim();
els.searchForm.requestSubmit();
});
});
// Sleep timer — click cycles through off/15/30/45/60/90 min
$('sleepTimerBtn').addEventListener('click', openSleepTimer);
$('sleepCurtain').addEventListener('click', () => $('sleepCurtain').classList.add('hidden'));
$('sleepCancelBtn').addEventListener('click', () => { cancelSleepTimer(); toast('Sleep timer cancelled'); });
$('transitionBtn').addEventListener('click', () => Transition.cycle());
$('shareMomentBtn').addEventListener('click', () => Share.open().catch(error => toast(error.message)));
$('externalBtn').addEventListener('click', () => External.open().catch(error => toast(error.message)));
$('pipBtn').addEventListener('click', () => PiP.toggle());
$('eqBtn').addEventListener('click', () => EQ.open());
// A-B markers
$('abABtn').addEventListener('click', setAbA);
$('abBBtn').addEventListener('click', setAbB);
$('abClearBtn').addEventListener('click', clearAb);
$('rememberPosBtn').addEventListener('click', toggleRememberPos);
// Related panel collapse toggle
$('relatedToggleBtn').addEventListener('click', () => {
// One source of truth: SectionRail owns fold state and applies it here
// (it sets relatedCollapsed, the list class and this button's label).
SectionRail.setFolded('related', !relatedCollapsed);
});
// List filter
$('listFilterInput').addEventListener('input', (e) => {
listFilter = e.target.value.toLowerCase();
renderList();
});
// Batch bar actions
$('batchCancelBtn').addEventListener('click', () => { exitSelectMode(); renderList(); });
// Select all / none of what the list shows (the filter applies).
$('batchAllBtn').addEventListener('click', () => {
let list = currentList();
if (listFilter) list = list.filter((v) => `${v.title || ''} ${v.channel || ''}`.toLowerCase().includes(listFilter));
const ids = list.map((v) => v.id);
const all = ids.length && ids.every((id) => selectedIds.has(id));
ids.forEach((id) => { if (all) selectedIds.delete(id); else selectedIds.add(id); });
$('batchCount').textContent = `${selectedIds.size} selected`;
document.querySelectorAll('#cards .card[data-id]').forEach((c) => c.classList.toggle('selected', selectedIds.has(c.dataset.id)));
$('batchAllBtn').textContent = all ? 'Select all' : 'Select none';
});
$('batchQueueBtn').addEventListener('click', () => {
const all = currentList();
selectedIds.forEach((id) => {
const v = all.find((x) => x.id === id) || videoById(id);
if (v) addToQueue(v, { quiet: true });
});
toast(`Added ${selectedIds.size} to queue`);
exitSelectMode(); renderList();
});
$('batchDeleteBtn').addEventListener('click', () => {
const count = selectedIds.size;
showModal(`Delete ${count} item${count === 1 ? '' : 's'}?`, document.createTextNode('This cannot be undone.'), [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Delete', danger: true, onClick: () => {
if (view.type === 'history' || view.type === 'smart') {
data.history = data.history.filter((v) => !selectedIds.has(v.id));
} else if (view.type === 'playlist') {
const pl = data.playlists.find((p) => p.id === view.id);
if (pl) pl.videos = pl.videos.filter((v) => !selectedIds.has(v.id));
} else if (view.type === 'queue') {
selectedIds.forEach((id) => removeFromQueue(id));
}
persist(); closeModal(); exitSelectMode(); render();
toast(`Deleted ${count} item${count === 1 ? '' : 's'}`);
}},
]);
});
$('batchAddPlaylistBtn').addEventListener('click', () => {
const all = currentList();
const videos = [...selectedIds].map((id) => all.find((v) => v.id === id) || videoById(id)).filter(Boolean);
const body = document.createElement('div');
body.className = 'modal-list';
data.playlists.forEach((pl) => {
const btn = document.createElement('button');
btn.textContent = '+ ' + pl.name;
btn.onclick = () => {
videos.forEach((v) => { if (!pl.videos.some((x) => x.id === v.id)) { pl.videos.push(slim(v)); preload(v, { quiet: true }); } });
persist(); closeModal(); exitSelectMode();
toast(`Added ${videos.length} to ${pl.name}`);
};
body.appendChild(btn);
});
const nb = document.createElement('button');
nb.textContent = '+ New playlist…';
nb.onclick = () => { closeModal(); newPlaylistWithVideos(videos); exitSelectMode(); };
body.appendChild(nb);
showModal('Add to playlist', body, [{ label: 'Close', onClick: closeModal }]);
});
// Now-playing: Save (preload) + Add to playlist
els.saveBtn.addEventListener('click', async () => {
if (!current || !current.meta) return;
const id = current.meta.id;
// Ignore clicks while either direction is already in flight for this video.
if (downloading.has(id) || removing.has(id)) return;
if (cachedIds.has(id)) {
// Already saved → remove from cache. Guarded so a rapid double-click
// can't fire two deletes; the button shows a busy state meanwhile.
await runExclusive(removing, id, async () => {
updateNowPlayingActions();
try {
const res = await API.cacheDelete(id);
if (res && res.ok === false) throw new Error(res.error || 'delete failed');
cachedIds.delete(id);
markCardCacheState(id, 'none');
toast('Removed from offline cache');
} catch {
// Non-blocking error; leave it marked cached and re-enable the button.
toast('⚠ Could not remove from cache');
}
});
updateNowPlayingActions();
} else {
await preload(current.meta);
}
});
if ($('moreBtn')) $('moreBtn').addEventListener('click', openMoreActions);
if ($('addBtn')) $('addBtn').addEventListener('click', openAddChoice);
if ($('serviceBtn')) $('serviceBtn').addEventListener('click', () => enterServiceMode());
if ($('ctlMoreBtn')) $('ctlMoreBtn').addEventListener('click', openMoreControls);
if ($('exportBtn')) $('exportBtn').addEventListener('click', () => { if (current && current.meta) exportToDevice(current.meta); });
els.addPlaylistBtn.addEventListener('click', () => {
if (current && current.meta) openCardMenu(current.meta);
});
if (els.editBtn) els.editBtn.addEventListener('click', () => {
if (current && current.meta) openVideoEditor(current.meta);
});
if (els.brokenBtn) els.brokenBtn.addEventListener('click', () => {
if (!current || !current.meta || current.meta.custom) return;
const video = { ...current.meta };
const body = document.createElement('p');
body.textContent = `Re-download “${video.title || video.id}” on the server? Its saved copy is deleted `
+ 'and fetched again from YouTube; playback continues from YouTube meanwhile'
+ (cachedIds.has(video.id) ? ', and this device’s offline copy is replaced with the fresh one.' : '.');
showModal('Video broken?', body, [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Re-download', primary: true, onClick: () => { closeModal(); reportBroken(video); } },
]);
});
els.queueBtn.addEventListener('click', () => {
if (current && current.meta) addToQueue(current.meta);
});
// Now-playing channel name → channel view
els.npChannel.addEventListener('keydown', (e) => { if (e.key === 'Enter') els.npChannel.click(); });
els.npChannel.addEventListener('click', () => {
if (current && current.meta && channelKeyOf(current.meta)) {
openChannel(channelKeyOf(current.meta), current.meta.channel);
}
});
$('miniChannel').addEventListener('click', () => {
if (current && current.meta && channelKeyOf(current.meta)) {
openChannel(channelKeyOf(current.meta), current.meta.channel);
}
});
// Controls
els.playBtn.addEventListener('click', () => Player.toggle());
els.nextBtn.addEventListener('click', playNext);
els.prevBtn.addEventListener('click', playPrev);
els.shuffleBtn.addEventListener('click', () => toggleShuffle());
if ($('modeBtn')) $('modeBtn').addEventListener('click', cyclePlayMode);
els.loopBtn.addEventListener('click', toggleLoopOne);
els.repeatBtn.addEventListener('click', toggleRepeat);
if ($('fsMini')) $('fsMini').addEventListener('click', () => els.fsBtn.click());
const fullscreen = FullscreenOrientation.create({
stage: els.video.parentElement, video: els.video,
settings: () => data.settings, persist,
toggle: () => Player.toggle(), seek: time => Player.seek(time),
state: () => ({ time: Player.master.currentTime, duration: Player.master.duration, paused: Player.master.paused, title: current?.meta?.title || '' }),
});
window.playerFullscreen = fullscreen;
els.fsBtn.addEventListener('click', () => fullscreen.enter());
els.seek.addEventListener('input', () => {
const dur = Player.master.duration || 0;
if (dur) Player.seek((parseFloat(els.seek.value) / 1000) * dur);
});
els.volume.addEventListener('input', () => {
data.settings.volume = parseFloat(els.volume.value);
Player.applyVolume();
syncMuteIcon();
persist();
});
els.muteBtn.addEventListener('click', () => {
if (data.settings.volume > 0) { lastVolume = data.settings.volume; data.settings.volume = 0; }
else { data.settings.volume = lastVolume > 0 ? lastVolume : 1; }
els.volume.value = String(data.settings.volume);
Player.applyVolume();
syncMuteIcon();
persist();
});
els.speed.addEventListener('change', () => Player.applySpeed());
els.quality.addEventListener('change', () => {
data.settings.quality = els.quality.value;
persist();
if (!current) return;
// reload at the new quality, preserving position + play state
const t = Player.master.currentTime;
const wasPlaying = !Player.master.paused;
const q = chooseQuality();
Player.attach(q);
const restore = () => {
Player.seek(t);
if (!wasPlaying) Player.pause();
Player.master.removeEventListener('canplay', restore);
};
Player.master.addEventListener('canplay', restore);
});
els.audioOnlyToggle.addEventListener('change', () => {
data.settings.audioOnly = els.audioOnlyToggle.checked;
persist();
Wave.refresh();
if (!current) return;
const t = Player.master.currentTime;
const q = chooseQuality();
Player.attach(q);
const restore = () => { Player.seek(t); Player.master.removeEventListener('canplay', restore); };
Player.master.addEventListener('canplay', restore);
});
// Keyboard
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
// Tap-sync owns Enter/Backspace while it runs (see lyrics tap-sync).
if (Notes.handleSyncKey(e)) return;
if (e.code === 'Space') { e.preventDefault(); Player.toggle(); }
else if (e.code === 'ArrowRight') Player.seek(Player.master.currentTime + 5);
else if (e.code === 'ArrowLeft') Player.seek(Math.max(0, Player.master.currentTime - 5));
else if (e.key === 'f') els.fsBtn.click();
else if (e.key === 'm') els.muteBtn.click();
else if (e.key === 'l') toggleLoopOne();
else if (e.key === 'r') toggleRepeat();
else if (e.key === 's') toggleShuffle();
else if (e.key === 'q') { if (current && current.meta) addToQueue(current.meta); }
else if (e.key === 'a') { if (current) setAbA(); }
else if (e.key === 'b') { if (current) setAbB(); }
else if (e.key === 'p') { if (current) Notes.addBookmark(); }
else if (e.key === 'n') { if (current) Notes.addNote(); }
else if (e.key === 'i') PiP.toggle();
else if (e.key === '[') { if (current) Notes.jumpChapter(-1); }
else if (e.key === ']') { if (current) Notes.jumpChapter(1); }
else if (e.key === 'e') EQ.open();
else if (e.key === '?') toggleShortcutHelp();
else if (e.key === 'Escape') {
if (isServiceModeOpen()) exitServiceMode();
else {
const app = document.querySelector('.app');
if (app && app.classList.contains('sidebar-open')) closeSidebar();
else if (!$('shortcutHelp').classList.contains('hidden')) $('shortcutHelp').classList.add('hidden');
else if (selectMode) { exitSelectMode(); renderList(); }
}
}
});
$('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); });
// Service mode controls
$('servicePlayBtn').addEventListener('click', () => Player.toggle());
$('serviceNextBtn').addEventListener('click', playNext);
$('servicePrevBtn').addEventListener('click', playPrev);
$('serviceExitBtn').addEventListener('click', exitServiceMode);
$('serviceViewBtn').addEventListener('click', cycleServiceView);
$('svcFontDown').addEventListener('click', () => stepServiceFont(-1));
$('svcFontUp').addEventListener('click', () => stepServiceFont(1));
// Mini now-playing bar
$('miniPlayBtn').addEventListener('click', (e) => { e.stopPropagation(); Player.toggle(); });
$('updateIconBtn').addEventListener('click', updateFromIcon);
$('chBadge').addEventListener('click', (e) => { e.stopPropagation(); Notes.openChapterPicker(); });
$('miniBar').addEventListener('click', () => {
hideMiniBar();
// Scroll the player into view if needed. In portrait PWA, scroll only the
// .body pane (the designated scroll container) — scrollIntoView() also
// scrolls overflow:hidden ancestors up to <html> on iOS, leaving the
// document offset and fixed-element hit testing broken.
if (isStackedPlayerLayout()) scrollPlayerIntoViewPortrait();
else {
restoreNowPlayingList();
els.playerPane.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
// Opens the set-list view for whatever is already playing. No playlist arg,
// so enterServiceMode leaves the live queue and position untouched.
$('miniServiceBtn').addEventListener('click', (e) => {
e.stopPropagation();
enterServiceMode();
});
// On the home screen the mini bar is the way back, so ✕ returns to the player.
$('miniCloseBtn').addEventListener('click', (e) => { e.stopPropagation(); restoreNowPlayingList(); hideMiniBar(); });
// Drag-to-reorder: prevent default on cards container
els.cards.addEventListener('dragover', (e) => {
if (view.type === 'playlist' || view.type === 'queue') e.preventDefault();
});
}
// ============================================================================
// Sleep timer
// ============================================================================
// Options live in data.settings: sleepFade (seconds, 0 = none) and
// sleepAction ('pause' | 'close' | 'screen'). "End of this song" pauses at the
// track's end instead of advancing (Transition and onTrackEnded honour it).
let sleepAtTrackEnd = false;
let sleepFading = false;
function sleepFadeSec() {
const f = Number(data.settings.sleepFade);
return Number.isFinite(f) ? Math.max(0, Math.min(120, f)) : 30;
}
// Fade the sound down over the last `sleepFadeSec()` seconds.
function applySleepFade(remaining) {
const fade = sleepFadeSec();
if (!fade || remaining > fade) {
if (sleepFading) { sleepFading = false; Player.applyVolume(); }
return;
}
sleepFading = true;
try { Player.soundEl.volume = (data.settings.volume ?? 1) * Math.max(0, remaining / fade); } catch { /* iOS: volume is read-only */ }
}
function startSleepTimer(minutes, { endOfSong = false } = {}) {
cancelSleepTimer();
if (endOfSong) {
sleepAtTrackEnd = true;
updateSleepTimerUI();
return;
}
if (!minutes) return;
sleepTimerRemaining = minutes * 60;
updateSleepTimerUI();
sleepTimerTick = setInterval(() => {
sleepTimerRemaining--;
if (sleepTimerRemaining <= 0) {
finishSleep();
} else {
applySleepFade(sleepTimerRemaining);
updateSleepTimerUI();
}
}, 1000);
}
function cancelSleepTimer() {
if (sleepTimerTick) clearInterval(sleepTimerTick);
sleepTimerTick = null;
sleepTimerRemaining = 0;
sleepAtTrackEnd = false;
if (sleepFading) { sleepFading = false; Player.applyVolume(); }
updateSleepTimerUI();
}
// Time's up: pause (volume restored for next time), then the chosen action.
function finishSleep() {
cancelSleepTimer();
Player.pause();
Player.applyVolume();
const action = data.settings.sleepAction || 'pause';
if (action === 'close') {
toast('Sleep timer — paused, closing…');
try { window.close(); } catch { /* not allowed */ }
// Browsers only let scripts close windows they opened; if we're still
// here, fall back to the black screen.
setTimeout(() => showSleepCurtain('Sleep timer ended — this browser won’t let the app close itself · tap to wake'), 500);
} else if (action === 'screen') {
showSleepCurtain('Sleep timer ended · tap to wake');
} else {
toast('Sleep timer — paused');
}
}
// A black curtain; releasing the wake lock and leaving fullscreen lets the
// device's own screen timeout switch the display off.
function showSleepCurtain(msg) {
if (isServiceModeOpen()) exitServiceMode();
try { releaseWakeLock(); } catch { /* none held */ }
try { if (document.fullscreenElement) document.exitFullscreen(); } catch { /* fine */ }
const el = $('sleepCurtain');
$('sleepCurtainMsg').textContent = msg;
el.classList.remove('hidden', 'dim');
setTimeout(() => el.classList.add('dim'), 5000);
}
function updateSleepTimerUI() {
const active = sleepTimerRemaining > 0 || sleepAtTrackEnd;
$('sleepStatus').classList.toggle('hidden', !active);
if (sleepAtTrackEnd) {
$('sleepCountdown').textContent = '⏱ Pausing after this song';
} else if (active) {
const m = Math.floor(sleepTimerRemaining / 60);
const s = sleepTimerRemaining % 60;
$('sleepCountdown').textContent = `⏱ Pausing in ${m}:${String(s).padStart(2, '0')}${sleepFading ? ' · fading' : ''}`;
}
const btn = $('sleepTimerBtn');
if (btn) btn.classList.toggle('sleep-active', active);
}
function openSleepTimer() {
const body = document.createElement('div');
body.className = 'sleep-panel';
const fade = sleepFadeSec();
const action = data.settings.sleepAction || 'pause';
body.innerHTML = `
<div class="sp-label">Pause after</div>
<div class="sp-chips">
${[5, 15, 30, 45, 60, 90].map((m) => `<button type="button" class="sp-chip" data-min="${m}">${m} min</button>`).join('')}
<button type="button" class="sp-chip" data-end="1">End of this song</button>
</div>
<div class="sp-label">Fade out</div>
<div class="sp-chips sp-fade">
${[0, 10, 30, 60].map((f) => `<button type="button" class="sp-chip${f === fade ? ' on' : ''}" data-fade="${f}">${f ? f + ' s' : 'No fade'}</button>`).join('')}
</div>
<div class="sp-label">Then</div>
<div class="sp-chips sp-action">
<button type="button" class="sp-chip${action === 'pause' ? ' on' : ''}" data-action="pause">Just pause</button>
<button type="button" class="sp-chip${action === 'close' ? ' on' : ''}" data-action="close">Close the app</button>
<button type="button" class="sp-chip${action === 'screen' ? ' on' : ''}" data-action="screen">Turn the screen off</button>
</div>
<p class="sp-note">“Turn the screen off” blacks out the app and lets your device's screen timeout take over. Browsers only allow “Close the app” for windows they opened, so it falls back to the black screen. iPhones can't fade — they just pause.</p>`;
body.addEventListener('click', (e) => {
const b = e.target.closest('.sp-chip');
if (!b) return;
if (b.dataset.fade !== undefined) {
data.settings.sleepFade = Number(b.dataset.fade); persist();
body.querySelectorAll('.sp-fade .sp-chip').forEach((x) => x.classList.toggle('on', x === b));
} else if (b.dataset.action) {
data.settings.sleepAction = b.dataset.action; persist();
body.querySelectorAll('.sp-action .sp-chip').forEach((x) => x.classList.toggle('on', x === b));
} else if (b.dataset.end) {
startSleepTimer(0, { endOfSong: true });
closeModal();
toast('Pausing after this song');
} else if (b.dataset.min) {
startSleepTimer(Number(b.dataset.min));
closeModal();
toast(`Pausing in ${b.dataset.min} min${sleepFadeSec() ? ` (fading over the last ${sleepFadeSec()} s)` : ''}`);
}
});
const running = sleepTimerRemaining > 0 || sleepAtTrackEnd;
showModal('⏱ Sleep timer', body, [
...(running ? [{ label: 'Turn off', onClick: () => { cancelSleepTimer(); closeModal(); toast('Sleep timer off'); } }] : []),
{ label: 'Close', primary: true, onClick: closeModal },
]);
}
// ============================================================================
// A-B loop markers
// ============================================================================
function setAbA() {
if (!current) return;
abA = Player.master.currentTime;
saveAbMarkers();
updateAbUI();
toast(`A set at ${fmtTime(abA)}`);
}
function setAbB() {
if (!current) return;
abB = Player.master.currentTime;
saveAbMarkers();
updateAbUI();
toast(`B set at ${fmtTime(abB)}`);
}
function clearAb() {
abA = null; abB = null;
if (current && current.meta) {
delete data.abMarkers[current.meta.id];
const entry = currentPlaylistEntry();
if (entry) delete entry.ab;
persist();
}
updateAbUI();
toast('A-B loop cleared');
}
function saveAbMarkers() {
if (!current || !current.meta) return;
if (abA !== null || abB !== null) {
const marker = { a: abA, b: abB };
// When playing from a playlist, store the marker on that playlist's own
// copy of the video so each playlist keeps its own A-B loop and it syncs
// to the database alongside the playlist. Otherwise fall back to the
// global per-video map.
const entry = currentPlaylistEntry();
if (entry) entry.ab = marker;
else data.abMarkers[current.meta.id] = marker;
persist();
}
}
function restoreAbMarkers() {
if (!current || !current.meta) { abA = null; abB = null; updateAbUI(); return; }
// Prefer a marker stored on the current playlist's copy of the video; fall
// back to the global per-video marker.
const entry = currentPlaylistEntry();
const saved = (entry && entry.ab) || data.abMarkers[current.meta.id];
abA = saved ? saved.a : null;
abB = saved ? saved.b : null;
updateAbUI();
}
// Returns the video entry inside the playlist currently being played, if the
// active playback source is a playlist and the playing video belongs to it.
function currentPlaylistEntry() {
if (!current || !current.meta) return null;
if (!queueSource || !queueSource.startsWith('playlist:')) return null;
const plId = queueSource.slice('playlist:'.length);
const pl = data.playlists.find((p) => p.id === plId);
if (!pl || !Array.isArray(pl.videos)) return null;
return pl.videos.find((v) => v.id === current.meta.id) || null;
}
// ---------- Remember-position flag ----------
// Like A-B markers, the flag is per-song-per-playlist: stored on the playlist's
// own copy of the video (`entry.rememberPos`) when playing from that playlist,
// with `data.rememberPos[videoId]` as the fallback for non-playlist playback.
// Resume positions are only saved and restored for songs with the flag on.
function rememberPosEnabled() {
if (!current || !current.meta) return false;
const entry = currentPlaylistEntry();
return entry ? !!entry.rememberPos : !!data.rememberPos[current.meta.id];
}
function toggleRememberPos() {
if (!current || !current.meta) return;
const id = current.meta.id;
const on = !rememberPosEnabled();
const entry = currentPlaylistEntry();
if (entry) {
if (on) entry.rememberPos = true; else delete entry.rememberPos;
} else {
if (on) data.rememberPos[id] = true; else delete data.rememberPos[id];
}
persist();
updateRememberPosUI();
toast(on ? 'Remembering playback position' : 'Not remembering position');
}
function updateRememberPosUI() {
const btn = $('rememberPosBtn');
if (btn) btn.classList.toggle('active', rememberPosEnabled());
}
function updateAbUI() {
const aSet = abA !== null, bSet = abB !== null;
const aBtn = $('abABtn'), bBtn = $('abBBtn'), clrBtn = $('abClearBtn');
const ind = $('abIndicator');
if (aBtn) { aBtn.classList.toggle('active', aSet); if (aSet) aBtn.dataset.t = fmtTime(abA); else delete aBtn.dataset.t; }
if (bBtn) { bBtn.classList.toggle('active', bSet); if (bSet) bBtn.dataset.t = fmtTime(abB); else delete bBtn.dataset.t; }
if (clrBtn) clrBtn.classList.toggle('hidden', !(aSet && bSet));
if (ind) ind.classList.toggle('hidden', !aSet && !bSet);
const q = document.querySelector('.dk-quick');
if (q) q.classList.toggle('ab-on', aSet || bSet);
const aLbl = $('abALabel'), bLbl = $('abBLabel');
if (aLbl) { aLbl.textContent = 'A: ' + (aSet ? fmtTime(abA) : '--'); aLbl.classList.toggle('active', aSet); }
if (bLbl) { bLbl.textContent = 'B: ' + (bSet ? fmtTime(abB) : '--'); bLbl.classList.toggle('active', bSet); }
}
// ============================================================================
// Related videos
// ============================================================================
let relatedState = { status: 'idle', message: '' };
const fetchRelatedVideos = RelatedVideos.createLoader({
fetchRelated: (meta, options) => API.related(meta, options),
update(state) {
if (current?.meta?.id !== state.id) return;
relatedState = state; relatedVideos = state.cards; renderRelated();
},
});
async function loadRelated(options = {}) {
if (current?.meta) return fetchRelatedVideos(current.meta, options);
}
// ============================================================================
// Smart / auto playlists
// ============================================================================
const SMART_PLAYLISTS = [
{ id: 'mostPlayed', label: 'Most Played' },
{ id: 'recentlyWatched', label: 'Recently Watched' },
{ id: 'unwatched', label: 'Unwatched' },
{ id: 'autoMix', label: 'Auto Mix' },
];
function getSmartList(smartType) {
switch (smartType) {
case 'mostPlayed': {
const counts = data.playCount || {};
return data.history.slice().sort((a, b) => (counts[b.id] || 0) - (counts[a.id] || 0)).slice(0, 50);
}
case 'recentlyWatched':
return data.history.slice(0, 50);
case 'unwatched': {
const watched = new Set(data.history.map((v) => v.id));
const seen = new Set();
const out = [];
for (const pl of data.playlists)
for (const v of pl.videos)
if (!watched.has(v.id) && !seen.has(v.id)) { seen.add(v.id); out.push(v); }
return out;
}
case 'autoMix': {
const pool = data.history.slice(0, 100);
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
return pool.slice(0, 30);
}
default: return [];
}
}
// ============================================================================
// Batch operations
// ============================================================================
function exitSelectMode() {
selectMode = false;
selectedIds.clear();
const c = $('cards');
if (c) c.classList.remove('select-mode');
$('batchBar').classList.add('hidden');
$('batchAllBtn').textContent = 'Select all';
}
function toggleSelectMode() {
selectMode = !selectMode;
selectedIds.clear();
const c = $('cards');
if (c) c.classList.toggle('select-mode', selectMode);
$('batchBar').classList.toggle('hidden', !selectMode);
$('batchCount').textContent = '0 selected';
$('batchAllBtn').textContent = 'Select all';
renderList();
}
function toggleSelectCard(id) {
if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id);
$('batchCount').textContent = `${selectedIds.size} selected`;
document.querySelectorAll(`.card[data-id="${CSS.escape(id)}"]`).forEach((c) => c.classList.toggle('selected', selectedIds.has(id)));
}
function newPlaylistWithVideos(videos) {
const input = document.createElement('input');
input.type = 'text'; input.placeholder = 'Playlist name';
showModal('New playlist', input, [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Create', primary: true, onClick: () => {
const name = input.value.trim();
if (!name) return;
const pl = { id: uid(), name, videos: videos.map(slim) };
data.playlists.push(pl);
videos.forEach((v) => preload(v, { quiet: true }));
persist();
closeModal();
view = { type: 'playlist', id: pl.id };
render();
toast(`Created "${name}" with ${videos.length} videos`);
}},
]);
setTimeout(() => input.focus(), 50);
}
// ============================================================================
// Auto-backup
// ============================================================================
function doAutoBackup(silent = false) {
const json = JSON.stringify({ ...data, _version: 1 }, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ytplayer-backup-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
data.lastAutoBackup = Date.now();
persist();
if (!silent) toast('Backup exported');
}
function checkAutoBackup() {
if (!data.settings.autoBackupEnabled) return;
const interval = (data.settings.autoBackupIntervalDays || 7) * 86400000;
if (Date.now() - (data.lastAutoBackup || 0) >= interval) doAutoBackup(true);
}
function importBackup(e) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const imp = JSON.parse(ev.target.result);
if (!imp.playlists || !Array.isArray(imp.playlists)) throw new Error('Invalid backup format');
showModal('Import backup?', document.createTextNode('Merges playlists, history, and resume positions. Existing data is preserved.'), [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Import', primary: true, onClick: () => {
const existingPl = new Set(data.playlists.map((p) => p.id));
for (const pl of (imp.playlists || [])) if (pl.id && !existingPl.has(pl.id)) { data.playlists.push(pl); existingPl.add(pl.id); }
const histIds = new Set(data.history.map((v) => v.id));
for (const v of (imp.history || [])) if (!histIds.has(v.id)) { data.history.push(v); histIds.add(v.id); }
Object.assign(data.resumePositions, imp.resumePositions || {});
Object.assign(data.rememberPos, imp.rememberPos || {});
for (const [id, cnt] of Object.entries(imp.playCount || {})) data.playCount[id] = Math.max(data.playCount[id] || 0, cnt);
Object.assign(data.abMarkers, imp.abMarkers || {});
Object.assign(data.bookmarks, imp.bookmarks || {});
Object.assign(data.lyricOffsets, imp.lyricOffsets || {});
if (!data.stats && imp.stats) data.stats = imp.stats;
persist();
closeModal();
render();
toast('Backup imported');
}},
]);
} catch (err) { toast('⚠ Invalid backup: ' + err.message); }
};
reader.readAsText(file);
e.target.value = '';
}
// ============================================================================
// Build-tag polling — detect server restarts / new deploys (WEB mode only)
//
// The server stamps every response from GET /api/version with a `buildTag`
// that is stable for the lifetime of the process (changes on restart/deploy).
// We baseline the tag on first fetch; any subsequent change means new code is
// live and we surface the existing update banner so the user can reload.
// ============================================================================
// The build this page is actually running: the server stamps it into
// index.html (<meta name="ytp-build">), and the service worker caches that
// index.html together with the rest of the shell, so it always describes the
// code in this tab. Null when served without the server (static test serve).
const RUNNING_BUILD = (() => {
const v = document.querySelector('meta[name="ytp-build"]')?.content || '';
return v && !v.startsWith('__') ? v : null;
})();
// Fallback baseline for pages without the meta tag: the first tag seen.
let _knownBuildTag = null;
async function fetchServerBuild() {
const res = await fetch('/api/version', { cache: 'no-store' });
if (!res.ok) return null;
const data = await res.json();
return data.buildTag || null;
}
// The ONLY way the update banner opens: the server runs a different build
// than this page. Every trigger (the poll, a waiting worker, the activate
// broadcast) funnels through here, so none of them can re-open the banner
// once the page is current — which is what kept happening before.
async function maybeShowUpdateBanner() {
if (_updateBannerShown || _updating) return;
let server = null;
try { server = await fetchServerBuild(); } catch { return; /* offline — next poll */ }
if (!server) return;
const running = RUNNING_BUILD || _knownBuildTag;
if (!running) { _knownBuildTag = server; return; }
if (server !== running) {
// Start fetching the new build NOW, in the background: registration.update()
// installs the new worker, whose install precaches the whole shell. By the
// time the user presses "Refresh UI" it is usually already on disk, and the
// swap is instant. Purely an optimisation — if it never finishes, applyUpdate
// falls back to downloading.
prefetchUpdate();
showUpdateBanner(server);
}
}
// Kicks the service worker into installing the new build in the background.
// Safe to call repeatedly: update() is a no-op when a worker is already
// installing or waiting.
let _prefetchedFor = null;
function prefetchUpdate() {
if (!('serviceWorker' in navigator)) return;
const tag = _knownBuildTag || 'new';
if (_prefetchedFor === tag) return;
_prefetchedFor = tag;
(async () => {
try {
const reg = _swReg || await navigator.serviceWorker.getRegistration();
if (reg) await reg.update();
} catch { /* offline or blocked — "Refresh UI" still downloads */ }
})();
}
function checkBuildTag() {
return maybeShowUpdateBanner();
}
function pollBuildTag() {
VisibleTimer.setIntervalWhenVisible(checkBuildTag, 5 * 60 * 1000, { immediate: true });
}
// ============================================================================
// PWA — service worker registration and update banner (WEB mode only)
// ============================================================================
// Hard-reload helper: wipes every SW cache and unregisters the SW so the
// next page load fetches fresh assets from the network, not stale cache.
// Called from both the update dialog and the Settings → Force refresh UI button.
async function hardReloadUI() {
try {
// 1. Delete every named cache (app shell, thumbs, fonts, etc.)
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
}
// 2. Unregister every service worker registration so the browser
// fetches sw.js fresh on the next load rather than serving a
// cached copy that could re-populate the caches.
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
}
} catch { /* best effort — reload regardless */ }
// Hard navigation to the root forces the browser to fetch index.html from
// the network (no SW is registered any more to intercept it).
window.location.href = window.location.origin + '/';
}
let _updateBannerShown = false;
let _updating = false;
let _updateToast = null;
// After "Later" (or a failed download) an update icon sits beside the search
// box until the user takes the update; it always targets the newest build.
function setUpdateIcon(on) {
const b = $('updateIconBtn');
if (b) b.classList.toggle('hidden', !on);
}
async function updateFromIcon() {
if (_updating) return;
setUpdateIcon(false);
let target = null;
try { target = await fetchServerBuild(); } catch { /* offline */ }
if (!target || target === (RUNNING_BUILD || _knownBuildTag)) {
if (!target) { toast('Offline — try the update again when connected'); setUpdateIcon(true); }
return;
}
await applyUpdate(target);
}
function showUpdateBanner(target) {
// Only show the dialog once per page load
if (_updateBannerShown) return;
_updateBannerShown = true;
// However the dialog is dismissed (Later, backdrop), the icon remains.
setUpdateIcon(true);
// Show a modal dialog instead of a fleeting toast so the user can't miss it.
const body = document.createElement('p');
body.textContent = 'A new version of YT Player is available. Click "Refresh UI" to reload the page with the latest build. All your playlists and history are stored locally and will be preserved.';
showModal('⬆ Update available', body, [
{ label: 'Later', onClick: () => { closeModal(); setUpdateIcon(true); } },
{ label: 'Refresh UI', primary: true, onClick: async () => {
closeModal();
await applyUpdate(target);
}},
]);
}
// Tracks the current SW registration so applyUpdate() can reach the
// waiting worker without re-querying getRegistration().
let _swReg = null;
// "Refresh UI": download the new shell into the SW caches (all-or-nothing),
// activate a waiting worker if any, reload once — see sw-update.js. The
// attempt is recorded in sessionStorage so the reloaded page can confirm it
// is really running `target` (checkUpdateOutcome) instead of looping.
const UPDATE_STATE_KEY = 'ytpUpdateAttempt';
const UPDATE_MAX_ATTEMPTS = 3;
const SHELL_CACHE_RE = /^ytplayer-(?!thumbs$|fonts$)/;
function readUpdateState() {
try { return JSON.parse(sessionStorage.getItem(UPDATE_STATE_KEY) || 'null'); } catch { return null; }
}
function writeUpdateState(st) {
try {
if (st) sessionStorage.setItem(UPDATE_STATE_KEY, JSON.stringify(st));
else sessionStorage.removeItem(UPDATE_STATE_KEY);
} catch { /* storage blocked — the update still applies, just unverified */ }
}
async function applyUpdate(target, { quiet = false } = {}) {
if (_updating) return;
_updating = true;
const prev = readUpdateState();
writeUpdateState({ target, attempt: (prev ? prev.attempt : 0) + 1 });
// The toast is raised by onPhase below, once we know whether this is an
// instant swap or a download.
try {
const reg = _swReg || (('serviceWorker' in navigator) ? await navigator.serviceWorker.getRegistration() : null);
const SU = window.SwUpdate;
const reload = () => window.location.reload();
if (!SU) throw new Error('Update helper unavailable; try Refresh UI again when paused');
await SU.applyUpdate({
reg,
container: navigator.serviceWorker,
reload,
// When the waiting worker already downloaded this build in the
// background, the toast says so and nothing is re-downloaded.
askStatus: (worker) => SU.askCacheStatus(worker, { timeoutMs: 4000, setTimeout: (fn, ms) => setTimeout(fn, ms) }),
onPhase: (phase) => {
if (quiet) return;
if (_updateToast) _updateToast.remove();
_updateToast = phase === 'ready'
? toast('Applying the update…', { duration: 20000 })
: toast('Downloading the update…', { duration: 305000 });
},
refreshShell: ('caches' in window) ? () => SU.refreshShellInPlace({
cachesApi: caches,
fetchFn: (url, init) => fetch(url, init),
isShellCache: (name) => SHELL_CACHE_RE.test(name),
// Lossy link: 4 downloads at a time, 5 minutes overall, and files
// that arrived survive a timeout so a retry only fetches the rest.
timeoutMs: 300000,
concurrency: 4,
stagingKey: target,
attempts: 3,
attemptTimeoutMs: 25000,
setTimeout: (fn, ms) => setTimeout(fn, ms),
}) : null,
});
} catch (err) {
_updating = false;
writeUpdateState(null);
if (_updateToast) { _updateToast.remove(); _updateToast = null; }
toast('⚠ Update didn’t download (' + (err && err.message ? err.message : 'connection problem') + '). Tap the update icon to try again.', { duration: 6000 });
setUpdateIcon(true);
// Let the next poll re-offer it.
_updateBannerShown = false;
}
}
// After an update reload: confirm the page now runs the build it asked for.
// If a stubborn cache still served the old one, retry (bounded) instead of
// re-opening the banner in a loop.
async function checkUpdateOutcome() {
const st = readUpdateState();
if (!st) return;
// Suppress the banner while this is being decided.
_updateBannerShown = true;
let server = null;
try { server = await fetchServerBuild(); } catch { /* offline */ }
// Current if it runs what was asked for — or whatever is deployed now (a
// newer deploy may have landed in between).
if (!RUNNING_BUILD || RUNNING_BUILD === st.target || (server && RUNNING_BUILD === server)) {
writeUpdateState(null);
_updateBannerShown = false;
if (RUNNING_BUILD) toast('Updated ✓');
return;
}
if (st.attempt < UPDATE_MAX_ATTEMPTS) {
_updateToast = toast('Finishing the update…', { duration: 125000 });
setTimeout(() => applyUpdate(server || st.target, { quiet: true }), 1500);
return;
}
_updateBannerShown = false;
writeUpdateState(null);
toast('⚠ Couldn’t finish updating — the connection looks unstable. It will be offered again later.', { duration: 6000 });
}
async function registerServiceWorker() {
if (!WEB || !('serviceWorker' in navigator)) return;
try {
// updateViaCache:'none' prevents the browser from serving a stale
// cached copy of sw.js — the server always returns it fresh (no-store).
const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });
_swReg = reg;
Lazy.reportLayout(reg);
// A waiting worker only means "update pending" when this page is already
// controlled by a previous SW. On a FIRST install (fresh visit, or right
// after Settings → Force refresh unregisters everything) the brand-new
// worker passes through the `installed`/waiting state for a moment before
// activating — with no controller that is not an update, and showing the
// banner for it is exactly the "Update available keeps coming back after
// Refresh UI" loop.
const hasController = () => !!navigator.serviceWorker.controller;
// If a new SW is already waiting (e.g. user refreshed after an update),
// show the dialog right away.
if (reg.waiting && hasController()) maybeShowUpdateBanner();
// Listen for a new SW installing after the page is open.
reg.addEventListener('updatefound', () => {
const sw = reg.installing;
if (!sw) return;
sw.addEventListener('statechange', () => {
if (sw.state === 'installed' && reg.waiting && hasController()) maybeShowUpdateBanner();
});
});
// The SW can also broadcast SW_UPDATE_AVAILABLE on its own activate.
navigator.serviceWorker.addEventListener('message', (e) => {
if (e.data && e.data.type === 'SW_UPDATE_AVAILABLE') maybeShowUpdateBanner();
});
// Check for updates in the background (useful for long-lived sessions)
reg.update().catch(() => {});
} catch (err) {
console.warn('[sw] registration failed:', err.message);
}
}
// /?q=<query> — a shareable search link (same family as Share's /?v=&t=).
// Runs after wireUI() so the search form's submit handler exists; the param
// is stripped immediately so a reload doesn't re-run the search.
// ============================================================================
// Share target — links and audio/video files shared to the installed app
// (manifest share_target → sw.js receiveShare → here). Android / ChromeOS /
// desktop Chrome; iOS has no share target, but an iOS Shortcut can open
// /?shared=<link> and land in the same place.
// ============================================================================
// YouTube video id (+ start time) anywhere in a shared text.
function youtubeFromText(text) {
const t = String(text || '');
const m = /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?(?:[^\s#]*&)?v=|shorts\/|live\/|embed\/|v\/))([\w-]{11})/i.exec(t);
if (!m) return null;
const tm = /[?&#](?:t|start)=(\d+)(?:s)?/.exec(t.slice(m.index));
return { id: m[1], t: tm ? Number(tm[1]) : 0 };
}
const SHARE_INBOX = 'ytp-share-inbox';
const SHARED_PLAYLIST = 'Shared uploads';
function sharedUploadsPlaylist() {
let pl = data.playlists.find((p) => p.sharedUploads);
if (!pl) {
pl = { id: uid(), name: SHARED_PLAYLIST, videos: [], sharedUploads: true };
data.playlists.push(pl);
}
return pl;
}
// PUT the file with upload progress (fetch has none) → { ok, upload | error }.
function uploadSharedFile(blob, name, onProgress) {
return new Promise((resolve) => {
const fp = window.getFingerprint ? window.getFingerprint() : '';
const qs = new URLSearchParams({ name, title: name.replace(/\.[^.]+$/, ''), fp });
const x = new XMLHttpRequest();
x.open('PUT', '/api/uploads/shared?' + qs.toString());
x.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); };
x.onload = () => {
let j = {};
try { j = JSON.parse(x.responseText); } catch { /* not JSON */ }
resolve(x.status >= 200 && x.status < 300 && j.ok ? { ok: true, upload: j.upload } : { ok: false, status: x.status, error: j.error || 'upload failed (' + x.status + ')' });
};
x.onerror = () => resolve({ ok: false, status: 0, error: 'connection lost' });
x.send(blob);
});
}
// Upload every file the share target parked; each finished one joins the
// "Shared uploads" playlist. Network failures stay in the inbox for next time.
let _sharing = false;
async function processSharedFiles() {
if (_sharing || !('caches' in window)) return;
_sharing = true;
try {
const inbox = await caches.open(SHARE_INBOX);
const keys = await inbox.keys();
let done = 0;
for (const req of keys) {
const res = await inbox.match(req);
if (!res) continue;
const name = decodeURIComponent(res.headers.get('X-Name') || 'shared');
const blob = await res.blob();
const t = toast(`Uploading “${name}”…`, { duration: 600000 });
const r = await uploadSharedFile(blob, name, (got, total) => {
if (t) t.textContent = `Uploading “${name}”… ${Math.round((got / total) * 100)}%`;
});
if (t) t.remove();
if (r.ok && r.upload) {
await inbox.delete(req);
const u = r.upload;
const v = { id: u.id, title: u.title || name, channel: u.artist || 'Shared upload', duration: u.duration || 0 };
const pl = sharedUploadsPlaylist();
if (!pl.videos.some((x) => x.id === v.id)) pl.videos.unshift(slim(v));
persist();
done++;
toast(`Added “${v.title}” to ${SHARED_PLAYLIST} ✓`);
} else if (r.status === 0) {
toast('⚠ Upload paused — it will continue when you open the app online');
break;
} else {
await inbox.delete(req); // refused (type, size, quota) — retrying will not help
toast('⚠ ' + r.error, { duration: 6000 });
}
}
if (done) {
const pl = sharedUploadsPlaylist();
view = { type: 'playlist', id: pl.id };
render();
}
} catch { /* inbox unavailable */ } finally { _sharing = false; }
}
function bootShareFromUrl() {
let q;
try { q = new URLSearchParams(location.search); } catch { return; }
const shared = q.get('shared');
const files = q.get('share') === 'files';
if (shared === null && !files && !q.has('sharefail')) return;
try {
const u = new URL(location.href);
['shared', 'share', 'sharefail'].forEach((k) => u.searchParams.delete(k));
history.replaceState(null, '', u.pathname + u.search + u.hash);
} catch { /* keep */ }
if (q.has('sharefail')) toast('⚠ Open the app once, then share the file again');
if (shared) {
const yt = youtubeFromText(shared);
if (yt) {
playVideoAt(data.history.find((x) => x.id === yt.id) || { id: yt.id, title: '' }, yt.t);
} else {
// Not a video link: search for whatever was shared.
const text = shared.replace(/https?:\/\/\S+/g, '').trim() || shared.trim();
els.searchInput.value = text.slice(0, 200);
els.searchForm.requestSubmit();
}
}
if (files) processSharedFiles();
else setTimeout(processSharedFiles, 5000); // anything left from an earlier share
}
// /?view=<page> — manifest shortcuts and links into a page of the app.
const URL_VIEWS = new Set(['search', 'queue', 'history', 'saved', 'downloads', 'library', 'notes', 'settings', 'stats']);
function bootViewFromUrl() {
let v = '';
try { v = new URLSearchParams(location.search).get('view') || ''; } catch { return; }
if (!v) return;
try {
const u = new URL(location.href);
u.searchParams.delete('view');
history.replaceState(null, '', u.pathname + u.search + u.hash);
} catch { /* keep */ }
if (!URL_VIEWS.has(v)) return;
view = { type: v };
render();
}
function bootSearchFromUrl() {
let query = '';
try {
query = (new URLSearchParams(location.search).get('q') || '').trim();
} catch { return; }
if (!query) return;
try {
const u = new URL(location.href);
u.searchParams.delete('q');
history.replaceState(null, '', u.pathname + u.search + u.hash);
} catch { /* keep */ }
els.searchInput.value = query;
els.searchForm.requestSubmit();
}
// ============================================================================
// Boot
// ============================================================================
async function boot() {
perfMark('ytp:boot-start');
wirePlayerEvents();
wireUI();
wireShortcutHelp();
setupPortraitPwaWatcher();
setupClassicLayout();
setupViewportAnchorGuard();
try {
const loaded = await API.loadData();
if (loaded && typeof loaded === 'object') {
data = {
playlists: loaded.playlists || [],
history: loaded.history || [],
queue: loaded.queue || [],
customVideos: loaded.customVideos || [],
pinnedPlaylists: Array.isArray(loaded.pinnedPlaylists) ? loaded.pinnedPlaylists : [],
resumePositions: loaded.resumePositions || {},
rememberPos: loaded.rememberPos || {},
playCount: loaded.playCount || {},
abMarkers: loaded.abMarkers || {},
bookmarks: loaded.bookmarks || {},
lyricOffsets: loaded.lyricOffsets || {},
stats: loaded.stats || null,
lastAutoBackup: loaded.lastAutoBackup || 0,
settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) },
profile: loaded.profile || null,
};
}
} catch {
// first run / bridge not ready — start with defaults
}
// A ?list=… or ?profile=… share link REPLACES the synced slice, so those two
// still run before the first paint — otherwise the old playlists would flash
// and be swapped out. They are no-ops without the URL parameter.
const hasShareParam = (() => {
try {
const q = new URLSearchParams(location.search);
return q.has('list') || q.has('profile');
} catch { return false; }
})();
if (hasShareParam) {
await adoptSharedPlaylistFromUrl();
await adoptProfileFromUrl();
}
// Everything this device already knows is in localStorage, so paint it NOW.
// On a slow link the profile pull and the playlist inbox used to be awaited
// first, which left the sidebar empty for as long as the network took.
renderSmartSidebar();
render();
perfMeasure('ytp:boot', 'ytp:boot-start');
// …then reconcile with the server in the background, with the spinner up,
// and re-render only if something actually changed.
syncOnLaunch();
startPlaylistInboxWatch();
if(LowerThird.active()) await Lazy.load('feature:lower-third');
await Presenter.boot().catch(error => toast(error.message));
if (LowerThird.active()) return;
SectionRail.boot();
if (isMediaId(new URLSearchParams(location.search).get('v'))) Share.bootFromUrl().catch(error => toast(error.message));
bootViewFromUrl();
bootSearchFromUrl();
if (WEB) bootShareFromUrl();
Party.boot();
PartyDJ.register(() => $('navPartyBtn').click());
SetlistImport.configure({ library: async () => { let uploads = []; try { const j = await webFetch('/api/uploads?limit=200'); uploads = j.uploads || []; } catch {} return [...data.history, ...data.playlists.flatMap(p => p.videos || []), ...uploads]; }, search: async q => (await API.search(q)).results || [], modal: showModal, close: closeModal, create: (name, videos) => { const pl = { id: uid(), name, videos: videos.map(v => ({ ...slim(v), tags: v.tags, key: v.key, chordPro: v.chordPro })) }; data.playlists.push(pl); persist(); view = { type: 'playlist', id: pl.id }; render(); toast(`Imported ${videos.length} songs`); } });
DirectMedia.configure({ settings: () => data.settings, persist, current: () => current?.meta, meta: id => data.history.find(v => v.id === id) || data.playlists.flatMap(p => p.videos).find(v => v.id === id), notify: toast,
saved: async file => { cachedIds.add(file.id); await DeviceDB.putFile({ videoId: file.id, cid: file.cid, size: file.size, savedAt: Date.now(), state: 'unverified' }); if (!data.history.some(v => v.id === file.id)) data.history.unshift({ ...file, thumbnail: '' }); persist(); renderSidebar(); },
fallback: file => { const body = document.createElement('p'); body.textContent = 'The devices could not finish a direct connection. Keep both screens open and send again to resume, or choose the existing server download.'; showModal('Direct transfer unavailable', body, [{ label: 'Keep partial copy', onClick: closeModal }, { label: 'Use server copy', onClick: () => { closeModal(); preload(file, { retry: true, directFallback: true }); } }]); },
});
// Resolve peer bytes before opening the existing native export sheet.
// The sheet itself and its user-gesture actions remain unchanged.
const exportWithExistingSheet = exportToDevice;
exportToDevice = async (...args) => {
const video = args[0];
if (video?.id && !cachedIds.has(video.id) && DirectMedia.hasSource(video.id) && !await DirectMedia.obtain(video.id)) return;
return exportWithExistingSheet(...args);
};
Remote.boot();
LyricsDisplay.register();
LowerThird.register(() => $('remoteChip').click());
Transition.renderButton();
PiP.render();
LyricsWindow.configure({ prepare: () => { if (!isServiceModeOpen()) Notes.stageLyrics.set(true); }, view: () => isServiceModeOpen() && data.settings.serviceLyrics ? $('serviceLyrics') : $('stageLyrics'), line: () => $('serviceLyricsList')?.querySelector('.active')?.textContent || $('stageLyricsList')?.querySelector('.active')?.textContent || current?.meta?.title || 'Nothing playing', toggle: () => { Player.toggle(); updatePlayBtn(); }, next: playNext, refresh: () => { Notes.stageLyrics.render(true); Notes.serviceRefresh(); }, toast });
EQ.renderButton();
if (data.settings.eq?.g?.length === 5) { data.settings.eq.g = EqParameters.migrate(data.settings.eq.g); persist(); }
EQ.apply();
EqCore.register({ settings: () => data.settings, persist, open: EQ.open, apply: () => { EQ.apply(); EqRender.scheduleAll(); } });
VocalReducer.register({ settings: () => data.settings, persist, apply: () => { EQ.apply(); EqRender.scheduleAll(); } });
Piano.configure({ settings: () => data.settings, persist, state: () => ({ id: current?.meta?.id || '', time: Player.master?.currentTime || 0, duration: Player.master?.duration || current?.meta?.duration || 0, paused: Player.master?.paused !== false }), loop: (start, end) => { abA = start; abB = end; saveAbMarkers(); updateAbUI(); Player.seek(start); Player.play(); }, clearLoop: clearAb });
MidiPedals.configure({ settings: () => data.settings, persist, action: action => {
if (action === 'toggle') { Player.toggle(); updatePlayBtn(); }
else if (action === 'next') playNext();
else if (action === 'repeat') Notes.repeatSection?.();
else if (action === 'loop') { if (abA === null) setAbA(); else if (abB === null) setAbB(); else clearAb(); }
else if (action === 'panic') MidiPedals.fade({ volume: Player.soundEl.volume, setVolume: v => { Player.soundEl.volume = v; }, pause: () => { Player.pause(); updatePlayBtn(); }, restore: () => Player.applyVolume() });
} });
applyAppearance();
Lazy.load('layout:'+data.settings.layout).then(()=>{ if(window.BentoHub)BentoHub.playlists=()=>data.playlists||[]; renderSidebar(); }).catch(error=>toast(error.message));
if(data.settings.midiEnabled)Lazy.load('feature:midi').catch(error=>toast(error.message));
CarMode.configure({ input: els.searchInput, search: () => els.searchForm.requestSubmit(), toast, state: () => ({ title: current?.meta?.title || '', paused: Player.master?.paused !== false }), action: action => { if (action === 'toggle') { Player.toggle(); updatePlayBtn(); } else if (action === 'next') playNext(); else if (action === 'prev') playPrev(); else if (action === 'back') Player.seek(Math.max(0, (Player.master.currentTime || 0) - 15)); } });
updateProfileStatus();
updateLoopRepeatButtons();
syncMuteIcon();
updateQueueBadge();
els.volume.value = String(data.settings.volume ?? 1);
els.quality.value = data.settings.quality || 'auto';
els.audioOnlyToggle.checked = !!data.settings.audioOnly;
// Learn what's already cached, then top up any playlist videos that aren't.
await refreshCachedIds();
startCacheResyncWatch();
// Peer-to-peer: report what this device holds (on by default; settings.p2pShare).
Lazy.onLoad('feature:p2p', () => { if (WEB) {
window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '', getProfileSecret: () => ProfileSecret.getFor(data.profile?.name), initialDelay: 0 });
if (window.P2PTransfer) window.P2PTransfer.start({ canShare: () => false }); // Scoped invitations preserve receiver confirmation.
} });
// Preserve the existing eight-second sharing startup without parsing transport at boot.
if (WEB) setTimeout(() => Lazy.load('feature:direct').catch(error=>console.warn(error.message)),8000);
data.playlists.forEach(preloadPlaylist);
preloadPinnedPlaylists();
updateBottomChrome();
if (window.Downloads) Downloads.configure({
jobs: () => SaveQueue.pending().map(v => ({ ...v, status: downloading.has(v.id) ? (SaveQueue.get(v.id)?.phase === 'waiting' ? 'queued' : 'active') : v.status || 'paused', progress: SaveQueue.get(v.id) })),
action: (id, action) => { if (action === 'pause') SaveQueue.pause(id); else if (action === 'cancel') SaveQueue.cancel(id); else { const v = SaveQueue.pending().find(v => v.id === id); if (v) preload(v, { quiet: true, retry: action === 'retry' }); } },
playlists: () => data.playlists, slots: () => data.settings.parallelSaves || 4,
files: async () => { const result = []; if (!WEB || !navigator.storage?.getDirectory) return result;
const root = await navigator.storage.getDirectory();
for (const name of ['videos', 'eq-renders']) { try { const dir = await root.getDirectoryHandle(name); for await (const [filename, handle] of dir.entries()) { if (handle.kind !== 'file' || /\.json$/.test(filename)) continue; const file = await handle.getFile(); result.push({ id: filename.replace(/\.[^.]+(?:\.part)?$/, ''), size: file.size, kind: name === 'eq-renders' ? 'eq' : /\.(mp3|m4a|aac|ogg|opus|wav)(?:\.part)?$/i.test(filename) ? 'audio' : 'video' }); } } catch {} }
return result;
},
});
// Continue saves that were interrupted (closed app, dropped connection).
if (WEB) SaveQueue.start();
// iPhone: bring saved songs' EQ/levelled copies up to date (one at a time).
if (WEB) setTimeout(() => EqRender.scheduleAll(), 15000);
// Not awaited — artwork backfill must never delay first paint.
warmOfflineThumbs();
checkAutoBackup();
render();
els.searchInput.focus();
// Register service worker + ping server with fingerprint (WEB mode only)
if (WEB) checkUpdateOutcome();
registerServiceWorker();
if (WEB) pollBuildTag();
window.addEventListener('ytp-reload-required',()=>checkBuildTag());
window.addEventListener('ytp-lazy-error',event=>{ toast(event.detail?.error?.message||'Feature unavailable; try Refresh UI'); checkBuildTag(); });
Lazy.intent($('navPartyBtn'),'feature:party-dj'); Lazy.intent($('navRemoteBtn'),'feature:direct');
Lazy.intent($('remoteChip'),'feature:direct'); Lazy.intent($('eqBtn'),'feature:eq-core');
Lazy.intent($('shareMomentBtn'),'feature:share-external'); Lazy.intent($('externalBtn'),'feature:share-external');
Lazy.reportLayout?.(); Lazy.warm?.();
if (WEB) {
fetch('/api/user/sync', {
method: 'POST',
headers: data.profile?.name ? profileHeaders(data.profile.name, { 'Content-Type': 'application/json' }) : { 'Content-Type': 'application/json' },
body: JSON.stringify({
fingerprint: window.getFingerprint ? window.getFingerprint() : 'unknown',
appVersion: APP_VERSION,
playlists: data.playlists || [],
videos: [...(data.history || []), ...(data.queue || [])].slice(0, 500),
stats: data.stats || null,
profileName: data.profile?.name || '',
}),
}).then(() => Recommendations.update(true)).catch(() => {});
CatalogSync.flush();
}
}
document.addEventListener('DOMContentLoaded', boot);