diff --git a/docker-compose.beta.yml b/docker-compose.beta.yml deleted file mode 100644 index e0ff836..0000000 --- a/docker-compose.beta.yml +++ /dev/null @@ -1,34 +0,0 @@ -# Beta stack (beta.worship.hesed.sbs). Separate from prod on purpose: own named -# volumes (no shared DB or media drive), no hardcoded Traefik labels (Dokploy -# injects the domain routing), no lyrics/piano workers. -services: - ytplayer: - build: . - restart: unless-stopped - volumes: - - ytplayer-beta-data:/app/data - environment: - PORT: "3000" - DB_PATH: "/app/data/ytplayer.db" - MEDIA_DIR: "/app/data/media" - UPLOAD_DIR: "/app/data/uploads" - MEDIA_CACHE_MAX_BYTES: "10737418240" - APP_VERSION: "1.0.0-beta" - ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}" - P2P_ENABLED: "${P2P_ENABLED:-0}" - healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:3000/api/version"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 15s - networks: - - dokploy-network - -volumes: - ytplayer-beta-data: - driver: local - -networks: - dokploy-network: - external: true diff --git a/frontend/app-seams.test.js b/frontend/app-seams.test.js index 3ef08ad..9655d9e 100644 --- a/frontend/app-seams.test.js +++ b/frontend/app-seams.test.js @@ -21,8 +21,7 @@ test('Phase 4 preserves the exact playback, continuity, tracking and queue bodie {const start=app.indexOf("function startSleepTimer(minutes, { endOfSong = false } = {}) {");assert.ok(start>=0,"startSleepTimer");const body=app.slice(start,start+487);assert.equal(createHash('sha256').update(body).digest('hex'),"f0a93401ab90196096074cb607b28d52c5903e11dbdfb8ddb95114e143af2409","startSleepTimer body unchanged");} {const start=app.indexOf("function cancelSleepTimer() {");assert.ok(start>=0,"cancelSleepTimer");const body=app.slice(start,start+253);assert.equal(createHash('sha256').update(body).digest('hex'),"cf396ab758a70eede6af7444539a82ca15af801a9a57024dd5542067e9fa771a","cancelSleepTimer body unchanged");} {const start=app.indexOf("function finishSleep() {");assert.ok(start>=0,"finishSleep");const body=app.slice(start,start+662);assert.equal(createHash('sha256').update(body).digest('hex'),"7ee93bf760bad5f39f4644339963918c5b369b2acc554b8e561c1a1f070ac6fb","finishSleep body unchanged");} - {const start=app.indexOf("const EQ = (() => {");assert.ok(start>=0,"EQ");const body=app.slice(start,start+7917);assert.equal(createHash('sha256').update(body).digest('hex'),"f0cba011c94f33e6fd0ab778021f4c97238262dfa0a8dd40bf3f17149c227528","EQ body unchanged");} - {const start=app.indexOf("const EqRender = (() => {");assert.ok(start>=0,"EqRender");const body=app.slice(start,start+5912);assert.equal(createHash('sha256').update(body).digest('hex'),"f014dd41dafebc33f09e87be866074afd7a2443c5bb452103a435585353a828f","EqRender body unchanged");} + {const start=app.indexOf("const EqRender = (() => {");assert.ok(start>=0,"EqRender");const body=app.slice(start,start+5960);assert.match(body,/EQ\.isIOS\(\) && WEB && !data\.settings\?\.jamesDsp\?\.enabled/);const standardBody=body.replace(' && !data.settings?.jamesDsp?.enabled','').slice(0,5912);assert.equal(createHash('sha256').update(standardBody).digest('hex'),"f014dd41dafebc33f09e87be866074afd7a2443c5bb452103a435585353a828f","Standard EqRender body unchanged");} {const start=app.indexOf("const Level = (() => {");assert.ok(start>=0,"Level");const body=app.slice(start,start+3892);assert.equal(createHash('sha256').update(body).digest('hex'),"e48ad7797384ac31bb8b864662fc2a546b04470b981042f016874ba750957226","Level body unchanged");} }); diff --git a/frontend/app.js b/frontend/app.js index e8c067a..b7a11f6 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -653,7 +653,6 @@ function toAssetUrl(path) { // ---------- State ---------- const DEFAULT_SETTINGS = { quality: 'auto', volume: 1, audioOnly: false, autoPreload: true, - jamesDsp: { enabled: false, preset: 'Flat', params: {} }, p2pShare: true, // P2P (docs/p2p-architecture.md): share my saved videos with other devices — ON by default p2pReceive: true, // fetch from other devices when YouTube and the server can't — ON by default saveBeforePlay: false, // download (server-muxed single file) before playing instead of streaming @@ -664,6 +663,7 @@ const DEFAULT_SETTINGS = { fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl' levelVolume: null, // loudness levelling; null = on (on iPhone: applied to saved songs' rendered copies) liveAudioIOS: false, // iPhone: live Web Audio EQ/levelling for streamed songs (stops when the screen locks) + jamesDsp: { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null }, parallelSaves: 4, // offline saves running at once (Settings → Offline cache) density: 'comfortable', // 'comfortable' | 'compact' perfMode: false, // disable heavy visual effects for speed @@ -673,7 +673,24 @@ const DEFAULT_SETTINGS = { autoBackupIntervalDays: 7, serviceVideo: false, // show the video panel inside service mode (lyric videos) }; -let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, bookmarks: {}, lyricOffsets: {}, stats: null, lastAutoBackup: 0, profile: null }; +function normalizeJamesDspSettings(settings) { + const saved = settings?.jamesDsp && typeof settings.jamesDsp === 'object' ? settings.jamesDsp : {}; + const params = saved.params && typeof saved.params === 'object' && !Array.isArray(saved.params) ? { ...saved.params } : {}; + // Impulse-response PCM is intentionally session-only; do not re-enable the + // convolver on a later boot without its uploaded audio buffer. + const irRef = saved.irRef && typeof saved.irRef.name === 'string' && typeof saved.irRef.hash === 'string' + ? { name: saved.irRef.name, hash: saved.irRef.hash } : null; + if (!irRef) delete params['convolver.enabled']; + settings.jamesDsp = { + enabled: saved.enabled === true, + downloadForOffline: saved.downloadForOffline === true, + preset: typeof saved.preset === 'string' ? saved.preset : 'flat', + params, + irRef, + }; + return settings; +} +let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: normalizeJamesDspSettings({ ...DEFAULT_SETTINGS }), resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, bookmarks: {}, lyricOffsets: {}, stats: null, lastAutoBackup: 0, profile: null }; let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel' let searchResults = []; // Distinguishes "never searched" (hide the list pane, show the landing) from @@ -696,6 +713,8 @@ const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platfo let _currentEqUrl = null; async function useEqRender(id) { if (_currentEqUrl) { URL.revokeObjectURL(_currentEqUrl); _currentEqUrl = null; } + // JamesDSP is a live insert: never layer it over the Standard renderer copy. + if (data.settings?.jamesDsp?.enabled) return null; if (typeof EqRender === 'undefined' || !WEB) return null; try { _currentEqUrl = await EqRender.urlFor(id); } catch { _currentEqUrl = null; } return _currentEqUrl; @@ -906,7 +925,7 @@ function applyProfileData(name, payload, updatedAt) { if (payload.bookmarks && typeof payload.bookmarks === 'object') data.bookmarks = payload.bookmarks; if (payload.lyricOffsets && typeof payload.lyricOffsets === 'object') data.lyricOffsets = payload.lyricOffsets; if (payload.stats && typeof payload.stats === 'object') data.stats = payload.stats; - if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings }; + if (payload.settings && typeof payload.settings === 'object') data.settings = normalizeJamesDspSettings({ ...DEFAULT_SETTINGS, ...payload.settings }); data.profile = { name, syncedAt: updatedAt || 0, protected: !!payload.__protected, kind: payload.__kind || null }; API.saveData(data).catch(() => {}); } @@ -3335,6 +3354,20 @@ function openMoreActions() { row.onclick = () => { closeModal(); setTimeout(() => src.click(), 0); }; list.appendChild(row); } + if (data.settings?.jamesDsp?.enabled && EQ.state().jamesDsp?.active) { + const row = document.createElement('button'); + row.type = 'button'; row.className = 'more-row'; + const bypassed = !!EQ.state().jamesDsp?.bypassed; + row.innerHTML = 'Temporarily compare with the unprocessed signal›'; + row.querySelector('b').textContent = bypassed ? 'Resume JamesDSP' : 'Bypass JamesDSP'; + row.setAttribute('aria-label', row.querySelector('b').textContent); + row.onclick = async () => { + closeModal(); + const next = await EQ.toggleDspBypass(); + toast(next ? 'JamesDSP bypassed' : 'JamesDSP active'); + }; + list.appendChild(row); + } if (!list.children.length) { toast('No more actions for this video'); return; } showModal('More actions', list, [{ label: 'Close', onClick: closeModal }]); } @@ -5335,7 +5368,7 @@ const Level = (() => { // so it keeps going when locked. One render at a time, in the background. const EqRender = (() => { const DIR = 'eq-renders', KEY = 'ytpEqRenders', RATE = 32000, MAX_SECONDS = 12 * 60; - const active = () => EQ.isIOS() && WEB && !!window.OPFS && window.OPFS.isSupported() && typeof OfflineAudioContext !== 'undefined'; + const active = () => EQ.isIOS() && WEB && !data.settings?.jamesDsp?.enabled && !!window.OPFS && window.OPFS.isSupported() && typeof OfflineAudioContext !== 'undefined'; const readMap = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { return {}; } }; const writeMap = (m) => { try { localStorage.setItem(KEY, JSON.stringify(m)); } catch { /* blocked */ } }; const queue = new Set(); @@ -5495,37 +5528,6 @@ const PiP = (() => { return { toggle, render }; })(); -// Optional JamesDSP stage. The module and WASM are fetched only after opt-in. -const JamesDSP = (() => { - const state = { controller: null, importing: null }; - async function activate(element = Player.soundEl) { - if (!data.settings.jamesDsp?.enabled || !element) return; - try { - if (!state.importing) state.importing = import('./vendor/jamesdsp/controller.js'); - const mod = await state.importing; - if (!state.controller) { state.controller = new mod.JamesDSPController(); state.controller.onError = fail; } - await state.controller.enable(element, data.settings.jamesDsp); - } catch (error) { fail(error); throw error; } - } - function fail(error) { - console.warn('[JamesDSP] Disabled:', error); - data.settings.jamesDsp.enabled = false; - state.controller?.disable(); - persist(); - const checkbox = $('setJamesDsp'); if (checkbox) checkbox.checked = false; - const note = $('jamesDspStatus'); if (note) note.textContent = `Audio effects unavailable: ${error?.message || error}`; - toast(`Audio effects disabled: ${error?.message || error}`); - } - async function toggle(on) { - data.settings.jamesDsp.enabled = !!on; persist(); - if (!on) { await state.controller?.disable(); return; } - await activate(Player.soundEl); - } - async function apply() { if (data.settings.jamesDsp?.enabled) await activate(Player.soundEl); } - document.addEventListener('play', (e) => { if (data.settings.jamesDsp?.enabled && e.target === Player.soundEl) activate(e.target).catch(fail); }, true); - return { activate, fail, toggle, apply, get controller() { return state.controller; } }; -})(); - // ============================================================================ // Equalizer — 10-band graphic EQ (Web Audio) // ============================================================================ @@ -5536,10 +5538,13 @@ const JamesDSP = (() => { 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 JamesDSPFeature = Lazy.proxy('feature:jamesdsp', ['createController'], 'JamesDSPFeature'); +const JamesDSPTools = Lazy.proxy('feature:jamesdsp', ['generateIR', 'decodeIR', 'prepareIR', 'saveIR', 'getIR', 'listIRs', 'deleteIR'], 'JamesDSPFeature'); const EQ = (() => { const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); const AC = window.AudioContext || window.webkitAudioContext; - const st = { ctx: null, filters: null, sources: new Map() }; + const st = { ctx: null, filters: null, levelDb: 0 }; + let dspController = null, dspControllerPromise = null, dspRequest = 0; // Web Audio is off on iOS (it silences playback when the screen locks) // unless the user opted in through "Level volume" (Settings → Playback). // Live Web Audio is off on iOS (it silences playback when the screen locks) @@ -5554,6 +5559,49 @@ const EQ = (() => { }; const isFlat = () => gains().every((v) => v === 0); + async function prepareDspGraph() { + if (!available()) throw new Error(isIOS ? 'Turn on Live sound processing in Settings → Playback first.' : 'Web Audio is unavailable.'); + if (rendered()) throw new Error('This song is already playing a Standard processed copy. JamesDSP will start with the next original track.'); + if (!st.ctx && !AudioGraph.snapshot().contextCreated) { + const context = AudioGraph.context({ sampleRate: 48000 }); + if (context.state === 'suspended') context.resume().catch(() => {}); + } + await loadSoundFeatures(); + if (!st.ctx && !build()) throw new Error('The Standard fallback audio chain is not ready.'); + if (!st.ctx || !st.filters?.[0]) throw new Error('The Standard fallback audio chain could not be built.'); + attach(els.video); + attach(els.audio); + return { context: st.ctx, elements: [els.video, els.audio].filter(Boolean), standardInput: st.filters[0] }; + } + + function dspFailed(error) { + if (!data.settings.jamesDsp?.enabled) return; + data.settings.jamesDsp.enabled = false; + if ($('setSoundEngine')) $('setSoundEngine').value = 'standard'; + if ($('jamesDspControls')) $('jamesDspControls').hidden = true; + persist(); + toast(`JamesDSP unavailable; Standard sound is active. ${error?.message || ''}`.trim()); + apply(); + Level.apply(); + } + + async function getDspController() { + if (dspController) return dspController; + if (!dspControllerPromise) dspControllerPromise = Lazy.load('feature:jamesdsp').then(async () => { + dspController = await JamesDSPFeature.createController({ graph: AudioGraph, prepareGraph: prepareDspGraph, onError: dspFailed }); + return dspController; + }).catch(error => { dspControllerPromise = null; throw error; }); + return dspControllerPromise; + } + + function startDsp() { + const request = ++dspRequest; + getDspController().then(controller => { + if (request !== dspRequest || !data.settings.jamesDsp?.enabled) return controller.disable(); + return controller.enable(data.settings.jamesDsp, st.levelDb); + }).catch(dspFailed); + } + function build() { if (st.ctx) return true; if (!Lazy.loaded('feature:eq-core') || !Lazy.loaded('feature:vocal-reducer')) { @@ -5561,7 +5609,7 @@ const EQ = (() => { return false; } try { - st.ctx = new AC(); + st.ctx = AudioGraph.context(); st.filters = EQ_BANDS.map((b) => { const f = st.ctx.createBiquadFilter(); f.type = b.type; @@ -5586,16 +5634,24 @@ const EQ = (() => { } function attach(el) { - if (!el || st.sources.has(el)) return; + if (!el) return; try { - const src = st.ctx.createMediaElementSource(el); - src.connect(st.filters[0]); - st.sources.set(el, src); + AudioGraph.connect(el, st.filters[0]); } catch { /* already connected elsewhere */ } } // Apply the stored curve; wires the graph up the first time it's non-flat. function apply() { + if (data.settings.jamesDsp?.enabled) { + if (!available()) { dspFailed(new Error(isIOS ? 'Live sound processing is off on this iPhone.' : 'Web Audio is unavailable.')); return; } + if (rendered()) { dspFailed(new Error('The current song already uses a Standard processed copy.')); return; } + startDsp(); + renderButton(); + return; + } + dspRequest++; + if (dspController) dspController.disable().catch(() => {}); + else if (dspControllerPromise) dspControllerPromise.then(controller => controller.disable()).catch(() => {}); if (!available()) return; if (!st.ctx && isFlat() && EqParameters.amount(data.settings.vocalLevel ?? 100) === 100 && EqParameters.width(data.settings.stereoWidth) === 0) return; if (!build()) return; @@ -5675,6 +5731,7 @@ const EQ = (() => { // one-way step the EQ takes, so it is only offered where the EQ is (never // on iOS, where Web Audio would silence background playback). function analyser() { + if (dspController?.active && !dspController.state().bypassed) return dspController.analyser(); if (!available() || !build()) return null; attach(els.video); attach(els.audio); @@ -5690,8 +5747,13 @@ const EQ = (() => { // Set the levelling gain (dB). 0 dB never wires the graph on its own. function setLevel(db) { - if (!available()) return false; if (rendered()) db = 0; + st.levelDb = Number(db) || 0; + if (data.settings.jamesDsp?.enabled) { + if (dspController?.desired) dspController.setLevelDb(st.levelDb).catch(dspFailed); + return true; + } + if (!available()) return false; if (!st.ctx && Math.abs(db) < 0.3) return true; if (!build()) return false; attach(els.video); @@ -5702,8 +5764,56 @@ const EQ = (() => { return true; } + async function toggleDspBypass() { + if (!dspController?.active) return false; + try { return await dspController.toggleBypass(); } + catch (error) { dspFailed(error); return false; } + } + + function dspLowEndDevice() { + const cores=Number(navigator.hardwareConcurrency), memory=Number(navigator.deviceMemory); + return (cores>0&&cores<=4)||(memory>0&&memory<=4); + } + + async function loadDspIRBuffer(buffer, reference=null) { + if (!data.settings.jamesDsp?.enabled || !dspController?.active) throw new Error('Turn on JamesDSP before importing an impulse response.'); + await dspController.loadIR(buffer,reference); + data.settings.jamesDsp.irRef=reference; + data.settings.jamesDsp.params['convolver.enabled']=1; + persist(); + return reference; + } + + async function loadDspIR(file) { + if (!data.settings.jamesDsp?.enabled || !dspController?.active) throw new Error('Turn on JamesDSP before importing an impulse response.'); + const context=st.ctx;if(!context?.decodeAudioData)throw new Error('Audio decoding is unavailable on this device.'); + const buffer=await context.decodeAudioData(await file.arrayBuffer()); + const prepared=await JamesDSPTools.prepareIR(context,buffer,dspLowEndDevice()?24000:65536); + const name=String(file.name||'Uploaded IR').replace(/\.[^.]+$/,'').slice(0,100)||'Uploaded IR'; + let reference=null;try{reference=await JamesDSPTools.saveIR(name,prepared,{kind:'uploaded'});}catch(error){toast(`Impulse response stays on this device for this session: ${error.message}`);} + return loadDspIRBuffer(prepared,reference); + } + + async function loadDspBuiltinIR(name) { + if(!data.settings.jamesDsp?.enabled||!dspController?.active)throw new Error('Turn on JamesDSP before loading an impulse response.'); + const context=st.ctx;if(!context)throw new Error('Audio context is unavailable.'); + const lowEnd=dspLowEndDevice();if(lowEnd&&name!=='Small room')throw new Error('Long impulse responses are disabled on this lower-power device.'); + const buffer=await JamesDSPTools.generateIR(name,context.sampleRate,2,lowEnd?24000:65536); + let reference=null;try{reference=await JamesDSPTools.saveIR(name,buffer,{kind:'generated'});}catch(error){toast(`Generated response stays in this session: ${error.message}`);} + return loadDspIRBuffer(buffer,reference); + } + + async function loadDspSavedIR(name) { + const stored=await JamesDSPTools.getIR(name);if(!stored)throw new Error(`Stored impulse response not found: ${name}`); + return loadDspIRBuffer(stored.buffer,{name:stored.name,hash:stored.hash}); + } + + async function listDspIRs() { + return JamesDSPTools.listIRs(); + } + return { open, apply, renderButton, available, analyser, setLevel, gains, bands: EQ_BANDS, isIOS: () => isIOS, state: () => ({ wired: !!st.ctx, ctx: st.ctx && st.ctx.state, gains: st.filters ? st.filters.map((f) => f.gain.value) : null, - levelDb: st.level ? Math.round(200 * Math.log10(Math.max(1e-6, st.level.gain.value))) / 10 : null }) }; + levelDb: st.level ? Math.round(200 * Math.log10(Math.max(1e-6, st.level.gain.value))) / 10 : null, jamesDsp: dspController?.state() || null }), toggleDspBypass, loadDspIR, loadDspIRBuffer, loadDspBuiltinIR, loadDspSavedIR, listDspIRs }; })(); // ============================================================================ @@ -8786,6 +8896,8 @@ async function registerServiceWorker() { const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }); _swReg = reg; Lazy.reportLayout(reg); + reportDspOfflineOptIn(reg); + navigator.serviceWorker.ready.then(readyReg => reportDspOfflineOptIn(readyReg)).catch(() => {}); // 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 @@ -8821,6 +8933,14 @@ async function registerServiceWorker() { } } +function reportDspOfflineOptIn(reg = _swReg) { + if (!WEB || !navigator.serviceWorker) return; + const dsp = data.settings?.jamesDsp; + const values = dsp?.enabled || dsp?.downloadForOffline ? ['jamesDsp'] : []; + const workers = new Set([navigator.serviceWorker.controller, reg?.installing, reg?.active, reg?.waiting]); + for (const worker of workers) worker?.postMessage({ type: 'OFFLINE_OPT_INS', values }); +} + // /?q= — a shareable search link (same family as Share's /?v=&t=). // Runs after wireUI() so the search form's submit handler exists; the param // is stripped immediately so a reload doesn't re-run the search. @@ -9000,7 +9120,7 @@ async function boot() { lyricOffsets: loaded.lyricOffsets || {}, stats: loaded.stats || null, lastAutoBackup: loaded.lastAutoBackup || 0, - settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) }, + settings: normalizeJamesDspSettings({ ...DEFAULT_SETTINGS, ...(loaded.settings || {}) }), profile: loaded.profile || null, }; } diff --git a/frontend/asset-sync-core.js b/frontend/asset-sync-core.js index 0c6ede2..366d7eb 100644 --- a/frontend/asset-sync-core.js +++ b/frontend/asset-sync-core.js @@ -3,12 +3,22 @@ 'use strict'; const CACHE = 'ytplayer-assets'; const STATE = '/__ytp_asset_state'; + const OPT_INS = '/__ytp_offline_opt_ins'; const url = (path, file) => path + '?v=' + file.h; function paths(manifest) { return Object.keys(manifest.files).sort(); } + function offlinePaths(manifest, optIns = []) { + const allowed = new Set(optIns || []), optional = new Map(); + for (const group of Object.values(manifest.groups || {})) { + if (!group.offlineOptIn || allowed.has(group.offlineOptIn)) continue; + for (const path of group.files || []) optional.set(path, true); + } + return paths(manifest).filter(path => !optional.has(path)); + } + function blocking(manifest, previous, activeLayout) { const names = new Set(Object.entries(manifest.groups) .filter(([name, group]) => name === 'core' || !group.background || @@ -61,13 +71,17 @@ return valid; } - async function status(manifest, cache, { previous, activeLayout, offline = true } = {}) { + async function readOptIns(cache) { + try { return (await (await cache.match(OPT_INS))?.json()) || []; } catch { return []; } + } + + async function status(manifest, cache, { previous, activeLayout, offline = true, optIns } = {}) { const found = await Promise.all(blocking(manifest, previous, activeLayout).map(async path => { const response = await cache.match(url(path, manifest.files[path])); return verifiedProbe(response, manifest.files[path].h); })); const missing = found.filter(value => !value).length; - return { ready: missing === 0, missing, version: manifest.buildTag, ...(offline ? await completeness(manifest, cache) : {}) }; + return { ready: missing === 0, missing, version: manifest.buildTag, ...(offline ? await completeness(manifest, cache, { optIns }) : {}) }; } // All concurrent messages/layout jobs in this worker share six download slots. @@ -89,10 +103,10 @@ finally { pendingDownloads.delete(key); } } - async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups, all = false }) { + async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups, all = false, optIns } = {}) { const previous = (await state(cache))?.current; const missing = []; - const selected = all ? paths(manifest) : groups ? [...new Set(groups.flatMap(name => manifest.groups[name]?.files || []))].sort() + const selected = all ? offlinePaths(manifest, optIns || await readOptIns(cache)) : groups ? [...new Set(groups.flatMap(name => manifest.groups[name]?.files || []))].sort() : blocking(manifest, previous, activeLayout); for (const path of selected) { const key = url(path, manifest.files[path]); @@ -135,8 +149,8 @@ // Verified cache entries are the durable progress journal. Counters are rebuilt // after worker termination or browser eviction; never trust a stored ready bit. - async function completeness(manifest, cache) { - const files = paths(manifest); + async function completeness(manifest, cache, { optIns } = {}) { + const files = offlinePaths(manifest, optIns || await readOptIns(cache)); const missingFiles = (await Promise.all(files.map(async path => { const response = await cache.match(url(path, manifest.files[path])); return verifiedProbe(response, manifest.files[path].h) ? null : path; @@ -146,12 +160,13 @@ } const completionKey = manifest => '/__ytp_completion/' + manifest.buildTag; - async function complete(manifest, { cache, fetchFn, cycles = 4, now = Date.now, + async function complete(manifest, { cache, fetchFn, cycles = 4, now = Date.now, optIns, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), baseDelay = 1000, maxDelay = 30000, notify = () => {}, force = false } = {}) { const key = completionKey(manifest), saved = await cache.match(key); let job = saved ? await saved.json() : { failures: 0, nextRetryAt: 0 }; - let snapshot = await completeness(manifest, cache); + const selectedOptIns = optIns || await readOptIns(cache); + let snapshot = await completeness(manifest, cache, { optIns: selectedOptIns }); const publish = async running => { job = { ...job, ...snapshot, running, version: manifest.buildTag }; await cache.put(key, Response.json(job)); @@ -162,9 +177,9 @@ for (let cycle = 0; cycle < cycles; cycle++) { await publish(true); let error; - try { await syncAssets(manifest, { cache, fetchFn, all: true }); } + try { await syncAssets(manifest, { cache, fetchFn, all: true, optIns: selectedOptIns }); } catch (failure) { error = failure; } - snapshot = await completeness(manifest, cache); + snapshot = await completeness(manifest, cache, { optIns: selectedOptIns }); if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; job.error = null; await publish(false); return job; } job.failures++; const delay = Math.min(maxDelay, baseDelay * 2 ** Math.min(job.failures - 1, 10)); @@ -198,7 +213,7 @@ return next; } - const api = { CACHE, STATE, url, paths, blocking, plan, retained, fallback, status, completeness, completionKey, complete, syncAssets, state, commit }; + const api = { CACHE, STATE, OPT_INS, url, paths, offlinePaths, blocking, plan, retained, fallback, status, completeness, completionKey, complete, syncAssets, readOptIns, state, commit }; if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.AssetSyncCore = api; })(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/frontend/asset-sync-core.test.js b/frontend/asset-sync-core.test.js index 60b366d..28f1da8 100644 --- a/frontend/asset-sync-core.test.js +++ b/frontend/asset-sync-core.test.js @@ -56,6 +56,20 @@ test('completion includes ungrouped runtime files and exposes verified missing p assert.equal((await core.status(m,c)).offlineReady,true); }); +test('offline completion skips opt-in DSP assets until the user enables offline audio effects', async () => { + const c=cache(),m=manifest();m.files['/vendor/jamesdsp/dsp/jamesdsp.wasm']={h:'wasm'}; + m.groups.jamesdsp={contract:1,eager:false,background:true,offlineOptIn:'jamesDsp',files:['/vendor/jamesdsp/dsp/jamesdsp.wasm']}; + const calls=[]; + const defaultJob=await core.complete(m,{cache:c,fetchFn:async u=>{calls.push(u);return response(u.split('=')[1])}}); + assert.deepEqual(calls.sort(),['/a.js?v=a','/index.html?v=b']); + assert.equal(defaultJob.total,2);assert.equal(defaultJob.offlineReady,true); + calls.length=0; + await c.put(core.OPT_INS,Response.json(['jamesDsp'])); + const optedJob=await core.complete(m,{cache:c,fetchFn:async u=>{calls.push(u);return response(u.split('=')[1])}}); + assert.deepEqual(calls,['/vendor/jamesdsp/dsp/jamesdsp.wasm?v=wasm']); + assert.equal(optedJob.total,3);assert.equal(optedJob.offlineReady,true); +}); + test('completion persists backoff and resumes only missing files after a worker restart',async()=>{ const c=cache(),m=manifest();let clock=1000;const calls=[]; await core.complete(m,{cache:c,cycles:1,now:()=>clock,baseDelay:10,fetchFn:async u=>{if(u.includes('index'))throw Error('offline');return response('a');}}); diff --git a/frontend/asset-worker.test.js b/frontend/asset-worker.test.js index d7d9978..3b5a866 100644 --- a/frontend/asset-worker.test.js +++ b/frontend/asset-worker.test.js @@ -57,6 +57,31 @@ test('waiting status does not start completion and activation delegates it to it assert.equal((await core.status(e.m,cache)).offlineReady,true); }); +test('legacy shell verification is staged during install so Refresh UI activation does no hashing',async()=>{ + const e=environment(),legacy=await e.storage.open('ytplayer-production-old');let digests=0; + await legacy.put('/app.js',new Response('legacy app bytes')); + const subtle=e.sandbox.crypto.subtle; + e.sandbox.crypto={subtle:{digest:async(...args)=>{digests++;return subtle.digest(...args);}}}; + await e.dispatch('install'); + const stagedDigests=digests,cache=await e.storage.open(core.CACHE),candidate=await(await cache.match('/__ytp_candidate/next')).json(); + assert.ok(stagedDigests>0,'install verified old cached bytes before offering Refresh UI'); + assert.deepEqual(candidate.legacyCacheNames,['ytplayer-production-old']); + assert.ok((await core.state(cache)).current.legacyURLs['/app.js']); + await e.dispatch('activate'); + assert.equal(digests,stagedDigests,'the user-triggered activation does not hash the old shell again'); + assert.equal((await e.storage.keys()).includes('ytplayer-production-old'),false,'old shell is deleted only after commit'); +}); + +test('offline opt-in is persisted and adds only declared optional groups to completion',async()=>{ + const e=environment();e.m.files['/large.wasm']={h:'wasm'};e.m.groups.dsp={files:['/large.wasm'],contract:1,background:true,offlineOptIn:'jamesDsp'}; + await e.dispatch('install');await e.dispatch('activate');await e.complete(); + const cache=await e.storage.open(core.CACHE);assert.equal(await cache.match('/large.wasm?v=wasm'),undefined); + await e.dispatch('message',{data:{type:'OFFLINE_OPT_INS',values:['jamesDsp','not-a-valid-opt-in']}}); + assert.deepEqual(await core.readOptIns(cache),['jamesDsp']); + assert.equal((await cache.match('/large.wasm?v=wasm')).headers.get('X-Asset-Hash'),'wasm'); + const status=await core.status(e.m,cache);assert.equal(status.offlineReady,true);assert.equal(status.total,3); +}); + test('page-captured app is reused on first install without a worker download',async()=>{ const e=environment(),cache=await e.storage.open(core.CACHE); await cache.put('/app.js?v=app',new Response('page captured app',{headers:{'X-Asset-Hash':'app'}})); diff --git a/frontend/assets.json b/frontend/assets.json index 844fd02..ec2f500 100644 --- a/frontend/assets.json +++ b/frontend/assets.json @@ -29,6 +29,7 @@ "/related-videos.js", "/related-videos.css", "/audio-continuity.js", + "/audio-graph.js", "/fullscreen-orientation.js", "/fullscreen-orientation.css", "/download-actions.js", @@ -363,15 +364,23 @@ "feature:jamesdsp": { "contract": 1, "eager": false, + "background": true, + "offlineOptIn": "jamesDsp", "files": [ - "/vendor/jamesdsp/controller.js", - "/vendor/jamesdsp/jamesdsp-node.js", - "/vendor/jamesdsp/worklet.js", - "/vendor/jamesdsp/params.js", - "/vendor/jamesdsp/dsp/jamesdsp.js", + "/vendor/jamesdsp/jamesdsp-feature.js", + "/vendor/jamesdsp/jamesdsp-node.mjs", + "/vendor/jamesdsp/params.mjs", + "/vendor/jamesdsp/ir-utils.mjs", + "/vendor/jamesdsp/ir-builtins.mjs", + "/vendor/jamesdsp/ir-store.mjs", + "/vendor/jamesdsp/worklet.mjs", + "/vendor/jamesdsp/dsp/jamesdsp-a982b53.mjs", "/vendor/jamesdsp/dsp/jamesdsp.wasm" ], - "background": false + "dependencies": [ + "feature:eq-core", + "feature:vocal-reducer" + ] } } } diff --git a/frontend/audio-graph.js b/frontend/audio-graph.js new file mode 100644 index 0000000..ba938b2 --- /dev/null +++ b/frontend/audio-graph.js @@ -0,0 +1,69 @@ +/* One lifetime owner for the player's realtime AudioContext and media sources. */ +(function (root) { + 'use strict'; + + function create(options = {}) { + const getConstructor = options.getConstructor || (() => root.AudioContext || root.webkitAudioContext); + let context = null; + const sources = new Map(); + const routes = new Map(); + const failed = new Map(); + + function getContext(contextOptions) { + if (context) return context; + const Constructor = getConstructor(); + if (typeof Constructor !== 'function') throw new Error('Web Audio is unavailable'); + context = contextOptions === undefined ? new Constructor() : new Constructor(contextOptions); + return context; + } + + function source(element) { + if (!element) throw new TypeError('A media element is required'); + if (sources.has(element)) return sources.get(element); + if (failed.has(element)) throw failed.get(element); + try { + const created = getContext().createMediaElementSource(element); + sources.set(element, created); + return created; + } catch (error) { + // MediaElementSource cannot be undone. Remember failure so a later + // route change can never attempt a second source for this element. + failed.set(element, error); + throw error; + } + } + + function connect(element, destination) { + const mediaSource = source(element); + if (!destination) throw new TypeError('An audio destination is required'); + if (routes.get(element) === destination) return mediaSource; + if (routes.has(element)) mediaSource.disconnect(); + mediaSource.connect(destination); + routes.set(element, destination); + return mediaSource; + } + + function routeAll(elements, destination) { + for (const element of elements || []) connect(element, destination); + } + + function snapshot() { + return { + contextCreated: !!context, + sampleRate: context?.sampleRate || null, + sourceCount: sources.size, + failedCount: failed.size, + routedCount: routes.size, + }; + } + + return { context: getContext, source, connect, routeAll, snapshot }; + } + + const singleton = create(); + const api = { create, context: (...args) => singleton.context(...args), source: (...args) => singleton.source(...args), + connect: (...args) => singleton.connect(...args), routeAll: (...args) => singleton.routeAll(...args), + snapshot: () => singleton.snapshot() }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.AudioGraph = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/audio-graph.test.js b/frontend/audio-graph.test.js new file mode 100644 index 0000000..d7e1272 --- /dev/null +++ b/frontend/audio-graph.test.js @@ -0,0 +1,75 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const AudioGraph = require('./audio-graph'); + +test('AudioGraph creates its context only on demand and retains the first context options', () => { + let contexts = 0, optionsSeen; + class Context { + constructor(options) { contexts++; optionsSeen = options; this.sampleRate = options?.sampleRate || 44100; } + } + const graph = AudioGraph.create({ getConstructor: () => Context }); + assert.equal(graph.snapshot().contextCreated, false); + const first = graph.context({ sampleRate: 48000 }); + assert.equal(graph.context({ sampleRate: 44100 }), first); + assert.equal(contexts, 1); + assert.deepEqual(optionsSeen, { sampleRate: 48000 }); + assert.equal(first.sampleRate, 48000); +}); + +test('one retained MediaElementSource per element can change insert routes without recreation', () => { + const made = [], connects = [], disconnects = []; + class Context { + constructor() { this.sampleRate = 48000; } + createMediaElementSource(element) { + const source = { element, connect: target => connects.push([element.id, target.id]), disconnect: () => disconnects.push(element.id) }; + made.push(source); return source; + } + } + const graph = AudioGraph.create({ getConstructor: () => Context }); + const a = { id: 'video' }, b = { id: 'audio' }, standard = { id: 'standard' }, dsp = { id: 'dsp' }; + const original = graph.connect(a, standard); + graph.connect(a, standard); + graph.routeAll([a, b], dsp); + graph.routeAll([a, b], standard); + assert.equal(made.length, 2); + assert.equal(graph.source(a), original); + assert.deepEqual(connects, [['video', 'standard'], ['video', 'dsp'], ['audio', 'dsp'], ['video', 'standard'], ['audio', 'standard']]); + assert.deepEqual(disconnects, ['video', 'video', 'audio']); + assert.deepEqual(graph.snapshot(), { contextCreated: true, sampleRate: 48000, sourceCount: 2, failedCount: 0, routedCount: 2 }); +}); + +test('a failed source creation is remembered and is never attempted twice', () => { + let attempts = 0; + class Context { + constructor() { this.sampleRate = 48000; } + createMediaElementSource() { attempts++; throw new Error('already sourced'); } + } + const graph = AudioGraph.create({ getConstructor: () => Context }), element = {}; + assert.throws(() => graph.source(element), /already sourced/); + assert.throws(() => graph.source(element), /already sourced/); + assert.equal(attempts, 1); + assert.deepEqual(graph.snapshot(), { contextCreated: true, sampleRate: 48000, sourceCount: 0, failedCount: 1, routedCount: 0 }); +}); + +test('the extracted Standard EQ build block is identical to the original except for context ownership', () => { + const source = readFileSync(join(__dirname, 'app.js'), 'utf8'); + const original = readFileSync(join(__dirname, '..', 'tests', 'fixtures', 'standard-eq-build.txt'), 'utf8').trimEnd(); + const start = source.indexOf(' function build() {', source.indexOf('const EQ = (() => {')); + const end = source.indexOf('\n function attach(el)', start); + assert.ok(start >= 0 && end > start, 'EQ build block exists'); + const current = source.slice(start, end).trimEnd(); + assert.equal(current, original.replace('st.ctx = new AC();', 'st.ctx = AudioGraph.context();')); + assert.match(source, /AudioGraph\.connect\(el, st\.filters\[0\]\)/); + assert.doesNotMatch(source, /\.createMediaElementSource\(/); +}); + +test('AudioGraph is a core runtime asset and is loaded before the lazy app', () => { + const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8')); + assert.ok(groups.core.files.includes('/audio-graph.js')); + const html = readFileSync(join(__dirname, 'index.html'), 'utf8'); + assert.ok(html.indexOf('src="audio-graph.js"') < html.indexOf('src="section-rail.js"')); + const embedded = JSON.parse(html.match(/id="ytp-assets">([^<]+)<\/script>/)[1]); + assert.deepEqual(embedded.groups, groups); +}); diff --git a/frontend/index.html b/frontend/index.html index 20516e4..f0f9bec 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,7 +19,7 @@ - + @@ -125,8 +125,8 @@
- - + +