diff --git a/.gitignore b/.gitignore index c9390e1..a37e8ff 100755 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,9 @@ plans/codex-report*.md # performance harness artifacts and temp files perf/fixtures/ perf/.tmp/ + +# UI regression screenshots and local measurements +plans/ui-shots/ + +# Transport regression screenshots +plans/transport-shots/ diff --git a/frontend/admin.html b/frontend/admin.html index 5c7eba0..1195045 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -6,6 +6,7 @@ Lyrics Studio + + .media-shape { display:none; } .media-shape-classic { display:inline; } +.media-icon { width:18px; height:18px; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; } + @@ -382,7 +385,7 @@
- +
0:00.00 / 0:00
s - +
@@ -508,7 +511,7 @@

Recent edits

- ⤓ Export all (JSON)
+ Export all (JSON)
@@ -569,6 +572,7 @@ + ']);assert.equal(f.appended.length,0); + for(const fn of f.listeners.DOMContentLoaded)fn();await f.root.AppBootstrap.ready; + assert.equal(f.root.bootRuns,1);assert.equal(f.calls(),0); +}); diff --git a/frontend/app-seams.test.js b/frontend/app-seams.test.js new file mode 100644 index 0000000..3ef08ad --- /dev/null +++ b/frontend/app-seams.test.js @@ -0,0 +1,84 @@ +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 playback, 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 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+'"')<=html.indexOf('src="section-rail.js"')); + const vm=require('node:vm');const doc={readyState:'complete',addEventListener(){},createElement:()=>({}),head:{append(){}}};const globals={document:doc,Lazy:{manifest:{appCache:false},url:()=>'/app.js'},console};globals.window=globals;const context=vm.createContext(globals); + // Evaluation before any data/$/els/Player declaration must be safe. + vm.runInContext(readFileSync(join(__dirname,file),'utf8'),context); + } + const views=readFileSync(join(__dirname,'views-core.js'),'utf8'); + assert.ok(views.includes('async function renderSettings()')); +}); + +test('remote facade preserves synchronous no-session and loaded changed hooks',()=>{ + const source=app.match(/\/\* Remote facade \*\/([\s\S]*?)\/\* End remote facade \*\//)?.[1]; + assert.ok(source,'remote facade is present'); + const context=require('node:vm').createContext({window:{},Lazy:{proxy:()=>({}),onLoad(){}}}); + require('node:vm').runInContext(source+';globalThis.api=Remote;',context); + assert.equal(context.api.changed(),undefined); + let calls=0;context.window.YT={Remote:{changed:()=>{calls++;return 'sync';}}}; + assert.equal(context.api.changed(),'sync');assert.equal(calls,1); +}); + +test('watch-party facade keeps playback decisions synchronous before and after loading',()=>{ + const source=app.match(/\/\* Party facade \*\/([\s\S]*?)\/\* End party facade \*\//)?.[1];assert.ok(source); + const context=require('node:vm').createContext({window:{},Lazy:{proxy:()=>({}),onLoad(){}}}); + require('node:vm').runInContext(source+';globalThis.api=Party;',context); + for(const method of ['guestActive','intercept','nextDJ'])assert.equal(context.api[method]('next'),false); + context.window.YT={Party:{guestActive:()=>true,intercept:action=>action==='next',nextDJ:()=>true}}; + for(const method of ['guestActive','intercept','nextDJ'])assert.equal(context.api[method]('next'),true); +}); + +test('Share and External stay behind lazy entry proxies',()=>{ + assert.match(app,/const Share = Lazy\.proxy\('feature:share-external'/); + assert.match(app,/const External = Lazy\.proxy\('feature:share-external'/); + const chunk=readFileSync(join(__dirname,'share-external.js'),'utf8'); + const context=require('node:vm').createContext({window:{},navigator:{userAgent:''},location:{origin:'https://fixture.test'},isUploadId:()=>false}); + require('node:vm').runInContext(chunk,context); + assert.equal(typeof context.window.YT.Share.open,'function'); + const text=context.window.YT.External.m3u([{id:'benchmedia01',title:'Fixture'}]); + assert.match(text,/#EXTM3U/);assert.match(text,/benchmedia01/); +}); + +test('listening tracking does not pull in the lazy Stats view',()=>{ + const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8')); + assert.deepEqual(groups['feature:stats-view'].files,['/stats-view.js']); + assert.deepEqual(groups['feature:stats-view'].dependencies,['feature:stats']); + assert.deepEqual(groups['feature:stats'].files,['/stats-core.js']); + assert.match(app,/Lazy\.proxy\('feature:stats-view', \['render'\], 'StatsView'\)/); +}); + +test('an unavailable Stats chunk reports an error without rejecting the UI navigation',async()=>{ + const source=app.match(/function renderStats\(\) \{[\s\S]*?\n\}/)?.[0];assert.ok(source); + const seen=[],context=require('node:vm').createContext({Lazy:{proxy:()=>({render:()=>Promise.reject(new Error('Unavailable fixture chunk'))})},toast:text=>seen.push(text)}); + require('node:vm').runInContext(source+';globalThis.render=renderStats;',context); + await context.render();assert.deepEqual(seen,['Unavailable fixture chunk']); +}); diff --git a/frontend/app.js b/frontend/app.js index af59737..e8c067a 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -623,8 +623,9 @@ const API = { // Pre-resolve streams for videos the user is likely to play next (server // /api/streams/warm). Each id is warmed at most once per 20 min per tab. const warmedAt = new Map(); +const knownServerCachedIds = new Set(); function warmStreams(ids) { - if (!WEB || navigator.onLine === false) return; + if (!WEB || navigator.onLine === false || navigator.connection?.saveData) return; const now = Date.now(); for (const id of ids) { if (!id || !/^[A-Za-z0-9_-]{11}$/.test(id) || cachedIds.has(id)) continue; @@ -634,6 +635,14 @@ function warmStreams(ids) { } } +function warmNextQueue() { + if (!window.PrewarmNext || queueIndex < 0) return; + warmStreams(window.PrewarmNext.candidates(queue, queueIndex, data.settings, 2, { + offline: cachedIds, + serverCached: knownServerCachedIds, + })); +} + // Resolve a native file path to a URL the WebView can load (Tauri asset // protocol). Returns null on shells without it. function toAssetUrl(path) { @@ -1233,7 +1242,7 @@ async function adoptSharedPlaylistByCode(code, { fromName = '' } = {}) { Add “${escapeHtml(shared.name)}” (${count} video${count === 1 ? '' : 's'}) to your playlists? This playlist is added alongside your existing playlists and nothing is replaced.

