From 3c9a24ae45cc6efdf1112667d20f1cf64a7411b1 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Thu, 8 Oct 2026 04:38:37 +0800 Subject: [PATCH] Extract eager shell and shared view renderers before app boot --- frontend/app-seams.test.js | 40 + frontend/app.js | 2032 +----------------------------------- frontend/assets.json | 8 +- frontend/index.html | 5 +- frontend/section-rail.js | 247 +++++ frontend/shell-core.js | 304 ++++++ frontend/sw.js | 3 + frontend/views-core.js | 1501 ++++++++++++++++++++++++++ perf/seams.mjs | 70 ++ 9 files changed, 2198 insertions(+), 2012 deletions(-) create mode 100644 frontend/app-seams.test.js create mode 100644 frontend/section-rail.js create mode 100644 frontend/shell-core.js create mode 100644 frontend/views-core.js create mode 100644 perf/seams.mjs diff --git a/frontend/app-seams.test.js b/frontend/app-seams.test.js new file mode 100644 index 0000000..23890ad --- /dev/null +++ b/frontend/app-seams.test.js @@ -0,0 +1,40 @@ +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const {readFileSync}=require('node:fs'); +const {createHash}=require('node:crypto'); +const {join}=require('node:path'); +const app=readFileSync(join(__dirname,'app.js'),'utf8'); +test('Phase 4 preserves the exact player, continuity, tracking and queue bodies',()=>{ + {const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"373df46f4f994717472d26a6bac07449f6ca0a930cb666bfe3895d1dc65aa351","Player body unchanged");} + {const start=app.indexOf("const Transition = (() => {");assert.ok(start>=0,"Transition");const body=app.slice(start,start+8588);assert.equal(createHash('sha256').update(body).digest('hex'),"71997a537078308a41f0cbf8e29fd1658fb3a199c4f5a25d8af0931f1c2a6d50","Transition body unchanged");} + {const start=app.indexOf("const Wave = (() => {");assert.ok(start>=0,"Wave");const body=app.slice(start,start+5241);assert.equal(createHash('sha256').update(body).digest('hex'),"7252f4e856c59aef8aabfcd394ce4bcba656110cbd01a1a8af662eeb48166a3c","Wave body unchanged");} + {const start=app.indexOf("const StatsTrack = (() => {");assert.ok(start>=0,"StatsTrack");const body=app.slice(start,start+1462);assert.equal(createHash('sha256').update(body).digest('hex'),"9aae2631ba3bfad918319579b7120814af06c84949394d04e52c65345332d514","StatsTrack body unchanged");} + {const start=app.indexOf("function wirePlayerEvents() {");assert.ok(start>=0,"wirePlayerEvents");const body=app.slice(start,start+4913);assert.equal(createHash('sha256').update(body).digest('hex'),"1c5a2b5ce310f6aef6764325eda85a95f47e4f507f2049036ce1da206379908c","wirePlayerEvents body unchanged");} + {const start=app.indexOf("function updatePlayBtn() {");assert.ok(start>=0,"updatePlayBtn");const body=app.slice(start,start+522);assert.equal(createHash('sha256').update(body).digest('hex'),"1326233af07ae92a5ecc81cc211bd98d21ed5f291dc4eb59d040b8df9baba8c8","updatePlayBtn body unchanged");} + {const start=app.indexOf("function persist() {");assert.ok(start>=0,"persist");const body=app.slice(start,start+147);assert.equal(createHash('sha256').update(body).digest('hex'),"01e5c5dbc80616266db09b3829dc45b66476a3256b1c169d8e44f4983b24ac45","persist body unchanged");} + {const start=app.indexOf("function playFromList(list, index, source = '', { playFull = false } = {}) {");assert.ok(start>=0,"playFromList");const body=app.slice(start,start+401);assert.equal(createHash('sha256').update(body).digest('hex'),"f8009c9674b3478959997dca881fb7b9e1ab3708c98e5abdf1958d752e47ee41","playFromList body unchanged");} + {const start=app.indexOf("function advanceQueue() {");assert.ok(start>=0,"advanceQueue");const body=app.slice(start,start+596);assert.equal(createHash('sha256').update(body).digest('hex'),"be405b9c163e3d41a949ef11f6022ef13115b04083e3b6ad21d97beba1f45ae6","advanceQueue body unchanged");} + {const start=app.indexOf("function playNext() {");assert.ok(start>=0,"playNext");const body=app.slice(start,start+143);assert.equal(createHash('sha256').update(body).digest('hex'),"859d2ffd2e8c89a8d9c0dd0cf429fd403675452cedba2e34aa3dc72683d4f8c6","playNext body unchanged");} + {const start=app.indexOf("function playPrev() {");assert.ok(start>=0,"playPrev");const body=app.slice(start,start+268);assert.equal(createHash('sha256').update(body).digest('hex'),"ece8d33bd03c8146a6a702534c8fed50ff606170685b08d730b19d9d3c68cf48","playPrev body unchanged");} + {const start=app.indexOf("function onTrackEnded() {");assert.ok(start>=0,"onTrackEnded");const body=app.slice(start,start+702);assert.equal(createHash('sha256').update(body).digest('hex'),"d6e147238ad9656fa1c2335973b37597749bcfd02df1a66390b306fe1194ae0d","onTrackEnded body unchanged");} + {const start=app.indexOf("function sleepFadeSec() {");assert.ok(start>=0,"sleepFadeSec");const body=app.slice(start,start+138);assert.equal(createHash('sha256').update(body).digest('hex'),"3f986c8cc474d8313476e9c209b9264e3724011ffaee887f47178bb5155a4d77","sleepFadeSec body unchanged");} + {const start=app.indexOf("function applySleepFade(remaining) {");assert.ok(start>=0,"applySleepFade");const body=app.slice(start,start+346);assert.equal(createHash('sha256').update(body).digest('hex'),"9bd0fa697b271c6b80c128ff1d0a7b00afcb3ccf08f98b5c7e7dc86fd4329681","applySleepFade body unchanged");} + {const start=app.indexOf("function startSleepTimer(minutes, { endOfSong = false } = {}) {");assert.ok(start>=0,"startSleepTimer");const body=app.slice(start,start+487);assert.equal(createHash('sha256').update(body).digest('hex'),"f0a93401ab90196096074cb607b28d52c5903e11dbdfb8ddb95114e143af2409","startSleepTimer body unchanged");} + {const start=app.indexOf("function cancelSleepTimer() {");assert.ok(start>=0,"cancelSleepTimer");const body=app.slice(start,start+253);assert.equal(createHash('sha256').update(body).digest('hex'),"cf396ab758a70eede6af7444539a82ca15af801a9a57024dd5542067e9fa771a","cancelSleepTimer body unchanged");} + {const start=app.indexOf("function finishSleep() {");assert.ok(start>=0,"finishSleep");const body=app.slice(start,start+662);assert.equal(createHash('sha256').update(body).digest('hex'),"7ee93bf760bad5f39f4644339963918c5b369b2acc554b8e561c1a1f070ac6fb","finishSleep body unchanged");} + {const start=app.indexOf("const EQ = (() => {");assert.ok(start>=0,"EQ");const body=app.slice(start,start+7917);assert.equal(createHash('sha256').update(body).digest('hex'),"f0cba011c94f33e6fd0ab778021f4c97238262dfa0a8dd40bf3f17149c227528","EQ body unchanged");} + {const start=app.indexOf("const EqRender = (() => {");assert.ok(start>=0,"EqRender");const body=app.slice(start,start+5912);assert.equal(createHash('sha256').update(body).digest('hex'),"f014dd41dafebc33f09e87be866074afd7a2443c5bb452103a435585353a828f","EqRender body unchanged");} + {const start=app.indexOf("const Level = (() => {");assert.ok(start>=0,"Level");const body=app.slice(start,start+3892);assert.equal(createHash('sha256').update(body).digest('hex'),"e48ad7797384ac31bb8b864662fc2a546b04470b981042f016874ba750957226","Level body unchanged");} +}); + +test('eager shell and Settings definitions load before app state and defer reading it',()=>{ + const html=readFileSync(join(__dirname,'index.html'),'utf8'); + for(const file of ['shell-core.js','views-core.js','section-rail.js']) { + assert.ok(html.indexOf('src="'+file+'"') { })(); 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) @@ -6322,111 +6199,12 @@ 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 @@ -7167,252 +6945,7 @@ 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 - } - - 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); @@ -7987,17 +7520,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 +7528,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 @@ -8694,109 +8180,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; @@ -8812,306 +8207,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 @@ -9235,314 +8349,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 @@ -9567,767 +8390,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() ? `` : ''} - - - - -
- -
-
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'} -
-
- - Profile link - Open this URL on another device to load the profile there. Anyone with the link gets full access to this data — share it only with your own devices or people you trust. - - ${escapeHtml(profileShareUrl() || '')} -
-
- - - - - - - - - -
-
- -
-
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'}`, ''], - ['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', async (e) => { - const select=e.target, next=select.value, previous=data.settings.layout; select.disabled=true; - try { await Lazy.load('layout:'+next); data.settings.layout=next; Lazy.layout=next; if(WEB)saveDataToStorage(JSON.stringify(data)); applyAppearance(); persist(); Lazy.reportLayout?.(); } - catch(error) { select.value=previous; toast(error.message); } - finally { select.disabled=false; } - }); - $('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(); - } - // ---- 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. @@ -11923,41 +9990,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 @@ -11968,26 +10001,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': { diff --git a/frontend/assets.json b/frontend/assets.json index 253339a..dd849b4 100644 --- a/frontend/assets.json +++ b/frontend/assets.json @@ -57,7 +57,10 @@ "/feature-bootstrap.js", "/eq-parameters.js", "/assets.json", - "/device-db.js" + "/device-db.js", + "/shell-core.js", + "/views-core.js", + "/section-rail.js" ], "background": false }, @@ -249,7 +252,8 @@ "embeddedIn": [ "/app.js", "/settings-sections.js", - "/settings-sections.css" + "/settings-sections.css", + "/views-core.js" ], "background": false }, diff --git a/frontend/index.html b/frontend/index.html index e0cb08b..7798602 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,7 +19,7 @@ - + @@ -669,6 +669,9 @@ + + + diff --git a/frontend/section-rail.js b/frontend/section-rail.js new file mode 100644 index 0000000..c84cf10 --- /dev/null +++ b/frontend/section-rail.js @@ -0,0 +1,247 @@ +/* Eager UI definitions. Read app lexical state only when called after initialization. */ +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 + } + + 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 }; +})(); diff --git a/frontend/shell-core.js b/frontend/shell-core.js new file mode 100644 index 0000000..89563fb --- /dev/null +++ b/frontend/shell-core.js @@ -0,0 +1,304 @@ +/* Eager UI definitions. Read app lexical state only when called after initialization. */ +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 }; +})(); + +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(); +} + +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); +} + +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 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'); +} + +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); +} + +function setTile(btn, label) { + const tl = btn.querySelector('.tl'); + if (tl) tl.textContent = label; else btn.textContent = label; +} + +function updateNowPlayingActions() { + if (!current || !current.meta) return; + const id = current.meta.id; + renderAvailability(id); + // Custom (edited) videos are already a saved-offline cut of a source video; + // re-saving or re-editing them makes no sense, so hide those actions. + const isCustom = !!current.meta.custom; + if (els.editBtn) els.editBtn.hidden = isCustom; + if (els.saveBtn) els.saveBtn.hidden = isCustom; + // The server cache exists only behind the web PWA backend. + if (els.brokenBtn) { + els.brokenBtn.hidden = isCustom || !WEB; + els.brokenBtn.disabled = reportingBroken.has(id); + els.brokenBtn.textContent = reportingBroken.has(id) ? '⏳ Reporting…' : '⚠ Broken'; + } + { const ch = $('npChannel'); if (ch) { const ok = isUploadId(id) || cachedIds.has(id); ch.classList.toggle('verified', ok); ch.title = ok ? 'Available on this device or the server' : ''; } } + const btn = els.saveBtn; + if (!btn) return; + if (removing.has(id)) { + setTile(btn, 'Removing…'); + btn.classList.remove('done'); + btn.disabled = true; + } else if (downloading.has(id)) { + setTile(btn, 'Saving…'); + btn.classList.remove('done'); + btn.disabled = true; + } else if (cachedIds.has(id)) { + setTile(btn, 'Saved'); + btn.classList.add('done'); + btn.disabled = false; + btn.title = 'Saved for offline — click to remove from cache'; + } else { + setTile(btn, 'Save'); + btn.classList.remove('done'); + btn.disabled = false; + btn.title = 'Save this video for offline playback'; + } +} + +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); + } +} diff --git a/frontend/sw.js b/frontend/sw.js index efca9b4..ed90ab1 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -137,6 +137,9 @@ const SHELL = [ '/car-mode.css', '/lower-third.css', '/settings-sections.css', + '/shell-core.js', + '/views-core.js', + '/section-rail.js', '/app.js', '/manifest.webmanifest', '/icons/icon-192.png', diff --git a/frontend/views-core.js b/frontend/views-core.js new file mode 100644 index 0000000..314b18b --- /dev/null +++ b/frontend/views-core.js @@ -0,0 +1,1501 @@ +/* Eager UI definitions. Read app lexical state only when called after initialization. */ +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))); +} + +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); + }); +} + +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); + } +} + +function setListPaneHidden(hidden) { + const pane = document.getElementById('listPane'); + if (pane) pane.classList.toggle('hidden', hidden); +} + +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 = ``; +} + +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); +} + +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'); +} + +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(); +} + +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)); + } + }); +} + +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() ? `` : ''} + + + + +
+ +
+
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'} +
+
+ + Profile link + Open this URL on another device to load the profile there. Anyone with the link gets full access to this data — share it only with your own devices or people you trust. + + ${escapeHtml(profileShareUrl() || '')} +
+
+ + + + + + + + + +
+
+ +
+
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'}`, ''], + ['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', async (e) => { + const select=e.target, next=select.value, previous=data.settings.layout; select.disabled=true; + try { await Lazy.load('layout:'+next); data.settings.layout=next; Lazy.layout=next; if(WEB)saveDataToStorage(JSON.stringify(data)); applyAppearance(); persist(); Lazy.reportLayout?.(); } + catch(error) { select.value=previous; toast(error.message); } + finally { select.disabled=false; } + }); + $('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(); + } + + // ---- 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); + }); +} + +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); + }); +} diff --git a/perf/seams.mjs b/perf/seams.mjs new file mode 100644 index 0000000..e661828 --- /dev/null +++ b/perf/seams.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +// Phase 3 checks against the real Bun server, a same-origin restart and real workers. +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, cpSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { createServer } from 'node:net'; +import { chromium, webkit } from 'playwright'; +import { createThrottleProxy } from './proxy.mjs'; +const repo=resolve(new URL('..',import.meta.url).pathname), scratch=mkdtempSync(join(repo,'perf/.tmp/seams-')); +const sleep=ms=>new Promise(r=>setTimeout(r,ms)); +async function wait(page,fn,arg,timeout=90000){const end=Date.now()+timeout;while(Date.now()portServer.listen(0,'127.0.0.1',r));const port=portServer.address().port;await new Promise(r=>portServer.close(r)); + let child,browser,proxy,log=''; + async function stop(){if(!child)return;const p=child;child=null;if(p.exitCode===null){p.kill('SIGTERM');await new Promise(r=>p.once('exit',r));}} + async function start(){log='';child=spawn('bun',[join(repo,'server/server.js')],{cwd,env:{...process.env,PORT:String(port),ASSET_HASHING:'1',ASSET_SYNC:'1',BUILD_TAG:'',DB_PATH:join(cwd,'db.sqlite'),DATA_DIR:join(cwd,'data'),MEDIA_DIR:join(cwd,'media'),UPLOAD_DIR:join(cwd,'uploads')},stdio:['ignore','pipe','pipe']});for(const stream of [child.stdout,child.stderr])stream.on('data',c=>log=(log+c).slice(-15000));for(let i=0;i<200;i++){if(child.exitCode!==null)throw Error(log);try{if((await fetch('http://127.0.0.1:'+port+'/api/version')).ok)return;}catch{}await sleep(100);}throw Error(log);} + try { + await start();proxy=createThrottleProxy({targetPort:port,profile:'unthrottled'});const origin='http://127.0.0.1:'+await proxy.listen();proxy.startTracking(); + browser=await ({chromium,webkit}[name]).launch({headless:true});const context=await browser.newContext(); + await context.route('**/*',r=>r.request().url().startsWith(origin)?r.continue():r.abort()); + await context.addInitScript(()=>{Object.defineProperty(navigator,'onLine',{configurable:true,get:()=>!window.__offline});}); + const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message)); + await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn')); + await wait(page,async()=>!!(await navigator.serviceWorker.getRegistration())?.active); + await page.evaluate(()=>Lazy.warm()); + await wait(page,async()=>{const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');if(!r)return false;const s=await r.json();return !(await Promise.all(Object.values(s.current.groups).flatMap(g=>g.files).map(async p=>!!await c.match(p+'?v='+s.current.files[p].h)))).includes(false);}); + await page.reload();await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn')); + await page.evaluate(()=>{window.__offline=true;window.__shellBuild=document.querySelector('meta[name="ytp-build"]').content;}); + await stop();await proxy.setOffline(true);const before=proxy.getStats().requestCount; + await page.evaluate(()=>{data.playlists=[{id:'smoke',name:'Cached playlist',videos:[]}];render();}); + assert.equal(await page.locator('#homePlGrid .home-pl-name').textContent(),'Cached playlist'); + await page.click('[data-view="settings"]');await page.waitForSelector('.settings'); + await page.evaluate(()=>{view={type:'search'};render();}); + const groups=await page.evaluate(()=>Object.keys(Lazy.manifest.groups)); + const checked=['shell']; + if(groups.includes('feature:presenter')) { + await page.evaluate(async()=>{history.replaceState(null,'','/?present=');await Presenter.boot();}); + await page.waitForSelector('#presenterView:not(.hidden)');await page.keyboard.press('b'); + assert.equal(await page.locator('#pvBlank').evaluate(n=>!n.classList.contains('hidden')),true); + await page.evaluate(()=>{document.querySelector('#presenterView').classList.add('hidden');document.body.classList.remove('presenter-open');});checked.push('presenter'); + } + if(groups.includes('feature:remote-client')) { + await page.evaluate(()=>document.querySelector('#navRemoteBtn').click()); + await page.waitForSelector('#modal:not(.hidden)');assert.match(await page.locator('#modal').textContent(),/remote|screen|phone/i); + await page.evaluate(()=>closeModal());checked.push('remote'); + } + if(groups.includes('feature:watch-party')) { + await page.evaluate(()=>document.querySelector('#navPartyBtn').click());await page.waitForSelector('#partyPanel:not(.hidden)'); + await page.locator('.pt-name').fill('Cached guest');assert.equal(await page.locator('.pt-name').inputValue(),'Cached guest');checked.push('party'); + } + if(groups.includes('feature:share-external')) { + await page.evaluate(async()=>{current={meta:{id:'benchmedia01',title:'Cached smoke',channel:'Fixture'}};await Share.open();}); + await page.waitForSelector('.share-moment');assert.match(await page.locator('#modal').textContent(),/Cached smoke/); + const m3u=await page.evaluate(async()=>{await Lazy.load('feature:share-external');return window.YT.External.m3u([{id:'benchmedia01',title:'Cached smoke'}]);});assert.match(m3u,/#EXTM3U/);assert.match(m3u,/benchmedia01/); + await page.evaluate(()=>closeModal());checked.push('share'); + } + if(groups.includes('feature:stats-view')) { + await page.evaluate(async()=>{view={type:'stats'};await renderStats();});await page.waitForSelector('.stats-empty');checked.push('stats'); + } + assert.equal(proxy.getStats().requestCount,before,'all warmed seam code loads from SW without network'); + // Ignore only verified disconnected version/recommendation API transports in WebKit. + const scriptErrors=errors.filter(e=>!(name==='webkit'&&(e==='TypeError: Load failed'||/\/api\/(?:recommendations\?[^\s]*|version)\.$/.test(e))));assert.deepEqual(scriptErrors,[]); + results.push({browser:name,checked,offlineNetworkRequests:proxy.getStats().requestCount-before,errors:scriptErrors});console.log(name,JSON.stringify(results.at(-1))); + }finally{await browser?.close();await stop();await proxy?.close();} +} +try{for(const name of requested==='all'?['chromium','webkit']:[requested])await run(name);writeFileSync(join(repo,'perf/results/phase4-seams-2026-10-08.json'),JSON.stringify(results,null,2)+'\n');}finally{rmSync(scratch,{recursive:true,force:true});}