diff --git a/.gitignore b/.gitignore index c9390e1..a37e8ff 100755 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,9 @@ plans/codex-report*.md # performance harness artifacts and temp files perf/fixtures/ perf/.tmp/ + +# UI regression screenshots and local measurements +plans/ui-shots/ + +# Transport regression screenshots +plans/transport-shots/ diff --git a/frontend/admin.html b/frontend/admin.html index 5c7eba0..1195045 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -6,6 +6,7 @@
${escapeHtml(note)}
` : ''}Let a phone drive this screen — play/pause, seek, volume, the queue and service mode — - while this computer plays to the TV or speakers. Pairing uses a one-time 6-digit code; nothing to sign in to.
` - : host.replaced ? `Another tab on this computer took over as the remote-controlled screen.
` - : !live ? `Connecting to the server…
` - : ` -${escapeHtml(location.origin)}/?present=${host.code} (same code), or scan the QR.Play on a computer hooked to the TV or speakers, and drive it from your phone.
`; - showModal('📱 Remote', body, [ - { label: paired ? `Control “${paired.hostName}”` : 'Use this phone as a remote', onClick: () => { closeModal(); openRemoteView(); } }, - { label: hosting ? 'This screen: remote on' : 'Control this screen from a phone', primary: true, onClick: () => { closeModal(); openHostModal(); } }, - ]); - } - - function bindUi() { - $('navRemoteBtn').addEventListener('click', () => { - if (typeof closeSidebar === 'function') { try { closeSidebar(); } catch { /* not open */ } } - openChooser(); - }); - $('remoteChip').addEventListener('click', openHostModal); - $('rvClose').addEventListener('click', closeRemoteView); - $('rvMenu').addEventListener('click', () => { - const p = load(PAIR_KEY); - showModal('Remote options', document.createTextNode(p ? `Paired with “${p.hostName}”.` : 'Not paired.'), [ - { label: 'Close', onClick: closeModal }, - ...(p ? [{ label: 'Unpair this phone', danger: true, onClick: () => { closeModal(); save(PAIR_KEY, null); try { rc.ws && rc.ws.close(); } catch { /* gone */ } showPairForm('Unpaired.'); } }] : []), - ]); - }); - $('rvPairBtn').addEventListener('click', () => doPair($('rvCode').value)); - $('rvCode').addEventListener('keydown', (e) => { if (e.key === 'Enter') doPair($('rvCode').value); }); - $('rvToggle').addEventListener('click', () => { - cmd('toggle'); - if (rc.state) { rc.state.cur = liveCur(); rc.stateAt = Date.now(); rc.state.paused = !rc.state.paused; renderRemote(); } - }); - $('rvPrev').addEventListener('click', () => cmd('prev')); - $('rvNext').addEventListener('click', () => cmd('next')); - const seek = $('rvSeek'); - seek.addEventListener('input', () => { - rc.seeking = true; - const dur = (rc.state && rc.state.dur) || 0; - $('rvCur').textContent = clock((Number(seek.value) / 1000) * dur); - }); - seek.addEventListener('change', () => { - const dur = (rc.state && rc.state.dur) || 0; - const t = (Number(seek.value) / 1000) * dur; - cmd('seek', { t }); - if (rc.state) { rc.state.cur = t; rc.stateAt = Date.now(); } - rc.seeking = false; - }); - $('rvVolume').addEventListener('input', (e) => { - const pct = Number(e.target.value); - $('rvVolVal').textContent = pct + '%'; - clearTimeout(rc.volTimer); - rc.volTimer = setTimeout(() => { rc.volTimer = null; cmd('volume', { v: pct / 100 }); }, 120); - }); - $('rvQueue').addEventListener('pointerdown', onDragStart); - $('rvQueue').addEventListener('click', (e) => { - const rm = e.target.closest('[data-rm]'); - if (rm) { cmd('remove', { i: Number(rm.dataset.rm) }); return; } - if (e.target.closest('.rv-handle')) return; - const row = e.target.closest('.rv-row'); - if (row) cmd('jump', { i: Number(row.dataset.i) }); - }); - $('rvAdd').addEventListener('click', openAddSheet); - $('rvSleep').addEventListener('click', () => { - const options = [0, 15, 30, 45, 60, 90]; - const curMin = rc.state && rc.state.sleep ? Math.ceil(rc.state.sleep / 60) : 0; - const next = options.find((o) => o > curMin) ?? 0; - cmd('sleep', { min: next }); - toast(next ? `Screen pauses in ${next} min` : 'Sleep timer off'); - }); - $('rvService').addEventListener('click', () => cmd('service', { on: !(rc.state && rc.state.service) })); - // Hosts keep phones current once a second while anything plays. - setInterval(() => { if (host.ws && host.remotes.length) { pushState(); pushQueue(); } }, 1000); - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible' && rc.open && !rc.ws) rcConnect(); - }); - } - - function boot() { - bindUi(); - if (hostCfg().enabled) hostConnect(); - // A scanned QR / shared link: /?pair=482913 - let code = ''; - try { code = (new URLSearchParams(location.search).get('pair') || '').replace(/\D/g, ''); } catch { /* no URL */ } - if (code) { - try { - const u = new URL(location.href); - u.searchParams.delete('pair'); - history.replaceState(null, '', u.pathname + u.search + u.hash); - } catch { /* leave the URL */ } - openRemoteView({ code }); - } - } - - return { boot, changed }; -})(); // ============================================================================ // Playlist transitions: normal · gapless · crossfade @@ -5808,90 +5126,7 @@ const CatalogSync = (() => { })(); function collectSearchMetadata(q, cards) { if (WEB) CatalogSync.send(q, cards); } -const Recommendations = (() => { - let cards = [], fetchedAt = 0, loading = false, error = false, actor = '', queuedRefresh = false; - let listHost = null; - const deviceFingerprint = () => window.getFingerprint ? window.getFingerprint() : 'unknown'; - const fingerprint = () => data.profile?.name ? 'profile:' + data.profile.name.toLowerCase() : 'device:' + deviceFingerprint(); - function load() { - const fp = fingerprint(); - if (fp === actor) return; - actor = fp; cards = []; fetchedAt = 0; - try { - const saved = JSON.parse(localStorage.getItem('ytpRecommendations:' + fp) || 'null'); - if (saved && Array.isArray(saved.cards)) cards = saved.cards; - } catch { /* no saved recommendations */ } - } - function paint(host) { - if (!host) return; - host.replaceChildren(); - const heading = document.createElement('div'); heading.className = 'rec-heading'; - const copy = document.createElement('div'); - const title = document.createElement('h2'); title.textContent = 'Recommended for you'; - const sub = document.createElement('p'); sub.textContent = error && cards.length ? 'Saved picks · reconnect to refresh' : 'Familiar favorites and something new, inspired by your listening.'; - copy.append(title, sub); - const refresh = document.createElement('button'); refresh.type = 'button'; refresh.className = 'rec-refresh'; - refresh.textContent = loading ? 'Updating…' : 'Refresh'; refresh.disabled = loading; - refresh.onclick = () => update(true); - heading.append(copy, refresh); host.appendChild(heading); - if (!cards.length) { - const empty = document.createElement('p'); empty.className = 'rec-empty'; empty.setAttribute('role', 'status'); - empty.textContent = loading ? 'Finding your next listen…' : error ? 'Recommendations are unavailable. You can still search or play your playlists.' : 'Search or play a few videos to start discovering recommendations here.'; - host.appendChild(empty); return; - } - const grid = document.createElement('div'); grid.className = 'rec-grid'; - cards.forEach((v, i) => { - const item = document.createElement('article'); item.className = 'rec-item'; - const play = document.createElement('button'); play.type = 'button'; play.className = 'rec-play'; - play.setAttribute('aria-label', 'Play ' + v.title); - const cover = document.createElement('span'); cover.className = 'rec-cover'; - const img = document.createElement('img'); img.src = v.thumbnail || ''; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; cover.appendChild(img); - const title = document.createElement('span'); title.className = 'rec-title'; title.textContent = v.title; - const channel = document.createElement('span'); channel.className = 'rec-channel'; channel.textContent = v.channel || ''; - play.append(cover, title, channel); play.onclick = () => { setHomeOverPlayer(false); playFromList(cards.slice(), i, 'recommendations'); }; - const reason = document.createElement('div'); reason.className = 'rec-reason'; reason.textContent = v.reason || 'Recommended for you'; - const actions = document.createElement('div'); actions.className = 'rec-actions'; - const queue = document.createElement('button'); queue.type = 'button'; queue.textContent = '+ Queue'; queue.setAttribute('aria-label', 'Queue ' + v.title); queue.onclick = () => addToQueue(v); - const more = document.createElement('button'); more.type = 'button'; more.textContent = '•••'; more.setAttribute('aria-label', 'More actions for ' + v.title); more.onclick = () => openCardMenu(v); - actions.append(queue, more); item.append(play, reason, actions); grid.appendChild(item); - }); - host.appendChild(grid); - } - function repaint() { - if (!WEB) return; - const home = $('homeRecommendations'); - if (home) { home.classList.remove('hidden'); paint(home); } - els.playerPane?.classList.add('has-recommendations'); - if (listHost?.isConnected) paint(listHost); - requestAnimationFrame(updateBottomChrome); - } - async function update(force = false) { - load(); - if (!WEB) return; - if (loading) { if (force) queuedRefresh = true; return; } - if (!force && Date.now() - fetchedAt < 300000) return; - const fp = actor; loading = true; error = false; repaint(); - try { - const name = data.profile?.name || ''; - const result = await webFetch('/api/recommendations?fp=' + encodeURIComponent(deviceFingerprint()) + (name ? '&profile=' + encodeURIComponent(name) : ''), { headers: name ? profileHeaders(name) : {} }); - if (fp !== fingerprint()) return; - if (!result.ok || !Array.isArray(result.results)) throw new Error('invalid recommendations'); - cards = result.results; - try { localStorage.setItem('ytpRecommendations:' + fp, JSON.stringify({ cards })); } catch { /* full */ } - } catch { error = true; } - finally { - loading = false; fetchedAt = fp === fingerprint() ? Date.now() : 0; repaint(); - if (queuedRefresh || fp !== fingerprint()) { queuedRefresh = false; update(true); } - } - } - function home() { if (WEB) { load(); repaint(); update(); } } - function inList() { - if (!WEB) return; - listHost = document.createElement('section'); listHost.className = 'recommendations'; listHost.setAttribute('aria-label', 'Recommended videos'); - els.cards.appendChild(listHost); load(); paint(listHost); update(); - } - return { home, inList, update }; -})(); + // ============================================================================ // Listening stats — tracker (data.stats, see frontend/stats-core.js) @@ -5910,7 +5145,8 @@ const StatsTrack = (() => { } function tick(cur) { const meta = current && current.meta; - if (!meta || !meta.id || !window.StatsCore) return; + 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; @@ -5942,127 +5178,10 @@ const StatsTrack = (() => { // Stats view // ============================================================================ function renderStats() { - StatsTrack.flush(); - els.listTitle.textContent = 'Stats'; - resetListHeaderExtras(); - els.listActions.innerHTML = ''; - els.status.classList.add('hidden'); - const c = els.cards; - const S = StatsCore.summarize(data.stats); - if (!S.totalPlays && S.totalSec < 60) { - c.innerHTML = ` -Stats start counting now — a song counts as played after 30 seconds. They sync with your online profile.
-| Day | Minutes |
|---|---|
| ${fmtDay(x.date)} | ${x.minutes} |
| No listening in this window. | |
)
// ============================================================================
@@ -6071,215 +5190,14 @@ function renderStats() {
// 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 = (() => {
- const noop = { boot() {}, active: () => false };
- const view = $('presenterView');
- if (!WEB || !view || typeof WebSocket === 'undefined') return noop;
- const overlay = LowerThird.active();
- const KEY = overlay ? `ytpOverlayPair:${new URLSearchParams(location.search).get('code') || ''}` : 'ytpPresenterPair';
- const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } };
- const save = (v) => { try { if (v) localStorage.setItem(KEY, JSON.stringify(v)); else localStorage.removeItem(KEY); } catch { /* blocked */ } };
- const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/remote?${new URLSearchParams(q)}`;
- const list = $('pvList');
- const st = { ws: null, state: null, stateAt: 0, hostName: '', vid: null, all: [], docOff: 0, idx: -2, retry: null, notes: new Map(), wake: null, idleTimer: null, seq: 0 };
+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'),
+};
- function setStatus(t) { $('pvStatus').textContent = t; }
-
- function show() {
- view.classList.remove('hidden');
- document.body.classList.add('presenter-open');
- // The app underneath may have focused its search box; keys belong here now.
- try { if (document.activeElement && !view.contains(document.activeElement)) document.activeElement.blur(); } catch { /* fine */ }
- try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ }
- keepAwake();
- bumpIdle();
- }
-
- async function keepAwake() {
- try { if ('wakeLock' in navigator && !st.wake) { st.wake = await navigator.wakeLock.request('screen'); st.wake.addEventListener('release', () => { st.wake = null; }); } } catch { /* not allowed */ }
- }
-
- function showPairForm(msg = '') {
- $('pvPair').classList.remove('hidden');
- $('pvMsg').textContent = msg;
- setStatus('Not paired');
- }
-
- async function pair(code) {
- code = String(code || '').replace(/\D/g, '');
- if (code.length !== 6) { showPairForm('Enter the 6-digit code from the playing screen.'); return; }
- $('pvMsg').textContent = 'Connecting…';
- try {
- const res = await fetch('/api/remote/pair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name: '🖥 Presenter' }) });
- const j = await res.json().catch(() => null);
- if (!j || !j.ok) { showPairForm('⚠ ' + ((j && j.error) || `HTTP ${res.status}`)); return; }
- save({ roomId: j.roomId, remoteId: j.remoteId, token: j.token, hostName: j.hostName });
- $('pvPair').classList.add('hidden');
- connect();
- } catch {
- showPairForm('⚠ Network error — try again');
- }
- }
-
- function connect() {
- const p = load();
- if (!p) { showPairForm(); return; }
- clearTimeout(st.retry);
- try { if (st.ws) st.ws.close(); } catch { /* gone */ }
- setStatus('Connecting…');
- const ws = new WebSocket(wsUrl({ role: 'remote', room: p.roomId, rid: p.remoteId, token: p.token, name: '🖥 Presenter' }));
- st.ws = ws;
- ws.onmessage = (e) => {
- let m; try { m = JSON.parse(e.data); } catch { return; }
- if (!overlay) { DirectMedia.attach('presenter', { send: m => ws.send(JSON.stringify(m)), peers: () => [{ id: 'host', name: st.hostName || 'Screen' }] }); }
- if (m.type === 'direct') { if (!overlay) DirectMedia.handle('presenter', m); return; }
- if (m.type === 'hello') { st.hostName = m.hostName || 'screen'; if (m.state) onState(m.state); else setStatus(`Following ${st.hostName}`); }
- else if (m.type === 'state') onState(m.state);
- else if (m.type === 'host-offline') { if (overlay) { st.all = []; LowerThird.paint([], -1); } setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); }
- };
- ws.onclose = (e) => {
- if (st.ws !== ws) return;
- st.ws = null;
- if (e.code === 4001) { save(null); showPairForm('This presenter was unpaired — enter a new code.'); return; }
- setStatus(e.code === 4004 ? 'Screen offline — retrying…' : 'Reconnecting…');
- st.retry = setTimeout(connect, 3000);
- };
- }
-
- function onState(s) {
- st.state = s;
- st.stateAt = Date.now();
- const v = s && s.v;
- setStatus(v ? `● Following ${st.hostName} · ${v.title}` : `● Following ${st.hostName} · nothing playing`);
- const id = v && v.id;
- if (id !== st.vid) loadSong(id, v);
- }
-
- async function loadSong(id, v) {
- const seq = ++st.seq;
- st.vid = id;
- st.all = [];
- st.docOff = 0;
- render(v);
- if (!id || !isMediaId(id)) return;
- let notes = st.notes.get(id);
- if (!notes) {
- try {
- const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}`)).json();
- notes = j && j.ok ? j : null;
- if (notes) { st.notes.set(id, notes); if (st.notes.size > 50) st.notes.delete(st.notes.keys().next().value); }
- } catch { notes = null; }
- }
- if (seq !== st.seq) return;
- const doc = notes && notes.lyrics && notes.lyrics.data;
- st.all = (doc && doc.lines) || [];
- st.docOff = (doc && Number(doc.offset)) || 0;
- render(v);
- }
-
- function render(v) {
- if (overlay) { LowerThird.paint([], -1); st.idx = -2; tick(true); return; }
- const sung = st.all.map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line');
- const title = $('pvTitle');
- if (!sung.length) {
- list.innerHTML = '';
- title.innerHTML = v ? `${escapeHtml(v.title)}${v.channel ? `${escapeHtml(v.channel)}` : ''}` : '';
- title.classList.toggle('hidden', !v);
- return;
- }
- title.classList.add('hidden');
- const pad = Math.round(list.clientHeight / 2);
- list.style.paddingTop = pad + 'px';
- list.style.paddingBottom = pad + 'px';
- list.innerHTML = sung.map((l) => `${LyricsDisplay.html(l)}`).join('');
- refit();
- st.idx = -2;
- tick(true);
- setTimeout(refit, 900); // once more after fonts/layout settle
- }
-
- function refit() {
- fitLyricLines(list, { maxShare: 0.2, maxPx: 240 });
- }
-
- function liveTime() {
- const s = st.state;
- if (!s) return 0;
- return s.paused ? s.cur : s.cur + ((Date.now() - st.stateAt) / 1000) * (s.rate || 1);
- }
-
- function tick(force = false) {
- if (view.classList.contains('hidden') || !st.all.length || !st.state) return;
- if (overlay) { LowerThird.paint(st.all, LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0))); return; }
- const idx = LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0));
- if (idx === st.idx && !force) return;
- st.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);
- });
- const target = pos >= 0 ? rows[pos] : rows[0];
- if (target) {
- const go = () => list.scrollTo({ top: Math.max(0, target.offsetTop - list.clientHeight / 2 + target.offsetHeight / 2), behavior: 'smooth' });
- go();
- setTimeout(go, 380);
- }
- }
-
- function toggleFullscreen() {
- if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
- else view.requestFullscreen?.().catch(() => {});
- }
-
- // The status bar and cursor hide after 3 s without mouse movement.
- function bumpIdle() {
- view.classList.remove('idle');
- clearTimeout(st.idleTimer);
- st.idleTimer = setTimeout(() => view.classList.add('idle'), 3000);
- }
-
- function boot() {
- let param = null;
- try { param = new URLSearchParams(location.search).get(overlay ? 'code' : 'present'); } catch { /* no URL */ }
- if (param === null) return;
- try {
- const u = new URL(location.href);
- u.searchParams.delete('present');
- history.replaceState(null, '', u.pathname + (u.search || '') + u.hash);
- } catch { /* keep */ }
- show();
- const code = String(param).replace(/\D/g, '');
- if (overlay && load()) connect();
- else if (code.length === 6) pair(code);
- else if (load()) connect();
- else showPairForm();
- setInterval(() => tick(), 200);
- window.addEventListener('resize', () => { refit(); tick(true); });
- if (document.fonts) {
- document.fonts.ready.then(refit);
- document.fonts.addEventListener('loadingdone', refit);
- }
- view.addEventListener('mousemove', bumpIdle);
- view.addEventListener('dblclick', toggleFullscreen);
- $('pvFull').addEventListener('click', toggleFullscreen);
- $('pvPairBtn').addEventListener('click', () => pair($('pvCode').value));
- $('pvCode').addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter') pair($('pvCode').value); });
- document.addEventListener('keydown', (e) => {
- if (view.classList.contains('hidden') || (e.target.closest && e.target.closest('#pvPair'))) return;
- if (e.key === 'f' || e.key === 'F') { e.preventDefault(); e.stopImmediatePropagation(); toggleFullscreen(); }
- else if (e.key === 'b' || e.key === 'B') { e.preventDefault(); e.stopImmediatePropagation(); $('pvBlank').classList.toggle('hidden'); }
- }, true);
- document.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'visible') { keepAwake(); if (!st.ws && load()) connect(); }
- });
- }
-
- return { boot, active: () => !view.classList.contains('hidden') };
-})();
// Open a video and jump to `t` seconds once it can play (deep links, notes).
async function playVideoAt(v, t = 0) {
@@ -6299,261 +5217,18 @@ async function playVideoAt(v, t = 0) {
// ============================================================================
// 📁 Library — video/audio uploaded to this server
// ============================================================================
-async function renderLibrary() {
- els.listTitle.textContent = 'Library';
- resetListHeaderExtras();
- els.listActions.innerHTML = '';
- els.status.classList.add('hidden');
- els.cards.innerHTML = 'Loading the server library…';
- let list = [];
- try {
- const j = await (await fetch('/api/uploads?limit=200')).json();
- list = (j && j.ok && j.uploads) || [];
- } catch {
- els.cards.innerHTML = '⚠ Could not reach the server';
- return;
- }
- if (view.type !== 'library') return;
- if (!list.length) {
- els.cards.innerHTML = `
-
-
- Nothing uploaded yet
- An admin can upload videos and audio at /admin; they show up here and in search, with their cover art and any embedded lyrics.
- `;
- return;
- }
- const cards = list.map((u) => ({
- id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', duration: u.duration,
- thumbnail: u.art ? `/api/uploads/${u.id}/art` : '', kind: u.kind, upload: true,
- }));
- els.cards.innerHTML = '';
- cards.forEach((v, i) => els.cards.appendChild(renderCard(v, i, cards)));
-}
+
// ============================================================================
// 📝 Notes view — every video's personal notes & bookmarks
// ============================================================================
-function renderNotesView() {
- els.listTitle.textContent = 'Notes';
- els.status.classList.add('hidden');
- const groups = Object.entries(data.bookmarks || {})
- .filter(([, list]) => Array.isArray(list) && list.length)
- .map(([id, list]) => {
- const h = data.history.find((x) => x.id === id);
- const first = list.find((b) => b.vt) || {};
- return { id, title: first.vt || (h && h.title) || id, channel: first.vc || (h && h.channel) || '', list };
- });
- // Most recently played first (history order), the rest after.
- const order = new Map(data.history.map((v, i) => [v.id, i]));
- groups.sort((a, b) => (order.has(a.id) ? order.get(a.id) : 1e9) - (order.has(b.id) ? order.get(b.id) : 1e9));
- els.listActions.innerHTML = groups.length ? '' : '';
- const c = els.cards;
- if (!groups.length) {
- c.innerHTML = `
-
-
- No notes yet
- While a video plays, open Chapters & bookmarks → 📝 Note (or press N) to write a note tied to that exact moment. They sync with your online profile.
- `;
- return;
- }
- const total = groups.reduce((n, g) => n + g.list.length, 0);
- c.innerHTML = `
-
-
- ${groups.map((g) => `
-
- ${escapeHtml(g.title)}${escapeHtml(g.channel)} · ${g.list.length}
- ${g.list.map((b) => `
- `).join('')}
- `).join('')}
- `;
- c.querySelector('.nv-filter').addEventListener('input', (e) => {
- const q = e.target.value.trim().toLowerCase();
- c.querySelectorAll('.nv-group').forEach((g) => {
- let any = false;
- g.querySelectorAll('.nv-item').forEach((it) => { const hit = !q || it.dataset.q.includes(q); it.classList.toggle('hidden', !hit); any = any || hit; });
- g.classList.toggle('hidden', !any);
- });
- });
- c.querySelector('.nv-filter').addEventListener('keydown', (e) => e.stopPropagation());
- c.querySelectorAll('.nv-item').forEach((it) => it.addEventListener('click', () => {
- const g = groups.find((x) => x.id === it.closest('.nv-group').dataset.id);
- playVideoAt({ id: g.id, title: g.title, channel: g.channel }, Number(it.dataset.t));
- }));
- const ex = $('notesExportBtn');
- if (ex) ex.addEventListener('click', () => {
- const md = ['# YT Player notes', `_Exported ${new Date().toLocaleString()}_`, ''];
- for (const g of groups) {
- md.push(`## ${g.title}`, `${g.channel ? g.channel + ' · ' : ''}https://youtu.be/${g.id}`, '');
- for (const b of g.list) {
- md.push(`- **[${LyricsCore.fmtClock(b.t)}](${location.origin}/?v=${g.id}&t=${Math.floor(b.t)})** ${b.title}`);
- if (b.note && b.note !== b.title) md.push(...b.note.split('\n').map((l) => ` ${l}`));
- }
- md.push('');
- }
- const blob = new Blob([md.join('\n')], { type: 'text/markdown' });
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = `ytplayer-notes-${new Date().toISOString().slice(0, 10)}.md`;
- a.click();
- setTimeout(() => URL.revokeObjectURL(a.href), 2000);
- });
-}
+
// ============================================================================
// Timestamp sharing — deep links (/?v=ID&t=SEC) and GIFs of a moment
// ============================================================================
-const Share = (() => {
- const appLink = (id, t) => `${location.origin}/?v=${encodeURIComponent(id)}${t >= 1 ? `&t=${Math.floor(t)}` : ''}`;
- const ytLink = (id, t) => `https://youtu.be/${encodeURIComponent(id)}${t >= 1 ? `?t=${Math.floor(t)}` : ''}`;
+const Share = Lazy.proxy('feature:share-external', ['open', 'bootFromUrl'], 'Share');
- async function copy(text, label) {
- try { await navigator.clipboard.writeText(text); toast(`${label} copied ✓`); }
- catch { toast('Copy blocked — long-press the link to copy it'); }
- }
-
- function open() {
- const meta = current && current.meta;
- if (!meta || !meta.id || meta.custom) { toast('Play a YouTube video first'); return; }
- const t0 = Math.floor(Player.master.currentTime || 0);
- const body = document.createElement('div');
- body.className = 'share-moment';
- body.innerHTML = `
-
-
- This app
- ${isUploadId(meta.id) ? '' : 'YouTube'}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
- const timeIn = body.querySelector('.sm-time');
- const cur = () => { const v = LyricsCore.parseStamp(timeIn.value); return v === null ? t0 : v; };
- const refresh = () => {
- body.querySelector('.sm-app').textContent = appLink(meta.id, cur());
- const yt = body.querySelector('.sm-yt');
- if (yt) yt.textContent = ytLink(meta.id, cur());
- };
- timeIn.addEventListener('input', refresh);
- timeIn.addEventListener('keydown', (e) => e.stopPropagation());
- refresh();
- // Soundbite range: the A-B loop when one is set, else the next 20 s.
- const bStart = abA !== null ? abA : t0;
- const bEnd = abB !== null && abB > bStart ? abB : bStart + 20;
- body.querySelector('.sm-b-start').value = LyricsCore.fmtClock(bStart);
- body.querySelector('.sm-b-end').value = LyricsCore.fmtClock(bEnd);
- body.querySelectorAll('.sm-bite input').forEach((i) => i.addEventListener('keydown', (e) => e.stopPropagation()));
- let biteBlob = null;
- let gifBlob = null;
- body.addEventListener('click', async (e) => {
- const act = e.target.closest('[data-act]');
- if (!act) return;
- if (act.dataset.act === 'app') copy(appLink(meta.id, cur()), 'App link');
- else if (act.dataset.act === 'yt') copy(ytLink(meta.id, cur()), 'YouTube link');
- else if (act.dataset.act === 'gif') {
- const out = body.querySelector('.sm-gif-out');
- out.innerHTML = 'Making the GIF…';
- act.disabled = true;
- try {
- const d = body.querySelector('.sm-len').value;
- const res = await fetch(`/api/media/${encodeURIComponent(meta.id)}/gif?t=${cur()}&d=${d}&w=480`);
- if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); }
- gifBlob = await res.blob();
- const url = URL.createObjectURL(gifBlob);
- const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(cur())}s.gif`;
- out.innerHTML = `
- ⤓ Download
- ${navigator.canShare && navigator.canShare({ files: [new File([gifBlob], name, { type: 'image/gif' })] }) ? '' : ''}`;
- out.dataset.name = name;
- } catch (err) {
- out.innerHTML = `⚠ ${escapeHtml(err.message)}`;
- } finally {
- act.disabled = false;
- }
- } else if (act.dataset.act === 'bite') {
- const out = body.querySelector('.sm-bite-out');
- const a = LyricsCore.parseStamp(body.querySelector('.sm-b-start').value);
- const b = LyricsCore.parseStamp(body.querySelector('.sm-b-end').value);
- const fmt = body.querySelector('.sm-b-fmt').value;
- const max = fmt === 'm4r' ? 40 : 60;
- if (a === null || b === null || b <= a) { out.innerHTML = '⚠ Times must look like 1:23, end after start'; return; }
- if (b - a > max) { out.innerHTML = `⚠ Max ${max} s for ${fmt === 'm4r' ? 'an iPhone ringtone' : 'a soundbite'}`; return; }
- out.innerHTML = 'Cutting the soundbite…';
- act.disabled = true;
- try {
- const res = await fetch(`/api/media/${encodeURIComponent(meta.id)}/clip?start=${a}&end=${b}&fmt=${fmt}`);
- if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); }
- biteBlob = await res.blob();
- const url = URL.createObjectURL(biteBlob);
- const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(a)}s.${fmt}`;
- out.dataset.name = name;
- out.dataset.type = fmt === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
- out.innerHTML = `
- ⤓ Download
- ${navigator.canShare && navigator.canShare({ files: [new File([biteBlob], name, { type: out.dataset.type })] }) ? '' : ''}
- ${fmt === 'm4r' ? 'iPhone: share it to GarageBand (or drag into Finder with the phone connected) to set it as a ringtone.
' : ''}`;
- } catch (err) {
- out.innerHTML = `⚠ ${escapeHtml(err.message)}`;
- } finally {
- act.disabled = false;
- }
- } else if (act.dataset.act === 'sharebite' && biteBlob) {
- const o = body.querySelector('.sm-bite-out');
- try { await navigator.share({ files: [new File([biteBlob], o.dataset.name, { type: o.dataset.type })], title: meta.title }); } catch { /* dismissed */ }
- } else if (act.dataset.act === 'sharegif' && gifBlob) {
- const name = body.querySelector('.sm-gif-out').dataset.name;
- try { await navigator.share({ files: [new File([gifBlob], name, { type: 'image/gif' })], title: meta.title }); } catch { /* dismissed */ }
- }
- });
- const actions = [{ label: 'Done', onClick: closeModal }];
- if (navigator.share) {
- actions.push({ label: '↗ Share link', primary: true, onClick: async () => {
- try { await navigator.share({ title: meta.title, text: `${meta.title} @ ${LyricsCore.fmtClock(cur())}`, url: appLink(meta.id, cur()) }); } catch { /* dismissed */ }
- } });
- } else {
- actions.push({ label: 'Copy app link', primary: true, onClick: () => copy(appLink(meta.id, cur()), 'App link') });
- }
- showModal('🔗 Share this moment', body, actions);
- }
-
- // /?v=&t= — open that video at that moment.
- async function bootFromUrl() {
- let id = '', t = 0;
- try {
- const q = new URLSearchParams(location.search);
- id = (q.get('v') || '').trim();
- t = Math.max(0, parseFloat(q.get('t')) || 0);
- } catch { return; }
- if (!isMediaId(id)) return;
- try {
- const u = new URL(location.href);
- u.searchParams.delete('v');
- u.searchParams.delete('t');
- history.replaceState(null, '', u.pathname + u.search + u.hash);
- } catch { /* keep */ }
- await playVideoAt(data.history.find((x) => x.id === id) || { id, title: '' }, t);
- }
-
- return { open, bootFromUrl };
-})();
// ============================================================================
// Loudness levelling (EBU R128) — every track toward -14 LUFS
@@ -6671,8 +5346,8 @@ const EqRender = (() => {
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 = VocalReducer.amount(data.settings.vocalLevel ?? 100);
- const width = EqCore.width(data.settings.stereoWidth);
+ 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 };
}
@@ -6711,7 +5386,7 @@ const EqRender = (() => {
function writeViaWorker(name, buffer) {
return new Promise((resolve, reject) => {
- const w = new Worker('/opfs-worker.js');
+ 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]);
@@ -6723,6 +5398,7 @@ const EqRender = (() => {
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();
@@ -6846,6 +5522,7 @@ const JamesDSP = (() => {
await activate(Player.soundEl);
}
async function apply() { if (data.settings.jamesDsp?.enabled) await activate(Player.soundEl); }
+ document.addEventListener('play', (e) => { if (data.settings.jamesDsp?.enabled && e.target === Player.soundEl) activate(e.target).catch(fail); }, true);
return { activate, fail, toggle, apply, get controller() { return state.controller; } };
})();
@@ -6856,8 +5533,9 @@ const JamesDSP = (() => {
// 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 EQ_BANDS = EqCore.bands;
-const EQ_PRESETS = EqCore.presets;
+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;
@@ -6872,12 +5550,16 @@ const EQ = (() => {
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 EqCore.migrate(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) => {
@@ -6915,7 +5597,7 @@ const EQ = (() => {
// Apply the stored curve; wires the graph up the first time it's non-flat.
function apply() {
if (!available()) return;
- if (!st.ctx && isFlat() && VocalReducer.amount(data.settings.vocalLevel ?? 100) === 100 && EqCore.width(data.settings.stereoWidth) === 0) 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);
@@ -6943,7 +5625,8 @@ const EQ = (() => {
if (typeof EqRender !== 'undefined') EqRender.scheduleAll();
}
- function open() {
+ async function open() {
+ try { await loadSoundFeatures(); } catch(error) { toast(error.message); return; }
const body = document.createElement('div');
body.className = 'eq-panel';
if (!AC) {
@@ -7070,7 +5753,7 @@ const Gestures = (() => {
if (x < 1 / 3) seekBy(-10, 'left');
else if (x > 2 / 3) seekBy(10, 'right');
else if (st.zoom > 1.001) { reset(); showHud('Zoom reset'); }
- else if (current) { Player.toggle(); updatePlayBtn(); showHud(`${Player.master.paused ? '⏸' : '▶'}`); }
+ else if (current) { Player.toggle(); updatePlayBtn(); showHud(`${TransportIcons.svg(Player.master.paused ? 'pause' : 'play')}`); }
return;
}
st.lastTap = now;
@@ -7167,325 +5850,10 @@ const Gestures = (() => {
* 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 SectionRail = (() => {
- const CLASSIC_ICONS = {
- player: ' ',
- meta: ' ',
- notes: ' ',
- related: ' ',
- list: ' ',
- };
- const SECTIONS = [
- { id: 'player', sel: '.player-stage', icon: '📺', label: 'Player' },
- { id: 'meta', sel: '#nowPlayingMeta', icon: '🎵', label: 'Now playing', fold: true },
- { id: 'notes', sel: '#notesPanel', icon: '📝', label: 'Lyrics', fold: true },
- { id: 'related', sel: '#relatedPanel', icon: '🔗', label: 'Related', fold: true },
- { id: 'list', sel: '#listPane', icon: '📋', label: 'List' },
- ];
- const st = { active: '', btns: new Map(), labelTimer: null, raf: 0, bottom: -1 };
- const rail = () => document.getElementById('sectionRail');
- const folds = () => (data.settings.foldedCards ||= {});
- const isFolded = (id) => !!folds()[id];
- const elOf = (s) => document.querySelector(s.sel);
- // Only cards that are actually on screen belong in the rail.
- const visible = () => SECTIONS.filter((s) => {
- const el = elOf(s);
- return el && !el.classList.contains('hidden') && el.offsetParent !== null;
- });
- function applyFold(sec) {
- const el = elOf(sec);
- if (!el || !sec.fold) return;
- el.classList.toggle('folded', isFolded(sec.id));
- if (sec.id === 'notes') {
- const b = document.getElementById('notesFoldBtn');
- if (b) b.setAttribute('aria-expanded', String(!isFolded(sec.id)));
- }
- // The Related card already had its own +/− button; keep the two in step.
- if (sec.id === 'related') {
- relatedCollapsed = isFolded(sec.id);
- const rl = document.getElementById('relatedList');
- const rb = document.getElementById('relatedToggleBtn');
- if (rl) rl.classList.toggle('hidden', relatedCollapsed);
- if (rb) rb.textContent = relatedCollapsed ? '+' : '−';
- }
- }
- function setFolded(id, on) {
- const sec = SECTIONS.find((x) => x.id === id);
- if (!sec || !sec.fold) return;
- folds()[id] = !!on;
- persist();
- applyFold(sec);
- paint(); // NOT render() — folding a card must not rebuild the list
- }
+const External = Lazy.proxy('feature:share-external', ['open', 'm3u', 'intent'], 'External');
- function flashLabel(btn) {
- clearTimeout(st.labelTimer);
- [...st.btns.values()].forEach((b) => b.classList.remove('show-label'));
- btn.classList.add('show-label');
- st.labelTimer = setTimeout(() => btn.classList.remove('show-label'), 1400);
- }
-
- function onTap(sec, btn) {
- const el = elOf(sec);
- if (!el) return;
- flashLabel(btn);
- if (sec.id === 'player' || sec.id === 'meta') restoreNowPlayingList();
- if (st.active === sec.id && sec.fold) {
- // Already here: this is the second tap — fold or unfold the card.
- setFolded(sec.id, !isFolded(sec.id));
- if (isFolded(sec.id)) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
- return;
- }
- st.active = sec.id; // claim it now so the next tap folds
- paint();
- el.scrollIntoView({ behavior: 'smooth', block: 'start' });
- }
-
- function build() {
- const r = rail();
- if (!r) return;
- const items = visible();
- // A rail with one entry is noise; so is one on the empty home screen.
- if (items.length < 2) { r.classList.add('hidden'); r.innerHTML = ''; st.btns.clear(); return; }
- const key = items.map((s) => s.id).join(',');
- if (r.dataset.key !== key) {
- r.dataset.key = key;
- r.innerHTML = '';
- st.btns.clear();
- for (const sec of items) {
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'sec-rail-btn';
- btn.dataset.sec = sec.id;
- btn.setAttribute('aria-label', sec.label);
- btn.innerHTML = `${escapeHtml(sec.label)}`;
- btn.addEventListener('click', () => onTap(sec, btn));
- r.appendChild(btn);
- st.btns.set(sec.id, btn);
- }
- }
- r.classList.remove('hidden');
- placeRail();
- paint();
- }
-
- // The page does NOT scroll the document: `.player-pane` is the scroller in
- // the browser, but the installed PWA in portrait scrolls `.body` instead
- // (`.player-pane` goes overflow:visible there). So find the real scroller
- // rather than assuming one.
- function scrollerOf(el) {
- for (let e = el && el.parentElement; e; e = e.parentElement) {
- const cs = getComputedStyle(e);
- if (/(auto|scroll)/.test(cs.overflowY) && e.scrollHeight > e.clientHeight + 4) return e;
- }
- return document.scrollingElement || document.documentElement;
- }
-
- // The visible box to measure against — the scroller's own rect, or the
- // window when the document itself scrolls.
- function viewBox() {
- const first = elOf(visible()[0] || SECTIONS[0]);
- const sc = scrollerOf(first);
- if (!sc || sc === document.scrollingElement || sc === document.documentElement) {
- return { top: 0, height: window.innerHeight };
- }
- const r = sc.getBoundingClientRect();
- return { top: r.top, height: r.height };
- }
-
- // Which card is the reader looking at? The one whose top is closest to a
- // line a third of the way down the visible box — steadier than "first
- // visible", which flickers between two cards at a boundary.
- function currentId() {
- const items = visible();
- if (!items.length) return '';
- const box = viewBox();
- const line = box.top + box.height / 3;
- let best = '', bestD = Infinity;
- let near = items[0].id, nearD = Infinity; // fallback: closest card overall
- for (const sec of items) {
- const el = elOf(sec);
- if (!el) continue;
- const r = el.getBoundingClientRect();
- const d = Math.abs(r.top - line);
- // Distance to the visible band, for the fallback below.
- const outside = Math.max(box.top - r.bottom, r.top - (box.top + box.height), 0);
- if (outside < nearD || (outside === nearD && d < bestD)) { nearD = outside; near = sec.id; }
- if (r.bottom < box.top || r.top > box.top + box.height) continue;
- if (d < bestD) { bestD = d; best = sec.id; }
- }
- // Scrolled past the last card (or a card is taller than the viewport and
- // its top has gone by): never leave the rail with nothing highlighted.
- return best || near;
- }
-
- // The mini player and the bottom nav are both position:fixed bottom:0, and
- // either can be absent (nothing playing / landscape). Measure whatever is
- // actually pinned down there and park the rail above it, rather than
- // guessing an offset that is wrong on the next phone.
- function placeRail() {
- const r = rail();
- let occupied = 0;
- for (const id of ['bottomNav', 'miniBar']) {
- const el = document.getElementById(id);
- if (!el || el.classList.contains('hidden')) continue;
- const box = el.getBoundingClientRect();
- if (!box.height) continue;
- if (getComputedStyle(el).position !== 'fixed') continue;
- // Count any fixed bar sitting in the lower half. It must NOT be required
- // to touch the viewport bottom: on an iPhone XR the bottom nav ends
- // above the home-indicator gap, so a "bottom >= innerHeight" test missed
- // it entirely and the rail was parked underneath it.
- if (box.top > window.innerHeight * 0.5) occupied = Math.max(occupied, window.innerHeight - box.top);
- }
- // 14px was too tight: the mini player is a floating CARD with a shadow and
- // rounded corners, so its visual edge reads higher than its box.
- const want = Math.round(occupied + 24);
- if (st.bottom !== want) {
- st.bottom = want;
- if (r) r.style.setProperty('--rail-bottom', want + 'px');
- // Published for anything else that must sit above the bottom bars —
- // the watch-party panel's leave button was hidden behind them.
- document.documentElement.style.setProperty('--bottom-chrome', Math.round(occupied) + 'px');
- }
- }
-
- function paint() {
- for (const [id, btn] of st.btns) {
- btn.classList.toggle('active', id === st.active);
- btn.classList.toggle('folded', isFolded(id));
- }
- }
-
- function onScroll() {
- if (st.raf) return;
- st.raf = requestAnimationFrame(() => {
- st.raf = 0;
- // Panels appear and disappear outside render() too (the Related card
- // shows once a video's related list arrives), and a card with no icon
- // would leave the rail with nothing highlighted. build() is a no-op
- // unless the set of visible cards actually changed.
- build();
- placeRail();
- const id = currentId();
- if (id && id !== st.active) { st.active = id; paint(); }
- });
- }
-
- function boot() {
- const fb = document.getElementById('notesFoldBtn');
- if (fb) fb.addEventListener('click', () => setFolded('notes', !isFolded('notes')));
- SECTIONS.forEach(applyFold);
- // Scroll events from an element do NOT bubble, so a window listener never
- // fires here. A capture-phase listener on document catches them all, and
- // keeps working when the scroller changes between browser and installed
- // PWA (different elements scroll in each).
- document.addEventListener('scroll', onScroll, { passive: true, capture: true });
- window.addEventListener('resize', () => { build(); placeRail(); onScroll(); });
- // The mini player appears AFTER the scroll that reveals it, so a scroll
- // handler alone measures the layout as it was a moment ago and parks the
- // rail underneath it. Watch the two bottom bars for real.
- const watch = ['bottomNav', 'miniBar'].map((id) => document.getElementById(id)).filter(Boolean);
- if (watch.length && typeof MutationObserver !== 'undefined') {
- // The mini bar slides in over 0.25s, so a measurement taken the instant
- // it appears catches it mid-animation and lands the rail a few pixels
- // too low. Measure now and again once it has settled.
- const mo = new MutationObserver(() => { placeRail(); setTimeout(placeRail, 320); });
- watch.forEach((el) => mo.observe(el, { attributes: true, attributeFilter: ['class', 'style'] }));
- }
- if (watch.length && typeof ResizeObserver !== 'undefined') {
- const ro = new ResizeObserver(() => placeRail());
- watch.forEach((el) => ro.observe(el));
- }
- build();
- onScroll();
- }
-
- // Called from render(): cards appear and disappear as videos load.
- function refresh() {
- SECTIONS.forEach(applyFold);
- build();
- placeRail();
- onScroll();
- }
-
- return { boot, refresh, setFolded, isFolded };
-})();
-
-const External = (() => {
- const isAndroid = /Android/i.test(navigator.userAgent);
- const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
- const streamUrl = (id) => `${location.origin}${isUploadId(id) ? '/api/uploads/' : '/api/media/'}${encodeURIComponent(id)}`;
- async function cached(id) {
- if (isUploadId(id)) return true; // uploads are always a single file on the server
- try { const j = await (await fetch(`/api/media/${encodeURIComponent(id)}/status`)).json(); return j && j.status === 'ready'; } catch { return false; }
- }
- const intent = (url, title, pkg) => `intent:${url}#Intent;action=android.intent.action.VIEW;type=video/*;${pkg ? `package=${pkg};` : ''}S.title=${encodeURIComponent(title || '')};end`;
- function m3u(items) {
- return ['#EXTM3U', ...items.flatMap((v) => [`#EXTINF:${Math.round(v.duration || -1)},${(v.title || v.id).replace(/[\r\n]/g, ' ')}`, streamUrl(v.id)])].join('\n') + '\n';
- }
- function download(name, text) {
- const a = document.createElement('a');
- a.href = URL.createObjectURL(new Blob([text], { type: 'audio/x-mpegurl' }));
- a.download = name;
- a.click();
- setTimeout(() => URL.revokeObjectURL(a.href), 2000);
- }
-
- async function open() {
- const meta = current && current.meta;
- if (!meta || !meta.id || meta.custom) { toast('Play a YouTube video first'); return; }
- const body = document.createElement('div');
- body.className = 'ext-panel';
- body.innerHTML = 'Checking the server copy…';
- showModal('📺 Open in another player', body, [{ label: 'Close', primary: true, onClick: closeModal }]);
- const ok = await cached(meta.id);
- if (!ok) {
- body.innerHTML = `The server hasn't finished caching this video yet, and other players need that single-file copy. Keep it playing for a minute (or tap ⬇ Save), then try again.
`;
- return;
- }
- const url = streamUrl(meta.id);
- const title = meta.title || meta.id;
- const inList = queue.length > 1 && queueIndex >= 0;
- body.innerHTML = `
- Stream${escapeHtml(url)}
-
- ${isAndroid ? 'VLC / MX Player must be installed; “Open with…” shows every video app.'
- : isIOS ? 'Needs the VLC (or Infuse) app. The link streams from your server; nothing is downloaded first.'
- : 'Desktop VLC may not register vlc:// links — the .m3u file opens in VLC, mpv, IINA or any media player.'}
`;
- body.addEventListener('click', async (e) => {
- const act = e.target.closest('[data-act]');
- if (!act) return;
- if (act.dataset.act === 'copy') {
- try { await navigator.clipboard.writeText(url); toast('Stream URL copied ✓'); } catch { toast('Copy blocked'); }
- } else if (act.dataset.act === 'm3u') {
- download(`${title.replace(/[^\w\- ]+/g, '').trim().slice(0, 50) || meta.id}.m3u`, m3u([meta]));
- } else if (act.dataset.act === 'm3u-list') {
- act.disabled = true;
- const items = queue.slice(Math.max(0, queueIndex));
- const flags = await Promise.all(items.map((v) => (v.custom ? false : cached(v.id))));
- const ready = items.filter((_, i) => flags[i]);
- download('ytplayer-queue.m3u', m3u(ready));
- toast(`Playlist with ${ready.length} of ${items.length} songs${ready.length < items.length ? ' (the rest aren’t cached on the server yet)' : ''}`, { duration: 5000 });
- act.disabled = false;
- }
- });
- }
- return { open, m3u, intent };
-})();
// ============================================================================
// 🎉 Watch party — synced playback + chat + voice (server/party.js relays)
@@ -7496,461 +5864,27 @@ const External = (() => {
// 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).
-const Party = (() => {
- const noop = { boot() {}, guestActive: () => false, intercept: () => false, afterLoad() {} };
- if (!WEB || typeof WebSocket === 'undefined') return noop;
- const PID_KEY = 'ytpPartyPid', HOST_KEY = 'ytpPartyHost', NAME_KEY = 'ytpPartyName';
- const ls = {
- get: (k) => { try { return localStorage.getItem(k); } catch { return null; } },
- set: (k, v) => { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch { /* blocked */ } },
- };
- let pid = ls.get(PID_KEY);
- if (!pid) { pid = 'p' + Math.random().toString(36).slice(2, 12) + Date.now().toString(36); ls.set(PID_KEY, pid); }
- const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/party?${new URLSearchParams(q)}`;
+/* 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 */
- const st = {
- ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
- dj: { enabled: false, entries: [] }, djPending: false,
- allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '',
- voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
- minimized: false,
- };
- const inParty = () => !!st.role;
- const isHost = () => st.role === 'host';
- const guestActive = () => st.role === 'guest' && !st.detached;
- const send = (m) => { if (st.ws && st.ws.readyState === 1) st.ws.send(JSON.stringify(m)); };
- const serverNow = () => Date.now() + st.offset;
- const myName = () => st.name || (data.profile && data.profile.name) || ls.get(NAME_KEY) || '';
-
- // ---------- connection ----------
- function connect() {
- clearTimeout(st.retry);
- const q = { role: st.role, pid, name: myName() || 'Guest' };
- if (st.code) q.code = st.code;
- if (isHost() && st.secret) q.secret = st.secret;
- const ws = new WebSocket(wsUrl(q));
- st.ws = ws;
- ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onMessage(m); };
- ws.onclose = (e) => {
- if (st.ws !== ws) return;
- st.ws = null;
- if (!inParty()) return;
- if (e.code === 4010 || e.code === 4004 || e.code === 4003) {
- const why = e.code === 4004 ? 'That party code doesn’t exist (or has ended).' : e.code === 4003 ? 'This party belongs to another host.' : 'The party ended.';
- leave(false);
- toast(`🎉 ${why}`, { duration: 5000 });
- return;
- }
- setStatus('Reconnecting…');
- st.retry = setTimeout(connect, 3000);
- };
- ws.onerror = () => { if (!st.code && isHost()) setStatus('Could not reach the server'); };
- }
-
- function onMessage(m) {
- DirectMedia.attach('party', { send, peers: () => st.members.filter(m => m.pid !== pid).map(m => ({ id: m.pid, name: m.name, files: m.host && st.state?.directAvailable && st.state?.v ? [st.state.v.id] : [] })) });
- if (m.type === 'direct') { DirectMedia.handle('party', m); return; }
- if (m.type === 'hello') {
- st.code = m.code;
- if (m.secret) { st.secret = m.secret; ls.set(HOST_KEY, JSON.stringify({ code: m.code, secret: m.secret })); }
- st.offset = (m.now || Date.now()) - Date.now();
- st.members = m.members || [];
- st.chat = m.chat || [];
- st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
- st.allowControl = !!m.allowControl;
- setStatus(isHost() ? 'You’re hosting' : 'Synced with the host');
- render();
- if (isHost()) pushState(true);
- else if (m.state) applyState(m.state);
- if (st.voice.on) { send({ type: 'voice', on: true }); connectVoicePeers(); }
- } else if (m.type === 'dj') {
- st.dj = { enabled: m.enabled, entries: m.entries || [] }; render();
- } else if (m.type === 'dj-play' && isHost()) {
- st.djPending = false; if (m.video) { Player._handoff = null; playVideoAt(m.video); } else { st.dj.entries = st.dj.entries.filter(e => !e.approved); advanceQueue(); }
- } else if (m.type === 'state') {
- st.state = m.state;
- if (st.role === 'guest') applyState(m.state);
- } else if (m.type === 'members') {
- st.members = m.members || [];
- if (m.joined) sysMsg(`${m.joined} joined`);
- if (m.left) sysMsg(`${m.left} left${m.hostAway ? ' — waiting for the host to come back' : ''}`);
- render();
- syncVoicePeers();
- } else if (m.type === 'chat') {
- st.chat.push(m.msg);
- if (st.chat.length > 100) st.chat.shift();
- renderChat();
- if (m.msg.pid !== pid && (st.minimized || $('partyPanel').classList.contains('hidden'))) toast(`💬 ${m.msg.from}: ${m.msg.text}`);
- } else if (m.type === 'settings') {
- st.allowControl = !!m.allowControl;
- render();
- if (!isHost()) sysMsg(st.allowControl ? 'The host lets everyone control playback' : 'Only the host controls playback now');
- } else if (m.type === 'cmd' && isHost()) {
- runCommand(m);
- } else if (m.type === 'rtc') {
- onRtc(m.from, m.data);
- } else if (m.type === 'ended') {
- toast(`🎉 ${m.reason || 'The party ended'}`, { duration: 5000 });
- }
- }
-
- // ---------- host: broadcast ----------
- function stateNow() {
- const meta = current && current.meta;
- const M = Player.master;
- return {
- directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false,
- v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '', duration: meta.duration || 0 } : null,
- cur: M ? M.currentTime || 0 : 0,
- paused: !M || M.paused,
- rate: (M && M.playbackRate) || 1,
- };
- }
- function pushState(force = false) {
- if (!isHost() || !st.ws) return;
- const s = stateNow();
- const key = JSON.stringify({ ...s, cur: Math.round(s.cur) });
- if (!force && key === st.lastPush) return;
- st.lastPush = key;
- send({ type: 'state', state: s });
- }
- function runCommand(m) {
- switch (m.cmd) {
- case 'toggle': Player.toggle(); break;
- case 'play': Player.play(); break;
- case 'pause': Player.pause(); break;
- case 'seek': if (Number.isFinite(Number(m.t))) Player.seek(Math.max(0, Number(m.t))); break;
- case 'next': playNext(); break;
- case 'prev': playPrev(); break;
- default: return;
- }
- toast(`🎉 ${m.from}: ${m.cmd === 'seek' ? `jumped to ${fmtTime(Number(m.t))}` : m.cmd}`);
- setTimeout(() => pushState(true), 200);
- }
-
- // ---------- guest: follow ----------
- const quiet = (ms = 1500) => { st.quietUntil = Date.now() + ms; };
- function targetTime(s) {
- if (!s) return 0;
- return s.paused ? s.cur : s.cur + ((serverNow() - (s.ts || serverNow())) / 1000) * (s.rate || 1);
- }
- async function applyState(s) {
- st.state = s;
- if (st.role !== 'guest' || st.detached || !s || !s.v) { render(); return; }
- const target = targetTime(s);
- if (!current || !current.meta || current.meta.id !== s.v.id) {
- quiet(6000);
- await playVideoAt({ ...s.v }, target);
- return;
- }
- const M = Player.master;
- if (!M) return;
- if ((M.playbackRate || 1) !== (s.rate || 1)) { els.speed.value = String(s.rate || 1); Player.applySpeed(); }
- if (Math.abs((M.currentTime || 0) - target) > 1.5) { quiet(); Player.seek(target); }
- if (s.paused && !M.paused) { quiet(); Player.pause(); updatePlayBtn(); }
- else if (!s.paused && M.paused) { quiet(); Player.play(); updatePlayBtn(); }
- }
- // A guest's own actions: forwarded when allowed, else they go off sync.
- function onLocalAction(kind) {
- if (st.role !== 'guest' || st.detached || Date.now() < st.quietUntil) return;
- if (st.allowControl) {
- const M = Player.master;
- if (kind === 'seeked') send({ type: 'cmd', cmd: 'seek', t: M.currentTime });
- else send({ type: 'cmd', cmd: kind === 'pause' ? 'pause' : 'play' });
- return;
- }
- st.detached = true;
- setStatus('Off sync — you paused or skipped on your own');
- render();
- }
- // playNext/playPrev guard: guests don't change songs themselves.
- function intercept(cmd) {
- if (st.role !== 'guest' || st.detached) return false;
- if (st.allowControl) send({ type: 'cmd', cmd });
- else toast('🎉 The host picks the songs in a watch party');
- return true;
- }
- function resync() {
- st.detached = false;
- setStatus('Synced with the host');
- render();
- applyState(st.state);
- }
-
- // ---------- voice (WebRTC mesh) ----------
- const ICE = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
- function peer(other) {
- let pc = st.voice.pcs.get(other);
- if (pc) return pc;
- pc = new RTCPeerConnection({ iceServers: ICE });
- st.voice.pcs.set(other, pc);
- if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => pc.addTrack(t, st.voice.stream));
- pc.onicecandidate = (e) => { if (e.candidate) send({ type: 'rtc', to: other, data: { ice: e.candidate } }); };
- pc.ontrack = (e) => {
- let a = st.voice.audios.get(other);
- if (!a) { a = new Audio(); a.autoplay = true; a.setAttribute('playsinline', ''); st.voice.audios.set(other, a); }
- a.srcObject = e.streams[0];
- a.play().catch(() => {});
- };
- pc.onconnectionstatechange = () => { if (['failed', 'closed'].includes(pc.connectionState)) dropPeer(other); render(); };
- return pc;
- }
- function dropPeer(other) {
- const pc = st.voice.pcs.get(other);
- if (pc) { try { pc.close(); } catch { /* closed */ } st.voice.pcs.delete(other); }
- const a = st.voice.audios.get(other);
- if (a) { a.srcObject = null; st.voice.audios.delete(other); }
- }
- async function offerTo(other) {
- const pc = peer(other);
- const offer = await pc.createOffer();
- await pc.setLocalDescription(offer);
- send({ type: 'rtc', to: other, data: { sdp: pc.localDescription } });
- }
- async function onRtc(from, d) {
- if (!st.voice.on || !d) return;
- try {
- if (d.sdp) {
- const pc = peer(from);
- await pc.setRemoteDescription(d.sdp);
- if (d.sdp.type === 'offer') {
- const ans = await pc.createAnswer();
- await pc.setLocalDescription(ans);
- send({ type: 'rtc', to: from, data: { sdp: pc.localDescription } });
- }
- } else if (d.ice) {
- await peer(from).addIceCandidate(d.ice);
- }
- } catch { /* a glare/ICE hiccup — the next members update retries */ }
- }
- // The member with the smaller pid makes the offer, so each pair connects once.
- function connectVoicePeers() {
- if (!st.voice.on) return;
- for (const m of st.members) if (m.voice && m.pid !== pid && !st.voice.pcs.has(m.pid) && pid < m.pid) offerTo(m.pid).catch(() => {});
- }
- function syncVoicePeers() {
- const voiced = new Set(st.members.filter((m) => m.voice).map((m) => m.pid));
- for (const other of [...st.voice.pcs.keys()]) if (!voiced.has(other)) dropPeer(other);
- connectVoicePeers();
- }
- async function toggleVoice() {
- if (st.voice.on) {
- st.voice.on = false;
- for (const other of [...st.voice.pcs.keys()]) dropPeer(other);
- if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => t.stop());
- st.voice.stream = null;
- send({ type: 'voice', on: false });
- render();
- return;
- }
- if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof RTCPeerConnection === 'undefined') { toast('Voice chat isn’t supported in this browser'); return; }
- try {
- st.voice.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
- } catch (e) {
- toast('🎤 Microphone blocked: ' + (e.message || e.name));
- return;
- }
- st.voice.on = true;
- st.voice.muted = false;
- send({ type: 'voice', on: true });
- toast('🎤 Voice on — headphones stop the music echoing into your mic', { duration: 5000 });
- render();
- connectVoicePeers();
- }
- function toggleMute() {
- if (!st.voice.stream) return;
- st.voice.muted = !st.voice.muted;
- st.voice.stream.getAudioTracks().forEach((t) => { t.enabled = !st.voice.muted; });
- render();
- }
-
- // ---------- lifecycle ----------
- function start() {
- st.role = 'host';
- st.code = ''; st.secret = ''; st.chat = []; st.members = [];
- ls.set(HOST_KEY, null);
- showPanel();
- setStatus('Starting…');
- connect();
- beginTimers();
- }
- function join(code, name) {
- code = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
- if (code.length !== 6) { toast('Party codes have 6 letters/numbers'); return; }
- if (name) { st.name = name; ls.set(NAME_KEY, name); }
- st.role = 'guest';
- st.code = code; st.detached = false; st.chat = []; st.members = [];
- showPanel();
- setStatus('Joining…');
- connect();
- beginTimers();
- }
- function beginTimers() {
- clearInterval(st.pushTimer); clearInterval(st.driftTimer);
- st.pushTimer = setInterval(() => pushState(), 3000);
- st.driftTimer = setInterval(() => { if (st.role === 'guest' && st.state && !st.detached) applyState(st.state); }, 2500);
- }
- function leave(sendEnd = true) {
- if (isHost() && sendEnd) send({ type: 'end' });
- if (st.voice.on) toggleVoice();
- const ws = st.ws;
- st.ws = null;
- st.role = null; st.code = ''; st.secret = '';
- clearTimeout(st.retry); clearInterval(st.pushTimer); clearInterval(st.driftTimer);
- try { if (ws) ws.close(); } catch { /* gone */ }
- ls.set(HOST_KEY, null);
- $('partyPanel').classList.add('hidden');
- render();
- }
-
- // ---------- UI ----------
- // Lyrics-instead-of-video is a per-device view: each guest chooses it for
- // themselves, and the host's playback is untouched (the video keeps playing
- // behind the lyrics, so sync and audio carry on).
- const lyricsOn = () => !!(Notes.stageLyrics && Notes.stageLyrics.on());
- function renderLyricsToggle() {
- const b = document.querySelector('#partyPanel .pt-lyrics');
- if (!b) return;
- b.textContent = lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video';
- b.classList.toggle('on', lyricsOn());
- }
- function setStatus(t) { const el = $('ptStatus'); if (el) el.textContent = t; }
- function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); renderChat(); }
- function link() { return `${location.origin}/?party=${st.code}`; }
- function showPanel() { $('partyPanel').classList.remove('hidden'); st.minimized = false; $('partyPanel').classList.remove('min'); render(); }
-
- function render() {
- const panel = $('partyPanel');
- const chip = $('navPartyBtn');
- if (chip) {
- const label = chip.querySelector('.nav-label');
- if (label) label.textContent = inParty() ? `Party · ${st.members.length}` : 'Watch party';
- else chip.textContent = inParty() ? `🎉 Party · ${st.members.length}` : '🎉 Watch party';
- }
- if (!panel || panel.classList.contains('hidden')) return;
- const nameOk = !!myName();
- if (!inParty()) {
- panel.querySelector('.pt-body').innerHTML = `
- Watch together: everyone's player follows the host, with chat and optional voice.
- ${nameOk ? '' : ''}
-
- or join one
- `;
- $('ptTitle').textContent = '🎉 Watch party';
- setStatus('');
- return;
- }
- $('ptTitle').textContent = `🎉 Party ${st.code || '…'}`;
- const s = st.state && st.state.v;
- panel.querySelector('.pt-body').innerHTML = `
- ${st.code ? `${escapeHtml(link())}${navigator.share ? '' : ''}` : ''}
- ${!isHost() && s ? `▶ ${escapeHtml(s.title)}` : ''}
- ${!isHost() && st.detached ? '' : ''}
- ${st.members.map((m) => `${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}`).join('')}
- ${isHost() ? `` : ''}
-
-
-
- ${st.voice.on ? `` : ''}
- ${st.voice.on ? `${st.voice.pcs.size} connected` : ''}
-
-
-
- `;
- const dj = document.createElement('div'); panel.querySelector('.pt-body').append(dj);
- PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } });
- renderChat();
- }
- function renderChat() {
- const box = $('ptChat');
- if (!box) return;
- box.innerHTML = st.chat.map((c) => c.sys
- ? `${escapeHtml(c.text)}`
- : `${escapeHtml(c.from)} ${escapeHtml(c.text)}`).join('');
- box.scrollTop = box.scrollHeight;
- }
-
- function bindUi() {
- const panel = $('partyPanel');
- $('navPartyBtn').addEventListener('click', () => {
- if (typeof closeSidebar === 'function') { try { closeSidebar(); } catch { /* not open */ } }
- if (panel.classList.contains('hidden')) showPanel();
- else if (st.minimized) { st.minimized = false; panel.classList.remove('min'); }
- else if (!inParty()) panel.classList.add('hidden');
- else { st.minimized = true; panel.classList.add('min'); }
- });
- $('ptMin').addEventListener('click', () => {
- if (!inParty()) { panel.classList.add('hidden'); return; }
- st.minimized = !st.minimized;
- panel.classList.toggle('min', st.minimized);
- });
- panel.addEventListener('keydown', (e) => e.stopPropagation());
- panel.addEventListener('click', async (e) => {
- const a = e.target.closest('[data-act]');
- if (!a || a.tagName === 'INPUT') return;
- const act = a.dataset.act;
- const nameIn = panel.querySelector('.pt-name');
- const name = nameIn ? nameIn.value.trim() : '';
- if ((act === 'start' || act === 'join') && !myName() && !name) { toast('Enter your name first'); if (nameIn) nameIn.focus(); return; }
- if (name) { st.name = name; ls.set(NAME_KEY, name); }
- if (act === 'start') start();
- else if (act === 'join') join(panel.querySelector('.pt-code').value);
- else if (act === 'copy') { try { await navigator.clipboard.writeText(link()); toast('Party link copied ✓'); } catch { toast('Copy blocked'); } }
- else if (act === 'share') { try { await navigator.share({ title: 'Join my watch party', url: link() }); } catch { /* dismissed */ } }
- else if (act === 'resync') resync();
- else if (act === 'voice') toggleVoice();
- else if (act === 'mute') toggleMute();
- else if (act === 'lyrics') { Notes.stageLyrics.set(!lyricsOn()); renderLyricsToggle(); }
- else if (act === 'leave') leave(true);
- });
- panel.addEventListener('change', (e) => {
- if (e.target.dataset.act === 'allow') { st.allowControl = e.target.checked; send({ type: 'settings', allowControl: st.allowControl }); }
- });
- panel.addEventListener('submit', (e) => {
- e.preventDefault();
- const input = panel.querySelector('.pt-msg');
- const text = input.value.trim();
- if (text) { send({ type: 'chat', text }); input.value = ''; }
- });
- // Host: push on every player change; guests: detect their own actions.
- ['play', 'pause', 'seeked', 'ratechange', 'loadedmetadata'].forEach((ev) => {
- [els.video, els.audio].forEach((el) => el.addEventListener(ev, (e) => {
- if (el !== Player.master) return;
- if (isHost()) pushState(true);
- else if (ev === 'play' || ev === 'pause' || ev === 'seeked') onLocalAction(ev);
- }));
- });
- }
-
- function boot() {
- bindUi();
- render();
- // Resume hosting after a reload.
- try {
- const h = JSON.parse(ls.get(HOST_KEY) || 'null');
- if (h && h.code && h.secret) { st.role = 'host'; st.code = h.code; st.secret = h.secret; showPanel(); connect(); beginTimers(); }
- } catch { /* none */ }
- let code = '';
- try { code = (new URLSearchParams(location.search).get('party') || '').toUpperCase(); } catch { /* no URL */ }
- if (code) {
- try { const u = new URL(location.href); u.searchParams.delete('party'); history.replaceState(null, '', u.pathname + u.search + u.hash); } catch { /* keep */ }
- if (myName()) join(code);
- else { showPanel(); const c = $('partyPanel').querySelector('.pt-code'); if (c) c.value = code; toast('Enter your name, then tap Join'); }
- }
- }
-
- return {
- nextDJ: () => { if (!isHost() || !st.dj.enabled || !st.dj.entries.some(e => e.approved)) return false; if (!st.djPending) { st.djPending = true; send({ type: 'dj', action: 'take' }); } return true; },
- boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true),
- state: () => ({
- role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on,
- peers: st.voice.pcs.size, peerStates: [...st.voice.pcs.values()].map((pc) => pc.connectionState),
- remoteAudio: [...st.voice.audios.values()].filter((a) => a.srcObject && a.srcObject.getAudioTracks().some((t) => t.readyState === 'live')).length,
- chat: st.chat.length, allowControl: st.allowControl,
- }),
- _join: join, _start: start,
- };
-})();
function updateProgress() {
const cur = Player.master.currentTime || 0;
@@ -7987,17 +5921,7 @@ function updateProgress() {
// 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.
-function updateBottomChrome() {
- let occupied = 0;
- for (const id of ['bottomNav', 'miniBar']) {
- const el = document.getElementById(id);
- if (!el || el.classList.contains('hidden')) continue;
- const box = el.getBoundingClientRect();
- if (!box.height || getComputedStyle(el).position !== 'fixed') continue;
- if (box.top > window.innerHeight * 0.5) occupied = Math.max(occupied, window.innerHeight - box.top);
- }
- document.documentElement.style.setProperty('--bottom-chrome', Math.round(occupied) + 'px');
-}
+
window.addEventListener('resize', () => requestAnimationFrame(updateBottomChrome));
window.addEventListener('orientationchange', () => setTimeout(updateBottomChrome, 300));
@@ -8005,48 +5929,11 @@ window.addEventListener('orientationchange', () => setTimeout(updateBottomChrome
// 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.
-function setHomeOverPlayer(on) {
- const pane = els.playerPane;
- if (!pane) return;
- on = !!on && !!(current && current.meta);
- // Service mode hides the bottom bar and mini bar (and, in its Video view,
- // lives inside this pane): leave it first or the home screen is a dead end.
- if (on && isServiceModeOpen()) exitServiceMode();
- if (pane.classList.contains('home-over') === on) return;
- pane.classList.toggle('home-over', on);
- pane.classList.toggle('empty', on);
- if (els.placeholder) els.placeholder.classList.toggle('hidden', !on);
- if (on) {
- renderHomePlaylists();
- showMiniBar();
- const body = document.querySelector('.body');
- if (body) body.scrollTo({ top: 0 });
- }
-}
-function showMiniBar() {
- if (!current || !current.meta) return;
- $('miniTitle').textContent = current.meta.title;
- const miniCh = $('miniChannel');
- miniCh.textContent = current.meta.channel || '';
- miniCh.classList.toggle('link', !!channelKeyOf(current.meta));
- $('miniBar').classList.remove('hidden');
- updateMiniBar();
- requestAnimationFrame(updateBottomChrome);
-}
-function updateMiniBar() {
- if (!current || !current.meta) return;
- const cur = Player.master.currentTime || 0;
- const dur = Player.master.duration || 0;
- $('miniTime').textContent = fmtTime(cur);
- if (dur > 0) {
- $('miniProgressFill').style.width = Math.min(100, (cur / dur) * 100) + '%';
- }
-}
-function hideMiniBar() {
- $('miniBar').classList.add('hidden');
- requestAnimationFrame(updateBottomChrome);
-}
+
+
+
+
// ============================================================================
// History
@@ -8076,6 +5963,7 @@ function playFromList(list, index, source = '', { playFull = false } = {}) {
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();
}
@@ -8425,7 +6313,7 @@ function updateServiceMode() {
const spBtn = $('servicePlayBtn');
if (spBtn && Player.master) {
- spBtn.textContent = Player.master.paused ? '▶' : '⏸';
+ TransportIcons.set(spBtn, Player.master.paused ? 'play' : 'pause');
}
updateWakeStateUI();
@@ -8693,109 +6581,18 @@ function channelKeyOf(v) {
// ============================================================================
// Rendering
// ============================================================================
-function renderSidebar() {
- els.playlistList.innerHTML = '';
- for (const pl of data.playlists) {
- const stats = playlistOfflineStats(pl);
- let offlineBadge = '';
- if (stats.total > 0) {
- if (stats.cached === stats.total) {
- offlineBadge = `⬇`;
- } else if (stats.cached > 0) {
- offlineBadge = `${stats.cached}/${stats.total}`;
- }
- }
- const item = document.createElement('div');
- item.className = 'playlist-item' + (view.type === 'playlist' && view.id === pl.id ? ' active' : '');
- item.innerHTML = `${offlineBadge}${pl.videos.length}`;
- item.querySelector('.pl-name').textContent = pl.name;
- item.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); });
- els.playlistList.appendChild(item);
- }
- document.querySelectorAll('.nav-item').forEach((b) => {
- b.classList.toggle('active', b.dataset.view === view.type);
- });
- // Keep bottom-nav in sync with sidebar nav active state
- document.querySelectorAll('.bottom-nav-btn').forEach((b) => {
- b.classList.toggle('active', b.dataset.view === view.type);
- });
- updateQueueBadge();
- updateDownloadBadge();
- renderHomePlaylists();
-}
+
// 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.
-function renderHomePlaylists() {
- if (window.BentoHub) BentoHub.playlists = () => data.playlists || [];
- const wrap = document.getElementById('homePlaylists');
- const hero = document.querySelector('#playerPlaceholder .hero');
- const grid = document.getElementById('homePlGrid');
- if (!wrap || !hero || !grid) return;
- Recommendations.home();
- const lists = data.playlists || [];
- wrap.classList.toggle('hidden', !lists.length);
- hero.classList.toggle('hidden', !!lists.length);
- // The hero is centered art; the grid wants the full box, so the placeholder
- // switches from centering to stretching (and tightens its padding), and the
- // stage drops its 16:9 lock so the grid gets the pane's real height.
- if (els.placeholder) els.placeholder.classList.toggle('showing-playlists', !!lists.length);
- if (els.playerPane) els.playerPane.classList.toggle('home-playlists', !!lists.length);
- if (!lists.length) { grid.innerHTML = ''; return; }
-
- grid.innerHTML = '';
- for (const pl of lists) {
- const count = pl.videos.length;
- const thumb = (pl.videos.find((v) => v && v.thumbnail) || {}).thumbnail || '';
- const stats = playlistOfflineStats(pl);
- let offlineHtml = '';
- if (stats.total > 0) {
- if (stats.cached === stats.total) {
- offlineHtml = `⬇`;
- } else if (stats.cached > 0) {
- offlineHtml = `${stats.cached}/${stats.total}`;
- }
- }
- const card = document.createElement('button');
- card.className = 'home-pl-card';
- card.type = 'button';
- card.innerHTML = `
-
- ${thumb ? `
`
- : '🎵'}
- ${offlineHtml}
- ▶
-
-
- ${count} video${count === 1 ? '' : 's'}`;
- card.querySelector('.home-pl-name').textContent = pl.name;
- card.title = pl.name;
- card.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); });
- card.querySelector('.home-pl-play').addEventListener('click', (e) => {
- e.stopPropagation(); // the ▶ badge plays; the rest of the card opens the list
- if (count) playFromList(pl.videos, 0, 'playlist:' + pl.id);
- else toast('That playlist is empty');
- });
- grid.appendChild(card);
- }
- // Measure after layout — scrollHeight is meaningless until the cards lay out.
- requestAnimationFrame(updateHomeGridScrollState);
-}
// 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 updateHomeGridScrollState() {
- const grid = document.getElementById('homePlGrid');
- if (!grid) return;
- const overflows = grid.scrollHeight > grid.clientHeight + 1;
- grid.classList.toggle('is-scrollable', overflows);
- grid.classList.toggle('at-end', !overflows ||
- grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 2);
-}
+
function currentList() {
if (view.type === 'nowPlaying') return queue;
@@ -8811,306 +6608,25 @@ function currentList() {
return [];
}
-function showSearchSkeletons() {
- // A search is in flight — the pane is about to have results, so reveal it
- // now rather than popping in only once they land.
- setListPaneHidden(false);
- els.status.classList.add('hidden');
- els.cards.innerHTML = '';
- for (let i = 0; i < 8; i++) {
- const s = document.createElement('div');
- s.className = 'card skeleton-card';
- s.innerHTML = `
-
-
-
-
-
- `;
- els.cards.appendChild(s);
- }
-}
+
// 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.
-function setListPaneHidden(hidden) {
- const pane = document.getElementById('listPane');
- if (pane) pane.classList.toggle('hidden', hidden);
-}
+
// Small icon-only button for the playlist title row and the filter row.
-const LIST_ICONS = {
- rename: ' ',
- share: ' ',
- offline: ' ',
- offlineDone: ' ',
- pin: ' ',
- select: ' ',
- del: ' ',
-};
-function listIconBtn(icon, label, onClick, extraClass) {
- const b = document.createElement('button');
- b.type = 'button';
- b.className = 'list-icon-btn' + (extraClass ? ' ' + extraClass : '');
- b.title = label;
- b.setAttribute('aria-label', label);
- b.innerHTML = ``;
- if (onClick) b.onclick = onClick;
- return b;
-}
-function setListIcon(btn, icon) {
- btn.innerHTML = ``;
-}
+
+
+
// Drop the playlist-only title/filter icons so they never linger on other views.
-function resetListHeaderExtras() {
- $('listTitleActions').innerHTML = '';
- const fa = $('listFilterActions');
- fa.innerHTML = '';
- fa._del = null;
- els.listTitle.onclick = null;
- els.listTitle.onkeydown = null;
- els.listTitle.classList.remove('title-clickable');
- els.listTitle.removeAttribute('role');
- els.listTitle.removeAttribute('tabindex');
- els.listTitle.removeAttribute('title');
-}
-function renderList() {
- // Theme composition follows the real view without changing its controls.
- els.cards.dataset.view = view.type;
- resetListHeaderExtras();
- { const ls = $('listSub'); if (ls) ls.textContent = ''; const ps = $('p2pStrip'); if (ps) ps.classList.add('hidden'); }
- if (view.type !== 'search') { const sm = $('searchMeta'); if (sm) sm.classList.add('hidden'); }
- setListPaneHidden(view.type === 'search' && !searchResults.length && !searchAttempted && !els.placeholder.classList.contains('hidden'));
- if (view.type === 'settings') { renderSettings(); return; }
- if (view.type === 'saved') { renderSaved(); return; }
- if (view.type === 'p2p') { renderP2pList(); return; }
- if (view.type === 'savedSearches') { renderSavedSearches(); return; }
- if (view.type === 'downloads') { renderDownloads(); return; }
- if (view.type === 'stats') { renderStats(); return; }
- if (view.type === 'notes') { renderNotesView(); return; }
- if (view.type === 'library') { renderLibrary(); return; }
- // Filter bar — show for filterable views
- const filterableViews = ['history', 'playlist', 'queue', 'channel', 'smart', 'nowPlaying'];
- const showFilter = filterableViews.includes(view.type);
- $('listFilterBar').classList.toggle('hidden', !showFilter);
- $('batchBar').classList.toggle('hidden', !selectMode);
- if (selectMode) $('batchCount').textContent = `${selectedIds.size} selected`;
- // A channel's videos can be queued or added to a playlist, not deleted.
- $('batchDeleteBtn').classList.toggle('hidden', view.type === 'channel');
- // Build the list, applying filter if active
- let list = currentList();
- if (listFilter && showFilter) {
- const q = listFilter.toLowerCase();
- list = list.filter((v) => (v.title || '').toLowerCase().includes(q) || (v.channel || '').toLowerCase().includes(q));
- }
- els.cards.innerHTML = '';
- els.cards.classList.toggle('select-mode', selectMode);
- resetListHeaderExtras();
- els.listActions.innerHTML = '';
- if (view.type === 'search' && WEB && els.placeholder.classList.contains('hidden')) {
- Recommendations.inList();
- }
-
- if (view.type === 'nowPlaying') {
- els.listTitle.textContent = 'Now playing';
- } else if (view.type === 'search') {
- els.listTitle.textContent = 'Search results';
- } else if (view.type === 'history') {
- els.listTitle.textContent = 'History';
- if (list.length) {
- const clear = document.createElement('button');
- clear.textContent = 'Clear';
- clear.onclick = () => { data.history = []; persist(); renderList(); };
- els.listActions.appendChild(clear);
- }
- } else if (view.type === 'queue') {
- els.listTitle.textContent = 'Queue';
- if (list.length) {
- const playAll = document.createElement('button');
- playAll.textContent = '▶ Play queue';
- playAll.onclick = () => playQueue(0);
- const clear = document.createElement('button');
- clear.textContent = 'Clear';
- clear.onclick = clearQueue;
- els.listActions.append(playAll, clear);
- }
- } else if (view.type === 'smart') {
- const sp = SMART_PLAYLISTS.find((s) => s.id === view.smartType);
- els.listTitle.textContent = sp ? sp.label : 'Auto Playlist';
- if (list.length) {
- const playAll = document.createElement('button');
- playAll.textContent = '▶ Play all';
- playAll.onclick = () => playFromList(list.slice(), 0, 'smart:' + view.smartType);
- const queueAll = document.createElement('button');
- queueAll.textContent = '+ Queue all';
- queueAll.onclick = () => { list.forEach((v) => addToQueue(v, { quiet: true })); toast('Added to queue'); };
- els.listActions.append(playAll, queueAll);
- }
- } else if (view.type === 'channel') {
- els.listTitle.textContent = channelData.name || 'Channel';
- if (list.length) {
- const playAll = document.createElement('button');
- playAll.textContent = '▶ Play all';
- playAll.onclick = () => playFromList(channelData.results.slice(), 0, 'channel');
- const queueAll = document.createElement('button');
- queueAll.textContent = '+ Queue all';
- queueAll.onclick = () => { channelData.results.forEach((v) => addToQueue(v, { quiet: true })); toast('Added channel to queue'); };
- els.listActions.append(playAll, queueAll);
- }
- } else if (view.type === 'playlist') {
- const pl = data.playlists.find((p) => p.id === view.id);
- els.listTitle.textContent = pl ? pl.name : 'Playlist';
- if (pl) {
- { const secs = (pl.videos || []).reduce((a, v) => a + (Number(v.duration) || 0), 0);
- const h = Math.floor(secs / 3600), m = Math.round((secs % 3600) / 60);
- $('listSub').textContent = `${pl.videos.length} track${pl.videos.length === 1 ? '' : 's'}${secs ? ' · ' + (h ? h + 'h ' : '') + m + 'm' : ''}`; }
- renderP2pStrip();
- const playAll = document.createElement('button');
- playAll.textContent = '▶ Play all';
- playAll.className = 'la-primary';
- playAll.onclick = () => { if (pl.videos.length) playFromList(pl.videos, 0, 'playlist:' + pl.id); };
- const playFull = document.createElement('button');
- playFull.textContent = '▶ Play in full';
- playFull.className = 'la-main';
- playFull.dataset.sub = 'Start to finish';
- playFull.title = 'Play every video start to finish — ignores saved positions and A-B loops, stops after the last one';
- playFull.onclick = () => {
- if (!pl.videos.length) return;
- playFromList(pl.videos, 0, 'playlist:' + pl.id, { playFull: true });
- toast('Playing in full — stops after the last video');
- };
- const serviceBtn = document.createElement('button');
- serviceBtn.textContent = '🎤 Service mode';
- serviceBtn.className = 'la-main';
- serviceBtn.dataset.sub = 'Full lyrics';
- serviceBtn.title = 'Set-list view with screen wake lock';
- serviceBtn.onclick = () => {
- if (!pl.videos || !pl.videos.length) { toast('Playlist is empty'); return; }
- enterServiceMode(pl);
- };
- const queueAll = document.createElement('button');
- queueAll.textContent = '+ Queue';
- queueAll.className = 'la-main';
- queueAll.dataset.sub = `+${pl.videos.length} items`;
- queueAll.onclick = () => { pl.videos.forEach((v) => addToQueue(v, { quiet: true })); toast('Added playlist to queue'); };
- // Title row: a long name wraps; tapping it opens the rename modal,
- // which also shows the full name. Rename + Share sit beside it as icons.
- els.listTitle.classList.add('title-clickable');
- els.listTitle.setAttribute('role', 'button');
- els.listTitle.tabIndex = 0;
- els.listTitle.title = 'Show full name / rename';
- els.listTitle.onclick = () => renamePlaylist(pl);
- els.listTitle.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); renamePlaylist(pl); } };
- const titleIcons = [listIconBtn('rename', 'Rename playlist', () => renamePlaylist(pl))];
- if (pl.videos && pl.videos.length >= 1) titleIcons.push(listIconBtn('share', 'Share playlist', () => sharePlaylist(pl)));
- $('listTitleActions').append(...titleIcons);
-
- // Filter row icons: offline + pin show their state by colour alone.
- const offlineBtn = listIconBtn('offline', 'Make available offline', () => makePlaylistOffline(pl));
- offlineBtn.id = 'plOfflineBtn';
- updatePlaylistOfflineButton(offlineBtn, pl);
-
- const isPinned = (data.pinnedPlaylists || []).includes(pl.id);
- const pinBtn = listIconBtn('pin', isPinned ? 'Pinned for offline keep — tap to unpin' : 'Pin to keep this playlist available offline', () => togglePinPlaylist(pl));
- pinBtn.id = 'plPinBtn';
- pinBtn.classList.toggle('pinned', isPinned);
- pinBtn.setAttribute('aria-pressed', String(isPinned));
-
- const del = listIconBtn('del', 'Delete playlist', () => deletePlaylist(pl), 'danger');
- $('listFilterActions').append(offlineBtn, pinBtn);
- els.listActions.append(playAll, playFull, serviceBtn, queueAll);
- $('listFilterActions')._del = del; // appended after Select below
- }
- }
-
- // Select button for batch-operable views
- const batchViews = ['history', 'playlist', 'queue', 'smart', 'channel'];
- if (batchViews.includes(view.type) && list.length) {
- const selBtn = listIconBtn('select', selectMode ? 'Stop selecting' : 'Select items', toggleSelectMode);
- selBtn.classList.toggle('pinned', selectMode);
- selBtn.setAttribute('aria-pressed', String(selectMode));
- $('listFilterActions').appendChild(selBtn);
- }
- { const fa = $('listFilterActions'); if (fa._del) { fa.appendChild(fa._del); fa._del = null; } }
-
- // Channel still loading — show skeletons.
- if (view.type === 'channel' && channelData.loading && !list.length) {
- showSearchSkeletons();
- return;
- }
-
- if (!list.length) {
- els.status.classList.add('hidden');
- els.cards.innerHTML = '';
- const empty = document.createElement('div');
- empty.className = 'empty-state';
-
- if (view.type === 'search') {
- // Landing (hero or playlist board) lives in the player pane. Before the
- // first search this pane is hidden outright; after one that came back
- // empty it stays visible so the result is legible.
- els.status.classList.remove('hidden');
- els.status.textContent = searchAttempted
- ? 'No results for that search.'
- : 'Search for something to begin.';
- return;
- }
-
- if (view.type === 'history') {
- empty.innerHTML = `
-
- Nothing watched yet
- Your viewing history shows up here once you start playing videos.
- `;
- addBrowseCta(empty, '🔍 Search videos');
- } else if (view.type === 'queue') {
- empty.innerHTML = `
-
- Your queue is empty
- Use + Queue on any video to line it up. The queue is temporary and plays in order.
- `;
- addBrowseCta(empty, '🔍 Find something to play');
- } else if (view.type === 'channel') {
- empty.innerHTML = `
-
- No videos found
- This channel didn't return any uploads.
- `;
- addBrowseCta(empty, '🔍 Back to search');
- } else {
- // Playlist view — empty
- empty.innerHTML = `
-
- This playlist is empty
- Add videos from search results or use the + Playlist button while playing.
- `;
- addBrowseCta(empty, '🔍 Browse videos');
- }
-
- els.cards.appendChild(empty);
- return;
- }
- els.status.classList.add('hidden');
-
- list.forEach((v, i) => els.cards.appendChild(renderCard(v, i, list)));
- markPlayingCard();
-}
-
-function addBrowseCta(empty, label) {
- const cta = document.createElement('button');
- cta.className = 'empty-cta';
- cta.textContent = label;
- cta.addEventListener('click', () => { view = { type: 'search' }; render(); });
- empty.appendChild(cta);
-}
// ============================================================================
// Saved videos page — every offline-cached file with sizes + totals
@@ -9125,6 +6641,7 @@ function addBrowseCta(empty, label) {
// 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;
@@ -9233,314 +6750,23 @@ async function exportToDevice(video) {
// Glass Stage: a compact "On other devices" card under a playlist (same data as the 📡 view).
let p2pStripAt = 0, p2pStripData = null;
-async function renderP2pStrip() {
- const box = $('p2pStrip');
- if (!box || document.documentElement.dataset.layout !== 'glass-stage' || !WEB || data.settings.p2pReceive === false) return;
- if (!p2pStripData || Date.now() - p2pStripAt > 60000) {
- try { const r = await fetch('/api/p2p/available?limit=3'); p2pStripData = r.ok ? await r.json() : null; } catch { p2pStripData = null; }
- p2pStripAt = Date.now();
- }
- if (view.type !== 'playlist') return;
- const items = (p2pStripData && p2pStripData.ok && p2pStripData.items) || [];
- if (!items.length) { box.classList.add('hidden'); return; }
- const online = items.filter((i) => i.online).length;
- box.innerHTML = `On other devices
- ${items.length} video${items.length === 1 ? '' : 's'} held by other devices · ${online ? online + ' online now' : 'none online'}
`;
- items.forEach((it) => {
- const v = { id: it.id, title: it.title || videoTitleById(it.id) || it.id, channel: it.channel || '', duration: it.duration || 0, thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg` };
- const row = document.createElement('div');
- row.className = 'ps-row';
- row.innerHTML = '📡';
- row.querySelector('b').textContent = v.title;
- row.querySelector('small').textContent = `${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''} · ${it.online ? 'Online' : 'Offline'}`;
- row.querySelector('.ps-dl').onclick = () => exportToDevice(v);
- row.querySelector('.ps-txt').onclick = () => playFromList([v], 0, 'p2p');
- box.appendChild(row);
- });
- box.classList.remove('hidden');
-}
+
// ============================================================================
// Saved searches — browse (read-only here; deleting happens in Settings)
// ============================================================================
-function fmtAgo(ts) {
- const s = Math.max(0, (Date.now() - ts) / 1000);
- if (s < 90) return 'just now';
- if (s < 3600) return Math.round(s / 60) + ' min ago';
- if (s < 86400) return Math.round(s / 3600) + ' h ago';
- return Math.round(s / 86400) + ' d ago';
-}
-function renderSavedSearches() {
- els.listTitle.textContent = '📚 Saved searches';
- resetListHeaderExtras();
- els.listActions.innerHTML = '';
- els.status.classList.add('hidden');
- const c = els.cards;
- c.innerHTML = '';
- const items = SearchLibrary.list();
- if (!items.length) {
- c.innerHTML = 'No saved searches yet
Every search you run is kept here with its thumbnails, even after you clear the search box. You can remove them in Settings.
';
- return;
- }
- const sum = document.createElement('div');
- sum.className = 'saved-summary';
- sum.innerHTML = `${items.length}saved search${items.length === 1 ? '' : 'es'} · ${fmtBytes(SearchLibrary.bytes())} · manage in Settings`;
- c.appendChild(sum);
- items.forEach((it) => {
- const row = document.createElement('div');
- row.className = 'card saved-card ss-card';
- row.innerHTML = `
-
- ${it.count} result${it.count === 1 ? '' : 's'} · ${fmtAgo(it.ts)}`;
- row.querySelector('.card-title').textContent = it.query;
- row.addEventListener('click', () => {
- els.searchInput.value = it.query;
- if (els.runSearch) els.runSearch(it.query);
- });
- c.appendChild(row);
- });
-}
-
-async function renderP2pList() {
- els.listTitle.textContent = '📡 On other devices';
- resetListHeaderExtras();
- els.listActions.innerHTML = '';
- els.status.classList.add('hidden');
- const c = els.cards;
- c.innerHTML = 'Looking for videos on other devices…';
- let res = null;
- try { const r = await fetch('/api/p2p/available?limit=100'); res = r.ok ? await r.json() : null; } catch { /* offline */ }
- if (view.type !== 'p2p') return;
- c.innerHTML = '';
- if (!res || !res.ok) {
- c.innerHTML = 'Not available
Peer-to-peer sharing is off on this server, or you are offline.
';
- return;
- }
- const items = res.items || [];
- const list = items.map((it) => ({
- id: it.id, title: it.title || videoTitleById(it.id) || it.id, channel: it.channel || '',
- duration: it.duration || 0, thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`,
- }));
- const refresh = document.createElement('button');
- refresh.textContent = '↻';
- refresh.className = 'la-icon';
- refresh.title = 'Refresh';
- refresh.setAttribute('aria-label', 'Refresh');
- refresh.onclick = () => renderP2pList();
- const playAll = document.createElement('button');
- playAll.textContent = '▶ Play all';
- playAll.className = 'la-primary la-inline';
- playAll.onclick = () => { if (list.length) playFromList(list, 0, 'p2p'); };
- els.listActions.append(playAll, refresh);
- if (!items.length) {
- c.innerHTML = 'Nothing shared yet
Videos that other devices have saved and verified show up here. Save a video and keep sharing on, and yours will appear for everyone else.
';
- return;
- }
- const online = items.filter((i) => i.online).length;
- const sum = document.createElement('div');
- sum.className = 'saved-summary';
- sum.innerHTML = `${items.length}video${items.length === 1 ? '' : 's'} on other devices · ${online} with a device online now`;
- c.appendChild(sum);
- items.forEach((it, idx) => {
- const v = list[idx];
- const row = document.createElement('div');
- row.className = 'card saved-card p2p-card';
- row.dataset.id = it.id;
- const state = it.online ? `● ${it.online} online` : '○ none online';
- row.innerHTML = `
-
-
-
- ${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · not checked lately' : ''}
-
-
- `;
- row.querySelector('.card-title').textContent = v.title;
- row.addEventListener('click', (e) => {
- const act = e.target.closest('[data-act]');
- if (act) { e.stopPropagation(); if (act.dataset.act === 'export') exportToDevice(v); else openCardMenu(v); return; }
- playFromList(list, idx, 'p2p');
- });
- c.appendChild(row);
- });
- markPlayingCard();
-}
-
-async function renderSaved() {
- els.listTitle.textContent = 'Saved videos';
- resetListHeaderExtras();
- els.listActions.innerHTML = '';
- els.status.classList.add('hidden');
- $('batchBar').classList.add('hidden');
- const c = els.cards;
- c.classList.remove('select-mode');
- c.innerHTML = 'Loading saved videos…';
-
- let res;
- try { res = await API.cacheList(); } catch { res = null; }
- if (view.type !== 'saved') return; // navigated away
-
- if (!res || !res.ok) {
- $('listFilterBar').classList.add('hidden');
- c.innerHTML = '';
- const empty = document.createElement('div');
- empty.className = 'empty-state';
- empty.innerHTML = `
-
- Offline cache unavailable
- This build doesn't support saving videos for offline playback.
`;
- c.appendChild(empty);
- return;
- }
-
- const items = (res.items || []).slice().sort((a, b) => b.size - a.size);
- const total = res.total || 0;
-
- if (!items.length) {
- $('listFilterBar').classList.add('hidden');
- c.innerHTML = '';
- const empty = document.createElement('div');
- empty.className = 'empty-state';
- empty.innerHTML = `
-
- Nothing saved yet
- Use ⬇ Save while playing, or add videos to a playlist to keep them offline.
`;
- addBrowseCta(empty, '🔍 Find videos');
- c.appendChild(empty);
- return;
- }
-
- // Header actions: total + clear all.
- const clearAll = document.createElement('button');
- clearAll.type = 'button';
- clearAll.className = 'saved-clear';
- clearAll.textContent = 'Clear all';
- clearAll.setAttribute('aria-label', 'Clear all saved videos');
- clearAll.onclick = () => {
- showModal('Clear all saved videos?', document.createTextNode('Frees disk space. Normal videos re-download on demand, but edited videos are removed for good (they exist only here).'), [
- { label: 'Cancel', onClick: closeModal },
- { label: 'Clear all', danger: true, onClick: async () => {
- try {
- const result = await API.cacheClear();
- if (result?.ok === false) throw new Error(result.error || 'Storage could not be cleared');
- } catch { toast('Could not clear saved videos. Try again.'); closeModal(); return; }
- cachedIds.clear();
- // Custom cuts live only in the cache — clearing it destroys their media,
- // so drop their registry entries and playlist references too.
- const customIds = new Set((data.customVideos || []).map((v) => v.id));
- if (customIds.size) {
- data.customVideos = [];
- data.playlists.forEach((pl) => { pl.videos = pl.videos.filter((x) => !customIds.has(x.id)); });
- data.queue = (data.queue || []).filter((x) => !customIds.has(x.id));
- persist();
- }
- closeModal();
- toast('Cache cleared');
- if (current) updateNowPlayingActions();
- renderSaved();
- } },
- ]);
- };
- c.innerHTML = '';
- const summary = document.createElement('div');
- summary.className = 'saved-summary saved-storage-summary';
- summary.innerHTML = `${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'} stored offline`;
- summary.appendChild(clearAll);
- $('listFilterBar').classList.remove('hidden');
- c.appendChild(summary);
- items.forEach((it) => {
- const known = videoById(it.id);
- // A custom (edited) id has no YouTube thumbnail; fall back to its source's
- // thumbnail when we know it, else a neutral placeholder.
- const isCustom = (known && known.custom) || String(it.id).startsWith('edit_');
- const fallbackThumb = isCustom ? '' : `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`;
- const v = known || { id: it.id, title: videoTitleById(it.id), thumbnail: fallbackThumb };
- if (!SavedPage.matches(v, listFilter)) return;
- const row = document.createElement('div');
- row.className = 'card saved-card' + (isCustom ? ' custom' : '');
- row.dataset.id = it.id;
- row.innerHTML = `
-
${isCustom ? '✂' : ''}${isUploadId(v.id) ? `${v.kind === 'audio' ? '♪' : '📁'}` : ''}
-
-
- ${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}
- `;
- row.querySelector('.card-title').textContent = v.title || it.id;
- row.addEventListener('click', (e) => {
- if (e.target.closest('.saved-file-actions')) return;
- playFromList([v], 0, 'saved');
- });
- SavedPage.actions(row, v, () => exportToDevice(v), () => {
- showModal(`Delete saved file “${v.title || v.id}”?`, document.createTextNode(isCustom
- ? 'This edited copy exists only on this device and will be removed permanently.'
- : 'Frees space on this device. You can save this video again later.'), [
- { label: 'Cancel', onClick: closeModal },
- { label: 'Delete saved file', danger: true, onClick: async () => {
- try {
- const result = await API.cacheDelete(it.id);
- if (result?.ok === false) throw new Error(result.error || 'Storage could not be updated');
- } catch { toast('Could not delete this saved file. Try again.'); closeModal(); return; }
- cachedIds.delete(it.id);
- if (isCustom) {
- data.customVideos = (data.customVideos || []).filter(x => x.id !== it.id);
- data.playlists.forEach(pl => { pl.videos = pl.videos.filter(x => x.id !== it.id); });
- data.queue = (data.queue || []).filter(x => x.id !== it.id);
- persist();
- }
- if (current?.meta?.id === it.id) updateNowPlayingActions();
- markCardCacheState(it.id, 'none'); closeModal();
- toast('Removed saved file'); renderSaved();
- } },
- ]);
- });
- c.appendChild(row);
- });
- if (!c.querySelector('.saved-card')) {
- const message = document.createElement('p'); message.className = 'status';
- message.textContent = 'No saved videos match your filter.'; c.appendChild(message);
- }
- markPlayingCard();
-}
+
+
+
// ============================================================================
// Downloads page — videos currently being saved
// ============================================================================
-function renderDownloads() {
- els.listTitle.textContent = 'Downloads';
- resetListHeaderExtras();
- els.listActions.innerHTML = '';
- els.status.classList.add('hidden');
- const c = els.cards;
- c.innerHTML = '';
- $('listFilterBar').classList.toggle('hidden', !Downloads.getJobs().length);
- $('batchBar').classList.add('hidden');
- c.classList.remove('select-mode');
- if (window.DownloadsPage && window.DownloadsPage.render) {
- window.DownloadsPage.render(c, {
- downloadsApi: window.Downloads,
- filter: listFilter,
- isActive: () => view.type === 'downloads',
- showModal: typeof showModal === 'function' ? showModal : null,
- closeModal: typeof closeModal === 'function' ? closeModal : null,
- onNavigateSaved: () => { view = { type: 'saved' }; render(); },
- thumbUrlFor: typeof thumbUrlFor === 'function' ? thumbUrlFor : null,
- refresh: renderList
- });
- }
-
- const jobs = window.Downloads && window.Downloads.getJobs ? window.Downloads.getJobs() : [];
- jobs.filter(j => j.status === 'active' || j.status === 'queued').forEach(v => {
- if (typeof SaveQueue !== 'undefined' && SaveQueue.progress) {
- SaveQueue.progress(v.id, SaveQueue.get(v.id));
- }
- });
-}
// ============================================================================
// Settings page
@@ -9565,782 +6791,11 @@ function videoTitleById(id) {
return v ? v.title : id;
}
-async function renderSettings() {
- els.listTitle.textContent = 'Settings';
- resetListHeaderExtras();
- els.listActions.innerHTML = '';
- const c = els.cards;
- els.status.classList.add('hidden');
- c.innerHTML = '';
-
- const wrap = document.createElement('div');
- wrap.className = 'settings';
-
- // ---- Playback ----
- const qualities = ['auto', '1080p', '720p', '480p', '360p', '240p'];
- const qOptions = qualities
- .map((q) => ``)
- .join('');
-
- const sel = (id, val, opts) => ``;
-
- wrap.innerHTML = `
-
- Appearance & accessibility
-
-
-
-
-
-
-
-
-
- Playback
-
-
-
-
- ${EQ.isIOS() ? `` : ''}
-
-
-
-
-
-
-
- Audio effects (JamesDSP)
-
-
-
-
-
-
-
-
- Supported sample rates: 44.1 and 48 kHz.
-
-
-
- Sharing (peer-to-peer)
-
-
-
- This device…
- …
-
-
-
-
-
- Offline cache
-
-
-
- Storage used
- …
-
-
-
-
-
-
- Saved videos
-
-
-
-
-
- Saved searches
-
-
- Kept on this device
- Search results and their thumbnails stay saved after you clear the search box. Only you can remove them, here.
-
- …
-
-
-
-
-
-
-
-
-
-
- Playlists
-
-
-
-
-
-
-
-
-
- Backup & restore
-
-
-
-
-
-
-
-
-
-
- Reset
-
-
- Force refresh UI
- Re-fetches the app shell and assets, bypassing the browser cache, then reloads.
-
-
-
-
-
-
-
- Reset app
- Clears all local storage, unregisters the service worker, and deletes every cache. You will need to reload after.
-
-
-
-
-
-
-
-
- Online profile — sync between devices
-
-
- Linked profile
- Playlists, history and settings sync to the server under this name. The name works like a passkey — anyone who knows it can load and change this data, so prefer a random one.
-
- ${data.profile && data.profile.name ? data.profile.name : 'Not linked'}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- About
-
-
- Version
- App version and current build.
-
-
- v${APP_VERSION}
-
-
-
-
- Build time
- When the running server build was produced — compare across devices to confirm you're on the latest version.
-
- …
-
- `;
-
- c.appendChild(wrap);
- const definitions = [
- ['appearance', 'Appearance', 'Listening', () => `${data.settings.theme || 'dark'} · ${data.settings.fontScale || 'normal'} text`, ' '],
- ['playback', 'Playback & sound', 'Listening', () => `${data.settings.quality || 'auto'} quality · ${Level.enabled() ? 'Levelled volume' : 'Original volume'}`, ' '],
- ['jamesdsp', 'Audio effects (JamesDSP)', 'Listening', () => data.settings.jamesDsp?.enabled ? `On · ${data.settings.jamesDsp.preset || 'Flat'}` : 'Off', ' '],
- ['sharing', 'Sharing', 'Library & storage', () => data.settings.p2pShare !== false ? 'Sharing saved videos' : 'Sharing off', ' '],
- ['offline', 'Offline storage', 'Library & storage', () => `${SaveSlots.limit ? SaveSlots.limit() : Number(data.settings.parallelSaves) || 4} at once · ${$('cacheTotal')?.textContent || 'Checking storage'}`, ' '],
- ['searches', 'Saved searches', 'Library & storage', () => `${SearchLibrary.list().length} saved searches`, ' '],
- ['playlists', 'Playlists', 'Library & storage', () => `${data.playlists.length} playlists`, ' '],
- ['backup', 'Backup & restore', 'Account & sync', () => data.settings.autoBackupEnabled ? 'Scheduled backup on' : 'Manual backups', ' '],
- ['reset', 'Refresh & reset', 'Advanced & about', () => 'Refresh the app or clear device data', ' '],
- ['profile', 'Profile & sync', 'Account & sync', () => data.profile?.name || 'Not linked', ' '],
- ['about', 'About', 'Advanced & about', () => `Version ${APP_VERSION}`, ' '],
- ];
- const groups = [...wrap.querySelectorAll(':scope > .set-group')];
- definitions.forEach(([id, title, cluster, summary, icon], i) => SettingsSections.register({ id, title, cluster, summary, icon, render(container) { container.append(groups[i]); } }));
- SettingsSections.register({ id: 'video-fullscreen', title: 'Video & fullscreen', cluster: 'Listening', icon: ' ', summary: () => `Fullscreen: ${FullscreenOrientation.normalize(data.settings.fullscreenOrientation)}`, render(container) {
- const row = document.createElement('label'); row.className = 'set-row';
- row.innerHTML = 'Fullscreen orientation';
- const select = row.querySelector('select'); select.value = FullscreenOrientation.normalize(data.settings.fullscreenOrientation);
- select.onchange = () => { data.settings.fullscreenOrientation = select.value; persist(); };
- container.append(row);
- } });
- SettingsSections.mount(wrap, { settings: data.settings, data, persist, toast });
-
- // ---- Wire appearance / accessibility ----
- $('setTheme').addEventListener('change', (e) => { data.settings.theme = e.target.value; applyAppearance(); persist(); });
- $('setLayout').addEventListener('change', (e) => { data.settings.layout = e.target.value; applyAppearance(); persist(); });
- $('setFont').addEventListener('change', (e) => { data.settings.fontScale = e.target.value; applyAppearance(); persist(); });
- $('setParallelSaves').addEventListener('change', (e) => { data.settings.parallelSaves = Number(e.target.value) || 4; persist(); SaveSlots.pump(); });
- $('setDensity').addEventListener('change', (e) => { data.settings.density = e.target.value; applyAppearance(); persist(); });
- $('setPerf').addEventListener('change', (e) => { data.settings.perfMode = e.target.checked; applyAppearance(); persist(); });
- $('setMotion').addEventListener('change', (e) => { data.settings.reduceMotion = e.target.checked; applyAppearance(); persist(); });
- $('setRepeat').addEventListener('change', (e) => { data.settings.repeatMode = e.target.checked ? 'all' : 'off'; updateLoopRepeatButtons(); persist(); });
- $('setLoopOne').addEventListener('change', (e) => { data.settings.loopOne = e.target.checked; updateLoopRepeatButtons(); persist(); });
- $('setLevelVolume').addEventListener('change', (e) => {
- data.settings.levelVolume = e.target.checked;
- persist();
- if (current && current.meta) Level.onTrack(current.meta); else EQ.setLevel(0);
- EqRender.scheduleAll();
- });
- if ($('setLiveAudioIOS')) $('setLiveAudioIOS').addEventListener('change', (e) => {
- data.settings.liveAudioIOS = e.target.checked;
- persist();
- EQ.apply();
- if (current && current.meta) Level.onTrack(current.meta);
- if (e.target.checked) toast('Live sound processing on — streamed songs go silent when the screen locks', { duration: 4500 });
- });
- // ---- Saved searches manager ----
- {
- const listEl = $('ssList');
- const sel = new Set();
- const refresh = () => {
- const items = SearchLibrary.list();
- for (const k of [...sel]) if (!items.some((i) => i.key === k)) sel.delete(k);
- $('ssTotal').textContent = items.length ? `${items.length} · ${fmtBytes(SearchLibrary.bytes())}` : 'None';
- listEl.innerHTML = items.length ? items.map((i) => `
- `).join('') : 'No saved searches.';
- $('ssDeleteSel').disabled = !sel.size;
- $('ssDeleteSel').textContent = sel.size ? `Delete selected (${sel.size})` : 'Delete selected';
- $('ssSelectAll').textContent = items.length && sel.size === items.length ? 'Select none' : 'Select all';
- $('ssClearAll').disabled = !items.length;
- };
- listEl.addEventListener('change', (e) => { const k = e.target.dataset && e.target.dataset.k; if (!k) return; e.target.checked ? sel.add(k) : sel.delete(k); refresh(); });
- $('ssSelectAll').addEventListener('click', () => {
- const items = SearchLibrary.list();
- if (sel.size === items.length) sel.clear(); else items.forEach((i) => sel.add(i.key));
- refresh();
- });
- $('ssDeleteSel').addEventListener('click', () => {
- if (!sel.size || !confirm(`Delete ${sel.size} saved search${sel.size === 1 ? '' : 'es'}? Their results and thumbnails are removed from this device.`)) return;
- SearchLibrary.remove([...sel]); sel.clear(); refresh(); toast('Saved searches deleted');
- });
- $('ssClearAll').addEventListener('click', () => {
- if (!confirm('Delete ALL saved searches and their thumbnails from this device?')) return;
- SearchLibrary.clear(); sel.clear(); refresh(); toast('All saved searches deleted');
- });
- refresh();
- }
- $('setJamesDsp').addEventListener('change', (e) => { JamesDSP.toggle(e.target.checked).catch(() => {}); });
- $('setJamesDspPreset').addEventListener('change', async (e) => { data.settings.jamesDsp ||= { enabled: false, params: {} }; data.settings.jamesDsp.preset = e.target.value; persist(); try { await JamesDSP.apply(); } catch {} });
- const dspParam = (id, name, parse = Number) => $(id).addEventListener('input', async (e) => { data.settings.jamesDsp ||= { enabled: false, preset: 'Flat', params: {} }; const value = parse(e.target.value); data.settings.jamesDsp.params ||= {}; data.settings.jamesDsp.params[name] = value; persist(); try { if (JamesDSP.controller) await JamesDSP.controller.update(name, value, data.settings.jamesDsp); } catch (error) { JamesDSP.fail(error); } });
- dspParam('setDspEqLow', 'eq.band0'); dspParam('setDspEqMid', 'eq.band8'); dspParam('setDspEqHigh', 'eq.band14'); dspParam('setDspBass', 'bass.boost'); dspParam('setDspLimiter', 'limiter.threshold');
- $('setDspCrossfeed').addEventListener('change', async (e) => { const s = data.settings.jamesDsp ||= { enabled: false, preset: 'Flat', params: {} }; s.params ||= {}; s.params['crossfeed.enabled'] = Number(e.target.checked); persist(); try { if (JamesDSP.controller) await JamesDSP.controller.update('crossfeed.enabled', Number(e.target.checked), s); } catch (error) { JamesDSP.fail(error); } });
- // ---- Wire playback controls ----
- $('setQuality').addEventListener('change', (e) => {
- data.settings.quality = e.target.value;
- els.quality.value = e.target.value;
- persist();
- });
- $('setVolume').addEventListener('input', (e) => {
- data.settings.volume = parseFloat(e.target.value);
- els.volume.value = e.target.value;
- Player.applyVolume();
- syncMuteIcon();
- persist();
- });
- $('setAudioOnly').addEventListener('change', (e) => {
- data.settings.audioOnly = e.target.checked;
- els.audioOnlyToggle.checked = e.target.checked;
- persist();
- });
- $('setSaveBeforePlay').addEventListener('change', (e) => {
- data.settings.saveBeforePlay = e.target.checked;
- persist();
- });
- $('setLyricFlags').addEventListener('change', (e) => {
- data.settings.lyricFlags = e.target.checked;
- persist();
- Notes.refreshFlags();
- });
- $('setAutoPreload').addEventListener('change', (e) => {
- data.settings.autoPreload = e.target.checked;
- persist();
- if (e.target.checked) data.playlists.forEach(preloadPlaylist);
- });
- $('setP2pShare').addEventListener('change', (e) => {
- data.settings.p2pShare = e.target.checked;
- persist();
- if (window.P2PClient) window.P2PClient.sync(); // share off → withdraws every holding
- });
- $('setP2pReceive').addEventListener('change', (e) => {
- data.settings.p2pReceive = e.target.checked;
- persist();
- if (window.P2PClient) window.P2PClient.sync();
- });
- (async () => {
- const info = $('p2pDeviceInfo');
- const cnt = $('p2pHoldingCount');
- if (!info || !cnt) return;
- const P = window.P2PClient;
- const d = P && P.device();
- info.textContent = !d ? 'Not registered yet'
- : P.isConnected() ? `Online · peer ${P.peer()}` : 'Registered · not connected';
- const files = window.DeviceDB ? await window.DeviceDB.listFiles() : [];
- cnt.textContent = `${files.filter((f) => f.state === 'verified').length} verified / ${files.length} saved`;
- const unknown = window.P2PClient ? await window.P2PClient.unknownVideos() : [];
- const row = $('p2pContributeRow');
- const btn = $('p2pContributeBtn');
- if (row && btn && unknown.length) {
- row.classList.remove('hidden');
- btn.textContent = `Verify & share ${unknown.length} saved video${unknown.length === 1 ? '' : 's'}`;
- btn.onclick = async () => {
- btn.disabled = true;
- let ok = 0;
- for (let i = 0; i < unknown.length; i++) {
- const v = videoById(unknown[i]) || {};
- btn.textContent = `Uploading ${i + 1} of ${unknown.length}…`;
- const r = await window.P2PClient.contribute(unknown[i], { title: v.title || '', channel: v.channel || '' });
- if (r && r.ok) ok++;
- }
- toast(`Verified ${ok} of ${unknown.length} saved video${unknown.length === 1 ? '' : 's'}`);
- btn.disabled = false;
- row.classList.add('hidden');
- };
- }
- })();
- // ---- Cache management ----
- $('clearCacheBtn').addEventListener('click', () => {
- showModal('Clear all cached videos?', document.createTextNode('This frees disk space. Playlists keep their entries and will re-download on demand.'), [
- { label: 'Cancel', onClick: closeModal },
- {
- label: 'Clear all', danger: true, onClick: async () => {
- try { await API.cacheClear(); } catch {}
- cachedIds.clear();
- closeModal();
- toast('Cache cleared');
- if (current) updateNowPlayingActions();
- renderList();
- },
- },
- ]);
- });
-
- // The saved-videos list folds; remember how the user left it.
- {
- const fold = $('cacheFold');
- try { fold.open = localStorage.getItem('ytpCacheFoldOpen') === '1'; } catch { /* blocked */ }
- fold.addEventListener('toggle', () => { try { localStorage.setItem('ytpCacheFoldOpen', fold.open ? '1' : '0'); } catch { /* blocked */ } });
- }
-
- // ---- Populate live cache stats ----
- try {
- const res = await API.cacheList();
- if (view.type !== 'settings') return; // user navigated away
- const total = (res && res.total) || 0;
- const items = (res && res.items) || [];
- $('cacheTotal').textContent = `${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'}`;
- $('cacheFoldCount').textContent = items.length ? `${items.length} · ${fmtBytes(total)}` : 'none';
- const listEl = $('cacheList');
- if (!items.length) {
- listEl.innerHTML = 'No videos saved offline yet.';
- } else {
- items.sort((a, b) => b.size - a.size);
- items.forEach((it) => {
- const row = document.createElement('div');
- row.className = 'cache-item';
- row.innerHTML = `${fmtBytes(it.size)}`;
- row.querySelector('.ci-title').textContent = videoTitleById(it.id);
- row.querySelector('.ci-del').addEventListener('click', async () => {
- try { await API.cacheDelete(it.id); } catch {}
- cachedIds.delete(it.id);
- if (current && current.meta && current.meta.id === it.id) updateNowPlayingActions();
- toast('Removed from cache');
- renderList();
- });
- listEl.appendChild(row);
- });
- }
- } catch {
- const t = $('cacheTotal');
- if (t) t.textContent = 'Offline cache not available in this build';
- }
-
- // ---- Cache cap ----
- const capSel = $('cacheCapSelect');
- capSel.value = String(data.settings.cacheCap || 0);
- capSel.addEventListener('change', (e) => {
- data.settings.cacheCap = parseInt(e.target.value) || 0;
- persist();
- });
-
- // ---- Export / Import playlists ----
- $('importYtBtn')?.addEventListener('click', openImportYouTubeModal);
- $('exportBtn').addEventListener('click', exportPlaylists);
- $('importBtn').addEventListener('click', () => $('fileInput').click());
- $('fileInput').addEventListener('change', importPlaylists);
-
- // ---- Backup ----
- $('setAutoBackup').addEventListener('change', (e) => { data.settings.autoBackupEnabled = e.target.checked; persist(); });
- $('setBackupInterval').addEventListener('change', (e) => { data.settings.autoBackupIntervalDays = parseInt(e.target.value) || 7; persist(); });
- $('backupNowBtn').addEventListener('click', () => doAutoBackup());
- $('importBackupBtn').addEventListener('click', () => $('backupFileInput').click());
- $('backupFileInput').addEventListener('change', importBackup);
-
- // ---- Online profile ----
- $('profileCreateBtn').addEventListener('click', createProfileFlow);
- $('profileLoadBtn').addEventListener('click', loadProfileFlow);
- $('profileCopyLinkBtn').addEventListener('click', copyProfileLink);
- $('profileShareBtn').addEventListener('click', shareProfileLink);
- $('profileProtectBtn').addEventListener('click', protectProfileFlow);
- $('profileExportCredBtn').addEventListener('click', exportProfileCredentials);
- $('profileImportCredBtn').addEventListener('click', importProfileCredentials);
- $('profileUnlinkBtn').addEventListener('click', () => {
- data.profile = null;
- ProfileSecret.clear();
- persist();
- updateProfileStatus();
- toast('Profile unlinked — this device stops syncing (server copy is kept)');
- });
- updateProfileStatus();
-
- // ---- About: append server build tag + build time (WEB mode only) ----
- if (WEB) {
- try {
- const res = await fetch('/api/version', { cache: 'no-store' });
- if (res.ok && view.type === 'settings') {
- const v = await res.json();
- const el = $('aboutVersion');
- if (el && v.buildTag) el.textContent = `v${v.version || APP_VERSION} · build ${v.buildTag}`;
- const bt = $('aboutBuildTime');
- if (bt) {
- const t = v.buildTime ? new Date(v.buildTime) : null;
- if (t && !isNaN(t)) {
- // Shown in GMT+8 regardless of device/server timezone, plus build age.
- const gmt8 = new Date(t.getTime() + 8 * 3600e3).toISOString().replace('T', ' ').slice(0, 16);
- const s = Math.max(0, Math.floor((Date.now() - t.getTime()) / 1000));
- const ago = s < 60 ? `${s} second${s === 1 ? '' : 's'} ago`
- : s < 3600 ? `${Math.floor(s / 60)} minute${Math.floor(s / 60) === 1 ? '' : 's'} ago`
- : s < 86400 ? `${Math.floor(s / 3600)} hour${Math.floor(s / 3600) === 1 ? '' : 's'} ago`
- : `${Math.floor(s / 86400)} day${Math.floor(s / 86400) === 1 ? '' : 's'} ago`;
- bt.textContent = `${gmt8} +08 · built ${ago}`;
- } else {
- bt.textContent = 'unknown';
- }
- }
- }
- } catch { /* offline — leave the static version in place */ }
- }
-
- // ---- Reset ----
- $('refreshUiBtn').addEventListener('click', async () => {
- const btn = $('refreshUiBtn');
- btn.disabled = true;
- btn.textContent = 'Refreshing…';
- // hardReloadUI() deletes all caches and unregisters the SW so the
- // next page load always fetches fresh assets from the network.
- await hardReloadUI();
- });
-
- $('resetAppBtn').addEventListener('click', () => {
- showModal(
- 'Reset app?',
- (() => { const p = document.createElement('p'); p.textContent = 'This clears all local data and caches. The page will reload.'; return p; })(),
- [
- { label: 'Cancel', onClick: closeModal },
- {
- label: 'Reset', danger: true, onClick: async () => {
- closeModal();
- try {
- localStorage.clear();
- sessionStorage.clear();
- if ('serviceWorker' in navigator) {
- const regs = await navigator.serviceWorker.getRegistrations();
- await Promise.all(regs.map((r) => r.unregister()));
- }
- if ('caches' in window) {
- const keys = await caches.keys();
- await Promise.all(keys.map((k) => caches.delete(k)));
- }
- } catch { /* best effort — reload regardless */ }
- window.location.href = '/';
- },
- },
- ],
- );
- });
-}
-
-function renderCard(v, index, list) {
- const card = document.createElement('div');
- const isCached = cachedIds.has(v.id);
- const isDownloading = downloading.has(v.id);
- card.className = 'card' + (isCached ? ' cached' : '') + (isDownloading ? ' downloading' : '') + (selectedIds.has(v.id) ? ' selected' : '');
- card.dataset.id = v.id;
- card._video = v; // read by f7-layout.js swipe actions
- card.innerHTML = `
-
-
-
- ${v.duration ? `${fmtTime(v.duration)}` : ''}
- ${isDownloading ? '' : '⬇'}
- ${isDownloading ? '' : ''}
-
-
-
-
-
- `;
- card.querySelector('.card-title').textContent = v.title;
- const chEl = card.querySelector('.card-channel');
- chEl.textContent = v.channel || '';
- if (channelKeyOf(v)) {
- chEl.classList.add('link');
- chEl.title = 'View channel';
- chEl.addEventListener('click', (e) => {
- e.stopPropagation();
- openChannel(channelKeyOf(v), v.channel);
- });
- }
-
- // Playlist view: note under title (if present) and note edit button (Section 5)
- if (view.type === 'playlist') {
- const hasNote = !!(v.notes && v.notes.trim());
- if (hasNote) {
- const noteEl = document.createElement('div');
- noteEl.className = 'card-note';
- noteEl.textContent = v.notes.trim();
- noteEl.title = v.notes.trim();
- const titleEl = card.querySelector('.card-title');
- if (titleEl) titleEl.after(noteEl);
- }
-
- const noteBtn = document.createElement('button');
- noteBtn.type = 'button';
- noteBtn.className = 'card-note-btn' + (hasNote ? ' has-note' : '');
- noteBtn.title = hasNote ? 'Edit note' : 'Add note';
- noteBtn.textContent = '✎';
- noteBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- openSongNoteModal(v);
- });
- const menuBtn = card.querySelector('.card-menu');
- card.insertBefore(noteBtn, menuBtn);
- }
-
- card.addEventListener('pointerdown', () => warmStreams([v.id]), { passive: true });
- card.addEventListener('click', (e) => {
- if (e.target.closest('.card-menu') || e.target.closest('.card-del') || e.target.closest('.card-note-btn') || e.target.closest('.card-channel.link')) return;
- if (selectMode) { toggleSelectCard(v.id); return; }
- playFromList(list, index, view.type === 'playlist' ? 'playlist:' + view.id : view.type === 'nowPlaying' ? queueSource : view.type);
- });
- card.querySelector('.card-menu').addEventListener('click', (e) => {
- e.stopPropagation();
- openCardMenu(v);
- });
-
- // Per-item delete in playlist, queue, history, and smart views.
- if (view.type === 'playlist' || view.type === 'queue' || view.type === 'history' || view.type === 'smart') {
- const del = document.createElement('button');
- del.className = 'card-del';
- del.title = view.type === 'queue' ? 'Remove from queue'
- : (view.type === 'history' || view.type === 'smart') ? 'Remove from history'
- : 'Remove from playlist';
- del.textContent = '✕';
- del.addEventListener('click', (e) => {
- e.stopPropagation();
- if (view.type === 'queue') { removeFromQueue(v.id); return; }
- if (view.type === 'history' || view.type === 'smart') {
- data.history = data.history.filter((x) => x.id !== v.id);
- persist();
- renderList();
- return;
- }
- const pl = data.playlists.find((p) => p.id === view.id);
- if (pl) { pl.videos = pl.videos.filter((x) => x.id !== v.id); persist(); render(); toast('Removed from playlist'); }
- });
- card.appendChild(del);
- }
-
- // Drag-to-reorder in playlist and queue views
- if (view.type === 'playlist' || view.type === 'queue') {
- card.draggable = true;
- card.addEventListener('dragstart', () => {
- card.classList.add('dragging');
- dragSource = index;
- });
- card.addEventListener('dragend', () => {
- card.classList.remove('dragging');
- });
- card.addEventListener('dragover', (e) => {
- e.preventDefault();
- card.classList.add('drag-over');
- });
- card.addEventListener('dragleave', () => {
- card.classList.remove('drag-over');
- });
- card.addEventListener('drop', (e) => {
- e.preventDefault();
- card.classList.remove('drag-over');
- const from = dragSource;
- const to = index;
- if (from === to || from < 0) return;
- const arr = view.type === 'queue'
- ? data.queue
- : (data.playlists.find((p) => p.id === view.id) || {}).videos;
- if (!arr) return;
- const [moved] = arr.splice(from, 1);
- arr.splice(to, 0, moved);
- if (view.type === 'queue' && queueSource === 'queue') {
- const playingId = current && current.meta && current.meta.id;
- queue = data.queue.slice();
- queueIndex = playingId ? queue.findIndex((x) => x.id === playingId) : queueIndex;
- renderUpNext();
- }
- persist();
- renderList();
- });
- }
-
- return card;
-}
-
-function markPlayingCard() {
- document.querySelectorAll('.card').forEach((c) => {
- c.classList.toggle('playing', current && c.dataset.id === current.meta.id);
- });
-}
// Only entries created by returning to the player participate here; leave
// unrelated history (including Settings section navigation) to its owner.
@@ -10493,7 +6948,7 @@ function openImportYouTubeModal() {
`;
- showModal('⬇ Import from YouTube', successBody, [
+ showModal('Import from YouTube', successBody, [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Create playlist',
@@ -10532,7 +6987,7 @@ function openImportYouTubeModal() {
}
}
- showModal('⬇ Import from YouTube', body, [
+ showModal('Import from YouTube', body, [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Import', primary: true, onClick: handleImport },
]);
@@ -10557,14 +7012,14 @@ function openCardMenu(video) {
// Quick: add to queue
const queueBtn = document.createElement('button');
- queueBtn.textContent = '▶ Add to queue';
+ queueBtn.innerHTML = TransportIcons.svg('play') + ' 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.innerHTML = MediaIcons.svg('download') + ' 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);
@@ -10578,7 +7033,7 @@ function openCardMenu(video) {
saveBtn.className = 'danger';
saveBtn.onclick = () => { closeModal(); deleteCustomVideo(video.id); };
} else {
- saveBtn.textContent = isSaved ? '✓ Saved offline — remove' : '⬇ Save for offline';
+ saveBtn.innerHTML = MediaIcons.svg(isSaved ? 'offline' : 'download') + (isSaved ? ' Saved offline — remove' : ' Save for offline');
saveBtn.onclick = async () => {
closeModal();
if (cachedIds.has(video.id)) {
@@ -10805,17 +7260,17 @@ function updatePlaylistOfflineButton(btn, pl) {
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';
+ if (iconOnly) { setListIcon(btn, 'offlineDone'); btn.classList.add('done'); } else btn.innerHTML = MediaIcons.svg('offline') + ' 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 = `${stats.cached}/${stats.total}`; btn.classList.add('busy'); } else btn.textContent = `Saving ${stats.cached}/${stats.total}…`;
+ if (iconOnly) { btn.innerHTML = MediaIcons.svg('progress') + `${stats.cached}/${stats.total}`; btn.classList.add('busy'); } else btn.innerHTML = MediaIcons.svg('progress') + ` 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';
+ if (iconOnly) setListIcon(btn, 'offline'); else btn.innerHTML = MediaIcons.svg('download') + ' 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';
@@ -10832,7 +7287,7 @@ async function makePlaylistOffline(pl) {
const btn = $('plOfflineBtn');
if (btn) {
const stats = playlistOfflineStats(pl);
- btn.innerHTML = `${stats.cached}/${stats.total}`;
+ btn.innerHTML = MediaIcons.svg('progress') + `${stats.cached}/${stats.total}`;
btn.classList.add('busy');
btn.disabled = true;
btn.classList.add('disabled');
@@ -11159,7 +7614,7 @@ function wireUI() {
// ---- 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);
+ 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);
@@ -11371,8 +7826,8 @@ document.querySelectorAll('.chip').forEach((c) => {
$('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());
- $('externalBtn').addEventListener('click', () => External.open());
+ $('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());
@@ -11936,41 +8391,7 @@ const fetchRelatedVideos = RelatedVideos.createLoader({
async function loadRelated(options = {}) {
if (current?.meta) return fetchRelatedVideos(current.meta, options);
}
-function renderRelated() {
- const panel = $('relatedPanel'), list = $('relatedList');
- if (!panel || !list || (relatedState.status === 'idle' && !relatedVideos.length)) { if (panel) panel.classList.add('hidden'); return; }
- panel.classList.remove('hidden');
- list.classList.toggle('hidden', relatedCollapsed);
- list.innerHTML = '';
- if (relatedState.message) {
- const notice = document.createElement('div'); notice.className = 'related-notice'; notice.setAttribute('role', 'status');
- const message = document.createElement('p'); message.textContent = relatedState.message; notice.append(message);
- if (relatedState.status === 'error') {
- const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'related-retry'; retry.setAttribute('aria-label', 'Retry related videos');
- retry.innerHTML = 'Retry';
- retry.onclick = () => loadRelated({ refresh: true }); notice.append(retry);
- }
- list.append(notice);
- }
- relatedVideos.forEach((v) => {
- const item = document.createElement('div');
- item.className = 'related-item';
- item.innerHTML = `
`;
- item.querySelector('.ri-title').textContent = v.title;
- const riChEl = item.querySelector('.ri-channel');
- riChEl.textContent = v.channel || '';
- if (channelKeyOf(v)) {
- riChEl.classList.add('link');
- riChEl.title = 'View channel';
- riChEl.addEventListener('click', (e) => { e.stopPropagation(); openChannel(channelKeyOf(v), v.channel); });
- }
- item.addEventListener('click', (e) => {
- if (e.target.closest('.ri-channel.link')) return;
- queue = [v]; queueIndex = 0; playFullMode = false; Player.loadVideo(v); renderUpNext();
- });
- list.appendChild(item);
- });
-}
+
// ============================================================================
// Smart / auto playlists
@@ -11981,26 +8402,7 @@ const SMART_PLAYLISTS = [
{ id: 'unwatched', label: 'Unwatched' },
{ id: 'autoMix', label: 'Auto Mix' },
];
-function renderSmartSidebar() {
- const list = $('smartPlaylistList');
- if (!list) return;
- list.innerHTML = '';
- SMART_PLAYLISTS.forEach((sp) => {
- const item = document.createElement('div');
- const isActive = view.type === 'smart' && view.smartType === sp.id;
- item.className = 'playlist-item smart-item' + (isActive ? ' active' : '');
- item.innerHTML = `${sp.label}`;
- item.addEventListener('click', () => { view = { type: 'smart', smartType: sp.id }; render(); });
- list.appendChild(item);
- });
- if (WEB && data.settings.p2pReceive !== false) {
- const item = document.createElement('div');
- item.className = 'playlist-item smart-item' + (view.type === 'p2p' ? ' active' : '');
- item.innerHTML = '📡 On other devices';
- item.addEventListener('click', () => { view = { type: 'p2p' }; render(); });
- list.appendChild(item);
- }
-}
+
function getSmartList(smartType) {
switch (smartType) {
case 'mostPlayed': {
@@ -12308,7 +8710,7 @@ async function applyUpdate(target, { quiet = false } = {}) {
const reg = _swReg || (('serviceWorker' in navigator) ? await navigator.serviceWorker.getRegistration() : null);
const SU = window.SwUpdate;
const reload = () => window.location.reload();
- if (!SU) { reload(); return; } // helper failed to load (flaky link): a reload is the best we can do
+ if (!SU) throw new Error('Update helper unavailable; try Refresh UI again when paused');
await SU.applyUpdate({
reg,
container: navigator.serviceWorker,
@@ -12316,11 +8718,11 @@ async function applyUpdate(target, { quiet = false } = {}) {
// 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) => {
+ onPhase: (phase, status) => {
if (quiet) return;
if (_updateToast) _updateToast.remove();
_updateToast = phase === 'ready'
- ? toast('Applying the update…', { duration: 20000 })
+ ? toast(status?.offlineReady === false ? `Applying the update… Offline cache is still preparing (${status.cached}/${status.total}).` : 'Applying the update…', { duration: 20000 })
: toast('Downloading the update…', { duration: 305000 });
},
refreshShell: ('caches' in window) ? () => SU.refreshShellInPlace({
@@ -12383,6 +8785,7 @@ async function registerServiceWorker() {
// 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
@@ -12629,10 +9032,11 @@ async function boot() {
// and re-render only if something actually changed.
syncOnLaunch();
startPlaylistInboxWatch();
- Presenter.boot();
+ if(LowerThird.active()) await Lazy.load('feature:lower-third');
+ await Presenter.boot().catch(error => toast(error.message));
if (LowerThird.active()) return;
SectionRail.boot();
- Share.bootFromUrl();
+ if (isMediaId(new URLSearchParams(location.search).get('v'))) Share.bootFromUrl().catch(error => toast(error.message));
bootViewFromUrl();
bootSearchFromUrl();
if (WEB) bootShareFromUrl();
@@ -12658,7 +9062,7 @@ async function boot() {
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 = EqCore.migrate(data.settings.eq.g); persist(); }
+ 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(); } });
@@ -12671,6 +9075,8 @@ async function boot() {
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();
@@ -12684,10 +9090,12 @@ async function boot() {
await refreshCachedIds();
startCacheResyncWatch();
// Peer-to-peer: report what this device holds (on by default; settings.p2pShare).
- if (WEB && window.P2PClient) {
- window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '', getProfileSecret: () => ProfileSecret.getFor(data.profile?.name) });
- if (window.P2PTransfer) window.P2PTransfer.start({ canShare: () => false }); // New scoped invitations replace the unconfirmed legacy sender.
- }
+ 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();
@@ -12716,6 +9124,12 @@ async function boot() {
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',
diff --git a/frontend/asset-sync-core.js b/frontend/asset-sync-core.js
new file mode 100644
index 0000000..0c6ede2
--- /dev/null
+++ b/frontend/asset-sync-core.js
@@ -0,0 +1,204 @@
+/* Exact, resumable asset synchronization shared by pages and workers. */
+(function (root) {
+ 'use strict';
+ const CACHE = 'ytplayer-assets';
+ const STATE = '/__ytp_asset_state';
+ const url = (path, file) => path + '?v=' + file.h;
+
+ function paths(manifest) {
+ return Object.keys(manifest.files).sort();
+ }
+
+ function blocking(manifest, previous, activeLayout) {
+ const names = new Set(Object.entries(manifest.groups)
+ .filter(([name, group]) => name === 'core' || !group.background ||
+ name === 'layout:' + activeLayout ||
+ (previous?.groups[name] && previous.groups[name].contract !== group.contract))
+ .map(([name]) => name));
+ for (const name of names) for (const dependency of manifest.groups[name].dependencies || []) names.add(dependency);
+ return [...new Set([...names].flatMap(name => manifest.groups[name].files))].sort();
+ }
+
+ function plan(manifest, keys) {
+ const held = new Set(keys);
+ return {
+ missing: paths(manifest).map(path => url(path, manifest.files[path])).filter(key => !held.has(key)),
+ blocking: blocking(manifest).map(path => url(path, manifest.files[path])),
+ };
+ }
+
+ function retained(current, previous) {
+ const currentPaths = new Set(paths(current));
+ const keep = new Set([...currentPaths].map(path => url(path, current.files[path])));
+ if (previous) for (const path of paths(previous)) {
+ if (!currentPaths.has(path) || current.files[path]?.h !== previous.files[path].h) keep.add(url(path, previous.files[path]));
+ }
+ return keep;
+ }
+
+ function fallback(manifest, previous, path) {
+ if (!manifest.files[path] || !previous?.files[path]) return null;
+ const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path));
+ if (!group || !previous.groups[group]?.files.includes(path) ||
+ manifest.groups[group].contract !== previous.groups[group].contract) return null;
+ // A stale core must understand every group contract in the new shell.
+ if (path === "/app.js" && Object.entries(manifest.groups).some(([name, entry]) =>
+ previous.groups[name]?.contract !== entry.contract)) return null;
+ return url(path, previous.files[path]);
+ }
+
+ async function state(cache) {
+ const response = await cache.match(STATE);
+ return response ? response.json() : null;
+ }
+
+ function verifiedProbe(response, hash) {
+ if (!response) return false;
+ const valid = response.headers.get('X-Asset-Hash') === hash;
+ // Metadata checks must release Blob/data-pipe readers. They never consume
+ // asset bytes, and cancelling this response does not remove its cache entry.
+ response.body?.cancel().catch(() => {});
+ return valid;
+ }
+
+ async function status(manifest, cache, { previous, activeLayout, offline = true } = {}) {
+ const found = await Promise.all(blocking(manifest, previous, activeLayout).map(async path => {
+ const response = await cache.match(url(path, manifest.files[path]));
+ return verifiedProbe(response, manifest.files[path].h);
+ }));
+ const missing = found.filter(value => !value).length;
+ return { ready: missing === 0, missing, version: manifest.buildTag, ...(offline ? await completeness(manifest, cache) : {}) };
+ }
+
+ // All concurrent messages/layout jobs in this worker share six download slots.
+ let downloads = 0;
+ const waiters = [];
+ async function downloadSlot(task) {
+ if (downloads >= 6) await new Promise(resolve => waiters.push(resolve));
+ else downloads++;
+ try { return await task(); }
+ finally { const next = waiters.shift(); if (next) next(); else downloads--; }
+ }
+
+ const pendingDownloads = new Map();
+ async function sharedDownload(key, task) {
+ while (pendingDownloads.has(key)) await pendingDownloads.get(key).catch(() => {});
+ const promise = downloadSlot(task);
+ pendingDownloads.set(key, promise);
+ try { return await promise; }
+ finally { pendingDownloads.delete(key); }
+ }
+
+ async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups, all = false }) {
+ const previous = (await state(cache))?.current;
+ const missing = [];
+ const selected = all ? paths(manifest) : groups ? [...new Set(groups.flatMap(name => manifest.groups[name]?.files || []))].sort()
+ : blocking(manifest, previous, activeLayout);
+ for (const path of selected) {
+ const key = url(path, manifest.files[path]);
+ const response = await cache.match(key);
+ if (!verifiedProbe(response, manifest.files[path].h)) missing.push(key);
+ }
+ const count = missing.length;
+ let failure;
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(6, concurrency, count)) }, async () => {
+ while (missing.length) {
+ const key = missing.shift();
+ try { await sharedDownload(key, async () => {
+ const held = await cache.match(key);
+ if (verifiedProbe(held, key.split('v=')[1])) return;
+ let error;
+ for (let attempt = 0; attempt < Math.min(3, attempts); attempt++) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 30000);
+ try {
+ const response = await fetchFn(key, { credentials: 'same-origin', signal: controller.signal });
+ if (!response.ok || response.headers.get('X-Asset-Hash') !== key.split('v=')[1]) {
+ throw new Error('Asset hash mismatch: ' + key);
+ }
+ // Keep the timeout through body consumption, not just response headers.
+ await cache.put(key, response);
+ error = null;
+ break;
+ } catch (err) { error = err; }
+ finally { clearTimeout(timer); }
+ }
+ if (error) throw error;
+ }); } catch (error) { failure = error; }
+ }
+ }));
+ if (failure) throw failure;
+ if (!groups && !all && !(await status(manifest, cache, { previous, activeLayout, offline: false })).ready) throw new Error('Incomplete blocking assets');
+ return { refreshed: count, caches: 1 };
+ }
+
+
+ // Verified cache entries are the durable progress journal. Counters are rebuilt
+ // after worker termination or browser eviction; never trust a stored ready bit.
+ async function completeness(manifest, cache) {
+ const files = paths(manifest);
+ const missingFiles = (await Promise.all(files.map(async path => {
+ const response = await cache.match(url(path, manifest.files[path]));
+ return verifiedProbe(response, manifest.files[path].h) ? null : path;
+ }))).filter(Boolean);
+ const total = files.length;
+ return { offlineReady: missingFiles.length === 0, cached: total - missingFiles.length, total, missingFiles };
+ }
+
+ const completionKey = manifest => '/__ytp_completion/' + manifest.buildTag;
+ async function complete(manifest, { cache, fetchFn, cycles = 4, now = Date.now,
+ sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), baseDelay = 1000,
+ maxDelay = 30000, notify = () => {}, force = false } = {}) {
+ const key = completionKey(manifest), saved = await cache.match(key);
+ let job = saved ? await saved.json() : { failures: 0, nextRetryAt: 0 };
+ let snapshot = await completeness(manifest, cache);
+ const publish = async running => {
+ job = { ...job, ...snapshot, running, version: manifest.buildTag };
+ await cache.put(key, Response.json(job));
+ await notify(job);
+ };
+ if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; await publish(false); return job; }
+ if (!force && job.nextRetryAt > now()) await sleep(Math.min(maxDelay, job.nextRetryAt - now()));
+ for (let cycle = 0; cycle < cycles; cycle++) {
+ await publish(true);
+ let error;
+ try { await syncAssets(manifest, { cache, fetchFn, all: true }); }
+ catch (failure) { error = failure; }
+ snapshot = await completeness(manifest, cache);
+ if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; job.error = null; await publish(false); return job; }
+ job.failures++;
+ const delay = Math.min(maxDelay, baseDelay * 2 ** Math.min(job.failures - 1, 10));
+ job.nextRetryAt = now() + delay;
+ job.error = String(error?.message || 'Incomplete offline cache');
+ await publish(cycle + 1 < cycles);
+ if (cycle + 1 < cycles) await sleep(delay);
+ }
+ return job;
+ }
+
+ async function commit(manifest, cache, { previousClients, activeLayout } = {}) {
+ const old = await state(cache);
+ if (!(await status(manifest, cache, { previous: old?.current, activeLayout, offline: false })).ready) {
+ throw new Error('Incomplete blocking assets');
+ }
+ const sameBuild = old?.current?.buildTag === manifest.buildTag;
+ const previous = sameBuild ? old.previous : old?.current;
+ const next = {
+ current: manifest, previous,
+ previousClients: previousClients || (sameBuild ? old.previousClients : []),
+ };
+ // One state write publishes the complete build and its old-tab affinity.
+ await cache.put(STATE, new Response(JSON.stringify(next), { headers: { 'Content-Type': 'application/json' } }));
+ const keep = retained(manifest, previous);
+ for (const request of await cache.keys()) {
+ const parsed = new URL(request.url);
+ const key = parsed.pathname + parsed.search;
+ if (parsed.searchParams.has('v') && !keep.has(key)) await cache.delete(key);
+ }
+ return next;
+ }
+
+ const api = { CACHE, STATE, url, paths, blocking, plan, retained, fallback, status, completeness, completionKey, complete, syncAssets, state, commit };
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ else root.AssetSyncCore = api;
+})(typeof globalThis !== 'undefined' ? globalThis : this);
diff --git a/frontend/asset-sync-core.test.js b/frontend/asset-sync-core.test.js
new file mode 100644
index 0000000..60b366d
--- /dev/null
+++ b/frontend/asset-sync-core.test.js
@@ -0,0 +1,104 @@
+const {test}=require('node:test');
+const assert=require('node:assert/strict');
+const core=require('./asset-sync-core');
+const manifest=(a='a',b='b')=>({buildTag:a+b,files:{'/a.js':{h:a},'/index.html':{h:b}},groups:{core:{files:['/a.js','/index.html']}}});
+function cache(){const entries=new Map();return {entries,async match(k){return entries.get(k)?.clone()},async put(k,r){entries.set(k,r.clone())},async keys(){return [...entries.keys()].map(url=>({url:'http://local'+url}))},async delete(k){return entries.delete(k)}}}
+const response=h=>new Response(h,{headers:{'X-Asset-Hash':h}});
+test('exact diff shares unchanged URLs; retains N-1 and prunes N-2',()=>{assert.deepEqual(core.plan(manifest(),['/a.js?v=a']).missing,['/index.html?v=b']);assert.deepEqual([...core.retained(manifest('c'),manifest())].sort(),['/a.js?v=a','/a.js?v=c','/index.html?v=b']);});
+test('interrupted sync resumes verified files and refuses a mismatched hash',async()=>{const c=cache();let fail=true;let calls=[];const fetchFn=async u=>{calls.push(u);if(u.includes('index')&&fail)return response('wrong');return response(u.split('=')[1])};await assert.rejects(core.syncAssets(manifest(),{cache:c,fetchFn}),/hash/);assert.ok(await c.match('/a.js?v=a'));fail=false;calls=[];await core.syncAssets(manifest(),{cache:c,fetchFn});assert.deepEqual(calls,['/index.html?v=b']);await c.delete('/a.js?v=a');assert.equal((await core.status(manifest(),c)).missing,1);});
+test('commit is atomic and pruning keeps only current and previous changed versions',async()=>{const c=cache();await c.put('/a.js?v=z',response('z'));await core.syncAssets(manifest(),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest(),c);await core.syncAssets(manifest('c'),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest('c'),c);assert.ok(await c.match('/a.js?v=a'));assert.equal(await c.match('/a.js?v=z'),undefined);await core.syncAssets(manifest('d'),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest('d'),c);assert.equal(await c.match('/a.js?v=a'),undefined);});
+test('downloads are capped at six and each failure gets three attempts',async()=>{const c=cache(),files={},list=[];for(let i=0;i<13;i++){const p='/'+i+'.js';files[p]={h:String(i)};list.push(p)}const m={buildTag:'pool',files,groups:{core:{files:list}}};let active=0,max=0;const tries={};await core.syncAssets(m,{cache:c,concurrency:20,fetchFn:async u=>{active++;max=Math.max(max,active);await new Promise(r=>setTimeout(r,5));active--;tries[u]=(tries[u]||0)+1;if(tries[u]<3)throw Error('drop');return response(u.split('=')[1])}});assert.equal(max,6);assert.ok(Object.values(tries).every(n=>n===3));});
+test('background files do not block, but active layout and contract changes do',()=>{const m=manifest();m.groups.extra={background:true,contract:2,files:['/extra.js']};m.files['/extra.js']={h:'e'};assert.deepEqual(core.blocking(m),['/a.js','/index.html']);assert.ok(core.blocking(m,{groups:{extra:{contract:1}}}).includes('/extra.js'));m.groups['layout:classic']={background:true,files:['/classic.css']};m.files['/classic.css']={h:'c'};assert.ok(core.blocking(m,null,'classic').includes('/classic.css'));});
+test('removed files survive one previous build for open tabs',()=>{const previous=manifest(),current=manifest('c');delete current.files['/a.js'];current.groups.core.files=['/index.html'];assert.ok(core.retained(current,previous).has('/a.js?v=a'));});
+
+test('staged fallback permits only the previous same-contract version of a shipped group file', () => {
+ const old = manifest(), next = manifest('c');
+ old.groups.core.contract = next.groups.core.contract = 1;
+ assert.equal(core.fallback(next, old, '/a.js'), '/a.js?v=a');
+ next.groups.core.contract = 2;
+ assert.equal(core.fallback(next, old, '/a.js'), null);
+ assert.equal(core.fallback(next, old, '/missing.js'), null);
+});
+
+test('background synchronization fills all groups without publishing a new current manifest', async () => {
+ const c = cache(), m = manifest();
+ m.groups.optional = { contract: 1, background: true, files: ['/extra.js'] };
+ m.files['/extra.js'] = { h: 'e' };
+ const fetchFn = async key => response(key.split('=')[1]);
+ await core.syncAssets(m, { cache: c, fetchFn });
+ assert.equal(await c.match('/extra.js?v=e'), undefined);
+ await core.commit(m, c);
+ const before = await core.state(c);
+ const result = await core.syncAssets(m, { cache: c, fetchFn, groups: ['optional'] });
+ assert.equal(result.refreshed, 1);
+ assert.ok(await c.match('/extra.js?v=e'));
+ assert.deepEqual(await core.state(c), before);
+});
+
+test('active layouts also block on shared dependencies, while other layouts stay background', () => {
+ const m=manifest(); m.groups['layout:glass-stage']={contract:1,background:true,files:['/glass.css'],dependencies:['feature:controls']}; m.groups['feature:controls']={contract:1,background:true,files:['/controls.js']}; m.groups['layout:bento-hub']={contract:1,background:true,files:['/bento.css']};
+ for(const path of ['/glass.css','/controls.js','/bento.css'])m.files[path]={h:'h'};
+ assert.deepEqual(core.blocking(m,null,'glass-stage'),['/a.js','/controls.js','/glass.css','/index.html']);
+});
+
+test('removing group membership retains the old tab URL even if the physical file is still shipped unchanged',()=>{
+ const previous=manifest(),current=manifest();current.groups.core.files=['/index.html'];
+ assert.ok(core.retained(current,previous).has('/a.js?v=a'));
+});
+
+test('completion includes ungrouped runtime files and exposes verified missing paths', async () => {
+ const c=cache(), m=manifest();m.files['/worker.js']={h:'w'};
+ await core.syncAssets(m,{cache:c,fetchFn:async u=>response(u.split('=')[1])});
+ const status=await core.status(m,c);
+ assert.equal(status.ready,true);assert.equal(status.offlineReady,false);
+ assert.deepEqual(status.missingFiles,['/worker.js']);assert.equal(status.cached,2);assert.equal(status.total,3);
+ await core.complete(m,{cache:c,fetchFn:async u=>response(u.split('=')[1])});
+ assert.equal((await core.status(m,c)).offlineReady,true);
+});
+
+test('completion persists backoff and resumes only missing files after a worker restart',async()=>{
+ const c=cache(),m=manifest();let clock=1000;const calls=[];
+ await core.complete(m,{cache:c,cycles:1,now:()=>clock,baseDelay:10,fetchFn:async u=>{if(u.includes('index'))throw Error('offline');return response('a');}});
+ const checkpoint=await(await c.match(core.completionKey(m))).json();
+ assert.equal(checkpoint.running,false);assert.equal(checkpoint.failures,1);assert.equal(checkpoint.nextRetryAt,1010);
+ await core.complete(m,{cache:c,now:()=>clock,sleep:async ms=>{assert.equal(ms,10);clock+=ms;},fetchFn:async u=>{calls.push(u);return response(u.split('=')[1]);}});
+ assert.deepEqual(calls,['/index.html?v=b']);
+ const status=await(await c.match(core.completionKey(m))).json();assert.equal(status.offlineReady,true);assert.equal(status.failures,0);
+ await c.delete('/a.js?v=a');assert.equal((await core.status(m,c)).offlineReady,false);
+});
+
+test('completion retries with exponential backoff and keeps making progress past a failed asset',async()=>{
+ const c=cache(),m=manifest(),delays=[];let tries=0;
+ const job=await core.complete(m,{cache:c,baseDelay:5,sleep:async ms=>delays.push(ms),fetchFn:async u=>{if(u.includes('a.js')&&++tries<=6)throw Error('drop');return response(u.split('=')[1]);}});
+ assert.deepEqual(delays,[5,10]);assert.equal(job.offlineReady,true);assert.ok(await c.match('/index.html?v=b'));
+});
+
+test('overlapping layout and completion jobs share a six-download ceiling',async()=>{
+ const c=cache(),m=manifest();m.groups.extra={background:true,files:[]};
+ for(let i=0;i<15;i++){const p='/extra'+i+'.js';m.files[p]={h:String(i)};m.groups.extra.files.push(p);}
+ let active=0,peak=0;const calls=new Map();
+ const fetchFn=async u=>{active++;peak=Math.max(active,peak);calls.set(u,(calls.get(u)||0)+1);await new Promise(r=>setTimeout(r,3));active--;return response(u.split('=')[1]);};
+ await Promise.all([core.complete(m,{cache:c,fetchFn}),core.syncAssets(m,{cache:c,fetchFn,groups:['extra']})]);
+ assert.ok(peak<=6);assert.ok([...calls.values()].every(count=>count===1));
+});
+
+test('blocking commit never waits on an optional file readiness probe',{timeout:1000},async()=>{
+ const c=cache(),m=manifest();m.files['/extra.js']={h:'extra'};m.groups.extra={files:['/extra.js'],background:true,contract:1};
+ await c.put('/a.js?v=a',response('a'));await c.put('/index.html?v=b',response('b'));
+ const match=c.match;let extraProbes=0;c.match=async key=>{if(key==='/extra.js?v=extra'){extraProbes++;return new Promise(()=>{});}return match(key);};
+ await core.commit(m,c);assert.equal(extraProbes,0);assert.equal((await core.state(c)).current.buildTag,m.buildTag);
+ c.match=match;assert.equal((await core.status(m,c)).offlineReady,false);
+});
+
+test('metadata probes release response streams while stored asset bodies remain readable',async()=>{
+ const c=cache(),m=manifest();await c.put('/a.js?v=a',response('a'));await c.put('/index.html?v=b',response('b'));
+ const match=c.match,probes=[];c.match=async key=>{const r=await match(key);if(r)probes.push(r);return r;};
+ assert.equal((await core.status(m,c)).offlineReady,true);assert.ok(probes.length>0);assert.ok(probes.every(r=>r.bodyUsed));
+ assert.equal(await(await match('/a.js?v=a')).text(),'a');assert.equal(await(await match('/index.html?v=b')).text(),'b');
+});
+
+test('stale app core requires every feature contract to match the new shell',()=>{
+ const old={files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}};
+ const next=structuredClone(old);next.files['/app.js'].h='new';next.groups.extra.contract=2;
+ assert.equal(core.fallback(next,old,'/app.js'),null);next.groups.extra.contract=1;assert.equal(core.fallback(next,old,'/app.js'),'/app.js?v=old');
+});
diff --git a/frontend/asset-worker.test.js b/frontend/asset-worker.test.js
new file mode 100644
index 0000000..d7d9978
--- /dev/null
+++ b/frontend/asset-worker.test.js
@@ -0,0 +1,77 @@
+const {test}=require('node:test');const assert=require('node:assert/strict');const vm=require('node:vm');const fs=require('node:fs');const core=require('./asset-sync-core');
+function environment(){
+ const stores=new Map(),listeners={},messages=[],skips=[],fetches=[],selfMessages=[];const m={buildTag:'next',files:{'/index.html':{h:'index'},'/app.js':{h:'app'}},groups:{core:{files:['/index.html','/app.js']}}};
+ const storage={async keys(){return [...stores.keys()]},async delete(n){return stores.delete(n)},async open(n){if(!stores.has(n)){const map=new Map();stores.set(n,{async match(k){return map.get(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)?.clone()},async put(k,r){map.set(k,r.clone())},async keys(){return [...map.keys()].map(k=>({url:'https://local'+k}))},async delete(k){return map.delete(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)}})}return stores.get(n)}};
+ const sandbox={__BUILD_TAG__:'next',__ASSET_SYNC__:true,AssetSyncCore:core,importScripts:()=>{},caches:storage,URL,Response,Request,Headers,setTimeout,clearTimeout,crypto:require('node:crypto').webcrypto,console,fetch:async u=>{fetches.push(u);return u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':m.files[u.split('?')[0]]?.h || ''}})},self:{registration:{active:{postMessage:data=>selfMessages.push(data)}},location:{origin:'https://local'},addEventListener:(t,f)=>listeners[t]=f,skipWaiting:()=>skips.push(1),clients:{claim:async()=>{},matchAll:async()=>[{id:'old',postMessage:x=>messages.push(x)}]}}};
+ vm.runInNewContext(fs.readFileSync(__dirname+'/sw.js','utf8'),sandbox);
+ async function dispatch(t,e={}){const tasks=[];listeners[t]({...e,waitUntil:v=>tasks.push(v)});await Promise.all(tasks)}
+ async function request(url,mode='cors',clientId='new'){let p;const tasks=[];listeners.fetch({request:{url:'https://local'+url,method:'GET',mode},clientId,waitUntil:v=>tasks.push(v),respondWith:v=>p=v});const response=await p;await Promise.all(tasks);return response;}
+ return {storage,m,skips,fetches,dispatch,request,sandbox,selfMessages,async complete(){for(const data of selfMessages.splice(0))await dispatch('message',{data});}};
+}
+test('install does not activate or publish; CACHE_STATUS is honest; activation commits and exact requests self-heal',async()=>{const e=environment();await e.dispatch('install');assert.equal(e.skips.length,0);const cache=await e.storage.open(core.CACHE);assert.equal(await core.state(cache),null);let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);await e.dispatch('activate');await e.complete();assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await e.request('/playlist/x','navigate')).headers.get('X-Asset-Hash'),'index');await cache.delete('/app.js?v=app');assert.equal((await core.status(e.m,cache)).ready,false);await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal((await core.status(e.m,cache)).offlineReady,true);assert.equal(reply.version,'next');assert.equal((await e.request('/app.js')).headers.get('X-Asset-Hash'),'app');assert.ok(await cache.match('/app.js?v=app'));assert.equal(await cache.match('/app.js?v=stale'),undefined);assert.equal((await e.request('/app.js?v=stale')).status,409);});
+test('reported playback defers explicit activation until pause; install never calls skipWaiting',async()=>{const e=environment(),source={id:'p'};await e.dispatch('message',{data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:true}});await e.dispatch('message',{source,data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:false}});assert.equal(e.skips.length,1);});
+
+test('staged same-contract responses use verified N-1 without poisoning the new URL',async()=>{
+ const e=environment(); const cache=await e.storage.open(core.CACHE);
+ const old={buildTag:'old',files:{'/extra.js':{h:'old'}},groups:{core:{files:[],contract:1},'feature:extra':{files:['/extra.js'],contract:1,background:true}}};
+ e.m.groups.core.contract=1; e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true}; e.m.files['/extra.js']={h:'new'};
+ await cache.put(core.STATE,Response.json({current:old})); await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old','Cache-Control':'public, max-age=31536000, immutable','Content-Encoding':'gzip','Content-Length':'9'}}));
+ await e.dispatch('install'); await e.dispatch('activate');await e.complete();
+ assert.ok(await cache.match('/extra.js?v=new'));await cache.delete('/extra.js?v=new');
+ assert.equal(await cache.match('/extra.js?v=new'),undefined);
+ const stale=await e.request('/extra.js?v=new'); assert.equal(await stale.text(),'old body');assert.equal(stale.headers.get('X-Asset-Hash'),'old');assert.equal(stale.headers.get('Cache-Control'),'no-store');assert.equal(stale.headers.get('Content-Encoding'),null);assert.equal(stale.headers.get('Content-Length'),null);assert.equal(await cache.match('/extra.js?v=new'),undefined);
+ await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:true}}); assert.ok(await cache.match('/extra.js?v=new'));
+ await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}); assert.equal((await cache.match('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
+ assert.equal((await e.request('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
+});
+
+test('a changed background contract is downloaded before ready and cannot fall back after commit',async()=>{
+ const e=environment(),cache=await e.storage.open(core.CACHE);
+ const old={buildTag:'old',files:{'/extra.js':{h:'old'}},groups:{core:{files:[],contract:1},'feature:extra':{files:['/extra.js'],contract:1,background:true}}};
+ e.m.groups.core.contract=1;e.m.groups['feature:extra']={files:['/extra.js'],contract:2,background:true};e.m.files['/extra.js']={h:'new'};
+ await cache.put(core.STATE,Response.json({current:old}));await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old'}}));
+ await e.dispatch('install');assert.equal((await core.state(cache)).current.buildTag,'old');
+ assert.equal((await cache.match('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
+ let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);
+ await e.dispatch('activate');await e.complete();await cache.delete('/extra.js?v=new');
+ const response=await e.request('/extra.js?v=new');assert.equal(response.headers.get('X-Asset-Hash'),'new');assert.notEqual(await response.text(),'old body');
+});
+
+test('overlapping idle warm messages share one download pool',async()=>{
+ const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true};
+ await e.dispatch('install');await e.dispatch('activate');await e.complete();
+ await Promise.all([e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}),e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}})]);
+ assert.equal(e.fetches.filter(url=>url==='/extra.js?v=extra').length,1);
+});
+
+test('waiting status does not start completion and activation delegates it to its own event',async()=>{
+ const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true};
+ await e.dispatch('install');let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});
+ assert.equal(reply.ready,true);assert.equal(reply.offlineReady,false);assert.ok(!e.fetches.includes('/extra.js?v=extra'));
+ let release,started;const gate=new Promise(r=>release=r),begin=new Promise(r=>started=r),fetch=e.sandbox.fetch;
+ e.sandbox.fetch=async url=>{if(url==='/extra.js?v=extra'){started();await gate;}return fetch(url);};
+ await e.dispatch('activate');const cache=await e.storage.open(core.CACHE);
+ assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await core.status(e.m,cache)).ready,true);
+ assert.equal((await core.status(e.m,cache)).offlineReady,false);assert.equal(e.selfMessages[0].type,'COMPLETE_ASSETS');
+ const job=e.complete();await begin;assert.equal(await cache.match('/extra.js?v=extra'),undefined);release();await job;
+ assert.equal((await core.status(e.m,cache)).offlineReady,true);
+});
+
+test('page-captured app is reused on first install without a worker download',async()=>{
+ const e=environment(),cache=await e.storage.open(core.CACHE);
+ await cache.put('/app.js?v=app',new Response('page captured app',{headers:{'X-Asset-Hash':'app'}}));
+ await e.dispatch('install');assert.equal(e.fetches.includes('/app.js?v=app'),false);
+ assert.equal(await(await cache.match('/app.js?v=app')).text(),'page captured app');
+ assert.equal(e.skips.length,0);assert.equal(await core.state(cache),null);
+});
+
+
+test('worker never supplies stale app to a native script when a feature contract changed',async()=>{
+ const e=environment(),cache=await e.storage.open(core.CACHE);
+ const old={buildTag:'old',files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}};
+ e.m.groups.core.contract=1;e.m.groups.extra={contract:2,background:true,files:[]};
+ await cache.put(core.STATE,Response.json({current:old}));await cache.put('/app.js?v=old',new Response('old app',{headers:{'X-Asset-Hash':'old'}}));
+ await e.dispatch('install');await e.dispatch('activate');await cache.delete('/app.js?v=app');
+ e.sandbox.fetch=async()=>{throw Error('offline')};
+ const response=await e.request('/app.js?v=app');assert.equal(response.status,503);assert.equal(await response.text(),'Offline');
+});
diff --git a/frontend/assets.json b/frontend/assets.json
new file mode 100644
index 0000000..844fd02
--- /dev/null
+++ b/frontend/assets.json
@@ -0,0 +1,377 @@
+{
+ "groups": {
+ "core": {
+ "contract": 2,
+ "eager": true,
+ "files": [
+ "/index.html",
+ "/styles.css",
+ "/service-ui.css",
+ "/refresh.css",
+ "/layout-base.css",
+ "/recommendations.css",
+ "/flag-ui.js",
+ "/async-guard.js",
+ "/visible-timer.js",
+ "/asset-sync-core.js",
+ "/sw-update.js",
+ "/fingerprint.js",
+ "/opfs.js",
+ "/opfs-worker.js",
+ "/lyrics-core.js",
+ "/lyrics-display.js",
+ "/sha256.js",
+ "/resume-core.js",
+ "/loudness-wasm.js",
+ "/loudness.js",
+ "/sha256-wasm.js",
+ "/settings-sections.js",
+ "/related-videos.js",
+ "/related-videos.css",
+ "/audio-continuity.js",
+ "/fullscreen-orientation.js",
+ "/fullscreen-orientation.css",
+ "/download-actions.js",
+ "/download-actions.css",
+ "/download-errors.js",
+ "/downloads.js",
+ "/downloads-page.js",
+ "/offline-pages.css",
+ "/saved-page.js",
+ "/server-backfill.js",
+ "/saved-page.css",
+ "/prewarm-next.js",
+ "/settings-sections.css",
+ "/app.js",
+ "/manifest.webmanifest",
+ "/icons/icon-192.png",
+ "/icons/icon-512.png",
+ "/fonts/fonts.css",
+ "/fonts/BricolageGrotesque-latin-ext.woff2",
+ "/fonts/BricolageGrotesque-latin.woff2",
+ "/fonts/HankenGrotesk-latin-ext.woff2",
+ "/fonts/HankenGrotesk-latin.woff2",
+ "/fonts/JetBrainsMono-latin-ext.woff2",
+ "/fonts/JetBrainsMono-latin.woff2",
+ "/lazy.js",
+ "/feature-bootstrap.js",
+ "/eq-parameters.js",
+ "/assets.json",
+ "/device-db.js",
+ "/shell-core.js",
+ "/views-core.js",
+ "/section-rail.js"
+ ],
+ "background": false
+ },
+ "layout:glass-stage": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/theme-glass.css",
+ "/theme-glass-panel.css",
+ "/glass-panel-layout.js"
+ ],
+ "background": true,
+ "dependencies": [
+ "feature:layout-controls"
+ ]
+ },
+ "layout:classic": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/theme-classic.css"
+ ],
+ "background": true
+ },
+ "layout:bento-hub": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/theme-bento-hub.css",
+ "/bento-hub.js",
+ "/fonts/PlusJakartaSans-latin.woff2",
+ "/fonts/PlusJakartaSans-latin-ext.woff2"
+ ],
+ "background": true,
+ "dependencies": [
+ "feature:layout-controls"
+ ]
+ },
+ "layout:f7-swipe": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/theme-f7.css",
+ "/vendor/framework7-swipe.min.js",
+ "/f7-layout.js"
+ ],
+ "background": true
+ },
+ "feature:piano": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/piano-core.js",
+ "/piano.js",
+ "/piano-engine.mjs"
+ ],
+ "background": true
+ },
+ "feature:midi": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/midi.js"
+ ],
+ "background": true
+ },
+ "feature:party-dj": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/party-dj.js"
+ ],
+ "background": true
+ },
+ "feature:lyrics-window": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/lyrics-window.js"
+ ],
+ "background": true
+ },
+ "feature:lower-third": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/lower-third.js",
+ "/lower-third.css"
+ ],
+ "background": true
+ },
+ "feature:vocal-reducer": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/vocal-reducer.js"
+ ],
+ "background": true
+ },
+ "feature:eq-core": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/eq-core.js"
+ ],
+ "background": true
+ },
+ "feature:setlist-import": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/setlist-import.js"
+ ],
+ "background": true
+ },
+ "feature:car-mode": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/car-mode.js",
+ "/car-mode.css"
+ ],
+ "background": true
+ },
+ "feature:p2p": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/p2p-core.js",
+ "/p2p-client.js",
+ "/p2p-transfer.js",
+ "/p2p-recv-worker.js",
+ "/hash-worker.js"
+ ],
+ "background": true
+ },
+ "feature:direct": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/direct-protocol.js",
+ "/direct-stream.js",
+ "/direct-media.js",
+ "/direct-media.css",
+ "/direct-recv-worker.js"
+ ],
+ "background": true,
+ "dependencies": [
+ "feature:p2p"
+ ]
+ },
+ "feature:video-edit": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/video-edit.js"
+ ],
+ "background": true
+ },
+ "feature:stats": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/stats-core.js"
+ ],
+ "background": true
+ },
+ "feature:export": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/export.js"
+ ],
+ "background": true
+ },
+ "feature:playlist": {
+ "contract": 1,
+ "eager": true,
+ "files": [],
+ "embeddedIn": [
+ "/app.js"
+ ],
+ "background": false
+ },
+ "feature:settings": {
+ "contract": 1,
+ "eager": true,
+ "files": [],
+ "embeddedIn": [
+ "/app.js",
+ "/settings-sections.js",
+ "/settings-sections.css",
+ "/views-core.js"
+ ],
+ "background": false
+ },
+ "feature:service-mode": {
+ "contract": 1,
+ "eager": true,
+ "files": [],
+ "embeddedIn": [
+ "/app.js",
+ "/service-ui.css"
+ ],
+ "background": false
+ },
+ "feature:lyrics-editor": {
+ "contract": 1,
+ "eager": true,
+ "files": [],
+ "embeddedIn": [
+ "/app.js",
+ "/lyrics-core.js",
+ "/lyrics-display.js"
+ ],
+ "background": false
+ },
+ "feature:layout-controls": {
+ "contract": 1,
+ "eager": false,
+ "background": true,
+ "files": [
+ "/theme-glass-controls.css",
+ "/glass-controls.js"
+ ]
+ },
+ "layout:sanctuary-stage": {
+ "contract": 1,
+ "eager": false,
+ "background": true,
+ "files": []
+ },
+ "layout:liturgical": {
+ "contract": 1,
+ "eager": false,
+ "background": true,
+ "files": []
+ },
+ "layout:living-hymnal": {
+ "contract": 1,
+ "eager": false,
+ "background": true,
+ "files": []
+ },
+ "layout:broadcaster": {
+ "contract": 1,
+ "eager": false,
+ "background": true,
+ "files": []
+ },
+ "layout:contemplative": {
+ "contract": 1,
+ "eager": false,
+ "background": true,
+ "files": []
+ },
+ "feature:presenter": {
+ "contract": 1,
+ "eager": false,
+ "background": true,
+ "files": [
+ "/presenter.js"
+ ]
+ },
+ "feature:remote-client": {
+ "contract": 1,
+ "eager": false,
+ "background": true,
+ "files": [
+ "/remote-client.js"
+ ]
+ },
+ "feature:watch-party": {
+ "contract": 1,
+ "eager": false,
+ "background": true,
+ "files": [
+ "/watch-party-client.js"
+ ]
+ },
+ "feature:share-external": {
+ "contract": 1,
+ "eager": false,
+ "background": true,
+ "files": [
+ "/share-external.js"
+ ]
+ },
+ "feature:stats-view": {
+ "contract": 1,
+ "eager": false,
+ "background": true,
+ "files": [
+ "/stats-view.js"
+ ],
+ "dependencies": [
+ "feature:stats"
+ ]
+ },
+ "feature:jamesdsp": {
+ "contract": 1,
+ "eager": false,
+ "files": [
+ "/vendor/jamesdsp/controller.js",
+ "/vendor/jamesdsp/jamesdsp-node.js",
+ "/vendor/jamesdsp/worklet.js",
+ "/vendor/jamesdsp/params.js",
+ "/vendor/jamesdsp/dsp/jamesdsp.js",
+ "/vendor/jamesdsp/dsp/jamesdsp.wasm"
+ ],
+ "background": false
+ }
+ }
+}
diff --git a/frontend/autosave.test.js b/frontend/autosave.test.js
new file mode 100644
index 0000000..812c1f6
--- /dev/null
+++ b/frontend/autosave.test.js
@@ -0,0 +1,65 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+
+// Exercise the actual app functions without DOM/player/network dependencies.
+const source = fs.readFileSync(require('node:path').join(__dirname, 'app.js'), 'utf8');
+function harness({ autoPreload = true, cached = [], result = { ok: true, cached: true }, error } = {}) {
+ const calls = [], jobs = new Map();
+ const video = { id: 'benchmedia01', title: 'Local clip' };
+ const context = vm.createContext({
+ data: { settings: { autoPreload }, playlists: [{ id: 'pl', videos: [video] }], pinnedPlaylists: [] },
+ cachedIds: new Set(cached), downloading: new Set(), downloadMeta: new Map(), saveControllers: new Map(),
+ view: { type: 'home' }, current: null, window: {}, AbortController,
+ slim: v => v, markCardCacheState() {}, renderSidebar() {}, updateDownloadBadge() {}, toast() {},
+ SaveSlots: { acquire: async () => {}, release() {} },
+ SaveQueue: { add: v => jobs.set(v.id, { status: 'queued' }), remove: id => jobs.delete(id), progress() {}, state: (id, status, message) => jobs.set(id, { status, message }) },
+ API: { cacheDownload: async id => { calls.push(id); if (error) throw error; return result; } },
+ recordDeviceFile() {}, warmThumb() {}, thumbUrlFor: () => '', cacheMutations: 0,
+ });
+ const preload = source.slice(source.indexOf('async function preload(video,'), source.indexOf('// A device copy that failed to play.'));
+ const playlists = source.slice(source.indexOf('function preloadPlaylist(pl)'), source.indexOf('// Update a single card\'s saved badge'));
+ vm.runInContext(preload + playlists, context);
+ return { context, calls, jobs, video };
+}
+
+test('auto preload tops up playlists, while pinned playlists save with the setting off', async () => {
+ const h = harness({ autoPreload: false });
+ h.context.preloadPlaylist(h.context.data.playlists[0]);
+ await new Promise(r => setImmediate(r));
+ assert.deepEqual(h.calls, []);
+ h.context.data.pinnedPlaylists = ['pl'];
+ h.context.preloadPinnedPlaylists();
+ await new Promise(r => setImmediate(r));
+ assert.deepEqual(h.calls, [h.video.id]);
+ assert.ok(h.context.cachedIds.has(h.video.id));
+ assert.equal(h.jobs.size, 0);
+ assert.equal(h.context.downloading.size, 0);
+ const enabled = harness();
+ enabled.context.preloadPlaylist(enabled.context.data.playlists[0]);
+ await new Promise(r => setImmediate(r));
+ assert.deepEqual(enabled.calls, [enabled.video.id]);
+});
+
+test('a repeated save shares the in-flight operation and does not download an existing copy', async () => {
+ const h = harness();
+ await Promise.all([h.context.preload(h.video), h.context.preload(h.video)]);
+ await h.context.preload(h.video);
+ assert.deepEqual(h.calls, [h.video.id]);
+ assert.ok(h.context.cachedIds.has(h.video.id));
+ assert.equal(h.context.saveControllers.size, 0);
+});
+
+test('failed and interrupted saves do not acquire a Saved badge and can be attempted again', async () => {
+ for (const options of [{ result: { ok: false, error: 'server failed' } }, { error: new Error('storage quota') }, { result: { ok: false, paused: true } }]) {
+ const h = harness(options);
+ await h.context.preload(h.video, { quiet: true });
+ assert.equal(h.context.cachedIds.has(h.video.id), false);
+ assert.equal(h.context.downloading.size, 0);
+ assert.equal(h.context.saveControllers.size, 0);
+ assert.ok(h.jobs.has(h.video.id));
+ await h.context.preload(h.video, { quiet: true });
+ assert.equal(h.calls.length, 2);
+ }
+});
diff --git a/frontend/car-mode.css b/frontend/car-mode.css
index 8d869cf..9f0c2a2 100644
--- a/frontend/car-mode.css
+++ b/frontend/car-mode.css
@@ -4,4 +4,3 @@
.car-controls button { min-height: 110px; font: 650 clamp(22px, 3vw, 36px)/1.2 system-ui; background: #fff; color: #050607; border: 3px solid #fff; border-radius: 16px; }
.car-exit { align-self: flex-start; min-height: 52px; padding: 12px 20px; background: #070809; color: #fff; border: 2px solid #fff; border-radius: 8px; }
.car-mode button:focus-visible { outline: 4px solid #61caff; outline-offset: 4px; }
-.voice-search { flex-shrink: 0; min-width: 44px; min-height: 44px; }
diff --git a/frontend/car-mode.js b/frontend/car-mode.js
index 9785ce1..500b967 100644
--- a/frontend/car-mode.js
+++ b/frontend/car-mode.js
@@ -10,19 +10,19 @@
overlay = doc.createElement('section'); overlay.className = 'car-mode'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', 'Car mode');
const exit = doc.createElement('button'); exit.type = 'button'; exit.className = 'car-exit'; exit.textContent = 'Exit Car mode'; exit.onclick = close;
const title = doc.createElement('h1'); const controls = doc.createElement('div'); controls.className = 'car-controls'; let toggle;
- for (const [label, action] of [['Previous', 'prev'], ['Play / Pause', 'toggle'], ['15 s back', 'back'], ['Next', 'next']]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = label; b.setAttribute('aria-label', label); b.onclick = () => adapter.action(action); if (action === 'toggle') toggle = b; controls.append(b); }
- const update = () => { const state = adapter.state(); title.textContent = state.title || 'Choose a song before entering Car mode'; toggle.textContent = state.paused ? 'Play' : 'Pause'; }; update();
+ for (const [label, action] of [['Previous', 'prev'], ['Play / Pause', 'toggle'], ['15 s back', 'back'], ['Next', 'next']]) { const b = doc.createElement('button'); b.type = 'button'; b.innerHTML = root.TransportIcons.svg({prev:'previous',toggle:'play',back:'back15',next:'next'}[action]) + '' + label + ''; b.setAttribute('aria-label', label); b.onclick = () => adapter.action(action); if (action === 'toggle') toggle = b; controls.append(b); }
+ const update = () => { const state = adapter.state(); title.textContent = state.title || 'Choose a song before entering Car mode'; root.TransportIcons.set(toggle, state.paused ? 'play' : 'pause'); toggle.insertAdjacentHTML('beforeend','' + (state.paused ? 'Play' : 'Pause') + ''); }; update();
overlay.append(exit, title, controls); doc.body.append(overlay); timer = setInterval(update, 500); exit.focus();
overlay.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); } else if (e.key === 'Tab') { const buttons = [...overlay.querySelectorAll('button')], first = buttons[0], last = buttons.at(-1); if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus(); } } e.stopPropagation(); };
}
function configure(options) {
adapter = options; const doc = root.document, Recognition = root.SpeechRecognition || root.webkitSpeechRecognition;
if (Recognition) {
- const b = doc.createElement('button'); b.type = 'button'; b.className = 'icon-btn voice-search'; b.title = 'Voice search'; b.setAttribute('aria-label', 'Voice search'); b.innerHTML = ''; adapter.input.after(b);
+ const b = doc.getElementById('voiceSearchBtn') || doc.createElement('button'); b.id='voiceSearchBtn'; b.type = 'button'; b.className = 'icon-btn voice-search'; b.title = 'Voice search'; b.setAttribute('aria-label', 'Voice search'); b.innerHTML = ''; if(!b.isConnected)adapter.input.after(b);
let recognition, listening = false;
b.onclick = () => { if (listening) { recognition.abort(); return; } recognition = new Recognition(); recognition.lang = doc.documentElement.lang || root.navigator.language || 'en-US'; recognition.continuous = false; recognition.interimResults = false; recognition.onstart = () => { listening = true; b.setAttribute('aria-pressed', 'true'); b.title = 'Listening… tap to cancel'; }; recognition.onend = () => { listening = false; b.setAttribute('aria-pressed', 'false'); b.title = 'Voice search'; }; recognition.onerror = e => adapter.toast(`Voice search: ${e.error === 'not-allowed' ? 'microphone permission was denied' : e.error}`); recognition.onresult = e => { const query = transcript(e); if (query) { adapter.input.value = query; adapter.search(); } }; try { recognition.start(); } catch (error) { adapter.toast(error.message); } };
}
- const car = doc.createElement('button'); car.type = 'button'; car.className = 'icon-btn'; car.title = 'Car mode'; car.setAttribute('aria-label', 'Car mode'); car.innerHTML = ''; doc.getElementById('searchForm').after(car); car.onclick = open;
+ const car = doc.getElementById('carModeBtn') || doc.createElement('button'); car.id='carModeBtn'; car.type = 'button'; car.className = 'icon-btn'; car.title = 'Car mode'; car.setAttribute('aria-label', 'Car mode'); car.innerHTML = ''; if(!car.isConnected)doc.getElementById('searchForm').after(car); car.onclick = open;
root.SettingsSections.register({ id: 'voice-car', title: 'Voice & Car mode', cluster: 'Listening', summary: () => Recognition ? 'Voice search available · Car mode' : 'Car mode · voice unavailable', render(container) { const p = doc.createElement('p'); p.textContent = Recognition ? 'Use the microphone beside Search to speak a query. Recognition availability and processing depend on your browser.' : 'Voice search is unavailable in this browser.'; const b = doc.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Enter Car mode'; b.onclick = open; container.append(p, b); } });
}
const api = { transcript, configure, open, close }; if (typeof module !== 'undefined') module.exports = api; else root.CarMode = api;
diff --git a/frontend/car-mode.test.js b/frontend/car-mode.test.js
index ae51c4e..ff996b6 100644
--- a/frontend/car-mode.test.js
+++ b/frontend/car-mode.test.js
@@ -1,2 +1,27 @@
const { test } = require('node:test'), assert = require('node:assert/strict'); const { transcript } = require('./car-mode');
test('voice search submits only final results from the result index', () => { const interim = [{transcript:'wrong'}]; interim.isFinal = false; const final = [{transcript:'Amazing Grace'}]; final.isFinal = true; assert.equal(transcript({results:[interim, final]}), 'Amazing Grace'); assert.equal(transcript({resultIndex:2,results:[final,final]}), ''); assert.equal(transcript({}), ''); });
+
+test('lazy Car mode keeps four independent transport buttons and the voice entry in place', () => {
+ const vm=require('node:vm'),fs=require('node:fs');
+ class Element {
+ constructor(tag){this.tag=tag;this.children=[];this.inert=false;}
+ append(...nodes){for(const node of nodes){node.remove();node.parent=this;this.children.push(node);}}
+ remove(){if(this.parent){this.parent.children=this.parent.children.filter(n=>n!==this);this.parent=null;}}
+ after(node){this.parent.append(node);}
+ setAttribute(name,value){this[name]=value;}
+ focus(){}
+ insertAdjacentHTML(position,html){this.innerHTML+=html;}
+ get isConnected(){return !!this.parent;}
+ }
+ const body=new Element('body'),input=new Element('input'),form=new Element('form'),voice=new Element('button');form.id='searchForm';voice.id='voiceSearchBtn';body.append(form,input,voice);
+ const all=node=>[node,...node.children.flatMap(all)],doc={body,documentElement:{lang:'en'},createElement:tag=>new Element(tag),getElementById:id=>all(body).find(n=>n.id===id)};
+ let section;const actions=[],root={document:doc,TransportIcons:require('./download-actions').transport,navigator:{},SpeechRecognition:function(){},SettingsSections:{register:s=>section=s}};
+ vm.runInNewContext(fs.readFileSync(__dirname+'/car-mode.js','utf8'),{window:root,setInterval:()=>1,clearInterval:()=>{}});
+ root.CarMode.configure({input,action:action=>actions.push(action),state:()=>({paused:true,title:'Song'})});
+ root.CarMode.open();const overlay=body.children.find(n=>n.className==='car-mode'),controls=overlay.children.find(n=>n.className==='car-controls');
+ assert.equal(controls.children.length,4);assert.equal(new Set(controls.children).size,4);assert.equal(voice.parent,body);
+ controls.children.forEach(b=>b.onclick());assert.deepEqual(actions,['prev','toggle','back','next']);
+ root.CarMode.close();assert.equal(voice.parent,body);assert.equal(body.inert,false);
+ const panel=new Element('section');body.append(panel);section.render(panel);
+ assert.equal(voice.parent,body,'settings must not steal the voice entry');assert.equal(panel.children.at(-1).textContent,'Enter Car mode');
+});
diff --git a/frontend/direct-media.js b/frontend/direct-media.js
index be308a6..1e5843c 100644
--- a/frontend/direct-media.js
+++ b/frontend/direct-media.js
@@ -77,7 +77,7 @@
s.preview = m.file.streamMime ? root.DirectStream?.create(m.file.streamMime, m.file.title) : null;
const wait = waiting.get(m.file.id); if (wait) clearTimeout(wait.timer);
send(key, { type: 'direct', action: 'accept', token: m.token });
- s.worker = new Worker('/direct-recv-worker.js');
+ s.worker = window.Lazy ? window.Lazy.worker('/direct-recv-worker.js') : new Worker('/direct-recv-worker.js');
s.worker.onmessage = async e => {
const result = e.data;
if (result.op === 'opened') {
diff --git a/frontend/direct-recv-worker.js b/frontend/direct-recv-worker.js
index da6c14e..94ed130 100644
--- a/frontend/direct-recv-worker.js
+++ b/frontend/direct-recv-worker.js
@@ -1,11 +1,11 @@
'use strict';
-importScripts('/sha256.js');
let dir, handle, access, hash, pos = 0, claim, name;
let chain = Promise.resolve();
self.onmessage = e => { chain = chain.then(() => run(e.data)); };
async function close() { if (access) { access.flush(); access.close(); access = null; } }
async function run(m) {
try {
+ if (!self.Sha256) { self.__ASSET_URLS__=m.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); }
if (m.op === 'open') {
claim = m.file;
if (!/^[A-Za-z0-9_-]{1,128}$/.test(claim.id) || !/^[a-f0-9]{64}$/.test(claim.cid)) throw Error('Invalid file identity');
diff --git a/frontend/download-actions.css b/frontend/download-actions.css
index 3a413a1..7c4b117 100644
--- a/frontend/download-actions.css
+++ b/frontend/download-actions.css
@@ -9,3 +9,30 @@ html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action { bo
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:hover { border-color:var(--glass-line); background:var(--tile-hi); color:var(--text); }
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action.danger { color:var(--accent-bright); }
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:focus-visible { outline-color:var(--accent-bright); }
+
+/* Transport drawings and touch targets survive layout/state changes. */
+.transport-icon { display:inline-block; flex:none; width:24px; height:24px; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; }
+.transport-shape { display:none; }
+.transport-shape-classic { display:inline; }
+html:is([data-layout="f7-swipe"],[data-layout="glass-stage"],[data-layout="bento-hub"]) .transport-shape-classic { display:none; }
+html[data-layout="f7-swipe"] .transport-shape-f7-swipe,
+html[data-layout="glass-stage"] .transport-shape-glass-stage,
+html[data-layout="bento-hub"] .transport-shape-bento-hub { display:inline; }
+html[data-layout="f7-swipe"] .transport-shape-f7-swipe { fill:currentColor; stroke-width:.7; }
+html[data-layout="f7-swipe"] .transport-icon:is([data-transport-icon="play"],[data-transport-icon="pause"],[data-transport-icon="previous"],[data-transport-icon="next"],[data-transport-icon="forward"]),
+html[data-layout="f7-swipe"] #controls .transport-icon:is([data-transport-icon="play"],[data-transport-icon="pause"],[data-transport-icon="previous"],[data-transport-icon="next"],[data-transport-icon="forward"]) { fill:currentColor; stroke-width:.7; }
+html[data-layout="f7-swipe"] :is([data-transport-icon="back10"],[data-transport-icon="forward10"],[data-transport-icon="back15"]) .transport-shape { fill:none; stroke-width:2; }
+html[data-layout="glass-stage"] .transport-icon,
+html[data-layout="glass-stage"] #controls .transport-icon { stroke-width:1.5; }
+html[data-layout="bento-hub"] .transport-icon { stroke-width:2; stroke-linejoin:round; }
+button:has(> .transport-icon), a.np-btn:has(> .transport-icon) { min-width:44px; min-height:44px; }
+button:has(> .transport-icon) { align-items:center; justify-content:center; }
+html[data-layout="f7-swipe"] :is(#playBtn,#stgPlay,.service-btn-play,.rv-play) { border-radius:50%; }
+html[data-layout="f7-swipe"] #playBtn { width:64px; height:64px; }
+html[data-layout="glass-stage"] :is(#playBtn,.mini-btn,.rv-tbtn):has(> .transport-icon) { background:var(--tile,rgba(255,255,255,.08)); border:1px solid var(--line); backdrop-filter:blur(12px); }
+html[data-layout="bento-hub"] :is(#playBtn,.mini-btn,.service-btn,.rv-tbtn):has(> .transport-icon) { border-radius:16px; }
+.car-controls .transport-icon { width:36px; height:36px; margin-right:12px; }
+.gh-seek .transport-icon, .empty-icon .transport-icon { width:48px; height:48px; }
+.ph-logo .transport-icon { width:60%; height:60%; }
+
+.home-pl-play:has(> .transport-icon) { min-width:44px; min-height:44px; }
diff --git a/frontend/download-actions.js b/frontend/download-actions.js
index 0a92f22..ea74585 100644
--- a/frontend/download-actions.js
+++ b/frontend/download-actions.js
@@ -1,6 +1,83 @@
/* One action control for the Downloads page and Settings download manager. */
(function(root) {
'use strict';
+ // One SVG definition per media concept; every instance inherits its control colour.
+ const mediaPaths = {
+ download: ' ',
+ offline: ' ',
+ progress: ' '
+ };
+ const variants = {
+ 'classic': mediaPaths,
+ 'f7-swipe': {
+ download: ' ',
+ offline: ' ',
+ progress: ' '
+ },
+ 'glass-stage': {
+ download: ' ',
+ offline: ' ',
+ progress: ' '
+ },
+ 'bento-hub': {
+ download: ' ',
+ offline: ' ',
+ progress: ' '
+ }
+ };
+ const use = kind => ``;
+ const svg = (kind, extra = '') => ``;
+ const media = { paths: mediaPaths, use, svg };
+ if (root.document) {
+ const defs = root.document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ defs.setAttribute('aria-hidden', 'true'); defs.setAttribute('width', '0'); defs.setAttribute('height', '0');
+ defs.style.position = 'absolute'; defs.style.pointerEvents = 'none';
+ defs.innerHTML = '' + Object.keys(mediaPaths).map(kind => `${Object.entries(variants).map(([layout, paths]) => `${paths[kind]} `).join('')} `).join('') + ' ';
+ root.document.body.appendChild(defs);
+ root.MediaIcons = media;
+ }
+ // Select variants on visible use elements: ancestor CSS in referenced SVG groups
+ // can report changed styles without repainting the cloned geometry in Chromium.
+ const transportPaths = {
+ play: ' ',
+ pause: ' ',
+ previous: ' ',
+ next: ' ',
+ forward: ' '
+ };
+ for (const seconds of [10,15]) for (const direction of ['back','forward']) {
+ transportPaths[direction + seconds] = (direction === 'back'
+ ? ' '
+ : ' ') +
+ `${seconds} `;
+ }
+ const transportVariants = Object.fromEntries(['classic','f7-swipe','glass-stage','bento-hub'].map(layout => [layout,{...transportPaths}]));
+ Object.assign(transportVariants['f7-swipe'], {
+ play: ' ',
+ previous: ' ',
+ next: ' '
+ });
+ const transport = {
+ paths: transportPaths, variants: transportVariants,
+ svg(kind) {
+ if (!transportPaths[kind]) throw new Error('Unknown transport icon: ' + kind);
+ return ``;
+ },
+ set(button, kind, label) {
+ if (!button) return;
+ const iconId=button.querySelector?.('.transport-icon')?.id;
+ button.innerHTML = this.svg(kind);
+ if(iconId)button.querySelector('.transport-icon').id=iconId;
+ button.setAttribute('aria-label', label || (kind === 'pause' ? 'Pause' : 'Play'));
+ }
+ };
+ if (root.document) {
+ const defs = root.document.createElementNS('http://www.w3.org/2000/svg','svg');
+ defs.setAttribute('aria-hidden','true'); defs.setAttribute('width','0'); defs.setAttribute('height','0');
+ defs.style.position='absolute'; defs.style.pointerEvents='none';
+ defs.innerHTML=''+Object.keys(transportPaths).flatMap(kind=>Object.entries(transportVariants).map(([layout,paths])=>`${paths[kind]} `)).join('')+' ';
+ root.document.body.appendChild(defs); root.TransportIcons=transport;
+ }
const paths = {
retry: ' ',
cancel: ' ',
@@ -19,6 +96,6 @@
const text = doc.createElement('span'); text.textContent = label;
button.append(icon, text); return button;
}
- const api = { create };
+ const api = { create, media, transport };
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.DownloadActions = api;
})(globalThis);
diff --git a/frontend/downloads-page.js b/frontend/downloads-page.js
index 169ad44..2336c3d 100644
--- a/frontend/downloads-page.js
+++ b/frontend/downloads-page.js
@@ -119,11 +119,7 @@
empty.className = 'empty-state dl-empty-state';
empty.innerHTML = `
No active downloads
Saves in progress appear here with live status. Videos you save land in Saved.
diff --git a/frontend/downloads.js b/frontend/downloads.js
index 903c1c8..a94ce4c 100644
--- a/frontend/downloads.js
+++ b/frontend/downloads.js
@@ -12,7 +12,7 @@
let adapter, total = 0;
function configure(options) {
adapter = options;
- root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: ' ', summary: () => `${adapter.slots()} at once · ${bytes(total)}`, render(container) {
+ root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: '', summary: () => `${adapter.slots()} at once · ${bytes(total)}`, render(container) {
const doc = container.ownerDocument;
const heading = doc.createElement('h3'); heading.textContent = 'Downloads & storage';
const jobs = doc.createElement('div'); jobs.className = 'download-manager'; jobs.setAttribute('aria-label', 'Download manager');
diff --git a/frontend/eq-core.js b/frontend/eq-core.js
index 2e84752..25d946c 100644
--- a/frontend/eq-core.js
+++ b/frontend/eq-core.js
@@ -1,24 +1,7 @@
/* Shared live/offline EQ definitions and legacy curve migration. */
(function(root) {
'use strict';
- const frequencies = [31,62,125,250,500,1000,2000,4000,8000,16000];
- const bands = frequencies.map((f,i) => ({ f, label: f >= 1000 ? `${f/1000}k` : String(f), type: i === 0 ? 'lowshelf' : i === 9 ? 'highshelf' : 'peaking' }));
- const clamp = n => Math.max(-12, Math.min(12, Number(n) || 0));
- function migrate(values) {
- if (!Array.isArray(values)) return frequencies.map(() => 0);
- if (values.length !== 5) return frequencies.map((_,i) => clamp(values[i]));
- const old = [60,250,1000,4000,12000], curve = values.map(clamp);
- return frequencies.map(f => { if (f <= old[0]) return curve[0]; if (f >= old[4]) return curve[4]; const i = old.findIndex(x => x >= f), weight = Math.log(f / old[i-1]) / Math.log(old[i] / old[i-1]); return Math.round((curve[i-1] * (1-weight) + curve[i] * weight) * 10) / 10; });
- }
- const presets = {
- flat: { name: 'Flat', g: frequencies.map(() => 0) },
- bass: { name: 'Bass boost', g: [5,7,5,3,1,0,0,0,0,0] },
- vocal: { name: 'Vocal clarity', g: [-3,-4,-2,0,1,2,3,4,1,0] },
- acoustic: { name: 'Acoustic live', g: [-2,-1,0,-2,-1,1,2,2,1,0] },
- classical: { name: 'Classical', g: migrate([4,2,-1,1,3]) },
- treble: { name: 'Treble boost', g: [0,0,0,0,0,0,1,3,5,6] },
- };
- const width = value => Math.max(0, Math.min(100, Number(value) || 0));
+ const { bands, presets, migrate, width } = typeof module !== 'undefined' ? require('./eq-parameters') : root.EqParameters;
function createWidener(context, source, value = 0) {
const splitter = context.createChannelSplitter(2), output = context.createChannelMerger(2), delay = context.createDelay(.05);
source.connect(splitter); splitter.connect(output, 0, 0); splitter.connect(delay, 1); delay.connect(output, 0, 1);
diff --git a/frontend/eq-parameters.js b/frontend/eq-parameters.js
new file mode 100644
index 0000000..1adce32
--- /dev/null
+++ b/frontend/eq-parameters.js
@@ -0,0 +1,25 @@
+/* Eager settings values; audio graphs remain in lazy feature groups. */
+(function(root) {
+ 'use strict';
+ const frequencies = [31,62,125,250,500,1000,2000,4000,8000,16000];
+ const bands = frequencies.map((f,i) => ({ f, label: f >= 1000 ? `${f/1000}k` : String(f), type: i === 0 ? 'lowshelf' : i === 9 ? 'highshelf' : 'peaking' }));
+ const clamp = n => Math.max(-12, Math.min(12, Number(n) || 0));
+ function migrate(values) {
+ if (!Array.isArray(values)) return frequencies.map(() => 0);
+ if (values.length !== 5) return frequencies.map((_,i) => clamp(values[i]));
+ const old = [60,250,1000,4000,12000], curve = values.map(clamp);
+ return frequencies.map(f => { if (f <= old[0]) return curve[0]; if (f >= old[4]) return curve[4]; const i = old.findIndex(x => x >= f), weight = Math.log(f / old[i-1]) / Math.log(old[i] / old[i-1]); return Math.round((curve[i-1] * (1-weight) + curve[i] * weight) * 10) / 10; });
+ }
+ const presets = {
+ flat: { name: 'Flat', g: frequencies.map(() => 0) },
+ bass: { name: 'Bass boost', g: [5,7,5,3,1,0,0,0,0,0] },
+ vocal: { name: 'Vocal clarity', g: [-3,-4,-2,0,1,2,3,4,1,0] },
+ acoustic: { name: 'Acoustic live', g: [-2,-1,0,-2,-1,1,2,2,1,0] },
+ classical: { name: 'Classical', g: migrate([4,2,-1,1,3]) },
+ treble: { name: 'Treble boost', g: [0,0,0,0,0,0,1,3,5,6] },
+ };
+ const width = value => Math.max(0, Math.min(100, Number(value) || 0));
+ const amount = value => Math.max(0, Math.min(100, Number.isFinite(Number(value)) ? Number(value) : 100));
+ const api = { bands, presets, migrate, width, amount };
+ if (typeof module !== 'undefined') module.exports = api; else root.EqParameters = api;
+})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/frontend/f7-layout.js b/frontend/f7-layout.js
index 234c19a..e0f07ed 100644
--- a/frontend/f7-layout.js
+++ b/frontend/f7-layout.js
@@ -35,7 +35,7 @@
function loadF7(){
if(root.Framework7)return Promise.resolve(root.Framework7);
- if(!loading)loading=new Promise((res,rej)=>{const s=doc.createElement('script');s.src=F7_SRC;s.onload=()=>res(root.Framework7);s.onerror=()=>{loading=null;rej(new Error('Framework7 failed to load'));};doc.head.append(s);});
+ if(!loading)loading=new Promise((res,rej)=>{const s=doc.createElement('script');s.src=root.Lazy?.url('/vendor/framework7-swipe.min.js')||F7_SRC;s.onload=()=>res(root.Framework7);s.onerror=()=>{loading=null;rej(new Error('Framework7 failed to load'));};doc.head.append(s);});
return loading;
}
function ensureApp(){
diff --git a/frontend/feature-bootstrap.js b/frontend/feature-bootstrap.js
new file mode 100644
index 0000000..7442dd3
--- /dev/null
+++ b/frontend/feature-bootstrap.js
@@ -0,0 +1,77 @@
+/* Thin eager entries and configuration for optional, stateful feature modules. */
+(function(root) {
+ 'use strict';
+ const Lazy = root.Lazy, doc = root.document;
+ if (!Lazy) return;
+ const specs = [
+ ['Piano','piano','configure','piano','Piano transcription','Live & worship','Off · load model on request'],
+ ['MidiPedals','midi','configure','midi','MIDI pedalboard','Live & worship','Off'],
+ ['PartyDJ','party-dj','register','party-dj','Watch party','Live & worship','Shared suggestions · host approval'],
+ ['LyricsWindow','lyrics-window','configure','floating-lyrics','Floating lyrics','Live & worship','Floating lyrics window'],
+ ['LowerThird','lower-third','register','broadcast','Presenter & broadcast','Live & worship','Projector · OBS lower third'],
+ ['EqCore','eq-core','register','equalizer','Equalizer & stereo','Listening','Flat · Original stereo'],
+ ['VocalReducer','vocal-reducer','register','vocal-reducer','Vocal reducer','Listening','100% · centre-panned vocals'],
+ ['SetlistImport','setlist-import','configure','setlist-import','Setlist import','Library & storage','Planning Center · SongSelect · ChordPro'],
+ ['CarMode','car-mode','configure','voice-car','Voice & Car mode','Listening','Voice search · Car mode'],
+ ['DirectMedia','direct','configure','direct-transfer','Direct device transfer','Library & storage','Paired devices only'],
+ ];
+ function button(id, text, anchor, name, action, className='np-btn') {
+ if (!anchor || doc.getElementById(id)) return;
+ const b=doc.createElement('button'); b.id=id; b.type='button'; b.className=className; b.textContent=text; b.setAttribute('aria-label',text);
+ if(id==='carModeBtn')b.innerHTML='';
+ if(id==='voiceSearchBtn')b.innerHTML='';
+ if(id.startsWith('floating-')){b.title='Open floating lyrics';b.setAttribute('aria-label','Open floating lyrics window');}
+ b.title ||= text;
+ b.onclick=()=>Lazy.load(name).then(action).catch(error=>root.dispatchEvent(new CustomEvent('ytp-lazy-error',{detail:{error}})));
+ anchor.after(b); Lazy.intent(b,name);
+ }
+ for (const [globalName, suffix, setup, id, title, cluster, summary] of specs) {
+ const group='feature:'+suffix;
+ const facade=Lazy.proxy(group,['open','close','obtain','publish','attach','handle','offer','paint','fade'],globalName);
+ let configured=false;
+ facade[setup]=options=>{
+ if (configured) return; configured=true;
+ Lazy.onLoad(group,()=>root[globalName][setup](options));
+ root.SettingsSections.register({id,title,cluster,lazyGroup:group,summary:()=>{
+ const s=options?.settings?.() || {};
+ if(globalName==='Piano')return s.pianoEnabled?'On · browser or optional worker':'Off · load model on request';
+ if(globalName==='MidiPedals')return s.midiEnabled?'On · pedal mappings':'Off';
+ if(globalName==='EqCore')return `${root.EqParameters.presets[s.eq?.preset]?.name || 'Custom'} · ${root.EqParameters.width(s.stereoWidth)?'Widened':'Original stereo'}`;
+ if(globalName==='VocalReducer')return `${root.EqParameters.amount(s.vocalLevel ?? 100)}% · centre-panned vocals`;
+ if(globalName==='DirectMedia')return facade.enabled()?'On · paired devices only':'Off or unsupported';
+ if(globalName==='CarMode')return root.SpeechRecognition||root.webkitSpeechRecognition?'Voice search available · Car mode':'Car mode · voice unavailable';
+ if(globalName==='LyricsWindow')return root.documentPictureInPicture?.requestWindow?'Interactive window available':root.HTMLCanvasElement?.prototype.captureStream&&doc.pictureInPictureEnabled?'Lyric video PiP available':'Not supported on this browser';
+ return summary;
+ },
+ render(container,context) {
+ let disposed=false, cleanup;
+ container.textContent='Loading…';
+ Lazy.load(group).then(()=>{
+ if (disposed || !container.isConnected) return;
+ container.replaceChildren(); cleanup=root.SettingsSections.get(id).render(container,context);
+ }).catch(error=>{if(!disposed)container.textContent=error.message;});
+ return ()=>{disposed=true; if(typeof cleanup==='function')cleanup();};
+ },
+ });
+ if (globalName==='CarMode') {
+ button('carModeBtn','Car mode',doc.getElementById('searchForm'),group,()=>root.CarMode.open(),'icon-btn');
+ if(root.SpeechRecognition||root.webkitSpeechRecognition)button('voiceSearchBtn','Voice search',options.input,group,()=>doc.getElementById('voiceSearchBtn').click(),'icon-btn voice-search');
+ }
+ if (globalName==='LyricsWindow' && (root.documentPictureInPicture?.requestWindow || (root.HTMLCanvasElement?.prototype.captureStream && doc.pictureInPictureEnabled))) {
+ for(const anchor of ['stageLyricsAuto','serviceAutoscrollBtn','pipBtn'])button('floating-'+anchor,'⧉ Lyrics',doc.getElementById(anchor),group,()=>doc.getElementById('floating-'+anchor).click());
+ }
+ };
+ if(globalName==='LowerThird') {
+ facade.active=()=>new URLSearchParams(root.location.search).get('overlay')==='lowerthird';
+ facade.url=(origin,code)=>{const u=new URL('/',origin);u.searchParams.set('overlay','lowerthird');u.searchParams.set('code',String(code));return u.href;};
+ }
+ if(globalName==='DirectMedia') {
+ let settings=()=>({}); const configure=facade.configure;
+ facade.configure=options=>{settings=options.settings;configure(options);};
+ facade.enabled=()=>settings().directTransfer!==false && !!root.RTCPeerConnection;
+ facade.hasSource=()=>false;
+ }
+ if(globalName==='LyricsWindow')facade.element=()=>null;
+ root[globalName]=facade;
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/frontend/fullscreen-orientation.js b/frontend/fullscreen-orientation.js
index c7e5ee9..4d508da 100644
--- a/frontend/fullscreen-orientation.js
+++ b/frontend/fullscreen-orientation.js
@@ -24,7 +24,7 @@
function lift() { if (home || stage.parentElement === document.body) return; home = { parent: stage.parentElement, next: stage.nextSibling }; document.body.append(stage); }
function unlift() { if (!home) return; const { parent, next } = home; home = null; if (next && next.parentNode === parent) parent.insertBefore(stage, next); else parent.append(stage); }
const hud = document.createElement('div'); hud.className = 'fullscreen-hud'; hud.hidden = true;
- hud.innerHTML = '';
+ hud.innerHTML = '';
stage.append(hud);
const play = hud.querySelector('button[aria-label="Play or pause"]'), orientation = hud.querySelector('.fullscreen-orientation'), fitButton = hud.querySelector('.fullscreen-fit'), exit = hud.querySelector('button[aria-label="Exit fullscreen"]'), range = hud.querySelector('input');
const title = hud.querySelector('.fullscreen-title');
@@ -109,9 +109,7 @@
range.style.setProperty('--seek-progress', `${s.duration ? Math.min(100, s.time / s.duration * 100) : 0}%`);
play.setAttribute('aria-label', s.paused ? 'Play video' : 'Pause video');
play.title = s.paused ? 'Play' : 'Pause';
- play.innerHTML = s.paused
- ? ''
- : '';
+ root.TransportIcons.set(play, s.paused ? 'play' : 'pause', s.paused ? 'Play video' : 'Pause video');
}, 250);
}
document.addEventListener('fullscreenchange', () => { if (active && !document.fullscreenElement && native) cleanup(); });
diff --git a/frontend/glass-controls.js b/frontend/glass-controls.js
index 742b96e..db8bd6f 100644
--- a/frontend/glass-controls.js
+++ b/frontend/glass-controls.js
@@ -6,9 +6,9 @@
music:' ',
add:' ', close:' ',
search:' ',
- save:' ',
- play:' ', pause:' ',
- next:' ', previous:' ',
+ save:'',
+ play:'', pause:'',
+ next:'', previous:'',
repeat:' ',
edit:' ',
lyrics:' ',
@@ -23,7 +23,7 @@
television:' ', pin:' ',
devices:' '
};
- const glyphs = {'+':'add','✕':'close','×':'close','✖':'close','🔍':'search','🔎':'search','⬇':'save','⤓':'save','💾':'save','▶':'play','⏸':'pause','⏭':'next','⏮':'previous','↻':'repeat','↺':'repeat','✎':'edit','✏':'edit','📝':'edit','🎤':'lyrics','↗':'share','⧉':'copy','✓':'check','📁':'folder','⚙':'settings','📋':'chapters','🕘':'clock','🔀':'shuffle','📡':'devices','▾':'down','▸':'right','←':'back','⋯':'more','⋮':'more','🗑':'trash','🎵':'music'};
+ const glyphs = {'+':'add','✕':'close','×':'close','✖':'close','🔍':'search','🔎':'search','▶':'play','⏸':'pause','⏭':'next','⏮':'previous','↻':'repeat','↺':'repeat','✎':'edit','✏':'edit','📝':'edit','🎤':'lyrics','↗':'share','⧉':'copy','✓':'check','📁':'folder','⚙':'settings','📋':'chapters','🕘':'clock','🔀':'shuffle','📡':'devices','▾':'down','▸':'right','←':'back','⋯':'more','⋮':'more','🗑':'trash','🎵':'music'};
function leadingGlyph(text,bento=false) {
const mapping=bento?{...glyphs,'📺':'television','⏱':'clock','⇅':'settings','📌':'pin','🎧':'music','📚':'folder'}:glyphs;
const prefix = text.match(/^\s*/)[0], rest = text.slice(prefix.length);
diff --git a/frontend/glass-controls.test.js b/frontend/glass-controls.test.js
index f28cf96..27c0cb8 100644
--- a/frontend/glass-controls.test.js
+++ b/frontend/glass-controls.test.js
@@ -1,7 +1,7 @@
const test=require('node:test'),assert=require('node:assert/strict');
const {leadingGlyph}=require('./glass-controls.js');
test('glass icon substitution preserves leading whitespace and remaining labels',()=>{
- assert.deepEqual(leadingGlyph(' ⬇ Save'),{kind:'save',original:' ⬇',rest:' Save'});
+ assert.equal(leadingGlyph('Save'),null);
assert.deepEqual(leadingGlyph('⚙️ Settings'),{kind:'settings',original:'⚙️',rest:' Settings'});
assert.equal(leadingGlyph('A song with 🔍 in its title'),null);
assert.equal(leadingGlyph('Clear all'),null);
diff --git a/frontend/hash-worker.js b/frontend/hash-worker.js
index 455dab6..5d10f89 100644
--- a/frontend/hash-worker.js
+++ b/frontend/hash-worker.js
@@ -6,11 +6,11 @@
^ * Out: { ok: true, sha256, size, engine } | { ok: false, error }
* ========================================================================== */
'use strict';
-importScripts('/sha256.js');
self.onmessage = async (e) => {
const { name } = e.data || {};
try {
+ if (!self.Sha256) { self.__ASSET_URLS__=e.data.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); }
const root = await navigator.storage.getDirectory();
const dir = await root.getDirectoryHandle('videos');
const file = await (await dir.getFileHandle(String(name))).getFile();
diff --git a/frontend/index.html b/frontend/index.html
index d7e4400..20516e4 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -13,48 +13,44 @@
YT Player
+
+
+
+
-
-
+
-
-
-
-
-
-
-
- ▶
+
YT Player
Ad-free YouTube — no login needed. Search, play, and discover videos shaped by what you listen to.
@@ -182,9 +178,9 @@
-
-
-
+
+
+
Pinch 1.0×Buffered
@@ -211,11 +207,11 @@
-
-
-
-
-
+
+
+
+
+
@@ -274,8 +270,8 @@
-
-
+
+
@@ -344,7 +340,7 @@
-
+
Chapters shared with everyone
@@ -477,9 +473,9 @@
0:00 / 0:00
-
-
-
+
+
+
@@ -494,7 +490,7 @@