`; - showModal(fromName ? '🎵 Playlist sent to you' : '⬇ Add shared playlist', body, [ + showModal(fromName ? '🎵 Playlist sent to you' : 'Add shared playlist', body, [ { label: fromName ? 'No thanks' : 'Cancel', onClick: () => { closeModal(); resolve(false); } }, { label: 'Add playlist', primary: true, onClick: () => { closeModal(); resolve(true); } }, ]); @@ -1353,7 +1362,7 @@ async function adoptProfileFromUrl() { Loading it replaces the playlists, history and settings on this device, and future changes here sync to that profile.

`; - showModal('⬇ Open shared profile', body, [ + showModal('Open shared profile', body, [ { label: 'Cancel', onClick: () => { closeModal(); resolve(false); } }, { label: 'Load profile', primary: true, onClick: () => { closeModal(); resolve(true); } }, ]); @@ -1559,7 +1568,7 @@ function loadProfileFlow() { changes here sync back to that profile.

`; - showModal('⬇ Load online profile', body, [ + showModal('Load online profile', body, [ { label: 'Cancel', onClick: closeModal }, { label: 'Load', primary: true, onClick: async () => { const name = ($('profileNameInput').value || '').trim(); @@ -1590,8 +1599,8 @@ function escapeHtml(s) { .replace(/"/g, '"').replace(/'/g, '''); } -const ICON_PLAY = ''; -const ICON_PAUSE = ''; +const ICON_PLAY = TransportIcons.svg('play'); +const ICON_PAUSE = TransportIcons.svg('pause'); // Speaker icon follows the volume (muted / low / high); the level before a mute // is remembered so un-muting returns to it. let lastVolume = 1; @@ -1658,7 +1667,13 @@ function applyAppearance() { root.dataset.density = s.density || 'comfortable'; root.dataset.perf = s.perfMode ? 'on' : 'off'; root.dataset.motion = s.reduceMotion ? 'reduced' : 'full'; - root.dataset.layout = s.layout || 'classic'; + const desiredLayout=s.layout || 'classic'; + if(root.dataset.layout!==desiredLayout && !Lazy.loaded('layout:'+desiredLayout)) { + Lazy.load('layout:'+desiredLayout).then(()=>{if((data.settings.layout||'classic')===desiredLayout)applyAppearance();}).catch(error=>toast(error.message)); + } else { + root.dataset.layout=desiredLayout; + if(Lazy.layout!==desiredLayout) { Lazy.layout=desiredLayout; Lazy.reportLayout?.(); } + } } function fmtBytes(n) { if (!n) return '0 B'; @@ -2048,7 +2063,8 @@ async function deleteCustomVideo(id) { // Open the editor modal for a source video. `duration` seconds is needed to // compute keep segments; we take it from the live player when the video is // currently playing, else from the card metadata. -function openVideoEditor(source) { +async function openVideoEditor(source) { + try { await Lazy.load('feature:video-edit'); } catch (error) { toast(error.message); return; } if (!(WEB && window.OPFS && window.OPFS.isSupported())) { toast('⚠ Editing needs offline storage, which this browser doesn’t support'); return; @@ -2194,7 +2210,7 @@ function openVideoEditor(source) { { label: 'Save edited copy', primary: true, onClick: () => { if (!VideoEdit.hasEdits(cuts, duration)) { - showErr('Add at least one cut, or use ⬇ Save for the full video.'); + showErr('Add at least one cut, or use Save for the full video.'); return; } const keep = VideoEdit.invertCuts(cuts, duration); @@ -2333,6 +2349,7 @@ const Player = { if (seq !== this._loadSeq) return; // a newer load took over meanwhile if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams'); current = res.data; + if (current.serverCached && current.meta?.id) knownServerCachedIds.add(current.meta.id); // Merge richer metadata we may already have from the list card. current.meta = { ...videoObj, ...current.meta }; @@ -2659,7 +2676,6 @@ const Player = { play() { this._wantsPlaying = true; - if (data.settings.jamesDsp?.enabled) JamesDSP.activate(this.soundEl).catch((e) => JamesDSP.fail(e)); audioContinuity.play(this.master); audioContinuity.resume(this.secondary, this.master); }, @@ -3016,7 +3032,7 @@ function wirePlayerEvents() { perfMeasure('ytp:tap-to-play', 'ytp:tap'); try { performance.clearMarks('ytp:tap'); } catch { /* ignore */ } } - if (Array.isArray(queue) && queueIndex >= 0) warmStreams(queue.slice(queueIndex + 1, queueIndex + 3).map((x) => x && x.id)); + warmNextQueue(); showSpinner(false); Player.clearBufferGrace(); if (Player.secondary && !el.paused && Player.secondary.paused) { @@ -3073,14 +3089,10 @@ function wirePlayerEvents() { function updatePlayBtn() { Remote.changed(); - els.playBtn.innerHTML = Player.master.paused ? ICON_PLAY : ICON_PAUSE; - const sp = $('stgPlay'); - if (sp) sp.innerHTML = Player.master.paused ? ICON_PLAY : ICON_PAUSE; + const kind = Player.master.paused ? 'play' : 'pause'; + for (const id of ['playBtn','stgPlay','miniPlayBtn','servicePlayBtn']) TransportIcons.set($(id), kind); const stage = document.querySelector('.player-stage'); if (stage) stage.classList.toggle('is-paused', !!Player.master.paused); - $('miniPlayBtn').textContent = Player.master.paused ? '▶' : '⏸'; - const spBtn = $('servicePlayBtn'); - if (spBtn) spBtn.textContent = Player.master.paused ? '▶' : '⏸'; } // "⚠ Broken": ask the server to drop its cached copy and fetch a fresh one. // Playback switches to live YouTube streams meanwhile (resuming where it @@ -3127,6 +3139,7 @@ async function renderAvailability(id) { const el = $('npAvail'); if (!el) return; const off = data.settings.p2pShare === false && data.settings.p2pReceive === false; + if (WEB && !off && navigator.onLine !== false && id && !window.P2PCore) { try { await Lazy.load('feature:p2p'); } catch {} } if (!WEB || !window.P2PCore || off || navigator.onLine === false || String(id).startsWith('edit_')) { el.classList.add('hidden'); return; @@ -3153,6 +3166,7 @@ async function renderAvailability(id) { // Fetch a verified copy from another device (docs/p2p-architecture.md flow 6). // Download-then-play: on success the file is saved offline like any save. async function getFromPeers(videoObj, c, peers, onProgress) { + try { await Lazy.load('feature:direct'); } catch (error) { toast(error.message); return false; } if (window.DirectMedia && !DirectMedia.enabled()) { toast('Direct device transfer is off. Enable it in Settings or use a server copy.'); return false; } if (window.DirectMedia?.enabled()) { const ok = await DirectMedia.obtain(videoObj.id); if (!ok) toast('Pair the source device through Remote or use the same profile to receive its saved copy.'); return !!ok; } const id = videoObj.id; @@ -3178,6 +3192,7 @@ async function getFromPeers(videoObj, c, peers, onProgress) { // Offered when a video won't load: only if some device holding it is online now. async function offerPeerDownload(videoObj) { + if (WEB && data.settings.p2pReceive !== false) { try { await Lazy.load('feature:p2p'); } catch { return; } } if (!WEB || !window.P2PTransfer || !window.P2PCore || data.settings.p2pReceive === false) return; const id = videoObj && videoObj.id; if (!id || videoObj.custom || cachedIds.has(id)) return; @@ -3227,7 +3242,7 @@ function openMoreControls() { ['eqBtn', '🎚', 'Equalizer', false], ['pipBtn', '⧉', 'Picture-in-picture', false], ]; - const selects = [['speedSelect', '⏩', 'Speed'], ['qualitySelect', '🎞', 'Quality']]; + const selects = [['speedSelect', TransportIcons.svg('forward'), 'Speed'], ['qualitySelect', '🎞', 'Quality']]; const list = document.createElement('div'); list.className = 'more-list'; const draw = () => { @@ -3238,7 +3253,7 @@ function openMoreControls() { const row = document.createElement('label'); row.className = 'more-row'; row.innerHTML = ''; - row.querySelector('.more-ico').textContent = icon; + row.querySelector('.more-ico').innerHTML = icon; row.querySelector('b').textContent = title; const sel = document.createElement('select'); sel.className = 'more-sel'; @@ -3256,7 +3271,7 @@ function openMoreControls() { row.type = 'button'; row.className = 'more-row' + (on ? ' on' : ''); row.innerHTML = '' + (on ? '●' : '›') + ''; - row.querySelector('.more-ico').textContent = icon; + row.querySelector('.more-ico').innerHTML = icon; row.querySelector('b').textContent = title; row.querySelector('small').textContent = String(src.title || '').replace(/\s*\[[A-Za-z]\]\s*$/, ''); row.onclick = () => { @@ -3283,13 +3298,13 @@ function openAddChoice() { r.type = 'button'; r.className = 'more-row'; r.innerHTML = '›'; - r.querySelector('.more-ico').textContent = icon; + r.querySelector('.more-ico').innerHTML = icon; r.querySelector('b').textContent = title; r.querySelector('small').textContent = sub; r.onclick = () => { closeModal(); setTimeout(fn, 0); }; list.appendChild(r); }; - row('▶', 'Add to queue', 'Play it next in this session', () => addToQueue(video)); + row(TransportIcons.svg('play'), 'Add to queue', 'Play it next in this session', () => addToQueue(video)); row('☰', 'Add to playlist', 'Save it to one of your playlists', () => openCardMenu(video)); showModal('Add “' + (video.title || 'video').slice(0, 40) + '”', list, [{ label: 'Cancel', onClick: closeModal }]); } @@ -3314,7 +3329,7 @@ function openMoreActions() { row.className = 'more-row' + (danger ? ' danger' : ''); row.disabled = !!src.disabled; row.innerHTML = '›'; - row.querySelector('.more-ico').textContent = icon; + row.querySelector('.more-ico').innerHTML = icon; row.querySelector('b').textContent = title; row.querySelector('small').textContent = sub; row.onclick = () => { closeModal(); setTimeout(() => src.click(), 0); }; @@ -3326,48 +3341,8 @@ function openMoreActions() { // Reflect cache state on the now-playing Save button. // Tiles are [icon][label]; only the label text changes with state. -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'; - } -} + + // Smallest font (px) the sung line may be fitted to in service mode; longer lines wrap instead. const SERVICE_LYRIC_MIN_PX = 30; @@ -3581,7 +3556,7 @@ const Notes = (() => {
- ${isUploadId(st.videoId) ? '' : ''} + ${isUploadId(st.videoId) ? '' : ``}
Lyrics and chapters are shared with everyone who plays this song.
`; @@ -3655,7 +3630,7 @@ const Notes = (() => {
${m.kind === 'note' ? '📝 ' : ''}${escapeHtml(m.title)}
${m.note ? `
${escapeHtml(m.note)}
` : ''} - + ${m.auto ? '' : ` `} `; @@ -3823,7 +3798,7 @@ const Notes = (() => { ${note ? `

${escapeHtml(note)}

` : ''}
- +
`; @@ -4203,7 +4178,7 @@ const Notes = (() => { const refresh = () => { ta.value = build(inc.checked); }; inc.addEventListener('change', refresh); refresh(); - showModal('⤓ Export chapters', body, [ + showModal('Export chapters', body, [ { label: 'Download .txt', onClick: () => { const blob = new Blob([`${title}\nhttps://youtu.be/${st.videoId}\n\n${ta.value}\n`], { type: 'text/plain' }); const a = document.createElement('a'); @@ -4741,681 +4716,24 @@ const Notes = (() => { // Remote = this phone drives a host. Hosting survives reloads (the secret and // "enabled" flag live in localStorage), so a TV PC that restarts its browser // keeps its phones paired. -const Remote = (() => { - const noop = { boot() {}, changed() {} }; - if (!WEB || typeof WebSocket === 'undefined') return noop; +/* Remote facade */ +const Remote = { + ...Lazy.proxy('feature:remote-client', ['openChooser', 'openHostModal'], 'Remote'), + changed: () => window.YT?.Remote?.changed(), + boot() { + if (!WEB) return; + $('navRemoteBtn').addEventListener('click', () => { closeSidebar(); Remote.openChooser().catch(error => toast(error.message)); }); + $('remoteChip').addEventListener('click', () => Remote.openHostModal().catch(error => toast(error.message))); + Lazy.intent($('navRemoteBtn'), 'feature:remote-client'); + Lazy.intent($('remoteChip'), 'feature:remote-client'); + let configured = false; + try { configured = !!JSON.parse(localStorage.getItem('ytpRemoteHost') || 'null')?.enabled; } catch {} + if (configured || new URLSearchParams(location.search).has('pair')) Lazy.load('feature:remote-client').catch(error => toast(error.message)); + }, +}; +Lazy.onLoad('feature:remote-client', () => window.YT.Remote.boot()); +/* End remote facade */ - const HOST_KEY = 'ytpRemoteHost'; - const PAIR_KEY = 'ytpRemotePair'; - const load = (k) => { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch { return null; } }; - const save = (k, v) => { try { if (v) localStorage.setItem(k, JSON.stringify(v)); else localStorage.removeItem(k); } catch { /* storage blocked */ } }; - const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/remote?${new URLSearchParams(q)}`; - const newSecret = () => { - const b = new Uint8Array(32); - crypto.getRandomValues(b); - return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - }; - const guessName = (fallback) => { - const ua = navigator.userAgent || ''; - if (/iPhone/.test(ua)) return 'iPhone'; - if (/iPad/.test(ua)) return 'iPad'; - if (/Android/.test(ua)) return 'Android phone'; - if (/Windows/.test(ua)) return 'Windows PC'; - if (/Mac/.test(ua)) return 'Mac'; - return fallback; - }; - const clock = (t) => (window.LyricsCore ? LyricsCore.fmtClock(t) : fmtTime(t)); - - // ========================================================================== - // Host (the screen being controlled) - // ========================================================================== - const host = { ws: null, remotes: [], code: '', codeExpires: 0, retryMs: 2000, timer: null, lastState: '', lastQueue: '', replaced: false, pending: false }; - - function hostCfg() { - let cfg = load(HOST_KEY); - if (!cfg || !cfg.secret) cfg = { secret: newSecret(), name: guessName('Desktop'), enabled: false }; - return cfg; - } - - function hostConnect() { - const cfg = hostCfg(); - if (!cfg.enabled) return; - try { if (host.ws) host.ws.close(); } catch { /* already closed */ } - const ws = new WebSocket(wsUrl({ role: 'host', secret: cfg.secret, name: cfg.name })); - host.ws = ws; - host.replaced = false; - ws.onopen = () => { host.retryMs = 2000; }; - ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onHostMessage(m); }; - ws.onclose = (e) => { - if (host.ws !== ws) return; - host.ws = null; - host.remotes = []; - renderChip(); - renderHostModal(); - if (e.code === 4000) { host.replaced = true; renderHostModal(); return; } // another tab hosts now - if (hostCfg().enabled) { - clearTimeout(host.timer); - host.timer = setTimeout(hostConnect, host.retryMs); - host.retryMs = Math.min(host.retryMs * 2, 30000); - } - }; - } - - const hostSend = (m) => { if (host.ws && host.ws.readyState === 1) host.ws.send(JSON.stringify(m)); }; - - function onHostMessage(m) { - DirectMedia.attach('remote-host', { send: hostSend, peers: () => host.remotes.map(r => ({ id: r.rid, name: r.name })) }); - if (m.type === 'direct') { DirectMedia.handle('remote-host', m); return; } - if (m.type === 'hello') { - host.code = m.code; host.codeExpires = m.codeExpires; host.remotes = m.remotes || []; - host.lastState = ''; host.lastQueue = ''; - pushState(); pushQueue(); - } else if (m.type === 'code') { - host.code = m.code; host.codeExpires = m.codeExpires; - } else if (m.type === 'remotes') { - host.remotes = m.remotes || []; - if (m.joined) { toast(`📱 ${m.joined} connected as a remote`); host.lastState = ''; host.lastQueue = ''; pushState(); pushQueue(); } - if (m.left) toast(`📱 ${m.left} disconnected`); - } else if (m.type === 'ping') { - hostSend({ type: 'pong', rid: m.rid, t: m.t }); - return; - } else if (m.type === 'cmd') { - runCommand(m); - return; - } - renderChip(); - renderHostModal(); - } - - function stateNow() { - const meta = current && current.meta; - const M = Player.master; - return { - directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false, - v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '' } : null, - cur: M ? Math.floor((M.currentTime || 0) * 10) / 10 : 0, - dur: (M && isFinite(M.duration) && M.duration) || (meta && meta.duration) || 0, - paused: !M || M.paused, - volume: data.settings.volume ?? 1, - service: isServiceModeOpen(), - sleep: sleepTimerRemaining || 0, - hostName: hostCfg().name, - // For the presenter screen: this screen's personal lyric offset for the - // song and the playback speed (to extrapolate between updates). - off: meta && data.lyricOffsets ? Number(data.lyricOffsets[meta.id]) || 0 : 0, - rate: (M && M.playbackRate) || 1, - }; - } - - function pushState(force = false) { - if (!host.ws || !host.remotes.length) return; - const st = stateNow(); - const key = JSON.stringify({ ...st, cur: Math.floor(st.cur) }); - if (!force && key === host.lastState) return; - host.lastState = key; - hostSend({ type: 'state', state: { ...st, at: Date.now() } }); - } - - function pushQueue() { - if (!host.ws || !host.remotes.length) return; - const items = queue.slice(0, 200).map((v) => ({ - id: v.id, title: v.title || '', channel: v.channel || '', duration: v.duration || 0, thumbnail: v.thumbnail || '', - })); - const key = queueIndex + '|' + items.map((v) => v.id).join(','); - if (key === host.lastQueue) return; - host.lastQueue = key; - hostSend({ type: 'queue', queue: { items, idx: queueIndex } }); - } - - // Coalesce bursts (play event + renderUpNext + afterLoad) into one push. - function changed() { - if (!host.ws || host.pending) return; - host.pending = true; - setTimeout(() => { host.pending = false; pushState(); pushQueue(); }, 50); - } - - const validIdx = (i) => Number.isInteger(i) && i > queueIndex && i < queue.length; - - function runCommand(m) { - const who = m.from || 'Phone'; - switch (m.cmd) { - case 'toggle': Player.toggle(); break; - case 'play': Player.play(); break; - case 'pause': Player.pause(); break; - case 'next': playNext(); break; - case 'prev': playPrev(); break; - case 'seek': { - const t = Number(m.t); - if (isFinite(t) && t >= 0) Player.seek(t); - break; - } - case 'volume': { - const v = Math.max(0, Math.min(1, Number(m.v))); - if (!isFinite(v)) break; - data.settings.volume = v; - els.volume.value = String(v); - Player.applyVolume(); - syncMuteIcon(); - persist(); - break; - } - case 'jump': { - const i = Number(m.i); - if (Number.isInteger(i) && i >= 0 && i < queue.length) { queueIndex = i - 1; playNext(); } - break; - } - case 'remove': { - const i = Number(m.i); - if (validIdx(i)) { queue.splice(i, 1); renderUpNext(); } - break; - } - case 'move': { - const from = Number(m.from), to = Number(m.to); - if (validIdx(from) && validIdx(to) && from !== to) { - const [v] = queue.splice(from, 1); - queue.splice(to, 0, v); - renderUpNext(); - } - break; - } - case 'add': { - const v = m.video; - if (!v || typeof v.id !== 'string' || !isMediaId(v.id)) break; - const item = slim({ ...v, title: String(v.title || v.id).slice(0, 300), channel: String(v.channel || '').slice(0, 200) }); - if (!current || !queue.length) { playFromList([item], 0, 'remote'); } - else if (!queue.slice(queueIndex + 1).some((x) => x.id === item.id)) { queue.push(item); renderUpNext(); } - toast(`📱 ${who} added “${item.title}”`); - break; - } - case 'sleep': { - const min = Number(m.min) || 0; - if (min > 0) startSleepTimer(Math.min(min, 240)); else cancelSleepTimer(); - break; - } - case 'service': - if (m.on) enterServiceMode(); else exitServiceMode(); - break; - default: return; - } - changed(); - } - - function startHosting() { - const cfg = hostCfg(); - cfg.enabled = true; - save(HOST_KEY, cfg); - hostConnect(); - renderHostModal(); - } - - function stopHosting() { - const cfg = hostCfg(); - cfg.enabled = false; - save(HOST_KEY, cfg); - clearTimeout(host.timer); - const ws = host.ws; - host.ws = null; - host.remotes = []; - try { if (ws) ws.close(); } catch { /* gone */ } - renderChip(); - renderHostModal(); - } - - function unpairAll() { - hostSend({ type: 'unpair-all' }); - const cfg = hostCfg(); - cfg.secret = newSecret(); // every phone's token was derived from the old one - save(HOST_KEY, cfg); - setTimeout(hostConnect, 300); - toast('All phones unpaired'); - } - - function renderChip() { - const chip = $('remoteChip'); - if (!chip) return; - const n = host.remotes.length; - chip.classList.toggle('hidden', !host.ws || !n); - if (n) { - $('remoteChipText').textContent = n === 1 - ? `Remote connected — ${host.remotes[0].name}` - : `${n} remotes connected`; - } - } - - let hostModalOpen = false; - function renderHostModal() { - if (!hostModalOpen) return; - const body = $('modalBody') && $('modalBody').querySelector('.remote-host'); - if (!body) { hostModalOpen = false; return; } - const cfg = hostCfg(); - const live = !!host.ws; - const mins = host.codeExpires ? Math.max(0, Math.round((host.codeExpires - Date.now()) / 60000)) : 0; - body.querySelector('.rh-body').innerHTML = !cfg.enabled ? ` - ` - : host.replaced ? `` - : !live ? `` - : ` -
- QR code to pair a phone -
-
Pairing code
-
${host.code.slice(0, 3)} ${host.code.slice(3)}
-
Valid ${mins} more min · one use
-
On the phone: open this app → 📱 Remote → Use this phone as a remote, or scan the QR code.
-
-
-
Presenter / projector screen
-
- QR code for the presenter screen -
-
Lyrics-only display that follows this screen. On the projector's computer or TV browser open - ${escapeHtml(location.origin)}/?present=${host.code} (same code), or scan the QR.
- - -
-
-
Connected phones & screens
- ${host.remotes.length ? host.remotes.map((r) => ` -
📱 ${escapeHtml(r.name)}
`).join('') - : '
None yet.
'}`; - const nameIn = body.querySelector('.rh-name'); - if (document.activeElement !== nameIn) nameIn.value = cfg.name; - body.querySelector('[data-act="start"]').classList.toggle('hidden', cfg.enabled && !host.replaced); - body.querySelector('[data-act="stop"]').classList.toggle('hidden', !cfg.enabled); - body.querySelector('[data-act="code"]').classList.toggle('hidden', !live); - body.querySelector('[data-act="unpair"]').classList.toggle('hidden', !live); - } - - function openHostModal() { - const body = document.createElement('div'); - body.className = 'remote-host'; - body.innerHTML = ` -
- -
- - - - -
`; - body.addEventListener('click', (e) => { - const kick = e.target.closest('[data-kick]'); - if (kick) { hostSend({ type: 'kick', rid: kick.dataset.kick }); return; } - const act = e.target.closest('[data-act]'); - if (!act) return; - if (act.dataset.act === 'start') startHosting(); - else if (act.dataset.act === 'stop') stopHosting(); - else if (act.dataset.act === 'code') hostSend({ type: 'new-code' }); - else if (act.dataset.act === 'unpair') unpairAll(); - else if (act.dataset.act === 'obs') { navigator.clipboard.writeText(LowerThird.url(location.origin, host.code)).then(() => toast('OBS URL copied'), () => toast('Copy blocked')); } - else if (act.dataset.act === 'present') { - // A second window on this computer (drag it to the projector display). - window.open(`/?present=${host.code}`, 'ytp-presenter', 'popup,width=1280,height=720'); - } - }); - body.querySelector('.rh-name').addEventListener('change', (e) => { - const cfg = hostCfg(); - cfg.name = e.target.value.trim().slice(0, 40) || guessName('Desktop'); - save(HOST_KEY, cfg); - if (cfg.enabled) hostConnect(); // the server learns names on connect - }); - showModal('📱 Control this screen from a phone', body, [ - { label: 'Done', primary: true, onClick: () => { hostModalOpen = false; closeModal(); } }, - ]); - hostModalOpen = true; - renderHostModal(); - } - - // ========================================================================== - // Remote (this phone drives a host) - // ========================================================================== - const rc = { ws: null, open: false, state: null, stateAt: 0, queue: { items: [], idx: -1 }, retry: null, ping: null, seekTimer: null, latency: null, online: false, seeking: false, volTimer: null, drag: null }; - - const rcSend = (m) => { if (rc.ws && rc.ws.readyState === 1) rc.ws.send(JSON.stringify(m)); }; - const cmd = (name, args = {}) => rcSend({ type: 'cmd', cmd: name, ...args }); - - function setStatus(text, ok = false) { - $('rvStatus').textContent = text; - $('rvStatus').classList.toggle('ok', ok); - } - - function showPairForm(msg = '') { - $('rvPair').classList.remove('hidden'); - $('rvControl').classList.add('hidden'); - $('rvHostName').textContent = 'Remote'; - setStatus('Not paired'); - $('rvPairMsg').textContent = msg; - if (!$('rvName').value) $('rvName').value = (load(PAIR_KEY) || {}).name || guessName('Phone'); - } - - function rcConnect() { - const p = load(PAIR_KEY); - if (!p || !rc.open) return; - clearTimeout(rc.retry); - try { if (rc.ws) rc.ws.close(); } catch { /* gone */ } - $('rvPair').classList.add('hidden'); - $('rvControl').classList.remove('hidden'); - $('rvHostName').textContent = p.hostName || 'Screen'; - setStatus('Connecting…'); - const ws = new WebSocket(wsUrl({ role: 'remote', room: p.roomId, rid: p.remoteId, token: p.token, name: p.name || guessName('Phone') })); - rc.ws = ws; - ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onRemoteMessage(m); }; - ws.onclose = (e) => { - if (rc.ws !== ws) return; - rc.ws = null; - rc.online = false; - clearInterval(rc.ping); - if (e.code === 4001) { save(PAIR_KEY, null); showPairForm('This phone was unpaired by the screen — pair again.'); return; } - if (!rc.open) return; - setStatus(e.code === 4004 ? 'Screen offline — retrying…' : 'Reconnecting…'); - rc.retry = setTimeout(rcConnect, 4000); - }; - } - - function onRemoteMessage(m) { - DirectMedia.attach('remote-client', { send: rcSend, peers: () => [{ id: 'host', name: 'Paired screen', files: rc.state?.directAvailable && rc.state?.v ? [rc.state.v.id] : [] }] }); - if (m.type === 'direct') { DirectMedia.handle('remote-client', m); return; } - if (m.type === 'hello') { - rc.online = true; - $('rvHostName').textContent = m.hostName || 'Screen'; - if (m.state) { rc.state = m.state; rc.stateAt = Date.now(); } - if (m.queue) rc.queue = m.queue; - setStatus('Synced', true); - clearInterval(rc.ping); - const ping = () => rcSend({ type: 'ping', t: Date.now() }); - ping(); - rc.ping = setInterval(ping, 5000); - renderRemote(); renderQueue(); - } else if (m.type === 'state') { - rc.state = m.state; rc.stateAt = Date.now(); - renderRemote(); - } else if (m.type === 'queue') { - rc.queue = m.queue || { items: [], idx: -1 }; - renderQueue(); - } else if (m.type === 'pong') { - rc.latency = Date.now() - m.t; - if (rc.online) setStatus(`Synced · ${rc.latency} ms`, true); - } else if (m.type === 'host-offline') { - rc.online = false; - setStatus('Screen offline — waiting…'); - } - } - - function liveCur() { - const s = rc.state; - if (!s) return 0; - const cur = s.paused ? s.cur : s.cur + (Date.now() - rc.stateAt) / 1000; - return Math.min(cur, s.dur || cur); - } - - function renderRemote() { - const s = rc.state; - const v = s && s.v; - $('rvTitle').textContent = v ? v.title : 'Nothing playing'; - $('rvChannel').textContent = v ? v.channel : ''; - let directPlay = $('rvDirectPlay'); - if (!directPlay) { directPlay = document.createElement('button'); directPlay.id = 'rvDirectPlay'; directPlay.className = 'np-btn'; directPlay.style.minHeight = '44px'; directPlay.textContent = 'Play here from paired screen'; $('rvChannel').after(directPlay); } - directPlay.hidden = !v || !s.directAvailable || !DirectMedia.enabled(); - directPlay.onclick = () => v && playVideoAt({ ...v, duration: s.dur || 0 }, liveCur()); - - const thumb = $('rvThumb'); - if (v && v.thumbnail) { if (thumb.getAttribute('src') !== v.thumbnail) thumb.src = v.thumbnail; thumb.classList.remove('hidden'); } - else thumb.classList.add('hidden'); - $('rvToggle').textContent = s && !s.paused ? '⏸' : '▶'; - $('rvDur').textContent = clock((s && s.dur) || 0); - if (!rc.volTimer && document.activeElement !== $('rvVolume')) { - const pct = Math.round(((s && s.volume) ?? 1) * 100); - $('rvVolume').value = String(pct); - $('rvVolVal').textContent = pct + '%'; - } - const sleepMin = s && s.sleep ? Math.ceil(s.sleep / 60) : 0; - $('rvSleep').textContent = sleepMin ? `⏱ ${sleepMin} min` : '⏱ Sleep'; - $('rvSleep').classList.toggle('on', !!sleepMin); - $('rvService').classList.toggle('on', !!(s && s.service)); - renderSeek(); - } - - function renderSeek() { - if (rc.seeking || !rc.open) return; - const s = rc.state; - const cur = liveCur(); - $('rvCur').textContent = clock(cur); - $('rvSeek').value = s && s.dur ? String(Math.round((cur / s.dur) * 1000)) : '0'; - } - - function renderQueue() { - const q = rc.queue || { items: [], idx: -1 }; - const up = q.items.map((v, i) => ({ ...v, i })).filter((v) => v.i > q.idx); - $('rvQueueCount').textContent = up.length ? `${up.length} song${up.length === 1 ? '' : 's'}` : ''; - const list = $('rvQueue'); - list.innerHTML = up.length ? up.map((v) => ` -
- ⠿ -
-
${escapeHtml(v.title)}
-
${escapeHtml(v.channel)}
-
- ${v.duration ? clock(v.duration) : ''} - -
`).join('') : '
Nothing queued after this song.
'; - } - - // ---- drag to reorder (pointer events, works for touch + mouse) ---- - function onDragStart(e) { - const handle = e.target.closest('.rv-handle'); - if (!handle) return; - const row = handle.closest('.rv-row'); - e.preventDefault(); - const rows = [...$('rvQueue').querySelectorAll('.rv-row')]; - rc.drag = { row, rows, startY: e.clientY, from: Number(row.dataset.i), to: Number(row.dataset.i), h: row.offsetHeight }; - row.classList.add('dragging'); - row.setPointerCapture(e.pointerId); - row.addEventListener('pointermove', onDragMove); - row.addEventListener('pointerup', onDragEnd, { once: true }); - row.addEventListener('pointercancel', onDragEnd, { once: true }); - } - function onDragMove(e) { - const d = rc.drag; - if (!d) return; - const dy = e.clientY - d.startY; - d.row.style.transform = `translateY(${dy}px)`; - const pos = d.rows.indexOf(d.row); - const target = Math.max(0, Math.min(d.rows.length - 1, pos + Math.round(dy / d.h))); - d.to = Number(d.rows[target].dataset.i); - } - function onDragEnd() { - const d = rc.drag; - rc.drag = null; - if (!d) return; - d.row.removeEventListener('pointermove', onDragMove); - d.row.classList.remove('dragging'); - d.row.style.transform = ''; - if (d.to !== d.from) { - // Optimistic: reorder locally now, the host's queue push confirms it. - const items = rc.queue.items.slice(); - const [v] = items.splice(d.from, 1); - items.splice(d.to, 0, v); - rc.queue = { ...rc.queue, items }; - renderQueue(); - cmd('move', { from: d.from, to: d.to }); - } - } - - // ---- add to queue: search sheet ---- - function openAddSheet() { - const body = document.createElement('div'); - body.className = 'rv-add'; - body.innerHTML = ` -
-
`; - const results = body.querySelector('.rv-add-results'); - let found = []; - body.querySelector('form').addEventListener('submit', async (e) => { - e.preventDefault(); - const q = body.querySelector('.rv-add-q').value.trim(); - if (!q) return; - results.innerHTML = '
Searching…
'; - try { - const res = await API.search(q); - found = (res && res.ok && res.results) || []; - } catch { found = []; } - results.innerHTML = found.length ? found.slice(0, 20).map((v, i) => ` - `).join('') : '
No results.
'; - }); - results.addEventListener('click', (e) => { - const b = e.target.closest('[data-k]'); - if (!b) return; - const v = found[Number(b.dataset.k)]; - if (!v) return; - cmd('add', { video: { id: v.id, title: v.title, channel: v.channel, duration: v.duration, thumbnail: v.thumbnail } }); - b.classList.add('added'); - toast(`Added “${v.title}”`); - }); - showModal('+ Add to the screen’s queue', body, [{ label: 'Done', primary: true, onClick: closeModal }]); - setTimeout(() => body.querySelector('.rv-add-q').focus(), 50); - } - - async function doPair(codeRaw) { - const code = String(codeRaw || '').replace(/\D/g, ''); - const name = $('rvName').value.trim() || guessName('Phone'); - if (code.length !== 6) { $('rvPairMsg').textContent = 'Enter the 6-digit code from the screen.'; return; } - $('rvPairMsg').textContent = 'Pairing…'; - try { - const res = await fetch('/api/remote/pair', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name }), - }); - const j = await res.json().catch(() => null); - if (!j || !j.ok) { $('rvPairMsg').textContent = '⚠ ' + ((j && j.error) || `HTTP ${res.status}`); return; } - save(PAIR_KEY, { roomId: j.roomId, remoteId: j.remoteId, token: j.token, hostName: j.hostName, name: j.name }); - $('rvCode').value = ''; - rcConnect(); - } catch { - $('rvPairMsg').textContent = '⚠ Network error — try again'; - } - } - - function openRemoteView({ code = '' } = {}) { - rc.open = true; - rc.seekTimer = VisibleTimer.setIntervalWhenVisible(renderSeek, 500); - // This phone is the remote now — don't also play here. - try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ } - $('remoteView').classList.remove('hidden'); - document.body.classList.add('remote-open'); - if (code) { save(PAIR_KEY, null); showPairForm(); $('rvCode').value = code; doPair(code); return; } - if (load(PAIR_KEY)) rcConnect(); else showPairForm(); - } - - function closeRemoteView() { - rc.open = false; - rc.seekTimer?.clear(); - rc.seekTimer = null; - clearTimeout(rc.retry); - clearInterval(rc.ping); - const ws = rc.ws; - rc.ws = null; - try { if (ws) ws.close(); } catch { /* gone */ } - $('remoteView').classList.add('hidden'); - document.body.classList.remove('remote-open'); - } - - function openChooser() { - const hosting = hostCfg().enabled; - const paired = load(PAIR_KEY); - const body = document.createElement('div'); - body.innerHTML = ``; - showModal('📱 Remote', body, [ - { label: paired ? `Control “${paired.hostName}”` : 'Use this phone as a remote', onClick: () => { closeModal(); openRemoteView(); } }, - { label: hosting ? 'This screen: remote on' : 'Control this screen from a phone', primary: true, onClick: () => { closeModal(); openHostModal(); } }, - ]); - } - - function bindUi() { - $('navRemoteBtn').addEventListener('click', () => { - if (typeof closeSidebar === 'function') { try { closeSidebar(); } catch { /* not open */ } } - openChooser(); - }); - $('remoteChip').addEventListener('click', openHostModal); - $('rvClose').addEventListener('click', closeRemoteView); - $('rvMenu').addEventListener('click', () => { - const p = load(PAIR_KEY); - showModal('Remote options', document.createTextNode(p ? `Paired with “${p.hostName}”.` : 'Not paired.'), [ - { label: 'Close', onClick: closeModal }, - ...(p ? [{ label: 'Unpair this phone', danger: true, onClick: () => { closeModal(); save(PAIR_KEY, null); try { rc.ws && rc.ws.close(); } catch { /* gone */ } showPairForm('Unpaired.'); } }] : []), - ]); - }); - $('rvPairBtn').addEventListener('click', () => doPair($('rvCode').value)); - $('rvCode').addEventListener('keydown', (e) => { if (e.key === 'Enter') doPair($('rvCode').value); }); - $('rvToggle').addEventListener('click', () => { - cmd('toggle'); - if (rc.state) { rc.state.cur = liveCur(); rc.stateAt = Date.now(); rc.state.paused = !rc.state.paused; renderRemote(); } - }); - $('rvPrev').addEventListener('click', () => cmd('prev')); - $('rvNext').addEventListener('click', () => cmd('next')); - const seek = $('rvSeek'); - seek.addEventListener('input', () => { - rc.seeking = true; - const dur = (rc.state && rc.state.dur) || 0; - $('rvCur').textContent = clock((Number(seek.value) / 1000) * dur); - }); - seek.addEventListener('change', () => { - const dur = (rc.state && rc.state.dur) || 0; - const t = (Number(seek.value) / 1000) * dur; - cmd('seek', { t }); - if (rc.state) { rc.state.cur = t; rc.stateAt = Date.now(); } - rc.seeking = false; - }); - $('rvVolume').addEventListener('input', (e) => { - const pct = Number(e.target.value); - $('rvVolVal').textContent = pct + '%'; - clearTimeout(rc.volTimer); - rc.volTimer = setTimeout(() => { rc.volTimer = null; cmd('volume', { v: pct / 100 }); }, 120); - }); - $('rvQueue').addEventListener('pointerdown', onDragStart); - $('rvQueue').addEventListener('click', (e) => { - const rm = e.target.closest('[data-rm]'); - if (rm) { cmd('remove', { i: Number(rm.dataset.rm) }); return; } - if (e.target.closest('.rv-handle')) return; - const row = e.target.closest('.rv-row'); - if (row) cmd('jump', { i: Number(row.dataset.i) }); - }); - $('rvAdd').addEventListener('click', openAddSheet); - $('rvSleep').addEventListener('click', () => { - const options = [0, 15, 30, 45, 60, 90]; - const curMin = rc.state && rc.state.sleep ? Math.ceil(rc.state.sleep / 60) : 0; - const next = options.find((o) => o > curMin) ?? 0; - cmd('sleep', { min: next }); - toast(next ? `Screen pauses in ${next} min` : 'Sleep timer off'); - }); - $('rvService').addEventListener('click', () => cmd('service', { on: !(rc.state && rc.state.service) })); - // Hosts keep phones current once a second while anything plays. - setInterval(() => { if (host.ws && host.remotes.length) { pushState(); pushQueue(); } }, 1000); - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible' && rc.open && !rc.ws) rcConnect(); - }); - } - - function boot() { - bindUi(); - if (hostCfg().enabled) hostConnect(); - // A scanned QR / shared link: /?pair=482913 - let code = ''; - try { code = (new URLSearchParams(location.search).get('pair') || '').replace(/\D/g, ''); } catch { /* no URL */ } - if (code) { - try { - const u = new URL(location.href); - u.searchParams.delete('pair'); - history.replaceState(null, '', u.pathname + u.search + u.hash); - } catch { /* leave the URL */ } - openRemoteView({ code }); - } - } - - return { boot, changed }; -})(); // ============================================================================ // Playlist transitions: normal · gapless · crossfade @@ -5808,90 +5126,7 @@ const CatalogSync = (() => { })(); function collectSearchMetadata(q, cards) { if (WEB) CatalogSync.send(q, cards); } -const Recommendations = (() => { - let cards = [], fetchedAt = 0, loading = false, error = false, actor = '', queuedRefresh = false; - let listHost = null; - const deviceFingerprint = () => window.getFingerprint ? window.getFingerprint() : 'unknown'; - const fingerprint = () => data.profile?.name ? 'profile:' + data.profile.name.toLowerCase() : 'device:' + deviceFingerprint(); - function load() { - const fp = fingerprint(); - if (fp === actor) return; - actor = fp; cards = []; fetchedAt = 0; - try { - const saved = JSON.parse(localStorage.getItem('ytpRecommendations:' + fp) || 'null'); - if (saved && Array.isArray(saved.cards)) cards = saved.cards; - } catch { /* no saved recommendations */ } - } - function paint(host) { - if (!host) return; - host.replaceChildren(); - const heading = document.createElement('div'); heading.className = 'rec-heading'; - const copy = document.createElement('div'); - const title = document.createElement('h2'); title.textContent = 'Recommended for you'; - const sub = document.createElement('p'); sub.textContent = error && cards.length ? 'Saved picks · reconnect to refresh' : 'Familiar favorites and something new, inspired by your listening.'; - copy.append(title, sub); - const refresh = document.createElement('button'); refresh.type = 'button'; refresh.className = 'rec-refresh'; - refresh.textContent = loading ? 'Updating…' : 'Refresh'; refresh.disabled = loading; - refresh.onclick = () => update(true); - heading.append(copy, refresh); host.appendChild(heading); - if (!cards.length) { - const empty = document.createElement('p'); empty.className = 'rec-empty'; empty.setAttribute('role', 'status'); - empty.textContent = loading ? 'Finding your next listen…' : error ? 'Recommendations are unavailable. You can still search or play your playlists.' : 'Search or play a few videos to start discovering recommendations here.'; - host.appendChild(empty); return; - } - const grid = document.createElement('div'); grid.className = 'rec-grid'; - cards.forEach((v, i) => { - const item = document.createElement('article'); item.className = 'rec-item'; - const play = document.createElement('button'); play.type = 'button'; play.className = 'rec-play'; - play.setAttribute('aria-label', 'Play ' + v.title); - const cover = document.createElement('span'); cover.className = 'rec-cover'; - const img = document.createElement('img'); img.src = v.thumbnail || ''; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; cover.appendChild(img); - const title = document.createElement('span'); title.className = 'rec-title'; title.textContent = v.title; - const channel = document.createElement('span'); channel.className = 'rec-channel'; channel.textContent = v.channel || ''; - play.append(cover, title, channel); play.onclick = () => { setHomeOverPlayer(false); playFromList(cards.slice(), i, 'recommendations'); }; - const reason = document.createElement('div'); reason.className = 'rec-reason'; reason.textContent = v.reason || 'Recommended for you'; - const actions = document.createElement('div'); actions.className = 'rec-actions'; - const queue = document.createElement('button'); queue.type = 'button'; queue.textContent = '+ Queue'; queue.setAttribute('aria-label', 'Queue ' + v.title); queue.onclick = () => addToQueue(v); - const more = document.createElement('button'); more.type = 'button'; more.textContent = '•••'; more.setAttribute('aria-label', 'More actions for ' + v.title); more.onclick = () => openCardMenu(v); - actions.append(queue, more); item.append(play, reason, actions); grid.appendChild(item); - }); - host.appendChild(grid); - } - function repaint() { - if (!WEB) return; - const home = $('homeRecommendations'); - if (home) { home.classList.remove('hidden'); paint(home); } - els.playerPane?.classList.add('has-recommendations'); - if (listHost?.isConnected) paint(listHost); - requestAnimationFrame(updateBottomChrome); - } - async function update(force = false) { - load(); - if (!WEB) return; - if (loading) { if (force) queuedRefresh = true; return; } - if (!force && Date.now() - fetchedAt < 300000) return; - const fp = actor; loading = true; error = false; repaint(); - try { - const name = data.profile?.name || ''; - const result = await webFetch('/api/recommendations?fp=' + encodeURIComponent(deviceFingerprint()) + (name ? '&profile=' + encodeURIComponent(name) : ''), { headers: name ? profileHeaders(name) : {} }); - if (fp !== fingerprint()) return; - if (!result.ok || !Array.isArray(result.results)) throw new Error('invalid recommendations'); - cards = result.results; - try { localStorage.setItem('ytpRecommendations:' + fp, JSON.stringify({ cards })); } catch { /* full */ } - } catch { error = true; } - finally { - loading = false; fetchedAt = fp === fingerprint() ? Date.now() : 0; repaint(); - if (queuedRefresh || fp !== fingerprint()) { queuedRefresh = false; update(true); } - } - } - function home() { if (WEB) { load(); repaint(); update(); } } - function inList() { - if (!WEB) return; - listHost = document.createElement('section'); listHost.className = 'recommendations'; listHost.setAttribute('aria-label', 'Recommended videos'); - els.cards.appendChild(listHost); load(); paint(listHost); update(); - } - return { home, inList, update }; -})(); + // ============================================================================ // Listening stats — tracker (data.stats, see frontend/stats-core.js) @@ -5910,7 +5145,8 @@ const StatsTrack = (() => { } function tick(cur) { const meta = current && current.meta; - if (!meta || !meta.id || !window.StatsCore) return; + if (!meta || !meta.id) return; + if (!window.StatsCore) { Lazy.load('feature:stats').catch(() => {}); return; } if (meta.id !== st.id) { commit(false); st.id = meta.id; @@ -5942,127 +5178,10 @@ const StatsTrack = (() => { // Stats view // ============================================================================ function renderStats() { - StatsTrack.flush(); - els.listTitle.textContent = 'Stats'; - resetListHeaderExtras(); - els.listActions.innerHTML = ''; - els.status.classList.add('hidden'); - const c = els.cards; - const S = StatsCore.summarize(data.stats); - if (!S.totalPlays && S.totalSec < 60) { - c.innerHTML = ` -
-
📊
-

No listening stats yet

-

Stats start counting now — a song counts as played after 30 seconds. They sync with your online profile.

-
`; - return; - } - const hrs = (sec) => StatsCore.fmtHours(sec); - const delta = S.thisMonthSec - S.lastMonthSec; - const deltaTxt = S.lastMonthSec || S.thisMonthSec - ? `${delta >= 0 ? '▲ +' : '▼ −'}${(Math.abs(delta) / 3600).toFixed(1)}h vs last month` - : ''; - const maxArtist = Math.max(1, ...S.topArtists.map((a) => a.plays)); - const spark = (vals) => { - const w = 96, h = 26, max = Math.max(1, ...vals); - const pts = vals.map((v, i) => `${(i / (vals.length - 1)) * w},${h - 3 - (v / max) * (h - 6)}`).join(' '); - return ``; - }; - const heatMax = Math.max(1, ...S.heat.flat().map((x) => x.minutes)); - const level = (m) => (m <= 0 ? 0 : Math.min(4, 1 + Math.floor((m / heatMax) * 3.999))); - const fmtDay = (k) => new Date(`${k}T12:00:00`).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); - // Month label over the first week column of each month. - const months = S.heat.map((col, w) => { - const d = new Date(`${col[0].date}T12:00:00`); - const prev = w ? new Date(`${S.heat[w - 1][0].date}T12:00:00`) : null; - return !prev || prev.getMonth() !== d.getMonth() ? d.toLocaleDateString(undefined, { month: 'short' }) : ''; - }); - const playlistsWith = data.playlists.length; - - c.innerHTML = ` -
-
-
-
⏱ Listening this month
-
${hrs(S.thisMonthSec)}
-
${escapeHtml(deltaTxt)}${deltaTxt ? ' · ' : ''}${hrs(S.totalSec)} all time
-
-
-
▶ Track plays
-
${S.totalPlays}
-
${S.songsPlayed} song${S.songsPlayed === 1 ? '' : 's'} · ${playlistsWith} playlist${playlistsWith === 1 ? '' : 's'}
-
-
-
🔥 Practice streak
-
${S.streak.current}-day
-
${S.streak.current ? 'Streak active' : 'Listen 5 min today to start one'} · best ${S.streak.best} day${S.streak.best === 1 ? '' : 's'}
-
-
- -
-
-
🎤 Top artistsplays this month
- ${S.topArtists.length ? S.topArtists.map((a) => ` -
- ${escapeHtml(a.name)} - - ${a.plays} -
`).join('') : '
No plays this month yet.
'} -
-
-
🎵 Top songslast 30 days
- ${S.topSongs.length ? S.topSongs.map((t, i) => ` -
- ${String(i + 1).padStart(2, '0')} - ${escapeHtml(t.title)}${escapeHtml(t.channel)} - ${spark(t.spark)} - ${t.plays} -
`).join('') : '
No plays in the last 30 days.
'} -
-
- -
-
🗓 Listening habitslast 16 weeks${S.peak ? ` · peak day: ${S.peak.day}s (avg ${S.peak.avgHours.toFixed(1)} h)` : ''}
-
-
MonWedFriSun
-
-
${months.map((m) => `${m}`).join('')}
-
${S.heat.map((col) => col.map((x) => x.future - ? '' - : ``).join('')).join('')}
-
-
-
Less${[0, 1, 2, 3, 4].map((l) => ``).join('')}More
-
Show as table - - ${S.heat.flat().filter((x) => !x.future && x.minutes > 0).reverse().map((x) => ``).join('') || ''} -
DayMinutes
${fmtDay(x.date)}${x.minutes}
No listening in this window.
-
-
- -
`; - - // One tooltip for every mark (bars, songs, heat cells). - const root = c.querySelector('.stats'); - const tip = $('statsTip'); - root.addEventListener('pointermove', (e) => { - const el = e.target.closest('[data-tip]'); - if (!el) { tip.classList.add('hidden'); return; } - tip.textContent = el.dataset.tip; - tip.classList.remove('hidden'); - const r = root.getBoundingClientRect(); - const half = tip.offsetWidth / 2 + 4; // keep the whole tip inside the panel - tip.style.left = `${Math.min(r.width - half, Math.max(half, e.clientX - r.left))}px`; - tip.style.top = `${e.clientY - r.top - 14}px`; - }); - root.addEventListener('pointerleave', () => tip.classList.add('hidden')); - root.querySelectorAll('.st-song').forEach((row) => row.addEventListener('click', () => { - const v = data.history.find((x) => x.id === row.dataset.id) || { id: row.dataset.id, title: row.querySelector('b').textContent }; - Player.loadVideo(v); - })); + return Lazy.proxy('feature:stats-view', ['render'], 'StatsView').render().catch(error => toast(error.message)); } + // ============================================================================ // Presenter / projector view (/?present=) // ============================================================================ @@ -6071,215 +5190,14 @@ function renderStats() { // like a phone, but only listens: the host's state messages carry the song id, // position, speed and lyric offset, and this view extrapolates the position // between updates. Lyrics come from the shared /api/notes store. -const Presenter = (() => { - const noop = { boot() {}, active: () => false }; - const view = $('presenterView'); - if (!WEB || !view || typeof WebSocket === 'undefined') return noop; - const overlay = LowerThird.active(); - const KEY = overlay ? `ytpOverlayPair:${new URLSearchParams(location.search).get('code') || ''}` : 'ytpPresenterPair'; - const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } }; - const save = (v) => { try { if (v) localStorage.setItem(KEY, JSON.stringify(v)); else localStorage.removeItem(KEY); } catch { /* blocked */ } }; - const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/remote?${new URLSearchParams(q)}`; - const list = $('pvList'); - const st = { ws: null, state: null, stateAt: 0, hostName: '', vid: null, all: [], docOff: 0, idx: -2, retry: null, notes: new Map(), wake: null, idleTimer: null, seq: 0 }; +const Presenter = { + async boot() { + if (!new URLSearchParams(location.search).has('present') && !LowerThird.active()) return; + return Lazy.proxy('feature:presenter', ['boot'], 'Presenter').boot(); + }, + active: () => !document.getElementById('presenterView')?.classList.contains('hidden'), +}; - function setStatus(t) { $('pvStatus').textContent = t; } - - function show() { - view.classList.remove('hidden'); - document.body.classList.add('presenter-open'); - // The app underneath may have focused its search box; keys belong here now. - try { if (document.activeElement && !view.contains(document.activeElement)) document.activeElement.blur(); } catch { /* fine */ } - try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ } - keepAwake(); - bumpIdle(); - } - - async function keepAwake() { - try { if ('wakeLock' in navigator && !st.wake) { st.wake = await navigator.wakeLock.request('screen'); st.wake.addEventListener('release', () => { st.wake = null; }); } } catch { /* not allowed */ } - } - - function showPairForm(msg = '') { - $('pvPair').classList.remove('hidden'); - $('pvMsg').textContent = msg; - setStatus('Not paired'); - } - - async function pair(code) { - code = String(code || '').replace(/\D/g, ''); - if (code.length !== 6) { showPairForm('Enter the 6-digit code from the playing screen.'); return; } - $('pvMsg').textContent = 'Connecting…'; - try { - const res = await fetch('/api/remote/pair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name: '🖥 Presenter' }) }); - const j = await res.json().catch(() => null); - if (!j || !j.ok) { showPairForm('⚠ ' + ((j && j.error) || `HTTP ${res.status}`)); return; } - save({ roomId: j.roomId, remoteId: j.remoteId, token: j.token, hostName: j.hostName }); - $('pvPair').classList.add('hidden'); - connect(); - } catch { - showPairForm('⚠ Network error — try again'); - } - } - - function connect() { - const p = load(); - if (!p) { showPairForm(); return; } - clearTimeout(st.retry); - try { if (st.ws) st.ws.close(); } catch { /* gone */ } - setStatus('Connecting…'); - const ws = new WebSocket(wsUrl({ role: 'remote', room: p.roomId, rid: p.remoteId, token: p.token, name: '🖥 Presenter' })); - st.ws = ws; - ws.onmessage = (e) => { - let m; try { m = JSON.parse(e.data); } catch { return; } - if (!overlay) { DirectMedia.attach('presenter', { send: m => ws.send(JSON.stringify(m)), peers: () => [{ id: 'host', name: st.hostName || 'Screen' }] }); } - if (m.type === 'direct') { if (!overlay) DirectMedia.handle('presenter', m); return; } - if (m.type === 'hello') { st.hostName = m.hostName || 'screen'; if (m.state) onState(m.state); else setStatus(`Following ${st.hostName}`); } - else if (m.type === 'state') onState(m.state); - else if (m.type === 'host-offline') { if (overlay) { st.all = []; LowerThird.paint([], -1); } setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); } - }; - ws.onclose = (e) => { - if (st.ws !== ws) return; - st.ws = null; - if (e.code === 4001) { save(null); showPairForm('This presenter was unpaired — enter a new code.'); return; } - setStatus(e.code === 4004 ? 'Screen offline — retrying…' : 'Reconnecting…'); - st.retry = setTimeout(connect, 3000); - }; - } - - function onState(s) { - st.state = s; - st.stateAt = Date.now(); - const v = s && s.v; - setStatus(v ? `● Following ${st.hostName} · ${v.title}` : `● Following ${st.hostName} · nothing playing`); - const id = v && v.id; - if (id !== st.vid) loadSong(id, v); - } - - async function loadSong(id, v) { - const seq = ++st.seq; - st.vid = id; - st.all = []; - st.docOff = 0; - render(v); - if (!id || !isMediaId(id)) return; - let notes = st.notes.get(id); - if (!notes) { - try { - const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}`)).json(); - notes = j && j.ok ? j : null; - if (notes) { st.notes.set(id, notes); if (st.notes.size > 50) st.notes.delete(st.notes.keys().next().value); } - } catch { notes = null; } - } - if (seq !== st.seq) return; - const doc = notes && notes.lyrics && notes.lyrics.data; - st.all = (doc && doc.lines) || []; - st.docOff = (doc && Number(doc.offset)) || 0; - render(v); - } - - function render(v) { - if (overlay) { LowerThird.paint([], -1); st.idx = -2; tick(true); return; } - const sung = st.all.map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line'); - const title = $('pvTitle'); - if (!sung.length) { - list.innerHTML = ''; - title.innerHTML = v ? `${escapeHtml(v.title)}${v.channel ? `${escapeHtml(v.channel)}` : ''}` : ''; - title.classList.toggle('hidden', !v); - return; - } - title.classList.add('hidden'); - const pad = Math.round(list.clientHeight / 2); - list.style.paddingTop = pad + 'px'; - list.style.paddingBottom = pad + 'px'; - list.innerHTML = sung.map((l) => `
${LyricsDisplay.html(l)}
`).join(''); - refit(); - st.idx = -2; - tick(true); - setTimeout(refit, 900); // once more after fonts/layout settle - } - - function refit() { - fitLyricLines(list, { maxShare: 0.2, maxPx: 240 }); - } - - function liveTime() { - const s = st.state; - if (!s) return 0; - return s.paused ? s.cur : s.cur + ((Date.now() - st.stateAt) / 1000) * (s.rate || 1); - } - - function tick(force = false) { - if (view.classList.contains('hidden') || !st.all.length || !st.state) return; - if (overlay) { LowerThird.paint(st.all, LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0))); return; } - const idx = LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0)); - if (idx === st.idx && !force) return; - st.idx = idx; - const rows = [...list.querySelectorAll('.sl-line')]; - const pos = rows.findIndex((el) => Number(el.dataset.i) === idx); - rows.forEach((el, k) => { - const d = pos < 0 ? 99 : Math.abs(k - pos); - el.classList.toggle('active', d === 0); - el.classList.toggle('near', d === 1); - el.classList.toggle('past', pos >= 0 && k < pos); - }); - const target = pos >= 0 ? rows[pos] : rows[0]; - if (target) { - const go = () => list.scrollTo({ top: Math.max(0, target.offsetTop - list.clientHeight / 2 + target.offsetHeight / 2), behavior: 'smooth' }); - go(); - setTimeout(go, 380); - } - } - - function toggleFullscreen() { - if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); - else view.requestFullscreen?.().catch(() => {}); - } - - // The status bar and cursor hide after 3 s without mouse movement. - function bumpIdle() { - view.classList.remove('idle'); - clearTimeout(st.idleTimer); - st.idleTimer = setTimeout(() => view.classList.add('idle'), 3000); - } - - function boot() { - let param = null; - try { param = new URLSearchParams(location.search).get(overlay ? 'code' : 'present'); } catch { /* no URL */ } - if (param === null) return; - try { - const u = new URL(location.href); - u.searchParams.delete('present'); - history.replaceState(null, '', u.pathname + (u.search || '') + u.hash); - } catch { /* keep */ } - show(); - const code = String(param).replace(/\D/g, ''); - if (overlay && load()) connect(); - else if (code.length === 6) pair(code); - else if (load()) connect(); - else showPairForm(); - setInterval(() => tick(), 200); - window.addEventListener('resize', () => { refit(); tick(true); }); - if (document.fonts) { - document.fonts.ready.then(refit); - document.fonts.addEventListener('loadingdone', refit); - } - view.addEventListener('mousemove', bumpIdle); - view.addEventListener('dblclick', toggleFullscreen); - $('pvFull').addEventListener('click', toggleFullscreen); - $('pvPairBtn').addEventListener('click', () => pair($('pvCode').value)); - $('pvCode').addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter') pair($('pvCode').value); }); - document.addEventListener('keydown', (e) => { - if (view.classList.contains('hidden') || (e.target.closest && e.target.closest('#pvPair'))) return; - if (e.key === 'f' || e.key === 'F') { e.preventDefault(); e.stopImmediatePropagation(); toggleFullscreen(); } - else if (e.key === 'b' || e.key === 'B') { e.preventDefault(); e.stopImmediatePropagation(); $('pvBlank').classList.toggle('hidden'); } - }, true); - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { keepAwake(); if (!st.ws && load()) connect(); } - }); - } - - return { boot, active: () => !view.classList.contains('hidden') }; -})(); // Open a video and jump to `t` seconds once it can play (deep links, notes). async function playVideoAt(v, t = 0) { @@ -6299,261 +5217,18 @@ async function playVideoAt(v, t = 0) { // ============================================================================ // 📁 Library — video/audio uploaded to this server // ============================================================================ -async function renderLibrary() { - els.listTitle.textContent = 'Library'; - resetListHeaderExtras(); - els.listActions.innerHTML = ''; - els.status.classList.add('hidden'); - els.cards.innerHTML = '
Loading the server library…
'; - let list = []; - try { - const j = await (await fetch('/api/uploads?limit=200')).json(); - list = (j && j.ok && j.uploads) || []; - } catch { - els.cards.innerHTML = '
⚠ Could not reach the server
'; - return; - } - if (view.type !== 'library') return; - if (!list.length) { - els.cards.innerHTML = ` -
-
📁
-

Nothing uploaded yet

-

An admin can upload videos and audio at /admin; they show up here and in search, with their cover art and any embedded lyrics.

-
`; - return; - } - const cards = list.map((u) => ({ - id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', duration: u.duration, - thumbnail: u.art ? `/api/uploads/${u.id}/art` : '', kind: u.kind, upload: true, - })); - els.cards.innerHTML = ''; - cards.forEach((v, i) => els.cards.appendChild(renderCard(v, i, cards))); -} + // ============================================================================ // 📝 Notes view — every video's personal notes & bookmarks // ============================================================================ -function renderNotesView() { - els.listTitle.textContent = 'Notes'; - els.status.classList.add('hidden'); - const groups = Object.entries(data.bookmarks || {}) - .filter(([, list]) => Array.isArray(list) && list.length) - .map(([id, list]) => { - const h = data.history.find((x) => x.id === id); - const first = list.find((b) => b.vt) || {}; - return { id, title: first.vt || (h && h.title) || id, channel: first.vc || (h && h.channel) || '', list }; - }); - // Most recently played first (history order), the rest after. - const order = new Map(data.history.map((v, i) => [v.id, i])); - groups.sort((a, b) => (order.has(a.id) ? order.get(a.id) : 1e9) - (order.has(b.id) ? order.get(b.id) : 1e9)); - els.listActions.innerHTML = groups.length ? '' : ''; - const c = els.cards; - if (!groups.length) { - c.innerHTML = ` -
-
📝
-

No notes yet

-

While a video plays, open Chapters & bookmarks → 📝 Note (or press N) to write a note tied to that exact moment. They sync with your online profile.

-
`; - return; - } - const total = groups.reduce((n, g) => n + g.list.length, 0); - c.innerHTML = ` -
- - ${groups.map((g) => ` -
-
${escapeHtml(g.title)}${escapeHtml(g.channel)} · ${g.list.length}
- ${g.list.map((b) => ` - `).join('')} -
`).join('')} -
`; - c.querySelector('.nv-filter').addEventListener('input', (e) => { - const q = e.target.value.trim().toLowerCase(); - c.querySelectorAll('.nv-group').forEach((g) => { - let any = false; - g.querySelectorAll('.nv-item').forEach((it) => { const hit = !q || it.dataset.q.includes(q); it.classList.toggle('hidden', !hit); any = any || hit; }); - g.classList.toggle('hidden', !any); - }); - }); - c.querySelector('.nv-filter').addEventListener('keydown', (e) => e.stopPropagation()); - c.querySelectorAll('.nv-item').forEach((it) => it.addEventListener('click', () => { - const g = groups.find((x) => x.id === it.closest('.nv-group').dataset.id); - playVideoAt({ id: g.id, title: g.title, channel: g.channel }, Number(it.dataset.t)); - })); - const ex = $('notesExportBtn'); - if (ex) ex.addEventListener('click', () => { - const md = ['# YT Player notes', `_Exported ${new Date().toLocaleString()}_`, '']; - for (const g of groups) { - md.push(`## ${g.title}`, `${g.channel ? g.channel + ' · ' : ''}https://youtu.be/${g.id}`, ''); - for (const b of g.list) { - md.push(`- **[${LyricsCore.fmtClock(b.t)}](${location.origin}/?v=${g.id}&t=${Math.floor(b.t)})** ${b.title}`); - if (b.note && b.note !== b.title) md.push(...b.note.split('\n').map((l) => ` ${l}`)); - } - md.push(''); - } - const blob = new Blob([md.join('\n')], { type: 'text/markdown' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = `ytplayer-notes-${new Date().toISOString().slice(0, 10)}.md`; - a.click(); - setTimeout(() => URL.revokeObjectURL(a.href), 2000); - }); -} + // ============================================================================ // Timestamp sharing — deep links (/?v=ID&t=SEC) and GIFs of a moment // ============================================================================ -const Share = (() => { - const appLink = (id, t) => `${location.origin}/?v=${encodeURIComponent(id)}${t >= 1 ? `&t=${Math.floor(t)}` : ''}`; - const ytLink = (id, t) => `https://youtu.be/${encodeURIComponent(id)}${t >= 1 ? `?t=${Math.floor(t)}` : ''}`; +const Share = Lazy.proxy('feature:share-external', ['open', 'bootFromUrl'], 'Share'); - async function copy(text, label) { - try { await navigator.clipboard.writeText(text); toast(`${label} copied ✓`); } - catch { toast('Copy blocked — long-press the link to copy it'); } - } - - function open() { - const meta = current && current.meta; - if (!meta || !meta.id || meta.custom) { toast('Play a YouTube video first'); return; } - const t0 = Math.floor(Player.master.currentTime || 0); - const body = document.createElement('div'); - body.className = 'share-moment'; - body.innerHTML = ` - - -
-
- - -
-
-
-
-
- - - - -
-
-
`; - const timeIn = body.querySelector('.sm-time'); - const cur = () => { const v = LyricsCore.parseStamp(timeIn.value); return v === null ? t0 : v; }; - const refresh = () => { - body.querySelector('.sm-app').textContent = appLink(meta.id, cur()); - const yt = body.querySelector('.sm-yt'); - if (yt) yt.textContent = ytLink(meta.id, cur()); - }; - timeIn.addEventListener('input', refresh); - timeIn.addEventListener('keydown', (e) => e.stopPropagation()); - refresh(); - // Soundbite range: the A-B loop when one is set, else the next 20 s. - const bStart = abA !== null ? abA : t0; - const bEnd = abB !== null && abB > bStart ? abB : bStart + 20; - body.querySelector('.sm-b-start').value = LyricsCore.fmtClock(bStart); - body.querySelector('.sm-b-end').value = LyricsCore.fmtClock(bEnd); - body.querySelectorAll('.sm-bite input').forEach((i) => i.addEventListener('keydown', (e) => e.stopPropagation())); - let biteBlob = null; - let gifBlob = null; - body.addEventListener('click', async (e) => { - const act = e.target.closest('[data-act]'); - if (!act) return; - if (act.dataset.act === 'app') copy(appLink(meta.id, cur()), 'App link'); - else if (act.dataset.act === 'yt') copy(ytLink(meta.id, cur()), 'YouTube link'); - else if (act.dataset.act === 'gif') { - const out = body.querySelector('.sm-gif-out'); - out.innerHTML = '
Making the GIF…
'; - act.disabled = true; - try { - const d = body.querySelector('.sm-len').value; - const res = await fetch(`/api/media/${encodeURIComponent(meta.id)}/gif?t=${cur()}&d=${d}&w=480`); - if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); } - gifBlob = await res.blob(); - const url = URL.createObjectURL(gifBlob); - const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(cur())}s.gif`; - out.innerHTML = `GIF preview -
⤓ Download - ${navigator.canShare && navigator.canShare({ files: [new File([gifBlob], name, { type: 'image/gif' })] }) ? '' : ''}
`; - out.dataset.name = name; - } catch (err) { - out.innerHTML = `
⚠ ${escapeHtml(err.message)}
`; - } finally { - act.disabled = false; - } - } else if (act.dataset.act === 'bite') { - const out = body.querySelector('.sm-bite-out'); - const a = LyricsCore.parseStamp(body.querySelector('.sm-b-start').value); - const b = LyricsCore.parseStamp(body.querySelector('.sm-b-end').value); - const fmt = body.querySelector('.sm-b-fmt').value; - const max = fmt === 'm4r' ? 40 : 60; - if (a === null || b === null || b <= a) { out.innerHTML = '
⚠ Times must look like 1:23, end after start
'; return; } - if (b - a > max) { out.innerHTML = `
⚠ Max ${max} s for ${fmt === 'm4r' ? 'an iPhone ringtone' : 'a soundbite'}
`; return; } - out.innerHTML = '
Cutting the soundbite…
'; - act.disabled = true; - try { - const res = await fetch(`/api/media/${encodeURIComponent(meta.id)}/clip?start=${a}&end=${b}&fmt=${fmt}`); - if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); } - biteBlob = await res.blob(); - const url = URL.createObjectURL(biteBlob); - const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(a)}s.${fmt}`; - out.dataset.name = name; - out.dataset.type = fmt === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; - out.innerHTML = ` -
⤓ Download - ${navigator.canShare && navigator.canShare({ files: [new File([biteBlob], name, { type: out.dataset.type })] }) ? '' : ''}
- ${fmt === 'm4r' ? '

iPhone: share it to GarageBand (or drag into Finder with the phone connected) to set it as a ringtone.

' : ''}`; - } catch (err) { - out.innerHTML = `
⚠ ${escapeHtml(err.message)}
`; - } finally { - act.disabled = false; - } - } else if (act.dataset.act === 'sharebite' && biteBlob) { - const o = body.querySelector('.sm-bite-out'); - try { await navigator.share({ files: [new File([biteBlob], o.dataset.name, { type: o.dataset.type })], title: meta.title }); } catch { /* dismissed */ } - } else if (act.dataset.act === 'sharegif' && gifBlob) { - const name = body.querySelector('.sm-gif-out').dataset.name; - try { await navigator.share({ files: [new File([gifBlob], name, { type: 'image/gif' })], title: meta.title }); } catch { /* dismissed */ } - } - }); - const actions = [{ label: 'Done', onClick: closeModal }]; - if (navigator.share) { - actions.push({ label: '↗ Share link', primary: true, onClick: async () => { - try { await navigator.share({ title: meta.title, text: `${meta.title} @ ${LyricsCore.fmtClock(cur())}`, url: appLink(meta.id, cur()) }); } catch { /* dismissed */ } - } }); - } else { - actions.push({ label: 'Copy app link', primary: true, onClick: () => copy(appLink(meta.id, cur()), 'App link') }); - } - showModal('🔗 Share this moment', body, actions); - } - - // /?v=&t= — open that video at that moment. - async function bootFromUrl() { - let id = '', t = 0; - try { - const q = new URLSearchParams(location.search); - id = (q.get('v') || '').trim(); - t = Math.max(0, parseFloat(q.get('t')) || 0); - } catch { return; } - if (!isMediaId(id)) return; - try { - const u = new URL(location.href); - u.searchParams.delete('v'); - u.searchParams.delete('t'); - history.replaceState(null, '', u.pathname + u.search + u.hash); - } catch { /* keep */ } - await playVideoAt(data.history.find((x) => x.id === id) || { id, title: '' }, t); - } - - return { open, bootFromUrl }; -})(); // ============================================================================ // Loudness levelling (EBU R128) — every track toward -14 LUFS @@ -6671,8 +5346,8 @@ const EqRender = (() => { const g = EQ.gains(); const lufs = Level.enabled() ? Level.remembered(id) : null; const levelDb = lufs == null ? 0 : Math.round(Level.gainFor(lufs) * 2) / 2; - const vocal = VocalReducer.amount(data.settings.vocalLevel ?? 100); - const width = EqCore.width(data.settings.stereoWidth); + const vocal = EqParameters.amount(data.settings.vocalLevel ?? 100); + const width = EqParameters.width(data.settings.stereoWidth); if (g.every((v) => v === 0) && Math.abs(levelDb) < 0.5 && vocal === 100 && width === 0) return null; return { gains: g, levelDb, vocal, width, sig: g.join(',') + '|' + levelDb + '|vocal:' + vocal + '|width:' + width }; } @@ -6711,7 +5386,7 @@ const EqRender = (() => { function writeViaWorker(name, buffer) { return new Promise((resolve, reject) => { - const w = new Worker('/opfs-worker.js'); + const w = Lazy.worker('/opfs-worker.js'); w.onmessage = (e) => { w.terminate(); (e.data && e.data.type === 'written') ? resolve() : reject(new Error((e.data && e.data.error) || 'write failed')); }; w.onerror = () => { w.terminate(); reject(new Error('worker failed')); }; w.postMessage({ op: 'write', dir: DIR, name, buffer }, [buffer]); @@ -6723,6 +5398,7 @@ const EqRender = (() => { const w = wanted(id); if (!w) { await drop(id); return; } if (readMap()[id] === w.sig) return; + await loadSoundFeatures(); const file = await window.OPFS.getFileObject(id); if (!file) return; const buf = await file.arrayBuffer(); @@ -6846,6 +5522,7 @@ const JamesDSP = (() => { await activate(Player.soundEl); } async function apply() { if (data.settings.jamesDsp?.enabled) await activate(Player.soundEl); } + document.addEventListener('play', (e) => { if (data.settings.jamesDsp?.enabled && e.target === Player.soundEl) activate(e.target).catch(fail); }, true); return { activate, fail, toggle, apply, get controller() { return state.controller; } }; })(); @@ -6856,8 +5533,9 @@ const JamesDSP = (() => { // is chosen (createMediaElementSource can't be undone; afterwards "Flat" // simply zeroes the gains). Disabled on iOS: Safari suspends Web Audio when // the screen locks, which would silence background playback. -const EQ_BANDS = EqCore.bands; -const EQ_PRESETS = EqCore.presets; +const loadSoundFeatures=()=>Promise.all([Lazy.load('feature:eq-core'),Lazy.load('feature:vocal-reducer')]); +const EQ_BANDS = EqParameters.bands; +const EQ_PRESETS = EqParameters.presets; const EQ = (() => { const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); const AC = window.AudioContext || window.webkitAudioContext; @@ -6872,12 +5550,16 @@ const EQ = (() => { const rendered = () => !!(current && current.eqAudioUrl); const gains = () => { const g = data.settings.eq && Array.isArray(data.settings.eq.g) ? data.settings.eq.g : EQ_PRESETS.flat.g; - return EqCore.migrate(g); + return EqParameters.migrate(g); }; const isFlat = () => gains().every((v) => v === 0); function build() { if (st.ctx) return true; + if (!Lazy.loaded('feature:eq-core') || !Lazy.loaded('feature:vocal-reducer')) { + loadSoundFeatures().then(()=>{apply(); Level.apply();}).catch(error=>toast(error.message)); + return false; + } try { st.ctx = new AC(); st.filters = EQ_BANDS.map((b) => { @@ -6915,7 +5597,7 @@ const EQ = (() => { // Apply the stored curve; wires the graph up the first time it's non-flat. function apply() { if (!available()) return; - if (!st.ctx && isFlat() && VocalReducer.amount(data.settings.vocalLevel ?? 100) === 100 && EqCore.width(data.settings.stereoWidth) === 0) return; + if (!st.ctx && isFlat() && EqParameters.amount(data.settings.vocalLevel ?? 100) === 100 && EqParameters.width(data.settings.stereoWidth) === 0) return; if (!build()) return; attach(els.video); attach(els.audio); @@ -6943,7 +5625,8 @@ const EQ = (() => { if (typeof EqRender !== 'undefined') EqRender.scheduleAll(); } - function open() { + async function open() { + try { await loadSoundFeatures(); } catch(error) { toast(error.message); return; } const body = document.createElement('div'); body.className = 'eq-panel'; if (!AC) { @@ -7070,7 +5753,7 @@ const Gestures = (() => { if (x < 1 / 3) seekBy(-10, 'left'); else if (x > 2 / 3) seekBy(10, 'right'); else if (st.zoom > 1.001) { reset(); showHud('
Zoom reset
'); } - else if (current) { Player.toggle(); updatePlayBtn(); showHud(`
${Player.master.paused ? '⏸' : '▶'}
`); } + else if (current) { Player.toggle(); updatePlayBtn(); showHud(`
${TransportIcons.svg(Player.master.paused ? 'pause' : 'play')}
`); } return; } st.lastTap = now; @@ -7167,325 +5850,10 @@ const Gestures = (() => { * A folded card keeps its header, so it stays a landmark you can scroll to * and reopen. Fold state is per device and rides in settings. * ========================================================================== */ -const SectionRail = (() => { - const CLASSIC_ICONS = { - player: '', - meta: '', - notes: '', - related: '', - list: '', - }; - const SECTIONS = [ - { id: 'player', sel: '.player-stage', icon: '📺', label: 'Player' }, - { id: 'meta', sel: '#nowPlayingMeta', icon: '🎵', label: 'Now playing', fold: true }, - { id: 'notes', sel: '#notesPanel', icon: '📝', label: 'Lyrics', fold: true }, - { id: 'related', sel: '#relatedPanel', icon: '🔗', label: 'Related', fold: true }, - { id: 'list', sel: '#listPane', icon: '📋', label: 'List' }, - ]; - const st = { active: '', btns: new Map(), labelTimer: null, raf: 0, bottom: -1 }; - const rail = () => document.getElementById('sectionRail'); - const folds = () => (data.settings.foldedCards ||= {}); - const isFolded = (id) => !!folds()[id]; - const elOf = (s) => document.querySelector(s.sel); - // Only cards that are actually on screen belong in the rail. - const visible = () => SECTIONS.filter((s) => { - const el = elOf(s); - return el && !el.classList.contains('hidden') && el.offsetParent !== null; - }); - function applyFold(sec) { - const el = elOf(sec); - if (!el || !sec.fold) return; - el.classList.toggle('folded', isFolded(sec.id)); - if (sec.id === 'notes') { - const b = document.getElementById('notesFoldBtn'); - if (b) b.setAttribute('aria-expanded', String(!isFolded(sec.id))); - } - // The Related card already had its own +/− button; keep the two in step. - if (sec.id === 'related') { - relatedCollapsed = isFolded(sec.id); - const rl = document.getElementById('relatedList'); - const rb = document.getElementById('relatedToggleBtn'); - if (rl) rl.classList.toggle('hidden', relatedCollapsed); - if (rb) rb.textContent = relatedCollapsed ? '+' : '−'; - } - } - function setFolded(id, on) { - const sec = SECTIONS.find((x) => x.id === id); - if (!sec || !sec.fold) return; - folds()[id] = !!on; - persist(); - applyFold(sec); - paint(); // NOT render() — folding a card must not rebuild the list - } +const External = Lazy.proxy('feature:share-external', ['open', 'm3u', 'intent'], 'External'); - function flashLabel(btn) { - clearTimeout(st.labelTimer); - [...st.btns.values()].forEach((b) => b.classList.remove('show-label')); - btn.classList.add('show-label'); - st.labelTimer = setTimeout(() => btn.classList.remove('show-label'), 1400); - } - - function onTap(sec, btn) { - const el = elOf(sec); - if (!el) return; - flashLabel(btn); - if (sec.id === 'player' || sec.id === 'meta') restoreNowPlayingList(); - if (st.active === sec.id && sec.fold) { - // Already here: this is the second tap — fold or unfold the card. - setFolded(sec.id, !isFolded(sec.id)); - if (isFolded(sec.id)) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - return; - } - st.active = sec.id; // claim it now so the next tap folds - paint(); - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - - function build() { - const r = rail(); - if (!r) return; - const items = visible(); - // A rail with one entry is noise; so is one on the empty home screen. - if (items.length < 2) { r.classList.add('hidden'); r.innerHTML = ''; st.btns.clear(); return; } - const key = items.map((s) => s.id).join(','); - if (r.dataset.key !== key) { - r.dataset.key = key; - r.innerHTML = ''; - st.btns.clear(); - for (const sec of items) { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'sec-rail-btn'; - btn.dataset.sec = sec.id; - btn.setAttribute('aria-label', sec.label); - btn.innerHTML = `${escapeHtml(sec.label)}`; - btn.addEventListener('click', () => onTap(sec, btn)); - r.appendChild(btn); - st.btns.set(sec.id, btn); - } - } - r.classList.remove('hidden'); - placeRail(); - paint(); - } - - // The page does NOT scroll the document: `.player-pane` is the scroller in - // the browser, but the installed PWA in portrait scrolls `.body` instead - // (`.player-pane` goes overflow:visible there). So find the real scroller - // rather than assuming one. - function scrollerOf(el) { - for (let e = el && el.parentElement; e; e = e.parentElement) { - const cs = getComputedStyle(e); - if (/(auto|scroll)/.test(cs.overflowY) && e.scrollHeight > e.clientHeight + 4) return e; - } - return document.scrollingElement || document.documentElement; - } - - // The visible box to measure against — the scroller's own rect, or the - // window when the document itself scrolls. - function viewBox() { - const first = elOf(visible()[0] || SECTIONS[0]); - const sc = scrollerOf(first); - if (!sc || sc === document.scrollingElement || sc === document.documentElement) { - return { top: 0, height: window.innerHeight }; - } - const r = sc.getBoundingClientRect(); - return { top: r.top, height: r.height }; - } - - // Which card is the reader looking at? The one whose top is closest to a - // line a third of the way down the visible box — steadier than "first - // visible", which flickers between two cards at a boundary. - function currentId() { - const items = visible(); - if (!items.length) return ''; - const box = viewBox(); - const line = box.top + box.height / 3; - let best = '', bestD = Infinity; - let near = items[0].id, nearD = Infinity; // fallback: closest card overall - for (const sec of items) { - const el = elOf(sec); - if (!el) continue; - const r = el.getBoundingClientRect(); - const d = Math.abs(r.top - line); - // Distance to the visible band, for the fallback below. - const outside = Math.max(box.top - r.bottom, r.top - (box.top + box.height), 0); - if (outside < nearD || (outside === nearD && d < bestD)) { nearD = outside; near = sec.id; } - if (r.bottom < box.top || r.top > box.top + box.height) continue; - if (d < bestD) { bestD = d; best = sec.id; } - } - // Scrolled past the last card (or a card is taller than the viewport and - // its top has gone by): never leave the rail with nothing highlighted. - return best || near; - } - - // The mini player and the bottom nav are both position:fixed bottom:0, and - // either can be absent (nothing playing / landscape). Measure whatever is - // actually pinned down there and park the rail above it, rather than - // guessing an offset that is wrong on the next phone. - function placeRail() { - const r = rail(); - let occupied = 0; - for (const id of ['bottomNav', 'miniBar']) { - const el = document.getElementById(id); - if (!el || el.classList.contains('hidden')) continue; - const box = el.getBoundingClientRect(); - if (!box.height) continue; - if (getComputedStyle(el).position !== 'fixed') continue; - // Count any fixed bar sitting in the lower half. It must NOT be required - // to touch the viewport bottom: on an iPhone XR the bottom nav ends - // above the home-indicator gap, so a "bottom >= innerHeight" test missed - // it entirely and the rail was parked underneath it. - if (box.top > window.innerHeight * 0.5) occupied = Math.max(occupied, window.innerHeight - box.top); - } - // 14px was too tight: the mini player is a floating CARD with a shadow and - // rounded corners, so its visual edge reads higher than its box. - const want = Math.round(occupied + 24); - if (st.bottom !== want) { - st.bottom = want; - if (r) r.style.setProperty('--rail-bottom', want + 'px'); - // Published for anything else that must sit above the bottom bars — - // the watch-party panel's leave button was hidden behind them. - document.documentElement.style.setProperty('--bottom-chrome', Math.round(occupied) + 'px'); - } - } - - function paint() { - for (const [id, btn] of st.btns) { - btn.classList.toggle('active', id === st.active); - btn.classList.toggle('folded', isFolded(id)); - } - } - - function onScroll() { - if (st.raf) return; - st.raf = requestAnimationFrame(() => { - st.raf = 0; - // Panels appear and disappear outside render() too (the Related card - // shows once a video's related list arrives), and a card with no icon - // would leave the rail with nothing highlighted. build() is a no-op - // unless the set of visible cards actually changed. - build(); - placeRail(); - const id = currentId(); - if (id && id !== st.active) { st.active = id; paint(); } - }); - } - - function boot() { - const fb = document.getElementById('notesFoldBtn'); - if (fb) fb.addEventListener('click', () => setFolded('notes', !isFolded('notes'))); - SECTIONS.forEach(applyFold); - // Scroll events from an element do NOT bubble, so a window listener never - // fires here. A capture-phase listener on document catches them all, and - // keeps working when the scroller changes between browser and installed - // PWA (different elements scroll in each). - document.addEventListener('scroll', onScroll, { passive: true, capture: true }); - window.addEventListener('resize', () => { build(); placeRail(); onScroll(); }); - // The mini player appears AFTER the scroll that reveals it, so a scroll - // handler alone measures the layout as it was a moment ago and parks the - // rail underneath it. Watch the two bottom bars for real. - const watch = ['bottomNav', 'miniBar'].map((id) => document.getElementById(id)).filter(Boolean); - if (watch.length && typeof MutationObserver !== 'undefined') { - // The mini bar slides in over 0.25s, so a measurement taken the instant - // it appears catches it mid-animation and lands the rail a few pixels - // too low. Measure now and again once it has settled. - const mo = new MutationObserver(() => { placeRail(); setTimeout(placeRail, 320); }); - watch.forEach((el) => mo.observe(el, { attributes: true, attributeFilter: ['class', 'style'] })); - } - if (watch.length && typeof ResizeObserver !== 'undefined') { - const ro = new ResizeObserver(() => placeRail()); - watch.forEach((el) => ro.observe(el)); - } - build(); - onScroll(); - } - - // Called from render(): cards appear and disappear as videos load. - function refresh() { - SECTIONS.forEach(applyFold); - build(); - placeRail(); - onScroll(); - } - - return { boot, refresh, setFolded, isFolded }; -})(); - -const External = (() => { - const isAndroid = /Android/i.test(navigator.userAgent); - const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); - const streamUrl = (id) => `${location.origin}${isUploadId(id) ? '/api/uploads/' : '/api/media/'}${encodeURIComponent(id)}`; - async function cached(id) { - if (isUploadId(id)) return true; // uploads are always a single file on the server - try { const j = await (await fetch(`/api/media/${encodeURIComponent(id)}/status`)).json(); return j && j.status === 'ready'; } catch { return false; } - } - const intent = (url, title, pkg) => `intent:${url}#Intent;action=android.intent.action.VIEW;type=video/*;${pkg ? `package=${pkg};` : ''}S.title=${encodeURIComponent(title || '')};end`; - function m3u(items) { - return ['#EXTM3U', ...items.flatMap((v) => [`#EXTINF:${Math.round(v.duration || -1)},${(v.title || v.id).replace(/[\r\n]/g, ' ')}`, streamUrl(v.id)])].join('\n') + '\n'; - } - function download(name, text) { - const a = document.createElement('a'); - a.href = URL.createObjectURL(new Blob([text], { type: 'audio/x-mpegurl' })); - a.download = name; - a.click(); - setTimeout(() => URL.revokeObjectURL(a.href), 2000); - } - - async function open() { - const meta = current && current.meta; - if (!meta || !meta.id || meta.custom) { toast('Play a YouTube video first'); return; } - const body = document.createElement('div'); - body.className = 'ext-panel'; - body.innerHTML = '
Checking the server copy…
'; - showModal('📺 Open in another player', body, [{ label: 'Close', primary: true, onClick: closeModal }]); - const ok = await cached(meta.id); - if (!ok) { - body.innerHTML = ``; - return; - } - const url = streamUrl(meta.id); - const title = meta.title || meta.id; - const inList = queue.length > 1 && queueIndex >= 0; - body.innerHTML = ` - -
- ${isAndroid ? ` - ▶ VLC - ▶ MX Player - ▶ Open with…` - : isIOS ? ` - ▶ VLC - ▶ Infuse` - : `▶ VLC`} - - ${inList ? '' : ''} -
-

${isAndroid ? 'VLC / MX Player must be installed; “Open with…” shows every video app.' - : isIOS ? 'Needs the VLC (or Infuse) app. The link streams from your server; nothing is downloaded first.' - : 'Desktop VLC may not register vlc:// links — the .m3u file opens in VLC, mpv, IINA or any media player.'}

`; - body.addEventListener('click', async (e) => { - const act = e.target.closest('[data-act]'); - if (!act) return; - if (act.dataset.act === 'copy') { - try { await navigator.clipboard.writeText(url); toast('Stream URL copied ✓'); } catch { toast('Copy blocked'); } - } else if (act.dataset.act === 'm3u') { - download(`${title.replace(/[^\w\- ]+/g, '').trim().slice(0, 50) || meta.id}.m3u`, m3u([meta])); - } else if (act.dataset.act === 'm3u-list') { - act.disabled = true; - const items = queue.slice(Math.max(0, queueIndex)); - const flags = await Promise.all(items.map((v) => (v.custom ? false : cached(v.id)))); - const ready = items.filter((_, i) => flags[i]); - download('ytplayer-queue.m3u', m3u(ready)); - toast(`Playlist with ${ready.length} of ${items.length} songs${ready.length < items.length ? ' (the rest aren’t cached on the server yet)' : ''}`, { duration: 5000 }); - act.disabled = false; - } - }); - } - return { open, m3u, intent }; -})(); // ============================================================================ // 🎉 Watch party — synced playback + chat + voice (server/party.js relays) @@ -7496,461 +5864,27 @@ const External = (() => { // player either sends a command (when the host allows control) or goes // "off sync" until they tap Re-sync. Voice chat is a WebRTC mesh between // members who switch it on (STUN only — works on most home/mobile networks). -const Party = (() => { - const noop = { boot() {}, guestActive: () => false, intercept: () => false, afterLoad() {} }; - if (!WEB || typeof WebSocket === 'undefined') return noop; - const PID_KEY = 'ytpPartyPid', HOST_KEY = 'ytpPartyHost', NAME_KEY = 'ytpPartyName'; - const ls = { - get: (k) => { try { return localStorage.getItem(k); } catch { return null; } }, - set: (k, v) => { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch { /* blocked */ } }, - }; - let pid = ls.get(PID_KEY); - if (!pid) { pid = 'p' + Math.random().toString(36).slice(2, 12) + Date.now().toString(36); ls.set(PID_KEY, pid); } - const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/party?${new URLSearchParams(q)}`; +/* Party facade */ +const Party = { + ...Lazy.proxy('feature:watch-party', ['open', '_join', '_start'], 'Party'), + guestActive: () => window.YT?.Party?.guestActive() || false, + intercept: action => window.YT?.Party?.intercept(action) || false, + nextDJ: () => window.YT?.Party?.nextDJ() || false, + afterLoad: () => window.YT?.Party?.afterLoad(), + renderLyricsToggle: () => window.YT?.Party?.renderLyricsToggle(), + state: () => window.YT?.Party?.state() || { role: null }, + boot() { + if (!WEB) return; + $('navPartyBtn').addEventListener('click', () => Party.open().catch(error => toast(error.message))); + Lazy.intent($('navPartyBtn'), 'feature:watch-party'); + let resume = false; + try { const host = JSON.parse(localStorage.getItem('ytpPartyHost') || 'null'); resume = !!(host?.code && host?.secret); } catch {} + if (resume || new URLSearchParams(location.search).has('party')) Lazy.load('feature:watch-party').catch(error => toast(error.message)); + }, +}; +Lazy.onLoad('feature:watch-party', () => window.YT.Party.boot()); +/* End party facade */ - const st = { - ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0, - dj: { enabled: false, entries: [] }, djPending: false, - allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '', - voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() }, - minimized: false, - }; - const inParty = () => !!st.role; - const isHost = () => st.role === 'host'; - const guestActive = () => st.role === 'guest' && !st.detached; - const send = (m) => { if (st.ws && st.ws.readyState === 1) st.ws.send(JSON.stringify(m)); }; - const serverNow = () => Date.now() + st.offset; - const myName = () => st.name || (data.profile && data.profile.name) || ls.get(NAME_KEY) || ''; - - // ---------- connection ---------- - function connect() { - clearTimeout(st.retry); - const q = { role: st.role, pid, name: myName() || 'Guest' }; - if (st.code) q.code = st.code; - if (isHost() && st.secret) q.secret = st.secret; - const ws = new WebSocket(wsUrl(q)); - st.ws = ws; - ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onMessage(m); }; - ws.onclose = (e) => { - if (st.ws !== ws) return; - st.ws = null; - if (!inParty()) return; - if (e.code === 4010 || e.code === 4004 || e.code === 4003) { - const why = e.code === 4004 ? 'That party code doesn’t exist (or has ended).' : e.code === 4003 ? 'This party belongs to another host.' : 'The party ended.'; - leave(false); - toast(`🎉 ${why}`, { duration: 5000 }); - return; - } - setStatus('Reconnecting…'); - st.retry = setTimeout(connect, 3000); - }; - ws.onerror = () => { if (!st.code && isHost()) setStatus('Could not reach the server'); }; - } - - function onMessage(m) { - DirectMedia.attach('party', { send, peers: () => st.members.filter(m => m.pid !== pid).map(m => ({ id: m.pid, name: m.name, files: m.host && st.state?.directAvailable && st.state?.v ? [st.state.v.id] : [] })) }); - if (m.type === 'direct') { DirectMedia.handle('party', m); return; } - if (m.type === 'hello') { - st.code = m.code; - if (m.secret) { st.secret = m.secret; ls.set(HOST_KEY, JSON.stringify({ code: m.code, secret: m.secret })); } - st.offset = (m.now || Date.now()) - Date.now(); - st.members = m.members || []; - st.chat = m.chat || []; - st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false; - st.allowControl = !!m.allowControl; - setStatus(isHost() ? 'You’re hosting' : 'Synced with the host'); - render(); - if (isHost()) pushState(true); - else if (m.state) applyState(m.state); - if (st.voice.on) { send({ type: 'voice', on: true }); connectVoicePeers(); } - } else if (m.type === 'dj') { - st.dj = { enabled: m.enabled, entries: m.entries || [] }; render(); - } else if (m.type === 'dj-play' && isHost()) { - st.djPending = false; if (m.video) { Player._handoff = null; playVideoAt(m.video); } else { st.dj.entries = st.dj.entries.filter(e => !e.approved); advanceQueue(); } - } else if (m.type === 'state') { - st.state = m.state; - if (st.role === 'guest') applyState(m.state); - } else if (m.type === 'members') { - st.members = m.members || []; - if (m.joined) sysMsg(`${m.joined} joined`); - if (m.left) sysMsg(`${m.left} left${m.hostAway ? ' — waiting for the host to come back' : ''}`); - render(); - syncVoicePeers(); - } else if (m.type === 'chat') { - st.chat.push(m.msg); - if (st.chat.length > 100) st.chat.shift(); - renderChat(); - if (m.msg.pid !== pid && (st.minimized || $('partyPanel').classList.contains('hidden'))) toast(`💬 ${m.msg.from}: ${m.msg.text}`); - } else if (m.type === 'settings') { - st.allowControl = !!m.allowControl; - render(); - if (!isHost()) sysMsg(st.allowControl ? 'The host lets everyone control playback' : 'Only the host controls playback now'); - } else if (m.type === 'cmd' && isHost()) { - runCommand(m); - } else if (m.type === 'rtc') { - onRtc(m.from, m.data); - } else if (m.type === 'ended') { - toast(`🎉 ${m.reason || 'The party ended'}`, { duration: 5000 }); - } - } - - // ---------- host: broadcast ---------- - function stateNow() { - const meta = current && current.meta; - const M = Player.master; - return { - directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false, - v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '', duration: meta.duration || 0 } : null, - cur: M ? M.currentTime || 0 : 0, - paused: !M || M.paused, - rate: (M && M.playbackRate) || 1, - }; - } - function pushState(force = false) { - if (!isHost() || !st.ws) return; - const s = stateNow(); - const key = JSON.stringify({ ...s, cur: Math.round(s.cur) }); - if (!force && key === st.lastPush) return; - st.lastPush = key; - send({ type: 'state', state: s }); - } - function runCommand(m) { - switch (m.cmd) { - case 'toggle': Player.toggle(); break; - case 'play': Player.play(); break; - case 'pause': Player.pause(); break; - case 'seek': if (Number.isFinite(Number(m.t))) Player.seek(Math.max(0, Number(m.t))); break; - case 'next': playNext(); break; - case 'prev': playPrev(); break; - default: return; - } - toast(`🎉 ${m.from}: ${m.cmd === 'seek' ? `jumped to ${fmtTime(Number(m.t))}` : m.cmd}`); - setTimeout(() => pushState(true), 200); - } - - // ---------- guest: follow ---------- - const quiet = (ms = 1500) => { st.quietUntil = Date.now() + ms; }; - function targetTime(s) { - if (!s) return 0; - return s.paused ? s.cur : s.cur + ((serverNow() - (s.ts || serverNow())) / 1000) * (s.rate || 1); - } - async function applyState(s) { - st.state = s; - if (st.role !== 'guest' || st.detached || !s || !s.v) { render(); return; } - const target = targetTime(s); - if (!current || !current.meta || current.meta.id !== s.v.id) { - quiet(6000); - await playVideoAt({ ...s.v }, target); - return; - } - const M = Player.master; - if (!M) return; - if ((M.playbackRate || 1) !== (s.rate || 1)) { els.speed.value = String(s.rate || 1); Player.applySpeed(); } - if (Math.abs((M.currentTime || 0) - target) > 1.5) { quiet(); Player.seek(target); } - if (s.paused && !M.paused) { quiet(); Player.pause(); updatePlayBtn(); } - else if (!s.paused && M.paused) { quiet(); Player.play(); updatePlayBtn(); } - } - // A guest's own actions: forwarded when allowed, else they go off sync. - function onLocalAction(kind) { - if (st.role !== 'guest' || st.detached || Date.now() < st.quietUntil) return; - if (st.allowControl) { - const M = Player.master; - if (kind === 'seeked') send({ type: 'cmd', cmd: 'seek', t: M.currentTime }); - else send({ type: 'cmd', cmd: kind === 'pause' ? 'pause' : 'play' }); - return; - } - st.detached = true; - setStatus('Off sync — you paused or skipped on your own'); - render(); - } - // playNext/playPrev guard: guests don't change songs themselves. - function intercept(cmd) { - if (st.role !== 'guest' || st.detached) return false; - if (st.allowControl) send({ type: 'cmd', cmd }); - else toast('🎉 The host picks the songs in a watch party'); - return true; - } - function resync() { - st.detached = false; - setStatus('Synced with the host'); - render(); - applyState(st.state); - } - - // ---------- voice (WebRTC mesh) ---------- - const ICE = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; - function peer(other) { - let pc = st.voice.pcs.get(other); - if (pc) return pc; - pc = new RTCPeerConnection({ iceServers: ICE }); - st.voice.pcs.set(other, pc); - if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => pc.addTrack(t, st.voice.stream)); - pc.onicecandidate = (e) => { if (e.candidate) send({ type: 'rtc', to: other, data: { ice: e.candidate } }); }; - pc.ontrack = (e) => { - let a = st.voice.audios.get(other); - if (!a) { a = new Audio(); a.autoplay = true; a.setAttribute('playsinline', ''); st.voice.audios.set(other, a); } - a.srcObject = e.streams[0]; - a.play().catch(() => {}); - }; - pc.onconnectionstatechange = () => { if (['failed', 'closed'].includes(pc.connectionState)) dropPeer(other); render(); }; - return pc; - } - function dropPeer(other) { - const pc = st.voice.pcs.get(other); - if (pc) { try { pc.close(); } catch { /* closed */ } st.voice.pcs.delete(other); } - const a = st.voice.audios.get(other); - if (a) { a.srcObject = null; st.voice.audios.delete(other); } - } - async function offerTo(other) { - const pc = peer(other); - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - send({ type: 'rtc', to: other, data: { sdp: pc.localDescription } }); - } - async function onRtc(from, d) { - if (!st.voice.on || !d) return; - try { - if (d.sdp) { - const pc = peer(from); - await pc.setRemoteDescription(d.sdp); - if (d.sdp.type === 'offer') { - const ans = await pc.createAnswer(); - await pc.setLocalDescription(ans); - send({ type: 'rtc', to: from, data: { sdp: pc.localDescription } }); - } - } else if (d.ice) { - await peer(from).addIceCandidate(d.ice); - } - } catch { /* a glare/ICE hiccup — the next members update retries */ } - } - // The member with the smaller pid makes the offer, so each pair connects once. - function connectVoicePeers() { - if (!st.voice.on) return; - for (const m of st.members) if (m.voice && m.pid !== pid && !st.voice.pcs.has(m.pid) && pid < m.pid) offerTo(m.pid).catch(() => {}); - } - function syncVoicePeers() { - const voiced = new Set(st.members.filter((m) => m.voice).map((m) => m.pid)); - for (const other of [...st.voice.pcs.keys()]) if (!voiced.has(other)) dropPeer(other); - connectVoicePeers(); - } - async function toggleVoice() { - if (st.voice.on) { - st.voice.on = false; - for (const other of [...st.voice.pcs.keys()]) dropPeer(other); - if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => t.stop()); - st.voice.stream = null; - send({ type: 'voice', on: false }); - render(); - return; - } - if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof RTCPeerConnection === 'undefined') { toast('Voice chat isn’t supported in this browser'); return; } - try { - st.voice.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); - } catch (e) { - toast('🎤 Microphone blocked: ' + (e.message || e.name)); - return; - } - st.voice.on = true; - st.voice.muted = false; - send({ type: 'voice', on: true }); - toast('🎤 Voice on — headphones stop the music echoing into your mic', { duration: 5000 }); - render(); - connectVoicePeers(); - } - function toggleMute() { - if (!st.voice.stream) return; - st.voice.muted = !st.voice.muted; - st.voice.stream.getAudioTracks().forEach((t) => { t.enabled = !st.voice.muted; }); - render(); - } - - // ---------- lifecycle ---------- - function start() { - st.role = 'host'; - st.code = ''; st.secret = ''; st.chat = []; st.members = []; - ls.set(HOST_KEY, null); - showPanel(); - setStatus('Starting…'); - connect(); - beginTimers(); - } - function join(code, name) { - code = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); - if (code.length !== 6) { toast('Party codes have 6 letters/numbers'); return; } - if (name) { st.name = name; ls.set(NAME_KEY, name); } - st.role = 'guest'; - st.code = code; st.detached = false; st.chat = []; st.members = []; - showPanel(); - setStatus('Joining…'); - connect(); - beginTimers(); - } - function beginTimers() { - clearInterval(st.pushTimer); clearInterval(st.driftTimer); - st.pushTimer = setInterval(() => pushState(), 3000); - st.driftTimer = setInterval(() => { if (st.role === 'guest' && st.state && !st.detached) applyState(st.state); }, 2500); - } - function leave(sendEnd = true) { - if (isHost() && sendEnd) send({ type: 'end' }); - if (st.voice.on) toggleVoice(); - const ws = st.ws; - st.ws = null; - st.role = null; st.code = ''; st.secret = ''; - clearTimeout(st.retry); clearInterval(st.pushTimer); clearInterval(st.driftTimer); - try { if (ws) ws.close(); } catch { /* gone */ } - ls.set(HOST_KEY, null); - $('partyPanel').classList.add('hidden'); - render(); - } - - // ---------- UI ---------- - // Lyrics-instead-of-video is a per-device view: each guest chooses it for - // themselves, and the host's playback is untouched (the video keeps playing - // behind the lyrics, so sync and audio carry on). - const lyricsOn = () => !!(Notes.stageLyrics && Notes.stageLyrics.on()); - function renderLyricsToggle() { - const b = document.querySelector('#partyPanel .pt-lyrics'); - if (!b) return; - b.textContent = lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'; - b.classList.toggle('on', lyricsOn()); - } - function setStatus(t) { const el = $('ptStatus'); if (el) el.textContent = t; } - function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); renderChat(); } - function link() { return `${location.origin}/?party=${st.code}`; } - function showPanel() { $('partyPanel').classList.remove('hidden'); st.minimized = false; $('partyPanel').classList.remove('min'); render(); } - - function render() { - const panel = $('partyPanel'); - const chip = $('navPartyBtn'); - if (chip) { - const label = chip.querySelector('.nav-label'); - if (label) label.textContent = inParty() ? `Party · ${st.members.length}` : 'Watch party'; - else chip.textContent = inParty() ? `🎉 Party · ${st.members.length}` : '🎉 Watch party'; - } - if (!panel || panel.classList.contains('hidden')) return; - const nameOk = !!myName(); - if (!inParty()) { - panel.querySelector('.pt-body').innerHTML = ` - - ${nameOk ? '' : ''} - -
or join one
-
`; - $('ptTitle').textContent = '🎉 Watch party'; - setStatus(''); - return; - } - $('ptTitle').textContent = `🎉 Party ${st.code || '…'}`; - const s = st.state && st.state.v; - panel.querySelector('.pt-body').innerHTML = ` - ${st.code ? `
${escapeHtml(link())}${navigator.share ? '' : ''}
` : ''} - ${!isHost() && s ? `
▶ ${escapeHtml(s.title)}
` : ''} - ${!isHost() && st.detached ? '' : ''} -
${st.members.map((m) => `${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}`).join('')}
- ${isHost() ? `` : ''} - -
- - ${st.voice.on ? `` : ''} - ${st.voice.on ? `${st.voice.pcs.size} connected` : ''} -
-
-
- `; - const dj = document.createElement('div'); panel.querySelector('.pt-body').append(dj); - PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } }); - renderChat(); - } - function renderChat() { - const box = $('ptChat'); - if (!box) return; - box.innerHTML = st.chat.map((c) => c.sys - ? `
${escapeHtml(c.text)}
` - : `
${escapeHtml(c.from)} ${escapeHtml(c.text)}
`).join(''); - box.scrollTop = box.scrollHeight; - } - - function bindUi() { - const panel = $('partyPanel'); - $('navPartyBtn').addEventListener('click', () => { - if (typeof closeSidebar === 'function') { try { closeSidebar(); } catch { /* not open */ } } - if (panel.classList.contains('hidden')) showPanel(); - else if (st.minimized) { st.minimized = false; panel.classList.remove('min'); } - else if (!inParty()) panel.classList.add('hidden'); - else { st.minimized = true; panel.classList.add('min'); } - }); - $('ptMin').addEventListener('click', () => { - if (!inParty()) { panel.classList.add('hidden'); return; } - st.minimized = !st.minimized; - panel.classList.toggle('min', st.minimized); - }); - panel.addEventListener('keydown', (e) => e.stopPropagation()); - panel.addEventListener('click', async (e) => { - const a = e.target.closest('[data-act]'); - if (!a || a.tagName === 'INPUT') return; - const act = a.dataset.act; - const nameIn = panel.querySelector('.pt-name'); - const name = nameIn ? nameIn.value.trim() : ''; - if ((act === 'start' || act === 'join') && !myName() && !name) { toast('Enter your name first'); if (nameIn) nameIn.focus(); return; } - if (name) { st.name = name; ls.set(NAME_KEY, name); } - if (act === 'start') start(); - else if (act === 'join') join(panel.querySelector('.pt-code').value); - else if (act === 'copy') { try { await navigator.clipboard.writeText(link()); toast('Party link copied ✓'); } catch { toast('Copy blocked'); } } - else if (act === 'share') { try { await navigator.share({ title: 'Join my watch party', url: link() }); } catch { /* dismissed */ } } - else if (act === 'resync') resync(); - else if (act === 'voice') toggleVoice(); - else if (act === 'mute') toggleMute(); - else if (act === 'lyrics') { Notes.stageLyrics.set(!lyricsOn()); renderLyricsToggle(); } - else if (act === 'leave') leave(true); - }); - panel.addEventListener('change', (e) => { - if (e.target.dataset.act === 'allow') { st.allowControl = e.target.checked; send({ type: 'settings', allowControl: st.allowControl }); } - }); - panel.addEventListener('submit', (e) => { - e.preventDefault(); - const input = panel.querySelector('.pt-msg'); - const text = input.value.trim(); - if (text) { send({ type: 'chat', text }); input.value = ''; } - }); - // Host: push on every player change; guests: detect their own actions. - ['play', 'pause', 'seeked', 'ratechange', 'loadedmetadata'].forEach((ev) => { - [els.video, els.audio].forEach((el) => el.addEventListener(ev, (e) => { - if (el !== Player.master) return; - if (isHost()) pushState(true); - else if (ev === 'play' || ev === 'pause' || ev === 'seeked') onLocalAction(ev); - })); - }); - } - - function boot() { - bindUi(); - render(); - // Resume hosting after a reload. - try { - const h = JSON.parse(ls.get(HOST_KEY) || 'null'); - if (h && h.code && h.secret) { st.role = 'host'; st.code = h.code; st.secret = h.secret; showPanel(); connect(); beginTimers(); } - } catch { /* none */ } - let code = ''; - try { code = (new URLSearchParams(location.search).get('party') || '').toUpperCase(); } catch { /* no URL */ } - if (code) { - try { const u = new URL(location.href); u.searchParams.delete('party'); history.replaceState(null, '', u.pathname + u.search + u.hash); } catch { /* keep */ } - if (myName()) join(code); - else { showPanel(); const c = $('partyPanel').querySelector('.pt-code'); if (c) c.value = code; toast('Enter your name, then tap Join'); } - } - } - - return { - nextDJ: () => { if (!isHost() || !st.dj.enabled || !st.dj.entries.some(e => e.approved)) return false; if (!st.djPending) { st.djPending = true; send({ type: 'dj', action: 'take' }); } return true; }, - boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true), - state: () => ({ - role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on, - peers: st.voice.pcs.size, peerStates: [...st.voice.pcs.values()].map((pc) => pc.connectionState), - remoteAudio: [...st.voice.audios.values()].filter((a) => a.srcObject && a.srcObject.getAudioTracks().some((t) => t.readyState === 'live')).length, - chat: st.chat.length, allowControl: st.allowControl, - }), - _join: join, _start: start, - }; -})(); function updateProgress() { const cur = Player.master.currentTime || 0; @@ -7987,17 +5921,7 @@ function updateProgress() { // Height of the fixed bars at the bottom (bottom nav, mini player) as CSS // --bottom-chrome, so lists can scroll their last card clear of them. Measured, // never guessed: the mini player comes and goes, and phones differ. -function updateBottomChrome() { - let occupied = 0; - for (const id of ['bottomNav', 'miniBar']) { - const el = document.getElementById(id); - if (!el || el.classList.contains('hidden')) continue; - const box = el.getBoundingClientRect(); - if (!box.height || getComputedStyle(el).position !== 'fixed') continue; - if (box.top > window.innerHeight * 0.5) occupied = Math.max(occupied, window.innerHeight - box.top); - } - document.documentElement.style.setProperty('--bottom-chrome', Math.round(occupied) + 'px'); -} + window.addEventListener('resize', () => requestAnimationFrame(updateBottomChrome)); window.addEventListener('orientationchange', () => setTimeout(updateBottomChrome, 300)); @@ -8005,48 +5929,11 @@ window.addEventListener('orientationchange', () => setTimeout(updateBottomChrome // board / hero) over the player pane, using the same look as "nothing playing" // (.empty). The media keeps playing — it is only made invisible, never removed // or paused — and the mini bar stays up to get back to it. -function setHomeOverPlayer(on) { - const pane = els.playerPane; - if (!pane) return; - on = !!on && !!(current && current.meta); - // Service mode hides the bottom bar and mini bar (and, in its Video view, - // lives inside this pane): leave it first or the home screen is a dead end. - if (on && isServiceModeOpen()) exitServiceMode(); - if (pane.classList.contains('home-over') === on) return; - pane.classList.toggle('home-over', on); - pane.classList.toggle('empty', on); - if (els.placeholder) els.placeholder.classList.toggle('hidden', !on); - if (on) { - renderHomePlaylists(); - showMiniBar(); - const body = document.querySelector('.body'); - if (body) body.scrollTo({ top: 0 }); - } -} -function showMiniBar() { - if (!current || !current.meta) return; - $('miniTitle').textContent = current.meta.title; - const miniCh = $('miniChannel'); - miniCh.textContent = current.meta.channel || ''; - miniCh.classList.toggle('link', !!channelKeyOf(current.meta)); - $('miniBar').classList.remove('hidden'); - updateMiniBar(); - requestAnimationFrame(updateBottomChrome); -} -function updateMiniBar() { - if (!current || !current.meta) return; - const cur = Player.master.currentTime || 0; - const dur = Player.master.duration || 0; - $('miniTime').textContent = fmtTime(cur); - if (dur > 0) { - $('miniProgressFill').style.width = Math.min(100, (cur / dur) * 100) + '%'; - } -} -function hideMiniBar() { - $('miniBar').classList.add('hidden'); - requestAnimationFrame(updateBottomChrome); -} + + + + // ============================================================================ // History @@ -8076,6 +5963,7 @@ function playFromList(list, index, source = '', { playFull = false } = {}) { unshuffledQueue = null; // Play-in-full keeps the list order — a service set plays as arranged. if (data.settings.shuffle && !playFull) applyShuffle(); + warmNextQueue(); Player.loadVideo(queue[queueIndex]); renderUpNext(); } @@ -8425,7 +6313,7 @@ function updateServiceMode() { const spBtn = $('servicePlayBtn'); if (spBtn && Player.master) { - spBtn.textContent = Player.master.paused ? '▶' : '⏸'; + TransportIcons.set(spBtn, Player.master.paused ? 'play' : 'pause'); } updateWakeStateUI(); @@ -8693,109 +6581,18 @@ function channelKeyOf(v) { // ============================================================================ // Rendering // ============================================================================ -function renderSidebar() { - els.playlistList.innerHTML = ''; - for (const pl of data.playlists) { - const stats = playlistOfflineStats(pl); - let offlineBadge = ''; - if (stats.total > 0) { - if (stats.cached === stats.total) { - offlineBadge = `⬇`; - } else if (stats.cached > 0) { - offlineBadge = `${stats.cached}/${stats.total}`; - } - } - const item = document.createElement('div'); - item.className = 'playlist-item' + (view.type === 'playlist' && view.id === pl.id ? ' active' : ''); - item.innerHTML = `${offlineBadge}${pl.videos.length}`; - item.querySelector('.pl-name').textContent = pl.name; - item.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); }); - els.playlistList.appendChild(item); - } - document.querySelectorAll('.nav-item').forEach((b) => { - b.classList.toggle('active', b.dataset.view === view.type); - }); - // Keep bottom-nav in sync with sidebar nav active state - document.querySelectorAll('.bottom-nav-btn').forEach((b) => { - b.classList.toggle('active', b.dataset.view === view.type); - }); - updateQueueBadge(); - updateDownloadBadge(); - renderHomePlaylists(); -} + // The empty player pane (nothing playing yet) shows the branding hero. Once // this device has playlists, they take that space instead — the hero stays as // the fallback so a first-time visitor still gets the landing. // Driven from renderSidebar() so every playlist mutation refreshes both. -function renderHomePlaylists() { - if (window.BentoHub) BentoHub.playlists = () => data.playlists || []; - const wrap = document.getElementById('homePlaylists'); - const hero = document.querySelector('#playerPlaceholder .hero'); - const grid = document.getElementById('homePlGrid'); - if (!wrap || !hero || !grid) return; - Recommendations.home(); - const lists = data.playlists || []; - wrap.classList.toggle('hidden', !lists.length); - hero.classList.toggle('hidden', !!lists.length); - // The hero is centered art; the grid wants the full box, so the placeholder - // switches from centering to stretching (and tightens its padding), and the - // stage drops its 16:9 lock so the grid gets the pane's real height. - if (els.placeholder) els.placeholder.classList.toggle('showing-playlists', !!lists.length); - if (els.playerPane) els.playerPane.classList.toggle('home-playlists', !!lists.length); - if (!lists.length) { grid.innerHTML = ''; return; } - - grid.innerHTML = ''; - for (const pl of lists) { - const count = pl.videos.length; - const thumb = (pl.videos.find((v) => v && v.thumbnail) || {}).thumbnail || ''; - const stats = playlistOfflineStats(pl); - let offlineHtml = ''; - if (stats.total > 0) { - if (stats.cached === stats.total) { - offlineHtml = `⬇`; - } else if (stats.cached > 0) { - offlineHtml = `${stats.cached}/${stats.total}`; - } - } - const card = document.createElement('button'); - card.className = 'home-pl-card'; - card.type = 'button'; - card.innerHTML = ` - - ${thumb ? `` - : '🎵'} - ${offlineHtml} - ▶ - - - ${count} video${count === 1 ? '' : 's'}`; - card.querySelector('.home-pl-name').textContent = pl.name; - card.title = pl.name; - card.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); }); - card.querySelector('.home-pl-play').addEventListener('click', (e) => { - e.stopPropagation(); // the ▶ badge plays; the rest of the card opens the list - if (count) playFromList(pl.videos, 0, 'playlist:' + pl.id); - else toast('That playlist is empty'); - }); - grid.appendChild(card); - } - // Measure after layout — scrollHeight is meaningless until the cards lay out. - requestAnimationFrame(updateHomeGridScrollState); -} // With enough playlists the grid still has to scroll. Flag when it genuinely // overflows (and whether it's at the end) so the CSS fades the bottom edge // instead of slicing the last row against a hard border. -function updateHomeGridScrollState() { - const grid = document.getElementById('homePlGrid'); - if (!grid) return; - const overflows = grid.scrollHeight > grid.clientHeight + 1; - grid.classList.toggle('is-scrollable', overflows); - grid.classList.toggle('at-end', !overflows || - grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 2); -} + function currentList() { if (view.type === 'nowPlaying') return queue; @@ -8811,306 +6608,25 @@ function currentList() { return []; } -function showSearchSkeletons() { - // A search is in flight — the pane is about to have results, so reveal it - // now rather than popping in only once they land. - setListPaneHidden(false); - els.status.classList.add('hidden'); - els.cards.innerHTML = ''; - for (let i = 0; i < 8; i++) { - const s = document.createElement('div'); - s.className = 'card skeleton-card'; - s.innerHTML = ` -
-
-
-
-
-
`; - els.cards.appendChild(s); - } -} + // Before the first search the list pane has nothing to say but "Search for // something to begin" — hide it outright so the playlist board (or the hero) // owns the full width on a fresh launch. Any search or view change brings it // back. Must run before the early returns below, which skip the rest. -function setListPaneHidden(hidden) { - const pane = document.getElementById('listPane'); - if (pane) pane.classList.toggle('hidden', hidden); -} + // Small icon-only button for the playlist title row and the filter row. -const LIST_ICONS = { - rename: '', - share: '', - offline: '', - offlineDone: '', - pin: '', - select: '', - del: '', -}; -function listIconBtn(icon, label, onClick, extraClass) { - const b = document.createElement('button'); - b.type = 'button'; - b.className = 'list-icon-btn' + (extraClass ? ' ' + extraClass : ''); - b.title = label; - b.setAttribute('aria-label', label); - b.innerHTML = ``; - if (onClick) b.onclick = onClick; - return b; -} -function setListIcon(btn, icon) { - btn.innerHTML = ``; -} + + + // Drop the playlist-only title/filter icons so they never linger on other views. -function resetListHeaderExtras() { - $('listTitleActions').innerHTML = ''; - const fa = $('listFilterActions'); - fa.innerHTML = ''; - fa._del = null; - els.listTitle.onclick = null; - els.listTitle.onkeydown = null; - els.listTitle.classList.remove('title-clickable'); - els.listTitle.removeAttribute('role'); - els.listTitle.removeAttribute('tabindex'); - els.listTitle.removeAttribute('title'); -} -function renderList() { - // Theme composition follows the real view without changing its controls. - els.cards.dataset.view = view.type; - resetListHeaderExtras(); - { const ls = $('listSub'); if (ls) ls.textContent = ''; const ps = $('p2pStrip'); if (ps) ps.classList.add('hidden'); } - if (view.type !== 'search') { const sm = $('searchMeta'); if (sm) sm.classList.add('hidden'); } - setListPaneHidden(view.type === 'search' && !searchResults.length && !searchAttempted && !els.placeholder.classList.contains('hidden')); - if (view.type === 'settings') { renderSettings(); return; } - if (view.type === 'saved') { renderSaved(); return; } - if (view.type === 'p2p') { renderP2pList(); return; } - if (view.type === 'savedSearches') { renderSavedSearches(); return; } - if (view.type === 'downloads') { renderDownloads(); return; } - if (view.type === 'stats') { renderStats(); return; } - if (view.type === 'notes') { renderNotesView(); return; } - if (view.type === 'library') { renderLibrary(); return; } - // Filter bar — show for filterable views - const filterableViews = ['history', 'playlist', 'queue', 'channel', 'smart', 'nowPlaying']; - const showFilter = filterableViews.includes(view.type); - $('listFilterBar').classList.toggle('hidden', !showFilter); - $('batchBar').classList.toggle('hidden', !selectMode); - if (selectMode) $('batchCount').textContent = `${selectedIds.size} selected`; - // A channel's videos can be queued or added to a playlist, not deleted. - $('batchDeleteBtn').classList.toggle('hidden', view.type === 'channel'); - // Build the list, applying filter if active - let list = currentList(); - if (listFilter && showFilter) { - const q = listFilter.toLowerCase(); - list = list.filter((v) => (v.title || '').toLowerCase().includes(q) || (v.channel || '').toLowerCase().includes(q)); - } - els.cards.innerHTML = ''; - els.cards.classList.toggle('select-mode', selectMode); - resetListHeaderExtras(); - els.listActions.innerHTML = ''; - if (view.type === 'search' && WEB && els.placeholder.classList.contains('hidden')) { - Recommendations.inList(); - } - - if (view.type === 'nowPlaying') { - els.listTitle.textContent = 'Now playing'; - } else if (view.type === 'search') { - els.listTitle.textContent = 'Search results'; - } else if (view.type === 'history') { - els.listTitle.textContent = 'History'; - if (list.length) { - const clear = document.createElement('button'); - clear.textContent = 'Clear'; - clear.onclick = () => { data.history = []; persist(); renderList(); }; - els.listActions.appendChild(clear); - } - } else if (view.type === 'queue') { - els.listTitle.textContent = 'Queue'; - if (list.length) { - const playAll = document.createElement('button'); - playAll.textContent = '▶ Play queue'; - playAll.onclick = () => playQueue(0); - const clear = document.createElement('button'); - clear.textContent = 'Clear'; - clear.onclick = clearQueue; - els.listActions.append(playAll, clear); - } - } else if (view.type === 'smart') { - const sp = SMART_PLAYLISTS.find((s) => s.id === view.smartType); - els.listTitle.textContent = sp ? sp.label : 'Auto Playlist'; - if (list.length) { - const playAll = document.createElement('button'); - playAll.textContent = '▶ Play all'; - playAll.onclick = () => playFromList(list.slice(), 0, 'smart:' + view.smartType); - const queueAll = document.createElement('button'); - queueAll.textContent = '+ Queue all'; - queueAll.onclick = () => { list.forEach((v) => addToQueue(v, { quiet: true })); toast('Added to queue'); }; - els.listActions.append(playAll, queueAll); - } - } else if (view.type === 'channel') { - els.listTitle.textContent = channelData.name || 'Channel'; - if (list.length) { - const playAll = document.createElement('button'); - playAll.textContent = '▶ Play all'; - playAll.onclick = () => playFromList(channelData.results.slice(), 0, 'channel'); - const queueAll = document.createElement('button'); - queueAll.textContent = '+ Queue all'; - queueAll.onclick = () => { channelData.results.forEach((v) => addToQueue(v, { quiet: true })); toast('Added channel to queue'); }; - els.listActions.append(playAll, queueAll); - } - } else if (view.type === 'playlist') { - const pl = data.playlists.find((p) => p.id === view.id); - els.listTitle.textContent = pl ? pl.name : 'Playlist'; - if (pl) { - { const secs = (pl.videos || []).reduce((a, v) => a + (Number(v.duration) || 0), 0); - const h = Math.floor(secs / 3600), m = Math.round((secs % 3600) / 60); - $('listSub').textContent = `${pl.videos.length} track${pl.videos.length === 1 ? '' : 's'}${secs ? ' · ' + (h ? h + 'h ' : '') + m + 'm' : ''}`; } - renderP2pStrip(); - const playAll = document.createElement('button'); - playAll.textContent = '▶ Play all'; - playAll.className = 'la-primary'; - playAll.onclick = () => { if (pl.videos.length) playFromList(pl.videos, 0, 'playlist:' + pl.id); }; - const playFull = document.createElement('button'); - playFull.textContent = '▶ Play in full'; - playFull.className = 'la-main'; - playFull.dataset.sub = 'Start to finish'; - playFull.title = 'Play every video start to finish — ignores saved positions and A-B loops, stops after the last one'; - playFull.onclick = () => { - if (!pl.videos.length) return; - playFromList(pl.videos, 0, 'playlist:' + pl.id, { playFull: true }); - toast('Playing in full — stops after the last video'); - }; - const serviceBtn = document.createElement('button'); - serviceBtn.textContent = '🎤 Service mode'; - serviceBtn.className = 'la-main'; - serviceBtn.dataset.sub = 'Full lyrics'; - serviceBtn.title = 'Set-list view with screen wake lock'; - serviceBtn.onclick = () => { - if (!pl.videos || !pl.videos.length) { toast('Playlist is empty'); return; } - enterServiceMode(pl); - }; - const queueAll = document.createElement('button'); - queueAll.textContent = '+ Queue'; - queueAll.className = 'la-main'; - queueAll.dataset.sub = `+${pl.videos.length} items`; - queueAll.onclick = () => { pl.videos.forEach((v) => addToQueue(v, { quiet: true })); toast('Added playlist to queue'); }; - // Title row: a long name wraps; tapping it opens the rename modal, - // which also shows the full name. Rename + Share sit beside it as icons. - els.listTitle.classList.add('title-clickable'); - els.listTitle.setAttribute('role', 'button'); - els.listTitle.tabIndex = 0; - els.listTitle.title = 'Show full name / rename'; - els.listTitle.onclick = () => renamePlaylist(pl); - els.listTitle.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); renamePlaylist(pl); } }; - const titleIcons = [listIconBtn('rename', 'Rename playlist', () => renamePlaylist(pl))]; - if (pl.videos && pl.videos.length >= 1) titleIcons.push(listIconBtn('share', 'Share playlist', () => sharePlaylist(pl))); - $('listTitleActions').append(...titleIcons); - - // Filter row icons: offline + pin show their state by colour alone. - const offlineBtn = listIconBtn('offline', 'Make available offline', () => makePlaylistOffline(pl)); - offlineBtn.id = 'plOfflineBtn'; - updatePlaylistOfflineButton(offlineBtn, pl); - - const isPinned = (data.pinnedPlaylists || []).includes(pl.id); - const pinBtn = listIconBtn('pin', isPinned ? 'Pinned for offline keep — tap to unpin' : 'Pin to keep this playlist available offline', () => togglePinPlaylist(pl)); - pinBtn.id = 'plPinBtn'; - pinBtn.classList.toggle('pinned', isPinned); - pinBtn.setAttribute('aria-pressed', String(isPinned)); - - const del = listIconBtn('del', 'Delete playlist', () => deletePlaylist(pl), 'danger'); - $('listFilterActions').append(offlineBtn, pinBtn); - els.listActions.append(playAll, playFull, serviceBtn, queueAll); - $('listFilterActions')._del = del; // appended after Select below - } - } - - // Select button for batch-operable views - const batchViews = ['history', 'playlist', 'queue', 'smart', 'channel']; - if (batchViews.includes(view.type) && list.length) { - const selBtn = listIconBtn('select', selectMode ? 'Stop selecting' : 'Select items', toggleSelectMode); - selBtn.classList.toggle('pinned', selectMode); - selBtn.setAttribute('aria-pressed', String(selectMode)); - $('listFilterActions').appendChild(selBtn); - } - { const fa = $('listFilterActions'); if (fa._del) { fa.appendChild(fa._del); fa._del = null; } } - - // Channel still loading — show skeletons. - if (view.type === 'channel' && channelData.loading && !list.length) { - showSearchSkeletons(); - return; - } - - if (!list.length) { - els.status.classList.add('hidden'); - els.cards.innerHTML = ''; - const empty = document.createElement('div'); - empty.className = 'empty-state'; - - if (view.type === 'search') { - // Landing (hero or playlist board) lives in the player pane. Before the - // first search this pane is hidden outright; after one that came back - // empty it stays visible so the result is legible. - els.status.classList.remove('hidden'); - els.status.textContent = searchAttempted - ? 'No results for that search.' - : 'Search for something to begin.'; - return; - } - - if (view.type === 'history') { - empty.innerHTML = ` -
🕘
-

Nothing watched yet

-

Your viewing history shows up here once you start playing videos.

- `; - addBrowseCta(empty, '🔍 Search videos'); - } else if (view.type === 'queue') { - empty.innerHTML = ` -
▶
-

Your queue is empty

-

Use + Queue on any video to line it up. The queue is temporary and plays in order.

- `; - addBrowseCta(empty, '🔍 Find something to play'); - } else if (view.type === 'channel') { - empty.innerHTML = ` -
📺
-

No videos found

-

This channel didn't return any uploads.

- `; - addBrowseCta(empty, '🔍 Back to search'); - } else { - // Playlist view — empty - empty.innerHTML = ` -
🎵
-

This playlist is empty

-

Add videos from search results or use the + Playlist button while playing.

- `; - addBrowseCta(empty, '🔍 Browse videos'); - } - - els.cards.appendChild(empty); - return; - } - els.status.classList.add('hidden'); - - list.forEach((v, i) => els.cards.appendChild(renderCard(v, i, list))); - markPlayingCard(); -} - -function addBrowseCta(empty, label) { - const cta = document.createElement('button'); - cta.className = 'empty-cta'; - cta.textContent = label; - cta.addEventListener('click', () => { view = { type: 'search' }; render(); }); - empty.appendChild(cta); -} // ============================================================================ // Saved videos page — every offline-cached file with sizes + totals @@ -9125,6 +6641,7 @@ function addBrowseCta(empty, label) { // through the server. Buttons appear only when the file is ready so each one // runs inside its own tap (the share sheet needs a fresh user gesture). async function exportToDevice(video) { + try { await Lazy.load('feature:export'); } catch(error) { toast(error.message); return; } if (!video || !video.id || !window.DeviceExport) return; const id = video.id; const title = video.title || videoTitleById(id) || id; @@ -9233,314 +6750,23 @@ async function exportToDevice(video) { // Glass Stage: a compact "On other devices" card under a playlist (same data as the 📡 view). let p2pStripAt = 0, p2pStripData = null; -async function renderP2pStrip() { - const box = $('p2pStrip'); - if (!box || document.documentElement.dataset.layout !== 'glass-stage' || !WEB || data.settings.p2pReceive === false) return; - if (!p2pStripData || Date.now() - p2pStripAt > 60000) { - try { const r = await fetch('/api/p2p/available?limit=3'); p2pStripData = r.ok ? await r.json() : null; } catch { p2pStripData = null; } - p2pStripAt = Date.now(); - } - if (view.type !== 'playlist') return; - const items = (p2pStripData && p2pStripData.ok && p2pStripData.items) || []; - if (!items.length) { box.classList.add('hidden'); return; } - const online = items.filter((i) => i.online).length; - box.innerHTML = `
On other devices
-

${items.length} video${items.length === 1 ? '' : 's'} held by other devices · ${online ? online + ' online now' : 'none online'}

`; - items.forEach((it) => { - const v = { id: it.id, title: it.title || videoTitleById(it.id) || it.id, channel: it.channel || '', duration: it.duration || 0, thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg` }; - const row = document.createElement('div'); - row.className = 'ps-row'; - row.innerHTML = '📡'; - row.querySelector('b').textContent = v.title; - row.querySelector('small').textContent = `${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''} · ${it.online ? 'Online' : 'Offline'}`; - row.querySelector('.ps-dl').onclick = () => exportToDevice(v); - row.querySelector('.ps-txt').onclick = () => playFromList([v], 0, 'p2p'); - box.appendChild(row); - }); - box.classList.remove('hidden'); -} + // ============================================================================ // Saved searches — browse (read-only here; deleting happens in Settings) // ============================================================================ -function fmtAgo(ts) { - const s = Math.max(0, (Date.now() - ts) / 1000); - if (s < 90) return 'just now'; - if (s < 3600) return Math.round(s / 60) + ' min ago'; - if (s < 86400) return Math.round(s / 3600) + ' h ago'; - return Math.round(s / 86400) + ' d ago'; -} -function renderSavedSearches() { - els.listTitle.textContent = '📚 Saved searches'; - resetListHeaderExtras(); - els.listActions.innerHTML = ''; - els.status.classList.add('hidden'); - const c = els.cards; - c.innerHTML = ''; - const items = SearchLibrary.list(); - if (!items.length) { - c.innerHTML = '
📚

No saved searches yet

Every search you run is kept here with its thumbnails, even after you clear the search box. You can remove them in Settings.

'; - return; - } - const sum = document.createElement('div'); - sum.className = 'saved-summary'; - sum.innerHTML = `${items.length}saved search${items.length === 1 ? '' : 'es'} · ${fmtBytes(SearchLibrary.bytes())} · manage in Settings`; - c.appendChild(sum); - items.forEach((it) => { - const row = document.createElement('div'); - row.className = 'card saved-card ss-card'; - row.innerHTML = ` -
-
${it.count} result${it.count === 1 ? '' : 's'} · ${fmtAgo(it.ts)}
`; - row.querySelector('.card-title').textContent = it.query; - row.addEventListener('click', () => { - els.searchInput.value = it.query; - if (els.runSearch) els.runSearch(it.query); - }); - c.appendChild(row); - }); -} - -async function renderP2pList() { - els.listTitle.textContent = '📡 On other devices'; - resetListHeaderExtras(); - els.listActions.innerHTML = ''; - els.status.classList.add('hidden'); - const c = els.cards; - c.innerHTML = '
Looking for videos on other devices…
'; - let res = null; - try { const r = await fetch('/api/p2p/available?limit=100'); res = r.ok ? await r.json() : null; } catch { /* offline */ } - if (view.type !== 'p2p') return; - c.innerHTML = ''; - if (!res || !res.ok) { - c.innerHTML = '
📡

Not available

Peer-to-peer sharing is off on this server, or you are offline.

'; - return; - } - const items = res.items || []; - const list = items.map((it) => ({ - id: it.id, title: it.title || videoTitleById(it.id) || it.id, channel: it.channel || '', - duration: it.duration || 0, thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`, - })); - const refresh = document.createElement('button'); - refresh.textContent = '↻'; - refresh.className = 'la-icon'; - refresh.title = 'Refresh'; - refresh.setAttribute('aria-label', 'Refresh'); - refresh.onclick = () => renderP2pList(); - const playAll = document.createElement('button'); - playAll.textContent = '▶ Play all'; - playAll.className = 'la-primary la-inline'; - playAll.onclick = () => { if (list.length) playFromList(list, 0, 'p2p'); }; - els.listActions.append(playAll, refresh); - if (!items.length) { - c.innerHTML = '
📡

Nothing shared yet

Videos that other devices have saved and verified show up here. Save a video and keep sharing on, and yours will appear for everyone else.

'; - return; - } - const online = items.filter((i) => i.online).length; - const sum = document.createElement('div'); - sum.className = 'saved-summary'; - sum.innerHTML = `${items.length}video${items.length === 1 ? '' : 's'} on other devices · ${online} with a device online now`; - c.appendChild(sum); - items.forEach((it, idx) => { - const v = list[idx]; - const row = document.createElement('div'); - row.className = 'card saved-card p2p-card'; - row.dataset.id = it.id; - const state = it.online ? `● ${it.online} online` : '○ none online'; - row.innerHTML = ` -
-
-
-
${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · not checked lately' : ''}
-
- - `; - row.querySelector('.card-title').textContent = v.title; - row.addEventListener('click', (e) => { - const act = e.target.closest('[data-act]'); - if (act) { e.stopPropagation(); if (act.dataset.act === 'export') exportToDevice(v); else openCardMenu(v); return; } - playFromList(list, idx, 'p2p'); - }); - c.appendChild(row); - }); - markPlayingCard(); -} - -async function renderSaved() { - els.listTitle.textContent = 'Saved videos'; - resetListHeaderExtras(); - els.listActions.innerHTML = ''; - els.status.classList.add('hidden'); - $('batchBar').classList.add('hidden'); - const c = els.cards; - c.classList.remove('select-mode'); - c.innerHTML = '
Loading saved videos…
'; - - let res; - try { res = await API.cacheList(); } catch { res = null; } - if (view.type !== 'saved') return; // navigated away - - if (!res || !res.ok) { - $('listFilterBar').classList.add('hidden'); - c.innerHTML = ''; - const empty = document.createElement('div'); - empty.className = 'empty-state'; - empty.innerHTML = ` -
💾
-

Offline cache unavailable

-

This build doesn't support saving videos for offline playback.

`; - c.appendChild(empty); - return; - } - - const items = (res.items || []).slice().sort((a, b) => b.size - a.size); - const total = res.total || 0; - - if (!items.length) { - $('listFilterBar').classList.add('hidden'); - c.innerHTML = ''; - const empty = document.createElement('div'); - empty.className = 'empty-state'; - empty.innerHTML = ` -
💾
-

Nothing saved yet

-

Use ⬇ Save while playing, or add videos to a playlist to keep them offline.

`; - addBrowseCta(empty, '🔍 Find videos'); - c.appendChild(empty); - return; - } - - // Header actions: total + clear all. - const clearAll = document.createElement('button'); - clearAll.type = 'button'; - clearAll.className = 'saved-clear'; - clearAll.textContent = 'Clear all'; - clearAll.setAttribute('aria-label', 'Clear all saved videos'); - clearAll.onclick = () => { - showModal('Clear all saved videos?', document.createTextNode('Frees disk space. Normal videos re-download on demand, but edited videos are removed for good (they exist only here).'), [ - { label: 'Cancel', onClick: closeModal }, - { label: 'Clear all', danger: true, onClick: async () => { - try { - const result = await API.cacheClear(); - if (result?.ok === false) throw new Error(result.error || 'Storage could not be cleared'); - } catch { toast('Could not clear saved videos. Try again.'); closeModal(); return; } - cachedIds.clear(); - // Custom cuts live only in the cache — clearing it destroys their media, - // so drop their registry entries and playlist references too. - const customIds = new Set((data.customVideos || []).map((v) => v.id)); - if (customIds.size) { - data.customVideos = []; - data.playlists.forEach((pl) => { pl.videos = pl.videos.filter((x) => !customIds.has(x.id)); }); - data.queue = (data.queue || []).filter((x) => !customIds.has(x.id)); - persist(); - } - closeModal(); - toast('Cache cleared'); - if (current) updateNowPlayingActions(); - renderSaved(); - } }, - ]); - }; - c.innerHTML = ''; - const summary = document.createElement('div'); - summary.className = 'saved-summary saved-storage-summary'; - summary.innerHTML = `${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'} stored offline`; - summary.appendChild(clearAll); - $('listFilterBar').classList.remove('hidden'); - c.appendChild(summary); - items.forEach((it) => { - const known = videoById(it.id); - // A custom (edited) id has no YouTube thumbnail; fall back to its source's - // thumbnail when we know it, else a neutral placeholder. - const isCustom = (known && known.custom) || String(it.id).startsWith('edit_'); - const fallbackThumb = isCustom ? '' : `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`; - const v = known || { id: it.id, title: videoTitleById(it.id), thumbnail: fallbackThumb }; - if (!SavedPage.matches(v, listFilter)) return; - const row = document.createElement('div'); - row.className = 'card saved-card' + (isCustom ? ' custom' : ''); - row.dataset.id = it.id; - row.innerHTML = ` -
${isCustom ? '✂' : ''}${isUploadId(v.id) ? `${v.kind === 'audio' ? '♪' : '📁'}` : ''}
-
-
-
${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}
-
`; - row.querySelector('.card-title').textContent = v.title || it.id; - row.addEventListener('click', (e) => { - if (e.target.closest('.saved-file-actions')) return; - playFromList([v], 0, 'saved'); - }); - SavedPage.actions(row, v, () => exportToDevice(v), () => { - showModal(`Delete saved file “${v.title || v.id}”?`, document.createTextNode(isCustom - ? 'This edited copy exists only on this device and will be removed permanently.' - : 'Frees space on this device. You can save this video again later.'), [ - { label: 'Cancel', onClick: closeModal }, - { label: 'Delete saved file', danger: true, onClick: async () => { - try { - const result = await API.cacheDelete(it.id); - if (result?.ok === false) throw new Error(result.error || 'Storage could not be updated'); - } catch { toast('Could not delete this saved file. Try again.'); closeModal(); return; } - cachedIds.delete(it.id); - if (isCustom) { - data.customVideos = (data.customVideos || []).filter(x => x.id !== it.id); - data.playlists.forEach(pl => { pl.videos = pl.videos.filter(x => x.id !== it.id); }); - data.queue = (data.queue || []).filter(x => x.id !== it.id); - persist(); - } - if (current?.meta?.id === it.id) updateNowPlayingActions(); - markCardCacheState(it.id, 'none'); closeModal(); - toast('Removed saved file'); renderSaved(); - } }, - ]); - }); - c.appendChild(row); - }); - if (!c.querySelector('.saved-card')) { - const message = document.createElement('p'); message.className = 'status'; - message.textContent = 'No saved videos match your filter.'; c.appendChild(message); - } - markPlayingCard(); -} + + + // ============================================================================ // Downloads page — videos currently being saved // ============================================================================ -function renderDownloads() { - els.listTitle.textContent = 'Downloads'; - resetListHeaderExtras(); - els.listActions.innerHTML = ''; - els.status.classList.add('hidden'); - const c = els.cards; - c.innerHTML = ''; - $('listFilterBar').classList.toggle('hidden', !Downloads.getJobs().length); - $('batchBar').classList.add('hidden'); - c.classList.remove('select-mode'); - if (window.DownloadsPage && window.DownloadsPage.render) { - window.DownloadsPage.render(c, { - downloadsApi: window.Downloads, - filter: listFilter, - isActive: () => view.type === 'downloads', - showModal: typeof showModal === 'function' ? showModal : null, - closeModal: typeof closeModal === 'function' ? closeModal : null, - onNavigateSaved: () => { view = { type: 'saved' }; render(); }, - thumbUrlFor: typeof thumbUrlFor === 'function' ? thumbUrlFor : null, - refresh: renderList - }); - } - - const jobs = window.Downloads && window.Downloads.getJobs ? window.Downloads.getJobs() : []; - jobs.filter(j => j.status === 'active' || j.status === 'queued').forEach(v => { - if (typeof SaveQueue !== 'undefined' && SaveQueue.progress) { - SaveQueue.progress(v.id, SaveQueue.get(v.id)); - } - }); -} // ============================================================================ // Settings page @@ -9565,782 +6791,11 @@ function videoTitleById(id) { return v ? v.title : id; } -async function renderSettings() { - els.listTitle.textContent = 'Settings'; - resetListHeaderExtras(); - els.listActions.innerHTML = ''; - const c = els.cards; - els.status.classList.add('hidden'); - c.innerHTML = ''; - - const wrap = document.createElement('div'); - wrap.className = 'settings'; - - // ---- Playback ---- - const qualities = ['auto', '1080p', '720p', '480p', '360p', '240p']; - const qOptions = qualities - .map((q) => ``) - .join(''); - - const sel = (id, val, opts) => ``; - - wrap.innerHTML = ` -
-
Appearance & accessibility
- - - - - - -
- -
-
Playback
- - - - - ${EQ.isIOS() ? `` : ''} - - - - -
- -
-
Audio effects (JamesDSP)
- - - - - - - - - Supported sample rates: 44.1 and 48 kHz. -
- -
-
Sharing (peer-to-peer)
- - -
- This device… - … -
- -
- -
-
Offline cache
- - -
- Storage used - … -
- -
- -
-
- Saved videos -
-
-
- -
-
Saved searches
-
- - Kept on this device - Search results and their thumbnails stay saved after you clear the search box. Only you can remove them, here. - - … -
-
- - - -
-
-
- -
-
Playlists
-
- - - - -
-
- -
-
Backup & restore
- - -
- - - -
-
- -
-
Reset
-
- - Force refresh UI - Re-fetches the app shell and assets, bypassing the browser cache, then reloads. - -
- -
-
-
- - Reset app - Clears all local storage, unregisters the service worker, and deletes every cache. You will need to reload after. - -
- -
-
-
- -
-
Online profile — sync between devices
-
- - Linked profile - Playlists, history and settings sync to the server under this name. The name works like a passkey — anyone who knows it can load and change this data, so prefer a random one. - - ${data.profile && data.profile.name ? data.profile.name : 'Not linked'} -
-
- - 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'}`, ''], - ['jamesdsp', 'Audio effects (JamesDSP)', 'Listening', () => data.settings.jamesDsp?.enabled ? `On · ${data.settings.jamesDsp.preset || 'Flat'}` : 'Off', ''], - ['sharing', 'Sharing', 'Library & storage', () => data.settings.p2pShare !== false ? 'Sharing saved videos' : 'Sharing off', ''], - ['offline', 'Offline storage', 'Library & storage', () => `${SaveSlots.limit ? SaveSlots.limit() : Number(data.settings.parallelSaves) || 4} at once · ${$('cacheTotal')?.textContent || 'Checking storage'}`, ''], - ['searches', 'Saved searches', 'Library & storage', () => `${SearchLibrary.list().length} saved searches`, ''], - ['playlists', 'Playlists', 'Library & storage', () => `${data.playlists.length} playlists`, ''], - ['backup', 'Backup & restore', 'Account & sync', () => data.settings.autoBackupEnabled ? 'Scheduled backup on' : 'Manual backups', ''], - ['reset', 'Refresh & reset', 'Advanced & about', () => 'Refresh the app or clear device data', ''], - ['profile', 'Profile & sync', 'Account & sync', () => data.profile?.name || 'Not linked', ''], - ['about', 'About', 'Advanced & about', () => `Version ${APP_VERSION}`, ''], - ]; - const groups = [...wrap.querySelectorAll(':scope > .set-group')]; - definitions.forEach(([id, title, cluster, summary, icon], i) => SettingsSections.register({ id, title, cluster, summary, icon, render(container) { container.append(groups[i]); } })); - SettingsSections.register({ id: 'video-fullscreen', title: 'Video & fullscreen', cluster: 'Listening', icon: '', summary: () => `Fullscreen: ${FullscreenOrientation.normalize(data.settings.fullscreenOrientation)}`, render(container) { - const row = document.createElement('label'); row.className = 'set-row'; - row.innerHTML = 'Fullscreen orientation'; - const select = row.querySelector('select'); select.value = FullscreenOrientation.normalize(data.settings.fullscreenOrientation); - select.onchange = () => { data.settings.fullscreenOrientation = select.value; persist(); }; - container.append(row); - } }); - SettingsSections.mount(wrap, { settings: data.settings, data, persist, toast }); - - // ---- Wire appearance / accessibility ---- - $('setTheme').addEventListener('change', (e) => { data.settings.theme = e.target.value; applyAppearance(); persist(); }); - $('setLayout').addEventListener('change', (e) => { data.settings.layout = e.target.value; applyAppearance(); persist(); }); - $('setFont').addEventListener('change', (e) => { data.settings.fontScale = e.target.value; applyAppearance(); persist(); }); - $('setParallelSaves').addEventListener('change', (e) => { data.settings.parallelSaves = Number(e.target.value) || 4; persist(); SaveSlots.pump(); }); - $('setDensity').addEventListener('change', (e) => { data.settings.density = e.target.value; applyAppearance(); persist(); }); - $('setPerf').addEventListener('change', (e) => { data.settings.perfMode = e.target.checked; applyAppearance(); persist(); }); - $('setMotion').addEventListener('change', (e) => { data.settings.reduceMotion = e.target.checked; applyAppearance(); persist(); }); - $('setRepeat').addEventListener('change', (e) => { data.settings.repeatMode = e.target.checked ? 'all' : 'off'; updateLoopRepeatButtons(); persist(); }); - $('setLoopOne').addEventListener('change', (e) => { data.settings.loopOne = e.target.checked; updateLoopRepeatButtons(); persist(); }); - $('setLevelVolume').addEventListener('change', (e) => { - data.settings.levelVolume = e.target.checked; - persist(); - if (current && current.meta) Level.onTrack(current.meta); else EQ.setLevel(0); - EqRender.scheduleAll(); - }); - if ($('setLiveAudioIOS')) $('setLiveAudioIOS').addEventListener('change', (e) => { - data.settings.liveAudioIOS = e.target.checked; - persist(); - EQ.apply(); - if (current && current.meta) Level.onTrack(current.meta); - if (e.target.checked) toast('Live sound processing on — streamed songs go silent when the screen locks', { duration: 4500 }); - }); - // ---- Saved searches manager ---- - { - const listEl = $('ssList'); - const sel = new Set(); - const refresh = () => { - const items = SearchLibrary.list(); - for (const k of [...sel]) if (!items.some((i) => i.key === k)) sel.delete(k); - $('ssTotal').textContent = items.length ? `${items.length} · ${fmtBytes(SearchLibrary.bytes())}` : 'None'; - listEl.innerHTML = items.length ? items.map((i) => ` - `).join('') : '
No saved searches.
'; - $('ssDeleteSel').disabled = !sel.size; - $('ssDeleteSel').textContent = sel.size ? `Delete selected (${sel.size})` : 'Delete selected'; - $('ssSelectAll').textContent = items.length && sel.size === items.length ? 'Select none' : 'Select all'; - $('ssClearAll').disabled = !items.length; - }; - listEl.addEventListener('change', (e) => { const k = e.target.dataset && e.target.dataset.k; if (!k) return; e.target.checked ? sel.add(k) : sel.delete(k); refresh(); }); - $('ssSelectAll').addEventListener('click', () => { - const items = SearchLibrary.list(); - if (sel.size === items.length) sel.clear(); else items.forEach((i) => sel.add(i.key)); - refresh(); - }); - $('ssDeleteSel').addEventListener('click', () => { - if (!sel.size || !confirm(`Delete ${sel.size} saved search${sel.size === 1 ? '' : 'es'}? Their results and thumbnails are removed from this device.`)) return; - SearchLibrary.remove([...sel]); sel.clear(); refresh(); toast('Saved searches deleted'); - }); - $('ssClearAll').addEventListener('click', () => { - if (!confirm('Delete ALL saved searches and their thumbnails from this device?')) return; - SearchLibrary.clear(); sel.clear(); refresh(); toast('All saved searches deleted'); - }); - refresh(); - } - $('setJamesDsp').addEventListener('change', (e) => { JamesDSP.toggle(e.target.checked).catch(() => {}); }); - $('setJamesDspPreset').addEventListener('change', async (e) => { data.settings.jamesDsp ||= { enabled: false, params: {} }; data.settings.jamesDsp.preset = e.target.value; persist(); try { await JamesDSP.apply(); } catch {} }); - const dspParam = (id, name, parse = Number) => $(id).addEventListener('input', async (e) => { data.settings.jamesDsp ||= { enabled: false, preset: 'Flat', params: {} }; const value = parse(e.target.value); data.settings.jamesDsp.params ||= {}; data.settings.jamesDsp.params[name] = value; persist(); try { if (JamesDSP.controller) await JamesDSP.controller.update(name, value, data.settings.jamesDsp); } catch (error) { JamesDSP.fail(error); } }); - dspParam('setDspEqLow', 'eq.band0'); dspParam('setDspEqMid', 'eq.band8'); dspParam('setDspEqHigh', 'eq.band14'); dspParam('setDspBass', 'bass.boost'); dspParam('setDspLimiter', 'limiter.threshold'); - $('setDspCrossfeed').addEventListener('change', async (e) => { const s = data.settings.jamesDsp ||= { enabled: false, preset: 'Flat', params: {} }; s.params ||= {}; s.params['crossfeed.enabled'] = Number(e.target.checked); persist(); try { if (JamesDSP.controller) await JamesDSP.controller.update('crossfeed.enabled', Number(e.target.checked), s); } catch (error) { JamesDSP.fail(error); } }); - // ---- Wire playback controls ---- - $('setQuality').addEventListener('change', (e) => { - data.settings.quality = e.target.value; - els.quality.value = e.target.value; - persist(); - }); - $('setVolume').addEventListener('input', (e) => { - data.settings.volume = parseFloat(e.target.value); - els.volume.value = e.target.value; - Player.applyVolume(); - syncMuteIcon(); - persist(); - }); - $('setAudioOnly').addEventListener('change', (e) => { - data.settings.audioOnly = e.target.checked; - els.audioOnlyToggle.checked = e.target.checked; - persist(); - }); - $('setSaveBeforePlay').addEventListener('change', (e) => { - data.settings.saveBeforePlay = e.target.checked; - persist(); - }); - $('setLyricFlags').addEventListener('change', (e) => { - data.settings.lyricFlags = e.target.checked; - persist(); - Notes.refreshFlags(); - }); - $('setAutoPreload').addEventListener('change', (e) => { - data.settings.autoPreload = e.target.checked; - persist(); - if (e.target.checked) data.playlists.forEach(preloadPlaylist); - }); - $('setP2pShare').addEventListener('change', (e) => { - data.settings.p2pShare = e.target.checked; - persist(); - if (window.P2PClient) window.P2PClient.sync(); // share off → withdraws every holding - }); - $('setP2pReceive').addEventListener('change', (e) => { - data.settings.p2pReceive = e.target.checked; - persist(); - if (window.P2PClient) window.P2PClient.sync(); - }); - (async () => { - const info = $('p2pDeviceInfo'); - const cnt = $('p2pHoldingCount'); - if (!info || !cnt) return; - const P = window.P2PClient; - const d = P && P.device(); - info.textContent = !d ? 'Not registered yet' - : P.isConnected() ? `Online · peer ${P.peer()}` : 'Registered · not connected'; - const files = window.DeviceDB ? await window.DeviceDB.listFiles() : []; - cnt.textContent = `${files.filter((f) => f.state === 'verified').length} verified / ${files.length} saved`; - const unknown = window.P2PClient ? await window.P2PClient.unknownVideos() : []; - const row = $('p2pContributeRow'); - const btn = $('p2pContributeBtn'); - if (row && btn && unknown.length) { - row.classList.remove('hidden'); - btn.textContent = `Verify & share ${unknown.length} saved video${unknown.length === 1 ? '' : 's'}`; - btn.onclick = async () => { - btn.disabled = true; - let ok = 0; - for (let i = 0; i < unknown.length; i++) { - const v = videoById(unknown[i]) || {}; - btn.textContent = `Uploading ${i + 1} of ${unknown.length}…`; - const r = await window.P2PClient.contribute(unknown[i], { title: v.title || '', channel: v.channel || '' }); - if (r && r.ok) ok++; - } - toast(`Verified ${ok} of ${unknown.length} saved video${unknown.length === 1 ? '' : 's'}`); - btn.disabled = false; - row.classList.add('hidden'); - }; - } - })(); - // ---- Cache management ---- - $('clearCacheBtn').addEventListener('click', () => { - showModal('Clear all cached videos?', document.createTextNode('This frees disk space. Playlists keep their entries and will re-download on demand.'), [ - { label: 'Cancel', onClick: closeModal }, - { - label: 'Clear all', danger: true, onClick: async () => { - try { await API.cacheClear(); } catch {} - cachedIds.clear(); - closeModal(); - toast('Cache cleared'); - if (current) updateNowPlayingActions(); - renderList(); - }, - }, - ]); - }); - - // The saved-videos list folds; remember how the user left it. - { - const fold = $('cacheFold'); - try { fold.open = localStorage.getItem('ytpCacheFoldOpen') === '1'; } catch { /* blocked */ } - fold.addEventListener('toggle', () => { try { localStorage.setItem('ytpCacheFoldOpen', fold.open ? '1' : '0'); } catch { /* blocked */ } }); - } - - // ---- Populate live cache stats ---- - try { - const res = await API.cacheList(); - if (view.type !== 'settings') return; // user navigated away - const total = (res && res.total) || 0; - const items = (res && res.items) || []; - $('cacheTotal').textContent = `${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'}`; - $('cacheFoldCount').textContent = items.length ? `${items.length} · ${fmtBytes(total)}` : 'none'; - const listEl = $('cacheList'); - if (!items.length) { - listEl.innerHTML = '
No videos saved offline yet.
'; - } else { - items.sort((a, b) => b.size - a.size); - items.forEach((it) => { - const row = document.createElement('div'); - row.className = 'cache-item'; - row.innerHTML = `${fmtBytes(it.size)}`; - row.querySelector('.ci-title').textContent = videoTitleById(it.id); - row.querySelector('.ci-del').addEventListener('click', async () => { - try { await API.cacheDelete(it.id); } catch {} - cachedIds.delete(it.id); - if (current && current.meta && current.meta.id === it.id) updateNowPlayingActions(); - toast('Removed from cache'); - renderList(); - }); - listEl.appendChild(row); - }); - } - } catch { - const t = $('cacheTotal'); - if (t) t.textContent = 'Offline cache not available in this build'; - } - - // ---- Cache cap ---- - const capSel = $('cacheCapSelect'); - capSel.value = String(data.settings.cacheCap || 0); - capSel.addEventListener('change', (e) => { - data.settings.cacheCap = parseInt(e.target.value) || 0; - persist(); - }); - - // ---- Export / Import playlists ---- - $('importYtBtn')?.addEventListener('click', openImportYouTubeModal); - $('exportBtn').addEventListener('click', exportPlaylists); - $('importBtn').addEventListener('click', () => $('fileInput').click()); - $('fileInput').addEventListener('change', importPlaylists); - - // ---- Backup ---- - $('setAutoBackup').addEventListener('change', (e) => { data.settings.autoBackupEnabled = e.target.checked; persist(); }); - $('setBackupInterval').addEventListener('change', (e) => { data.settings.autoBackupIntervalDays = parseInt(e.target.value) || 7; persist(); }); - $('backupNowBtn').addEventListener('click', () => doAutoBackup()); - $('importBackupBtn').addEventListener('click', () => $('backupFileInput').click()); - $('backupFileInput').addEventListener('change', importBackup); - - // ---- Online profile ---- - $('profileCreateBtn').addEventListener('click', createProfileFlow); - $('profileLoadBtn').addEventListener('click', loadProfileFlow); - $('profileCopyLinkBtn').addEventListener('click', copyProfileLink); - $('profileShareBtn').addEventListener('click', shareProfileLink); - $('profileProtectBtn').addEventListener('click', protectProfileFlow); - $('profileExportCredBtn').addEventListener('click', exportProfileCredentials); - $('profileImportCredBtn').addEventListener('click', importProfileCredentials); - $('profileUnlinkBtn').addEventListener('click', () => { - data.profile = null; - ProfileSecret.clear(); - persist(); - updateProfileStatus(); - toast('Profile unlinked — this device stops syncing (server copy is kept)'); - }); - updateProfileStatus(); - - // ---- About: append server build tag + build time (WEB mode only) ---- - if (WEB) { - try { - const res = await fetch('/api/version', { cache: 'no-store' }); - if (res.ok && view.type === 'settings') { - const v = await res.json(); - const el = $('aboutVersion'); - if (el && v.buildTag) el.textContent = `v${v.version || APP_VERSION} · build ${v.buildTag}`; - const bt = $('aboutBuildTime'); - if (bt) { - const t = v.buildTime ? new Date(v.buildTime) : null; - if (t && !isNaN(t)) { - // Shown in GMT+8 regardless of device/server timezone, plus build age. - const gmt8 = new Date(t.getTime() + 8 * 3600e3).toISOString().replace('T', ' ').slice(0, 16); - const s = Math.max(0, Math.floor((Date.now() - t.getTime()) / 1000)); - const ago = s < 60 ? `${s} second${s === 1 ? '' : 's'} ago` - : s < 3600 ? `${Math.floor(s / 60)} minute${Math.floor(s / 60) === 1 ? '' : 's'} ago` - : s < 86400 ? `${Math.floor(s / 3600)} hour${Math.floor(s / 3600) === 1 ? '' : 's'} ago` - : `${Math.floor(s / 86400)} day${Math.floor(s / 86400) === 1 ? '' : 's'} ago`; - bt.textContent = `${gmt8} +08 · built ${ago}`; - } else { - bt.textContent = 'unknown'; - } - } - } - } catch { /* offline — leave the static version in place */ } - } - - // ---- Reset ---- - $('refreshUiBtn').addEventListener('click', async () => { - const btn = $('refreshUiBtn'); - btn.disabled = true; - btn.textContent = 'Refreshing…'; - // hardReloadUI() deletes all caches and unregisters the SW so the - // next page load always fetches fresh assets from the network. - await hardReloadUI(); - }); - - $('resetAppBtn').addEventListener('click', () => { - showModal( - 'Reset app?', - (() => { const p = document.createElement('p'); p.textContent = 'This clears all local data and caches. The page will reload.'; return p; })(), - [ - { label: 'Cancel', onClick: closeModal }, - { - label: 'Reset', danger: true, onClick: async () => { - closeModal(); - try { - localStorage.clear(); - sessionStorage.clear(); - if ('serviceWorker' in navigator) { - const regs = await navigator.serviceWorker.getRegistrations(); - await Promise.all(regs.map((r) => r.unregister())); - } - if ('caches' in window) { - const keys = await caches.keys(); - await Promise.all(keys.map((k) => caches.delete(k))); - } - } catch { /* best effort — reload regardless */ } - window.location.href = '/'; - }, - }, - ], - ); - }); -} - -function renderCard(v, index, list) { - const card = document.createElement('div'); - const isCached = cachedIds.has(v.id); - const isDownloading = downloading.has(v.id); - card.className = 'card' + (isCached ? ' cached' : '') + (isDownloading ? ' downloading' : '') + (selectedIds.has(v.id) ? ' selected' : ''); - card.dataset.id = v.id; - card._video = v; // read by f7-layout.js swipe actions - card.innerHTML = ` -
-
- - ${v.duration ? `${fmtTime(v.duration)}` : ''} - ${isDownloading ? '' : '⬇'} - ${isDownloading ? '
' : ''} -
-
-
-
-
- `; - card.querySelector('.card-title').textContent = v.title; - const chEl = card.querySelector('.card-channel'); - chEl.textContent = v.channel || ''; - if (channelKeyOf(v)) { - chEl.classList.add('link'); - chEl.title = 'View channel'; - chEl.addEventListener('click', (e) => { - e.stopPropagation(); - openChannel(channelKeyOf(v), v.channel); - }); - } - - // Playlist view: note under title (if present) and note edit button (Section 5) - if (view.type === 'playlist') { - const hasNote = !!(v.notes && v.notes.trim()); - if (hasNote) { - const noteEl = document.createElement('div'); - noteEl.className = 'card-note'; - noteEl.textContent = v.notes.trim(); - noteEl.title = v.notes.trim(); - const titleEl = card.querySelector('.card-title'); - if (titleEl) titleEl.after(noteEl); - } - - const noteBtn = document.createElement('button'); - noteBtn.type = 'button'; - noteBtn.className = 'card-note-btn' + (hasNote ? ' has-note' : ''); - noteBtn.title = hasNote ? 'Edit note' : 'Add note'; - noteBtn.textContent = '✎'; - noteBtn.addEventListener('click', (e) => { - e.stopPropagation(); - openSongNoteModal(v); - }); - const menuBtn = card.querySelector('.card-menu'); - card.insertBefore(noteBtn, menuBtn); - } - - card.addEventListener('pointerdown', () => warmStreams([v.id]), { passive: true }); - card.addEventListener('click', (e) => { - if (e.target.closest('.card-menu') || e.target.closest('.card-del') || e.target.closest('.card-note-btn') || e.target.closest('.card-channel.link')) return; - if (selectMode) { toggleSelectCard(v.id); return; } - playFromList(list, index, view.type === 'playlist' ? 'playlist:' + view.id : view.type === 'nowPlaying' ? queueSource : view.type); - }); - card.querySelector('.card-menu').addEventListener('click', (e) => { - e.stopPropagation(); - openCardMenu(v); - }); - - // Per-item delete in playlist, queue, history, and smart views. - if (view.type === 'playlist' || view.type === 'queue' || view.type === 'history' || view.type === 'smart') { - const del = document.createElement('button'); - del.className = 'card-del'; - del.title = view.type === 'queue' ? 'Remove from queue' - : (view.type === 'history' || view.type === 'smart') ? 'Remove from history' - : 'Remove from playlist'; - del.textContent = '✕'; - del.addEventListener('click', (e) => { - e.stopPropagation(); - if (view.type === 'queue') { removeFromQueue(v.id); return; } - if (view.type === 'history' || view.type === 'smart') { - data.history = data.history.filter((x) => x.id !== v.id); - persist(); - renderList(); - return; - } - const pl = data.playlists.find((p) => p.id === view.id); - if (pl) { pl.videos = pl.videos.filter((x) => x.id !== v.id); persist(); render(); toast('Removed from playlist'); } - }); - card.appendChild(del); - } - - // Drag-to-reorder in playlist and queue views - if (view.type === 'playlist' || view.type === 'queue') { - card.draggable = true; - card.addEventListener('dragstart', () => { - card.classList.add('dragging'); - dragSource = index; - }); - card.addEventListener('dragend', () => { - card.classList.remove('dragging'); - }); - card.addEventListener('dragover', (e) => { - e.preventDefault(); - card.classList.add('drag-over'); - }); - card.addEventListener('dragleave', () => { - card.classList.remove('drag-over'); - }); - card.addEventListener('drop', (e) => { - e.preventDefault(); - card.classList.remove('drag-over'); - const from = dragSource; - const to = index; - if (from === to || from < 0) return; - const arr = view.type === 'queue' - ? data.queue - : (data.playlists.find((p) => p.id === view.id) || {}).videos; - if (!arr) return; - const [moved] = arr.splice(from, 1); - arr.splice(to, 0, moved); - if (view.type === 'queue' && queueSource === 'queue') { - const playingId = current && current.meta && current.meta.id; - queue = data.queue.slice(); - queueIndex = playingId ? queue.findIndex((x) => x.id === playingId) : queueIndex; - renderUpNext(); - } - persist(); - renderList(); - }); - } - - return card; -} - -function markPlayingCard() { - document.querySelectorAll('.card').forEach((c) => { - c.classList.toggle('playing', current && c.dataset.id === current.meta.id); - }); -} // Only entries created by returning to the player participate here; leave // unrelated history (including Settings section navigation) to its owner. @@ -10493,7 +6948,7 @@ function openImportYouTubeModal() {

`; - showModal('⬇ Import from YouTube', successBody, [ + showModal('Import from YouTube', successBody, [ { label: 'Cancel', onClick: closeModal }, { label: 'Create playlist', @@ -10532,7 +6987,7 @@ function openImportYouTubeModal() { } } - showModal('⬇ Import from YouTube', body, [ + showModal('Import from YouTube', body, [ { label: 'Cancel', onClick: closeModal }, { label: 'Import', primary: true, onClick: handleImport }, ]); @@ -10557,14 +7012,14 @@ function openCardMenu(video) { // Quick: add to queue const queueBtn = document.createElement('button'); - queueBtn.textContent = '▶ Add to queue'; + queueBtn.innerHTML = TransportIcons.svg('play') + ' Add to queue'; queueBtn.onclick = () => { addToQueue(video); closeModal(); }; body.appendChild(queueBtn); const isCustom = !!video.custom; const exportBtn = document.createElement('button'); - exportBtn.textContent = '🖼 Save to gallery'; + exportBtn.innerHTML = MediaIcons.svg('download') + ' Save to gallery'; exportBtn.title = 'Put the video file in your gallery, Files app or Downloads folder'; exportBtn.onclick = () => { closeModal(); exportToDevice(video); }; body.appendChild(exportBtn); @@ -10578,7 +7033,7 @@ function openCardMenu(video) { saveBtn.className = 'danger'; saveBtn.onclick = () => { closeModal(); deleteCustomVideo(video.id); }; } else { - saveBtn.textContent = isSaved ? '✓ Saved offline — remove' : '⬇ Save for offline'; + saveBtn.innerHTML = MediaIcons.svg(isSaved ? 'offline' : 'download') + (isSaved ? ' Saved offline — remove' : ' Save for offline'); saveBtn.onclick = async () => { closeModal(); if (cachedIds.has(video.id)) { @@ -10805,17 +7260,17 @@ function updatePlaylistOfflineButton(btn, pl) { let label; btn.classList.remove('done', 'busy'); if (stats.total > 0 && stats.cached === stats.total) { - if (iconOnly) { setListIcon(btn, 'offlineDone'); btn.classList.add('done'); } else btn.textContent = '✓ Available offline'; + if (iconOnly) { setListIcon(btn, 'offlineDone'); btn.classList.add('done'); } else btn.innerHTML = MediaIcons.svg('offline') + ' Available offline'; btn.disabled = true; btn.classList.add('disabled'); label = 'All videos in this playlist are available offline'; } else if (isDownloading) { - if (iconOnly) { btn.innerHTML = `${stats.cached}/${stats.total}`; btn.classList.add('busy'); } else btn.textContent = `Saving ${stats.cached}/${stats.total}…`; + if (iconOnly) { btn.innerHTML = MediaIcons.svg('progress') + `${stats.cached}/${stats.total}`; btn.classList.add('busy'); } else btn.innerHTML = MediaIcons.svg('progress') + ` Saving ${stats.cached}/${stats.total}…`; btn.disabled = true; btn.classList.add('disabled'); label = `${stats.cached} of ${stats.total} videos saved offline`; } else { - if (iconOnly) setListIcon(btn, 'offline'); else btn.textContent = '⬇ Make available offline'; + if (iconOnly) setListIcon(btn, 'offline'); else btn.innerHTML = MediaIcons.svg('download') + ' Make available offline'; btn.disabled = stats.total === 0; btn.classList.toggle('disabled', stats.total === 0); label = stats.total === 0 ? 'Playlist is empty' : 'Make available offline'; @@ -10832,7 +7287,7 @@ async function makePlaylistOffline(pl) { const btn = $('plOfflineBtn'); if (btn) { const stats = playlistOfflineStats(pl); - btn.innerHTML = `${stats.cached}/${stats.total}`; + btn.innerHTML = MediaIcons.svg('progress') + `${stats.cached}/${stats.total}`; btn.classList.add('busy'); btn.disabled = true; btn.classList.add('disabled'); @@ -11159,7 +7614,7 @@ function wireUI() { // ---- Empty-home playlist grid ---- $('homePlNewBtn').addEventListener('click', () => newPlaylist(null)); - const importSetlist = document.createElement('button'); importSetlist.type = 'button'; importSetlist.className = 'icon-btn'; importSetlist.textContent = '⇥'; importSetlist.title = 'Import setlist'; importSetlist.setAttribute('aria-label', 'Import setlist'); importSetlist.onclick = () => SetlistImport.open(); els.newPlaylistBtn.after(importSetlist); + const importSetlist = document.createElement('button'); importSetlist.type = 'button'; importSetlist.className = 'icon-btn'; importSetlist.textContent = '⇥'; importSetlist.title = 'Import setlist'; importSetlist.setAttribute('aria-label', 'Import setlist'); importSetlist.onclick = () => SetlistImport.open(); els.newPlaylistBtn.after(importSetlist); Lazy.intent(importSetlist,'feature:setlist-import'); $('homePlGrid').addEventListener('scroll', updateHomeGridScrollState, { passive: true }); window.addEventListener('resize', updateHomeGridScrollState); @@ -11371,8 +7826,8 @@ document.querySelectorAll('.chip').forEach((c) => { $('sleepCurtain').addEventListener('click', () => $('sleepCurtain').classList.add('hidden')); $('sleepCancelBtn').addEventListener('click', () => { cancelSleepTimer(); toast('Sleep timer cancelled'); }); $('transitionBtn').addEventListener('click', () => Transition.cycle()); - $('shareMomentBtn').addEventListener('click', () => Share.open()); - $('externalBtn').addEventListener('click', () => External.open()); + $('shareMomentBtn').addEventListener('click', () => Share.open().catch(error => toast(error.message))); + $('externalBtn').addEventListener('click', () => External.open().catch(error => toast(error.message))); $('pipBtn').addEventListener('click', () => PiP.toggle()); $('eqBtn').addEventListener('click', () => EQ.open()); @@ -11936,41 +8391,7 @@ const fetchRelatedVideos = RelatedVideos.createLoader({ async function loadRelated(options = {}) { if (current?.meta) return fetchRelatedVideos(current.meta, options); } -function renderRelated() { - const panel = $('relatedPanel'), list = $('relatedList'); - if (!panel || !list || (relatedState.status === 'idle' && !relatedVideos.length)) { if (panel) panel.classList.add('hidden'); return; } - panel.classList.remove('hidden'); - list.classList.toggle('hidden', relatedCollapsed); - list.innerHTML = ''; - if (relatedState.message) { - const notice = document.createElement('div'); notice.className = 'related-notice'; notice.setAttribute('role', 'status'); - const message = document.createElement('p'); message.textContent = relatedState.message; notice.append(message); - if (relatedState.status === 'error') { - const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'related-retry'; retry.setAttribute('aria-label', 'Retry related videos'); - retry.innerHTML = 'Retry'; - retry.onclick = () => loadRelated({ refresh: true }); notice.append(retry); - } - list.append(notice); - } - relatedVideos.forEach((v) => { - const item = document.createElement('div'); - item.className = 'related-item'; - item.innerHTML = `
`; - item.querySelector('.ri-title').textContent = v.title; - const riChEl = item.querySelector('.ri-channel'); - riChEl.textContent = v.channel || ''; - if (channelKeyOf(v)) { - riChEl.classList.add('link'); - riChEl.title = 'View channel'; - riChEl.addEventListener('click', (e) => { e.stopPropagation(); openChannel(channelKeyOf(v), v.channel); }); - } - item.addEventListener('click', (e) => { - if (e.target.closest('.ri-channel.link')) return; - queue = [v]; queueIndex = 0; playFullMode = false; Player.loadVideo(v); renderUpNext(); - }); - list.appendChild(item); - }); -} + // ============================================================================ // Smart / auto playlists @@ -11981,26 +8402,7 @@ const SMART_PLAYLISTS = [ { id: 'unwatched', label: 'Unwatched' }, { id: 'autoMix', label: 'Auto Mix' }, ]; -function renderSmartSidebar() { - const list = $('smartPlaylistList'); - if (!list) return; - list.innerHTML = ''; - SMART_PLAYLISTS.forEach((sp) => { - const item = document.createElement('div'); - const isActive = view.type === 'smart' && view.smartType === sp.id; - item.className = 'playlist-item smart-item' + (isActive ? ' active' : ''); - item.innerHTML = `${sp.label}`; - item.addEventListener('click', () => { view = { type: 'smart', smartType: sp.id }; render(); }); - list.appendChild(item); - }); - if (WEB && data.settings.p2pReceive !== false) { - const item = document.createElement('div'); - item.className = 'playlist-item smart-item' + (view.type === 'p2p' ? ' active' : ''); - item.innerHTML = '📡 On other devices'; - item.addEventListener('click', () => { view = { type: 'p2p' }; render(); }); - list.appendChild(item); - } -} + function getSmartList(smartType) { switch (smartType) { case 'mostPlayed': { @@ -12308,7 +8710,7 @@ async function applyUpdate(target, { quiet = false } = {}) { const reg = _swReg || (('serviceWorker' in navigator) ? await navigator.serviceWorker.getRegistration() : null); const SU = window.SwUpdate; const reload = () => window.location.reload(); - if (!SU) { reload(); return; } // helper failed to load (flaky link): a reload is the best we can do + if (!SU) throw new Error('Update helper unavailable; try Refresh UI again when paused'); await SU.applyUpdate({ reg, container: navigator.serviceWorker, @@ -12316,11 +8718,11 @@ async function applyUpdate(target, { quiet = false } = {}) { // When the waiting worker already downloaded this build in the // background, the toast says so and nothing is re-downloaded. askStatus: (worker) => SU.askCacheStatus(worker, { timeoutMs: 4000, setTimeout: (fn, ms) => setTimeout(fn, ms) }), - onPhase: (phase) => { + onPhase: (phase, status) => { if (quiet) return; if (_updateToast) _updateToast.remove(); _updateToast = phase === 'ready' - ? toast('Applying the update…', { duration: 20000 }) + ? toast(status?.offlineReady === false ? `Applying the update… Offline cache is still preparing (${status.cached}/${status.total}).` : 'Applying the update…', { duration: 20000 }) : toast('Downloading the update…', { duration: 305000 }); }, refreshShell: ('caches' in window) ? () => SU.refreshShellInPlace({ @@ -12383,6 +8785,7 @@ async function registerServiceWorker() { // cached copy of sw.js — the server always returns it fresh (no-store). const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }); _swReg = reg; + Lazy.reportLayout(reg); // A waiting worker only means "update pending" when this page is already // controlled by a previous SW. On a FIRST install (fresh visit, or right @@ -12629,10 +9032,11 @@ async function boot() { // and re-render only if something actually changed. syncOnLaunch(); startPlaylistInboxWatch(); - Presenter.boot(); + if(LowerThird.active()) await Lazy.load('feature:lower-third'); + await Presenter.boot().catch(error => toast(error.message)); if (LowerThird.active()) return; SectionRail.boot(); - Share.bootFromUrl(); + if (isMediaId(new URLSearchParams(location.search).get('v'))) Share.bootFromUrl().catch(error => toast(error.message)); bootViewFromUrl(); bootSearchFromUrl(); if (WEB) bootShareFromUrl(); @@ -12658,7 +9062,7 @@ async function boot() { PiP.render(); LyricsWindow.configure({ prepare: () => { if (!isServiceModeOpen()) Notes.stageLyrics.set(true); }, view: () => isServiceModeOpen() && data.settings.serviceLyrics ? $('serviceLyrics') : $('stageLyrics'), line: () => $('serviceLyricsList')?.querySelector('.active')?.textContent || $('stageLyricsList')?.querySelector('.active')?.textContent || current?.meta?.title || 'Nothing playing', toggle: () => { Player.toggle(); updatePlayBtn(); }, next: playNext, refresh: () => { Notes.stageLyrics.render(true); Notes.serviceRefresh(); }, toast }); EQ.renderButton(); - if (data.settings.eq?.g?.length === 5) { data.settings.eq.g = EqCore.migrate(data.settings.eq.g); persist(); } + if (data.settings.eq?.g?.length === 5) { data.settings.eq.g = EqParameters.migrate(data.settings.eq.g); persist(); } EQ.apply(); EqCore.register({ settings: () => data.settings, persist, open: EQ.open, apply: () => { EQ.apply(); EqRender.scheduleAll(); } }); VocalReducer.register({ settings: () => data.settings, persist, apply: () => { EQ.apply(); EqRender.scheduleAll(); } }); @@ -12671,6 +9075,8 @@ async function boot() { else if (action === 'panic') MidiPedals.fade({ volume: Player.soundEl.volume, setVolume: v => { Player.soundEl.volume = v; }, pause: () => { Player.pause(); updatePlayBtn(); }, restore: () => Player.applyVolume() }); } }); applyAppearance(); + Lazy.load('layout:'+data.settings.layout).then(()=>{ if(window.BentoHub)BentoHub.playlists=()=>data.playlists||[]; renderSidebar(); }).catch(error=>toast(error.message)); + if(data.settings.midiEnabled)Lazy.load('feature:midi').catch(error=>toast(error.message)); CarMode.configure({ input: els.searchInput, search: () => els.searchForm.requestSubmit(), toast, state: () => ({ title: current?.meta?.title || '', paused: Player.master?.paused !== false }), action: action => { if (action === 'toggle') { Player.toggle(); updatePlayBtn(); } else if (action === 'next') playNext(); else if (action === 'prev') playPrev(); else if (action === 'back') Player.seek(Math.max(0, (Player.master.currentTime || 0) - 15)); } }); updateProfileStatus(); updateLoopRepeatButtons(); @@ -12684,10 +9090,12 @@ async function boot() { await refreshCachedIds(); startCacheResyncWatch(); // Peer-to-peer: report what this device holds (on by default; settings.p2pShare). - if (WEB && window.P2PClient) { - window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '', getProfileSecret: () => ProfileSecret.getFor(data.profile?.name) }); - if (window.P2PTransfer) window.P2PTransfer.start({ canShare: () => false }); // New scoped invitations replace the unconfirmed legacy sender. - } + Lazy.onLoad('feature:p2p', () => { if (WEB) { + window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '', getProfileSecret: () => ProfileSecret.getFor(data.profile?.name), initialDelay: 0 }); + if (window.P2PTransfer) window.P2PTransfer.start({ canShare: () => false }); // Scoped invitations preserve receiver confirmation. + } }); + // Preserve the existing eight-second sharing startup without parsing transport at boot. + if (WEB) setTimeout(() => Lazy.load('feature:direct').catch(error=>console.warn(error.message)),8000); data.playlists.forEach(preloadPlaylist); preloadPinnedPlaylists(); updateBottomChrome(); @@ -12716,6 +9124,12 @@ async function boot() { if (WEB) checkUpdateOutcome(); registerServiceWorker(); if (WEB) pollBuildTag(); + window.addEventListener('ytp-reload-required',()=>checkBuildTag()); + window.addEventListener('ytp-lazy-error',event=>{ toast(event.detail?.error?.message||'Feature unavailable; try Refresh UI'); checkBuildTag(); }); + Lazy.intent($('navPartyBtn'),'feature:party-dj'); Lazy.intent($('navRemoteBtn'),'feature:direct'); + Lazy.intent($('remoteChip'),'feature:direct'); Lazy.intent($('eqBtn'),'feature:eq-core'); + Lazy.intent($('shareMomentBtn'),'feature:share-external'); Lazy.intent($('externalBtn'),'feature:share-external'); + Lazy.reportLayout?.(); Lazy.warm?.(); if (WEB) { fetch('/api/user/sync', { method: 'POST', diff --git a/frontend/asset-sync-core.js b/frontend/asset-sync-core.js new file mode 100644 index 0000000..0c6ede2 --- /dev/null +++ b/frontend/asset-sync-core.js @@ -0,0 +1,204 @@ +/* Exact, resumable asset synchronization shared by pages and workers. */ +(function (root) { + 'use strict'; + const CACHE = 'ytplayer-assets'; + const STATE = '/__ytp_asset_state'; + const url = (path, file) => path + '?v=' + file.h; + + function paths(manifest) { + return Object.keys(manifest.files).sort(); + } + + function blocking(manifest, previous, activeLayout) { + const names = new Set(Object.entries(manifest.groups) + .filter(([name, group]) => name === 'core' || !group.background || + name === 'layout:' + activeLayout || + (previous?.groups[name] && previous.groups[name].contract !== group.contract)) + .map(([name]) => name)); + for (const name of names) for (const dependency of manifest.groups[name].dependencies || []) names.add(dependency); + return [...new Set([...names].flatMap(name => manifest.groups[name].files))].sort(); + } + + function plan(manifest, keys) { + const held = new Set(keys); + return { + missing: paths(manifest).map(path => url(path, manifest.files[path])).filter(key => !held.has(key)), + blocking: blocking(manifest).map(path => url(path, manifest.files[path])), + }; + } + + function retained(current, previous) { + const currentPaths = new Set(paths(current)); + const keep = new Set([...currentPaths].map(path => url(path, current.files[path]))); + if (previous) for (const path of paths(previous)) { + if (!currentPaths.has(path) || current.files[path]?.h !== previous.files[path].h) keep.add(url(path, previous.files[path])); + } + return keep; + } + + function fallback(manifest, previous, path) { + if (!manifest.files[path] || !previous?.files[path]) return null; + const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path)); + if (!group || !previous.groups[group]?.files.includes(path) || + manifest.groups[group].contract !== previous.groups[group].contract) return null; + // A stale core must understand every group contract in the new shell. + if (path === "/app.js" && Object.entries(manifest.groups).some(([name, entry]) => + previous.groups[name]?.contract !== entry.contract)) return null; + return url(path, previous.files[path]); + } + + async function state(cache) { + const response = await cache.match(STATE); + return response ? response.json() : null; + } + + function verifiedProbe(response, hash) { + if (!response) return false; + const valid = response.headers.get('X-Asset-Hash') === hash; + // Metadata checks must release Blob/data-pipe readers. They never consume + // asset bytes, and cancelling this response does not remove its cache entry. + response.body?.cancel().catch(() => {}); + return valid; + } + + async function status(manifest, cache, { previous, activeLayout, offline = true } = {}) { + const found = await Promise.all(blocking(manifest, previous, activeLayout).map(async path => { + const response = await cache.match(url(path, manifest.files[path])); + return verifiedProbe(response, manifest.files[path].h); + })); + const missing = found.filter(value => !value).length; + return { ready: missing === 0, missing, version: manifest.buildTag, ...(offline ? await completeness(manifest, cache) : {}) }; + } + + // All concurrent messages/layout jobs in this worker share six download slots. + let downloads = 0; + const waiters = []; + async function downloadSlot(task) { + if (downloads >= 6) await new Promise(resolve => waiters.push(resolve)); + else downloads++; + try { return await task(); } + finally { const next = waiters.shift(); if (next) next(); else downloads--; } + } + + const pendingDownloads = new Map(); + async function sharedDownload(key, task) { + while (pendingDownloads.has(key)) await pendingDownloads.get(key).catch(() => {}); + const promise = downloadSlot(task); + pendingDownloads.set(key, promise); + try { return await promise; } + finally { pendingDownloads.delete(key); } + } + + async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups, all = false }) { + const previous = (await state(cache))?.current; + const missing = []; + const selected = all ? paths(manifest) : groups ? [...new Set(groups.flatMap(name => manifest.groups[name]?.files || []))].sort() + : blocking(manifest, previous, activeLayout); + for (const path of selected) { + const key = url(path, manifest.files[path]); + const response = await cache.match(key); + if (!verifiedProbe(response, manifest.files[path].h)) missing.push(key); + } + const count = missing.length; + let failure; + await Promise.all(Array.from({ length: Math.max(1, Math.min(6, concurrency, count)) }, async () => { + while (missing.length) { + const key = missing.shift(); + try { await sharedDownload(key, async () => { + const held = await cache.match(key); + if (verifiedProbe(held, key.split('v=')[1])) return; + let error; + for (let attempt = 0; attempt < Math.min(3, attempts); attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 30000); + try { + const response = await fetchFn(key, { credentials: 'same-origin', signal: controller.signal }); + if (!response.ok || response.headers.get('X-Asset-Hash') !== key.split('v=')[1]) { + throw new Error('Asset hash mismatch: ' + key); + } + // Keep the timeout through body consumption, not just response headers. + await cache.put(key, response); + error = null; + break; + } catch (err) { error = err; } + finally { clearTimeout(timer); } + } + if (error) throw error; + }); } catch (error) { failure = error; } + } + })); + if (failure) throw failure; + if (!groups && !all && !(await status(manifest, cache, { previous, activeLayout, offline: false })).ready) throw new Error('Incomplete blocking assets'); + return { refreshed: count, caches: 1 }; + } + + + // Verified cache entries are the durable progress journal. Counters are rebuilt + // after worker termination or browser eviction; never trust a stored ready bit. + async function completeness(manifest, cache) { + const files = paths(manifest); + const missingFiles = (await Promise.all(files.map(async path => { + const response = await cache.match(url(path, manifest.files[path])); + return verifiedProbe(response, manifest.files[path].h) ? null : path; + }))).filter(Boolean); + const total = files.length; + return { offlineReady: missingFiles.length === 0, cached: total - missingFiles.length, total, missingFiles }; + } + + const completionKey = manifest => '/__ytp_completion/' + manifest.buildTag; + async function complete(manifest, { cache, fetchFn, cycles = 4, now = Date.now, + sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), baseDelay = 1000, + maxDelay = 30000, notify = () => {}, force = false } = {}) { + const key = completionKey(manifest), saved = await cache.match(key); + let job = saved ? await saved.json() : { failures: 0, nextRetryAt: 0 }; + let snapshot = await completeness(manifest, cache); + const publish = async running => { + job = { ...job, ...snapshot, running, version: manifest.buildTag }; + await cache.put(key, Response.json(job)); + await notify(job); + }; + if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; await publish(false); return job; } + if (!force && job.nextRetryAt > now()) await sleep(Math.min(maxDelay, job.nextRetryAt - now())); + for (let cycle = 0; cycle < cycles; cycle++) { + await publish(true); + let error; + try { await syncAssets(manifest, { cache, fetchFn, all: true }); } + catch (failure) { error = failure; } + snapshot = await completeness(manifest, cache); + if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; job.error = null; await publish(false); return job; } + job.failures++; + const delay = Math.min(maxDelay, baseDelay * 2 ** Math.min(job.failures - 1, 10)); + job.nextRetryAt = now() + delay; + job.error = String(error?.message || 'Incomplete offline cache'); + await publish(cycle + 1 < cycles); + if (cycle + 1 < cycles) await sleep(delay); + } + return job; + } + + async function commit(manifest, cache, { previousClients, activeLayout } = {}) { + const old = await state(cache); + if (!(await status(manifest, cache, { previous: old?.current, activeLayout, offline: false })).ready) { + throw new Error('Incomplete blocking assets'); + } + const sameBuild = old?.current?.buildTag === manifest.buildTag; + const previous = sameBuild ? old.previous : old?.current; + const next = { + current: manifest, previous, + previousClients: previousClients || (sameBuild ? old.previousClients : []), + }; + // One state write publishes the complete build and its old-tab affinity. + await cache.put(STATE, new Response(JSON.stringify(next), { headers: { 'Content-Type': 'application/json' } })); + const keep = retained(manifest, previous); + for (const request of await cache.keys()) { + const parsed = new URL(request.url); + const key = parsed.pathname + parsed.search; + if (parsed.searchParams.has('v') && !keep.has(key)) await cache.delete(key); + } + return next; + } + + const api = { CACHE, STATE, url, paths, blocking, plan, retained, fallback, status, completeness, completionKey, complete, syncAssets, state, commit }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.AssetSyncCore = api; +})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/frontend/asset-sync-core.test.js b/frontend/asset-sync-core.test.js new file mode 100644 index 0000000..60b366d --- /dev/null +++ b/frontend/asset-sync-core.test.js @@ -0,0 +1,104 @@ +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const core=require('./asset-sync-core'); +const manifest=(a='a',b='b')=>({buildTag:a+b,files:{'/a.js':{h:a},'/index.html':{h:b}},groups:{core:{files:['/a.js','/index.html']}}}); +function cache(){const entries=new Map();return {entries,async match(k){return entries.get(k)?.clone()},async put(k,r){entries.set(k,r.clone())},async keys(){return [...entries.keys()].map(url=>({url:'http://local'+url}))},async delete(k){return entries.delete(k)}}} +const response=h=>new Response(h,{headers:{'X-Asset-Hash':h}}); +test('exact diff shares unchanged URLs; retains N-1 and prunes N-2',()=>{assert.deepEqual(core.plan(manifest(),['/a.js?v=a']).missing,['/index.html?v=b']);assert.deepEqual([...core.retained(manifest('c'),manifest())].sort(),['/a.js?v=a','/a.js?v=c','/index.html?v=b']);}); +test('interrupted sync resumes verified files and refuses a mismatched hash',async()=>{const c=cache();let fail=true;let calls=[];const fetchFn=async u=>{calls.push(u);if(u.includes('index')&&fail)return response('wrong');return response(u.split('=')[1])};await assert.rejects(core.syncAssets(manifest(),{cache:c,fetchFn}),/hash/);assert.ok(await c.match('/a.js?v=a'));fail=false;calls=[];await core.syncAssets(manifest(),{cache:c,fetchFn});assert.deepEqual(calls,['/index.html?v=b']);await c.delete('/a.js?v=a');assert.equal((await core.status(manifest(),c)).missing,1);}); +test('commit is atomic and pruning keeps only current and previous changed versions',async()=>{const c=cache();await c.put('/a.js?v=z',response('z'));await core.syncAssets(manifest(),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest(),c);await core.syncAssets(manifest('c'),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest('c'),c);assert.ok(await c.match('/a.js?v=a'));assert.equal(await c.match('/a.js?v=z'),undefined);await core.syncAssets(manifest('d'),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest('d'),c);assert.equal(await c.match('/a.js?v=a'),undefined);}); +test('downloads are capped at six and each failure gets three attempts',async()=>{const c=cache(),files={},list=[];for(let i=0;i<13;i++){const p='/'+i+'.js';files[p]={h:String(i)};list.push(p)}const m={buildTag:'pool',files,groups:{core:{files:list}}};let active=0,max=0;const tries={};await core.syncAssets(m,{cache:c,concurrency:20,fetchFn:async u=>{active++;max=Math.max(max,active);await new Promise(r=>setTimeout(r,5));active--;tries[u]=(tries[u]||0)+1;if(tries[u]<3)throw Error('drop');return response(u.split('=')[1])}});assert.equal(max,6);assert.ok(Object.values(tries).every(n=>n===3));}); +test('background files do not block, but active layout and contract changes do',()=>{const m=manifest();m.groups.extra={background:true,contract:2,files:['/extra.js']};m.files['/extra.js']={h:'e'};assert.deepEqual(core.blocking(m),['/a.js','/index.html']);assert.ok(core.blocking(m,{groups:{extra:{contract:1}}}).includes('/extra.js'));m.groups['layout:classic']={background:true,files:['/classic.css']};m.files['/classic.css']={h:'c'};assert.ok(core.blocking(m,null,'classic').includes('/classic.css'));}); +test('removed files survive one previous build for open tabs',()=>{const previous=manifest(),current=manifest('c');delete current.files['/a.js'];current.groups.core.files=['/index.html'];assert.ok(core.retained(current,previous).has('/a.js?v=a'));}); + +test('staged fallback permits only the previous same-contract version of a shipped group file', () => { + const old = manifest(), next = manifest('c'); + old.groups.core.contract = next.groups.core.contract = 1; + assert.equal(core.fallback(next, old, '/a.js'), '/a.js?v=a'); + next.groups.core.contract = 2; + assert.equal(core.fallback(next, old, '/a.js'), null); + assert.equal(core.fallback(next, old, '/missing.js'), null); +}); + +test('background synchronization fills all groups without publishing a new current manifest', async () => { + const c = cache(), m = manifest(); + m.groups.optional = { contract: 1, background: true, files: ['/extra.js'] }; + m.files['/extra.js'] = { h: 'e' }; + const fetchFn = async key => response(key.split('=')[1]); + await core.syncAssets(m, { cache: c, fetchFn }); + assert.equal(await c.match('/extra.js?v=e'), undefined); + await core.commit(m, c); + const before = await core.state(c); + const result = await core.syncAssets(m, { cache: c, fetchFn, groups: ['optional'] }); + assert.equal(result.refreshed, 1); + assert.ok(await c.match('/extra.js?v=e')); + assert.deepEqual(await core.state(c), before); +}); + +test('active layouts also block on shared dependencies, while other layouts stay background', () => { + const m=manifest(); m.groups['layout:glass-stage']={contract:1,background:true,files:['/glass.css'],dependencies:['feature:controls']}; m.groups['feature:controls']={contract:1,background:true,files:['/controls.js']}; m.groups['layout:bento-hub']={contract:1,background:true,files:['/bento.css']}; + for(const path of ['/glass.css','/controls.js','/bento.css'])m.files[path]={h:'h'}; + assert.deepEqual(core.blocking(m,null,'glass-stage'),['/a.js','/controls.js','/glass.css','/index.html']); +}); + +test('removing group membership retains the old tab URL even if the physical file is still shipped unchanged',()=>{ + const previous=manifest(),current=manifest();current.groups.core.files=['/index.html']; + assert.ok(core.retained(current,previous).has('/a.js?v=a')); +}); + +test('completion includes ungrouped runtime files and exposes verified missing paths', async () => { + const c=cache(), m=manifest();m.files['/worker.js']={h:'w'}; + await core.syncAssets(m,{cache:c,fetchFn:async u=>response(u.split('=')[1])}); + const status=await core.status(m,c); + assert.equal(status.ready,true);assert.equal(status.offlineReady,false); + assert.deepEqual(status.missingFiles,['/worker.js']);assert.equal(status.cached,2);assert.equal(status.total,3); + await core.complete(m,{cache:c,fetchFn:async u=>response(u.split('=')[1])}); + assert.equal((await core.status(m,c)).offlineReady,true); +}); + +test('completion persists backoff and resumes only missing files after a worker restart',async()=>{ + const c=cache(),m=manifest();let clock=1000;const calls=[]; + await core.complete(m,{cache:c,cycles:1,now:()=>clock,baseDelay:10,fetchFn:async u=>{if(u.includes('index'))throw Error('offline');return response('a');}}); + const checkpoint=await(await c.match(core.completionKey(m))).json(); + assert.equal(checkpoint.running,false);assert.equal(checkpoint.failures,1);assert.equal(checkpoint.nextRetryAt,1010); + await core.complete(m,{cache:c,now:()=>clock,sleep:async ms=>{assert.equal(ms,10);clock+=ms;},fetchFn:async u=>{calls.push(u);return response(u.split('=')[1]);}}); + assert.deepEqual(calls,['/index.html?v=b']); + const status=await(await c.match(core.completionKey(m))).json();assert.equal(status.offlineReady,true);assert.equal(status.failures,0); + await c.delete('/a.js?v=a');assert.equal((await core.status(m,c)).offlineReady,false); +}); + +test('completion retries with exponential backoff and keeps making progress past a failed asset',async()=>{ + const c=cache(),m=manifest(),delays=[];let tries=0; + const job=await core.complete(m,{cache:c,baseDelay:5,sleep:async ms=>delays.push(ms),fetchFn:async u=>{if(u.includes('a.js')&&++tries<=6)throw Error('drop');return response(u.split('=')[1]);}}); + assert.deepEqual(delays,[5,10]);assert.equal(job.offlineReady,true);assert.ok(await c.match('/index.html?v=b')); +}); + +test('overlapping layout and completion jobs share a six-download ceiling',async()=>{ + const c=cache(),m=manifest();m.groups.extra={background:true,files:[]}; + for(let i=0;i<15;i++){const p='/extra'+i+'.js';m.files[p]={h:String(i)};m.groups.extra.files.push(p);} + let active=0,peak=0;const calls=new Map(); + const fetchFn=async u=>{active++;peak=Math.max(active,peak);calls.set(u,(calls.get(u)||0)+1);await new Promise(r=>setTimeout(r,3));active--;return response(u.split('=')[1]);}; + await Promise.all([core.complete(m,{cache:c,fetchFn}),core.syncAssets(m,{cache:c,fetchFn,groups:['extra']})]); + assert.ok(peak<=6);assert.ok([...calls.values()].every(count=>count===1)); +}); + +test('blocking commit never waits on an optional file readiness probe',{timeout:1000},async()=>{ + const c=cache(),m=manifest();m.files['/extra.js']={h:'extra'};m.groups.extra={files:['/extra.js'],background:true,contract:1}; + await c.put('/a.js?v=a',response('a'));await c.put('/index.html?v=b',response('b')); + const match=c.match;let extraProbes=0;c.match=async key=>{if(key==='/extra.js?v=extra'){extraProbes++;return new Promise(()=>{});}return match(key);}; + await core.commit(m,c);assert.equal(extraProbes,0);assert.equal((await core.state(c)).current.buildTag,m.buildTag); + c.match=match;assert.equal((await core.status(m,c)).offlineReady,false); +}); + +test('metadata probes release response streams while stored asset bodies remain readable',async()=>{ + const c=cache(),m=manifest();await c.put('/a.js?v=a',response('a'));await c.put('/index.html?v=b',response('b')); + const match=c.match,probes=[];c.match=async key=>{const r=await match(key);if(r)probes.push(r);return r;}; + assert.equal((await core.status(m,c)).offlineReady,true);assert.ok(probes.length>0);assert.ok(probes.every(r=>r.bodyUsed)); + assert.equal(await(await match('/a.js?v=a')).text(),'a');assert.equal(await(await match('/index.html?v=b')).text(),'b'); +}); + +test('stale app core requires every feature contract to match the new shell',()=>{ + const old={files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}}; + const next=structuredClone(old);next.files['/app.js'].h='new';next.groups.extra.contract=2; + assert.equal(core.fallback(next,old,'/app.js'),null);next.groups.extra.contract=1;assert.equal(core.fallback(next,old,'/app.js'),'/app.js?v=old'); +}); diff --git a/frontend/asset-worker.test.js b/frontend/asset-worker.test.js new file mode 100644 index 0000000..d7d9978 --- /dev/null +++ b/frontend/asset-worker.test.js @@ -0,0 +1,77 @@ +const {test}=require('node:test');const assert=require('node:assert/strict');const vm=require('node:vm');const fs=require('node:fs');const core=require('./asset-sync-core'); +function environment(){ + const stores=new Map(),listeners={},messages=[],skips=[],fetches=[],selfMessages=[];const m={buildTag:'next',files:{'/index.html':{h:'index'},'/app.js':{h:'app'}},groups:{core:{files:['/index.html','/app.js']}}}; + const storage={async keys(){return [...stores.keys()]},async delete(n){return stores.delete(n)},async open(n){if(!stores.has(n)){const map=new Map();stores.set(n,{async match(k){return map.get(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)?.clone()},async put(k,r){map.set(k,r.clone())},async keys(){return [...map.keys()].map(k=>({url:'https://local'+k}))},async delete(k){return map.delete(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)}})}return stores.get(n)}}; + const sandbox={__BUILD_TAG__:'next',__ASSET_SYNC__:true,AssetSyncCore:core,importScripts:()=>{},caches:storage,URL,Response,Request,Headers,setTimeout,clearTimeout,crypto:require('node:crypto').webcrypto,console,fetch:async u=>{fetches.push(u);return u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':m.files[u.split('?')[0]]?.h || ''}})},self:{registration:{active:{postMessage:data=>selfMessages.push(data)}},location:{origin:'https://local'},addEventListener:(t,f)=>listeners[t]=f,skipWaiting:()=>skips.push(1),clients:{claim:async()=>{},matchAll:async()=>[{id:'old',postMessage:x=>messages.push(x)}]}}}; + vm.runInNewContext(fs.readFileSync(__dirname+'/sw.js','utf8'),sandbox); + async function dispatch(t,e={}){const tasks=[];listeners[t]({...e,waitUntil:v=>tasks.push(v)});await Promise.all(tasks)} + async function request(url,mode='cors',clientId='new'){let p;const tasks=[];listeners.fetch({request:{url:'https://local'+url,method:'GET',mode},clientId,waitUntil:v=>tasks.push(v),respondWith:v=>p=v});const response=await p;await Promise.all(tasks);return response;} + return {storage,m,skips,fetches,dispatch,request,sandbox,selfMessages,async complete(){for(const data of selfMessages.splice(0))await dispatch('message',{data});}}; +} +test('install does not activate or publish; CACHE_STATUS is honest; activation commits and exact requests self-heal',async()=>{const e=environment();await e.dispatch('install');assert.equal(e.skips.length,0);const cache=await e.storage.open(core.CACHE);assert.equal(await core.state(cache),null);let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);await e.dispatch('activate');await e.complete();assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await e.request('/playlist/x','navigate')).headers.get('X-Asset-Hash'),'index');await cache.delete('/app.js?v=app');assert.equal((await core.status(e.m,cache)).ready,false);await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal((await core.status(e.m,cache)).offlineReady,true);assert.equal(reply.version,'next');assert.equal((await e.request('/app.js')).headers.get('X-Asset-Hash'),'app');assert.ok(await cache.match('/app.js?v=app'));assert.equal(await cache.match('/app.js?v=stale'),undefined);assert.equal((await e.request('/app.js?v=stale')).status,409);}); +test('reported playback defers explicit activation until pause; install never calls skipWaiting',async()=>{const e=environment(),source={id:'p'};await e.dispatch('message',{data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:true}});await e.dispatch('message',{source,data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:false}});assert.equal(e.skips.length,1);}); + +test('staged same-contract responses use verified N-1 without poisoning the new URL',async()=>{ + const e=environment(); const cache=await e.storage.open(core.CACHE); + const old={buildTag:'old',files:{'/extra.js':{h:'old'}},groups:{core:{files:[],contract:1},'feature:extra':{files:['/extra.js'],contract:1,background:true}}}; + e.m.groups.core.contract=1; e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true}; e.m.files['/extra.js']={h:'new'}; + await cache.put(core.STATE,Response.json({current:old})); await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old','Cache-Control':'public, max-age=31536000, immutable','Content-Encoding':'gzip','Content-Length':'9'}})); + await e.dispatch('install'); await e.dispatch('activate');await e.complete(); + assert.ok(await cache.match('/extra.js?v=new'));await cache.delete('/extra.js?v=new'); + assert.equal(await cache.match('/extra.js?v=new'),undefined); + const stale=await e.request('/extra.js?v=new'); assert.equal(await stale.text(),'old body');assert.equal(stale.headers.get('X-Asset-Hash'),'old');assert.equal(stale.headers.get('Cache-Control'),'no-store');assert.equal(stale.headers.get('Content-Encoding'),null);assert.equal(stale.headers.get('Content-Length'),null);assert.equal(await cache.match('/extra.js?v=new'),undefined); + await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:true}}); assert.ok(await cache.match('/extra.js?v=new')); + await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}); assert.equal((await cache.match('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new'); + assert.equal((await e.request('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new'); +}); + +test('a changed background contract is downloaded before ready and cannot fall back after commit',async()=>{ + const e=environment(),cache=await e.storage.open(core.CACHE); + const old={buildTag:'old',files:{'/extra.js':{h:'old'}},groups:{core:{files:[],contract:1},'feature:extra':{files:['/extra.js'],contract:1,background:true}}}; + e.m.groups.core.contract=1;e.m.groups['feature:extra']={files:['/extra.js'],contract:2,background:true};e.m.files['/extra.js']={h:'new'}; + await cache.put(core.STATE,Response.json({current:old}));await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old'}})); + await e.dispatch('install');assert.equal((await core.state(cache)).current.buildTag,'old'); + assert.equal((await cache.match('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new'); + let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true); + await e.dispatch('activate');await e.complete();await cache.delete('/extra.js?v=new'); + const response=await e.request('/extra.js?v=new');assert.equal(response.headers.get('X-Asset-Hash'),'new');assert.notEqual(await response.text(),'old body'); +}); + +test('overlapping idle warm messages share one download pool',async()=>{ + const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true}; + await e.dispatch('install');await e.dispatch('activate');await e.complete(); + await Promise.all([e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}),e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}})]); + assert.equal(e.fetches.filter(url=>url==='/extra.js?v=extra').length,1); +}); + +test('waiting status does not start completion and activation delegates it to its own event',async()=>{ + const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true}; + await e.dispatch('install');let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]}); + assert.equal(reply.ready,true);assert.equal(reply.offlineReady,false);assert.ok(!e.fetches.includes('/extra.js?v=extra')); + let release,started;const gate=new Promise(r=>release=r),begin=new Promise(r=>started=r),fetch=e.sandbox.fetch; + e.sandbox.fetch=async url=>{if(url==='/extra.js?v=extra'){started();await gate;}return fetch(url);}; + await e.dispatch('activate');const cache=await e.storage.open(core.CACHE); + assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await core.status(e.m,cache)).ready,true); + assert.equal((await core.status(e.m,cache)).offlineReady,false);assert.equal(e.selfMessages[0].type,'COMPLETE_ASSETS'); + const job=e.complete();await begin;assert.equal(await cache.match('/extra.js?v=extra'),undefined);release();await job; + assert.equal((await core.status(e.m,cache)).offlineReady,true); +}); + +test('page-captured app is reused on first install without a worker download',async()=>{ + const e=environment(),cache=await e.storage.open(core.CACHE); + await cache.put('/app.js?v=app',new Response('page captured app',{headers:{'X-Asset-Hash':'app'}})); + await e.dispatch('install');assert.equal(e.fetches.includes('/app.js?v=app'),false); + assert.equal(await(await cache.match('/app.js?v=app')).text(),'page captured app'); + assert.equal(e.skips.length,0);assert.equal(await core.state(cache),null); +}); + + +test('worker never supplies stale app to a native script when a feature contract changed',async()=>{ + const e=environment(),cache=await e.storage.open(core.CACHE); + const old={buildTag:'old',files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}}; + e.m.groups.core.contract=1;e.m.groups.extra={contract:2,background:true,files:[]}; + await cache.put(core.STATE,Response.json({current:old}));await cache.put('/app.js?v=old',new Response('old app',{headers:{'X-Asset-Hash':'old'}})); + await e.dispatch('install');await e.dispatch('activate');await cache.delete('/app.js?v=app'); + e.sandbox.fetch=async()=>{throw Error('offline')}; + const response=await e.request('/app.js?v=app');assert.equal(response.status,503);assert.equal(await response.text(),'Offline'); +}); diff --git a/frontend/assets.json b/frontend/assets.json new file mode 100644 index 0000000..844fd02 --- /dev/null +++ b/frontend/assets.json @@ -0,0 +1,377 @@ +{ + "groups": { + "core": { + "contract": 2, + "eager": true, + "files": [ + "/index.html", + "/styles.css", + "/service-ui.css", + "/refresh.css", + "/layout-base.css", + "/recommendations.css", + "/flag-ui.js", + "/async-guard.js", + "/visible-timer.js", + "/asset-sync-core.js", + "/sw-update.js", + "/fingerprint.js", + "/opfs.js", + "/opfs-worker.js", + "/lyrics-core.js", + "/lyrics-display.js", + "/sha256.js", + "/resume-core.js", + "/loudness-wasm.js", + "/loudness.js", + "/sha256-wasm.js", + "/settings-sections.js", + "/related-videos.js", + "/related-videos.css", + "/audio-continuity.js", + "/fullscreen-orientation.js", + "/fullscreen-orientation.css", + "/download-actions.js", + "/download-actions.css", + "/download-errors.js", + "/downloads.js", + "/downloads-page.js", + "/offline-pages.css", + "/saved-page.js", + "/server-backfill.js", + "/saved-page.css", + "/prewarm-next.js", + "/settings-sections.css", + "/app.js", + "/manifest.webmanifest", + "/icons/icon-192.png", + "/icons/icon-512.png", + "/fonts/fonts.css", + "/fonts/BricolageGrotesque-latin-ext.woff2", + "/fonts/BricolageGrotesque-latin.woff2", + "/fonts/HankenGrotesk-latin-ext.woff2", + "/fonts/HankenGrotesk-latin.woff2", + "/fonts/JetBrainsMono-latin-ext.woff2", + "/fonts/JetBrainsMono-latin.woff2", + "/lazy.js", + "/feature-bootstrap.js", + "/eq-parameters.js", + "/assets.json", + "/device-db.js", + "/shell-core.js", + "/views-core.js", + "/section-rail.js" + ], + "background": false + }, + "layout:glass-stage": { + "contract": 1, + "eager": false, + "files": [ + "/theme-glass.css", + "/theme-glass-panel.css", + "/glass-panel-layout.js" + ], + "background": true, + "dependencies": [ + "feature:layout-controls" + ] + }, + "layout:classic": { + "contract": 1, + "eager": false, + "files": [ + "/theme-classic.css" + ], + "background": true + }, + "layout:bento-hub": { + "contract": 1, + "eager": false, + "files": [ + "/theme-bento-hub.css", + "/bento-hub.js", + "/fonts/PlusJakartaSans-latin.woff2", + "/fonts/PlusJakartaSans-latin-ext.woff2" + ], + "background": true, + "dependencies": [ + "feature:layout-controls" + ] + }, + "layout:f7-swipe": { + "contract": 1, + "eager": false, + "files": [ + "/theme-f7.css", + "/vendor/framework7-swipe.min.js", + "/f7-layout.js" + ], + "background": true + }, + "feature:piano": { + "contract": 1, + "eager": false, + "files": [ + "/piano-core.js", + "/piano.js", + "/piano-engine.mjs" + ], + "background": true + }, + "feature:midi": { + "contract": 1, + "eager": false, + "files": [ + "/midi.js" + ], + "background": true + }, + "feature:party-dj": { + "contract": 1, + "eager": false, + "files": [ + "/party-dj.js" + ], + "background": true + }, + "feature:lyrics-window": { + "contract": 1, + "eager": false, + "files": [ + "/lyrics-window.js" + ], + "background": true + }, + "feature:lower-third": { + "contract": 1, + "eager": false, + "files": [ + "/lower-third.js", + "/lower-third.css" + ], + "background": true + }, + "feature:vocal-reducer": { + "contract": 1, + "eager": false, + "files": [ + "/vocal-reducer.js" + ], + "background": true + }, + "feature:eq-core": { + "contract": 1, + "eager": false, + "files": [ + "/eq-core.js" + ], + "background": true + }, + "feature:setlist-import": { + "contract": 1, + "eager": false, + "files": [ + "/setlist-import.js" + ], + "background": true + }, + "feature:car-mode": { + "contract": 1, + "eager": false, + "files": [ + "/car-mode.js", + "/car-mode.css" + ], + "background": true + }, + "feature:p2p": { + "contract": 1, + "eager": false, + "files": [ + "/p2p-core.js", + "/p2p-client.js", + "/p2p-transfer.js", + "/p2p-recv-worker.js", + "/hash-worker.js" + ], + "background": true + }, + "feature:direct": { + "contract": 1, + "eager": false, + "files": [ + "/direct-protocol.js", + "/direct-stream.js", + "/direct-media.js", + "/direct-media.css", + "/direct-recv-worker.js" + ], + "background": true, + "dependencies": [ + "feature:p2p" + ] + }, + "feature:video-edit": { + "contract": 1, + "eager": false, + "files": [ + "/video-edit.js" + ], + "background": true + }, + "feature:stats": { + "contract": 1, + "eager": false, + "files": [ + "/stats-core.js" + ], + "background": true + }, + "feature:export": { + "contract": 1, + "eager": false, + "files": [ + "/export.js" + ], + "background": true + }, + "feature:playlist": { + "contract": 1, + "eager": true, + "files": [], + "embeddedIn": [ + "/app.js" + ], + "background": false + }, + "feature:settings": { + "contract": 1, + "eager": true, + "files": [], + "embeddedIn": [ + "/app.js", + "/settings-sections.js", + "/settings-sections.css", + "/views-core.js" + ], + "background": false + }, + "feature:service-mode": { + "contract": 1, + "eager": true, + "files": [], + "embeddedIn": [ + "/app.js", + "/service-ui.css" + ], + "background": false + }, + "feature:lyrics-editor": { + "contract": 1, + "eager": true, + "files": [], + "embeddedIn": [ + "/app.js", + "/lyrics-core.js", + "/lyrics-display.js" + ], + "background": false + }, + "feature:layout-controls": { + "contract": 1, + "eager": false, + "background": true, + "files": [ + "/theme-glass-controls.css", + "/glass-controls.js" + ] + }, + "layout:sanctuary-stage": { + "contract": 1, + "eager": false, + "background": true, + "files": [] + }, + "layout:liturgical": { + "contract": 1, + "eager": false, + "background": true, + "files": [] + }, + "layout:living-hymnal": { + "contract": 1, + "eager": false, + "background": true, + "files": [] + }, + "layout:broadcaster": { + "contract": 1, + "eager": false, + "background": true, + "files": [] + }, + "layout:contemplative": { + "contract": 1, + "eager": false, + "background": true, + "files": [] + }, + "feature:presenter": { + "contract": 1, + "eager": false, + "background": true, + "files": [ + "/presenter.js" + ] + }, + "feature:remote-client": { + "contract": 1, + "eager": false, + "background": true, + "files": [ + "/remote-client.js" + ] + }, + "feature:watch-party": { + "contract": 1, + "eager": false, + "background": true, + "files": [ + "/watch-party-client.js" + ] + }, + "feature:share-external": { + "contract": 1, + "eager": false, + "background": true, + "files": [ + "/share-external.js" + ] + }, + "feature:stats-view": { + "contract": 1, + "eager": false, + "background": true, + "files": [ + "/stats-view.js" + ], + "dependencies": [ + "feature:stats" + ] + }, + "feature:jamesdsp": { + "contract": 1, + "eager": false, + "files": [ + "/vendor/jamesdsp/controller.js", + "/vendor/jamesdsp/jamesdsp-node.js", + "/vendor/jamesdsp/worklet.js", + "/vendor/jamesdsp/params.js", + "/vendor/jamesdsp/dsp/jamesdsp.js", + "/vendor/jamesdsp/dsp/jamesdsp.wasm" + ], + "background": false + } + } +} diff --git a/frontend/autosave.test.js b/frontend/autosave.test.js new file mode 100644 index 0000000..812c1f6 --- /dev/null +++ b/frontend/autosave.test.js @@ -0,0 +1,65 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +// Exercise the actual app functions without DOM/player/network dependencies. +const source = fs.readFileSync(require('node:path').join(__dirname, 'app.js'), 'utf8'); +function harness({ autoPreload = true, cached = [], result = { ok: true, cached: true }, error } = {}) { + const calls = [], jobs = new Map(); + const video = { id: 'benchmedia01', title: 'Local clip' }; + const context = vm.createContext({ + data: { settings: { autoPreload }, playlists: [{ id: 'pl', videos: [video] }], pinnedPlaylists: [] }, + cachedIds: new Set(cached), downloading: new Set(), downloadMeta: new Map(), saveControllers: new Map(), + view: { type: 'home' }, current: null, window: {}, AbortController, + slim: v => v, markCardCacheState() {}, renderSidebar() {}, updateDownloadBadge() {}, toast() {}, + SaveSlots: { acquire: async () => {}, release() {} }, + SaveQueue: { add: v => jobs.set(v.id, { status: 'queued' }), remove: id => jobs.delete(id), progress() {}, state: (id, status, message) => jobs.set(id, { status, message }) }, + API: { cacheDownload: async id => { calls.push(id); if (error) throw error; return result; } }, + recordDeviceFile() {}, warmThumb() {}, thumbUrlFor: () => '', cacheMutations: 0, + }); + const preload = source.slice(source.indexOf('async function preload(video,'), source.indexOf('// A device copy that failed to play.')); + const playlists = source.slice(source.indexOf('function preloadPlaylist(pl)'), source.indexOf('// Update a single card\'s saved badge')); + vm.runInContext(preload + playlists, context); + return { context, calls, jobs, video }; +} + +test('auto preload tops up playlists, while pinned playlists save with the setting off', async () => { + const h = harness({ autoPreload: false }); + h.context.preloadPlaylist(h.context.data.playlists[0]); + await new Promise(r => setImmediate(r)); + assert.deepEqual(h.calls, []); + h.context.data.pinnedPlaylists = ['pl']; + h.context.preloadPinnedPlaylists(); + await new Promise(r => setImmediate(r)); + assert.deepEqual(h.calls, [h.video.id]); + assert.ok(h.context.cachedIds.has(h.video.id)); + assert.equal(h.jobs.size, 0); + assert.equal(h.context.downloading.size, 0); + const enabled = harness(); + enabled.context.preloadPlaylist(enabled.context.data.playlists[0]); + await new Promise(r => setImmediate(r)); + assert.deepEqual(enabled.calls, [enabled.video.id]); +}); + +test('a repeated save shares the in-flight operation and does not download an existing copy', async () => { + const h = harness(); + await Promise.all([h.context.preload(h.video), h.context.preload(h.video)]); + await h.context.preload(h.video); + assert.deepEqual(h.calls, [h.video.id]); + assert.ok(h.context.cachedIds.has(h.video.id)); + assert.equal(h.context.saveControllers.size, 0); +}); + +test('failed and interrupted saves do not acquire a Saved badge and can be attempted again', async () => { + for (const options of [{ result: { ok: false, error: 'server failed' } }, { error: new Error('storage quota') }, { result: { ok: false, paused: true } }]) { + const h = harness(options); + await h.context.preload(h.video, { quiet: true }); + assert.equal(h.context.cachedIds.has(h.video.id), false); + assert.equal(h.context.downloading.size, 0); + assert.equal(h.context.saveControllers.size, 0); + assert.ok(h.jobs.has(h.video.id)); + await h.context.preload(h.video, { quiet: true }); + assert.equal(h.calls.length, 2); + } +}); diff --git a/frontend/car-mode.css b/frontend/car-mode.css index 8d869cf..9f0c2a2 100644 --- a/frontend/car-mode.css +++ b/frontend/car-mode.css @@ -4,4 +4,3 @@ .car-controls button { min-height: 110px; font: 650 clamp(22px, 3vw, 36px)/1.2 system-ui; background: #fff; color: #050607; border: 3px solid #fff; border-radius: 16px; } .car-exit { align-self: flex-start; min-height: 52px; padding: 12px 20px; background: #070809; color: #fff; border: 2px solid #fff; border-radius: 8px; } .car-mode button:focus-visible { outline: 4px solid #61caff; outline-offset: 4px; } -.voice-search { flex-shrink: 0; min-width: 44px; min-height: 44px; } diff --git a/frontend/car-mode.js b/frontend/car-mode.js index 9785ce1..500b967 100644 --- a/frontend/car-mode.js +++ b/frontend/car-mode.js @@ -10,19 +10,19 @@ overlay = doc.createElement('section'); overlay.className = 'car-mode'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', 'Car mode'); const exit = doc.createElement('button'); exit.type = 'button'; exit.className = 'car-exit'; exit.textContent = 'Exit Car mode'; exit.onclick = close; const title = doc.createElement('h1'); const controls = doc.createElement('div'); controls.className = 'car-controls'; let toggle; - for (const [label, action] of [['Previous', 'prev'], ['Play / Pause', 'toggle'], ['15 s back', 'back'], ['Next', 'next']]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = label; b.setAttribute('aria-label', label); b.onclick = () => adapter.action(action); if (action === 'toggle') toggle = b; controls.append(b); } - const update = () => { const state = adapter.state(); title.textContent = state.title || 'Choose a song before entering Car mode'; toggle.textContent = state.paused ? 'Play' : 'Pause'; }; update(); + for (const [label, action] of [['Previous', 'prev'], ['Play / Pause', 'toggle'], ['15 s back', 'back'], ['Next', 'next']]) { const b = doc.createElement('button'); b.type = 'button'; b.innerHTML = root.TransportIcons.svg({prev:'previous',toggle:'play',back:'back15',next:'next'}[action]) + '' + label + ''; b.setAttribute('aria-label', label); b.onclick = () => adapter.action(action); if (action === 'toggle') toggle = b; controls.append(b); } + const update = () => { const state = adapter.state(); title.textContent = state.title || 'Choose a song before entering Car mode'; root.TransportIcons.set(toggle, state.paused ? 'play' : 'pause'); toggle.insertAdjacentHTML('beforeend','' + (state.paused ? 'Play' : 'Pause') + ''); }; update(); overlay.append(exit, title, controls); doc.body.append(overlay); timer = setInterval(update, 500); exit.focus(); overlay.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); } else if (e.key === 'Tab') { const buttons = [...overlay.querySelectorAll('button')], first = buttons[0], last = buttons.at(-1); if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus(); } } e.stopPropagation(); }; } function configure(options) { adapter = options; const doc = root.document, Recognition = root.SpeechRecognition || root.webkitSpeechRecognition; if (Recognition) { - const b = doc.createElement('button'); b.type = 'button'; b.className = 'icon-btn voice-search'; b.title = 'Voice search'; b.setAttribute('aria-label', 'Voice search'); b.innerHTML = ''; adapter.input.after(b); + const b = doc.getElementById('voiceSearchBtn') || doc.createElement('button'); b.id='voiceSearchBtn'; b.type = 'button'; b.className = 'icon-btn voice-search'; b.title = 'Voice search'; b.setAttribute('aria-label', 'Voice search'); b.innerHTML = ''; if(!b.isConnected)adapter.input.after(b); let recognition, listening = false; b.onclick = () => { if (listening) { recognition.abort(); return; } recognition = new Recognition(); recognition.lang = doc.documentElement.lang || root.navigator.language || 'en-US'; recognition.continuous = false; recognition.interimResults = false; recognition.onstart = () => { listening = true; b.setAttribute('aria-pressed', 'true'); b.title = 'Listening… tap to cancel'; }; recognition.onend = () => { listening = false; b.setAttribute('aria-pressed', 'false'); b.title = 'Voice search'; }; recognition.onerror = e => adapter.toast(`Voice search: ${e.error === 'not-allowed' ? 'microphone permission was denied' : e.error}`); recognition.onresult = e => { const query = transcript(e); if (query) { adapter.input.value = query; adapter.search(); } }; try { recognition.start(); } catch (error) { adapter.toast(error.message); } }; } - const car = doc.createElement('button'); car.type = 'button'; car.className = 'icon-btn'; car.title = 'Car mode'; car.setAttribute('aria-label', 'Car mode'); car.innerHTML = ''; doc.getElementById('searchForm').after(car); car.onclick = open; + const car = doc.getElementById('carModeBtn') || doc.createElement('button'); car.id='carModeBtn'; car.type = 'button'; car.className = 'icon-btn'; car.title = 'Car mode'; car.setAttribute('aria-label', 'Car mode'); car.innerHTML = ''; if(!car.isConnected)doc.getElementById('searchForm').after(car); car.onclick = open; root.SettingsSections.register({ id: 'voice-car', title: 'Voice & Car mode', cluster: 'Listening', summary: () => Recognition ? 'Voice search available · Car mode' : 'Car mode · voice unavailable', render(container) { const p = doc.createElement('p'); p.textContent = Recognition ? 'Use the microphone beside Search to speak a query. Recognition availability and processing depend on your browser.' : 'Voice search is unavailable in this browser.'; const b = doc.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Enter Car mode'; b.onclick = open; container.append(p, b); } }); } const api = { transcript, configure, open, close }; if (typeof module !== 'undefined') module.exports = api; else root.CarMode = api; diff --git a/frontend/car-mode.test.js b/frontend/car-mode.test.js index ae51c4e..ff996b6 100644 --- a/frontend/car-mode.test.js +++ b/frontend/car-mode.test.js @@ -1,2 +1,27 @@ const { test } = require('node:test'), assert = require('node:assert/strict'); const { transcript } = require('./car-mode'); test('voice search submits only final results from the result index', () => { const interim = [{transcript:'wrong'}]; interim.isFinal = false; const final = [{transcript:'Amazing Grace'}]; final.isFinal = true; assert.equal(transcript({results:[interim, final]}), 'Amazing Grace'); assert.equal(transcript({resultIndex:2,results:[final,final]}), ''); assert.equal(transcript({}), ''); }); + +test('lazy Car mode keeps four independent transport buttons and the voice entry in place', () => { + const vm=require('node:vm'),fs=require('node:fs'); + class Element { + constructor(tag){this.tag=tag;this.children=[];this.inert=false;} + append(...nodes){for(const node of nodes){node.remove();node.parent=this;this.children.push(node);}} + remove(){if(this.parent){this.parent.children=this.parent.children.filter(n=>n!==this);this.parent=null;}} + after(node){this.parent.append(node);} + setAttribute(name,value){this[name]=value;} + focus(){} + insertAdjacentHTML(position,html){this.innerHTML+=html;} + get isConnected(){return !!this.parent;} + } + const body=new Element('body'),input=new Element('input'),form=new Element('form'),voice=new Element('button');form.id='searchForm';voice.id='voiceSearchBtn';body.append(form,input,voice); + const all=node=>[node,...node.children.flatMap(all)],doc={body,documentElement:{lang:'en'},createElement:tag=>new Element(tag),getElementById:id=>all(body).find(n=>n.id===id)}; + let section;const actions=[],root={document:doc,TransportIcons:require('./download-actions').transport,navigator:{},SpeechRecognition:function(){},SettingsSections:{register:s=>section=s}}; + vm.runInNewContext(fs.readFileSync(__dirname+'/car-mode.js','utf8'),{window:root,setInterval:()=>1,clearInterval:()=>{}}); + root.CarMode.configure({input,action:action=>actions.push(action),state:()=>({paused:true,title:'Song'})}); + root.CarMode.open();const overlay=body.children.find(n=>n.className==='car-mode'),controls=overlay.children.find(n=>n.className==='car-controls'); + assert.equal(controls.children.length,4);assert.equal(new Set(controls.children).size,4);assert.equal(voice.parent,body); + controls.children.forEach(b=>b.onclick());assert.deepEqual(actions,['prev','toggle','back','next']); + root.CarMode.close();assert.equal(voice.parent,body);assert.equal(body.inert,false); + const panel=new Element('section');body.append(panel);section.render(panel); + assert.equal(voice.parent,body,'settings must not steal the voice entry');assert.equal(panel.children.at(-1).textContent,'Enter Car mode'); +}); diff --git a/frontend/direct-media.js b/frontend/direct-media.js index be308a6..1e5843c 100644 --- a/frontend/direct-media.js +++ b/frontend/direct-media.js @@ -77,7 +77,7 @@ s.preview = m.file.streamMime ? root.DirectStream?.create(m.file.streamMime, m.file.title) : null; const wait = waiting.get(m.file.id); if (wait) clearTimeout(wait.timer); send(key, { type: 'direct', action: 'accept', token: m.token }); - s.worker = new Worker('/direct-recv-worker.js'); + s.worker = window.Lazy ? window.Lazy.worker('/direct-recv-worker.js') : new Worker('/direct-recv-worker.js'); s.worker.onmessage = async e => { const result = e.data; if (result.op === 'opened') { diff --git a/frontend/direct-recv-worker.js b/frontend/direct-recv-worker.js index da6c14e..94ed130 100644 --- a/frontend/direct-recv-worker.js +++ b/frontend/direct-recv-worker.js @@ -1,11 +1,11 @@ 'use strict'; -importScripts('/sha256.js'); let dir, handle, access, hash, pos = 0, claim, name; let chain = Promise.resolve(); self.onmessage = e => { chain = chain.then(() => run(e.data)); }; async function close() { if (access) { access.flush(); access.close(); access = null; } } async function run(m) { try { + if (!self.Sha256) { self.__ASSET_URLS__=m.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); } if (m.op === 'open') { claim = m.file; if (!/^[A-Za-z0-9_-]{1,128}$/.test(claim.id) || !/^[a-f0-9]{64}$/.test(claim.cid)) throw Error('Invalid file identity'); diff --git a/frontend/download-actions.css b/frontend/download-actions.css index 3a413a1..7c4b117 100644 --- a/frontend/download-actions.css +++ b/frontend/download-actions.css @@ -9,3 +9,30 @@ html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action { bo html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:hover { border-color:var(--glass-line); background:var(--tile-hi); color:var(--text); } html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action.danger { color:var(--accent-bright); } html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:focus-visible { outline-color:var(--accent-bright); } + +/* Transport drawings and touch targets survive layout/state changes. */ +.transport-icon { display:inline-block; flex:none; width:24px; height:24px; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; } +.transport-shape { display:none; } +.transport-shape-classic { display:inline; } +html:is([data-layout="f7-swipe"],[data-layout="glass-stage"],[data-layout="bento-hub"]) .transport-shape-classic { display:none; } +html[data-layout="f7-swipe"] .transport-shape-f7-swipe, +html[data-layout="glass-stage"] .transport-shape-glass-stage, +html[data-layout="bento-hub"] .transport-shape-bento-hub { display:inline; } +html[data-layout="f7-swipe"] .transport-shape-f7-swipe { fill:currentColor; stroke-width:.7; } +html[data-layout="f7-swipe"] .transport-icon:is([data-transport-icon="play"],[data-transport-icon="pause"],[data-transport-icon="previous"],[data-transport-icon="next"],[data-transport-icon="forward"]), +html[data-layout="f7-swipe"] #controls .transport-icon:is([data-transport-icon="play"],[data-transport-icon="pause"],[data-transport-icon="previous"],[data-transport-icon="next"],[data-transport-icon="forward"]) { fill:currentColor; stroke-width:.7; } +html[data-layout="f7-swipe"] :is([data-transport-icon="back10"],[data-transport-icon="forward10"],[data-transport-icon="back15"]) .transport-shape { fill:none; stroke-width:2; } +html[data-layout="glass-stage"] .transport-icon, +html[data-layout="glass-stage"] #controls .transport-icon { stroke-width:1.5; } +html[data-layout="bento-hub"] .transport-icon { stroke-width:2; stroke-linejoin:round; } +button:has(> .transport-icon), a.np-btn:has(> .transport-icon) { min-width:44px; min-height:44px; } +button:has(> .transport-icon) { align-items:center; justify-content:center; } +html[data-layout="f7-swipe"] :is(#playBtn,#stgPlay,.service-btn-play,.rv-play) { border-radius:50%; } +html[data-layout="f7-swipe"] #playBtn { width:64px; height:64px; } +html[data-layout="glass-stage"] :is(#playBtn,.mini-btn,.rv-tbtn):has(> .transport-icon) { background:var(--tile,rgba(255,255,255,.08)); border:1px solid var(--line); backdrop-filter:blur(12px); } +html[data-layout="bento-hub"] :is(#playBtn,.mini-btn,.service-btn,.rv-tbtn):has(> .transport-icon) { border-radius:16px; } +.car-controls .transport-icon { width:36px; height:36px; margin-right:12px; } +.gh-seek .transport-icon, .empty-icon .transport-icon { width:48px; height:48px; } +.ph-logo .transport-icon { width:60%; height:60%; } + +.home-pl-play:has(> .transport-icon) { min-width:44px; min-height:44px; } diff --git a/frontend/download-actions.js b/frontend/download-actions.js index 0a92f22..ea74585 100644 --- a/frontend/download-actions.js +++ b/frontend/download-actions.js @@ -1,6 +1,83 @@ /* One action control for the Downloads page and Settings download manager. */ (function(root) { 'use strict'; + // One SVG definition per media concept; every instance inherits its control colour. + const mediaPaths = { + download: '', + offline: '', + progress: '' + }; + const variants = { + 'classic': mediaPaths, + 'f7-swipe': { + download: '', + offline: '', + progress: '' + }, + 'glass-stage': { + download: '', + offline: '', + progress: '' + }, + 'bento-hub': { + download: '', + offline: '', + progress: '' + } + }; + const use = kind => ``; + const svg = (kind, extra = '') => ``; + const media = { paths: mediaPaths, use, svg }; + if (root.document) { + const defs = root.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + defs.setAttribute('aria-hidden', 'true'); defs.setAttribute('width', '0'); defs.setAttribute('height', '0'); + defs.style.position = 'absolute'; defs.style.pointerEvents = 'none'; + defs.innerHTML = '' + Object.keys(mediaPaths).map(kind => `${Object.entries(variants).map(([layout, paths]) => `${paths[kind]}`).join('')}`).join('') + ''; + root.document.body.appendChild(defs); + root.MediaIcons = media; + } + // Select variants on visible use elements: ancestor CSS in referenced SVG groups + // can report changed styles without repainting the cloned geometry in Chromium. + const transportPaths = { + play: '', + pause: '', + previous: '', + next: '', + forward: '' + }; + for (const seconds of [10,15]) for (const direction of ['back','forward']) { + transportPaths[direction + seconds] = (direction === 'back' + ? '' + : '') + + `${seconds}`; + } + const transportVariants = Object.fromEntries(['classic','f7-swipe','glass-stage','bento-hub'].map(layout => [layout,{...transportPaths}])); + Object.assign(transportVariants['f7-swipe'], { + play: '', + previous: '', + next: '' + }); + const transport = { + paths: transportPaths, variants: transportVariants, + svg(kind) { + if (!transportPaths[kind]) throw new Error('Unknown transport icon: ' + kind); + return ``; + }, + set(button, kind, label) { + if (!button) return; + const iconId=button.querySelector?.('.transport-icon')?.id; + button.innerHTML = this.svg(kind); + if(iconId)button.querySelector('.transport-icon').id=iconId; + button.setAttribute('aria-label', label || (kind === 'pause' ? 'Pause' : 'Play')); + } + }; + if (root.document) { + const defs = root.document.createElementNS('http://www.w3.org/2000/svg','svg'); + defs.setAttribute('aria-hidden','true'); defs.setAttribute('width','0'); defs.setAttribute('height','0'); + defs.style.position='absolute'; defs.style.pointerEvents='none'; + defs.innerHTML=''+Object.keys(transportPaths).flatMap(kind=>Object.entries(transportVariants).map(([layout,paths])=>`${paths[kind]}`)).join('')+''; + root.document.body.appendChild(defs); root.TransportIcons=transport; + } const paths = { retry: '', cancel: '', @@ -19,6 +96,6 @@ const text = doc.createElement('span'); text.textContent = label; button.append(icon, text); return button; } - const api = { create }; + const api = { create, media, transport }; if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.DownloadActions = api; })(globalThis); diff --git a/frontend/downloads-page.js b/frontend/downloads-page.js index 169ad44..2336c3d 100644 --- a/frontend/downloads-page.js +++ b/frontend/downloads-page.js @@ -119,11 +119,7 @@ empty.className = 'empty-state dl-empty-state'; empty.innerHTML = `

No active downloads

Saves in progress appear here with live status. Videos you save land in Saved.

diff --git a/frontend/downloads.js b/frontend/downloads.js index 903c1c8..a94ce4c 100644 --- a/frontend/downloads.js +++ b/frontend/downloads.js @@ -12,7 +12,7 @@ let adapter, total = 0; function configure(options) { adapter = options; - root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: '', summary: () => `${adapter.slots()} at once · ${bytes(total)}`, render(container) { + root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: '', summary: () => `${adapter.slots()} at once · ${bytes(total)}`, render(container) { const doc = container.ownerDocument; const heading = doc.createElement('h3'); heading.textContent = 'Downloads & storage'; const jobs = doc.createElement('div'); jobs.className = 'download-manager'; jobs.setAttribute('aria-label', 'Download manager'); diff --git a/frontend/eq-core.js b/frontend/eq-core.js index 2e84752..25d946c 100644 --- a/frontend/eq-core.js +++ b/frontend/eq-core.js @@ -1,24 +1,7 @@ /* Shared live/offline EQ definitions and legacy curve migration. */ (function(root) { 'use strict'; - const frequencies = [31,62,125,250,500,1000,2000,4000,8000,16000]; - const bands = frequencies.map((f,i) => ({ f, label: f >= 1000 ? `${f/1000}k` : String(f), type: i === 0 ? 'lowshelf' : i === 9 ? 'highshelf' : 'peaking' })); - const clamp = n => Math.max(-12, Math.min(12, Number(n) || 0)); - function migrate(values) { - if (!Array.isArray(values)) return frequencies.map(() => 0); - if (values.length !== 5) return frequencies.map((_,i) => clamp(values[i])); - const old = [60,250,1000,4000,12000], curve = values.map(clamp); - return frequencies.map(f => { if (f <= old[0]) return curve[0]; if (f >= old[4]) return curve[4]; const i = old.findIndex(x => x >= f), weight = Math.log(f / old[i-1]) / Math.log(old[i] / old[i-1]); return Math.round((curve[i-1] * (1-weight) + curve[i] * weight) * 10) / 10; }); - } - const presets = { - flat: { name: 'Flat', g: frequencies.map(() => 0) }, - bass: { name: 'Bass boost', g: [5,7,5,3,1,0,0,0,0,0] }, - vocal: { name: 'Vocal clarity', g: [-3,-4,-2,0,1,2,3,4,1,0] }, - acoustic: { name: 'Acoustic live', g: [-2,-1,0,-2,-1,1,2,2,1,0] }, - classical: { name: 'Classical', g: migrate([4,2,-1,1,3]) }, - treble: { name: 'Treble boost', g: [0,0,0,0,0,0,1,3,5,6] }, - }; - const width = value => Math.max(0, Math.min(100, Number(value) || 0)); + const { bands, presets, migrate, width } = typeof module !== 'undefined' ? require('./eq-parameters') : root.EqParameters; function createWidener(context, source, value = 0) { const splitter = context.createChannelSplitter(2), output = context.createChannelMerger(2), delay = context.createDelay(.05); source.connect(splitter); splitter.connect(output, 0, 0); splitter.connect(delay, 1); delay.connect(output, 0, 1); diff --git a/frontend/eq-parameters.js b/frontend/eq-parameters.js new file mode 100644 index 0000000..1adce32 --- /dev/null +++ b/frontend/eq-parameters.js @@ -0,0 +1,25 @@ +/* Eager settings values; audio graphs remain in lazy feature groups. */ +(function(root) { + 'use strict'; + const frequencies = [31,62,125,250,500,1000,2000,4000,8000,16000]; + const bands = frequencies.map((f,i) => ({ f, label: f >= 1000 ? `${f/1000}k` : String(f), type: i === 0 ? 'lowshelf' : i === 9 ? 'highshelf' : 'peaking' })); + const clamp = n => Math.max(-12, Math.min(12, Number(n) || 0)); + function migrate(values) { + if (!Array.isArray(values)) return frequencies.map(() => 0); + if (values.length !== 5) return frequencies.map((_,i) => clamp(values[i])); + const old = [60,250,1000,4000,12000], curve = values.map(clamp); + return frequencies.map(f => { if (f <= old[0]) return curve[0]; if (f >= old[4]) return curve[4]; const i = old.findIndex(x => x >= f), weight = Math.log(f / old[i-1]) / Math.log(old[i] / old[i-1]); return Math.round((curve[i-1] * (1-weight) + curve[i] * weight) * 10) / 10; }); + } + const presets = { + flat: { name: 'Flat', g: frequencies.map(() => 0) }, + bass: { name: 'Bass boost', g: [5,7,5,3,1,0,0,0,0,0] }, + vocal: { name: 'Vocal clarity', g: [-3,-4,-2,0,1,2,3,4,1,0] }, + acoustic: { name: 'Acoustic live', g: [-2,-1,0,-2,-1,1,2,2,1,0] }, + classical: { name: 'Classical', g: migrate([4,2,-1,1,3]) }, + treble: { name: 'Treble boost', g: [0,0,0,0,0,0,1,3,5,6] }, + }; + const width = value => Math.max(0, Math.min(100, Number(value) || 0)); + const amount = value => Math.max(0, Math.min(100, Number.isFinite(Number(value)) ? Number(value) : 100)); + const api = { bands, presets, migrate, width, amount }; + if (typeof module !== 'undefined') module.exports = api; else root.EqParameters = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/f7-layout.js b/frontend/f7-layout.js index 234c19a..e0f07ed 100644 --- a/frontend/f7-layout.js +++ b/frontend/f7-layout.js @@ -35,7 +35,7 @@ function loadF7(){ if(root.Framework7)return Promise.resolve(root.Framework7); - if(!loading)loading=new Promise((res,rej)=>{const s=doc.createElement('script');s.src=F7_SRC;s.onload=()=>res(root.Framework7);s.onerror=()=>{loading=null;rej(new Error('Framework7 failed to load'));};doc.head.append(s);}); + if(!loading)loading=new Promise((res,rej)=>{const s=doc.createElement('script');s.src=root.Lazy?.url('/vendor/framework7-swipe.min.js')||F7_SRC;s.onload=()=>res(root.Framework7);s.onerror=()=>{loading=null;rej(new Error('Framework7 failed to load'));};doc.head.append(s);}); return loading; } function ensureApp(){ diff --git a/frontend/feature-bootstrap.js b/frontend/feature-bootstrap.js new file mode 100644 index 0000000..7442dd3 --- /dev/null +++ b/frontend/feature-bootstrap.js @@ -0,0 +1,77 @@ +/* Thin eager entries and configuration for optional, stateful feature modules. */ +(function(root) { + 'use strict'; + const Lazy = root.Lazy, doc = root.document; + if (!Lazy) return; + const specs = [ + ['Piano','piano','configure','piano','Piano transcription','Live & worship','Off · load model on request'], + ['MidiPedals','midi','configure','midi','MIDI pedalboard','Live & worship','Off'], + ['PartyDJ','party-dj','register','party-dj','Watch party','Live & worship','Shared suggestions · host approval'], + ['LyricsWindow','lyrics-window','configure','floating-lyrics','Floating lyrics','Live & worship','Floating lyrics window'], + ['LowerThird','lower-third','register','broadcast','Presenter & broadcast','Live & worship','Projector · OBS lower third'], + ['EqCore','eq-core','register','equalizer','Equalizer & stereo','Listening','Flat · Original stereo'], + ['VocalReducer','vocal-reducer','register','vocal-reducer','Vocal reducer','Listening','100% · centre-panned vocals'], + ['SetlistImport','setlist-import','configure','setlist-import','Setlist import','Library & storage','Planning Center · SongSelect · ChordPro'], + ['CarMode','car-mode','configure','voice-car','Voice & Car mode','Listening','Voice search · Car mode'], + ['DirectMedia','direct','configure','direct-transfer','Direct device transfer','Library & storage','Paired devices only'], + ]; + function button(id, text, anchor, name, action, className='np-btn') { + if (!anchor || doc.getElementById(id)) return; + const b=doc.createElement('button'); b.id=id; b.type='button'; b.className=className; b.textContent=text; b.setAttribute('aria-label',text); + if(id==='carModeBtn')b.innerHTML=''; + if(id==='voiceSearchBtn')b.innerHTML=''; + if(id.startsWith('floating-')){b.title='Open floating lyrics';b.setAttribute('aria-label','Open floating lyrics window');} + b.title ||= text; + b.onclick=()=>Lazy.load(name).then(action).catch(error=>root.dispatchEvent(new CustomEvent('ytp-lazy-error',{detail:{error}}))); + anchor.after(b); Lazy.intent(b,name); + } + for (const [globalName, suffix, setup, id, title, cluster, summary] of specs) { + const group='feature:'+suffix; + const facade=Lazy.proxy(group,['open','close','obtain','publish','attach','handle','offer','paint','fade'],globalName); + let configured=false; + facade[setup]=options=>{ + if (configured) return; configured=true; + Lazy.onLoad(group,()=>root[globalName][setup](options)); + root.SettingsSections.register({id,title,cluster,lazyGroup:group,summary:()=>{ + const s=options?.settings?.() || {}; + if(globalName==='Piano')return s.pianoEnabled?'On · browser or optional worker':'Off · load model on request'; + if(globalName==='MidiPedals')return s.midiEnabled?'On · pedal mappings':'Off'; + if(globalName==='EqCore')return `${root.EqParameters.presets[s.eq?.preset]?.name || 'Custom'} · ${root.EqParameters.width(s.stereoWidth)?'Widened':'Original stereo'}`; + if(globalName==='VocalReducer')return `${root.EqParameters.amount(s.vocalLevel ?? 100)}% · centre-panned vocals`; + if(globalName==='DirectMedia')return facade.enabled()?'On · paired devices only':'Off or unsupported'; + if(globalName==='CarMode')return root.SpeechRecognition||root.webkitSpeechRecognition?'Voice search available · Car mode':'Car mode · voice unavailable'; + if(globalName==='LyricsWindow')return root.documentPictureInPicture?.requestWindow?'Interactive window available':root.HTMLCanvasElement?.prototype.captureStream&&doc.pictureInPictureEnabled?'Lyric video PiP available':'Not supported on this browser'; + return summary; + }, + render(container,context) { + let disposed=false, cleanup; + container.textContent='Loading…'; + Lazy.load(group).then(()=>{ + if (disposed || !container.isConnected) return; + container.replaceChildren(); cleanup=root.SettingsSections.get(id).render(container,context); + }).catch(error=>{if(!disposed)container.textContent=error.message;}); + return ()=>{disposed=true; if(typeof cleanup==='function')cleanup();}; + }, + }); + if (globalName==='CarMode') { + button('carModeBtn','Car mode',doc.getElementById('searchForm'),group,()=>root.CarMode.open(),'icon-btn'); + if(root.SpeechRecognition||root.webkitSpeechRecognition)button('voiceSearchBtn','Voice search',options.input,group,()=>doc.getElementById('voiceSearchBtn').click(),'icon-btn voice-search'); + } + if (globalName==='LyricsWindow' && (root.documentPictureInPicture?.requestWindow || (root.HTMLCanvasElement?.prototype.captureStream && doc.pictureInPictureEnabled))) { + for(const anchor of ['stageLyricsAuto','serviceAutoscrollBtn','pipBtn'])button('floating-'+anchor,'⧉ Lyrics',doc.getElementById(anchor),group,()=>doc.getElementById('floating-'+anchor).click()); + } + }; + if(globalName==='LowerThird') { + facade.active=()=>new URLSearchParams(root.location.search).get('overlay')==='lowerthird'; + facade.url=(origin,code)=>{const u=new URL('/',origin);u.searchParams.set('overlay','lowerthird');u.searchParams.set('code',String(code));return u.href;}; + } + if(globalName==='DirectMedia') { + let settings=()=>({}); const configure=facade.configure; + facade.configure=options=>{settings=options.settings;configure(options);}; + facade.enabled=()=>settings().directTransfer!==false && !!root.RTCPeerConnection; + facade.hasSource=()=>false; + } + if(globalName==='LyricsWindow')facade.element=()=>null; + root[globalName]=facade; + } +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/fullscreen-orientation.js b/frontend/fullscreen-orientation.js index c7e5ee9..4d508da 100644 --- a/frontend/fullscreen-orientation.js +++ b/frontend/fullscreen-orientation.js @@ -24,7 +24,7 @@ function lift() { if (home || stage.parentElement === document.body) return; home = { parent: stage.parentElement, next: stage.nextSibling }; document.body.append(stage); } function unlift() { if (!home) return; const { parent, next } = home; home = null; if (next && next.parentNode === parent) parent.insertBefore(stage, next); else parent.append(stage); } const hud = document.createElement('div'); hud.className = 'fullscreen-hud'; hud.hidden = true; - hud.innerHTML = '
'; + hud.innerHTML = '
'; stage.append(hud); const play = hud.querySelector('button[aria-label="Play or pause"]'), orientation = hud.querySelector('.fullscreen-orientation'), fitButton = hud.querySelector('.fullscreen-fit'), exit = hud.querySelector('button[aria-label="Exit fullscreen"]'), range = hud.querySelector('input'); const title = hud.querySelector('.fullscreen-title'); @@ -109,9 +109,7 @@ range.style.setProperty('--seek-progress', `${s.duration ? Math.min(100, s.time / s.duration * 100) : 0}%`); play.setAttribute('aria-label', s.paused ? 'Play video' : 'Pause video'); play.title = s.paused ? 'Play' : 'Pause'; - play.innerHTML = s.paused - ? '' - : ''; + root.TransportIcons.set(play, s.paused ? 'play' : 'pause', s.paused ? 'Play video' : 'Pause video'); }, 250); } document.addEventListener('fullscreenchange', () => { if (active && !document.fullscreenElement && native) cleanup(); }); diff --git a/frontend/glass-controls.js b/frontend/glass-controls.js index 742b96e..db8bd6f 100644 --- a/frontend/glass-controls.js +++ b/frontend/glass-controls.js @@ -6,9 +6,9 @@ music:'', add:'', close:'', search:'', - save:'', - play:'', pause:'', - next:'', previous:'', + save:'', + play:'', pause:'', + next:'', previous:'', repeat:'', edit:'', lyrics:'', @@ -23,7 +23,7 @@ television:'', pin:'', devices:'' }; - const glyphs = {'+':'add','✕':'close','×':'close','✖':'close','🔍':'search','🔎':'search','⬇':'save','⤓':'save','💾':'save','▶':'play','⏸':'pause','⏭':'next','⏮':'previous','↻':'repeat','↺':'repeat','✎':'edit','✏':'edit','📝':'edit','🎤':'lyrics','↗':'share','⧉':'copy','✓':'check','📁':'folder','⚙':'settings','📋':'chapters','🕘':'clock','🔀':'shuffle','📡':'devices','▾':'down','▸':'right','←':'back','⋯':'more','⋮':'more','🗑':'trash','🎵':'music'}; + const glyphs = {'+':'add','✕':'close','×':'close','✖':'close','🔍':'search','🔎':'search','▶':'play','⏸':'pause','⏭':'next','⏮':'previous','↻':'repeat','↺':'repeat','✎':'edit','✏':'edit','📝':'edit','🎤':'lyrics','↗':'share','⧉':'copy','✓':'check','📁':'folder','⚙':'settings','📋':'chapters','🕘':'clock','🔀':'shuffle','📡':'devices','▾':'down','▸':'right','←':'back','⋯':'more','⋮':'more','🗑':'trash','🎵':'music'}; function leadingGlyph(text,bento=false) { const mapping=bento?{...glyphs,'📺':'television','⏱':'clock','⇅':'settings','📌':'pin','🎧':'music','📚':'folder'}:glyphs; const prefix = text.match(/^\s*/)[0], rest = text.slice(prefix.length); diff --git a/frontend/glass-controls.test.js b/frontend/glass-controls.test.js index f28cf96..27c0cb8 100644 --- a/frontend/glass-controls.test.js +++ b/frontend/glass-controls.test.js @@ -1,7 +1,7 @@ const test=require('node:test'),assert=require('node:assert/strict'); const {leadingGlyph}=require('./glass-controls.js'); test('glass icon substitution preserves leading whitespace and remaining labels',()=>{ - assert.deepEqual(leadingGlyph(' ⬇ Save'),{kind:'save',original:' ⬇',rest:' Save'}); + assert.equal(leadingGlyph('Save'),null); assert.deepEqual(leadingGlyph('⚙️ Settings'),{kind:'settings',original:'⚙️',rest:' Settings'}); assert.equal(leadingGlyph('A song with 🔍 in its title'),null); assert.equal(leadingGlyph('Clear all'),null); diff --git a/frontend/hash-worker.js b/frontend/hash-worker.js index 455dab6..5d10f89 100644 --- a/frontend/hash-worker.js +++ b/frontend/hash-worker.js @@ -6,11 +6,11 @@ ^ * Out: { ok: true, sha256, size, engine } | { ok: false, error } * ========================================================================== */ 'use strict'; -importScripts('/sha256.js'); self.onmessage = async (e) => { const { name } = e.data || {}; try { + if (!self.Sha256) { self.__ASSET_URLS__=e.data.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); } const root = await navigator.storage.getDirectory(); const dir = await root.getDirectoryHandle('videos'); const file = await (await dir.getFileHandle(String(name))).getFile(); diff --git a/frontend/index.html b/frontend/index.html index d7e4400..20516e4 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,48 +13,44 @@ YT Player + + + + - - + - - - - - - -
- +

YT Player

Ad-free YouTube — no login needed. Search, play, and discover videos shaped by what you listen to.

@@ -182,9 +178,9 @@
- - - + + +
Pinch 1.0×Buffered
@@ -211,11 +207,11 @@
- - - - - + + + + +
@@ -274,8 +270,8 @@
- - + +
Chapters shared with everyone
@@ -477,9 +473,9 @@ 0:00 / 0:00
- - - + + +
@@ -494,7 +490,7 @@