From 5d6a1545df3fa2c39809bf77293df51a7a4b6765 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Thu, 8 Oct 2026 01:29:50 +0800 Subject: [PATCH] Load layouts and optional features from staged asset caches --- frontend/app.js | 84 +++++++++++++------ frontend/asset-sync-core.js | 11 ++- frontend/asset-sync-core.test.js | 11 +++ frontend/asset-worker.test.js | 27 +++++- frontend/assets.json | 127 +++++++++++++++++++++++------ frontend/car-mode.js | 8 +- frontend/direct-media.js | 2 +- frontend/direct-recv-worker.js | 2 +- frontend/eq-core.js | 19 +---- frontend/eq-parameters.js | 25 ++++++ frontend/f7-layout.js | 2 +- frontend/feature-bootstrap.js | 77 +++++++++++++++++ frontend/hash-worker.js | 2 +- frontend/index.html | 36 +------- frontend/lazy.js | 70 +++++++++++++--- frontend/lazy.test.js | 13 +++ frontend/lyrics-window.js | 2 +- frontend/opfs-worker.js | 7 +- frontend/opfs.js | 2 +- frontend/p2p-client.js | 4 +- frontend/p2p-recv-worker.js | 2 +- frontend/p2p-transfer.js | 2 +- frontend/piano.js | 2 +- frontend/settings-sections.js | 11 ++- frontend/sha256.js | 2 +- frontend/shell-consistency.test.js | 18 +++- frontend/sw-update.js | 7 +- frontend/sw.js | 55 ++++++++++++- frontend/worker-imports.test.js | 12 +++ server/static-delivery.test.js | 16 +++- 30 files changed, 512 insertions(+), 146 deletions(-) create mode 100644 frontend/eq-parameters.js create mode 100644 frontend/feature-bootstrap.js create mode 100644 frontend/worker-imports.test.js diff --git a/frontend/app.js b/frontend/app.js index 2145032..26c2594 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -1666,7 +1666,10 @@ 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; } function fmtBytes(n) { if (!n) return '0 B'; @@ -2056,7 +2059,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; @@ -3135,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; @@ -3161,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; @@ -3186,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; @@ -5035,7 +5042,8 @@ const Remote = (() => { body.querySelector('[data-act="unpair"]').classList.toggle('hidden', !live); } - function openHostModal() { + async function openHostModal() { + try { await Lazy.load('feature:direct'); } catch(error) { toast(error.message); return; } const body = document.createElement('div'); body.className = 'remote-host'; body.innerHTML = ` @@ -5306,7 +5314,8 @@ const Remote = (() => { } } - function openRemoteView({ code = '' } = {}) { + async function openRemoteView({ code = '' } = {}) { + try { await Lazy.load('feature:direct'); } catch(error) { toast(error.message); return; } rc.open = true; rc.seekTimer = VisibleTimer.setIntervalWhenVisible(renderSeek, 500); // This phone is the remote now — don't also play here. @@ -5918,7 +5927,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; @@ -5949,7 +5959,9 @@ const StatsTrack = (() => { // ============================================================================ // Stats view // ============================================================================ -function renderStats() { +async function renderStats() { + try { await Lazy.load('feature:stats'); } catch (error) { toast(error.message); return; } + if (view.type !== 'stats') return; StatsTrack.flush(); els.listTitle.textContent = 'Stats'; resetListHeaderExtras(); @@ -6679,8 +6691,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 }; } @@ -6719,7 +6731,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]); @@ -6731,6 +6743,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(); @@ -6834,8 +6847,9 @@ const PiP = (() => { // 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; @@ -6850,12 +6864,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) => { @@ -6893,7 +6911,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); @@ -6921,7 +6939,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) { @@ -7795,7 +7814,7 @@ const Party = (() => { 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 showPanel() { Lazy.load('feature:party-dj').then(render).catch(error=>toast(error.message)); Lazy.load('feature:direct').catch(error=>toast(error.message)); $('partyPanel').classList.remove('hidden'); st.minimized = false; $('partyPanel').classList.remove('min'); render(); } function render() { const panel = $('partyPanel'); @@ -7836,7 +7855,7 @@ const Party = (() => {
`; 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 || []; } }); + if (Lazy.loaded('feature:party-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() { @@ -9104,6 +9123,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; @@ -9869,7 +9889,12 @@ async function renderSettings() { // ---- 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(); }); + $('setLayout').addEventListener('change', async (e) => { + const select=e.target, next=select.value, previous=data.settings.layout; select.disabled=true; + try { await Lazy.load('layout:'+next); data.settings.layout=next; Lazy.layout=next; if(WEB)saveDataToStorage(JSON.stringify(data)); applyAppearance(); persist(); Lazy.reportLayout?.(); } + catch(error) { select.value=previous; toast(error.message); } + finally { select.disabled=false; } + }); $('setFont').addEventListener('change', (e) => { data.settings.fontScale = e.target.value; applyAppearance(); persist(); }); $('setParallelSaves').addEventListener('change', (e) => { data.settings.parallelSaves = Number(e.target.value) || 4; persist(); SaveSlots.pump(); }); $('setDensity').addEventListener('change', (e) => { data.settings.density = e.target.value; applyAppearance(); persist(); }); @@ -11118,7 +11143,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); @@ -12267,7 +12292,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, @@ -12342,6 +12367,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 @@ -12588,6 +12614,7 @@ async function boot() { // and re-render only if something actually changed. syncOnLaunch(); startPlaylistInboxWatch(); + if(LowerThird.active()) await Lazy.load('feature:lower-third'); Presenter.boot(); if (LowerThird.active()) return; SectionRail.boot(); @@ -12617,7 +12644,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(); } }); @@ -12630,6 +12657,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(); @@ -12643,10 +12672,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(); @@ -12675,6 +12706,11 @@ 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.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 index 20671af..e8d951c 100644 --- a/frontend/asset-sync-core.js +++ b/frontend/asset-sync-core.js @@ -10,11 +10,13 @@ } function blocking(manifest, previous, activeLayout) { - return [...new Set(Object.entries(manifest.groups) + 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)) - .flatMap(([, group]) => group.files))].sort(); + .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) { @@ -26,9 +28,10 @@ } function retained(current, previous) { - const keep = new Set(paths(current).map(path => url(path, current.files[path]))); + 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 (current.files[path]?.h !== previous.files[path].h) keep.add(url(path, previous.files[path])); + if (!currentPaths.has(path) || current.files[path]?.h !== previous.files[path].h) keep.add(url(path, previous.files[path])); } return keep; } diff --git a/frontend/asset-sync-core.test.js b/frontend/asset-sync-core.test.js index 1e6979a..2c9a9d4 100644 --- a/frontend/asset-sync-core.test.js +++ b/frontend/asset-sync-core.test.js @@ -34,3 +34,14 @@ test('background synchronization fills all groups without publishing a new curre 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')); +}); diff --git a/frontend/asset-worker.test.js b/frontend/asset-worker.test.js index 193a31a..48b9909 100644 --- a/frontend/asset-worker.test.js +++ b/frontend/asset-worker.test.js @@ -2,7 +2,7 @@ const {test}=require('node:test');const assert=require('node:assert/strict');con function environment(){ const stores=new Map(),listeners={},messages=[],skips=[];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,crypto:require('node:crypto').webcrypto,console,fetch:async u=>u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':m.files[u.split('?')[0]]?.h || ''}}),self:{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)}]}}}; + 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=>u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':m.files[u.split('?')[0]]?.h || ''}}),self:{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={}){let p;listeners[t]({...e,waitUntil:v=>p=v});await p} async function request(url,mode='cors',clientId='new'){let p;listeners.fetch({request:{url:'https://local'+url,method:'GET',mode},clientId,respondWith:v=>p=v});return p} @@ -10,3 +10,28 @@ function environment(){ } 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');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');await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,false);assert.equal(reply.missing,1);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'}})); + await e.dispatch('install'); await e.dispatch('activate'); + 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(await cache.match('/extra.js?v=new'),undefined); + await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:true}}); assert.equal(await cache.match('/extra.js?v=new'),undefined); + 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 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'); +}); diff --git a/frontend/assets.json b/frontend/assets.json index 1326222..e845dec 100644 --- a/frontend/assets.json +++ b/frontend/assets.json @@ -51,18 +51,26 @@ "/fonts/HankenGrotesk-latin-ext.woff2", "/fonts/HankenGrotesk-latin.woff2", "/fonts/JetBrainsMono-latin-ext.woff2", - "/fonts/JetBrainsMono-latin.woff2" - ] + "/fonts/JetBrainsMono-latin.woff2", + "/lazy.js", + "/feature-bootstrap.js", + "/eq-parameters.js", + "/assets.json", + "/device-db.js" + ], + "background": false }, "layout:glass-stage": { "contract": 1, "eager": false, "files": [ "/theme-glass.css", - "/theme-glass-controls.css", - "/glass-controls.js", "/theme-glass-panel.css", "/glass-panel-layout.js" + ], + "background": true, + "dependencies": [ + "feature:layout-controls" ] }, "layout:classic": { @@ -70,7 +78,8 @@ "eager": false, "files": [ "/theme-classic.css" - ] + ], + "background": true }, "layout:bento-hub": { "contract": 1, @@ -80,6 +89,10 @@ "/bento-hub.js", "/fonts/PlusJakartaSans-latin.woff2", "/fonts/PlusJakartaSans-latin-ext.woff2" + ], + "background": true, + "dependencies": [ + "feature:layout-controls" ] }, "layout:f7-swipe": { @@ -87,9 +100,10 @@ "eager": false, "files": [ "/theme-f7.css", - "/f7-layout.js", - "/vendor/framework7-swipe.min.js" - ] + "/vendor/framework7-swipe.min.js", + "/f7-layout.js" + ], + "background": true }, "feature:piano": { "contract": 1, @@ -98,28 +112,32 @@ "/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, @@ -127,28 +145,32 @@ "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, @@ -156,19 +178,20 @@ "files": [ "/car-mode.js", "/car-mode.css" - ] + ], + "background": true }, "feature:p2p": { "contract": 1, "eager": false, "files": [ - "/p2p-client.js", "/p2p-core.js", + "/p2p-client.js", "/p2p-transfer.js", "/p2p-recv-worker.js", - "/hash-worker.js", - "/device-db.js" - ] + "/hash-worker.js" + ], + "background": true }, "feature:direct": { "contract": 1, @@ -179,6 +202,10 @@ "/direct-media.js", "/direct-media.css", "/direct-recv-worker.js" + ], + "background": true, + "dependencies": [ + "feature:p2p" ] }, "feature:video-edit": { @@ -186,21 +213,24 @@ "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, @@ -208,7 +238,8 @@ "files": [], "embeddedIn": [ "/app.js" - ] + ], + "background": false }, "feature:settings": { "contract": 1, @@ -218,7 +249,8 @@ "/app.js", "/settings-sections.js", "/settings-sections.css" - ] + ], + "background": false }, "feature:service-mode": { "contract": 1, @@ -227,7 +259,8 @@ "embeddedIn": [ "/app.js", "/service-ui.css" - ] + ], + "background": false }, "feature:lyrics-editor": { "contract": 1, @@ -237,7 +270,47 @@ "/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": [] } } } diff --git a/frontend/car-mode.js b/frontend/car-mode.js index 9785ce1..b5e7234 100644 --- a/frontend/car-mode.js +++ b/frontend/car-mode.js @@ -10,7 +10,7 @@ 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); } + for (const [label, action] of [['Previous', 'prev'], ['Play / Pause', 'toggle'], ['15 s back', 'back'], ['Next', 'next']]) { const b = doc.getElementById('voiceSearchBtn') || doc.createElement('button'); b.id='voiceSearchBtn'; 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(); 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(); }; @@ -18,12 +18,12 @@ 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; - 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 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.getElementById('voiceSearchBtn') || doc.createElement('button'); b.id='voiceSearchBtn'; 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; })(typeof window !== 'undefined' ? window : globalThis); 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/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/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 f55934d..bfc7c2a 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -24,22 +24,15 @@ - - + + - - - - - - -
@@ -650,20 +643,14 @@ - - - - - - @@ -671,31 +658,16 @@ - - - - - - - - - - - - - - - - - + + diff --git a/frontend/lazy.js b/frontend/lazy.js index 3811cbc..a10e2bd 100644 --- a/frontend/lazy.js +++ b/frontend/lazy.js @@ -4,15 +4,22 @@ const doc = root.document; let manifest; try { manifest = JSON.parse(doc.getElementById('ytp-assets').textContent); } - catch { return; } // Native/static shells retain their eager script path. + catch { return; } // The index must supply this map; never fetch one during boot. const assets = new Map(), groups = new Map(), hooks = new Map(); const css = new Set([...doc.querySelectorAll('link[data-lazy-css]')].map(link => link.dataset.lazyCss)); const selected = new Map(); - const url = path => selected.get(path) || (manifest.files[path]?.h ? path + '?v=' + manifest.files[path].h : path); + const url = path => selected.get(path) || (manifest.files[path]?.h ? path + '?v=' + manifest.files[path].h : './' + path.replace(/^\//,'')); function reloadRequired(name) { api.reloadRequired = true; if (root.CustomEvent) root.dispatchEvent?.(new root.CustomEvent('ytp-reload-required', { detail: { group: name } })); } + function compatible(name, current, seen = new Set()) { + if(seen.has(name))return true; + seen.add(name); + const built=manifest.groups[name], newer=current.groups[name]; + if(!built || !newer || built.contract!==newer.contract)return false; + return (newer.dependencies || []).every(dependency=>(built.dependencies || []).includes(dependency)&&compatible(dependency,current,seen)); + } async function select(name) { if (!root.caches) return; try { @@ -20,15 +27,22 @@ const response = await cache.match('/__ytp_asset_state'); const current = response && (await response.json()).current; const built = manifest.groups[name], newer = current?.groups[name]; + // The active layout's CSS already executed in the parser before app boot. + if(name === 'layout:'+api.layout) return; if (!newer || current.buildTag === manifest.buildTag) return; - if (newer.contract !== built.contract) { reloadRequired(name); return; } - for (const path of built.files) { + if (!compatible(name,current)) { reloadRequired(name); return; } + const changedList = newer.files.join('\n') !== built.files.join('\n'); + const available = new Map(); + for (const path of changedList ? newer.files : built.files) { // Membership and contract must both match the running core's expectation. if (!newer.files.includes(path) || !current.files[path]) continue; const key = path + '?v=' + current.files[path].h; const held = await cache.match(key); - if (held?.headers.get('X-Asset-Hash') === current.files[path].h) selected.set(path, key); + if (held?.headers.get('X-Asset-Hash') === current.files[path].h) available.set(path, key); } + if(changedList && available.size !== newer.files.length)return; + for(const [path,key] of available)selected.set(path,key); + if(changedList)return newer; } catch { /* Private browsing/cache eviction: keep the embedded build-local map. */ } } function inject(path) { @@ -54,8 +68,8 @@ if (!group) return Promise.reject(new Error('Unknown asset group: ' + name)); const task = (async () => { for (const dependency of group.dependencies || []) await load(dependency); - await select(name); - for (const path of group.files) if (!/-worker\.js$/.test(path)) await inject(path); + const selectedGroup = await select(name) || group; + for (const path of selectedGroup.files) if (!/-worker\.js$/.test(path)) await inject(path); for (const fn of hooks.get(name) || []) await fn(); group.executed = true; })(); @@ -69,7 +83,31 @@ const api = { load, prefetch: load, loaded: name => !!manifest.groups[name]?.executed, url, manifest, layout: 'classic', reloadRequired: false, - onLoad(name, fn) { const list = hooks.get(name) || []; list.push(fn); hooks.set(name, list); }, + worker(path) { + const worker=new root.Worker(url(path)), post=worker.postMessage.bind(worker); let first=true; + worker.postMessage=(message,transfer)=>{ + if(first) { first=false; message={...message,assetUrls:{sha256:url('/sha256.js'),wasm:url('/sha256-wasm.js'),resume:url('/resume-core.js')}}; } + post(message,transfer); + }; + return worker; + }, + reportLayout(registration) { + const send = reg => { + const workers = new Set([root.navigator.serviceWorker.controller, reg?.installing, reg?.waiting, reg?.active]); + for(const worker of workers) worker?.postMessage({type:'LAYOUT',value:api.layout}); + }; + if(registration)send(registration); + else root.navigator.serviceWorker?.getRegistration().then(send).catch(()=>{}); + }, + warm() { + if(root.navigator.connection?.saveData || !root.navigator.serviceWorker) return; + const idle = root.requestIdleCallback || (fn => root.setTimeout(fn,1000)); + idle(()=>root.navigator.serviceWorker.ready.then(reg=>reg.active?.postMessage({type:'WARM_ASSETS',saveData:!!root.navigator.connection?.saveData})).catch(()=>{})); + }, + onLoad(name, fn) { + if(api.loaded(name)) { Promise.resolve().then(fn).catch(error=>{if(root.CustomEvent)root.dispatchEvent?.(new root.CustomEvent('ytp-lazy-error',{detail:{group:name,error}}));}); return; } + const list = hooks.get(name) || []; list.push(fn); hooks.set(name, list); + }, proxy(name, methods, globalName) { return Object.fromEntries(methods.map(method => [method, async (...args) => { await load(name); @@ -84,10 +122,18 @@ }, }; root.Lazy = api; - try { api.layout = JSON.parse(root.localStorage.getItem('_ytpdata') || '{}').settings?.layout || 'classic'; } catch {} - doc.documentElement.dataset.layout = api.layout; - const layout = manifest.groups['layout:' + api.layout] || manifest.groups['layout:classic']; - if (doc.readyState === 'loading') for (const path of layout?.files || []) { + root.navigator.serviceWorker?.addEventListener('message',event=>{ + if(event.data?.type==='ASSET_LAYOUT_REQUEST') event.source?.postMessage({type:'LAYOUT',value:api.layout}); + if(event.data?.type==='SW_UPDATE_AVAILABLE') api.warm(); + }); + root.navigator.serviceWorker?.addEventListener('controllerchange',()=>{api.reportLayout();api.warm();}); + let settings={}; + try { settings=JSON.parse(root.localStorage.getItem('_ytpdata') || '{}').settings || {}; api.layout=settings.layout || 'classic'; } catch {} + Object.assign(doc.documentElement.dataset,{layout:api.layout,theme:settings.theme || 'dark',font:settings.fontScale || 'normal',density:settings.density || 'comfortable',perf:settings.perfMode?'on':'off',motion:settings.reduceMotion?'reduced':'full'}); + const layoutName = manifest.groups['layout:' + api.layout] ? 'layout:'+api.layout : 'layout:classic'; + const layoutGroups = new Set([layoutName]); + for(const name of layoutGroups) for(const dependency of manifest.groups[name]?.dependencies || []) layoutGroups.add(dependency); + if (doc.readyState === 'loading') for (const path of [...layoutGroups].flatMap(name=>manifest.groups[name]?.files || [])) { if (!/\.css$/.test(path) || css.has(path)) continue; // Parser-inserted CSS blocks rendering; dynamic head links do not on all WebKit versions. css.add(path); diff --git a/frontend/lazy.test.js b/frontend/lazy.test.js index bbd0d34..8265580 100644 --- a/frontend/lazy.test.js +++ b/frontend/lazy.test.js @@ -63,3 +63,16 @@ test('unexecuted incompatible group keeps its running-build N-1 URLs and require assert.equal(f.root.Lazy.reloadRequired,true); assert.ok(events.includes('ytp-reload-required')); }); + +test('workers receive the pinned import URL map in their first message', () => { + const f=fixture(), sent=[]; f.root.Worker=class { constructor(url){this.url=url;} postMessage(message,transfer){sent.push({message,transfer});} }; + f.manifest.files['/worker.js']={h:'w'}; f.root.Lazy.manifest.files['/worker.js']={h:'w'}; + const worker=f.root.Lazy.worker('/worker.js');worker.postMessage({op:'open'},['transfer']);worker.postMessage({op:'finish'}); + assert.equal(worker.url,'/worker.js?v=w');assert.equal(sent[0].message.assetUrls.sha256,f.root.Lazy.url('/sha256.js'));assert.equal(sent[0].message.assetUrls.wasm,f.root.Lazy.url('/sha256-wasm.js'));assert.deepEqual(sent[0].transfer,['transfer']);assert.equal(sent[1].message.assetUrls,undefined); +}); + +test('new group files with a changed dependency contract cannot run against the old dependency',async()=>{ + const next=fixture().manifest;next.buildTag='next';next.groups['feature:test'].dependencies=['feature:dep'];next.groups['feature:dep']={contract:2,files:[]};next.files['/one.js'].h='new'; + const f=fixture(next,['/one.js?v=new','/two.js?v=2']);f.root.Lazy.manifest.groups['feature:test'].dependencies=['feature:dep'];f.root.Lazy.manifest.groups['feature:dep']={contract:1,files:[]}; + await f.root.Lazy.load('feature:test');assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);assert.equal(f.root.Lazy.reloadRequired,true); +}); diff --git a/frontend/lyrics-window.js b/frontend/lyrics-window.js index 7bc2c02..4c32a07 100644 --- a/frontend/lyrics-window.js +++ b/frontend/lyrics-window.js @@ -33,7 +33,7 @@ } function configure(options) { adapter = options; const supported = !!mode(); - for (const id of ['stageLyricsAuto', 'serviceAutoscrollBtn', 'pipBtn']) { const anchor = root.document.getElementById(id); if (!anchor || !supported) continue; const button = root.document.createElement('button'); button.type = 'button'; button.className = 'np-btn'; button.textContent = '⧉ Lyrics'; button.title = 'Open floating lyrics'; button.setAttribute('aria-label', 'Open floating lyrics window'); button.onclick = open; anchor.after(button); } + for (const id of ['stageLyricsAuto', 'serviceAutoscrollBtn', 'pipBtn']) { const anchor = root.document.getElementById(id); if (!anchor || !supported) continue; const button = root.document.getElementById('floating-'+id) || root.document.createElement('button'); button.id='floating-'+id; button.type = 'button'; button.className = 'np-btn'; button.textContent = '⧉ Lyrics'; button.title = 'Open floating lyrics'; button.setAttribute('aria-label', 'Open floating lyrics window'); button.onclick = open; if(!button.isConnected)anchor.after(button); } root.SettingsSections.register({ id: 'floating-lyrics', title: 'Floating lyrics', cluster: 'Live & worship', summary: () => mode() === 'document' ? 'Interactive window available' : mode() ? 'Lyric video PiP available' : 'Not supported on this browser', render(container) { const p = container.ownerDocument.createElement('p'); p.textContent = mode() === 'document' ? 'Open synced lyrics with seeking, Play/Pause and Next in an always-on-top window.' : mode() ? 'This browser supports a lyric-video window. Playback controls remain in the player.' : 'Floating lyrics are not supported on this browser.'; container.append(p); if (supported) { const b = container.ownerDocument.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Open floating lyrics'; b.onclick = open; container.append(b); } } }); } const api = { configure, support, wrap, element: id => pipWindow?.document.getElementById(id) || null }; if (typeof module !== 'undefined') module.exports = api; else root.LyricsWindow = api; diff --git a/frontend/opfs-worker.js b/frontend/opfs-worker.js index e7fd0c4..a140730 100644 --- a/frontend/opfs-worker.js +++ b/frontend/opfs-worker.js @@ -34,8 +34,7 @@ // Incremental SHA-256 (frontend/sha256.js): the file is hashed while it is // written, so the device knows its content id without re-reading the file. -try { importScripts('/sha256.js'); } catch { /* hashing unavailable — save still works */ } -importScripts('/resume-core.js'); + async function getVideosDir() { const root = await navigator.storage.getDirectory(); @@ -203,6 +202,10 @@ async function writeFile(dirName, name, buffer) { } self.onmessage = async (e) => { + self.__ASSET_URLS__=e.data?.assetUrls || {}; + try { if(!self.Sha256)importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); } catch { /* hashing unavailable — legacy save still works */ } + try { if(!self.ResumeCore)importScripts(self.__ASSET_URLS__.resume || '/resume-core.js'); } + catch(error) { self.postMessage({type:'error',error:error.message}); return; } if (e.data && e.data.op === 'write') { try { await writeFile(e.data.dir, e.data.name, e.data.buffer); self.postMessage({ type: 'written' }); } catch (err) { self.postMessage({ type: 'error', error: err && err.message ? err.message : String(err) }); } diff --git a/frontend/opfs.js b/frontend/opfs.js index 268a30b..b81b52c 100644 --- a/frontend/opfs.js +++ b/frontend/opfs.js @@ -151,7 +151,7 @@ return new Promise((resolve) => { let worker; try { - worker = new Worker('/opfs-worker.js'); + worker = window.Lazy ? window.Lazy.worker('/opfs-worker.js') : new Worker('/opfs-worker.js'); } catch { resolve({ ok: false, fallback: true }); return; diff --git a/frontend/p2p-client.js b/frontend/p2p-client.js index 7ab5834..adf5b76 100644 --- a/frontend/p2p-client.js +++ b/frontend/p2p-client.js @@ -72,7 +72,7 @@ function hashInWorker(name) { return new Promise((resolve) => { let w; - try { w = new Worker('/hash-worker.js'); } catch { resolve(null); return; } + try { w = window.Lazy ? window.Lazy.worker('/hash-worker.js') : new Worker('/hash-worker.js'); } catch { resolve(null); return; } w.onmessage = (e) => { w.terminate(); resolve(e.data && e.data.ok ? e.data : null); }; w.onerror = () => { w.terminate(); resolve(null); }; w.postMessage({ name }); @@ -296,7 +296,7 @@ function start(h) { hooks = { ...hooks, ...(h || {}) }; const idle = window.requestIdleCallback || ((fn) => setTimeout(fn, 1)); - setTimeout(() => idle(() => sync()), 8000); + setTimeout(() => idle(() => sync()), h?.initialDelay ?? 8000); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && Date.now() - lastSync > RESYNC_MS) sync(); }); diff --git a/frontend/p2p-recv-worker.js b/frontend/p2p-recv-worker.js index 66f76d8..f9ee489 100644 --- a/frontend/p2p-recv-worker.js +++ b/frontend/p2p-recv-worker.js @@ -12,7 +12,6 @@ * The ".part" suffix keeps OPFS.listVideos() from ever listing a partial file. * ========================================================================== */ 'use strict'; -importScripts('/sha256.js'); let dir = null; let handle = null; @@ -36,6 +35,7 @@ self.onmessage = (e) => { chain = chain.then(() => onOp(e.data || {})); }; async function onOp(m) { try { + if (!self.Sha256) { self.__ASSET_URLS__=m.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); } if (m.op === 'open') { const root = await navigator.storage.getDirectory(); dir = await root.getDirectoryHandle('videos', { create: true }); diff --git a/frontend/p2p-transfer.js b/frontend/p2p-transfer.js index 74c8c21..dfe87e6 100644 --- a/frontend/p2p-transfer.js +++ b/frontend/p2p-transfer.js @@ -99,7 +99,7 @@ return new Promise((resolve) => { const xid = newXid(); const pc = new RTCPeerConnection({ iceServers }); - const worker = new Worker('/p2p-recv-worker.js'); + const worker = window.Lazy ? window.Lazy.worker('/p2p-recv-worker.js') : new Worker('/p2p-recv-worker.js'); let settled = false; let idle = null; let expected = size; diff --git a/frontend/piano.js b/frontend/piano.js index f3ae236..a492c53 100644 --- a/frontend/piano.js +++ b/frontend/piano.js @@ -200,7 +200,7 @@ button('Transcribe in browser', () => run(async (id) => { const buffer = await audio(id, file.files[0]); - const engine = await import('./piano-engine.mjs'); + const engine = await import(root.Lazy?.url('/piano-engine.mjs') || './piano-engine.mjs'); const result = await engine.transcribe(buffer, (text) => { if (alive) status.textContent = text; }); diff --git a/frontend/settings-sections.js b/frontend/settings-sections.js index 31095ba..d5e7f3e 100644 --- a/frontend/settings-sections.js +++ b/frontend/settings-sections.js @@ -45,14 +45,23 @@ const text = document.createElement('span'), title = document.createElement('b'), summary = document.createElement('small'); title.textContent = section.title; text.append(title, summary); button.append(text); button.setAttribute('aria-controls', 'settings-section-' + section.id); navigation.append(button); rows.set(section.id, { button, summary }); const panel = document.createElement('section'); panel.id = 'settings-section-' + section.id; panel.className = 'settings-section'; panel.setAttribute('aria-label', section.title); panels.set(section.id, panel); detail.append(panel); - const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup); + if (!section.lazyGroup) { + const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup); + } button.addEventListener('click', () => open(section.id)); + if(section.lazyGroup)root.Lazy.intent(button,section.lazyGroup); } } grid.append(navigation, detail); host.replaceChildren(toolbar, results, empty, grid); function summaries() { for (const section of sections) { try { rows.get(section.id).summary.textContent = section.summary(context) || ''; } catch {} } } function show(id, inDetail, focusId) { if (registry.get(id)) active = id; + const section = sections.find(section => section.id === active); + const panel = panels.get(active); + if ((inDetail || win.matchMedia?.('(min-width: 861px)').matches) && section?.lazyGroup && !panel.dataset.rendered) { + panel.dataset.rendered = '1'; + const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup); + } host.dataset.detail = inDetail ? '1' : '0'; for (const [key, panel] of panels) panel.hidden = key !== active; for (const [key, row] of rows) row.button.setAttribute('aria-current', key === active ? 'true' : 'false'); diff --git a/frontend/sha256.js b/frontend/sha256.js index 931d248..318656e 100644 --- a/frontend/sha256.js +++ b/frontend/sha256.js @@ -36,7 +36,7 @@ wasm = null; try { if (typeof WebAssembly === 'undefined') return wasm; - if (!root.SHA256_WASM_B64 && typeof importScripts === 'function') importScripts('/sha256-wasm.js'); + if (!root.SHA256_WASM_B64 && typeof importScripts === 'function') importScripts(root.__ASSET_URLS__?.wasm || '/sha256-wasm.js'); let b64 = root.SHA256_WASM_B64; if (!b64 && typeof require === 'function') b64 = require('./sha256-wasm.js'); if (!b64) return wasm; diff --git a/frontend/shell-consistency.test.js b/frontend/shell-consistency.test.js index 547c292..92080a7 100644 --- a/frontend/shell-consistency.test.js +++ b/frontend/shell-consistency.test.js @@ -53,9 +53,10 @@ test('assets.json describes the complete existing shell exactly once with eager test('the selected glass-stage layout includes its controls and panel dependencies', () => { const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8')); assert.deepEqual(groups['layout:glass-stage'].files, [ - '/theme-glass.css', '/theme-glass-controls.css', '/glass-controls.js', - '/theme-glass-panel.css', '/glass-panel-layout.js', + '/theme-glass.css', '/theme-glass-panel.css', '/glass-panel-layout.js', ]); + assert.deepEqual(groups['layout:glass-stage'].dependencies,['feature:layout-controls']); + assert.ok(groups['feature:layout-controls'].files.includes('/glass-controls.js')); }); test('the shared sync core is eager and loads before the update helper',()=>{ @@ -64,3 +65,16 @@ test('the shared sync core is eager and loads before the update helper',()=>{ assert.ok(html.indexOf('src="asset-sync-core.js"') { + const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8')); + const background=new Set(Object.values(groups).filter(g=>g.background).flatMap(g=>g.files)); + for(const match of html.matchAll(/<(?:script\b[^>]*\bsrc|link\b[^>]*\bhref)="([^"]+)"/gi)) { + const path='/'+match[1].replace(/^\//,'').split(/[?#]/)[0]; + assert.ok(!background.has(path),`${path} is not eager`); + } + assert.ok(html.indexOf('id="ytp-assets"')')); + assert.deepEqual(JSON.parse(html.match(/id="ytp-assets">([^<]+)<\/script>/)[1]).groups,groups); + for(const name of ['playlist','settings','service-mode','lyrics-editor'])assert.ok(groups['feature:'+name].eager); +}); diff --git a/frontend/sw-update.js b/frontend/sw-update.js index 5853665..974baa7 100644 --- a/frontend/sw-update.js +++ b/frontend/sw-update.js @@ -165,8 +165,9 @@ // legacy refresh followed by a reload into the old persistent manifest. if (worker && !status) throw new Error('Worker update status unavailable; try again'); if (status && status.assetSync) { - const result = await core.syncAssets(m,{cache,fetchFn:opts.fetchFn}); - if (!reg.waiting) await core.commit(m,cache); + const activeLayout = root.Lazy?.layout || 'classic'; + const result = await core.syncAssets(m,{cache,fetchFn:opts.fetchFn,activeLayout}); + if (!reg.waiting) await core.commit(m,cache,{activeLayout}); return result; } } @@ -210,7 +211,7 @@ try { const ch = new Ctor(); ch.port1.onmessage = (ev) => finish(ev && ev.data ? ev.data : null); - worker.postMessage({ type: 'CACHE_STATUS' }, [ch.port2]); + worker.postMessage({ type: 'CACHE_STATUS', activeLayout: root.Lazy?.layout }, [ch.port2]); } catch { finish(null); return; } if (schedule) schedule(() => finish(null), timeoutMs); }); diff --git a/frontend/sw.js b/frontend/sw.js index 266ef71..b86a59c 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -74,6 +74,10 @@ const SHELL = [ '/async-guard.js', '/visible-timer.js', '/asset-sync-core.js', + '/lazy.js', + '/feature-bootstrap.js', + '/eq-parameters.js', + '/assets.json', '/sw-update.js', '/fingerprint.js', '/opfs.js', @@ -287,7 +291,27 @@ self.addEventListener('fetch', (e) => { // ---- Message: handle SKIP_WAITING from the client ---- const playingClients = new Map(), deferredClients = new Set(); +let activeLayout = 'classic', layoutReply; self.addEventListener('message', (e) => { + if (ASSET_SYNC && e.data?.type === 'LAYOUT' && e.source && typeof e.data.value === 'string') { + activeLayout = e.data.value; + layoutReply?.(); + e.waitUntil((async()=>{ + const cache=await caches.open(AssetSyncCore.CACHE); + await cache.put('/__ytp_layout',Response.json({layout:activeLayout})); + const m=await candidate(cache); + if(m && m.activeLayout!==activeLayout) { + await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetch,activeLayout}); + m.activeLayout=activeLayout; await cache.put(candidateKey,Response.json(m)); + } + })()); + } + if (ASSET_SYNC && e.data?.type === 'WARM_ASSETS' && !e.data.saveData) { + e.waitUntil((async()=>{ + const cache=await caches.open(AssetSyncCore.CACHE), m=(await AssetSyncCore.state(cache))?.current; + if(m)await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetch,groups:Object.keys(m.groups).filter(name=>m.groups[name].background)}); + })().catch(error=>console.warn('[asset-sync] idle warm interrupted',error.message))); + } if (e.data && e.data.type === 'PLAYING' && e.source) { playingClients.set(e.source.id, !!e.data.value); if (!e.data.value && deferredClients.delete(e.source.id)) self.skipWaiting(); @@ -306,7 +330,11 @@ self.addEventListener('message', (e) => { e.waitUntil((async () => { if (ASSET_SYNC) { let reply = { ready: false, missing: -1, version: VERSION }; - try { const cache = await caches.open(AssetSyncCore.CACHE); const m = await candidate(cache) || (await AssetSyncCore.state(cache))?.current; if(m) reply = await AssetSyncCore.status(m,cache); } catch {} + try { + const cache = await caches.open(AssetSyncCore.CACHE), state = await AssetSyncCore.state(cache); + const pending = await candidate(cache), m = pending || state?.current; + if(m) reply = await AssetSyncCore.status(m,cache,{previous:pending ? state?.current : state?.previous,activeLayout:e.data.activeLayout || (pending ? m.activeLayout : activeLayout) || 'classic'}); + } catch {} reply.type = 'CACHE_STATUS'; reply.assetSync = true; if(e.ports && e.ports[0]) e.ports[0].postMessage(reply); else if(e.source) e.source.postMessage(reply); return; @@ -446,6 +474,15 @@ async function installAssets() { const m = await r.json(); if (m.buildTag !== VERSION) throw Error('Deployment changed during install'); const cache = await caches.open(AssetSyncCore.CACHE); + const remembered = await cache.match('/__ytp_layout'); + if(remembered)activeLayout=(await remembered.json()).layout || 'classic'; + let layoutKnown = !!remembered; + const clients = await self.clients.matchAll({type:'window',includeUncontrolled:true}); + await new Promise(resolve=>{ + const timer=setTimeout(resolve,500); layoutReply=()=>{layoutKnown=true;clearTimeout(timer);resolve();}; + for(const client of clients)client.postMessage({type:'ASSET_LAYOUT_REQUEST'}); + }); + layoutReply=null; m.activeLayout=activeLayout; const freshInstall = !(await AssetSyncCore.state(cache)); const fetchAsset = async (url,init) => { // Fonts/icons still use plain URLs in CSS/manifest. On first install reuse @@ -456,7 +493,14 @@ async function installAssets() { } return fetch(url,init); }; - await AssetSyncCore.syncAssets(m, { cache, fetchFn: fetchAsset }); + await AssetSyncCore.syncAssets(m, { cache, fetchFn: fetchAsset, activeLayout }); + // Older pages cannot report their saved choice. Keep every layout ready for + // that one migration so an immediate offline reload cannot lose its theme. + if(!layoutKnown && ((await AssetSyncCore.state(cache)) || (await caches.keys()).some(isVersionedShellCache))) { + const layouts=new Set(Object.keys(m.groups).filter(name=>name.startsWith('layout:'))); + for(const name of layouts)for(const dependency of m.groups[name].dependencies || [])layouts.add(dependency); + await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetchAsset,groups:[...layouts]}); + } await cache.put(candidateKey, new Response(JSON.stringify(m))); } async function migrateLegacy(cache, m) { @@ -489,7 +533,7 @@ async function activateAssets() { if (!m) throw Error('Missing candidate manifest'); const names = await migrateLegacy(cache,m), old = await AssetSyncCore.state(cache); const previousClients = (await self.clients.matchAll({type:'window'})).map(c=>c.id); - await AssetSyncCore.commit(m,cache,{previousClients}); + await AssetSyncCore.commit(m,cache,{previousClients,activeLayout:m.activeLayout || activeLayout}); // Legacy deletion occurs strictly after verified commit. await Promise.all(names.map(n => caches.delete(n))); for (const req of await cache.keys()) if(new URL(req.url).pathname.startsWith('/__ytp_candidate/')) await cache.delete(req); @@ -516,6 +560,11 @@ async function assetFetch(request, clientId) { const cached = await cache.match(key); const h = new URL(key,self.location.origin).searchParams.get('v'); if(cached && cached.headers.get('X-Asset-Hash') === h) return cached; + if(state.current.files[path]?.h === h) { + const previousKey=AssetSyncCore.fallback(state.current,state.previous,path); + const previousResponse=previousKey && await cache.match(previousKey); + if(previousResponse && previousResponse.headers.get('X-Asset-Hash')===state.previous.files[path].h) return previousResponse; + } try { const r = await fetch(key); if(r.ok && r.headers.get('X-Asset-Hash') !== h) return new Response('Asset version unavailable',{status:409}); if(r.ok && request.method !== 'HEAD') await cache.put(key,r.clone()); return r; } catch { return new Response('Offline',{status:503}); } } try { return await fetch(request); } catch { return new Response('Offline',{status:503}); } diff --git a/frontend/worker-imports.test.js b/frontend/worker-imports.test.js new file mode 100644 index 0000000..25641b5 --- /dev/null +++ b/frontend/worker-imports.test.js @@ -0,0 +1,12 @@ +const {test}=require('node:test'), assert=require('node:assert/strict'), vm=require('node:vm'), fs=require('node:fs'); +for(const name of ['hash-worker','direct-recv-worker','p2p-recv-worker','opfs-worker'])test(name+' imports its originating page hashes before handling the first operation',async()=>{ + const urls=[], replies=[];const self={postMessage:m=>replies.push(m)}; + const sandbox={self,console,URL,setTimeout,clearTimeout,Promise,TextEncoder,FileSystemFileHandle:class {createSyncAccessHandle(){}},navigator:{storage:{getDirectory:async()=>{throw Error('fixture: no disk writes')}}},fetch:async()=>{throw Error('fixture: no network')},importScripts:u=>{urls.push(u);if(u.includes('/sha256.js'))self.Sha256={create:()=>({})};if(u.includes('/resume-core.js'))self.ResumeCore={};}}; + vm.runInNewContext(fs.readFileSync(__dirname+'/'+name+'.js','utf8'),sandbox); + assert.deepEqual(urls,[]); + await self.onmessage({data:{name:'x',op:'open',videoId:'x',url:'/fixture',file:{id:'x',cid:'a'.repeat(64)},assetUrls:{sha256:'/sha256.js?v=sha',wasm:'/sha256-wasm.js?v=wasm',resume:'/resume-core.js?v=resume'}}}); + await new Promise(r=>setImmediate(r)); + assert.equal(urls[0],'/sha256.js?v=sha');assert.equal(self.__ASSET_URLS__.wasm,'/sha256-wasm.js?v=wasm'); + if(name==='opfs-worker')assert.ok(urls.includes('/resume-core.js?v=resume')); + assert.ok(replies.length>0); +}); diff --git a/server/static-delivery.test.js b/server/static-delivery.test.js index 0a6fff0..ed0661e 100644 --- a/server/static-delivery.test.js +++ b/server/static-delivery.test.js @@ -13,7 +13,7 @@ mkdirSync(publicDir); mkdirSync(dataDir); mkdirSync(join(publicDir, 'fonts')); mkdirSync(join(publicDir, 'icons')); -writeFileSync(join(publicDir, 'index.html'), ''); +writeFileSync(join(publicDir, 'index.html'), ''); writeFileSync(join(publicDir, 'app.css'), 'body{}'); writeFileSync(join(publicDir, 'app.js'), 'window.app=true;'); writeFileSync(join(publicDir, 'root.css'), 'body{color:red}'); @@ -219,6 +219,20 @@ describe('static delivery characterization', () => { } }); + test('all index routes carry the identical embedded build-local map', async () => { + const manifest=await (await fetch(`${server.base}/api/manifest`)).json(); + let expected; + for(const path of ['/', '/index.html','/search','/playlist/x']) { + const html=await (await fetch(`${server.base}${path}`)).text(); + const map=JSON.parse(html.match(/id="ytp-assets">([^<]+)<\/script>/)[1]); + expect(map.buildTag).toBe(manifest.buildTag); + expect(map.groups).toEqual(manifest.groups); + expect(map.files['/app.js'].h).toBe(manifest.files['/app.js'].h); + expect(map.files['/index.html']).toBeUndefined(); + if(expected)expect(html).toBe(expected);else expected=html; + } + }); + test('SPA routes fall back to the stamped index', async () => { const { buildTag } = await (await fetch(`${server.base}/api/version`)).json(); for (const path of ['/search', '/playlist/x']) {