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 @@ - - + + - - - - - - -