Compare commits
60 Commits
f133e1bb9f
...
codex/dsp-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f49c317fd | ||
|
|
f0daeaedd2 | ||
|
|
0c387a01cd | ||
|
|
e0802aa8a8 | ||
|
|
865c60cb85 | ||
|
|
ef12468a99 | ||
|
|
b3f0525a8e | ||
|
|
d02796b0eb | ||
|
|
7591c2d14c | ||
|
|
6bb3271431 | ||
|
|
27b32ff0c9 | ||
|
|
aca50c1690 | ||
|
|
76037613a7 | ||
|
|
d6a3570ff9 | ||
|
|
933e920701 | ||
|
|
a1048f1c30 | ||
|
|
1e83951972 | ||
|
|
9a37715da4 | ||
|
|
0a5b113ebe | ||
|
|
6ae81d1024 | ||
|
|
52e51278d5 | ||
|
|
d027696d4e | ||
|
|
4ba5130c80 | ||
|
|
ea221a4e90 | ||
|
|
8a4144eb00 | ||
|
|
dd09f66578 | ||
|
|
cf27d4f15a | ||
|
|
9c988912de | ||
|
|
6f0235f506 | ||
|
|
a497180352 | ||
|
|
2001e77cee | ||
|
|
f161a5de74 | ||
|
|
0b8e398469 | ||
|
|
2fc6d5d4dc | ||
|
|
f359e5dd39 | ||
|
|
2c8d563d01 | ||
|
|
6b8d590638 | ||
|
|
009a155568 | ||
|
|
0251720fa2 | ||
|
|
97f4a7396e | ||
|
|
b59fc7264a | ||
|
|
14716e648b | ||
|
|
71608c2ce8 | ||
|
|
66bbda51c7 | ||
|
|
ff3f934717 | ||
|
|
3de4cba402 | ||
|
|
4b1f5204f8 | ||
|
|
9408ee07e5 | ||
|
|
97eb31e732 | ||
|
|
57edb410c3 | ||
|
|
c4a512bea6 | ||
|
|
3505dc3c0e | ||
|
|
639f0a2257 | ||
|
|
a86fa1cebe | ||
|
|
e7a7cdc6b9 | ||
|
|
799b1e7ed6 | ||
|
|
af6aabc3b7 | ||
|
|
21c12733d3 | ||
|
|
a08d363f2d | ||
|
|
ce5a159d4a |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -44,3 +44,9 @@ plans/codex-report*.md
|
|||||||
# performance harness artifacts and temp files
|
# performance harness artifacts and temp files
|
||||||
perf/fixtures/
|
perf/fixtures/
|
||||||
perf/.tmp/
|
perf/.tmp/
|
||||||
|
|
||||||
|
# UI regression screenshots and local measurements
|
||||||
|
plans/ui-shots/
|
||||||
|
|
||||||
|
# Transport regression screenshots
|
||||||
|
plans/transport-shots/
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
<meta name="theme-color" content="#0a0c12" />
|
<meta name="theme-color" content="#0a0c12" />
|
||||||
<title>Lyrics Studio</title>
|
<title>Lyrics Studio</title>
|
||||||
|
<link rel="stylesheet" href="download-actions.css" />
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
@@ -193,7 +194,7 @@
|
|||||||
.transport { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
.transport { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||||
.round { width: 42px; height: 42px; border-radius: 50%; border: 1px solid var(--line-2); background: rgba(255,255,255,.06); display: grid; place-items: center; font-size: 12px; font-weight: 800; }
|
.round { width: 42px; height: 42px; border-radius: 50%; border: 1px solid var(--line-2); background: rgba(255,255,255,.06); display: grid; place-items: center; font-size: 12px; font-weight: 800; }
|
||||||
.round.big { width: 52px; height: 52px; border: 0; background: var(--grad); box-shadow: 0 10px 24px -8px var(--a1); }
|
.round.big { width: 52px; height: 52px; border: 0; background: var(--grad); box-shadow: 0 10px 24px -8px var(--a1); }
|
||||||
.round svg { width: 20px; height: 20px; fill: #fff; }
|
.round svg { width: 20px; height: 20px; }
|
||||||
.clock { font: 600 17px var(--mono); letter-spacing: -.02em; min-width: 108px; text-align: center; }
|
.clock { font: 600 17px var(--mono); letter-spacing: -.02em; min-width: 108px; text-align: center; }
|
||||||
.clock b { color: var(--a2); } .clock span { color: var(--text-dim); font-size: 13px; }
|
.clock b { color: var(--a2); } .clock span { color: var(--text-dim); font-size: 13px; }
|
||||||
.seg { display: inline-flex; padding: 3px; border-radius: 13px; background: rgba(0,0,0,.35); border: 1px solid var(--line); }
|
.seg { display: inline-flex; padding: 3px; border-radius: 13px; background: rgba(0,0,0,.35); border: 1px solid var(--line); }
|
||||||
@@ -312,7 +313,9 @@
|
|||||||
main { padding-top: 16px; }
|
main { padding-top: 16px; }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
||||||
</style>
|
.media-shape { display:none; } .media-shape-classic { display:inline; }
|
||||||
|
.media-icon { width:18px; height:18px; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -382,7 +385,7 @@
|
|||||||
<canvas id="wave" height="64"></canvas>
|
<canvas id="wave" height="64"></canvas>
|
||||||
<div class="transport">
|
<div class="transport">
|
||||||
<button class="round" id="bBack" type="button" title="Back 5 s" aria-label="Back 5 seconds">−5</button>
|
<button class="round" id="bBack" type="button" title="Back 5 s" aria-label="Back 5 seconds">−5</button>
|
||||||
<button class="round big" id="bPlay" type="button" aria-label="Play or pause"><svg viewBox="0 0 24 24"><path id="playIcon" d="M8 5v14l11-7z"/></svg></button>
|
<button class="round big" id="bPlay" type="button" aria-label="Play or pause"><svg id="playIcon" class="transport-icon" data-transport-icon="play" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use class="transport-shape transport-shape-classic" href="#transport-play"/><use class="transport-shape transport-shape-f7-swipe" href="#transport-play-f7-swipe"/><use class="transport-shape transport-shape-glass-stage" href="#transport-play-glass-stage"/><use class="transport-shape transport-shape-bento-hub" href="#transport-play-bento-hub"/></svg></button>
|
||||||
<button class="round" id="bFwd" type="button" title="Forward 5 s" aria-label="Forward 5 seconds">+5</button>
|
<button class="round" id="bFwd" type="button" title="Forward 5 s" aria-label="Forward 5 seconds">+5</button>
|
||||||
<div class="clock"><b id="cur">0:00.00</b> <span>/ <span id="dur">0:00</span></span></div>
|
<div class="clock"><b id="cur">0:00.00</b> <span>/ <span id="dur">0:00</span></span></div>
|
||||||
<select class="mini" id="rate" title="Playback speed" aria-label="Playback speed">
|
<select class="mini" id="rate" title="Playback speed" aria-label="Playback speed">
|
||||||
@@ -407,7 +410,7 @@
|
|||||||
<label>Shift every timed line by <input class="field" id="shiftBy" type="text" value="0.0" style="flex:0 0 84px;height:36px;text-align:center" /> s</label>
|
<label>Shift every timed line by <input class="field" id="shiftBy" type="text" value="0.0" style="flex:0 0 84px;height:36px;text-align:center" /> s</label>
|
||||||
<button class="btn sm" id="shiftBtn" type="button">Apply</button>
|
<button class="btn sm" id="shiftBtn" type="button">Apply</button>
|
||||||
<span class="grow"></span>
|
<span class="grow"></span>
|
||||||
<button class="btn sm" id="webBtn" type="button" title="Fetch lyrics from LRCLIB">⤓ Pull from LRCLIB</button>
|
<button class="btn sm" id="webBtn" type="button" title="Fetch lyrics from LRCLIB"><svg class="media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg> Pull from LRCLIB</button>
|
||||||
<button class="btn sm" id="rawBtn" type="button">✎ Edit as text</button>
|
<button class="btn sm" id="rawBtn" type="button">✎ Edit as text</button>
|
||||||
<label><input type="checkbox" id="follow" checked /> Follow playback</label>
|
<label><input type="checkbox" id="follow" checked /> Follow playback</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -508,7 +511,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row" style="margin-bottom:8px"><h2 style="margin:0">Recent edits</h2><span class="grow"></span>
|
<div class="row" style="margin-bottom:8px"><h2 style="margin:0">Recent edits</h2><span class="grow"></span>
|
||||||
<button class="btn sm" id="refreshBtn" type="button">↻ Refresh</button>
|
<button class="btn sm" id="refreshBtn" type="button">↻ Refresh</button>
|
||||||
<a class="btn sm" href="/api/admin/notes/export">⤓ Export all (JSON)</a></div>
|
<a class="btn sm" href="/api/admin/notes/export"><svg class="media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg> Export all (JSON)</a></div>
|
||||||
<div class="scroll"><table class="tbl" id="revTable"></table></div>
|
<div class="scroll"><table class="tbl" id="revTable"></table></div>
|
||||||
<pre id="revView" class="hidden" style="margin-top:12px"></pre>
|
<pre id="revView" class="hidden" style="margin-top:12px"></pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -569,6 +572,7 @@
|
|||||||
<nav class="nav" id="tabsBottom" role="tablist"></nav>
|
<nav class="nav" id="tabsBottom" role="tablist"></nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="download-actions.js"></script>
|
||||||
<script src="lyrics-core.js"></script>
|
<script src="lyrics-core.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
@@ -1169,8 +1173,8 @@
|
|||||||
const a = new Audio(url); a.preload = 'auto';
|
const a = new Audio(url); a.preload = 'auto';
|
||||||
a.addEventListener('timeupdate', tick); a.addEventListener('seeked', tick);
|
a.addEventListener('timeupdate', tick); a.addEventListener('seeked', tick);
|
||||||
a.addEventListener('loadedmetadata', () => { if (a.duration) { LE.dur = a.duration; $('dur').textContent = fmtT(a.duration).slice(0, -3); } draw(); });
|
a.addEventListener('loadedmetadata', () => { if (a.duration) { LE.dur = a.duration; $('dur').textContent = fmtT(a.duration).slice(0, -3); } draw(); });
|
||||||
a.addEventListener('play', () => { $('playIcon').setAttribute('d', 'M7 5h4v14H7zM13 5h4v14h-4z'); });
|
a.addEventListener('play', () => { TransportIcons.set($('bPlay'),'pause'); });
|
||||||
a.addEventListener('pause', () => { $('playIcon').setAttribute('d', 'M8 5v14l11-7z'); });
|
a.addEventListener('pause', () => { TransportIcons.set($('bPlay'),'play'); });
|
||||||
a.addEventListener('error', () => say('No playable audio for this song on the server — you can still edit the text.', 'err'));
|
a.addEventListener('error', () => say('No playable audio for this song on the server — you can still edit the text.', 'err'));
|
||||||
a.playbackRate = Number($('rate').value);
|
a.playbackRate = Number($('rate').value);
|
||||||
LE.audio = a;
|
LE.audio = a;
|
||||||
@@ -1196,7 +1200,7 @@
|
|||||||
<div class="ln-tools">
|
<div class="ln-tools">
|
||||||
<input class="tin" value="${esc(fmtT(line.t))}" placeholder="0:00.00" aria-label="Time" />
|
<input class="tin" value="${esc(fmtT(line.t))}" placeholder="0:00.00" aria-label="Time" />
|
||||||
<button class="btn" data-act="set" type="button">◎ Stamp now</button>
|
<button class="btn" data-act="set" type="button">◎ Stamp now</button>
|
||||||
<button class="btn" data-act="seek" type="button">▶ Play here</button>
|
<button class="btn" data-act="seek" type="button">${TransportIcons.svg('play')} Play here</button>
|
||||||
<button class="btn" data-act="nudge" data-d="-0.2" type="button">−0.2</button>
|
<button class="btn" data-act="nudge" data-d="-0.2" type="button">−0.2</button>
|
||||||
<button class="btn" data-act="nudge" data-d="0.2" type="button">+0.2</button>
|
<button class="btn" data-act="nudge" data-d="0.2" type="button">+0.2</button>
|
||||||
<select class="mini" data-kind aria-label="Line type"><option value="line">Line</option><option value="section">Section</option><option value="cue">Cue</option></select>
|
<select class="mini" data-kind aria-label="Line type"><option value="line">Line</option><option value="section">Section</option><option value="cue">Cue</option></select>
|
||||||
|
|||||||
84
frontend/app-bootstrap.test.js
Normal file
84
frontend/app-bootstrap.test.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
const {test}=require('node:test');
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const {webcrypto,createHash}=require('node:crypto');
|
||||||
|
const vm=require('node:vm');
|
||||||
|
const {readFileSync}=require('node:fs');
|
||||||
|
function fixture({cached=false,tampered=false,enabled=true,loading=false,quota=false,insecure=false,previous=false,contract=1,capture=true,controller=true,parser=false}={}){
|
||||||
|
const source='window.appRuns=(window.appRuns||0)+1; window.boot=()=>window.bootRuns=(window.bootRuns||0)+1; document.addEventListener("DOMContentLoaded",boot);',hash=createHash('sha256').update(source).digest('hex'), key='/app.js?v='+hash.slice(0,10);
|
||||||
|
const oldSource='window.oldApp=true;'+source, oldHash=createHash('sha256').update(oldSource).digest('hex').slice(0,10), oldKey='/app.js?v='+oldHash;
|
||||||
|
const held=new Map(), appended=[],writes=[],listeners={};let calls=0;
|
||||||
|
const response=()=>new Response(tampered?'bad':source,{headers:{'X-Asset-Hash':hash.slice(0,10)}});
|
||||||
|
if(cached)held.set(key,response());
|
||||||
|
if(previous){held.set(oldKey,new Response(oldSource,{headers:{'X-Asset-Hash':oldHash}}));held.set('/__ytp_asset_state',Response.json({previous:{files:{'/app.js':{h:oldHash}},groups:{core:{contract,files:['/app.js']}}}}));}
|
||||||
|
const root={crypto:insecure?undefined:webcrypto,TextDecoder,Uint8Array,btoa,console,AssetSyncCore:require('./asset-sync-core'),navigator:{serviceWorker:{controller:controller?{}:null}},Lazy:{captureApp:capture,manifest:{groups:{core:{contract:1,files:['/app.js']}},appCache:enabled,files:{'/app.js':{h:hash.slice(0,10)}}},url:()=>key},fetch:async()=>{calls++;return previous?new Response(oldSource,{headers:{'X-Asset-Hash':oldHash}}):response();},caches:{open:async()=>({match:async k=>held.get(k)?.clone(),put:async(k,r)=>{if(quota)throw Error('quota');held.set(k,r.clone());}})}};
|
||||||
|
const doc=root.document={readyState:loading?'loading':'complete',currentScript:parser?{}:null,write:value=>{writes.push(value);vm.runInContext(source,context);},querySelector:()=>({content:"script-src 'self' 'sha256-"+Buffer.from(hash,'hex').toString('base64')+"'"}),addEventListener:(name,fn)=>(listeners[name] ||= []).push(fn),createElement:()=>({remove(){this.removed=true;}}),head:{append(node){appended.push(node);if(node.textContent)vm.runInContext(node.textContent,context);else {queueMicrotask(async()=>{if(previous && node.src===key){const state=await held.get('/__ytp_asset_state').clone().json();if(!root.AssetSyncCore.fallback(root.Lazy.manifest,state.previous,'/app.js'))return node.onerror();vm.runInContext(oldSource,context);}else vm.runInContext(node.src===oldKey?oldSource:source,context);node.onload();});}}}};
|
||||||
|
root.window=root;const context=vm.createContext(root);
|
||||||
|
vm.runInContext(readFileSync(require.resolve('./section-rail.js'),'utf8'),context);
|
||||||
|
return {root,held,appended,writes,listeners,key,calls:()=>calls,doc,response,oldKey};
|
||||||
|
}
|
||||||
|
test('cold boot caches verified app before execution so worker skips its download',async()=>{
|
||||||
|
const f=fixture();await f.root.AppBootstrap.ready;assert.equal(f.calls(),1);assert.ok(f.held.has(f.key));assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);assert.equal(f.appended[0].src,undefined);assert.equal(f.appended[0].removed,true);
|
||||||
|
});
|
||||||
|
test('offline boot reads the exact cached app without network or re-evaluation',async()=>{
|
||||||
|
const f=fixture({cached:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);
|
||||||
|
});
|
||||||
|
test('tampered cached bytes are rejected before execution',async()=>{
|
||||||
|
const f=fixture({cached:true,tampered:true});await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined);
|
||||||
|
});
|
||||||
|
test('native and rollback boot use the original external classic script',async()=>{
|
||||||
|
const f=fixture({enabled:false});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
|
||||||
|
});
|
||||||
|
test('boot waits for the normal DOM event when the parser has not finished',async()=>{
|
||||||
|
const f=fixture({loading:true});await f.root.AppBootstrap.ready;assert.equal(f.root.bootRuns,undefined);for(const fn of f.listeners.DOMContentLoaded)fn();assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);
|
||||||
|
});
|
||||||
|
test('quota failure still executes the verified response without deleting saved data',async()=>{
|
||||||
|
const f=fixture({quota:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),1);assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('the early head response is consumed without a second page fetch',async()=>{
|
||||||
|
const f=fixture();f.root.Lazy.appResponse=Promise.resolve(f.response());await f.root.AppBootstrap.ready;
|
||||||
|
assert.equal(f.calls(),0);assert.ok(f.held.has(f.key));assert.equal(f.root.appRuns,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('insecure local HTTP boot retains external execution without WebCrypto',async()=>{
|
||||||
|
const f=fixture({insecure:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('missing current app uses verified same-contract N-1 offline through the controlling worker',async()=>{
|
||||||
|
const f=fixture({previous:true});await f.root.AppBootstrap.ready;
|
||||||
|
assert.equal(f.appended[0].src,f.oldKey);assert.equal(f.root.oldApp,true);assert.equal(f.root.bootRuns,1);assert.equal(f.held.has(f.key),false);
|
||||||
|
});
|
||||||
|
test('an incompatible previous core never executes against the new shell',async()=>{
|
||||||
|
const f=fixture({previous:true,contract:2});await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('a feature contract change also prevents stale core execution',async()=>{
|
||||||
|
const f=fixture({previous:true});f.root.Lazy.manifest.groups['feature:test']={contract:2,files:[]};
|
||||||
|
const state=await f.held.get('/__ytp_asset_state').json();state.previous.groups['feature:test']={contract:1,files:[]};f.held.set('/__ytp_asset_state',Response.json(state));
|
||||||
|
await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('native-cache engines use their external script and keep normal code-cache behavior',async()=>{
|
||||||
|
const f=fixture({capture:false,controller:false});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('native-cache engines still reject an incompatible retained core',async()=>{
|
||||||
|
const f=fixture({capture:false,previous:true,contract:2});await assert.rejects(f.root.AppBootstrap.ready,/Unable to load player/);assert.equal(f.root.appRuns,undefined);
|
||||||
|
});
|
||||||
|
test('native-cache engines use the validated current cache entry without decoding its body',async()=>{
|
||||||
|
const f=fixture({capture:false,cached:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('native parser boot preserves classic evaluation before the original DOM event',async()=>{
|
||||||
|
const f=fixture({capture:false,loading:true,parser:true});
|
||||||
|
assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,undefined);
|
||||||
|
assert.deepEqual(f.writes,['<script src="'+f.key+'"></script>']);assert.equal(f.appended.length,0);
|
||||||
|
for(const fn of f.listeners.DOMContentLoaded)fn();await f.root.AppBootstrap.ready;
|
||||||
|
assert.equal(f.root.bootRuns,1);assert.equal(f.calls(),0);
|
||||||
|
});
|
||||||
@@ -4,13 +4,12 @@ const {readFileSync}=require('node:fs');
|
|||||||
const {createHash}=require('node:crypto');
|
const {createHash}=require('node:crypto');
|
||||||
const {join}=require('node:path');
|
const {join}=require('node:path');
|
||||||
const app=readFileSync(join(__dirname,'app.js'),'utf8');
|
const app=readFileSync(join(__dirname,'app.js'),'utf8');
|
||||||
test('Phase 4 preserves the exact player, continuity, tracking and queue bodies',()=>{
|
test('Phase 4 preserves the exact playback, continuity, tracking and queue bodies',()=>{
|
||||||
{const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"373df46f4f994717472d26a6bac07449f6ca0a930cb666bfe3895d1dc65aa351","Player body unchanged");}
|
{const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"373df46f4f994717472d26a6bac07449f6ca0a930cb666bfe3895d1dc65aa351","Player body unchanged");}
|
||||||
{const start=app.indexOf("const Transition = (() => {");assert.ok(start>=0,"Transition");const body=app.slice(start,start+8588);assert.equal(createHash('sha256').update(body).digest('hex'),"71997a537078308a41f0cbf8e29fd1658fb3a199c4f5a25d8af0931f1c2a6d50","Transition body unchanged");}
|
{const start=app.indexOf("const Transition = (() => {");assert.ok(start>=0,"Transition");const body=app.slice(start,start+8588);assert.equal(createHash('sha256').update(body).digest('hex'),"71997a537078308a41f0cbf8e29fd1658fb3a199c4f5a25d8af0931f1c2a6d50","Transition body unchanged");}
|
||||||
{const start=app.indexOf("const Wave = (() => {");assert.ok(start>=0,"Wave");const body=app.slice(start,start+5241);assert.equal(createHash('sha256').update(body).digest('hex'),"7252f4e856c59aef8aabfcd394ce4bcba656110cbd01a1a8af662eeb48166a3c","Wave body unchanged");}
|
{const start=app.indexOf("const Wave = (() => {");assert.ok(start>=0,"Wave");const body=app.slice(start,start+5241);assert.equal(createHash('sha256').update(body).digest('hex'),"7252f4e856c59aef8aabfcd394ce4bcba656110cbd01a1a8af662eeb48166a3c","Wave body unchanged");}
|
||||||
{const start=app.indexOf("const StatsTrack = (() => {");assert.ok(start>=0,"StatsTrack");const body=app.slice(start,start+1462);assert.equal(createHash('sha256').update(body).digest('hex'),"9aae2631ba3bfad918319579b7120814af06c84949394d04e52c65345332d514","StatsTrack body unchanged");}
|
{const start=app.indexOf("const StatsTrack = (() => {");assert.ok(start>=0,"StatsTrack");const body=app.slice(start,start+1462);assert.equal(createHash('sha256').update(body).digest('hex'),"9aae2631ba3bfad918319579b7120814af06c84949394d04e52c65345332d514","StatsTrack body unchanged");}
|
||||||
{const start=app.indexOf("function wirePlayerEvents() {");assert.ok(start>=0,"wirePlayerEvents");const body=app.slice(start,start+4913);assert.equal(createHash('sha256').update(body).digest('hex'),"1c5a2b5ce310f6aef6764325eda85a95f47e4f507f2049036ce1da206379908c","wirePlayerEvents body unchanged");}
|
{const start=app.indexOf("function wirePlayerEvents() {");assert.ok(start>=0,"wirePlayerEvents");const body=app.slice(start,start+4913);assert.equal(createHash('sha256').update(body).digest('hex'),"1c5a2b5ce310f6aef6764325eda85a95f47e4f507f2049036ce1da206379908c","wirePlayerEvents body unchanged");}
|
||||||
{const start=app.indexOf("function updatePlayBtn() {");assert.ok(start>=0,"updatePlayBtn");const body=app.slice(start,start+522);assert.equal(createHash('sha256').update(body).digest('hex'),"1326233af07ae92a5ecc81cc211bd98d21ed5f291dc4eb59d040b8df9baba8c8","updatePlayBtn body unchanged");}
|
|
||||||
{const start=app.indexOf("function persist() {");assert.ok(start>=0,"persist");const body=app.slice(start,start+147);assert.equal(createHash('sha256').update(body).digest('hex'),"01e5c5dbc80616266db09b3829dc45b66476a3256b1c169d8e44f4983b24ac45","persist body unchanged");}
|
{const start=app.indexOf("function persist() {");assert.ok(start>=0,"persist");const body=app.slice(start,start+147);assert.equal(createHash('sha256').update(body).digest('hex'),"01e5c5dbc80616266db09b3829dc45b66476a3256b1c169d8e44f4983b24ac45","persist body unchanged");}
|
||||||
{const start=app.indexOf("function playFromList(list, index, source = '', { playFull = false } = {}) {");assert.ok(start>=0,"playFromList");const body=app.slice(start,start+401);assert.equal(createHash('sha256').update(body).digest('hex'),"f8009c9674b3478959997dca881fb7b9e1ab3708c98e5abdf1958d752e47ee41","playFromList body unchanged");}
|
{const start=app.indexOf("function playFromList(list, index, source = '', { playFull = false } = {}) {");assert.ok(start>=0,"playFromList");const body=app.slice(start,start+401);assert.equal(createHash('sha256').update(body).digest('hex'),"f8009c9674b3478959997dca881fb7b9e1ab3708c98e5abdf1958d752e47ee41","playFromList body unchanged");}
|
||||||
{const start=app.indexOf("function advanceQueue() {");assert.ok(start>=0,"advanceQueue");const body=app.slice(start,start+596);assert.equal(createHash('sha256').update(body).digest('hex'),"be405b9c163e3d41a949ef11f6022ef13115b04083e3b6ad21d97beba1f45ae6","advanceQueue body unchanged");}
|
{const start=app.indexOf("function advanceQueue() {");assert.ok(start>=0,"advanceQueue");const body=app.slice(start,start+596);assert.equal(createHash('sha256').update(body).digest('hex'),"be405b9c163e3d41a949ef11f6022ef13115b04083e3b6ad21d97beba1f45ae6","advanceQueue body unchanged");}
|
||||||
@@ -22,16 +21,15 @@ test('Phase 4 preserves the exact player, continuity, tracking and queue bodies'
|
|||||||
{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 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 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("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+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 EqRender = (() => {");assert.ok(start>=0,"EqRender");const body=app.slice(start,start+5912);assert.equal(createHash('sha256').update(body).digest('hex'),"f014dd41dafebc33f09e87be866074afd7a2443c5bb452103a435585353a828f","EqRender body unchanged");}
|
|
||||||
{const start=app.indexOf("const Level = (() => {");assert.ok(start>=0,"Level");const body=app.slice(start,start+3892);assert.equal(createHash('sha256').update(body).digest('hex'),"e48ad7797384ac31bb8b864662fc2a546b04470b981042f016874ba750957226","Level body unchanged");}
|
{const start=app.indexOf("const Level = (() => {");assert.ok(start>=0,"Level");const body=app.slice(start,start+3892);assert.equal(createHash('sha256').update(body).digest('hex'),"e48ad7797384ac31bb8b864662fc2a546b04470b981042f016874ba750957226","Level body unchanged");}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('eager shell and Settings definitions load before app state and defer reading it',()=>{
|
test('eager shell and Settings definitions load before app state and defer reading it',()=>{
|
||||||
const html=readFileSync(join(__dirname,'index.html'),'utf8');
|
const html=readFileSync(join(__dirname,'index.html'),'utf8');
|
||||||
for(const file of ['shell-core.js','views-core.js','section-rail.js']) {
|
for(const file of ['shell-core.js','views-core.js','section-rail.js']) {
|
||||||
assert.ok(html.indexOf('src="'+file+'"')<html.indexOf('src="app.js"'));
|
assert.ok(html.indexOf('src="'+file+'"')<=html.indexOf('src="section-rail.js"'));
|
||||||
const vm=require('node:vm');const context=vm.createContext({window:{},document:{}});
|
const vm=require('node:vm');const doc={readyState:'complete',addEventListener(){},createElement:()=>({}),head:{append(){}}};const globals={document:doc,Lazy:{manifest:{appCache:false},url:()=>'/app.js'},console};globals.window=globals;const context=vm.createContext(globals);
|
||||||
// Evaluation before any data/$/els/Player declaration must be safe.
|
// Evaluation before any data/$/els/Player declaration must be safe.
|
||||||
vm.runInContext(readFileSync(join(__dirname,file),'utf8'),context);
|
vm.runInContext(readFileSync(join(__dirname,file),'utf8'),context);
|
||||||
}
|
}
|
||||||
|
|||||||
260
frontend/app.js
260
frontend/app.js
@@ -60,12 +60,14 @@ async function call(zeroName, tauriName, payload = {}) {
|
|||||||
|
|
||||||
// Generic fetch wrapper — returns parsed JSON or throws with a human message.
|
// Generic fetch wrapper — returns parsed JSON or throws with a human message.
|
||||||
async function webFetch(path, opts = {}) {
|
async function webFetch(path, opts = {}) {
|
||||||
|
if ((opts.method || 'GET').toUpperCase() === 'GET' && window.AsyncGuard?.getJson)
|
||||||
|
return AsyncGuard.getJson(path, opts, {
|
||||||
|
// Some upstream failures are represented by HTTP 200 + {ok:false}; do
|
||||||
|
// not expose those transient responses as a manual Retry state either.
|
||||||
|
retryResult: value => value?.ok === false && /\b(?:408|425|429|5\d\d)\b|temporar|timed?\s*out|network|connection|upstream|unavailable|available right now/i.test(String(value.error || value.message || '')),
|
||||||
|
});
|
||||||
const res = await fetch(path, opts);
|
const res = await fetch(path, opts);
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
let msg = `HTTP ${res.status}`;
|
|
||||||
try { const j = await res.json(); msg = j.error || msg; } catch { /* non-JSON */ }
|
|
||||||
throw new Error(msg);
|
|
||||||
}
|
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,7 +575,7 @@ const API = {
|
|||||||
? webFetch(`/api/search?q=${encodeURIComponent(query)}${refresh ? '&refresh=1' : ''}`)
|
? webFetch(`/api/search?q=${encodeURIComponent(query)}${refresh ? '&refresh=1' : ''}`)
|
||||||
: call('yt.search', 'yt_search', { query }),
|
: call('yt.search', 'yt_search', { query }),
|
||||||
related: (meta, { refresh = false } = {}) => WEB && /^[\w-]{11}$/.test(meta.id)
|
related: (meta, { refresh = false } = {}) => WEB && /^[\w-]{11}$/.test(meta.id)
|
||||||
? fetch(`/api/related?${new URLSearchParams({ videoId: meta.id, title: meta.title || '', channel: meta.channel || meta.artist || '', ...(refresh ? { refresh: '1' } : {}) })}`).then(response => response.json())
|
? webFetch(`/api/related?${new URLSearchParams({ videoId: meta.id, title: meta.title || '', channel: meta.channel || meta.artist || '', ...(refresh ? { refresh: '1' } : {}) })}`)
|
||||||
: API.search(meta.title || meta.channel, { refresh }),
|
: API.search(meta.title || meta.channel, { refresh }),
|
||||||
// Videos the server already knows — answers fast while the real search loads.
|
// Videos the server already knows — answers fast while the real search loads.
|
||||||
searchLocal: (query) => WEB
|
searchLocal: (query) => WEB
|
||||||
@@ -663,6 +665,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl'
|
fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl'
|
||||||
levelVolume: null, // loudness levelling; null = on (on iPhone: applied to saved songs' rendered copies)
|
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)
|
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)
|
parallelSaves: 4, // offline saves running at once (Settings → Offline cache)
|
||||||
density: 'comfortable', // 'comfortable' | 'compact'
|
density: 'comfortable', // 'comfortable' | 'compact'
|
||||||
perfMode: false, // disable heavy visual effects for speed
|
perfMode: false, // disable heavy visual effects for speed
|
||||||
@@ -672,7 +675,24 @@ const DEFAULT_SETTINGS = {
|
|||||||
autoBackupIntervalDays: 7,
|
autoBackupIntervalDays: 7,
|
||||||
serviceVideo: false, // show the video panel inside service mode (lyric videos)
|
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 view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel'
|
||||||
let searchResults = [];
|
let searchResults = [];
|
||||||
// Distinguishes "never searched" (hide the list pane, show the landing) from
|
// Distinguishes "never searched" (hide the list pane, show the landing) from
|
||||||
@@ -695,6 +715,8 @@ const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platfo
|
|||||||
let _currentEqUrl = null;
|
let _currentEqUrl = null;
|
||||||
async function useEqRender(id) {
|
async function useEqRender(id) {
|
||||||
if (_currentEqUrl) { URL.revokeObjectURL(_currentEqUrl); _currentEqUrl = null; }
|
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;
|
if (typeof EqRender === 'undefined' || !WEB) return null;
|
||||||
try { _currentEqUrl = await EqRender.urlFor(id); } catch { _currentEqUrl = null; }
|
try { _currentEqUrl = await EqRender.urlFor(id); } catch { _currentEqUrl = null; }
|
||||||
return _currentEqUrl;
|
return _currentEqUrl;
|
||||||
@@ -905,7 +927,7 @@ function applyProfileData(name, payload, updatedAt) {
|
|||||||
if (payload.bookmarks && typeof payload.bookmarks === 'object') data.bookmarks = payload.bookmarks;
|
if (payload.bookmarks && typeof payload.bookmarks === 'object') data.bookmarks = payload.bookmarks;
|
||||||
if (payload.lyricOffsets && typeof payload.lyricOffsets === 'object') data.lyricOffsets = payload.lyricOffsets;
|
if (payload.lyricOffsets && typeof payload.lyricOffsets === 'object') data.lyricOffsets = payload.lyricOffsets;
|
||||||
if (payload.stats && typeof payload.stats === 'object') data.stats = payload.stats;
|
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 };
|
data.profile = { name, syncedAt: updatedAt || 0, protected: !!payload.__protected, kind: payload.__kind || null };
|
||||||
API.saveData(data).catch(() => {});
|
API.saveData(data).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -1241,7 +1263,7 @@ async function adoptSharedPlaylistByCode(code, { fromName = '' } = {}) {
|
|||||||
Add “<strong>${escapeHtml(shared.name)}</strong>” (${count} video${count === 1 ? '' : 's'}) to your playlists?
|
Add “<strong>${escapeHtml(shared.name)}</strong>” (${count} video${count === 1 ? '' : 's'}) to your playlists?
|
||||||
This playlist is added alongside your existing playlists and nothing is replaced.
|
This playlist is added alongside your existing playlists and nothing is replaced.
|
||||||
</p>`;
|
</p>`;
|
||||||
showModal(fromName ? '🎵 Playlist sent to you' : '⬇ Add shared playlist', body, [
|
showModal(fromName ? '🎵 Playlist sent to you' : 'Add shared playlist', body, [
|
||||||
{ label: fromName ? 'No thanks' : 'Cancel', onClick: () => { closeModal(); resolve(false); } },
|
{ label: fromName ? 'No thanks' : 'Cancel', onClick: () => { closeModal(); resolve(false); } },
|
||||||
{ label: 'Add playlist', primary: true, onClick: () => { closeModal(); resolve(true); } },
|
{ label: 'Add playlist', primary: true, onClick: () => { closeModal(); resolve(true); } },
|
||||||
]);
|
]);
|
||||||
@@ -1361,7 +1383,7 @@ async function adoptProfileFromUrl() {
|
|||||||
Loading it replaces the playlists, history and settings on this device,
|
Loading it replaces the playlists, history and settings on this device,
|
||||||
and future changes here sync to that profile.
|
and future changes here sync to that profile.
|
||||||
</p>`;
|
</p>`;
|
||||||
showModal('⬇ Open shared profile', body, [
|
showModal('Open shared profile', body, [
|
||||||
{ label: 'Cancel', onClick: () => { closeModal(); resolve(false); } },
|
{ label: 'Cancel', onClick: () => { closeModal(); resolve(false); } },
|
||||||
{ label: 'Load profile', primary: true, onClick: () => { closeModal(); resolve(true); } },
|
{ label: 'Load profile', primary: true, onClick: () => { closeModal(); resolve(true); } },
|
||||||
]);
|
]);
|
||||||
@@ -1567,7 +1589,7 @@ function loadProfileFlow() {
|
|||||||
changes here sync back to that profile.
|
changes here sync back to that profile.
|
||||||
</p>
|
</p>
|
||||||
<input id="profileNameInput" type="text" placeholder="profile name" autocomplete="off" />`;
|
<input id="profileNameInput" type="text" placeholder="profile name" autocomplete="off" />`;
|
||||||
showModal('⬇ Load online profile', body, [
|
showModal('Load online profile', body, [
|
||||||
{ label: 'Cancel', onClick: closeModal },
|
{ label: 'Cancel', onClick: closeModal },
|
||||||
{ label: 'Load', primary: true, onClick: async () => {
|
{ label: 'Load', primary: true, onClick: async () => {
|
||||||
const name = ($('profileNameInput').value || '').trim();
|
const name = ($('profileNameInput').value || '').trim();
|
||||||
@@ -1598,8 +1620,8 @@ function escapeHtml(s) {
|
|||||||
.replace(/"/g, '"').replace(/'/g, ''');
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICON_PLAY = '<svg viewBox="0 0 24 24" aria-hidden="true" class="fill"><path d="M9 5.5v13l10-6.5z"/></svg>';
|
const ICON_PLAY = TransportIcons.svg('play');
|
||||||
const ICON_PAUSE = '<svg viewBox="0 0 24 24" aria-hidden="true" class="fill"><path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z"/></svg>';
|
const ICON_PAUSE = TransportIcons.svg('pause');
|
||||||
// Speaker icon follows the volume (muted / low / high); the level before a mute
|
// Speaker icon follows the volume (muted / low / high); the level before a mute
|
||||||
// is remembered so un-muting returns to it.
|
// is remembered so un-muting returns to it.
|
||||||
let lastVolume = 1;
|
let lastVolume = 1;
|
||||||
@@ -2209,7 +2231,7 @@ async function openVideoEditor(source) {
|
|||||||
{
|
{
|
||||||
label: 'Save edited copy', primary: true, onClick: () => {
|
label: 'Save edited copy', primary: true, onClick: () => {
|
||||||
if (!VideoEdit.hasEdits(cuts, duration)) {
|
if (!VideoEdit.hasEdits(cuts, duration)) {
|
||||||
showErr('Add at least one cut, or use ⬇ Save for the full video.');
|
showErr('Add at least one cut, or use Save for the full video.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const keep = VideoEdit.invertCuts(cuts, duration);
|
const keep = VideoEdit.invertCuts(cuts, duration);
|
||||||
@@ -3088,14 +3110,10 @@ function wirePlayerEvents() {
|
|||||||
|
|
||||||
function updatePlayBtn() {
|
function updatePlayBtn() {
|
||||||
Remote.changed();
|
Remote.changed();
|
||||||
els.playBtn.innerHTML = Player.master.paused ? ICON_PLAY : ICON_PAUSE;
|
const kind = Player.master.paused ? 'play' : 'pause';
|
||||||
const sp = $('stgPlay');
|
for (const id of ['playBtn','stgPlay','miniPlayBtn','servicePlayBtn']) TransportIcons.set($(id), kind);
|
||||||
if (sp) sp.innerHTML = Player.master.paused ? ICON_PLAY : ICON_PAUSE;
|
|
||||||
const stage = document.querySelector('.player-stage');
|
const stage = document.querySelector('.player-stage');
|
||||||
if (stage) stage.classList.toggle('is-paused', !!Player.master.paused);
|
if (stage) stage.classList.toggle('is-paused', !!Player.master.paused);
|
||||||
$('miniPlayBtn').textContent = Player.master.paused ? '▶' : '⏸';
|
|
||||||
const spBtn = $('servicePlayBtn');
|
|
||||||
if (spBtn) spBtn.textContent = Player.master.paused ? '▶' : '⏸';
|
|
||||||
}
|
}
|
||||||
// "⚠ Broken": ask the server to drop its cached copy and fetch a fresh one.
|
// "⚠ Broken": ask the server to drop its cached copy and fetch a fresh one.
|
||||||
// Playback switches to live YouTube streams meanwhile (resuming where it
|
// Playback switches to live YouTube streams meanwhile (resuming where it
|
||||||
@@ -3245,7 +3263,7 @@ function openMoreControls() {
|
|||||||
['eqBtn', '🎚', 'Equalizer', false],
|
['eqBtn', '🎚', 'Equalizer', false],
|
||||||
['pipBtn', '⧉', 'Picture-in-picture', false],
|
['pipBtn', '⧉', 'Picture-in-picture', false],
|
||||||
];
|
];
|
||||||
const selects = [['speedSelect', '⏩', 'Speed'], ['qualitySelect', '🎞', 'Quality']];
|
const selects = [['speedSelect', TransportIcons.svg('forward'), 'Speed'], ['qualitySelect', '🎞', 'Quality']];
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
list.className = 'more-list';
|
list.className = 'more-list';
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
@@ -3256,7 +3274,7 @@ function openMoreControls() {
|
|||||||
const row = document.createElement('label');
|
const row = document.createElement('label');
|
||||||
row.className = 'more-row';
|
row.className = 'more-row';
|
||||||
row.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b></span>';
|
row.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b></span>';
|
||||||
row.querySelector('.more-ico').textContent = icon;
|
row.querySelector('.more-ico').innerHTML = icon;
|
||||||
row.querySelector('b').textContent = title;
|
row.querySelector('b').textContent = title;
|
||||||
const sel = document.createElement('select');
|
const sel = document.createElement('select');
|
||||||
sel.className = 'more-sel';
|
sel.className = 'more-sel';
|
||||||
@@ -3274,7 +3292,7 @@ function openMoreControls() {
|
|||||||
row.type = 'button';
|
row.type = 'button';
|
||||||
row.className = 'more-row' + (on ? ' on' : '');
|
row.className = 'more-row' + (on ? ' on' : '');
|
||||||
row.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b><small></small></span><span class="more-go">' + (on ? '●' : '›') + '</span>';
|
row.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b><small></small></span><span class="more-go">' + (on ? '●' : '›') + '</span>';
|
||||||
row.querySelector('.more-ico').textContent = icon;
|
row.querySelector('.more-ico').innerHTML = icon;
|
||||||
row.querySelector('b').textContent = title;
|
row.querySelector('b').textContent = title;
|
||||||
row.querySelector('small').textContent = String(src.title || '').replace(/\s*\[[A-Za-z]\]\s*$/, '');
|
row.querySelector('small').textContent = String(src.title || '').replace(/\s*\[[A-Za-z]\]\s*$/, '');
|
||||||
row.onclick = () => {
|
row.onclick = () => {
|
||||||
@@ -3301,13 +3319,13 @@ function openAddChoice() {
|
|||||||
r.type = 'button';
|
r.type = 'button';
|
||||||
r.className = 'more-row';
|
r.className = 'more-row';
|
||||||
r.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b><small></small></span><span class="more-go">›</span>';
|
r.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b><small></small></span><span class="more-go">›</span>';
|
||||||
r.querySelector('.more-ico').textContent = icon;
|
r.querySelector('.more-ico').innerHTML = icon;
|
||||||
r.querySelector('b').textContent = title;
|
r.querySelector('b').textContent = title;
|
||||||
r.querySelector('small').textContent = sub;
|
r.querySelector('small').textContent = sub;
|
||||||
r.onclick = () => { closeModal(); setTimeout(fn, 0); };
|
r.onclick = () => { closeModal(); setTimeout(fn, 0); };
|
||||||
list.appendChild(r);
|
list.appendChild(r);
|
||||||
};
|
};
|
||||||
row('▶', 'Add to queue', 'Play it next in this session', () => addToQueue(video));
|
row(TransportIcons.svg('play'), 'Add to queue', 'Play it next in this session', () => addToQueue(video));
|
||||||
row('☰', 'Add to playlist', 'Save it to one of your playlists', () => openCardMenu(video));
|
row('☰', 'Add to playlist', 'Save it to one of your playlists', () => openCardMenu(video));
|
||||||
showModal('Add “' + (video.title || 'video').slice(0, 40) + '”', list, [{ label: 'Cancel', onClick: closeModal }]);
|
showModal('Add “' + (video.title || 'video').slice(0, 40) + '”', list, [{ label: 'Cancel', onClick: closeModal }]);
|
||||||
}
|
}
|
||||||
@@ -3332,12 +3350,26 @@ function openMoreActions() {
|
|||||||
row.className = 'more-row' + (danger ? ' danger' : '');
|
row.className = 'more-row' + (danger ? ' danger' : '');
|
||||||
row.disabled = !!src.disabled;
|
row.disabled = !!src.disabled;
|
||||||
row.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b><small></small></span><span class="more-go">›</span>';
|
row.innerHTML = '<span class="more-ico"></span><span class="more-txt"><b></b><small></small></span><span class="more-go">›</span>';
|
||||||
row.querySelector('.more-ico').textContent = icon;
|
row.querySelector('.more-ico').innerHTML = icon;
|
||||||
row.querySelector('b').textContent = title;
|
row.querySelector('b').textContent = title;
|
||||||
row.querySelector('small').textContent = sub;
|
row.querySelector('small').textContent = sub;
|
||||||
row.onclick = () => { closeModal(); setTimeout(() => src.click(), 0); };
|
row.onclick = () => { closeModal(); setTimeout(() => src.click(), 0); };
|
||||||
list.appendChild(row);
|
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 = '<span class="more-ico" aria-hidden="true">A/B</span><span class="more-txt"><b></b><small>Temporarily compare with the unprocessed signal</small></span><span class="more-go">›</span>';
|
||||||
|
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; }
|
if (!list.children.length) { toast('No more actions for this video'); return; }
|
||||||
showModal('More actions', list, [{ label: 'Close', onClick: closeModal }]);
|
showModal('More actions', list, [{ label: 'Close', onClick: closeModal }]);
|
||||||
}
|
}
|
||||||
@@ -3559,7 +3591,7 @@ const Notes = (() => {
|
|||||||
<div class="notes-empty-actions">
|
<div class="notes-empty-actions">
|
||||||
<button class="np-btn" type="button" data-act="web">🌐 Find lyrics online</button>
|
<button class="np-btn" type="button" data-act="web">🌐 Find lyrics online</button>
|
||||||
<button class="np-btn" type="button" data-act="paste">✎ Add lyrics</button>
|
<button class="np-btn" type="button" data-act="paste">✎ Add lyrics</button>
|
||||||
${isUploadId(st.videoId) ? '' : '<button class="np-btn" type="button" data-act="captions">⤓ Import YouTube captions</button>'}
|
${isUploadId(st.videoId) ? '' : `<button class="np-btn" type="button" data-act="captions">${MediaIcons.svg('download')} Import YouTube captions</button>`}
|
||||||
</div>
|
</div>
|
||||||
<div class="notes-empty-hint">Lyrics and chapters are shared with everyone who plays this song.</div>
|
<div class="notes-empty-hint">Lyrics and chapters are shared with everyone who plays this song.</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -3633,7 +3665,7 @@ const Notes = (() => {
|
|||||||
<div class="mk-title">${m.kind === 'note' ? '📝 ' : ''}${escapeHtml(m.title)}</div>
|
<div class="mk-title">${m.kind === 'note' ? '📝 ' : ''}${escapeHtml(m.title)}</div>
|
||||||
${m.note ? `<div class="mk-note${m.kind === 'note' ? ' long' : ''}">${escapeHtml(m.note)}</div>` : ''}
|
${m.note ? `<div class="mk-note${m.kind === 'note' ? ' long' : ''}">${escapeHtml(m.note)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button class="mk-btn" type="button" data-act="jump" title="Play from here">▶</button>
|
<button class="mk-btn" type="button" data-act="jump" title="Play from here">${TransportIcons.svg('play')}</button>
|
||||||
${m.auto ? '' : `<button class="mk-btn" type="button" data-act="edit" title="Edit">✎</button>
|
${m.auto ? '' : `<button class="mk-btn" type="button" data-act="edit" title="Edit">✎</button>
|
||||||
<button class="mk-btn" type="button" data-act="del" title="Delete">🗑</button>`}
|
<button class="mk-btn" type="button" data-act="del" title="Delete">🗑</button>`}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -3801,7 +3833,7 @@ const Notes = (() => {
|
|||||||
${note ? `<p class="lyr-editor-note">${escapeHtml(note)}</p>` : ''}
|
${note ? `<p class="lyr-editor-note">${escapeHtml(note)}</p>` : ''}
|
||||||
<textarea class="lyr-editor-text" spellcheck="false" placeholder="Paste or type the lyrics…"></textarea>
|
<textarea class="lyr-editor-text" spellcheck="false" placeholder="Paste or type the lyrics…"></textarea>
|
||||||
<div class="lyr-editor-tools">
|
<div class="lyr-editor-tools">
|
||||||
<button type="button" class="np-btn" data-act="captions">⤓ Import YouTube captions</button>
|
<button type="button" class="np-btn" data-act="captions">${MediaIcons.svg('download')} Import YouTube captions</button>
|
||||||
<button type="button" class="np-btn" data-act="break">↵ Line break (same time)</button>
|
<button type="button" class="np-btn" data-act="break">↵ Line break (same time)</button>
|
||||||
<button type="button" class="np-btn" data-act="stamp">⏱ Stamp current time on this line</button>
|
<button type="button" class="np-btn" data-act="stamp">⏱ Stamp current time on this line</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -4181,7 +4213,7 @@ const Notes = (() => {
|
|||||||
const refresh = () => { ta.value = build(inc.checked); };
|
const refresh = () => { ta.value = build(inc.checked); };
|
||||||
inc.addEventListener('change', refresh);
|
inc.addEventListener('change', refresh);
|
||||||
refresh();
|
refresh();
|
||||||
showModal('⤓ Export chapters', body, [
|
showModal('Export chapters', body, [
|
||||||
{ label: 'Download .txt', onClick: () => {
|
{ label: 'Download .txt', onClick: () => {
|
||||||
const blob = new Blob([`${title}\nhttps://youtu.be/${st.videoId}\n\n${ta.value}\n`], { type: 'text/plain' });
|
const blob = new Blob([`${title}\nhttps://youtu.be/${st.videoId}\n\n${ta.value}\n`], { type: 'text/plain' });
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -5338,7 +5370,7 @@ const Level = (() => {
|
|||||||
// so it keeps going when locked. One render at a time, in the background.
|
// so it keeps going when locked. One render at a time, in the background.
|
||||||
const EqRender = (() => {
|
const EqRender = (() => {
|
||||||
const DIR = 'eq-renders', KEY = 'ytpEqRenders', RATE = 32000, MAX_SECONDS = 12 * 60;
|
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 readMap = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { return {}; } };
|
||||||
const writeMap = (m) => { try { localStorage.setItem(KEY, JSON.stringify(m)); } catch { /* blocked */ } };
|
const writeMap = (m) => { try { localStorage.setItem(KEY, JSON.stringify(m)); } catch { /* blocked */ } };
|
||||||
const queue = new Set();
|
const queue = new Set();
|
||||||
@@ -5508,10 +5540,13 @@ const PiP = (() => {
|
|||||||
const loadSoundFeatures=()=>Promise.all([Lazy.load('feature:eq-core'),Lazy.load('feature:vocal-reducer')]);
|
const loadSoundFeatures=()=>Promise.all([Lazy.load('feature:eq-core'),Lazy.load('feature:vocal-reducer')]);
|
||||||
const EQ_BANDS = EqParameters.bands;
|
const EQ_BANDS = EqParameters.bands;
|
||||||
const EQ_PRESETS = EqParameters.presets;
|
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 EQ = (() => {
|
||||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
const AC = window.AudioContext || window.webkitAudioContext;
|
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)
|
// Web Audio is off on iOS (it silences playback when the screen locks)
|
||||||
// unless the user opted in through "Level volume" (Settings → Playback).
|
// unless the user opted in through "Level volume" (Settings → Playback).
|
||||||
// Live Web Audio is off on iOS (it silences playback when the screen locks)
|
// Live Web Audio is off on iOS (it silences playback when the screen locks)
|
||||||
@@ -5526,6 +5561,49 @@ const EQ = (() => {
|
|||||||
};
|
};
|
||||||
const isFlat = () => gains().every((v) => v === 0);
|
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() {
|
function build() {
|
||||||
if (st.ctx) return true;
|
if (st.ctx) return true;
|
||||||
if (!Lazy.loaded('feature:eq-core') || !Lazy.loaded('feature:vocal-reducer')) {
|
if (!Lazy.loaded('feature:eq-core') || !Lazy.loaded('feature:vocal-reducer')) {
|
||||||
@@ -5533,7 +5611,7 @@ const EQ = (() => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
st.ctx = new AC();
|
st.ctx = AudioGraph.context();
|
||||||
st.filters = EQ_BANDS.map((b) => {
|
st.filters = EQ_BANDS.map((b) => {
|
||||||
const f = st.ctx.createBiquadFilter();
|
const f = st.ctx.createBiquadFilter();
|
||||||
f.type = b.type;
|
f.type = b.type;
|
||||||
@@ -5558,16 +5636,24 @@ const EQ = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function attach(el) {
|
function attach(el) {
|
||||||
if (!el || st.sources.has(el)) return;
|
if (!el) return;
|
||||||
try {
|
try {
|
||||||
const src = st.ctx.createMediaElementSource(el);
|
AudioGraph.connect(el, st.filters[0]);
|
||||||
src.connect(st.filters[0]);
|
|
||||||
st.sources.set(el, src);
|
|
||||||
} catch { /* already connected elsewhere */ }
|
} catch { /* already connected elsewhere */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply the stored curve; wires the graph up the first time it's non-flat.
|
// Apply the stored curve; wires the graph up the first time it's non-flat.
|
||||||
function apply() {
|
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 (!available()) return;
|
||||||
if (!st.ctx && isFlat() && EqParameters.amount(data.settings.vocalLevel ?? 100) === 100 && EqParameters.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;
|
if (!build()) return;
|
||||||
@@ -5647,6 +5733,7 @@ const EQ = (() => {
|
|||||||
// one-way step the EQ takes, so it is only offered where the EQ is (never
|
// 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).
|
// on iOS, where Web Audio would silence background playback).
|
||||||
function analyser() {
|
function analyser() {
|
||||||
|
if (dspController?.active && !dspController.state().bypassed) return dspController.analyser();
|
||||||
if (!available() || !build()) return null;
|
if (!available() || !build()) return null;
|
||||||
attach(els.video);
|
attach(els.video);
|
||||||
attach(els.audio);
|
attach(els.audio);
|
||||||
@@ -5662,8 +5749,13 @@ const EQ = (() => {
|
|||||||
|
|
||||||
// Set the levelling gain (dB). 0 dB never wires the graph on its own.
|
// Set the levelling gain (dB). 0 dB never wires the graph on its own.
|
||||||
function setLevel(db) {
|
function setLevel(db) {
|
||||||
if (!available()) return false;
|
|
||||||
if (rendered()) db = 0;
|
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 (!st.ctx && Math.abs(db) < 0.3) return true;
|
||||||
if (!build()) return false;
|
if (!build()) return false;
|
||||||
attach(els.video);
|
attach(els.video);
|
||||||
@@ -5674,8 +5766,56 @@ const EQ = (() => {
|
|||||||
return true;
|
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,
|
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 };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -5725,7 +5865,7 @@ const Gestures = (() => {
|
|||||||
if (x < 1 / 3) seekBy(-10, 'left');
|
if (x < 1 / 3) seekBy(-10, 'left');
|
||||||
else if (x > 2 / 3) seekBy(10, 'right');
|
else if (x > 2 / 3) seekBy(10, 'right');
|
||||||
else if (st.zoom > 1.001) { reset(); showHud('<div class="gh-label">Zoom reset</div>'); }
|
else if (st.zoom > 1.001) { reset(); showHud('<div class="gh-label">Zoom reset</div>'); }
|
||||||
else if (current) { Player.toggle(); updatePlayBtn(); showHud(`<div class="gh-seek">${Player.master.paused ? '⏸' : '▶'}</div>`); }
|
else if (current) { Player.toggle(); updatePlayBtn(); showHud(`<div class="gh-seek">${TransportIcons.svg(Player.master.paused ? 'pause' : 'play')}</div>`); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
st.lastTap = now;
|
st.lastTap = now;
|
||||||
@@ -6285,7 +6425,7 @@ function updateServiceMode() {
|
|||||||
|
|
||||||
const spBtn = $('servicePlayBtn');
|
const spBtn = $('servicePlayBtn');
|
||||||
if (spBtn && Player.master) {
|
if (spBtn && Player.master) {
|
||||||
spBtn.textContent = Player.master.paused ? '▶' : '⏸';
|
TransportIcons.set(spBtn, Player.master.paused ? 'play' : 'pause');
|
||||||
}
|
}
|
||||||
|
|
||||||
updateWakeStateUI();
|
updateWakeStateUI();
|
||||||
@@ -6920,7 +7060,7 @@ function openImportYouTubeModal() {
|
|||||||
</p>
|
</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
showModal('⬇ Import from YouTube', successBody, [
|
showModal('Import from YouTube', successBody, [
|
||||||
{ label: 'Cancel', onClick: closeModal },
|
{ label: 'Cancel', onClick: closeModal },
|
||||||
{
|
{
|
||||||
label: 'Create playlist',
|
label: 'Create playlist',
|
||||||
@@ -6959,7 +7099,7 @@ function openImportYouTubeModal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showModal('⬇ Import from YouTube', body, [
|
showModal('Import from YouTube', body, [
|
||||||
{ label: 'Cancel', onClick: closeModal },
|
{ label: 'Cancel', onClick: closeModal },
|
||||||
{ label: 'Import', primary: true, onClick: handleImport },
|
{ label: 'Import', primary: true, onClick: handleImport },
|
||||||
]);
|
]);
|
||||||
@@ -6984,14 +7124,14 @@ function openCardMenu(video) {
|
|||||||
|
|
||||||
// Quick: add to queue
|
// Quick: add to queue
|
||||||
const queueBtn = document.createElement('button');
|
const queueBtn = document.createElement('button');
|
||||||
queueBtn.textContent = '▶ Add to queue';
|
queueBtn.innerHTML = TransportIcons.svg('play') + ' Add to queue';
|
||||||
queueBtn.onclick = () => { addToQueue(video); closeModal(); };
|
queueBtn.onclick = () => { addToQueue(video); closeModal(); };
|
||||||
body.appendChild(queueBtn);
|
body.appendChild(queueBtn);
|
||||||
|
|
||||||
const isCustom = !!video.custom;
|
const isCustom = !!video.custom;
|
||||||
|
|
||||||
const exportBtn = document.createElement('button');
|
const exportBtn = document.createElement('button');
|
||||||
exportBtn.textContent = '🖼 Save to gallery';
|
exportBtn.innerHTML = MediaIcons.svg('download') + ' Save to gallery';
|
||||||
exportBtn.title = 'Put the video file in your gallery, Files app or Downloads folder';
|
exportBtn.title = 'Put the video file in your gallery, Files app or Downloads folder';
|
||||||
exportBtn.onclick = () => { closeModal(); exportToDevice(video); };
|
exportBtn.onclick = () => { closeModal(); exportToDevice(video); };
|
||||||
body.appendChild(exportBtn);
|
body.appendChild(exportBtn);
|
||||||
@@ -7005,7 +7145,7 @@ function openCardMenu(video) {
|
|||||||
saveBtn.className = 'danger';
|
saveBtn.className = 'danger';
|
||||||
saveBtn.onclick = () => { closeModal(); deleteCustomVideo(video.id); };
|
saveBtn.onclick = () => { closeModal(); deleteCustomVideo(video.id); };
|
||||||
} else {
|
} else {
|
||||||
saveBtn.textContent = isSaved ? '✓ Saved offline — remove' : '⬇ Save for offline';
|
saveBtn.innerHTML = MediaIcons.svg(isSaved ? 'offline' : 'download') + (isSaved ? ' Saved offline — remove' : ' Save for offline');
|
||||||
saveBtn.onclick = async () => {
|
saveBtn.onclick = async () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
if (cachedIds.has(video.id)) {
|
if (cachedIds.has(video.id)) {
|
||||||
@@ -7232,17 +7372,17 @@ function updatePlaylistOfflineButton(btn, pl) {
|
|||||||
let label;
|
let label;
|
||||||
btn.classList.remove('done', 'busy');
|
btn.classList.remove('done', 'busy');
|
||||||
if (stats.total > 0 && stats.cached === stats.total) {
|
if (stats.total > 0 && stats.cached === stats.total) {
|
||||||
if (iconOnly) { setListIcon(btn, 'offlineDone'); btn.classList.add('done'); } else btn.textContent = '✓ Available offline';
|
if (iconOnly) { setListIcon(btn, 'offlineDone'); btn.classList.add('done'); } else btn.innerHTML = MediaIcons.svg('offline') + ' Available offline';
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.classList.add('disabled');
|
btn.classList.add('disabled');
|
||||||
label = 'All videos in this playlist are available offline';
|
label = 'All videos in this playlist are available offline';
|
||||||
} else if (isDownloading) {
|
} else if (isDownloading) {
|
||||||
if (iconOnly) { btn.innerHTML = `<span class="lib-progress">${stats.cached}/${stats.total}</span>`; btn.classList.add('busy'); } else btn.textContent = `Saving ${stats.cached}/${stats.total}…`;
|
if (iconOnly) { btn.innerHTML = MediaIcons.svg('progress') + `<span class="lib-progress">${stats.cached}/${stats.total}</span>`; btn.classList.add('busy'); } else btn.innerHTML = MediaIcons.svg('progress') + ` Saving ${stats.cached}/${stats.total}…`;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.classList.add('disabled');
|
btn.classList.add('disabled');
|
||||||
label = `${stats.cached} of ${stats.total} videos saved offline`;
|
label = `${stats.cached} of ${stats.total} videos saved offline`;
|
||||||
} else {
|
} else {
|
||||||
if (iconOnly) setListIcon(btn, 'offline'); else btn.textContent = '⬇ Make available offline';
|
if (iconOnly) setListIcon(btn, 'offline'); else btn.innerHTML = MediaIcons.svg('download') + ' Make available offline';
|
||||||
btn.disabled = stats.total === 0;
|
btn.disabled = stats.total === 0;
|
||||||
btn.classList.toggle('disabled', stats.total === 0);
|
btn.classList.toggle('disabled', stats.total === 0);
|
||||||
label = stats.total === 0 ? 'Playlist is empty' : 'Make available offline';
|
label = stats.total === 0 ? 'Playlist is empty' : 'Make available offline';
|
||||||
@@ -7259,7 +7399,7 @@ async function makePlaylistOffline(pl) {
|
|||||||
const btn = $('plOfflineBtn');
|
const btn = $('plOfflineBtn');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
const stats = playlistOfflineStats(pl);
|
const stats = playlistOfflineStats(pl);
|
||||||
btn.innerHTML = `<span class="lib-progress">${stats.cached}/${stats.total}</span>`;
|
btn.innerHTML = MediaIcons.svg('progress') + `<span class="lib-progress">${stats.cached}/${stats.total}</span>`;
|
||||||
btn.classList.add('busy');
|
btn.classList.add('busy');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.classList.add('disabled');
|
btn.classList.add('disabled');
|
||||||
@@ -8528,9 +8668,7 @@ const RUNNING_BUILD = (() => {
|
|||||||
let _knownBuildTag = null;
|
let _knownBuildTag = null;
|
||||||
|
|
||||||
async function fetchServerBuild() {
|
async function fetchServerBuild() {
|
||||||
const res = await fetch('/api/version', { cache: 'no-store' });
|
const data = await webFetch('/api/version', { cache: 'no-store' });
|
||||||
if (!res.ok) return null;
|
|
||||||
const data = await res.json();
|
|
||||||
return data.buildTag || null;
|
return data.buildTag || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8690,11 +8828,11 @@ async function applyUpdate(target, { quiet = false } = {}) {
|
|||||||
// When the waiting worker already downloaded this build in the
|
// When the waiting worker already downloaded this build in the
|
||||||
// background, the toast says so and nothing is re-downloaded.
|
// background, the toast says so and nothing is re-downloaded.
|
||||||
askStatus: (worker) => SU.askCacheStatus(worker, { timeoutMs: 4000, setTimeout: (fn, ms) => setTimeout(fn, ms) }),
|
askStatus: (worker) => SU.askCacheStatus(worker, { timeoutMs: 4000, setTimeout: (fn, ms) => setTimeout(fn, ms) }),
|
||||||
onPhase: (phase) => {
|
onPhase: (phase, status) => {
|
||||||
if (quiet) return;
|
if (quiet) return;
|
||||||
if (_updateToast) _updateToast.remove();
|
if (_updateToast) _updateToast.remove();
|
||||||
_updateToast = phase === 'ready'
|
_updateToast = phase === 'ready'
|
||||||
? toast('Applying the update…', { duration: 20000 })
|
? toast(status?.offlineReady === false ? `Applying the update… Offline cache is still preparing (${status.cached}/${status.total}).` : 'Applying the update…', { duration: 20000 })
|
||||||
: toast('Downloading the update…', { duration: 305000 });
|
: toast('Downloading the update…', { duration: 305000 });
|
||||||
},
|
},
|
||||||
refreshShell: ('caches' in window) ? () => SU.refreshShellInPlace({
|
refreshShell: ('caches' in window) ? () => SU.refreshShellInPlace({
|
||||||
@@ -8758,6 +8896,8 @@ async function registerServiceWorker() {
|
|||||||
const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });
|
const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });
|
||||||
_swReg = reg;
|
_swReg = reg;
|
||||||
Lazy.reportLayout(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
|
// 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
|
// controlled by a previous SW. On a FIRST install (fresh visit, or right
|
||||||
@@ -8793,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=<query> — a shareable search link (same family as Share's /?v=&t=).
|
// /?q=<query> — a shareable search link (same family as Share's /?v=&t=).
|
||||||
// Runs after wireUI() so the search form's submit handler exists; the param
|
// 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.
|
// is stripped immediately so a reload doesn't re-run the search.
|
||||||
@@ -8972,7 +9120,7 @@ async function boot() {
|
|||||||
lyricOffsets: loaded.lyricOffsets || {},
|
lyricOffsets: loaded.lyricOffsets || {},
|
||||||
stats: loaded.stats || null,
|
stats: loaded.stats || null,
|
||||||
lastAutoBackup: loaded.lastAutoBackup || 0,
|
lastAutoBackup: loaded.lastAutoBackup || 0,
|
||||||
settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) },
|
settings: normalizeJamesDspSettings({ ...DEFAULT_SETTINGS, ...(loaded.settings || {}) }),
|
||||||
profile: loaded.profile || null,
|
profile: loaded.profile || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,20 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const CACHE = 'ytplayer-assets';
|
const CACHE = 'ytplayer-assets';
|
||||||
const STATE = '/__ytp_asset_state';
|
const STATE = '/__ytp_asset_state';
|
||||||
|
const OPT_INS = '/__ytp_offline_opt_ins';
|
||||||
const url = (path, file) => path + '?v=' + file.h;
|
const url = (path, file) => path + '?v=' + file.h;
|
||||||
|
|
||||||
function paths(manifest) {
|
function paths(manifest) {
|
||||||
return [...new Set(Object.values(manifest.groups).flatMap(group => group.files))].sort();
|
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) {
|
function blocking(manifest, previous, activeLayout) {
|
||||||
@@ -41,6 +51,9 @@
|
|||||||
const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path));
|
const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path));
|
||||||
if (!group || !previous.groups[group]?.files.includes(path) ||
|
if (!group || !previous.groups[group]?.files.includes(path) ||
|
||||||
manifest.groups[group].contract !== previous.groups[group].contract) return null;
|
manifest.groups[group].contract !== previous.groups[group].contract) return null;
|
||||||
|
// A stale core must understand every group contract in the new shell.
|
||||||
|
if (path === "/app.js" && Object.entries(manifest.groups).some(([name, entry]) =>
|
||||||
|
previous.groups[name]?.contract !== entry.contract)) return null;
|
||||||
return url(path, previous.files[path]);
|
return url(path, previous.files[path]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,30 +62,65 @@
|
|||||||
return response ? response.json() : null;
|
return response ? response.json() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function status(manifest, cache, { previous, activeLayout } = {}) {
|
function verifiedProbe(response, hash) {
|
||||||
const found = await Promise.all(blocking(manifest, previous, activeLayout).map(async path => {
|
if (!response) return false;
|
||||||
const response = await cache.match(url(path, manifest.files[path]));
|
const valid = response.headers.get('X-Asset-Hash') === hash;
|
||||||
return !!response && response.headers.get('X-Asset-Hash') === manifest.files[path].h;
|
// Metadata checks must release Blob/data-pipe readers. They never consume
|
||||||
}));
|
// asset bytes, and cancelling this response does not remove its cache entry.
|
||||||
const missing = found.filter(value => !value).length;
|
response.body?.cancel().catch(() => {});
|
||||||
return { ready: missing === 0, missing, version: manifest.buildTag };
|
return valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups }) {
|
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, { optIns }) : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// All concurrent messages/layout jobs in this worker share six download slots.
|
||||||
|
let downloads = 0;
|
||||||
|
const waiters = [];
|
||||||
|
async function downloadSlot(task) {
|
||||||
|
if (downloads >= 6) await new Promise(resolve => waiters.push(resolve));
|
||||||
|
else downloads++;
|
||||||
|
try { return await task(); }
|
||||||
|
finally { const next = waiters.shift(); if (next) next(); else downloads--; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingDownloads = new Map();
|
||||||
|
async function sharedDownload(key, task) {
|
||||||
|
while (pendingDownloads.has(key)) await pendingDownloads.get(key).catch(() => {});
|
||||||
|
const promise = downloadSlot(task);
|
||||||
|
pendingDownloads.set(key, promise);
|
||||||
|
try { return await promise; }
|
||||||
|
finally { pendingDownloads.delete(key); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups, all = false, optIns } = {}) {
|
||||||
const previous = (await state(cache))?.current;
|
const previous = (await state(cache))?.current;
|
||||||
const missing = [];
|
const missing = [];
|
||||||
const selected = 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);
|
: blocking(manifest, previous, activeLayout);
|
||||||
for (const path of selected) {
|
for (const path of selected) {
|
||||||
const key = url(path, manifest.files[path]);
|
const key = url(path, manifest.files[path]);
|
||||||
const response = await cache.match(key);
|
const response = await cache.match(key);
|
||||||
if (!response || response.headers.get('X-Asset-Hash') !== manifest.files[path].h) missing.push(key);
|
if (!verifiedProbe(response, manifest.files[path].h)) missing.push(key);
|
||||||
}
|
}
|
||||||
const count = missing.length;
|
const count = missing.length;
|
||||||
let failure;
|
let failure;
|
||||||
await Promise.all(Array.from({ length: Math.max(1, Math.min(6, concurrency, count)) }, async () => {
|
await Promise.all(Array.from({ length: Math.max(1, Math.min(6, concurrency, count)) }, async () => {
|
||||||
while (missing.length && !failure) {
|
while (missing.length) {
|
||||||
const key = missing.shift();
|
const key = missing.shift();
|
||||||
|
try { await sharedDownload(key, async () => {
|
||||||
|
const held = await cache.match(key);
|
||||||
|
if (verifiedProbe(held, key.split('v=')[1])) return;
|
||||||
let error;
|
let error;
|
||||||
for (let attempt = 0; attempt < Math.min(3, attempts); attempt++) {
|
for (let attempt = 0; attempt < Math.min(3, attempts); attempt++) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -89,17 +137,63 @@
|
|||||||
} catch (err) { error = err; }
|
} catch (err) { error = err; }
|
||||||
finally { clearTimeout(timer); }
|
finally { clearTimeout(timer); }
|
||||||
}
|
}
|
||||||
if (error) failure = error;
|
if (error) throw error;
|
||||||
|
}); } catch (error) { failure = error; }
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
if (failure) throw failure;
|
if (failure) throw failure;
|
||||||
if (!groups && !(await status(manifest, cache, { previous, activeLayout })).ready) throw new Error('Incomplete blocking assets');
|
if (!groups && !all && !(await status(manifest, cache, { previous, activeLayout, offline: false })).ready) throw new Error('Incomplete blocking assets');
|
||||||
return { refreshed: count, caches: 1 };
|
return { refreshed: count, caches: 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Verified cache entries are the durable progress journal. Counters are rebuilt
|
||||||
|
// after worker termination or browser eviction; never trust a stored ready bit.
|
||||||
|
async function completeness(manifest, cache, { 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;
|
||||||
|
}))).filter(Boolean);
|
||||||
|
const total = files.length;
|
||||||
|
return { offlineReady: missingFiles.length === 0, cached: total - missingFiles.length, total, missingFiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
const completionKey = manifest => '/__ytp_completion/' + manifest.buildTag;
|
||||||
|
async function complete(manifest, { cache, fetchFn, cycles = 4, now = Date.now, optIns,
|
||||||
|
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), baseDelay = 1000,
|
||||||
|
maxDelay = 30000, notify = () => {}, force = false, concurrency = 1 } = {}) {
|
||||||
|
const key = completionKey(manifest), saved = await cache.match(key);
|
||||||
|
let job = saved ? await saved.json() : { failures: 0, nextRetryAt: 0 };
|
||||||
|
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));
|
||||||
|
await notify(job);
|
||||||
|
};
|
||||||
|
if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; await publish(false); return job; }
|
||||||
|
if (!force && job.nextRetryAt > now()) await sleep(Math.min(maxDelay, job.nextRetryAt - now()));
|
||||||
|
for (let cycle = 0; cycle < cycles; cycle++) {
|
||||||
|
await publish(true);
|
||||||
|
let error;
|
||||||
|
try { await syncAssets(manifest, { cache, fetchFn, all: true, optIns: selectedOptIns, concurrency }); }
|
||||||
|
catch (failure) { error = failure; }
|
||||||
|
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));
|
||||||
|
job.nextRetryAt = now() + delay;
|
||||||
|
job.error = String(error?.message || 'Incomplete offline cache');
|
||||||
|
await publish(cycle + 1 < cycles);
|
||||||
|
if (cycle + 1 < cycles) await sleep(delay);
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
async function commit(manifest, cache, { previousClients, activeLayout } = {}) {
|
async function commit(manifest, cache, { previousClients, activeLayout } = {}) {
|
||||||
const old = await state(cache);
|
const old = await state(cache);
|
||||||
if (!(await status(manifest, cache, { previous: old?.current, activeLayout })).ready) {
|
if (!(await status(manifest, cache, { previous: old?.current, activeLayout, offline: false })).ready) {
|
||||||
throw new Error('Incomplete blocking assets');
|
throw new Error('Incomplete blocking assets');
|
||||||
}
|
}
|
||||||
const sameBuild = old?.current?.buildTag === manifest.buildTag;
|
const sameBuild = old?.current?.buildTag === manifest.buildTag;
|
||||||
@@ -119,7 +213,7 @@
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = { CACHE, STATE, url, paths, blocking, plan, retained, fallback, status, 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;
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
else root.AssetSyncCore = api;
|
else root.AssetSyncCore = api;
|
||||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||||
|
|||||||
@@ -45,3 +45,81 @@ test('removing group membership retains the old tab URL even if the physical fil
|
|||||||
const previous=manifest(),current=manifest();current.groups.core.files=['/index.html'];
|
const previous=manifest(),current=manifest();current.groups.core.files=['/index.html'];
|
||||||
assert.ok(core.retained(current,previous).has('/a.js?v=a'));
|
assert.ok(core.retained(current,previous).has('/a.js?v=a'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('completion includes ungrouped runtime files and exposes verified missing paths', async () => {
|
||||||
|
const c=cache(), m=manifest();m.files['/worker.js']={h:'w'};
|
||||||
|
await core.syncAssets(m,{cache:c,fetchFn:async u=>response(u.split('=')[1])});
|
||||||
|
const status=await core.status(m,c);
|
||||||
|
assert.equal(status.ready,true);assert.equal(status.offlineReady,false);
|
||||||
|
assert.deepEqual(status.missingFiles,['/worker.js']);assert.equal(status.cached,2);assert.equal(status.total,3);
|
||||||
|
await core.complete(m,{cache:c,fetchFn:async u=>response(u.split('=')[1])});
|
||||||
|
assert.equal((await core.status(m,c)).offlineReady,true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('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');}});
|
||||||
|
const checkpoint=await(await c.match(core.completionKey(m))).json();
|
||||||
|
assert.equal(checkpoint.running,false);assert.equal(checkpoint.failures,1);assert.equal(checkpoint.nextRetryAt,1010);
|
||||||
|
await core.complete(m,{cache:c,now:()=>clock,sleep:async ms=>{assert.equal(ms,10);clock+=ms;},fetchFn:async u=>{calls.push(u);return response(u.split('=')[1]);}});
|
||||||
|
assert.deepEqual(calls,['/index.html?v=b']);
|
||||||
|
const status=await(await c.match(core.completionKey(m))).json();assert.equal(status.offlineReady,true);assert.equal(status.failures,0);
|
||||||
|
await c.delete('/a.js?v=a');assert.equal((await core.status(m,c)).offlineReady,false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completion retries with exponential backoff and keeps making progress past a failed asset',async()=>{
|
||||||
|
const c=cache(),m=manifest(),delays=[];let tries=0;
|
||||||
|
const job=await core.complete(m,{cache:c,baseDelay:5,sleep:async ms=>delays.push(ms),fetchFn:async u=>{if(u.includes('a.js')&&++tries<=6)throw Error('drop');return response(u.split('=')[1]);}});
|
||||||
|
assert.deepEqual(delays,[5,10]);assert.equal(job.offlineReady,true);assert.ok(await c.match('/index.html?v=b'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlapping layout and completion jobs share a six-download ceiling',async()=>{
|
||||||
|
const c=cache(),m=manifest();m.groups.extra={background:true,files:[]};
|
||||||
|
for(let i=0;i<15;i++){const p='/extra'+i+'.js';m.files[p]={h:String(i)};m.groups.extra.files.push(p);}
|
||||||
|
let active=0,peak=0;const calls=new Map();
|
||||||
|
const fetchFn=async u=>{active++;peak=Math.max(active,peak);calls.set(u,(calls.get(u)||0)+1);await new Promise(r=>setTimeout(r,3));active--;return response(u.split('=')[1]);};
|
||||||
|
await Promise.all([core.complete(m,{cache:c,fetchFn}),core.syncAssets(m,{cache:c,fetchFn,groups:['extra']})]);
|
||||||
|
assert.ok(peak<=6);assert.ok([...calls.values()].every(count=>count===1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offline completion keeps its own network concurrency to one',async()=>{
|
||||||
|
const c=cache(),m=manifest();let active=0,peak=0;
|
||||||
|
for(let i=0;i<12;i++){const p='/complete'+i+'.js';m.files[p]={h:String(i)};}
|
||||||
|
const fetchFn=async u=>{active++;peak=Math.max(peak,active);await new Promise(r=>setTimeout(r,2));active--;return response(u.split('=')[1]);};
|
||||||
|
await core.complete(m,{cache:c,fetchFn});assert.equal(peak,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocking commit never waits on an optional file readiness probe',{timeout:1000},async()=>{
|
||||||
|
const c=cache(),m=manifest();m.files['/extra.js']={h:'extra'};m.groups.extra={files:['/extra.js'],background:true,contract:1};
|
||||||
|
await c.put('/a.js?v=a',response('a'));await c.put('/index.html?v=b',response('b'));
|
||||||
|
const match=c.match;let extraProbes=0;c.match=async key=>{if(key==='/extra.js?v=extra'){extraProbes++;return new Promise(()=>{});}return match(key);};
|
||||||
|
await core.commit(m,c);assert.equal(extraProbes,0);assert.equal((await core.state(c)).current.buildTag,m.buildTag);
|
||||||
|
c.match=match;assert.equal((await core.status(m,c)).offlineReady,false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('metadata probes release response streams while stored asset bodies remain readable',async()=>{
|
||||||
|
const c=cache(),m=manifest();await c.put('/a.js?v=a',response('a'));await c.put('/index.html?v=b',response('b'));
|
||||||
|
const match=c.match,probes=[];c.match=async key=>{const r=await match(key);if(r)probes.push(r);return r;};
|
||||||
|
assert.equal((await core.status(m,c)).offlineReady,true);assert.ok(probes.length>0);assert.ok(probes.every(r=>r.bodyUsed));
|
||||||
|
assert.equal(await(await match('/a.js?v=a')).text(),'a');assert.equal(await(await match('/index.html?v=b')).text(),'b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stale app core requires every feature contract to match the new shell',()=>{
|
||||||
|
const old={files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}};
|
||||||
|
const next=structuredClone(old);next.files['/app.js'].h='new';next.groups.extra.contract=2;
|
||||||
|
assert.equal(core.fallback(next,old,'/app.js'),null);next.groups.extra.contract=1;assert.equal(core.fallback(next,old,'/app.js'),'/app.js?v=old');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
const {test}=require('node:test');const assert=require('node:assert/strict');const vm=require('node:vm');const fs=require('node:fs');const core=require('./asset-sync-core');
|
const {test}=require('node:test');const assert=require('node:assert/strict');const vm=require('node:vm');const fs=require('node:fs');const core=require('./asset-sync-core');
|
||||||
function environment(){
|
function environment(){
|
||||||
const stores=new Map(),listeners={},messages=[],skips=[],fetches=[];const m={buildTag:'next',files:{'/index.html':{h:'index'},'/app.js':{h:'app'}},groups:{core:{files:['/index.html','/app.js']}}};
|
const stores=new Map(),listeners={},messages=[],skips=[],fetches=[],selfMessages=[];const m={buildTag:'next',files:{'/index.html':{h:'index'},'/app.js':{h:'app'}},groups:{core:{files:['/index.html','/app.js']}}};
|
||||||
const storage={async keys(){return [...stores.keys()]},async delete(n){return stores.delete(n)},async open(n){if(!stores.has(n)){const map=new Map();stores.set(n,{async match(k){return map.get(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)?.clone()},async put(k,r){map.set(k,r.clone())},async keys(){return [...map.keys()].map(k=>({url:'https://local'+k}))},async delete(k){return map.delete(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)}})}return stores.get(n)}};
|
const storage={async keys(){return [...stores.keys()]},async delete(n){return stores.delete(n)},async open(n){if(!stores.has(n)){const map=new Map();stores.set(n,{async match(k){return map.get(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)?.clone()},async put(k,r){map.set(k,r.clone())},async keys(){return [...map.keys()].map(k=>({url:'https://local'+k}))},async delete(k){return map.delete(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)}})}return stores.get(n)}};
|
||||||
const sandbox={__BUILD_TAG__:'next',__ASSET_SYNC__:true,AssetSyncCore:core,importScripts:()=>{},caches:storage,URL,Response,Request,Headers,setTimeout,clearTimeout,crypto:require('node:crypto').webcrypto,console,fetch:async u=>{fetches.push(u);return u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':m.files[u.split('?')[0]]?.h || ''}})},self:{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=>{fetches.push(u);return u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':m.files[u.split('?')[0]]?.h || ''}})},self:{registration:{active:{postMessage:data=>selfMessages.push(data)}},location:{origin:'https://local'},addEventListener:(t,f)=>listeners[t]=f,skipWaiting:()=>skips.push(1),clients:{claim:async()=>{},matchAll:async()=>[{id:'old',postMessage:x=>messages.push(x)}]}}};
|
||||||
vm.runInNewContext(fs.readFileSync(__dirname+'/sw.js','utf8'),sandbox);
|
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 dispatch(t,e={}){const tasks=[];listeners[t]({...e,waitUntil:v=>tasks.push(v)});await Promise.all(tasks)}
|
||||||
async function request(url,mode='cors',clientId='new'){let p;listeners.fetch({request:{url:'https://local'+url,method:'GET',mode},clientId,respondWith:v=>p=v});return p}
|
async function request(url,mode='cors',clientId='new'){let p;const tasks=[];listeners.fetch({request:{url:'https://local'+url,method:'GET',mode},clientId,waitUntil:v=>tasks.push(v),respondWith:v=>p=v});const response=await p;await Promise.all(tasks);return response;}
|
||||||
return {storage,m,skips,fetches,dispatch,request};
|
return {storage,m,skips,fetches,dispatch,request,sandbox,selfMessages,async complete(){for(const data of selfMessages.splice(0))await dispatch('message',{data});}};
|
||||||
}
|
}
|
||||||
test('install does not activate or publish; CACHE_STATUS is honest; activation commits and exact requests self-heal',async()=>{const e=environment();await e.dispatch('install');assert.equal(e.skips.length,0);const cache=await e.storage.open(core.CACHE);assert.equal(await core.state(cache),null);let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);await e.dispatch('activate');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('install does not activate or publish; CACHE_STATUS is honest; activation commits and exact requests self-heal',async()=>{const e=environment();await e.dispatch('install');assert.equal(e.skips.length,0);const cache=await e.storage.open(core.CACHE);assert.equal(await core.state(cache),null);let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);await e.dispatch('activate');await e.complete();assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await e.request('/playlist/x','navigate')).headers.get('X-Asset-Hash'),'index');await cache.delete('/app.js?v=app');assert.equal((await core.status(e.m,cache)).ready,false);await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal((await core.status(e.m,cache)).offlineReady,true);assert.equal(reply.version,'next');assert.equal((await e.request('/app.js')).headers.get('X-Asset-Hash'),'app');assert.ok(await cache.match('/app.js?v=app'));assert.equal(await cache.match('/app.js?v=stale'),undefined);assert.equal((await e.request('/app.js?v=stale')).status,409);});
|
||||||
test('reported playback defers explicit activation until pause; install never calls skipWaiting',async()=>{const e=environment(),source={id:'p'};await e.dispatch('message',{data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:true}});await e.dispatch('message',{source,data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:false}});assert.equal(e.skips.length,1);});
|
test('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()=>{
|
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 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}}};
|
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'};
|
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 cache.put(core.STATE,Response.json({current:old})); await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old','Cache-Control':'public, max-age=31536000, immutable','Content-Encoding':'gzip','Content-Length':'9'}}));
|
||||||
await e.dispatch('install'); await e.dispatch('activate');
|
await e.dispatch('install'); await e.dispatch('activate');await e.complete();
|
||||||
|
assert.ok(await cache.match('/extra.js?v=new'));await cache.delete('/extra.js?v=new');
|
||||||
assert.equal(await cache.match('/extra.js?v=new'),undefined);
|
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);
|
const stale=await e.request('/extra.js?v=new'); assert.equal(await stale.text(),'old body');assert.equal(stale.headers.get('X-Asset-Hash'),'old');assert.equal(stale.headers.get('Cache-Control'),'no-store');assert.equal(stale.headers.get('Content-Encoding'),null);assert.equal(stale.headers.get('Content-Length'),null);assert.equal(await cache.match('/extra.js?v=new'),undefined);
|
||||||
await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:true}}); assert.equal(await cache.match('/extra.js?v=new'),undefined);
|
await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:true}}); assert.ok(await cache.match('/extra.js?v=new'));
|
||||||
await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}); assert.equal((await cache.match('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
|
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');
|
assert.equal((await e.request('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
|
||||||
});
|
});
|
||||||
@@ -32,13 +33,70 @@ test('a changed background contract is downloaded before ready and cannot fall b
|
|||||||
await e.dispatch('install');assert.equal((await core.state(cache)).current.buildTag,'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');
|
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);
|
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');
|
await e.dispatch('activate');await e.complete();await cache.delete('/extra.js?v=new');
|
||||||
const response=await e.request('/extra.js?v=new');assert.equal(response.headers.get('X-Asset-Hash'),'new');assert.notEqual(await response.text(),'old body');
|
const response=await e.request('/extra.js?v=new');assert.equal(response.headers.get('X-Asset-Hash'),'new');assert.notEqual(await response.text(),'old body');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('overlapping idle warm messages share one download pool',async()=>{
|
test('overlapping idle warm messages share one download pool',async()=>{
|
||||||
const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true};
|
const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true};
|
||||||
await e.dispatch('install');await e.dispatch('activate');
|
await e.dispatch('install');await e.dispatch('activate');await e.complete();
|
||||||
await Promise.all([e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}),e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}})]);
|
await Promise.all([e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}),e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}})]);
|
||||||
assert.equal(e.fetches.filter(url=>url==='/extra.js?v=extra').length,1);
|
assert.equal(e.fetches.filter(url=>url==='/extra.js?v=extra').length,1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('waiting status does not start completion and activation delegates it to its own event',async()=>{
|
||||||
|
const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true};
|
||||||
|
await e.dispatch('install');let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});
|
||||||
|
assert.equal(reply.ready,true);assert.equal(reply.offlineReady,false);assert.ok(!e.fetches.includes('/extra.js?v=extra'));
|
||||||
|
let release,started;const gate=new Promise(r=>release=r),begin=new Promise(r=>started=r),fetch=e.sandbox.fetch;
|
||||||
|
e.sandbox.fetch=async url=>{if(url==='/extra.js?v=extra'){started();await gate;}return fetch(url);};
|
||||||
|
await e.dispatch('activate');const cache=await e.storage.open(core.CACHE);
|
||||||
|
assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await core.status(e.m,cache)).ready,true);
|
||||||
|
assert.equal((await core.status(e.m,cache)).offlineReady,false);assert.equal(e.selfMessages[0].type,'COMPLETE_ASSETS');
|
||||||
|
const job=e.complete();await begin;assert.equal(await cache.match('/extra.js?v=extra'),undefined);release();await job;
|
||||||
|
assert.equal((await core.status(e.m,cache)).offlineReady,true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('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'}}));
|
||||||
|
await e.dispatch('install');assert.equal(e.fetches.includes('/app.js?v=app'),false);
|
||||||
|
assert.equal(await(await cache.match('/app.js?v=app')).text(),'page captured app');
|
||||||
|
assert.equal(e.skips.length,0);assert.equal(await core.state(cache),null);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('worker never supplies stale app to a native script when a feature contract changed',async()=>{
|
||||||
|
const e=environment(),cache=await e.storage.open(core.CACHE);
|
||||||
|
const old={buildTag:'old',files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}};
|
||||||
|
e.m.groups.core.contract=1;e.m.groups.extra={contract:2,background:true,files:[]};
|
||||||
|
await cache.put(core.STATE,Response.json({current:old}));await cache.put('/app.js?v=old',new Response('old app',{headers:{'X-Asset-Hash':'old'}}));
|
||||||
|
await e.dispatch('install');await e.dispatch('activate');await cache.delete('/app.js?v=app');
|
||||||
|
e.sandbox.fetch=async()=>{throw Error('offline')};
|
||||||
|
const response=await e.request('/app.js?v=app');assert.equal(response.status,503);assert.equal(await response.text(),'Offline');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"groups": {
|
"groups": {
|
||||||
"core": {
|
"core": {
|
||||||
"contract": 1,
|
"contract": 2,
|
||||||
"eager": true,
|
"eager": true,
|
||||||
"files": [
|
"files": [
|
||||||
"/index.html",
|
"/index.html",
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"/related-videos.js",
|
"/related-videos.js",
|
||||||
"/related-videos.css",
|
"/related-videos.css",
|
||||||
"/audio-continuity.js",
|
"/audio-continuity.js",
|
||||||
|
"/audio-graph.js",
|
||||||
"/fullscreen-orientation.js",
|
"/fullscreen-orientation.js",
|
||||||
"/fullscreen-orientation.css",
|
"/fullscreen-orientation.css",
|
||||||
"/download-actions.js",
|
"/download-actions.js",
|
||||||
@@ -359,6 +360,27 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"feature:stats"
|
"feature:stats"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"feature:jamesdsp": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"background": true,
|
||||||
|
"offlineOptIn": "jamesDsp",
|
||||||
|
"files": [
|
||||||
|
"/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"
|
||||||
|
],
|
||||||
|
"dependencies": [
|
||||||
|
"feature:eq-core",
|
||||||
|
"feature:vocal-reducer"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,68 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const AsyncGuard = { runExclusive };
|
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
async function retry(task, { attempts = 3, delay = attempt => attempt === 1 ? 350 : 1000,
|
||||||
|
shouldRetry = () => true, sleep = wait } = {}) {
|
||||||
|
let last;
|
||||||
|
for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
|
||||||
|
try { return await task(attempt); }
|
||||||
|
catch (error) {
|
||||||
|
last = error;
|
||||||
|
if (attempt >= attempts || !shouldRetry(error, attempt)) throw error;
|
||||||
|
await sleep(delay(attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw last;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry only idempotent GETs. Keep the timeout active through JSON parsing so
|
||||||
|
// a stalled response body is treated like a stalled connection.
|
||||||
|
async function getJson(url, options = {}, config = {}) {
|
||||||
|
const fetchFn = config.fetch || root.fetch?.bind(root);
|
||||||
|
if (!fetchFn) throw new Error('fetch is unavailable');
|
||||||
|
if ((options.method || 'GET').toUpperCase() !== 'GET') throw new Error('Retry helper only accepts GET');
|
||||||
|
const attempts = config.attempts ?? 3, timeoutMs = config.timeoutMs ?? 20000;
|
||||||
|
return retry(async () => {
|
||||||
|
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||||
|
let timedOut = false, timer, externalAbort;
|
||||||
|
const external = options.signal;
|
||||||
|
if (controller && external) {
|
||||||
|
if (external.aborted) throw external.reason || new DOMException('Aborted', 'AbortError');
|
||||||
|
externalAbort = () => controller.abort(external.reason);
|
||||||
|
external.addEventListener('abort', externalAbort, { once: true });
|
||||||
|
}
|
||||||
|
if (controller && timeoutMs > 0) timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetchFn(url, { ...options, ...(controller ? { signal: controller.signal } : {}) });
|
||||||
|
if ([408, 425, 429].includes(response.status) || response.status >= 500) {
|
||||||
|
const error = new Error(`HTTP ${response.status}`); error.retryable = true; throw error;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `HTTP ${response.status}`;
|
||||||
|
try { message = (await response.json()).error || message; } catch {}
|
||||||
|
const error = new Error(message); error.retryable = false; throw error;
|
||||||
|
}
|
||||||
|
const value = await response.json();
|
||||||
|
if (config.retryResult?.(value)) {
|
||||||
|
const error = new Error(value.error || value.message || 'Temporary API failure');
|
||||||
|
error.retryable = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
if (external?.aborted) throw error;
|
||||||
|
if (timedOut) { const timeout = new Error('Request timed out'); timeout.retryable = true; throw timeout; }
|
||||||
|
if (error.retryable === undefined) error.retryable = true;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (external && externalAbort) external.removeEventListener('abort', externalAbort);
|
||||||
|
}
|
||||||
|
}, { attempts, shouldRetry: error => error.retryable !== false, sleep: config.sleep || wait });
|
||||||
|
}
|
||||||
|
|
||||||
|
const AsyncGuard = { runExclusive, retry, getJson };
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
module.exports = AsyncGuard;
|
module.exports = AsyncGuard;
|
||||||
|
|||||||
@@ -60,6 +60,55 @@ test('frees the key after failure so a retry is possible', async () => {
|
|||||||
assert.strictEqual(out, 'ok');
|
assert.strictEqual(out, 'ok');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('bounded GET retries recover transient network and server failures', async () => {
|
||||||
|
const { getJson } = require('./async-guard');
|
||||||
|
let calls = 0;
|
||||||
|
const result = await getJson('/api/search', {}, { sleep: async () => {}, fetch: async () => {
|
||||||
|
calls++;
|
||||||
|
if (calls === 1) throw new TypeError('connection reset');
|
||||||
|
if (calls === 2) return new Response('{}', { status: 503 });
|
||||||
|
return Response.json({ ok: true, results: [] });
|
||||||
|
} });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(calls, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET retry stops at the bound and does not retry permanent HTTP errors', async () => {
|
||||||
|
const { getJson } = require('./async-guard');
|
||||||
|
let calls = 0;
|
||||||
|
await assert.rejects(getJson('/api/search', {}, { sleep: async () => {}, fetch: async () => {
|
||||||
|
calls++; return new Response('{}', { status: 404 });
|
||||||
|
} }), /HTTP 404/);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
await assert.rejects(getJson('/api/search', {}, { attempts: 2, sleep: async () => {}, fetch: async () => {
|
||||||
|
calls++; throw new TypeError('offline');
|
||||||
|
} }), /offline/);
|
||||||
|
assert.equal(calls, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retryable API error payloads recover but permanent application errors do not retry', async () => {
|
||||||
|
const { getJson } = require('./async-guard'); let calls = 0;
|
||||||
|
const retryResult = value => value?.ok === false && /temporarily unavailable/i.test(value.error || '');
|
||||||
|
const result = await getJson('/api/streams', {}, { retryResult, sleep: async () => {}, fetch: async () => {
|
||||||
|
calls++;
|
||||||
|
return Response.json(calls === 1 ? { ok: false, error: 'temporarily unavailable' } : { ok: true, data: [] });
|
||||||
|
} });
|
||||||
|
assert.equal(result.ok, true); assert.equal(calls, 2);
|
||||||
|
calls = 0;
|
||||||
|
const absent = await getJson('/api/streams', {}, { retryResult, sleep: async () => {}, fetch: async () => { calls++; return Response.json({ ok: false, error: 'video not found' }); } });
|
||||||
|
assert.equal(absent.ok, false); assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timed out GET bodies retry within the configured bound', async () => {
|
||||||
|
const { getJson } = require('./async-guard'); let calls = 0;
|
||||||
|
const result = await getJson('/api/related', {}, { attempts: 2, timeoutMs: 5, sleep: async () => {}, fetch: async (_url, { signal }) => {
|
||||||
|
calls++;
|
||||||
|
if (calls === 1) return new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true }));
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
} });
|
||||||
|
assert.equal(result.ok, true); assert.equal(calls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
test('supports a synchronous fn', async () => {
|
test('supports a synchronous fn', async () => {
|
||||||
const set = new Set();
|
const set = new Set();
|
||||||
const out = await runExclusive(set, 's', () => 7);
|
const out = await runExclusive(set, 's', () => 7);
|
||||||
|
|||||||
69
frontend/audio-graph.js
Normal file
69
frontend/audio-graph.js
Normal file
@@ -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);
|
||||||
75
frontend/audio-graph.test.js
Normal file
75
frontend/audio-graph.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
65
frontend/autosave.test.js
Normal file
65
frontend/autosave.test.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
// Exercise the actual app functions without DOM/player/network dependencies.
|
||||||
|
const source = fs.readFileSync(require('node:path').join(__dirname, 'app.js'), 'utf8');
|
||||||
|
function harness({ autoPreload = true, cached = [], result = { ok: true, cached: true }, error } = {}) {
|
||||||
|
const calls = [], jobs = new Map();
|
||||||
|
const video = { id: 'benchmedia01', title: 'Local clip' };
|
||||||
|
const context = vm.createContext({
|
||||||
|
data: { settings: { autoPreload }, playlists: [{ id: 'pl', videos: [video] }], pinnedPlaylists: [] },
|
||||||
|
cachedIds: new Set(cached), downloading: new Set(), downloadMeta: new Map(), saveControllers: new Map(),
|
||||||
|
view: { type: 'home' }, current: null, window: {}, AbortController,
|
||||||
|
slim: v => v, markCardCacheState() {}, renderSidebar() {}, updateDownloadBadge() {}, toast() {},
|
||||||
|
SaveSlots: { acquire: async () => {}, release() {} },
|
||||||
|
SaveQueue: { add: v => jobs.set(v.id, { status: 'queued' }), remove: id => jobs.delete(id), progress() {}, state: (id, status, message) => jobs.set(id, { status, message }) },
|
||||||
|
API: { cacheDownload: async id => { calls.push(id); if (error) throw error; return result; } },
|
||||||
|
recordDeviceFile() {}, warmThumb() {}, thumbUrlFor: () => '', cacheMutations: 0,
|
||||||
|
});
|
||||||
|
const preload = source.slice(source.indexOf('async function preload(video,'), source.indexOf('// A device copy that failed to play.'));
|
||||||
|
const playlists = source.slice(source.indexOf('function preloadPlaylist(pl)'), source.indexOf('// Update a single card\'s saved badge'));
|
||||||
|
vm.runInContext(preload + playlists, context);
|
||||||
|
return { context, calls, jobs, video };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('auto preload tops up playlists, while pinned playlists save with the setting off', async () => {
|
||||||
|
const h = harness({ autoPreload: false });
|
||||||
|
h.context.preloadPlaylist(h.context.data.playlists[0]);
|
||||||
|
await new Promise(r => setImmediate(r));
|
||||||
|
assert.deepEqual(h.calls, []);
|
||||||
|
h.context.data.pinnedPlaylists = ['pl'];
|
||||||
|
h.context.preloadPinnedPlaylists();
|
||||||
|
await new Promise(r => setImmediate(r));
|
||||||
|
assert.deepEqual(h.calls, [h.video.id]);
|
||||||
|
assert.ok(h.context.cachedIds.has(h.video.id));
|
||||||
|
assert.equal(h.jobs.size, 0);
|
||||||
|
assert.equal(h.context.downloading.size, 0);
|
||||||
|
const enabled = harness();
|
||||||
|
enabled.context.preloadPlaylist(enabled.context.data.playlists[0]);
|
||||||
|
await new Promise(r => setImmediate(r));
|
||||||
|
assert.deepEqual(enabled.calls, [enabled.video.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a repeated save shares the in-flight operation and does not download an existing copy', async () => {
|
||||||
|
const h = harness();
|
||||||
|
await Promise.all([h.context.preload(h.video), h.context.preload(h.video)]);
|
||||||
|
await h.context.preload(h.video);
|
||||||
|
assert.deepEqual(h.calls, [h.video.id]);
|
||||||
|
assert.ok(h.context.cachedIds.has(h.video.id));
|
||||||
|
assert.equal(h.context.saveControllers.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed and interrupted saves do not acquire a Saved badge and can be attempted again', async () => {
|
||||||
|
for (const options of [{ result: { ok: false, error: 'server failed' } }, { error: new Error('storage quota') }, { result: { ok: false, paused: true } }]) {
|
||||||
|
const h = harness(options);
|
||||||
|
await h.context.preload(h.video, { quiet: true });
|
||||||
|
assert.equal(h.context.cachedIds.has(h.video.id), false);
|
||||||
|
assert.equal(h.context.downloading.size, 0);
|
||||||
|
assert.equal(h.context.saveControllers.size, 0);
|
||||||
|
assert.ok(h.jobs.has(h.video.id));
|
||||||
|
await h.context.preload(h.video, { quiet: true });
|
||||||
|
assert.equal(h.calls.length, 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,4 +4,3 @@
|
|||||||
.car-controls button { min-height: 110px; font: 650 clamp(22px, 3vw, 36px)/1.2 system-ui; background: #fff; color: #050607; border: 3px solid #fff; border-radius: 16px; }
|
.car-controls button { min-height: 110px; font: 650 clamp(22px, 3vw, 36px)/1.2 system-ui; background: #fff; color: #050607; border: 3px solid #fff; border-radius: 16px; }
|
||||||
.car-exit { align-self: flex-start; min-height: 52px; padding: 12px 20px; background: #070809; color: #fff; border: 2px solid #fff; border-radius: 8px; }
|
.car-exit { align-self: flex-start; min-height: 52px; padding: 12px 20px; background: #070809; color: #fff; border: 2px solid #fff; border-radius: 8px; }
|
||||||
.car-mode button:focus-visible { outline: 4px solid #61caff; outline-offset: 4px; }
|
.car-mode button:focus-visible { outline: 4px solid #61caff; outline-offset: 4px; }
|
||||||
.voice-search { flex-shrink: 0; min-width: 44px; min-height: 44px; }
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
overlay = doc.createElement('section'); overlay.className = 'car-mode'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', 'Car mode');
|
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 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;
|
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.createElement('button'); b.type = 'button'; b.innerHTML = root.TransportIcons.svg({prev:'previous',toggle:'play',back:'back15',next:'next'}[action]) + '<span>' + label + '</span>'; 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();
|
const update = () => { const state = adapter.state(); title.textContent = state.title || 'Choose a song before entering Car mode'; root.TransportIcons.set(toggle, state.paused ? 'play' : 'pause'); toggle.insertAdjacentHTML('beforeend','<span>' + (state.paused ? 'Play' : 'Pause') + '</span>'); }; update();
|
||||||
overlay.append(exit, title, controls); doc.body.append(overlay); timer = setInterval(update, 500); exit.focus();
|
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(); };
|
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(); };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ test('lazy Car mode keeps four independent transport buttons and the voice entry
|
|||||||
after(node){this.parent.append(node);}
|
after(node){this.parent.append(node);}
|
||||||
setAttribute(name,value){this[name]=value;}
|
setAttribute(name,value){this[name]=value;}
|
||||||
focus(){}
|
focus(){}
|
||||||
|
insertAdjacentHTML(position,html){this.innerHTML+=html;}
|
||||||
get isConnected(){return !!this.parent;}
|
get isConnected(){return !!this.parent;}
|
||||||
}
|
}
|
||||||
const body=new Element('body'),input=new Element('input'),form=new Element('form'),voice=new Element('button');form.id='searchForm';voice.id='voiceSearchBtn';body.append(form,input,voice);
|
const body=new Element('body'),input=new Element('input'),form=new Element('form'),voice=new Element('button');form.id='searchForm';voice.id='voiceSearchBtn';body.append(form,input,voice);
|
||||||
const all=node=>[node,...node.children.flatMap(all)],doc={body,documentElement:{lang:'en'},createElement:tag=>new Element(tag),getElementById:id=>all(body).find(n=>n.id===id)};
|
const all=node=>[node,...node.children.flatMap(all)],doc={body,documentElement:{lang:'en'},createElement:tag=>new Element(tag),getElementById:id=>all(body).find(n=>n.id===id)};
|
||||||
let section;const actions=[],root={document:doc,navigator:{},SpeechRecognition:function(){},SettingsSections:{register:s=>section=s}};
|
let section;const actions=[],root={document:doc,TransportIcons:require('./download-actions').transport,navigator:{},SpeechRecognition:function(){},SettingsSections:{register:s=>section=s}};
|
||||||
vm.runInNewContext(fs.readFileSync(__dirname+'/car-mode.js','utf8'),{window:root,setInterval:()=>1,clearInterval:()=>{}});
|
vm.runInNewContext(fs.readFileSync(__dirname+'/car-mode.js','utf8'),{window:root,setInterval:()=>1,clearInterval:()=>{}});
|
||||||
root.CarMode.configure({input,action:action=>actions.push(action),state:()=>({paused:true,title:'Song'})});
|
root.CarMode.configure({input,action:action=>actions.push(action),state:()=>({paused:true,title:'Song'})});
|
||||||
root.CarMode.open();const overlay=body.children.find(n=>n.className==='car-mode'),controls=overlay.children.find(n=>n.className==='car-controls');
|
root.CarMode.open();const overlay=body.children.find(n=>n.className==='car-mode'),controls=overlay.children.find(n=>n.className==='car-controls');
|
||||||
|
|||||||
@@ -9,3 +9,30 @@ html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action { bo
|
|||||||
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:hover { border-color:var(--glass-line); background:var(--tile-hi); color:var(--text); }
|
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:hover { border-color:var(--glass-line); background:var(--tile-hi); color:var(--text); }
|
||||||
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action.danger { color:var(--accent-bright); }
|
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action.danger { color:var(--accent-bright); }
|
||||||
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:focus-visible { outline-color:var(--accent-bright); }
|
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:focus-visible { outline-color:var(--accent-bright); }
|
||||||
|
|
||||||
|
/* Transport drawings and touch targets survive layout/state changes. */
|
||||||
|
.transport-icon { display:inline-block; flex:none; width:24px; height:24px; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; }
|
||||||
|
.transport-shape { display:none; }
|
||||||
|
.transport-shape-classic { display:inline; }
|
||||||
|
html:is([data-layout="f7-swipe"],[data-layout="glass-stage"],[data-layout="bento-hub"]) .transport-shape-classic { display:none; }
|
||||||
|
html[data-layout="f7-swipe"] .transport-shape-f7-swipe,
|
||||||
|
html[data-layout="glass-stage"] .transport-shape-glass-stage,
|
||||||
|
html[data-layout="bento-hub"] .transport-shape-bento-hub { display:inline; }
|
||||||
|
html[data-layout="f7-swipe"] .transport-shape-f7-swipe { fill:currentColor; stroke-width:.7; }
|
||||||
|
html[data-layout="f7-swipe"] .transport-icon:is([data-transport-icon="play"],[data-transport-icon="pause"],[data-transport-icon="previous"],[data-transport-icon="next"],[data-transport-icon="forward"]),
|
||||||
|
html[data-layout="f7-swipe"] #controls .transport-icon:is([data-transport-icon="play"],[data-transport-icon="pause"],[data-transport-icon="previous"],[data-transport-icon="next"],[data-transport-icon="forward"]) { fill:currentColor; stroke-width:.7; }
|
||||||
|
html[data-layout="f7-swipe"] :is([data-transport-icon="back10"],[data-transport-icon="forward10"],[data-transport-icon="back15"]) .transport-shape { fill:none; stroke-width:2; }
|
||||||
|
html[data-layout="glass-stage"] .transport-icon,
|
||||||
|
html[data-layout="glass-stage"] #controls .transport-icon { stroke-width:1.5; }
|
||||||
|
html[data-layout="bento-hub"] .transport-icon { stroke-width:2; stroke-linejoin:round; }
|
||||||
|
button:has(> .transport-icon), a.np-btn:has(> .transport-icon) { min-width:44px; min-height:44px; }
|
||||||
|
button:has(> .transport-icon) { align-items:center; justify-content:center; }
|
||||||
|
html[data-layout="f7-swipe"] :is(#playBtn,#stgPlay,.service-btn-play,.rv-play) { border-radius:50%; }
|
||||||
|
html[data-layout="f7-swipe"] #playBtn { width:64px; height:64px; }
|
||||||
|
html[data-layout="glass-stage"] :is(#playBtn,.mini-btn,.rv-tbtn):has(> .transport-icon) { background:var(--tile,rgba(255,255,255,.08)); border:1px solid var(--line); backdrop-filter:blur(12px); }
|
||||||
|
html[data-layout="bento-hub"] :is(#playBtn,.mini-btn,.service-btn,.rv-tbtn):has(> .transport-icon) { border-radius:16px; }
|
||||||
|
.car-controls .transport-icon { width:36px; height:36px; margin-right:12px; }
|
||||||
|
.gh-seek .transport-icon, .empty-icon .transport-icon { width:48px; height:48px; }
|
||||||
|
.ph-logo .transport-icon { width:60%; height:60%; }
|
||||||
|
|
||||||
|
.home-pl-play:has(> .transport-icon) { min-width:44px; min-height:44px; }
|
||||||
|
|||||||
@@ -1,6 +1,83 @@
|
|||||||
/* One action control for the Downloads page and Settings download manager. */
|
/* One action control for the Downloads page and Settings download manager. */
|
||||||
(function(root) {
|
(function(root) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
// One SVG definition per media concept; every instance inherits its control colour.
|
||||||
|
const mediaPaths = {
|
||||||
|
download: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>',
|
||||||
|
offline: '<circle cx="12" cy="12" r="9"/><path d="m7.5 12 3 3 6-6"/>',
|
||||||
|
progress: '<path d="M12 3a9 9 0 1 1-9 9"/>'
|
||||||
|
};
|
||||||
|
const variants = {
|
||||||
|
'classic': mediaPaths,
|
||||||
|
'f7-swipe': {
|
||||||
|
download: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/>',
|
||||||
|
offline: '<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',
|
||||||
|
progress: '<path d="M12 3a9 9 0 1 1-9 9"/>'
|
||||||
|
},
|
||||||
|
'glass-stage': {
|
||||||
|
download: '<path d="M12 4v11m-4-4 4 4 4-4M5 20h14"/>',
|
||||||
|
offline: '<circle cx="12" cy="12" r="8"/><path d="m8.5 12 2.5 2.5 4.5-5"/>',
|
||||||
|
progress: '<path d="M12 4a8 8 0 1 1-8 8"/>'
|
||||||
|
},
|
||||||
|
'bento-hub': {
|
||||||
|
download: '<path d="M12 4v11m-4-4 4 4 4-4M5 17v3h14v-3"/>',
|
||||||
|
offline: '<rect x="4" y="4" width="16" height="16" rx="4"/><path d="m8 12 3 3 5-6"/>',
|
||||||
|
progress: '<path d="M12 4a8 8 0 1 1-8 8"/>'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const use = kind => `<use href="#media-${kind}"/>`;
|
||||||
|
const svg = (kind, extra = '') => `<svg class="media-icon ${extra}" data-media-icon="${kind}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${use(kind)}</svg>`;
|
||||||
|
const media = { paths: mediaPaths, use, svg };
|
||||||
|
if (root.document) {
|
||||||
|
const defs = root.document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
defs.setAttribute('aria-hidden', 'true'); defs.setAttribute('width', '0'); defs.setAttribute('height', '0');
|
||||||
|
defs.style.position = 'absolute'; defs.style.pointerEvents = 'none';
|
||||||
|
defs.innerHTML = '<defs>' + Object.keys(mediaPaths).map(kind => `<g id="media-${kind}">${Object.entries(variants).map(([layout, paths]) => `<g class="media-shape media-shape-${layout}">${paths[kind]}</g>`).join('')}</g>`).join('') + '</defs>';
|
||||||
|
root.document.body.appendChild(defs);
|
||||||
|
root.MediaIcons = media;
|
||||||
|
}
|
||||||
|
// Select variants on visible use elements: ancestor CSS in referenced SVG groups
|
||||||
|
// can report changed styles without repainting the cloned geometry in Chromium.
|
||||||
|
const transportPaths = {
|
||||||
|
play: '<path d="m8 5 11 7-11 7z"/>',
|
||||||
|
pause: '<rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/>',
|
||||||
|
previous: '<path d="m19 5-10 7 10 7zM5 5v14"/>',
|
||||||
|
next: '<path d="m5 5 10 7-10 7zM19 5v14"/>',
|
||||||
|
forward: '<path d="m3 5 9 7-9 7zM12 5l9 7-9 7z"/>'
|
||||||
|
};
|
||||||
|
for (const seconds of [10,15]) for (const direction of ['back','forward']) {
|
||||||
|
transportPaths[direction + seconds] = (direction === 'back'
|
||||||
|
? '<path d="M4 12a8 8 0 1 0 3-6.2M4 4v4.5h4.5"/>'
|
||||||
|
: '<path d="M20 12a8 8 0 1 1-3-6.2M20 4v4.5h-4.5"/>') +
|
||||||
|
`<text x="12" y="15.5" text-anchor="middle" font-size="7.5" font-weight="700" fill="currentColor" stroke="none">${seconds}</text>`;
|
||||||
|
}
|
||||||
|
const transportVariants = Object.fromEntries(['classic','f7-swipe','glass-stage','bento-hub'].map(layout => [layout,{...transportPaths}]));
|
||||||
|
Object.assign(transportVariants['f7-swipe'], {
|
||||||
|
play: '<path d="M8 5.8a1 1 0 0 1 1.5-.86l10 6.2a1 1 0 0 1 0 1.72l-10 6.2A1 1 0 0 1 8 18.2z"/>',
|
||||||
|
previous: '<rect x="4" y="5" width="3" height="14" rx="1.5"/><path d="M19 6a1 1 0 0 0-1.5-.86l-8.5 6a1 1 0 0 0 0 1.72l8.5 6A1 1 0 0 0 19 18z"/>',
|
||||||
|
next: '<rect x="17" y="5" width="3" height="14" rx="1.5"/><path d="M5 6a1 1 0 0 1 1.5-.86l8.5 6a1 1 0 0 1 0 1.72l-8.5 6A1 1 0 0 1 5 18z"/>'
|
||||||
|
});
|
||||||
|
const transport = {
|
||||||
|
paths: transportPaths, variants: transportVariants,
|
||||||
|
svg(kind) {
|
||||||
|
if (!transportPaths[kind]) throw new Error('Unknown transport icon: ' + kind);
|
||||||
|
return `<svg class="transport-icon" data-transport-icon="${kind}" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">${Object.keys(transportVariants).map(layout=>`<use class="transport-shape transport-shape-${layout}" href="#transport-${kind}${layout==='classic'?'':'-'+layout}"/>`).join('')}</svg>`;
|
||||||
|
},
|
||||||
|
set(button, kind, label) {
|
||||||
|
if (!button) return;
|
||||||
|
const iconId=button.querySelector?.('.transport-icon')?.id;
|
||||||
|
button.innerHTML = this.svg(kind);
|
||||||
|
if(iconId)button.querySelector('.transport-icon').id=iconId;
|
||||||
|
button.setAttribute('aria-label', label || (kind === 'pause' ? 'Pause' : 'Play'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (root.document) {
|
||||||
|
const defs = root.document.createElementNS('http://www.w3.org/2000/svg','svg');
|
||||||
|
defs.setAttribute('aria-hidden','true'); defs.setAttribute('width','0'); defs.setAttribute('height','0');
|
||||||
|
defs.style.position='absolute'; defs.style.pointerEvents='none';
|
||||||
|
defs.innerHTML='<defs>'+Object.keys(transportPaths).flatMap(kind=>Object.entries(transportVariants).map(([layout,paths])=>`<g id="transport-${kind}${layout==='classic'?'':'-'+layout}">${paths[kind]}</g>`)).join('')+'</defs>';
|
||||||
|
root.document.body.appendChild(defs); root.TransportIcons=transport;
|
||||||
|
}
|
||||||
const paths = {
|
const paths = {
|
||||||
retry: '<path d="M3 11a9 9 0 1 1 2.6 7M3 3v8h8"/>',
|
retry: '<path d="M3 11a9 9 0 1 1 2.6 7M3 3v8h8"/>',
|
||||||
cancel: '<path d="m6 6 12 12M6 18 18 6"/>',
|
cancel: '<path d="m6 6 12 12M6 18 18 6"/>',
|
||||||
@@ -19,6 +96,6 @@
|
|||||||
const text = doc.createElement('span'); text.textContent = label;
|
const text = doc.createElement('span'); text.textContent = label;
|
||||||
button.append(icon, text); return button;
|
button.append(icon, text); return button;
|
||||||
}
|
}
|
||||||
const api = { create };
|
const api = { create, media, transport };
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.DownloadActions = api;
|
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.DownloadActions = api;
|
||||||
})(globalThis);
|
})(globalThis);
|
||||||
|
|||||||
@@ -119,11 +119,7 @@
|
|||||||
empty.className = 'empty-state dl-empty-state';
|
empty.className = 'empty-state dl-empty-state';
|
||||||
empty.innerHTML = `
|
empty.innerHTML = `
|
||||||
<div class="empty-icon dl-empty-icon-wrapper" aria-hidden="true">
|
<div class="empty-icon dl-empty-icon-wrapper" aria-hidden="true">
|
||||||
<svg class="dl-empty-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="dl-empty-svg media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg>
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
|
||||||
<polyline points="7 10 12 15 17 10"></polyline>
|
|
||||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 class="empty-title">No active downloads</h3>
|
<h3 class="empty-title">No active downloads</h3>
|
||||||
<p class="empty-desc">Saves in progress appear here with live status. Videos you save land in <strong>Saved</strong>.</p>
|
<p class="empty-desc">Saves in progress appear here with live status. Videos you save land in <strong>Saved</strong>.</p>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
let adapter, total = 0;
|
let adapter, total = 0;
|
||||||
function configure(options) {
|
function configure(options) {
|
||||||
adapter = options;
|
adapter = options;
|
||||||
root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>', summary: () => `${adapter.slots()} at once · ${bytes(total)}`, render(container) {
|
root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: '<use href="#media-download"/>', summary: () => `${adapter.slots()} at once · ${bytes(total)}`, render(container) {
|
||||||
const doc = container.ownerDocument;
|
const doc = container.ownerDocument;
|
||||||
const heading = doc.createElement('h3'); heading.textContent = 'Downloads & storage';
|
const heading = doc.createElement('h3'); heading.textContent = 'Downloads & storage';
|
||||||
const jobs = doc.createElement('div'); jobs.className = 'download-manager'; jobs.setAttribute('aria-label', 'Download manager');
|
const jobs = doc.createElement('div'); jobs.className = 'download-manager'; jobs.setAttribute('aria-label', 'Download manager');
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
function lift() { if (home || stage.parentElement === document.body) return; home = { parent: stage.parentElement, next: stage.nextSibling }; document.body.append(stage); }
|
function lift() { if (home || stage.parentElement === document.body) return; home = { parent: stage.parentElement, next: stage.nextSibling }; document.body.append(stage); }
|
||||||
function unlift() { if (!home) return; const { parent, next } = home; home = null; if (next && next.parentNode === parent) parent.insertBefore(stage, next); else parent.append(stage); }
|
function unlift() { if (!home) return; const { parent, next } = home; home = null; if (next && next.parentNode === parent) parent.insertBefore(stage, next); else parent.append(stage); }
|
||||||
const hud = document.createElement('div'); hud.className = 'fullscreen-hud'; hud.hidden = true;
|
const hud = document.createElement('div'); hud.className = 'fullscreen-hud'; hud.hidden = true;
|
||||||
hud.innerHTML = '<div class="fullscreen-top"><div class="fullscreen-title" aria-live="polite"></div><button type="button" aria-label="Exit fullscreen" title="Exit fullscreen"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4H4v5m0-5 6 6m5 9h5v-5m0 5-6-6"/></svg></button></div><div class="fullscreen-controls"><button type="button" aria-label="Play or pause" title="Play or pause"><svg class="fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg></button><input type="range" min="0" max="1000" value="0" aria-label="Seek video"><button type="button" class="fullscreen-orientation" aria-label="Change fullscreen orientation" title="Change fullscreen orientation"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8V4h4M20 16v4h-4M4 4l6 6m4 4 6 6M20 8V4h-4M4 16v4h4M20 4l-6 6m-4 4-6 6"/></svg></button><button type="button" class="fullscreen-fit" aria-label="Change video fit" title="Change video fit"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7V4h3M17 4h3v3M20 17v3h-3M7 20H4v-3M7 9h10v6H7z"/></svg></button></div>';
|
hud.innerHTML = '<div class="fullscreen-top"><div class="fullscreen-title" aria-live="polite"></div><button type="button" aria-label="Exit fullscreen" title="Exit fullscreen"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4H4v5m0-5 6 6m5 9h5v-5m0 5-6-6"/></svg></button></div><div class="fullscreen-controls"><button type="button" aria-label="Play or pause" title="Play or pause">' + root.TransportIcons.svg('play') + '</button><input type="range" min="0" max="1000" value="0" aria-label="Seek video"><button type="button" class="fullscreen-orientation" aria-label="Change fullscreen orientation" title="Change fullscreen orientation"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8V4h4M20 16v4h-4M4 4l6 6m4 4 6 6M20 8V4h-4M4 16v4h4M20 4l-6 6m-4 4-6 6"/></svg></button><button type="button" class="fullscreen-fit" aria-label="Change video fit" title="Change video fit"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7V4h3M17 4h3v3M20 17v3h-3M7 20H4v-3M7 9h10v6H7z"/></svg></button></div>';
|
||||||
stage.append(hud);
|
stage.append(hud);
|
||||||
const play = hud.querySelector('button[aria-label="Play or pause"]'), orientation = hud.querySelector('.fullscreen-orientation'), fitButton = hud.querySelector('.fullscreen-fit'), exit = hud.querySelector('button[aria-label="Exit fullscreen"]'), range = hud.querySelector('input');
|
const play = hud.querySelector('button[aria-label="Play or pause"]'), orientation = hud.querySelector('.fullscreen-orientation'), fitButton = hud.querySelector('.fullscreen-fit'), exit = hud.querySelector('button[aria-label="Exit fullscreen"]'), range = hud.querySelector('input');
|
||||||
const title = hud.querySelector('.fullscreen-title');
|
const title = hud.querySelector('.fullscreen-title');
|
||||||
@@ -109,9 +109,7 @@
|
|||||||
range.style.setProperty('--seek-progress', `${s.duration ? Math.min(100, s.time / s.duration * 100) : 0}%`);
|
range.style.setProperty('--seek-progress', `${s.duration ? Math.min(100, s.time / s.duration * 100) : 0}%`);
|
||||||
play.setAttribute('aria-label', s.paused ? 'Play video' : 'Pause video');
|
play.setAttribute('aria-label', s.paused ? 'Play video' : 'Pause video');
|
||||||
play.title = s.paused ? 'Play' : 'Pause';
|
play.title = s.paused ? 'Play' : 'Pause';
|
||||||
play.innerHTML = s.paused
|
root.TransportIcons.set(play, s.paused ? 'play' : 'pause', s.paused ? 'Play video' : 'Pause video');
|
||||||
? '<svg class="fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>'
|
|
||||||
: '<svg class="fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5h4v14H6zm8 0h4v14h-4z"/></svg>';
|
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
document.addEventListener('fullscreenchange', () => { if (active && !document.fullscreenElement && native) cleanup(); });
|
document.addEventListener('fullscreenchange', () => { if (active && !document.fullscreenElement && native) cleanup(); });
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
music:'<path d="M9 18V5l11-2v13M9 8l11-2"/><ellipse cx="6" cy="18" rx="3" ry="2"/><ellipse cx="17" cy="16" rx="3" ry="2"/>',
|
music:'<path d="M9 18V5l11-2v13M9 8l11-2"/><ellipse cx="6" cy="18" rx="3" ry="2"/><ellipse cx="17" cy="16" rx="3" ry="2"/>',
|
||||||
add:'<path d="M12 5v14M5 12h14"/>', close:'<path d="m6 6 12 12M18 6 6 18"/>',
|
add:'<path d="M12 5v14M5 12h14"/>', close:'<path d="m6 6 12 12M18 6 6 18"/>',
|
||||||
search:'<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
|
search:'<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
|
||||||
save:'<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>',
|
save:'<use href="#media-download"/>',
|
||||||
play:'<path d="m8 5 11 7-11 7z"/>', pause:'<path d="M8 5v14M16 5v14"/>',
|
play:'<use class="transport-shape transport-shape-classic" href="#transport-play"/><use class="transport-shape transport-shape-f7-swipe" href="#transport-play-f7-swipe"/><use class="transport-shape transport-shape-glass-stage" href="#transport-play-glass-stage"/><use class="transport-shape transport-shape-bento-hub" href="#transport-play-bento-hub"/>', pause:'<use class="transport-shape transport-shape-classic" href="#transport-pause"/><use class="transport-shape transport-shape-f7-swipe" href="#transport-pause-f7-swipe"/><use class="transport-shape transport-shape-glass-stage" href="#transport-pause-glass-stage"/><use class="transport-shape transport-shape-bento-hub" href="#transport-pause-bento-hub"/>',
|
||||||
next:'<path d="m5 5 10 7-10 7zM19 5v14"/>', previous:'<path d="m19 5-10 7 10 7zM5 5v14"/>',
|
next:'<use class="transport-shape transport-shape-classic" href="#transport-next"/><use class="transport-shape transport-shape-f7-swipe" href="#transport-next-f7-swipe"/><use class="transport-shape transport-shape-glass-stage" href="#transport-next-glass-stage"/><use class="transport-shape transport-shape-bento-hub" href="#transport-next-bento-hub"/>', previous:'<use class="transport-shape transport-shape-classic" href="#transport-previous"/><use class="transport-shape transport-shape-f7-swipe" href="#transport-previous-f7-swipe"/><use class="transport-shape transport-shape-glass-stage" href="#transport-previous-glass-stage"/><use class="transport-shape transport-shape-bento-hub" href="#transport-previous-bento-hub"/>',
|
||||||
repeat:'<path d="m17 2 4 4-4 4M3 11V8a2 2 0 0 1 2-2h16M7 22l-4-4 4-4m14-1v3a2 2 0 0 1-2 2H3"/>',
|
repeat:'<path d="m17 2 4 4-4 4M3 11V8a2 2 0 0 1 2-2h16M7 22l-4-4 4-4m14-1v3a2 2 0 0 1-2 2H3"/>',
|
||||||
edit:'<path d="m14 5 5 5M4 20l5-1L21 7l-4-4L5 15z"/>',
|
edit:'<path d="m14 5 5 5M4 20l5-1L21 7l-4-4L5 15z"/>',
|
||||||
lyrics:'<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8"/>',
|
lyrics:'<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8"/>',
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
television:'<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>', pin:'<path d="M9 3h6l-1 6 4 4H6l4-4zM12 13v8"/>',
|
television:'<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>', pin:'<path d="M9 3h6l-1 6 4 4H6l4-4zM12 13v8"/>',
|
||||||
devices:'<rect x="3" y="4" width="14" height="12" rx="2"/><path d="M7 20h6M10 16v4"/><rect x="17" y="10" width="5" height="11" rx="1"/>'
|
devices:'<rect x="3" y="4" width="14" height="12" rx="2"/><path d="M7 20h6M10 16v4"/><rect x="17" y="10" width="5" height="11" rx="1"/>'
|
||||||
};
|
};
|
||||||
const glyphs = {'+':'add','✕':'close','×':'close','✖':'close','🔍':'search','🔎':'search','⬇':'save','⤓':'save','💾':'save','▶':'play','⏸':'pause','⏭':'next','⏮':'previous','↻':'repeat','↺':'repeat','✎':'edit','✏':'edit','📝':'edit','🎤':'lyrics','↗':'share','⧉':'copy','✓':'check','📁':'folder','⚙':'settings','📋':'chapters','🕘':'clock','🔀':'shuffle','📡':'devices','▾':'down','▸':'right','←':'back','⋯':'more','⋮':'more','🗑':'trash','🎵':'music'};
|
const glyphs = {'+':'add','✕':'close','×':'close','✖':'close','🔍':'search','🔎':'search','▶':'play','⏸':'pause','⏭':'next','⏮':'previous','↻':'repeat','↺':'repeat','✎':'edit','✏':'edit','📝':'edit','🎤':'lyrics','↗':'share','⧉':'copy','✓':'check','📁':'folder','⚙':'settings','📋':'chapters','🕘':'clock','🔀':'shuffle','📡':'devices','▾':'down','▸':'right','←':'back','⋯':'more','⋮':'more','🗑':'trash','🎵':'music'};
|
||||||
function leadingGlyph(text,bento=false) {
|
function leadingGlyph(text,bento=false) {
|
||||||
const mapping=bento?{...glyphs,'📺':'television','⏱':'clock','⇅':'settings','📌':'pin','🎧':'music','📚':'folder'}:glyphs;
|
const mapping=bento?{...glyphs,'📺':'television','⏱':'clock','⇅':'settings','📌':'pin','🎧':'music','📚':'folder'}:glyphs;
|
||||||
const prefix = text.match(/^\s*/)[0], rest = text.slice(prefix.length);
|
const prefix = text.match(/^\s*/)[0], rest = text.slice(prefix.length);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const test=require('node:test'),assert=require('node:assert/strict');
|
const test=require('node:test'),assert=require('node:assert/strict');
|
||||||
const {leadingGlyph}=require('./glass-controls.js');
|
const {leadingGlyph}=require('./glass-controls.js');
|
||||||
test('glass icon substitution preserves leading whitespace and remaining labels',()=>{
|
test('glass icon substitution preserves leading whitespace and remaining labels',()=>{
|
||||||
assert.deepEqual(leadingGlyph(' ⬇ Save'),{kind:'save',original:' ⬇',rest:' Save'});
|
assert.equal(leadingGlyph('Save'),null);
|
||||||
assert.deepEqual(leadingGlyph('⚙️ Settings'),{kind:'settings',original:'⚙️',rest:' Settings'});
|
assert.deepEqual(leadingGlyph('⚙️ Settings'),{kind:'settings',original:'⚙️',rest:' Settings'});
|
||||||
assert.equal(leadingGlyph('A song with 🔍 in its title'),null);
|
assert.equal(leadingGlyph('A song with 🔍 in its title'),null);
|
||||||
assert.equal(leadingGlyph('Clear all'),null);
|
assert.equal(leadingGlyph('Clear all'),null);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
66
frontend/jamesdsp-app.test.js
Normal file
66
frontend/jamesdsp-app.test.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { readFileSync } = require('node:fs');
|
||||||
|
const { join } = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
const app = readFileSync(join(__dirname, 'app.js'), 'utf8');
|
||||||
|
const views = readFileSync(join(__dirname, 'views-core.js'), 'utf8');
|
||||||
|
|
||||||
|
function parsePreset(text) {
|
||||||
|
const declaration = views.match(/function parseJamesDspPreset\(text\) \{[\s\S]*?\n\}/)?.[0];
|
||||||
|
assert.ok(declaration, 'preset parser is present');
|
||||||
|
const context = vm.createContext({});
|
||||||
|
vm.runInContext(`${declaration}; globalThis.parse = parseJamesDspPreset;`, context);
|
||||||
|
return context.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the persisted engine is Standard by default and upgrades missing JamesDSP settings safely', () => {
|
||||||
|
assert.match(app, /jamesDsp: \{ enabled: false, downloadForOffline: false, preset: 'flat', params: \{\}, irRef: null \}/);
|
||||||
|
assert.match(app, /downloadForOffline: saved\.downloadForOffline === true/);
|
||||||
|
assert.match(app, /enabled: saved\.enabled === true/);
|
||||||
|
assert.match(app, /preset: typeof saved\.preset === 'string' \? saved\.preset : 'flat'/);
|
||||||
|
assert.match(app, /settings: normalizeJamesDspSettings\(\{ \.\.\.DEFAULT_SETTINGS, \.\.\.\(loaded\.settings \|\| \{\}\) \}\)/);
|
||||||
|
assert.match(app, /data\.settings = normalizeJamesDspSettings\(\{ \.\.\.DEFAULT_SETTINGS, \.\.\.payload\.settings \}\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live JamesDSP shares the existing iOS gate and never stacks over EqRender copies', () => {
|
||||||
|
assert.match(app, /const available = \(\) => !!AC && \(!isIOS \|\| data\.settings\.liveAudioIOS === true\)/);
|
||||||
|
assert.match(app, /if \(data\.settings\?\.jamesDsp\?\.enabled\) return null/);
|
||||||
|
assert.match(app, /const active = \(\) => EQ\.isIOS\(\) && WEB && !data\.settings\?\.jamesDsp\?\.enabled/);
|
||||||
|
assert.match(app, /if \(rendered\(\)\) throw new Error\('This song is already playing a Standard processed copy\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EQ creates no second context or element source and passes Standard fallback routing to the controller', () => {
|
||||||
|
assert.equal((app.match(/\.createMediaElementSource\(/g) || []).length, 0);
|
||||||
|
assert.match(app, /st\.ctx = AudioGraph\.context\(\)/);
|
||||||
|
assert.match(app, /AudioGraph\.connect\(el, st\.filters\[0\]\)/);
|
||||||
|
assert.match(app, /standardInput: st\.filters\[0\]/);
|
||||||
|
assert.match(app, /dspController\.setLevelDb\(st\.levelDb\)/);
|
||||||
|
assert.match(app, /if \(data\.settings\.jamesDsp\?\.enabled\) \{[\s\S]{0,450}startDsp\(\)/);
|
||||||
|
assert.match(app, /request !== dspRequest \|\| !data\.settings\.jamesDsp\?\.enabled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Playback settings expose persisted presets, bands, safe import/export, IR, and A/B bypass', () => {
|
||||||
|
assert.match(views, /sel\('setSoundEngine'/);
|
||||||
|
assert.match(views, /sel\('setDspPreset'/);
|
||||||
|
for (const id of ['setDspCompressor', 'dspPresetImportBtn', 'dspPresetExportBtn', 'dspResetBtn', 'dspIrFile']) assert.ok(views.includes(`id="${id}"`), id);
|
||||||
|
assert.match(views, /\[25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000\]/);
|
||||||
|
assert.match(views, /imported\?\.texts/);
|
||||||
|
assert.doesNotMatch(views, /liveprog|ddc|arbitrary/);
|
||||||
|
assert.match(views, /navigator\.hardwareConcurrency/);
|
||||||
|
assert.match(views, /Long impulse responses are disabled/);
|
||||||
|
assert.doesNotMatch(views, /44\.1 or 48 kHz/);
|
||||||
|
assert.match(views, /setDspSpatialMode/);
|
||||||
|
assert.match(views, /data-dsp-param="convolver\.predelayMs"/);
|
||||||
|
assert.match(views, /data-dsp-param="convolver\.mix"/);
|
||||||
|
assert.match(app, /Bypass JamesDSP/);
|
||||||
|
assert.match(app, /toggleDspBypass/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preset import validates ranges and excludes text-effect parameters', () => {
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(parsePreset(JSON.stringify({ version: 1, preset: 'warm', params: { 'eq.band0': 3, 'bass.enabled': 1 } })))), { preset: 'warm', params: { 'eq.band0': 3, 'bass.enabled': 1 } });
|
||||||
|
assert.throws(() => parsePreset({ version: 1, preset: 'flat', params: { 'liveprog.enabled': 1 } }), /text effects are disabled|Unsupported preset setting/);
|
||||||
|
assert.throws(() => parsePreset({ version: 1, params: { 'eq.band0': 50 } }), /Unsupported preset setting/);
|
||||||
|
assert.throws(() => parsePreset({ version: 1, texts: { effect: 'unsafe' }, params: {} }), /text effects are disabled/);
|
||||||
|
});
|
||||||
68
frontend/jamesdsp-assets.test.js
Normal file
68
frontend/jamesdsp-assets.test.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { readFileSync } = require('node:fs');
|
||||||
|
const { join } = require('node:path');
|
||||||
|
|
||||||
|
const root = __dirname;
|
||||||
|
const assets = JSON.parse(readFileSync(join(root, 'assets.json'), 'utf8'));
|
||||||
|
const group = assets.groups['feature:jamesdsp'];
|
||||||
|
|
||||||
|
test('JamesDSP runtime files live only in a lazy background group', () => {
|
||||||
|
assert.ok(group, 'feature group exists');
|
||||||
|
assert.equal(group.contract, 1);
|
||||||
|
assert.equal(group.eager, false);
|
||||||
|
assert.equal(group.background, true);
|
||||||
|
assert.equal(group.offlineOptIn, 'jamesDsp');
|
||||||
|
assert.deepEqual(group.dependencies, ['feature:eq-core', 'feature:vocal-reducer']);
|
||||||
|
assert.deepEqual(group.files, [
|
||||||
|
'/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',
|
||||||
|
]);
|
||||||
|
for (const path of group.files) assert.equal(assets.groups.core.files.includes(path), false, `${path} is not core`);
|
||||||
|
const sw = readFileSync(join(root, 'sw.js'), 'utf8');
|
||||||
|
for (const path of group.files) assert.equal(sw.includes(`'${path}'`), false, `${path} is not in the legacy shell`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('JamesDSP resolves every module dependency through the page build manifest', () => {
|
||||||
|
const node = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-node.mjs'), 'utf8');
|
||||||
|
const worklet = readFileSync(join(root, 'vendor/jamesdsp/worklet.mjs'), 'utf8');
|
||||||
|
assert.match(node, /globalThis\.Lazy\?\.url\?\.\(path\)/);
|
||||||
|
assert.match(node, /assetURL\('\/vendor\/jamesdsp\/dsp\/jamesdsp\.wasm'/);
|
||||||
|
assert.match(node, /assetURL\('\/vendor\/jamesdsp\/params\.mjs'/);
|
||||||
|
assert.match(node, /processorOptions:\{wasmBytes\}/);
|
||||||
|
assert.match(worklet, /import createJamesDSP from '\.\/dsp\/jamesdsp-a982b53\.mjs'/);
|
||||||
|
assert.doesNotMatch(worklet, /import\(/);
|
||||||
|
assert.match(node, /context\.sampleRate<8000\|\|context\.sampleRate>96000/);
|
||||||
|
assert.match(node, /!\[1,2,4\]\.includes\(buffer\.numberOfChannels\) \|\| buffer\.length>65536/);
|
||||||
|
const feature = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-feature.js'), 'utf8');
|
||||||
|
assert.match(feature, /Lazy\.url\('\/vendor\/jamesdsp\/jamesdsp-node\.mjs'\)/);
|
||||||
|
assert.match(feature, /Lazy\.url\('\/vendor\/jamesdsp\/dsp\/jamesdsp\.wasm'\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('player wrapper rejects text effects while portable presets retain IR references', () => {
|
||||||
|
const node = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-node.mjs'), 'utf8');
|
||||||
|
const worklet = readFileSync(join(root, 'vendor/jamesdsp/worklet.mjs'), 'utf8');
|
||||||
|
assert.match(node, /Text effects are disabled in this player version/);
|
||||||
|
assert.match(node, /toJSON\(\) \{return \{version:2,params:/);
|
||||||
|
assert.match(node, /irCapFrames:view\.getUint32\(32,true\)/);
|
||||||
|
assert.match(node, /getIR\(preset\.irRef\.name\)/);
|
||||||
|
const feature = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-feature.js'), 'utf8');
|
||||||
|
assert.match(feature, /async function prepareIR\(context, buffer, maxFrames = 65536\)/);
|
||||||
|
assert.match(feature, /prepared\.length>Math\.min\(65536,maxFrames\)/);
|
||||||
|
assert.doesNotMatch(worklet, /data\.type==='text'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vendor provenance pins the source and retains both supplied license texts', () => {
|
||||||
|
const version = readFileSync(join(root, 'vendor/jamesdsp/VERSION.txt'), 'utf8');
|
||||||
|
assert.match(version, /a982b53c4ab347d1d9c50f4ce7ee3c2735eaa817/);
|
||||||
|
assert.match(version, /GPL-2\.0 text/);
|
||||||
|
assert.ok(readFileSync(join(root, 'vendor/jamesdsp/LICENSE.txt'), 'utf8').includes('Version 3'));
|
||||||
|
assert.ok(readFileSync(join(root, 'vendor/jamesdsp/UPSTREAM-MAIN-LICENSE.txt'), 'utf8').includes('Version 2'));
|
||||||
|
});
|
||||||
114
frontend/jamesdsp-controller.test.js
Normal file
114
frontend/jamesdsp-controller.test.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { createController } = require('./vendor/jamesdsp/jamesdsp-feature.js');
|
||||||
|
|
||||||
|
function fixture({ loadNode } = {}) {
|
||||||
|
const routed = [], errors = [], created = [], configured = [], bypass = [];
|
||||||
|
const graph = { routeAll: (elements, destination) => routed.push([elements.map(x => x.id), destination]) };
|
||||||
|
const gainParam = { value: 1, cancelScheduledValues() {}, setValueAtTime(v) { this.value = v; }, linearRampToValueAtTime(v) { this.value = v; } };
|
||||||
|
const context = { sampleRate: 48000, state: 'running', currentTime: 0, destination: 'destination', createGain: () => ({ gain: gainParam, connect() {}, disconnect() {} }), resume: async () => {} };
|
||||||
|
const standardInput = 'standard-input', elements = [{ id: 'video' }, { id: 'audio' }];
|
||||||
|
const prepareGraph = async () => ({ context, standardInput, elements });
|
||||||
|
const defaultLoad = async () => {
|
||||||
|
const listeners = new Map();
|
||||||
|
const instance = {
|
||||||
|
node: { connect() {}, disconnect() {} }, destroyed: false,
|
||||||
|
addEventListener(type, fn) { listeners.set(type, fn); },
|
||||||
|
removeEventListener(type, fn) { if (listeners.get(type) === fn) listeners.delete(type); },
|
||||||
|
async setBypass(v) { bypass.push(v); },
|
||||||
|
async applyPreset(params) { configured.push({ ...params }); },
|
||||||
|
async setParam(name, value) { configured.push({ [name]: value }); },
|
||||||
|
destroy() { this.destroyed = true; },
|
||||||
|
fail(error) { listeners.get('error')?.({ detail: error }); },
|
||||||
|
};
|
||||||
|
created.push(instance); return instance;
|
||||||
|
};
|
||||||
|
const controller = createController({ graph, prepareGraph, loadNode: loadNode || defaultLoad, onError: error => errors.push(error) });
|
||||||
|
return { controller, routed, errors, created, configured, bypass, context, elements, standardInput };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('enable routes one initialized JamesDSP node and disable restores Standard before destroying it', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
assert.equal(await f.controller.enable({ preset: 'bass', params: { gain: -2 } }, 3), true);
|
||||||
|
assert.equal(f.controller.active, true);
|
||||||
|
assert.deepEqual(f.routed, [[['video', 'audio'], f.created[0].node]]);
|
||||||
|
assert.equal(f.created.length, 1);
|
||||||
|
assert.equal(f.controller.state().liveHeaps, 1);
|
||||||
|
assert.equal(f.controller.state().maxLiveHeaps, 1);
|
||||||
|
const standard = f.controller.disable();
|
||||||
|
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput]);
|
||||||
|
await standard;
|
||||||
|
assert.equal(f.created[0].destroyed, true);
|
||||||
|
assert.equal(f.controller.state().liveHeaps, 0);
|
||||||
|
assert.equal(f.controller.state().nodesDestroyed, 1);
|
||||||
|
assert.equal(f.errors.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rejected or unsupported engine leaves the Standard route active', async () => {
|
||||||
|
const f = fixture({ loadNode: async () => { throw new Error('corrupt wasm'); } });
|
||||||
|
await assert.rejects(f.controller.enable({ preset: 'flat' }), /corrupt wasm/);
|
||||||
|
assert.deepEqual(f.routed, [[['video', 'audio'], f.standardInput]]);
|
||||||
|
assert.equal(f.controller.active, false);
|
||||||
|
assert.equal(f.controller.state().liveHeaps, 0);
|
||||||
|
assert.equal(f.errors.length, 1);
|
||||||
|
|
||||||
|
const supportedRate = fixture();
|
||||||
|
supportedRate.context.sampleRate = 32000;
|
||||||
|
await supportedRate.controller.enable({ preset: 'flat' });
|
||||||
|
assert.equal(supportedRate.controller.state().active, true);
|
||||||
|
assert.equal(supportedRate.controller.state().sampleRate, 32000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a runtime worklet failure switches sources to Standard before destroying the heap', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
await f.controller.enable({ preset: 'flat' });
|
||||||
|
const instance = f.created[0];
|
||||||
|
instance.fail(new Error('processor fault'));
|
||||||
|
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput]);
|
||||||
|
assert.equal(instance.destroyed, true);
|
||||||
|
assert.equal(f.controller.active, false);
|
||||||
|
assert.equal(f.controller.state().liveHeaps, 0);
|
||||||
|
assert.match(f.errors[0].message, /processor fault/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disable during async node loading prevents a late DSP route', async () => {
|
||||||
|
let finish;
|
||||||
|
const f = fixture({ loadNode: () => new Promise(resolve => { finish = resolve; }) });
|
||||||
|
const enabling = f.controller.enable({ preset: 'flat' });
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
const disabling = f.controller.disable();
|
||||||
|
const neverRouted = {
|
||||||
|
node: { connect() {}, disconnect() {} },
|
||||||
|
addEventListener() {}, removeEventListener() {}, setBypass: async () => {}, applyPreset: async () => {}, setParam: async () => {}, destroy() { this.destroyed = true; },
|
||||||
|
};
|
||||||
|
finish(neverRouted);
|
||||||
|
await enabling; await disabling;
|
||||||
|
assert.equal(f.routed.some(([, destination]) => destination === neverRouted.node), false);
|
||||||
|
assert.equal(neverRouted.destroyed, true);
|
||||||
|
assert.equal(f.controller.state().maxLiveHeaps, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preset gain and Level gain combine within engine bounds without pausing playback', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
await f.controller.enable({ preset: 'loudness', params: { gain: 8 } }, 9);
|
||||||
|
assert.ok(f.configured.some(entry => entry.gain === 12));
|
||||||
|
assert.equal(f.created.length, 1);
|
||||||
|
assert.equal(f.controller.state().maxLiveHeaps, 1);
|
||||||
|
assert.equal(f.controller.active, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A/B bypass is retained when settings are reapplied', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
await f.controller.enable({ preset: 'warm' }, 0);
|
||||||
|
assert.equal(await f.controller.toggleBypass(), true);
|
||||||
|
assert.equal(f.controller.state().route, 'standard');
|
||||||
|
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput], 'bypass follows the exact Standard route');
|
||||||
|
await f.controller.enable({ preset: 'bass' }, 0);
|
||||||
|
assert.equal(f.controller.state().bypassed, true);
|
||||||
|
assert.equal(f.controller.state().route, 'standard');
|
||||||
|
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput], 'settings changes cannot replace the Standard route while bypassed');
|
||||||
|
assert.ok(f.bypass.at(-1));
|
||||||
|
assert.equal(await f.controller.toggleBypass(), false);
|
||||||
|
assert.equal(f.controller.state().route, 'jamesdsp');
|
||||||
|
assert.equal(f.bypass.at(-1), false);
|
||||||
|
});
|
||||||
23
frontend/jamesdsp-ir.test.js
Normal file
23
frontend/jamesdsp-ir.test.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
test('generated room responses support four true-stereo paths and respect the low-memory cap', async () => {
|
||||||
|
const { generateIR, BUILTIN_IRS } = await import('./vendor/jamesdsp/ir-builtins.mjs');
|
||||||
|
const { resampleIR } = await import('./vendor/jamesdsp/ir-utils.mjs');
|
||||||
|
const ir = generateIR('Hall', 48000, 4, { maxFrames: 24000 });
|
||||||
|
assert.equal(ir.numberOfChannels, 4);
|
||||||
|
assert.equal(ir.length, 24000);
|
||||||
|
assert.equal(ir.truncated, true);
|
||||||
|
assert.ok(ir.getChannelData(0).some((sample, i) => sample !== ir.getChannelData(1)[i]), 'each matrix path has distinct taps');
|
||||||
|
const converted = resampleIR(ir, 32000);
|
||||||
|
assert.equal(converted.sampleRate, 32000);
|
||||||
|
assert.equal(converted.numberOfChannels, 4);
|
||||||
|
assert.equal(converted.length, 16000);
|
||||||
|
assert.deepEqual(Object.keys(BUILTIN_IRS), ['Small room', 'Hall', 'Plate', 'Cathedral']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the IndexedDB store rejects impulse responses beyond its measured internal-frame cap', async () => {
|
||||||
|
const { saveIR } = await import('./vendor/jamesdsp/ir-store.mjs');
|
||||||
|
const oversized = { sampleRate: 48000, numberOfChannels: 4, length: 65537, getChannelData: () => new Float32Array(65537) };
|
||||||
|
await assert.rejects(saveIR('oversized', oversized), /65,536-frame internal limit/);
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
try { manifest = JSON.parse(doc.getElementById('ytp-assets').textContent); }
|
try { manifest = JSON.parse(doc.getElementById('ytp-assets').textContent); }
|
||||||
catch { return; } // The index must supply this map; never fetch one during boot.
|
catch { return; } // The index must supply this map; never fetch one during boot.
|
||||||
const assets = new Map(), groups = new Map(), hooks = new Map();
|
const assets = new Map(), groups = new Map(), hooks = new Map();
|
||||||
|
let warmScheduled = false;
|
||||||
const css = new Set([...doc.querySelectorAll('link[data-lazy-css]')].map(link => link.dataset.lazyCss));
|
const css = new Set([...doc.querySelectorAll('link[data-lazy-css]')].map(link => link.dataset.lazyCss));
|
||||||
const selected = new Map();
|
const selected = new Map();
|
||||||
const url = path => selected.get(path) || (manifest.files[path]?.h ? path + '?v=' + manifest.files[path].h : './' + path.replace(/^\//,''));
|
const url = path => selected.get(path) || (manifest.files[path]?.h ? path + '?v=' + manifest.files[path].h : './' + path.replace(/^\//,''));
|
||||||
@@ -50,14 +51,18 @@
|
|||||||
const style = /\.css$/.test(path);
|
const style = /\.css$/.test(path);
|
||||||
if (style && css.has(path)) return Promise.resolve();
|
if (style && css.has(path)) return Promise.resolve();
|
||||||
if (!style && !/\.js$/.test(path)) return Promise.resolve(); // workers/imports/fonts are not page scripts
|
if (!style && !/\.js$/.test(path)) return Promise.resolve(); // workers/imports/fonts are not page scripts
|
||||||
const task = new Promise((resolve, reject) => {
|
const loadOnce = () => new Promise((resolve, reject) => {
|
||||||
const node = doc.createElement(style ? 'link' : 'script');
|
const node = doc.createElement(style ? 'link' : 'script');
|
||||||
|
const timer = setTimeout(() => { node.remove?.(); reject(new Error('Timed out loading ' + path)); }, 20000);
|
||||||
if (style) { node.rel = 'stylesheet'; node.href = url(path); }
|
if (style) { node.rel = 'stylesheet'; node.href = url(path); }
|
||||||
else { node.async = false; node.src = url(path); }
|
else { node.async = false; node.src = url(path); }
|
||||||
node.onload = resolve;
|
node.onload = () => { clearTimeout(timer); resolve(); };
|
||||||
node.onerror = () => reject(new Error('Unable to load ' + path));
|
node.onerror = () => { clearTimeout(timer); node.remove?.(); reject(new Error('Unable to load ' + path)); };
|
||||||
doc.head.append(node);
|
doc.head.append(node);
|
||||||
});
|
});
|
||||||
|
const task = root.AsyncGuard?.retry
|
||||||
|
? root.AsyncGuard.retry(loadOnce, { attempts: 3 })
|
||||||
|
: loadOnce();
|
||||||
assets.set(path, task);
|
assets.set(path, task);
|
||||||
task.catch(() => assets.delete(path));
|
task.catch(() => assets.delete(path));
|
||||||
return task;
|
return task;
|
||||||
@@ -100,9 +105,17 @@
|
|||||||
else root.navigator.serviceWorker?.getRegistration().then(send).catch(()=>{});
|
else root.navigator.serviceWorker?.getRegistration().then(send).catch(()=>{});
|
||||||
},
|
},
|
||||||
warm() {
|
warm() {
|
||||||
if(root.navigator.connection?.saveData || !root.navigator.serviceWorker) return;
|
if (warmScheduled || !root.navigator.serviceWorker || root.navigator.onLine === false) return;
|
||||||
const idle = root.requestIdleCallback || (fn => root.setTimeout(fn,1000));
|
warmScheduled = true;
|
||||||
idle(()=>root.navigator.serviceWorker.ready.then(reg=>reg.active?.postMessage({type:'WARM_ASSETS',saveData:!!root.navigator.connection?.saveData})).catch(()=>{}));
|
const start = () => {
|
||||||
|
warmScheduled = false;
|
||||||
|
if(root.navigator.onLine === false)return;
|
||||||
|
root.navigator.serviceWorker.ready.then(reg => {
|
||||||
|
for (const worker of new Set([reg.active, reg.waiting])) worker?.postMessage({type:'COMPLETE_ASSETS'});
|
||||||
|
}).catch(()=>{});
|
||||||
|
};
|
||||||
|
if (root.requestIdleCallback) root.requestIdleCallback(start, { timeout: 8000 });
|
||||||
|
else setTimeout(start, 5000);
|
||||||
},
|
},
|
||||||
onLoad(name, fn) {
|
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; }
|
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; }
|
||||||
@@ -122,12 +135,32 @@
|
|||||||
for (const type of ['pointerenter', 'focus', 'touchstart']) element.addEventListener(type, () => load(name).catch(() => {}), { once: true, passive: true });
|
for (const type of ['pointerenter', 'focus', 'touchstart']) element.addEventListener(type, () => load(name).catch(() => {}), { once: true, passive: true });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// Own this response once. Classic-script and fetch preloads do not share
|
||||||
|
// their cached bodies in WebKit; the later bootstrap consumes this promise.
|
||||||
|
const agent=root.navigator.userAgent || '';
|
||||||
|
api.captureApp=!!(manifest.appCache && !root.navigator.serviceWorker?.controller && root.crypto?.subtle && /AppleWebKit\//.test(agent) && !/(?:Chrome|Chromium|Edg|OPR)\//.test(agent));
|
||||||
|
if(api.captureApp) {
|
||||||
|
api.appResponse=(async()=>{
|
||||||
|
const key=url('/app.js');
|
||||||
|
try {
|
||||||
|
const cache=await root.caches?.open('ytplayer-assets'), held=await cache?.match(key);
|
||||||
|
if(held?.headers.get('X-Asset-Hash')===manifest.files['/app.js']?.h && held)return held;
|
||||||
|
} catch {}
|
||||||
|
return root.fetch(key,{credentials:'same-origin'});
|
||||||
|
})();
|
||||||
|
api.appResponse.catch(()=>{});
|
||||||
|
} else if(manifest.appCache) {
|
||||||
|
// Preserve native script/code-cache reuse in engines without the WebKit gap.
|
||||||
|
const preload=doc.createElement('link');preload.rel='preload';preload.as='script';preload.fetchPriority='low';preload.href=url('/app.js');doc.head.append(preload);
|
||||||
|
}
|
||||||
root.Lazy = api;
|
root.Lazy = api;
|
||||||
root.navigator.serviceWorker?.addEventListener('message',event=>{
|
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==='ASSET_LAYOUT_REQUEST') event.source?.postMessage({type:'LAYOUT',value:api.layout});
|
||||||
if(event.data?.type==='SW_UPDATE_AVAILABLE') api.warm();
|
if(event.data?.type==='SW_UPDATE_AVAILABLE') api.warm();
|
||||||
});
|
});
|
||||||
root.navigator.serviceWorker?.addEventListener('controllerchange',()=>{api.reportLayout();api.warm();});
|
root.navigator.serviceWorker?.addEventListener('controllerchange',()=>{api.reportLayout();api.warm();});
|
||||||
|
root.addEventListener?.('online',()=>api.warm());
|
||||||
|
api.warm();
|
||||||
let settings={};
|
let settings={};
|
||||||
try { settings=JSON.parse(root.localStorage.getItem('_ytpdata') || '{}').settings || {}; api.layout=settings.layout || 'classic'; } catch {}
|
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'});
|
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'});
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ const { test } = require('node:test');
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const { readFileSync } = require('node:fs');
|
const { readFileSync } = require('node:fs');
|
||||||
const vm = require('node:vm');
|
const vm = require('node:vm');
|
||||||
function fixture(current, held = []) {
|
function fixture(current, held = [], appCache = false, userAgent = 'AppleWebKit/605.1 Safari/605.1', controlled = false) {
|
||||||
const inserted = [], listeners = {}, writes = [];
|
const inserted = [], listeners = {}, writes = [], fetched = [];
|
||||||
const manifest = { buildTag: 'own', groups: { core: { files: [] }, 'layout:classic': { files: ['/classic.css'] }, 'layout:glass-stage': { files: ['/glass.css', '/one.js', '/two.js'] }, 'feature:test': { contract:1, files: ['/one.js', '/two.js'] } }, files: { '/classic.css': {h:'c'}, '/glass.css':{h:'g'}, '/one.js':{h:'1'}, '/two.js':{h:'2'} } };
|
const manifest = { buildTag: 'own', appCache, groups: { core: { files: [] }, 'layout:classic': { files: ['/classic.css'] }, 'layout:glass-stage': { files: ['/glass.css', '/one.js', '/two.js'] }, 'feature:test': { contract:1, files: ['/one.js', '/two.js'] } }, files: { '/app.js':{h:'a'}, '/classic.css': {h:'c'}, '/glass.css':{h:'g'}, '/one.js':{h:'1'}, '/two.js':{h:'2'} } };
|
||||||
const doc = { readyState:'loading', documentElement: { dataset:{} }, getElementById: () => ({ textContent:JSON.stringify(manifest) }), querySelectorAll: () => [], createElement: tag => ({ tagName:tag.toUpperCase() }), write: value => writes.push(value), addEventListener: (t,f) => listeners[t]=f };
|
const doc = { readyState:'loading', documentElement: { dataset:{} }, getElementById: () => ({ textContent:JSON.stringify(manifest) }), querySelectorAll: () => [], createElement: tag => ({ tagName:tag.toUpperCase() }), write: value => writes.push(value), addEventListener: (t,f) => listeners[t]=f };
|
||||||
doc.head = { append: node => { inserted.push(node); queueMicrotask(() => node.onload?.()); } };
|
doc.head = { append: node => { inserted.push(node); queueMicrotask(() => node.onload?.()); } };
|
||||||
const root = { document:doc, localStorage:{ getItem:() => '{"settings":{"layout":"glass-stage"}}' }, console, Promise, URL, setTimeout, clearTimeout, navigator:{}, addEventListener(){} };
|
const root = { document:doc, localStorage:{ getItem:() => '{"settings":{"layout":"glass-stage"}}' }, console, Promise, URL, crypto:{subtle:{}}, AsyncGuard:require('./async-guard'), requestIdleCallback:fn=>{root.__idle=fn;}, fetch:async key=>{fetched.push(key);return new Response('app');}, setTimeout, clearTimeout, navigator:{userAgent,serviceWorker:{ready:Promise.resolve({}),controller:controlled?{}:null,addEventListener(){}}}, addEventListener(){} };
|
||||||
if (current) root.caches = { open: async () => ({ match: async key => key === '/__ytp_asset_state' ? new Response(JSON.stringify({current})) : held.includes(key) ? new Response('', {headers:{'X-Asset-Hash':key.split('=')[1]}}) : undefined }) };
|
if (current) root.caches = { open: async () => ({ match: async key => key === '/__ytp_asset_state' ? new Response(JSON.stringify({current})) : held.includes(key) ? new Response('', {headers:{'X-Asset-Hash':key.split('=')[1]}}) : undefined }) };
|
||||||
root.window=root; root.globalThis=root;
|
root.window=root; root.globalThis=root;
|
||||||
vm.runInNewContext(readFileSync(require.resolve('./lazy.js'),'utf8'), root);
|
vm.runInNewContext(readFileSync(require.resolve('./lazy.js'),'utf8'), root);
|
||||||
return { root, inserted, writes, listeners, manifest };
|
return { root, inserted, writes, listeners, manifest, fetched };
|
||||||
}
|
}
|
||||||
test('external head bootstrap selects remembered CSS before paint without fetching a manifest', () => {
|
test('external head bootstrap selects remembered CSS before paint without fetching a manifest', () => {
|
||||||
const {root,writes}=fixture();
|
const {root,writes}=fixture();
|
||||||
@@ -27,11 +27,13 @@ test('lazy groups execute ordered classic scripts once, sharing concurrent calls
|
|||||||
await root.Lazy.load('layout:glass-stage');
|
await root.Lazy.load('layout:glass-stage');
|
||||||
assert.equal(inserted.filter(n=>n.tagName==='SCRIPT').length,2);
|
assert.equal(inserted.filter(n=>n.tagName==='SCRIPT').length,2);
|
||||||
});
|
});
|
||||||
test('failed injection can retry and proxy dispatches only after execution', async () => {
|
test('transient script injection failure retries before proxy dispatch', async () => {
|
||||||
const {root,inserted}=fixture();
|
const {root,inserted}=fixture();
|
||||||
const append=root.document.head.append;
|
const append=root.document.head.append;
|
||||||
root.document.head.append=n=>{inserted.push(n);queueMicrotask(()=>n.onerror?.());};
|
let first=true;
|
||||||
await assert.rejects(root.Lazy.load('feature:test'),/one.js/);
|
root.document.head.append=n=>{inserted.push(n);queueMicrotask(()=>{if(first){first=false;n.onerror?.();}else n.onload?.();});};
|
||||||
|
await root.Lazy.load('feature:test');
|
||||||
|
assert.equal(inserted.length,3,'first file is retried, then the second file loads');
|
||||||
root.document.head.append=append;
|
root.document.head.append=append;
|
||||||
root.TestApi={open:value=>value+1};
|
root.TestApi={open:value=>value+1};
|
||||||
const proxy=root.Lazy.proxy('feature:test',['open'],'TestApi');
|
const proxy=root.Lazy.proxy('feature:test',['open'],'TestApi');
|
||||||
@@ -84,3 +86,44 @@ test('classic chunk registry proxies use window.YT and keep the executed singlet
|
|||||||
const singleton=root.YT.TestApi;await root.Lazy.load('feature:test');
|
const singleton=root.YT.TestApi;await root.Lazy.load('feature:test');
|
||||||
assert.equal(root.YT.TestApi,singleton);assert.equal(inserted.length,2);
|
assert.equal(root.YT.TestApi,singleton);assert.equal(inserted.length,2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('online launches request worker completion even with Save-Data and skip offline messages',async()=>{
|
||||||
|
const f=fixture(),sent=[];
|
||||||
|
const active={postMessage:m=>sent.push(m)},waiting={postMessage:m=>sent.push(m)};
|
||||||
|
f.root.navigator.connection={saveData:true};f.root.navigator.onLine=true;
|
||||||
|
f.root.navigator.serviceWorker={ready:Promise.resolve({active,waiting})};
|
||||||
|
f.root.Lazy.warm();await Promise.resolve();
|
||||||
|
assert.equal(sent.length,0,'warming waits until the page is idle');
|
||||||
|
f.root.__idle();await Promise.resolve();
|
||||||
|
assert.deepEqual(sent.map(m=>m.type),['COMPLETE_ASSETS','COMPLETE_ASSETS']);
|
||||||
|
f.root.navigator.onLine=false;f.root.Lazy.warm();await Promise.resolve();assert.equal(sent.length,2);
|
||||||
|
});
|
||||||
|
test('server-enabled head capture owns exactly one app response; native and rollback do not fetch',async()=>{
|
||||||
|
const modern=fixture(null,[],true);await modern.root.Lazy.appResponse;
|
||||||
|
assert.deepEqual(modern.fetched,['/app.js?v=a']);
|
||||||
|
const legacy=fixture();assert.deepEqual(legacy.fetched,[]);assert.equal(legacy.root.Lazy.appResponse,undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('head capture is cache-first even before a worker controls the page',async()=>{
|
||||||
|
const f=fixture(fixture().manifest,['/app.js?v=a'],true);const response=await f.root.Lazy.appResponse;
|
||||||
|
assert.equal(response.headers.get('X-Asset-Hash'),'a');assert.deepEqual(f.fetched,[]);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('Chromium retains native script loading and only preloads the current app URL',async()=>{
|
||||||
|
const f=fixture(null,[],true,'AppleWebKit/537.36 Chrome/140.0 Safari/537.36');
|
||||||
|
assert.equal(f.root.Lazy.captureApp,false);assert.equal(f.root.Lazy.appResponse,undefined);assert.deepEqual(f.fetched,[]);
|
||||||
|
assert.equal(f.inserted.length,1);assert.equal(f.inserted[0].rel,'preload');assert.equal(f.inserted[0].as,'script');assert.equal(f.inserted[0].href,'/app.js?v=a');assert.equal(f.inserted[0].fetchPriority,'low');
|
||||||
|
});
|
||||||
|
test('iOS Chromium-branded browsers still use their WebKit response capture',async()=>{
|
||||||
|
const f=fixture(null,[],true,'AppleWebKit/605.1 CriOS/140.0 Mobile Safari/605.1');await f.root.Lazy.appResponse;
|
||||||
|
assert.equal(f.root.Lazy.captureApp,true);assert.deepEqual(f.fetched,['/app.js?v=a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controlled WebKit visits use worker-cached native app scripts without a response handoff',async()=>{
|
||||||
|
const f=fixture(null,[],true,'AppleWebKit/605.1 Safari/605.1',true);
|
||||||
|
assert.equal(f.root.Lazy.captureApp,false);assert.equal(f.root.Lazy.appResponse,undefined);assert.deepEqual(f.fetched,[]);
|
||||||
|
assert.equal(f.inserted[0].as,'script');assert.equal(f.inserted[0].href,'/app.js?v=a');assert.equal(f.inserted[0].fetchPriority,'low');
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,10 +16,19 @@
|
|||||||
for (const link of root.document.querySelectorAll('link[rel="stylesheet"]')) { const copy = doc.createElement('link'); copy.rel = 'stylesheet'; copy.href = link.href; doc.head.append(copy); }
|
for (const link of root.document.querySelectorAll('link[rel="stylesheet"]')) { const copy = doc.createElement('link'); copy.rel = 'stylesheet'; copy.href = link.href; doc.head.append(copy); }
|
||||||
const style = doc.createElement('style'); style.textContent = 'body{margin:0;background:var(--bg,#151719);color:var(--text,#fff);display:flex;flex-direction:column;height:100vh;overflow:hidden}.lyrics-floating-view{position:relative!important;inset:auto!important;flex:1;min-height:0;display:flex!important;flex-direction:column!important;width:100%!important}.lyrics-floating-view .sl-list{flex:1;min-height:0;height:auto!important}.lyrics-floating-controls{display:flex;gap:8px;padding:12px}.lyrics-floating-controls button{flex:1;min-height:48px;background:var(--bg-2,#25292d);color:inherit;border:1px solid var(--line,#555);border-radius:8px}'; doc.head.append(style);
|
const style = doc.createElement('style'); style.textContent = 'body{margin:0;background:var(--bg,#151719);color:var(--text,#fff);display:flex;flex-direction:column;height:100vh;overflow:hidden}.lyrics-floating-view{position:relative!important;inset:auto!important;flex:1;min-height:0;display:flex!important;flex-direction:column!important;width:100%!important}.lyrics-floating-view .sl-list{flex:1;min-height:0;height:auto!important}.lyrics-floating-controls{display:flex;gap:8px;padding:12px}.lyrics-floating-controls button{flex:1;min-height:48px;background:var(--bg-2,#25292d);color:inherit;border:1px solid var(--line,#555);border-radius:8px}'; doc.head.append(style);
|
||||||
const view = adapter.view(), marker = root.document.createComment('floating lyrics'); view.before(marker); view.classList.add('lyrics-floating-view'); doc.body.append(view);
|
const view = adapter.view(), marker = root.document.createComment('floating lyrics'); view.before(marker); view.classList.add('lyrics-floating-view'); doc.body.append(view);
|
||||||
|
doc.body.append(root.document.querySelector('#transport-play').ownerSVGElement.cloneNode(true));
|
||||||
const controls = doc.createElement('div'); controls.className = 'lyrics-floating-controls';
|
const controls = doc.createElement('div'); controls.className = 'lyrics-floating-controls';
|
||||||
for (const [label, action] of [['Play / Pause', adapter.toggle], ['Next', adapter.next]]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = label; b.onclick = action; controls.append(b); } doc.body.append(controls);
|
for (const [kind, label, action] of [['play', 'Play', adapter.toggle], ['next', 'Next', adapter.next]]) { const b = doc.createElement('button'); b.type = 'button'; root.TransportIcons.set(b,kind,label); b.onclick = action; controls.append(b); } doc.body.append(controls);
|
||||||
|
const repaint = () => {
|
||||||
|
doc.documentElement.dataset.layout=root.document.documentElement.dataset.layout; doc.documentElement.dataset.theme=root.document.documentElement.dataset.theme;
|
||||||
|
const kind=root.document.querySelector('#playBtn .transport-icon')?.dataset.transportIcon || 'play';
|
||||||
|
root.TransportIcons.set(controls.firstElementChild,kind);
|
||||||
|
};
|
||||||
|
const transportObserver=new root.MutationObserver(repaint);
|
||||||
|
transportObserver.observe(root.document.getElementById('playBtn'),{childList:true});
|
||||||
|
transportObserver.observe(root.document.documentElement,{attributes:true,attributeFilter:['data-layout','data-theme']}); repaint();
|
||||||
const win = pipWindow; let restored = false;
|
const win = pipWindow; let restored = false;
|
||||||
cleanup = () => { if (restored) return; restored = true; marker.replaceWith(view); view.classList.remove('lyrics-floating-view'); pipWindow = null; adapter.refresh(); if (!win.closed) win.close(); };
|
cleanup = () => { if (restored) return; restored = true; transportObserver.disconnect(); marker.replaceWith(view); view.classList.remove('lyrics-floating-view'); pipWindow = null; adapter.refresh(); if (!win.closed) win.close(); };
|
||||||
win.addEventListener('pagehide', cleanup, { once: true }); win.addEventListener('resize', adapter.refresh); adapter.refresh();
|
win.addEventListener('pagehide', cleanup, { once: true }); win.addEventListener('resize', adapter.refresh); adapter.refresh();
|
||||||
} else {
|
} else {
|
||||||
const canvas = root.document.createElement('canvas'); canvas.width = 840; canvas.height = 500; const ctx = canvas.getContext('2d');
|
const canvas = root.document.createElement('canvas'); canvas.width = 840; canvas.height = 500; const ctx = canvas.getContext('2d');
|
||||||
|
|||||||
19
frontend/media-icons.test.js
Normal file
19
frontend/media-icons.test.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { readdirSync, readFileSync } = require('node:fs');
|
||||||
|
const { join } = require('node:path');
|
||||||
|
|
||||||
|
test('download and offline icons never use emoji or text glyphs', () => {
|
||||||
|
// Arrow keys are prose, not action icons. Exact lines prevent new exceptions.
|
||||||
|
const allow = new Map([
|
||||||
|
['index.html', new Set(['<div class="shortcut-row"><kbd>↑</kbd><kbd>↓</kbd><span>Volume up / down</span></div>'])],
|
||||||
|
['admin.html', new Set(['<span class="muted" style="font-size:12.5px">Shortcuts: <code>Space</code> stamp (Tap-sync) · <code>S</code> stamp selected · <code>K</code> play · <code>↑↓</code> select · <code>⌘/Ctrl S</code> save</span>'])],
|
||||||
|
]);
|
||||||
|
const failures=[];
|
||||||
|
for (const file of readdirSync(__dirname).filter(f=>/\.(js|html|css)$/.test(f) && !f.endsWith('.test.js'))) {
|
||||||
|
readFileSync(join(__dirname,file),'utf8').split('\n').forEach((line,i)=>{
|
||||||
|
if (/[⬇⤓⇩💾📥↓]/u.test(line) && !allow.get(file)?.has(line.trim())) failures.push(`${file}:${i+1}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.deepEqual(failures,[]);
|
||||||
|
});
|
||||||
@@ -293,7 +293,7 @@
|
|||||||
<div class="rh-body"></div>
|
<div class="rh-body"></div>
|
||||||
<label class="rh-name-row">This screen's name <input class="rh-name" maxlength="40" /></label>
|
<label class="rh-name-row">This screen's name <input class="rh-name" maxlength="40" /></label>
|
||||||
<div class="rh-buttons">
|
<div class="rh-buttons">
|
||||||
<button class="np-btn" type="button" data-act="start">▶ Start — control from a phone</button>
|
<button class="np-btn" type="button" data-act="start">${TransportIcons.svg('play')} Start — control from a phone</button>
|
||||||
<button class="np-btn" type="button" data-act="code">↻ New code</button>
|
<button class="np-btn" type="button" data-act="code">↻ New code</button>
|
||||||
<button class="np-btn" type="button" data-act="unpair">Unpair all phones</button>
|
<button class="np-btn" type="button" data-act="unpair">Unpair all phones</button>
|
||||||
<button class="np-btn" type="button" data-act="stop">Stop</button>
|
<button class="np-btn" type="button" data-act="stop">Stop</button>
|
||||||
@@ -414,14 +414,14 @@
|
|||||||
$('rvTitle').textContent = v ? v.title : 'Nothing playing';
|
$('rvTitle').textContent = v ? v.title : 'Nothing playing';
|
||||||
$('rvChannel').textContent = v ? v.channel : '';
|
$('rvChannel').textContent = v ? v.channel : '';
|
||||||
let directPlay = $('rvDirectPlay');
|
let directPlay = $('rvDirectPlay');
|
||||||
if (!directPlay) { directPlay = document.createElement('button'); directPlay.id = 'rvDirectPlay'; directPlay.className = 'np-btn'; directPlay.style.minHeight = '44px'; directPlay.textContent = 'Play here from paired screen'; $('rvChannel').after(directPlay); }
|
if (!directPlay) { directPlay = document.createElement('button'); directPlay.id = 'rvDirectPlay'; directPlay.className = 'np-btn'; directPlay.style.minHeight = '44px'; directPlay.innerHTML = TransportIcons.svg('play') + ' Play here from paired screen'; $('rvChannel').after(directPlay); }
|
||||||
directPlay.hidden = !v || !s.directAvailable || !DirectMedia.enabled();
|
directPlay.hidden = !v || !s.directAvailable || !DirectMedia.enabled();
|
||||||
directPlay.onclick = () => v && playVideoAt({ ...v, duration: s.dur || 0 }, liveCur());
|
directPlay.onclick = () => v && playVideoAt({ ...v, duration: s.dur || 0 }, liveCur());
|
||||||
|
|
||||||
const thumb = $('rvThumb');
|
const thumb = $('rvThumb');
|
||||||
if (v && v.thumbnail) { if (thumb.getAttribute('src') !== v.thumbnail) thumb.src = v.thumbnail; thumb.classList.remove('hidden'); }
|
if (v && v.thumbnail) { if (thumb.getAttribute('src') !== v.thumbnail) thumb.src = v.thumbnail; thumb.classList.remove('hidden'); }
|
||||||
else thumb.classList.add('hidden');
|
else thumb.classList.add('hidden');
|
||||||
$('rvToggle').textContent = s && !s.paused ? '⏸' : '▶';
|
TransportIcons.set($('rvToggle'), s && !s.paused ? 'pause' : 'play');
|
||||||
$('rvDur').textContent = clock((s && s.dur) || 0);
|
$('rvDur').textContent = clock((s && s.dur) || 0);
|
||||||
if (!rc.volTimer && document.activeElement !== $('rvVolume')) {
|
if (!rc.volTimer && document.activeElement !== $('rvVolume')) {
|
||||||
const pct = Math.round(((s && s.volume) ?? 1) * 100);
|
const pct = Math.round(((s && s.volume) ?? 1) * 100);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
.saved-storage-text { min-width:0; color:var(--text-2); font-size:14px; font-variant-numeric:tabular-nums; }
|
.saved-storage-text { min-width:0; color:var(--text-2); font-size:14px; font-variant-numeric:tabular-nums; }
|
||||||
.saved-clear { flex:none; min-height:44px; padding:0 10px; border:0; background:transparent; color:var(--accent); font:inherit; cursor:pointer; }
|
.saved-clear { flex:none; min-height:44px; padding:0 10px; border:0; background:transparent; color:var(--accent); font:inherit; cursor:pointer; }
|
||||||
html[data-layout] .card.saved-card { display:grid; grid-template-columns:72px minmax(0,1fr) 96px; align-items:center; gap:12px; min-height:88px; }
|
html[data-layout] .card.saved-card { display:grid; grid-template-columns:72px minmax(0,1fr) 96px; align-items:center; gap:12px; min-height:88px; }
|
||||||
html[data-layout] .saved-card .thumb { grid-column:1; grid-row:1; width:72px; }
|
html[data-layout] #cards .saved-card .thumb { grid-column:1; grid-row:1; width:100%; min-width:0; max-width:100%; flex:none; overflow:hidden; }
|
||||||
|
html[data-layout] #cards .saved-card .thumb img { width:100%; max-width:100%; height:100%; object-fit:cover; }
|
||||||
html[data-layout] .saved-card .card-info { grid-column:2; grid-row:1; min-width:0; }
|
html[data-layout] .saved-card .card-info { grid-column:2; grid-row:1; min-width:0; }
|
||||||
html[data-layout] .saved-card .card-title { display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden; overflow-wrap:anywhere; }
|
html[data-layout] .saved-card .card-title { display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden; overflow-wrap:anywhere; text-overflow:ellipsis; }
|
||||||
html[data-layout] .saved-file-actions { grid-column:3; grid-row:1; display:flex; align-items:center; gap:8px; }
|
html[data-layout] .saved-file-actions { grid-column:3; grid-row:1; display:flex; align-items:center; gap:8px; }
|
||||||
.saved-file-action { width:44px; height:44px; min-width:44px; padding:10px; border:1px solid var(--line); border-radius:var(--radius-sm,8px); background:var(--bg-2); color:var(--text-2); cursor:pointer; }
|
.saved-file-action { width:44px; height:44px; min-width:44px; padding:10px; border:1px solid var(--line); border-radius:var(--radius-sm,8px); background:var(--bg-2); color:var(--text-2); cursor:pointer; }
|
||||||
.saved-file-action svg { display:block; width:22px; height:22px; }
|
.saved-file-action svg { display:block; width:22px; height:22px; }
|
||||||
@@ -13,4 +14,4 @@ html[data-layout] .saved-file-actions { grid-column:3; grid-row:1; display:flex;
|
|||||||
.saved-file-action.danger:hover, .saved-clear:hover { color:var(--accent); }
|
.saved-file-action.danger:hover, .saved-clear:hover { color:var(--accent); }
|
||||||
.saved-file-action:focus-visible, .saved-clear:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
|
.saved-file-action:focus-visible, .saved-clear:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
|
||||||
.saved-file-action:disabled, .saved-clear:disabled { opacity:.5; cursor:wait; }
|
.saved-file-action:disabled, .saved-clear:disabled { opacity:.5; cursor:wait; }
|
||||||
@media(min-width:900px) { html[data-layout] .card.saved-card { grid-template-columns:88px minmax(0,1fr) 96px; } html[data-layout] .saved-card .thumb { width:88px; } }
|
@media(min-width:900px) { html[data-layout] .card.saved-card { grid-template-columns:88px minmax(0,1fr) 96px; } }
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase());
|
const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase());
|
||||||
const paths = {
|
const paths = {
|
||||||
export: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>',
|
export: '<use href="#media-download"/>',
|
||||||
delete: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>'
|
delete: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>'
|
||||||
};
|
};
|
||||||
function button(doc, kind, label, action) {
|
function button(doc, kind, label, action) {
|
||||||
const b = doc.createElement('button'); b.type = 'button';
|
const b = doc.createElement('button'); b.type = 'button';
|
||||||
b.className = `saved-file-action${kind === 'delete' ? ' danger' : ''}`;
|
b.className = `saved-file-action${kind === 'delete' ? ' danger' : ''}`;
|
||||||
b.setAttribute('aria-label', label); b.title = label;
|
b.setAttribute('aria-label', label); b.title = label;
|
||||||
b.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[kind]}</svg>`;
|
b.innerHTML = `<svg class="media-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[kind]}</svg>`;
|
||||||
b.onclick = e => { e.stopPropagation(); action(); }; return b;
|
b.onclick = e => { e.stopPropagation(); action(); }; return b;
|
||||||
}
|
}
|
||||||
function actions(row, video, exportFile, deleteFile) {
|
function actions(row, video, exportFile, deleteFile) {
|
||||||
|
|||||||
@@ -245,3 +245,77 @@ const SectionRail = (() => {
|
|||||||
|
|
||||||
return { boot, refresh, setFolded, isFolded };
|
return { boot, refresh, setFolded, isFolded };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/* Cache the single page-owned app response before it registers the worker. */
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
const doc=root.document, manifest=root.Lazy.manifest, key=root.Lazy.url('/app.js');
|
||||||
|
let domReady=doc.readyState==='complete', appSawDom=false;
|
||||||
|
doc.addEventListener('DOMContentLoaded',()=>{domReady=true;appSawDom=typeof root.boot==='function';},{once:true});
|
||||||
|
function execute(node) {
|
||||||
|
doc.head.append(node);
|
||||||
|
node.remove();
|
||||||
|
delete root.Lazy.appResponse;
|
||||||
|
// app.js keeps its original DOMContentLoaded listener. Async response
|
||||||
|
// consumption can finish after that event; boot exactly once in that case.
|
||||||
|
if(domReady)root.boot();
|
||||||
|
}
|
||||||
|
async function external(url) {
|
||||||
|
await new Promise((resolve,reject)=>{
|
||||||
|
const node=doc.createElement('script');node.src=url;
|
||||||
|
node.onload=()=>{if(domReady&&!appSawDom)root.boot();resolve();};node.onerror=()=>reject(Error('Unable to load player'));
|
||||||
|
doc.head.append(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function native() {
|
||||||
|
if(doc.readyState!=='loading' || !doc.currentScript)return external(key);
|
||||||
|
return new Promise((resolve,reject)=>{
|
||||||
|
doc.addEventListener('DOMContentLoaded',()=>typeof root.boot==='function'?resolve():reject(Error('Unable to load player')),{once:true});
|
||||||
|
const escaped=key.replaceAll('&','&').replaceAll('"','"').replaceAll('<','<');
|
||||||
|
doc.write('<script src="'+escaped+'"></script>');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function verified(response, expected) {
|
||||||
|
const bytes=await response.clone().arrayBuffer();
|
||||||
|
const digest=new Uint8Array(await root.crypto.subtle.digest('SHA-256',bytes));
|
||||||
|
const hex=Array.from(digest,b=>b.toString(16).padStart(2,'0')).join('');
|
||||||
|
if(hex.slice(0,10)!==expected)throw Error('Player body hash mismatch');
|
||||||
|
return {bytes,digest};
|
||||||
|
}
|
||||||
|
async function start() {
|
||||||
|
if(!manifest.appCache || !root.crypto?.subtle || !root.Lazy.captureApp)return native();
|
||||||
|
const expected=manifest.files['/app.js'].h;
|
||||||
|
let cache;
|
||||||
|
try { cache=await root.caches?.open('ytplayer-assets'); } catch {}
|
||||||
|
let response=await cache?.match(key);
|
||||||
|
if(!response || response.headers.get('X-Asset-Hash')!==expected) {
|
||||||
|
try { response=await (root.Lazy.appResponse || root.fetch(key,{credentials:'same-origin'})); } catch {}
|
||||||
|
}
|
||||||
|
if(!response?.ok || response.headers.get('X-Asset-Hash')!==expected) {
|
||||||
|
// Preserve staged N-1 boot after an individual cache eviction. Only the
|
||||||
|
// controller can serve this old URL safely; never fetch stale URLs bare.
|
||||||
|
const state=cache && await root.AssetSyncCore.state(cache);
|
||||||
|
const previousKey=state && root.AssetSyncCore.fallback(manifest,state.previous,'/app.js');
|
||||||
|
const previous=previousKey && await cache.match(previousKey);
|
||||||
|
const previousHash=state?.previous?.files['/app.js']?.h;
|
||||||
|
const contractsMatch=state?.previous && Object.entries(manifest.groups).every(([name,group])=>state.previous.groups[name]?.contract===group.contract);
|
||||||
|
if(contractsMatch && root.navigator?.serviceWorker?.controller && previous?.ok && previous.headers.get('X-Asset-Hash')===previousHash) {
|
||||||
|
await verified(previous,previousHash);
|
||||||
|
delete root.Lazy.appResponse;
|
||||||
|
return external(previousKey);
|
||||||
|
}
|
||||||
|
throw Error('Player asset hash mismatch');
|
||||||
|
}
|
||||||
|
const {bytes,digest}=await verified(response,expected);
|
||||||
|
const permission="'sha256-"+root.btoa(String.fromCharCode(...digest))+"'";
|
||||||
|
if(!doc.querySelector('meta[http-equiv="Content-Security-Policy"]').content.includes(permission))throw Error('Player body hash mismatch');
|
||||||
|
// CacheStorage may share a full quota with saved music. Never remove data
|
||||||
|
// to make room; boot from these verified bytes even if caching is refused.
|
||||||
|
try { await cache?.put(key,response); } catch(error) { root.console.warn('[app-cache]',error.message); }
|
||||||
|
const node=doc.createElement('script');node.textContent=new root.TextDecoder().decode(bytes);
|
||||||
|
execute(node);
|
||||||
|
}
|
||||||
|
const ready=start();
|
||||||
|
root.AppBootstrap={ready};
|
||||||
|
ready.catch(error=>root.console.error('[app-cache]',error.message));
|
||||||
|
})(typeof window!=='undefined'?window:globalThis);
|
||||||
|
|||||||
37
frontend/settings-persist.test.js
Normal file
37
frontend/settings-persist.test.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const source = fs.readFileSync(__dirname + '/app.js', 'utf8');
|
||||||
|
// Exercise the production persistence/profile functions without starting a DOM player.
|
||||||
|
const declaration = name => source.match(new RegExp('(?:async )?function ' + name + '\\([^]*?\\n}'))[0];
|
||||||
|
function fixture(updatedAt) {
|
||||||
|
const settings = { saveBeforePlay: true, autoPreload: true, layout: 'f7-swipe', eq: { preset: 'custom', g: [1,2,3,4,5] }, jamesDsp: { enabled: true, downloadForOffline: true, preset: 'warm', params: { 'eq.band0': 3 } } };
|
||||||
|
const defaults = { saveBeforePlay: false, autoPreload: true, layout: 'classic', jamesDsp: { enabled: false, downloadForOffline: false, preset: 'flat', params: {} } };
|
||||||
|
const context = vm.createContext({ WEB: true, data: { settings, profile: { name: 'test', syncedAt: 200 } }, DEFAULT_SETTINGS: defaults, API: { saveData: async () => {} }, profileHeaders: () => ({}), scheduleProfilePush: () => context.pushes++, pushes: 0, fetch: async () => ({ status: 200, json: async () => ({ ok: true, name: 'test', updatedAt, data: { settings: { saveBeforePlay: false } } }) }), localStorage: { getItem: () => JSON.stringify({ settings }) } });
|
||||||
|
vm.runInContext([declaration('loadDataFromStorage'), declaration('normalizeJamesDspSettings'), declaration('applyProfileData'), declaration('pullProfileIfNewer')].join('\n'), context);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
test('stored settings load verbatim instead of reverting to defaults', () => {
|
||||||
|
const c = fixture(100);
|
||||||
|
const loaded = vm.runInContext('loadDataFromStorage()', c);
|
||||||
|
assert.equal(loaded.settings.saveBeforePlay, true);
|
||||||
|
assert.equal(loaded.settings.layout, 'f7-swipe');
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(loaded.settings.eq.g)), [1,2,3,4,5]);
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(loaded.settings.jamesDsp)), { enabled: true, downloadForOffline: true, preset: 'warm', params: { 'eq.band0': 3 } });
|
||||||
|
});
|
||||||
|
for (const updatedAt of [100,200]) test(`profile timestamp ${updatedAt} cannot overwrite local settings synced at 200`, async () => {
|
||||||
|
const c = fixture(updatedAt), previous = c.data.settings;
|
||||||
|
await vm.runInContext('pullProfileIfNewer()', c);
|
||||||
|
assert.equal(c.data.settings, previous);
|
||||||
|
assert.equal(c.pushes, 1);
|
||||||
|
});
|
||||||
|
test('newer profile intentionally replaces settings and restores missing defaults', async () => {
|
||||||
|
const c = fixture(300);
|
||||||
|
await vm.runInContext('pullProfileIfNewer()', c);
|
||||||
|
assert.equal(c.data.settings.saveBeforePlay, false);
|
||||||
|
assert.equal(c.data.settings.autoPreload, true);
|
||||||
|
assert.equal(c.data.settings.layout, 'classic');
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(c.data.settings.jamesDsp)), { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null });
|
||||||
|
assert.equal(c.data.profile.syncedAt, 300);
|
||||||
|
});
|
||||||
@@ -1,4 +1,27 @@
|
|||||||
.settings-browser { display: block; width: 100%; min-width: 0; }
|
.settings-browser { display: block; width: 100%; min-width: 0; }
|
||||||
|
.settings-browser, .settings-grid, .settings-overview, .settings-detail, .settings-section,
|
||||||
|
.settings-browser .settings, .settings-browser .set-group, .settings-browser .dsp-settings,
|
||||||
|
.settings-browser .dsp-subsection, .settings-browser .dsp-param-list {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
/* Settings is a workspace, not a sidebar: give the section browser the full
|
||||||
|
content area on desktop instead of leaving it inside the fixed 452px list. */
|
||||||
|
.body:has(#listPane .cards[data-view="settings"]) .player-pane { display: none; }
|
||||||
|
.body:has(#listPane .cards[data-view="settings"]) #listPane {
|
||||||
|
flex: 1 1 0;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
.body:has(#listPane .cards[data-view="settings"]) #listPane .cards {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
.settings-browser [hidden] { display: none !important; }
|
.settings-browser [hidden] { display: none !important; }
|
||||||
.settings-search { margin-bottom: 12px; }
|
.settings-search { margin-bottom: 12px; }
|
||||||
.settings-search input { width: 100%; min-height: 44px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; font: inherit; }
|
.settings-search input { width: 100%; min-height: 44px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; font: inherit; }
|
||||||
@@ -11,10 +34,81 @@
|
|||||||
.settings-section-row:hover, .settings-section-row[aria-current="true"] { background: var(--bg-3); border-radius: 8px; }
|
.settings-section-row:hover, .settings-section-row[aria-current="true"] { background: var(--bg-3); border-radius: 8px; }
|
||||||
.settings-browser :is(button,input,select,textarea):focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
.settings-browser :is(button,input,select,textarea):focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||||
.settings-back { min-height: 44px; padding: 8px 12px; margin-bottom: 12px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; font: inherit; cursor: pointer; }
|
.settings-back { min-height: 44px; padding: 8px 12px; margin-bottom: 12px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; font: inherit; cursor: pointer; }
|
||||||
.settings-browser .set-row { min-height: 44px; }
|
.settings-browser .set-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, min(40%, 260px));
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.settings-browser .set-row > :is(span, input, select, button, output) { min-width: 0; max-width: 100%; }
|
||||||
|
.settings-browser .set-row > :is(input, select, button, output) { box-sizing: border-box; }
|
||||||
|
.settings-browser .set-control-wrap { display: block; width: 100%; min-width: 0; max-width: 100%; overflow: hidden; }
|
||||||
|
#listPane #cards .settings-browser .set-row select,
|
||||||
|
#listPane #cards .settings-browser .set-control-wrap select,
|
||||||
|
.settings-browser .set-row select,
|
||||||
|
.settings-browser .set-control-wrap select {
|
||||||
|
box-sizing: border-box;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding-right: 28px;
|
||||||
|
background-color: var(--bg-3);
|
||||||
|
background-image: linear-gradient(45deg, transparent 50%, var(--text-dim) 50%), linear-gradient(135deg, var(--text-dim) 50%, transparent 50%);
|
||||||
|
background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%;
|
||||||
|
background-size: 5px 5px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
.settings-browser .set-row > span:first-child { overflow-wrap: anywhere; }
|
||||||
|
.settings-browser .set-row:has(> span:only-child) { grid-template-columns: minmax(0, 1fr); }
|
||||||
.settings-browser .set-row small { font-size: 12px; max-width: 100%; }
|
.settings-browser .set-row small { font-size: 12px; max-width: 100%; }
|
||||||
.settings-browser .set-row > span { min-width: 0; }
|
.settings-browser .set-row > span { min-width: 0; }
|
||||||
.settings-browser .set-row input[type="range"] { max-width: 42%; }
|
.settings-browser .set-row input[type="range"] { width: calc(100% - 16px); max-width: calc(100% - 16px); min-width: 0; }
|
||||||
|
.settings-browser .set-row input[type="checkbox"] {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
justify-self: end;
|
||||||
|
width: 46px;
|
||||||
|
height: 28px;
|
||||||
|
min-width: 46px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: radial-gradient(circle at 14px 50%, var(--text) 0 9px, transparent 10px), var(--bg-3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .16s ease, border-color .16s ease;
|
||||||
|
}
|
||||||
|
.settings-browser .set-row input[type="checkbox"]:checked {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: radial-gradient(circle at 32px 50%, var(--on-accent, #fff) 0 9px, transparent 10px), var(--accent);
|
||||||
|
}
|
||||||
|
.settings-browser .set-row input[type="checkbox"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||||
|
.dsp-file-control {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.dsp-file-control input[type="file"] { position: absolute; width: 1px; height: 1px; min-width: 0; max-width: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||||
|
.dsp-file-control:focus-within { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 8px; }
|
||||||
|
.dsp-file-button { display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 7px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg-3); color: var(--text); font: 600 12px var(--ui); white-space: nowrap; }
|
||||||
|
.dsp-file-name { display: block; min-width: 0; overflow: hidden; color: var(--text-2); text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||||
|
.dsp-subsection .sp-note { min-width: 0; max-width: 100%; overflow-wrap: anywhere; }
|
||||||
|
.dsp-subsection > .btn { min-width: 0; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.dsp-actions { min-width: 0; max-width: 100%; }
|
||||||
|
.dsp-param-list { grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr)); }
|
||||||
|
.dsp-param-row input[type="range"] { width: calc(100% - 16px); min-width: 0; max-width: calc(100% - 16px); }
|
||||||
.settings-browser .settings-detail { display: none; min-width: 0; }
|
.settings-browser .settings-detail { display: none; min-width: 0; }
|
||||||
.settings-browser[data-detail="1"] .settings-overview { display: none; }
|
.settings-browser[data-detail="1"] .settings-overview { display: none; }
|
||||||
.settings-browser[data-detail="1"] .settings-detail { display: block; }
|
.settings-browser[data-detail="1"] .settings-detail { display: block; }
|
||||||
@@ -22,7 +116,7 @@
|
|||||||
.settings-grid { display: grid; grid-template-columns: minmax(160px, .8fr) minmax(0, 1.4fr); gap: 16px; }
|
.settings-grid { display: grid; grid-template-columns: minmax(160px, .8fr) minmax(0, 1.4fr); gap: 16px; }
|
||||||
.settings-browser .settings-overview, .settings-browser[data-detail="1"] .settings-overview, .settings-browser .settings-detail { display: block; }
|
.settings-browser .settings-overview, .settings-browser[data-detail="1"] .settings-overview, .settings-browser .settings-detail { display: block; }
|
||||||
.settings-back { display: none; }
|
.settings-back { display: none; }
|
||||||
.settings-browser .set-row { flex-wrap: wrap; gap: 12px; }
|
.settings-browser .set-row { flex-wrap: nowrap; gap: 12px; }
|
||||||
}
|
}
|
||||||
.download-job { padding: 14px 0; border-bottom: 1px solid var(--line); display: grid; gap: 8px; }
|
.download-job { padding: 14px 0; border-bottom: 1px solid var(--line); display: grid; gap: 8px; }
|
||||||
.download-job b { overflow-wrap: anywhere; }
|
.download-job b { overflow-wrap: anywhere; }
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
const url = URL.createObjectURL(gifBlob);
|
const url = URL.createObjectURL(gifBlob);
|
||||||
const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(cur())}s.gif`;
|
const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(cur())}s.gif`;
|
||||||
out.innerHTML = `<img class="sm-preview" alt="GIF preview" src="${url}" />
|
out.innerHTML = `<img class="sm-preview" alt="GIF preview" src="${url}" />
|
||||||
<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">⤓ Download</a>
|
<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">${MediaIcons.svg('download')} Download</a>
|
||||||
${navigator.canShare && navigator.canShare({ files: [new File([gifBlob], name, { type: 'image/gif' })] }) ? '<button class="np-btn" type="button" data-act="sharegif">↗ Share GIF</button>' : ''}</div>`;
|
${navigator.canShare && navigator.canShare({ files: [new File([gifBlob], name, { type: 'image/gif' })] }) ? '<button class="np-btn" type="button" data-act="sharegif">↗ Share GIF</button>' : ''}</div>`;
|
||||||
out.dataset.name = name;
|
out.dataset.name = name;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
out.dataset.name = name;
|
out.dataset.name = name;
|
||||||
out.dataset.type = fmt === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
|
out.dataset.type = fmt === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
|
||||||
out.innerHTML = `<audio controls src="${url}" class="sm-audio"></audio>
|
out.innerHTML = `<audio controls src="${url}" class="sm-audio"></audio>
|
||||||
<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">⤓ Download</a>
|
<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">${MediaIcons.svg('download')} Download</a>
|
||||||
${navigator.canShare && navigator.canShare({ files: [new File([biteBlob], name, { type: out.dataset.type })] }) ? '<button class="np-btn" type="button" data-act="sharebite">↗ Share</button>' : ''}</div>
|
${navigator.canShare && navigator.canShare({ files: [new File([biteBlob], name, { type: out.dataset.type })] }) ? '<button class="np-btn" type="button" data-act="sharebite">↗ Share</button>' : ''}</div>
|
||||||
${fmt === 'm4r' ? '<p class="sp-note">iPhone: share it to GarageBand (or drag into Finder with the phone connected) to set it as a ringtone.</p>' : ''}`;
|
${fmt === 'm4r' ? '<p class="sp-note">iPhone: share it to GarageBand (or drag into Finder with the phone connected) to set it as a ringtone.</p>' : ''}`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
showModal('📺 Open in another player', body, [{ label: 'Close', primary: true, onClick: closeModal }]);
|
showModal('📺 Open in another player', body, [{ label: 'Close', primary: true, onClick: closeModal }]);
|
||||||
const ok = await cached(meta.id);
|
const ok = await cached(meta.id);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
body.innerHTML = `<p class="modal-text">The server hasn't finished caching this video yet, and other players need that single-file copy. Keep it playing for a minute (or tap ⬇ Save), then try again.</p>`;
|
body.innerHTML = `<p class="modal-text">The server hasn't finished caching this video yet, and other players need that single-file copy. Keep it playing for a minute (or tap ${MediaIcons.svg('download')} Save), then try again.</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const url = streamUrl(meta.id);
|
const url = streamUrl(meta.id);
|
||||||
@@ -186,15 +186,15 @@
|
|||||||
<div class="sm-link"><span>Stream</span><code>${escapeHtml(url)}</code><button class="np-btn" type="button" data-act="copy">Copy</button></div>
|
<div class="sm-link"><span>Stream</span><code>${escapeHtml(url)}</code><button class="np-btn" type="button" data-act="copy">Copy</button></div>
|
||||||
<div class="ext-buttons">
|
<div class="ext-buttons">
|
||||||
${isAndroid ? `
|
${isAndroid ? `
|
||||||
<a class="np-btn" href="${escapeHtml(intent(url, title, 'org.videolan.vlc'))}">▶ VLC</a>
|
<a class="np-btn" href="${escapeHtml(intent(url, title, 'org.videolan.vlc'))}">${TransportIcons.svg('play')} VLC</a>
|
||||||
<a class="np-btn" href="${escapeHtml(intent(url, title, 'com.mxtech.videoplayer.ad'))}">▶ MX Player</a>
|
<a class="np-btn" href="${escapeHtml(intent(url, title, 'com.mxtech.videoplayer.ad'))}">${TransportIcons.svg('play')} MX Player</a>
|
||||||
<a class="np-btn" href="${escapeHtml(intent(url, title))}">▶ Open with…</a>`
|
<a class="np-btn" href="${escapeHtml(intent(url, title))}">${TransportIcons.svg('play')} Open with…</a>`
|
||||||
: isIOS ? `
|
: isIOS ? `
|
||||||
<a class="np-btn" href="vlc-x-callback://x-callback-url/stream?url=${encodeURIComponent(url)}">▶ VLC</a>
|
<a class="np-btn" href="vlc-x-callback://x-callback-url/stream?url=${encodeURIComponent(url)}">${TransportIcons.svg('play')} VLC</a>
|
||||||
<a class="np-btn" href="infuse://x-callback-url/play?url=${encodeURIComponent(url)}">▶ Infuse</a>`
|
<a class="np-btn" href="infuse://x-callback-url/play?url=${encodeURIComponent(url)}">${TransportIcons.svg('play')} Infuse</a>`
|
||||||
: `<a class="np-btn" href="vlc://${escapeHtml(url)}">▶ VLC</a>`}
|
: `<a class="np-btn" href="vlc://${escapeHtml(url)}">${TransportIcons.svg('play')} VLC</a>`}
|
||||||
<button class="np-btn" type="button" data-act="m3u">⤓ .m3u (this video)</button>
|
<button class="np-btn" type="button" data-act="m3u">${MediaIcons.svg('download')} .m3u (this video)</button>
|
||||||
${inList ? '<button class="np-btn" type="button" data-act="m3u-list">⤓ .m3u (whole list)</button>' : ''}
|
${inList ? `<button class="np-btn" type="button" data-act="m3u-list">${MediaIcons.svg('download')} .m3u (whole list)</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<p class="sp-note">${isAndroid ? 'VLC / MX Player must be installed; “Open with…” shows every video app.'
|
<p class="sp-note">${isAndroid ? 'VLC / MX Player must be installed; “Open with…” shows every video app.'
|
||||||
: isIOS ? 'Needs the VLC (or Infuse) app. The link streams from your server; nothing is downloaded first.'
|
: isIOS ? 'Needs the VLC (or Infuse) app. The link streams from your server; nothing is downloaded first.'
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ test('assets.json describes the complete existing shell exactly once with eager
|
|||||||
const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
|
const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
|
||||||
const described = new Set();
|
const described = new Set();
|
||||||
for (const [name, group] of Object.entries(groups)) {
|
for (const [name, group] of Object.entries(groups)) {
|
||||||
assert.equal(group.contract, 1, `${name} initial contract`);
|
assert.equal(group.contract, name === 'core' ? 2 : 1, `${name} contract (core boot packing changed)`);
|
||||||
assert.equal(typeof group.eager, 'boolean');
|
assert.equal(typeof group.eager, 'boolean');
|
||||||
for (const asset of group.files) {
|
for (const asset of group.files) {
|
||||||
assert.ok(!described.has(asset), `${asset} has one group`);
|
assert.ok(!described.has(asset), `${asset} has one group`);
|
||||||
@@ -42,7 +42,9 @@ test('assets.json describes the complete existing shell exactly once with eager
|
|||||||
}
|
}
|
||||||
for (const asset of shell) if (asset !== '/') assert.ok(described.has(asset), `${asset} is described`);
|
for (const asset of shell) if (asset !== '/') assert.ok(described.has(asset), `${asset} is described`);
|
||||||
// piano-engine is dynamically imported and historically absent from SHELL.
|
// piano-engine is dynamically imported and historically absent from SHELL.
|
||||||
for (const asset of described) assert.ok(listed.has(asset) || asset === '/piano-engine.mjs', `${asset} shell or documented dynamic import`);
|
// Piano and JamesDSP load only through build-local dynamic module URLs.
|
||||||
|
const dynamicOnly = new Set(['/piano-engine.mjs', ...groups['feature:jamesdsp'].files]);
|
||||||
|
for (const asset of described) assert.ok(listed.has(asset) || dynamicOnly.has(asset), `${asset} shell or documented dynamic import`);
|
||||||
assert.equal(groups.core.eager, true);
|
assert.equal(groups.core.eager, true);
|
||||||
for (const name of ['playlist', 'settings', 'service-mode', 'lyrics-editor']) {
|
for (const name of ['playlist', 'settings', 'service-mode', 'lyrics-editor']) {
|
||||||
assert.equal(groups[`feature:${name}`].eager, true);
|
assert.equal(groups[`feature:${name}`].eager, true);
|
||||||
@@ -70,6 +72,8 @@ test('only core scripts and styles remain eager; native fallback and active layo
|
|||||||
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
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));
|
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)) {
|
for(const match of html.matchAll(/<(?:script\b[^>]*\bsrc|link\b[^>]*\bhref)="([^"]+)"/gi)) {
|
||||||
|
// A style preload discovers bytes without applying or blocking another layout.
|
||||||
|
if(match[0].includes('rel="preload"'))continue;
|
||||||
const path='/'+match[1].replace(/^\//,'').split(/[?#]/)[0];
|
const path='/'+match[1].replace(/^\//,'').split(/[?#]/)[0];
|
||||||
assert.ok(!background.has(path),`${path} is not eager`);
|
assert.ok(!background.has(path),`${path} is not eager`);
|
||||||
}
|
}
|
||||||
@@ -88,3 +92,33 @@ test('layout-independent visibility and transport defaults stay in eager CSS',()
|
|||||||
for(const selector of ['.stg-overlay','.deck-chips','.np-btn.done .i-save','.sleep-status .sl-ic','.ab-group','.mc-tune'])assert.ok(shared.includes(selector));
|
for(const selector of ['.stg-overlay','.deck-chips','.np-btn.done .i-save','.sleep-status .sl-ic','.ab-group','.mc-tune'])assert.ok(shared.includes(selector));
|
||||||
assert.ok(html.includes('href="layout-base.css"'));
|
assert.ok(html.includes('href="layout-base.css"'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('classic first-visit style preload is discovered before the head loader without applying another layout',()=>{
|
||||||
|
const preload=html.match(/<link\b[^>]*rel="preload"[^>]*href="theme-classic.css"[^>]*as="style"[^>]*>/);
|
||||||
|
assert.ok(preload,'classic stylesheet has an early preload');
|
||||||
|
assert.ok(html.indexOf(preload[0])<html.indexOf('src="lazy.js"'));
|
||||||
|
assert.ok(!/<link\b[^>]*rel="stylesheet"[^>]*href="theme-classic.css"/.test(html),'the selected layout remains parser-applied by the loader');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('the last core script owns boot without an extra request or unsafe stale packing',()=>{
|
||||||
|
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||||
|
assert.equal(groups.core.contract,2,'old core packing cannot fall back without the loader');
|
||||||
|
assert.ok(groups.core.files.includes('/app.js'));
|
||||||
|
assert.ok(groups.core.files.includes('/section-rail.js'));
|
||||||
|
assert.equal(groups.core.files.includes('/app-bootstrap.js'),false);
|
||||||
|
assert.equal(listed.has('/app-bootstrap.js'),false);
|
||||||
|
const scripts=[...html.matchAll(/<script src="([^"]+)"/g)].map(m=>m[1]);
|
||||||
|
assert.equal(scripts.at(-1),'section-rail.js');
|
||||||
|
assert.ok(html.includes("'sha256-__APP_SCRIPT_HASH__'"));
|
||||||
|
assert.ok(readFileSync(join(__dirname,'section-rail.js'),'utf8').includes('root.AppBootstrap={ready}'));
|
||||||
|
});
|
||||||
|
test('runtime exclusion rules never remove shell or declared dynamic assets', async () => {
|
||||||
|
const { isRuntimeAsset } = await import('../server/asset-manifest.js');
|
||||||
|
const definition = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
|
||||||
|
for (const asset of new Set([...shell.filter(p => p !== '/'), ...Object.values(definition.groups).flatMap(g => g.files)])) {
|
||||||
|
assert.ok(isRuntimeAsset(asset, definition), `${asset} remains a runtime asset`);
|
||||||
|
}
|
||||||
|
for (const asset of ['/app.test.js', '/nested/probe.test.mjs', '/README.md', '/fonts/font-OFL.txt', '/admin.html', '/perf/fixture.js']) assert.equal(isRuntimeAsset(asset, definition), false, asset);
|
||||||
|
});
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ function renderSidebar() {
|
|||||||
let offlineBadge = '';
|
let offlineBadge = '';
|
||||||
if (stats.total > 0) {
|
if (stats.total > 0) {
|
||||||
if (stats.cached === stats.total) {
|
if (stats.cached === stats.total) {
|
||||||
offlineBadge = `<span class="pl-offline-badge all" title="Available offline">⬇</span>`;
|
offlineBadge = `<span class="pl-offline-badge all" title="Available offline">${MediaIcons.svg('offline')}</span>`;
|
||||||
} else if (stats.cached > 0) {
|
} else if (stats.cached > 0) {
|
||||||
offlineBadge = `<span class="pl-offline-badge partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`;
|
offlineBadge = `<span class="pl-offline-badge partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`;
|
||||||
}
|
}
|
||||||
@@ -141,7 +141,7 @@ function renderHomePlaylists() {
|
|||||||
let offlineHtml = '';
|
let offlineHtml = '';
|
||||||
if (stats.total > 0) {
|
if (stats.total > 0) {
|
||||||
if (stats.cached === stats.total) {
|
if (stats.cached === stats.total) {
|
||||||
offlineHtml = `<span class="home-pl-offline all" title="Available offline">⬇</span>`;
|
offlineHtml = `<span class="home-pl-offline all" title="Available offline">${MediaIcons.svg('offline')}</span>`;
|
||||||
} else if (stats.cached > 0) {
|
} else if (stats.cached > 0) {
|
||||||
offlineHtml = `<span class="home-pl-offline partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`;
|
offlineHtml = `<span class="home-pl-offline partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`;
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ function renderHomePlaylists() {
|
|||||||
${thumb ? `<img src="${escapeHtml(thumb)}" alt="" loading="lazy" decoding="async" />`
|
${thumb ? `<img src="${escapeHtml(thumb)}" alt="" loading="lazy" decoding="async" />`
|
||||||
: '<span class="home-pl-thumb-fallback">🎵</span>'}
|
: '<span class="home-pl-thumb-fallback">🎵</span>'}
|
||||||
${offlineHtml}
|
${offlineHtml}
|
||||||
<span class="home-pl-play" title="Play all">▶</span>
|
<span class="home-pl-play" title="Play all">${TransportIcons.svg('play')}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="home-pl-name"></span>
|
<span class="home-pl-name"></span>
|
||||||
<span class="home-pl-count">${count} video${count === 1 ? '' : 's'}</span>`;
|
<span class="home-pl-count">${count} video${count === 1 ? '' : 's'}</span>`;
|
||||||
@@ -162,7 +162,7 @@ function renderHomePlaylists() {
|
|||||||
card.title = pl.name;
|
card.title = pl.name;
|
||||||
card.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); });
|
card.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); });
|
||||||
card.querySelector('.home-pl-play').addEventListener('click', (e) => {
|
card.querySelector('.home-pl-play').addEventListener('click', (e) => {
|
||||||
e.stopPropagation(); // the ▶ badge plays; the rest of the card opens the list
|
e.stopPropagation(); // the play badge plays; the rest of the card opens the list
|
||||||
if (count) playFromList(pl.videos, 0, 'playlist:' + pl.id);
|
if (count) playFromList(pl.videos, 0, 'playlist:' + pl.id);
|
||||||
else toast('That playlist is empty');
|
else toast('That playlist is empty');
|
||||||
});
|
});
|
||||||
@@ -261,6 +261,7 @@ function updateNowPlayingActions() {
|
|||||||
{ const ch = $('npChannel'); if (ch) { const ok = isUploadId(id) || cachedIds.has(id); ch.classList.toggle('verified', ok); ch.title = ok ? 'Available on this device or the server' : ''; } }
|
{ const ch = $('npChannel'); if (ch) { const ok = isUploadId(id) || cachedIds.has(id); ch.classList.toggle('verified', ok); ch.title = ok ? 'Available on this device or the server' : ''; } }
|
||||||
const btn = els.saveBtn;
|
const btn = els.saveBtn;
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
btn.classList.toggle('saving', downloading.has(id));
|
||||||
if (removing.has(id)) {
|
if (removing.has(id)) {
|
||||||
setTile(btn, 'Removing…');
|
setTile(btn, 'Removing…');
|
||||||
btn.classList.remove('done');
|
btn.classList.remove('done');
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<div class="st-sub">${escapeHtml(deltaTxt)}${deltaTxt ? ' · ' : ''}${hrs(S.totalSec)} all time</div>
|
<div class="st-sub">${escapeHtml(deltaTxt)}${deltaTxt ? ' · ' : ''}${hrs(S.totalSec)} all time</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="st-tile">
|
<div class="st-tile">
|
||||||
<div class="st-label">▶ Track plays</div>
|
<div class="st-label">${TransportIcons.svg('play')} Track plays</div>
|
||||||
<div class="st-big">${S.totalPlays}</div>
|
<div class="st-big">${S.totalPlays}</div>
|
||||||
<div class="st-sub">${S.songsPlayed} song${S.songsPlayed === 1 ? '' : 's'} · ${playlistsWith} playlist${playlistsWith === 1 ? '' : 's'}</div>
|
<div class="st-sub">${S.songsPlayed} song${S.songsPlayed === 1 ? '' : 's'} · ${playlistsWith} playlist${playlistsWith === 1 ? '' : 's'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ body::after {
|
|||||||
background: linear-gradient(180deg, rgba(16,16,19,0.7), transparent);
|
background: linear-gradient(180deg, rgba(16,16,19,0.7), transparent);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
.search-form { flex: 1; display: flex; gap: 10px; max-width: 760px; position: relative; }
|
.search-form { flex: 1; display: flex; align-items: center; gap: 10px; max-width: 760px; position: relative; }
|
||||||
|
|
||||||
/* Recent-searches dropdown — device-local, kept in localStorage (RecentSearches in app.js) */
|
/* Recent-searches dropdown — device-local, kept in localStorage (RecentSearches in app.js) */
|
||||||
.search-recent {
|
.search-recent {
|
||||||
@@ -752,7 +752,7 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
|||||||
}
|
}
|
||||||
.np-btn:hover { color: var(--text); border-color: var(--accent); background: var(--bg-2); transform: translateY(-1px); }
|
.np-btn:hover { color: var(--text); border-color: var(--accent); background: var(--bg-2); transform: translateY(-1px); }
|
||||||
.np-btn:disabled { opacity: 0.6; cursor: default; transform: none; }
|
.np-btn:disabled { opacity: 0.6; cursor: default; transform: none; }
|
||||||
.np-btn.done { color: #7ee0a8; border-color: rgba(126,224,168,0.35); }
|
.np-btn.done { color: var(--accent-bright); border-color: var(--accent); }
|
||||||
.np-title {
|
.np-title {
|
||||||
font-family: var(--display);
|
font-family: var(--display);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -1019,7 +1019,7 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
|||||||
box-shadow: 0 0 26px -12px var(--accent-glow);
|
box-shadow: 0 0 26px -12px var(--accent-glow);
|
||||||
}
|
}
|
||||||
.card.playing::after {
|
.card.playing::after {
|
||||||
content: "▶ NOW PLAYING";
|
content: "NOW PLAYING";
|
||||||
position: absolute; top: 9px; right: 44px;
|
position: absolute; top: 9px; right: 44px;
|
||||||
font-family: var(--mono); font-size: 8px; font-weight: 700;
|
font-family: var(--mono); font-size: 8px; font-weight: 700;
|
||||||
letter-spacing: 0.1em; color: var(--accent);
|
letter-spacing: 0.1em; color: var(--accent);
|
||||||
@@ -1097,19 +1097,20 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
|||||||
display: none;
|
display: none;
|
||||||
align-items: center; justify-content: center;
|
align-items: center; justify-content: center;
|
||||||
width: 18px; height: 18px;
|
width: 18px; height: 18px;
|
||||||
color: #7ee0a8;
|
color: var(--accent-bright);
|
||||||
font-size: 12px; font-weight: 700;
|
font-size: 12px; font-weight: 700;
|
||||||
text-shadow: 0 1px 3px rgba(0,0,0,0.85);
|
text-shadow: 0 1px 3px rgba(0,0,0,0.85);
|
||||||
}
|
}
|
||||||
.card.cached .saved-badge { display: flex; }
|
.card.cached .saved-badge { display: flex; }
|
||||||
.card.downloading .saved-badge {
|
.card.downloading .saved-badge {
|
||||||
display: flex;
|
display: flex;
|
||||||
color: #ffc850;
|
color: var(--accent-bright);
|
||||||
animation: pulse 1s ease-in-out infinite;
|
|
||||||
}
|
}
|
||||||
.card.downloading .saved-badge::before { content: "⏳"; }
|
/* State changes reveal SVGs without rewriting badges or their handlers. */
|
||||||
.card.downloading .saved-badge { font-size: 0; }
|
.saved-badge .badge-progress { display:none; }
|
||||||
.card.downloading .saved-badge::before { font-size: 12px; }
|
.card.downloading .saved-badge .badge-offline { display:none; }
|
||||||
|
.card.downloading .saved-badge .badge-progress { display:block; }
|
||||||
|
.saved-badge .media-icon { width:16px; height:16px; filter:drop-shadow(0 1px 2px #000); }
|
||||||
|
|
||||||
/* Download progress bar on card thumb */
|
/* Download progress bar on card thumb */
|
||||||
.dl-progress {
|
.dl-progress {
|
||||||
@@ -1158,6 +1159,15 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
|||||||
.set-row > span { display: flex; flex-direction: column; gap: 3px; color: var(--text); font-size: 13.5px; font-weight: 500; }
|
.set-row > span { display: flex; flex-direction: column; gap: 3px; color: var(--text); font-size: 13.5px; font-weight: 500; }
|
||||||
.set-row small { color: var(--text-dim); font-size: 11.5px; font-weight: 400; line-height: 1.4; max-width: 280px; }
|
.set-row small { color: var(--text-dim); font-size: 11.5px; font-weight: 400; line-height: 1.4; max-width: 280px; }
|
||||||
.set-row input[type="range"] { width: 150px; }
|
.set-row input[type="range"] { width: 150px; }
|
||||||
|
.dsp-settings { margin-top: 8px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--bg-2); }
|
||||||
|
.dsp-settings .set-group-title { padding: 0 0 8px; }
|
||||||
|
.dsp-param-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 14px; }
|
||||||
|
.dsp-param-row input[type="range"] { width: min(46vw, 190px); }
|
||||||
|
.dsp-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 0; }
|
||||||
|
.dsp-actions .btn { color: var(--text); }
|
||||||
|
.dsp-subsection { margin: 14px 0; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg-1); }
|
||||||
|
.dsp-subsection h4 { margin: 0 0 8px; color: var(--text); }
|
||||||
|
.dsp-risk { padding: 9px 11px; border-radius: 8px; color: var(--text); background: color-mix(in srgb, var(--accent) 13%, var(--bg-2)); font-size: .9em; }
|
||||||
.set-row input[type="checkbox"] { width: 17px; height: 17px; accent-color: var(--accent); cursor: pointer; }
|
.set-row input[type="checkbox"] { width: 17px; height: 17px; accent-color: var(--accent); cursor: pointer; }
|
||||||
.about-version { color: var(--text-dim); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; user-select: text; }
|
.about-version { color: var(--text-dim); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; user-select: text; }
|
||||||
.set-select {
|
.set-select {
|
||||||
@@ -4257,6 +4267,30 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
|
|||||||
.search-form { min-width: 0; }
|
.search-form { min-width: 0; }
|
||||||
.search-form #searchInput { flex: 1 1 auto; min-width: 0; }
|
.search-form #searchInput { flex: 1 1 auto; min-width: 0; }
|
||||||
|
|
||||||
|
/* Eager toolbar geometry: these buttons exist before Car mode is loaded. */
|
||||||
|
.topbar :is(#voiceSearchBtn, #carModeBtn, #updateIconBtn) {
|
||||||
|
flex: none;
|
||||||
|
width: 44px; height: 44px; min-width: 44px; min-height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.topbar #updateIconBtn.hidden { display: none; }
|
||||||
|
.topbar :is(#voiceSearchBtn, #carModeBtn) svg { width: 22px; height: 22px; flex: none; }
|
||||||
|
.topbar :is(#voiceSearchBtn, #carModeBtn):hover { transform: none; }
|
||||||
|
@media (max-width: 430px) {
|
||||||
|
html[data-layout] .topbar, html[data-layout] #searchForm { gap: 4px; }
|
||||||
|
html[data-layout] #searchForm .search-btn { flex: none; padding-inline: 8px; }
|
||||||
|
html[data-layout="f7-swipe"] #searchInput { padding-right: 6px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
html[data-layout="bento-hub"] .topbar {
|
||||||
|
padding-top: calc(12px + env(safe-area-inset-top, 0px));
|
||||||
|
padding-left: max(12px, env(safe-area-inset-left, 0px));
|
||||||
|
padding-right: max(12px, env(safe-area-inset-right, 0px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* iPhone/iPad: no double-tap page zoom (scrolling unaffected) */
|
/* iPhone/iPad: no double-tap page zoom (scrolling unaffected) */
|
||||||
html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
|
html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
|
||||||
|
|
||||||
@@ -4307,3 +4341,27 @@ html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
|
|||||||
|
|
||||||
.lyric-primary { white-space: pre-line; }
|
.lyric-primary { white-space: pre-line; }
|
||||||
.lyric-secondary { display: block; white-space: pre-line; font-size: .62em; font-weight: 450; line-height: 1.2; opacity: .68; margin-top: .15em; }
|
.lyric-secondary { display: block; white-space: pre-line; font-size: .62em; font-weight: 450; line-height: 1.2; opacity: .68; margin-top: .15em; }
|
||||||
|
|
||||||
|
/* Media icons share definitions and follow the active layout's stroke family. */
|
||||||
|
.media-icon { display:inline-block; width:20px; height:20px; flex:none; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; }
|
||||||
|
.media-nav-icon { width:20px; height:20px; }
|
||||||
|
.empty-icon .media-icon { width:48px; height:48px; }
|
||||||
|
:is(.pl-offline-badge,.home-pl-offline) .media-icon { width:14px; height:14px; }
|
||||||
|
html[data-layout="classic"] .media-icon { stroke-width:1.8; }
|
||||||
|
html[data-layout="f7-swipe"] .media-icon { stroke-width:2; }
|
||||||
|
html[data-layout="glass-stage"] .media-icon { stroke-width:1.9; }
|
||||||
|
html[data-layout="bento-hub"] .media-icon { stroke-width:1.8; }
|
||||||
|
.np-btn .i-progress { display:none; }
|
||||||
|
.np-btn.saving :is(.i-save,.i-done) { display:none; }
|
||||||
|
.np-btn.saving .i-progress { display:inline; }
|
||||||
|
:is([data-media-icon="progress"], .np-btn.saving .media-icon) { animation:mediaSpin 1s linear infinite; transform-origin:center; }
|
||||||
|
@keyframes mediaSpin { to { transform:rotate(360deg); } }
|
||||||
|
@media(prefers-reduced-motion:reduce) { :is([data-media-icon="progress"], .np-btn.saving .media-icon) { animation:none; } }
|
||||||
|
.media-shape { display:none; }
|
||||||
|
.media-shape-classic { display:inline; }
|
||||||
|
html[data-layout="f7-swipe"] .media-shape-classic,
|
||||||
|
html[data-layout="glass-stage"] .media-shape-classic,
|
||||||
|
html[data-layout="bento-hub"] .media-shape-classic { display:none; }
|
||||||
|
html[data-layout="f7-swipe"] .media-shape-f7-swipe,
|
||||||
|
html[data-layout="glass-stage"] .media-shape-glass-stage,
|
||||||
|
html[data-layout="bento-hub"] .media-shape-bento-hub { display:inline; }
|
||||||
|
|||||||
@@ -250,14 +250,15 @@
|
|||||||
// Opt-in: without an askStatus the download path runs exactly as before.
|
// Opt-in: without an askStatus the download path runs exactly as before.
|
||||||
// (It must never be implicit — a probe that waits on a reply would hang
|
// (It must never be implicit — a probe that waits on a reply would hang
|
||||||
// forever if the caller injected a setTimeout that never fires.)
|
// forever if the caller injected a setTimeout that never fires.)
|
||||||
let precached = false;
|
let precached = false, offlineStatus;
|
||||||
if (waiting && askStatus) {
|
if (waiting && askStatus) {
|
||||||
try {
|
try {
|
||||||
const status = await askStatus(waiting);
|
const status = await askStatus(waiting);
|
||||||
precached = !!(status && status.ready);
|
precached = !!(status && status.ready);
|
||||||
|
offlineStatus = status;
|
||||||
} catch { precached = false; }
|
} catch { precached = false; }
|
||||||
}
|
}
|
||||||
if (onPhase) onPhase(precached ? 'ready' : 'downloading');
|
if (onPhase) onPhase(precached ? 'ready' : 'downloading', offlineStatus);
|
||||||
if (!precached && refreshShell) await refreshShell();
|
if (!precached && refreshShell) await refreshShell();
|
||||||
|
|
||||||
if (!guard()) throw new Error('Playing - update after this song / when paused');
|
if (!guard()) throw new Error('Playing - update after this song / when paused');
|
||||||
@@ -280,7 +281,25 @@
|
|||||||
waiting.postMessage({ type: 'SKIP_WAITING' });
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const SwUpdate = { applyUpdate, refreshShellInPlace, askCacheStatus, BUST_PARAM };
|
function showOfflineStatus(status) {
|
||||||
|
const el = root.document?.getElementById('offlineAssetStatus');
|
||||||
|
if (el && typeof status?.total === 'number') {
|
||||||
|
el.textContent = `${status.offlineReady ? 'Offline ready' : 'Preparing offline'} ${status.cached}/${status.total}`;
|
||||||
|
} else if (el && typeof status?.ready === 'boolean') {
|
||||||
|
el.textContent = status.ready ? 'Offline ready (legacy cache)' : 'Offline cache incomplete (legacy mode)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function offlineStatus() {
|
||||||
|
const registration = await root.navigator?.serviceWorker?.getRegistration();
|
||||||
|
const status = await askCacheStatus(registration?.active);
|
||||||
|
showOfflineStatus(status);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
root.navigator?.serviceWorker?.addEventListener?.('message', event => {
|
||||||
|
if (event.data?.type === 'OFFLINE_STATUS') showOfflineStatus(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
const SwUpdate = { offlineStatus, showOfflineStatus, applyUpdate, refreshShellInPlace, askCacheStatus, BUST_PARAM };
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
module.exports = SwUpdate;
|
module.exports = SwUpdate;
|
||||||
|
|||||||
@@ -368,3 +368,22 @@ test('an unanswered worker status cannot fall through to an empty legacy refresh
|
|||||||
vm.runInNewContext(fs.readFileSync(__dirname+'/sw-update.js','utf8'),sandbox);
|
vm.runInNewContext(fs.readFileSync(__dirname+'/sw-update.js','utf8'),sandbox);
|
||||||
await assert.rejects(sandbox.SwUpdate.refreshShellInPlace({cachesApi:{open:async()=>({}),keys:async()=>[]},fetchFn:async()=>({ok:true,json:async()=>({buildTag:'new'})}),isShellCache:()=>true}),/Worker update status unavailable/);
|
await assert.rejects(sandbox.SwUpdate.refreshShellInPlace({cachesApi:{open:async()=>({}),keys:async()=>[]},fetchFn:async()=>({ok:true,json:async()=>({buildTag:'new'})}),isShellCache:()=>true}),/Worker update status unavailable/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('update readiness retains completion progress without blocking the ready fast path',async()=>{
|
||||||
|
const phases=[],container=fakeContainer();let downloaded=false;
|
||||||
|
const status={ready:true,offlineReady:false,cached:90,total:151,missingFiles:['/optional.js']};
|
||||||
|
await applyUpdate({reg:{waiting:{postMessage(){}}},container,askStatus:async()=>status,
|
||||||
|
onPhase:(phase,progress)=>phases.push({phase,progress}),refreshShell:async()=>{downloaded=true;},setTimeout:()=>{}});
|
||||||
|
assert.equal(downloaded,false);assert.equal(phases[0].phase,'ready');assert.deepStrictEqual(phases[0].progress,status);
|
||||||
|
});
|
||||||
|
test('offline cache status reports preparing and complete counts in Settings',()=>{
|
||||||
|
const vm=require('node:vm'),fs=require('node:fs'),element={};
|
||||||
|
const sandbox={document:{getElementById:()=>element}};
|
||||||
|
vm.runInNewContext(fs.readFileSync(__dirname+'/sw-update.js','utf8'),sandbox);
|
||||||
|
sandbox.SwUpdate.showOfflineStatus({offlineReady:false,cached:90,total:151});
|
||||||
|
assert.equal(element.textContent,'Preparing offline 90/151');
|
||||||
|
sandbox.SwUpdate.showOfflineStatus({offlineReady:true,cached:151,total:151});
|
||||||
|
assert.equal(element.textContent,'Offline ready 151/151');
|
||||||
|
sandbox.SwUpdate.showOfflineStatus({ready:true});assert.equal(element.textContent,'Offline ready (legacy cache)');
|
||||||
|
});
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ const SHELL = [
|
|||||||
'/glass-panel-layout.js',
|
'/glass-panel-layout.js',
|
||||||
'/theme-classic.css',
|
'/theme-classic.css',
|
||||||
'/recommendations.css',
|
'/recommendations.css',
|
||||||
|
'/audio-graph.js',
|
||||||
'/flag-ui.js',
|
'/flag-ui.js',
|
||||||
'/export.js',
|
'/export.js',
|
||||||
'/async-guard.js',
|
'/async-guard.js',
|
||||||
@@ -264,6 +265,9 @@ self.addEventListener('fetch', (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The page schedules completion after first paint/idle via Lazy.warm(). Do
|
||||||
|
// not start a six-file background burst from this foreground fetch event.
|
||||||
|
|
||||||
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
|
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
|
||||||
if (request.method !== 'GET' && request.method !== 'HEAD') return;
|
if (request.method !== 'GET' && request.method !== 'HEAD') return;
|
||||||
|
|
||||||
@@ -300,8 +304,48 @@ self.addEventListener('fetch', (e) => {
|
|||||||
|
|
||||||
// ---- Message: handle SKIP_WAITING from the client ----
|
// ---- Message: handle SKIP_WAITING from the client ----
|
||||||
const playingClients = new Map(), deferredClients = new Set();
|
const playingClients = new Map(), deferredClients = new Set();
|
||||||
let activeLayout = 'classic', layoutReply, warmTask;
|
let activeLayout = 'classic', layoutReply;
|
||||||
|
let completionTask;
|
||||||
|
function completeAssets(force = false, requestedOptIns) {
|
||||||
|
if (!ASSET_SYNC) return Promise.resolve();
|
||||||
|
if (completionTask) {
|
||||||
|
if (!requestedOptIns) return completionTask;
|
||||||
|
return completionTask.finally(() => completeAssets(true, requestedOptIns));
|
||||||
|
}
|
||||||
|
completionTask ||= (async () => {
|
||||||
|
const cache = await caches.open(AssetSyncCore.CACHE);
|
||||||
|
const m = (await AssetSyncCore.state(cache))?.current;
|
||||||
|
if (!m || m.buildTag !== VERSION) return;
|
||||||
|
const optIns = requestedOptIns || await AssetSyncCore.readOptIns(cache);
|
||||||
|
const job = await AssetSyncCore.complete(m, { cache, fetchFn: fetch, force, optIns,
|
||||||
|
notify: async status => {
|
||||||
|
for (const client of await self.clients.matchAll({ type: 'window' }))
|
||||||
|
client.postMessage({ type: 'OFFLINE_STATUS', ...status });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!job.offlineReady) {
|
||||||
|
// Chromium can wake this job without a page; Safari resumes on the next
|
||||||
|
// navigation/online message. No timers outside an event lifetime.
|
||||||
|
try { await self.registration.sync?.register('ytp-offline-complete'); } catch {}
|
||||||
|
}
|
||||||
|
})().catch(error => console.warn('[asset-sync] completion paused', error.message))
|
||||||
|
.finally(() => { completionTask = null; });
|
||||||
|
return completionTask;
|
||||||
|
}
|
||||||
|
self.addEventListener('sync', e => {
|
||||||
|
if (e.tag === 'ytp-offline-complete') e.waitUntil(completeAssets());
|
||||||
|
});
|
||||||
self.addEventListener('message', (e) => {
|
self.addEventListener('message', (e) => {
|
||||||
|
if (ASSET_SYNC && e.data?.type === 'OFFLINE_OPT_INS' && Array.isArray(e.data.values)) {
|
||||||
|
e.waitUntil((async () => {
|
||||||
|
const cache = await caches.open(AssetSyncCore.CACHE);
|
||||||
|
const current = (await AssetSyncCore.state(cache))?.current || await candidate(cache);
|
||||||
|
const allowed = new Set(Object.values(current?.groups || {}).map(group => group.offlineOptIn).filter(Boolean));
|
||||||
|
const values = [...new Set(e.data.values.filter(value => typeof value === 'string' && allowed.has(value)))];
|
||||||
|
await cache.put(AssetSyncCore.OPT_INS, Response.json(values));
|
||||||
|
await completeAssets(true, values);
|
||||||
|
})());
|
||||||
|
}
|
||||||
if (ASSET_SYNC && e.data?.type === 'LAYOUT' && e.source && typeof e.data.value === 'string') {
|
if (ASSET_SYNC && e.data?.type === 'LAYOUT' && e.source && typeof e.data.value === 'string') {
|
||||||
activeLayout = e.data.value;
|
activeLayout = e.data.value;
|
||||||
layoutReply?.();
|
layoutReply?.();
|
||||||
@@ -315,14 +359,8 @@ self.addEventListener('message', (e) => {
|
|||||||
}
|
}
|
||||||
})());
|
})());
|
||||||
}
|
}
|
||||||
if (ASSET_SYNC && e.data?.type === 'WARM_ASSETS' && !e.data.saveData) {
|
if (ASSET_SYNC && ['WARM_ASSETS', 'COMPLETE_ASSETS'].includes(e.data?.type)) {
|
||||||
// Boot, activation and multiple tabs can report idle together. Share the
|
e.waitUntil(completeAssets(e.data.type === 'COMPLETE_ASSETS'));
|
||||||
// verified pool so they never download the same missing group twice.
|
|
||||||
warmTask ||= (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)).finally(()=>{warmTask=null;});
|
|
||||||
e.waitUntil(warmTask);
|
|
||||||
}
|
}
|
||||||
if (e.data && e.data.type === 'PLAYING' && e.source) {
|
if (e.data && e.data.type === 'PLAYING' && e.source) {
|
||||||
playingClients.set(e.source.id, !!e.data.value);
|
playingClients.set(e.source.id, !!e.data.value);
|
||||||
@@ -339,6 +377,7 @@ self.addEventListener('message', (e) => {
|
|||||||
// deletes its cache on failure, but a cache can also be evicted under
|
// deletes its cache on failure, but a cache can also be evicted under
|
||||||
// storage pressure, so the files are actually checked.
|
// storage pressure, so the files are actually checked.
|
||||||
if (e.data && e.data.type === 'CACHE_STATUS') {
|
if (e.data && e.data.type === 'CACHE_STATUS') {
|
||||||
|
if (ASSET_SYNC) e.waitUntil(completeAssets());
|
||||||
e.waitUntil((async () => {
|
e.waitUntil((async () => {
|
||||||
if (ASSET_SYNC) {
|
if (ASSET_SYNC) {
|
||||||
let reply = { ready: false, missing: -1, version: VERSION };
|
let reply = { ready: false, missing: -1, version: VERSION };
|
||||||
@@ -347,7 +386,7 @@ self.addEventListener('message', (e) => {
|
|||||||
const pending = await candidate(cache), m = pending || state?.current;
|
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'});
|
if(m) reply = await AssetSyncCore.status(m,cache,{previous:pending ? state?.current : state?.previous,activeLayout:e.data.activeLayout || (pending ? m.activeLayout : activeLayout) || 'classic'});
|
||||||
} catch {}
|
} catch {}
|
||||||
reply.type = 'CACHE_STATUS'; reply.assetSync = true;
|
reply.type = 'CACHE_STATUS'; reply.assetSync = true; reply.completing = !!completionTask && !reply.offlineReady;
|
||||||
if(e.ports && e.ports[0]) e.ports[0].postMessage(reply); else if(e.source) e.source.postMessage(reply);
|
if(e.ports && e.ports[0]) e.ports[0].postMessage(reply); else if(e.source) e.source.postMessage(reply);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -513,6 +552,10 @@ async function installAssets() {
|
|||||||
for(const name of layouts)for(const dependency of m.groups[name].dependencies || [])layouts.add(dependency);
|
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 AssetSyncCore.syncAssets(m,{cache,fetchFn:fetchAsset,groups:[...layouts]});
|
||||||
}
|
}
|
||||||
|
// Copy and hash legacy shell entries before the candidate reaches `installed`.
|
||||||
|
// A legacy page's Refresh UI then activates quickly, before its four-second
|
||||||
|
// reload safety timer can send it back into the old shell.
|
||||||
|
m.legacyCacheNames = await migrateLegacy(cache,m);
|
||||||
await cache.put(candidateKey, new Response(JSON.stringify(m)));
|
await cache.put(candidateKey, new Response(JSON.stringify(m)));
|
||||||
}
|
}
|
||||||
async function migrateLegacy(cache, m) {
|
async function migrateLegacy(cache, m) {
|
||||||
@@ -543,7 +586,11 @@ async function migrateLegacy(cache, m) {
|
|||||||
async function activateAssets() {
|
async function activateAssets() {
|
||||||
const cache = await caches.open(AssetSyncCore.CACHE), m = await candidate(cache);
|
const cache = await caches.open(AssetSyncCore.CACHE), m = await candidate(cache);
|
||||||
if (!m) throw Error('Missing candidate manifest');
|
if (!m) throw Error('Missing candidate manifest');
|
||||||
const names = await migrateLegacy(cache,m), old = await AssetSyncCore.state(cache);
|
// Legacy migration is staged by installAssets. Keep the fallback for
|
||||||
|
// candidates created by an older worker version.
|
||||||
|
const names = m.legacyCacheNames || await migrateLegacy(cache,m);
|
||||||
|
delete m.legacyCacheNames;
|
||||||
|
const old = await AssetSyncCore.state(cache);
|
||||||
const previousClients = (await self.clients.matchAll({type:'window'})).map(c=>c.id);
|
const previousClients = (await self.clients.matchAll({type:'window'})).map(c=>c.id);
|
||||||
await AssetSyncCore.commit(m,cache,{previousClients,activeLayout:m.activeLayout || activeLayout});
|
await AssetSyncCore.commit(m,cache,{previousClients,activeLayout:m.activeLayout || activeLayout});
|
||||||
// Legacy deletion occurs strictly after verified commit.
|
// Legacy deletion occurs strictly after verified commit.
|
||||||
@@ -551,6 +598,9 @@ async function activateAssets() {
|
|||||||
for (const req of await cache.keys()) if(new URL(req.url).pathname.startsWith('/__ytp_candidate/')) await cache.delete(req);
|
for (const req of await cache.keys()) if(new URL(req.url).pathname.startsWith('/__ytp_candidate/')) await cache.delete(req);
|
||||||
await self.clients.claim();
|
await self.clients.claim();
|
||||||
if(old && old.current.buildTag !== m.buildTag) for(const c of await self.clients.matchAll({type:'window',includeUncontrolled:true})) c.postMessage({type:'SW_UPDATE_AVAILABLE',version:VERSION});
|
if(old && old.current.buildTag !== m.buildTag) for(const c of await self.clients.matchAll({type:'window',includeUncontrolled:true})) c.postMessage({type:'SW_UPDATE_AVAILABLE',version:VERSION});
|
||||||
|
// Resume completion after activation, but with one request at a time so
|
||||||
|
// foreground navigation retains most of the available bandwidth.
|
||||||
|
self.registration.active.postMessage({ type: 'COMPLETE_ASSETS' });
|
||||||
try { console.info('[asset-sync] storage', await self.navigator.storage.estimate()); } catch {}
|
try { console.info('[asset-sync] storage', await self.navigator.storage.estimate()); } catch {}
|
||||||
}
|
}
|
||||||
async function assetFetch(request, clientId) {
|
async function assetFetch(request, clientId) {
|
||||||
@@ -575,7 +625,13 @@ async function assetFetch(request, clientId) {
|
|||||||
if(state.current.files[path]?.h === h) {
|
if(state.current.files[path]?.h === h) {
|
||||||
const previousKey=AssetSyncCore.fallback(state.current,state.previous,path);
|
const previousKey=AssetSyncCore.fallback(state.current,state.previous,path);
|
||||||
const previousResponse=previousKey && await cache.match(previousKey);
|
const previousResponse=previousKey && await cache.match(previousKey);
|
||||||
if(previousResponse && previousResponse.headers.get('X-Asset-Hash')===state.previous.files[path].h) return previousResponse;
|
if(previousResponse && previousResponse.headers.get('X-Asset-Hash')===state.previous.files[path].h) {
|
||||||
|
// A stale body returned under a new URL must not poison the browser's
|
||||||
|
// resource cache either. Its decoded stream has no transport encoding.
|
||||||
|
const headers=new Headers(previousResponse.headers);headers.set('Cache-Control','no-store');
|
||||||
|
for(const name of ['Content-Encoding','Content-Length','Transfer-Encoding'])headers.delete(name);
|
||||||
|
return new Response(previousResponse.body,{status:previousResponse.status,headers});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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 { 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}); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ html[data-layout="bento-hub"] .player-pane.empty .player-stage { border:0; borde
|
|||||||
html[data-layout="bento-hub"] :is(#controls,.now-meta,.notes-panel,.upnext,.related-panel) { flex-shrink:0; padding:16px; border:1px solid var(--line); border-radius:16px; background:var(--bg-1); box-shadow:var(--shadow-sm); }
|
html[data-layout="bento-hub"] :is(#controls,.now-meta,.notes-panel,.upnext,.related-panel) { flex-shrink:0; padding:16px; border:1px solid var(--line); border-radius:16px; background:var(--bg-1); box-shadow:var(--shadow-sm); }
|
||||||
html[data-layout="bento-hub"] #controls { gap:12px; }
|
html[data-layout="bento-hub"] #controls { gap:12px; }
|
||||||
html[data-layout="bento-hub"] #controls .deck-main { gap:8px; }
|
html[data-layout="bento-hub"] #controls .deck-main { gap:8px; }
|
||||||
html[data-layout="bento-hub"] #controls .deck-main .ctrl { width:44px; height:44px; min-width:44px; padding:10px; background:var(--bg-2); color:var(--text); border:1px solid var(--line); border-radius:999px; box-shadow:none; }
|
html[data-layout="bento-hub"] #controls .deck-main .ctrl { width:44px; height:44px; min-width:44px; padding:10px; background:var(--bg-2); color:var(--text); border:1px solid var(--line); border-radius:16px; box-shadow:none; }
|
||||||
html[data-layout="bento-hub"] #controls .deck-main .ctrl.play { width:56px; height:56px; padding:14px; background:var(--accent); color:var(--on-accent); border:0; box-shadow:0 6px 18px -4px var(--accent-glow); }
|
html[data-layout="bento-hub"] #controls .deck-main .ctrl.play { width:56px; height:56px; padding:14px; background:var(--accent); color:var(--on-accent); border:0; box-shadow:0 6px 18px -4px var(--accent-glow); }
|
||||||
html[data-layout="bento-hub"] #controls .deck-main .ctrl.play svg { width:28px; height:28px; }
|
html[data-layout="bento-hub"] #controls .deck-main .ctrl.play svg { width:28px; height:28px; }
|
||||||
html[data-layout="bento-hub"] #controls .deck-main .ctrl.active { color:var(--accent-sec); border-color:var(--accent-sec); }
|
html[data-layout="bento-hub"] #controls .deck-main .ctrl.active { color:var(--accent-sec); border-color:var(--accent-sec); }
|
||||||
@@ -314,7 +314,7 @@ html[data-layout="bento-hub"] .hero-title { background:none; -webkit-text-fill-c
|
|||||||
html[data-layout="bento-hub"] .ph-logo { width:56px; height:56px; border-radius:16px; color:var(--on-accent); animation:none; }
|
html[data-layout="bento-hub"] .ph-logo { width:56px; height:56px; border-radius:16px; color:var(--on-accent); animation:none; }
|
||||||
html[data-layout="bento-hub"] .player-pane.empty :is(#controls,#nowPlayingMeta,#notesPanel,.upnext) { display:none; }
|
html[data-layout="bento-hub"] .player-pane.empty :is(#controls,#nowPlayingMeta,#notesPanel,.upnext) { display:none; }
|
||||||
html[data-layout="bento-hub"] .body:has(.cards[data-view="settings"]) #listFilterBar { display:none; }
|
html[data-layout="bento-hub"] .body:has(.cards[data-view="settings"]) #listFilterBar { display:none; }
|
||||||
html[data-layout="bento-hub"] #controls .deck-main .ctrl { border-radius:999px; }
|
html[data-layout="bento-hub"] #controls .deck-main .ctrl { border-radius:16px; }
|
||||||
html[data-layout="bento-hub"] #controls input[type="range"]::-webkit-slider-thumb { border:0; box-shadow:var(--shadow-sm); }
|
html[data-layout="bento-hub"] #controls input[type="range"]::-webkit-slider-thumb { border:0; box-shadow:var(--shadow-sm); }
|
||||||
html[data-layout="bento-hub"] #nowPlayingMeta .np-actions :is(#addBtn,#saveBtn,#exportBtn,#serviceBtn) { border:1px solid var(--line); border-radius:10px; background:var(--bg-2); color:var(--text); box-shadow:none; min-width:44px; }
|
html[data-layout="bento-hub"] #nowPlayingMeta .np-actions :is(#addBtn,#saveBtn,#exportBtn,#serviceBtn) { border:1px solid var(--line); border-radius:10px; background:var(--bg-2); color:var(--text); box-shadow:none; min-width:44px; }
|
||||||
html[data-layout="bento-hub"] #nowPlayingMeta .np-actions #saveBtn.done { color:var(--accent-sec); }
|
html[data-layout="bento-hub"] #nowPlayingMeta .np-actions #saveBtn.done { color:var(--accent-sec); }
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ html[data-layout="glass-stage"] .stg-skip, html[data-layout="glass-stage"] .stg-
|
|||||||
html[data-layout="glass-stage"] .stg-skip { width: 46px; height: 46px; border-radius: 50%; background: rgba(0, 0, 0, 0.45); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); }
|
html[data-layout="glass-stage"] .stg-skip { width: 46px; height: 46px; border-radius: 50%; background: rgba(0, 0, 0, 0.45); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); }
|
||||||
html[data-layout="glass-stage"] .stg-skip svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
html[data-layout="glass-stage"] .stg-skip svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
html[data-layout="glass-stage"] .stg-play { width: 62px; height: 62px; border-radius: 50%; border: 0; background: linear-gradient(135deg, #ff553c, #ff8471); box-shadow: 0 0 34px rgba(255, 85, 60, 0.6); }
|
html[data-layout="glass-stage"] .stg-play { width: 62px; height: 62px; border-radius: 50%; border: 0; background: linear-gradient(135deg, #ff553c, #ff8471); box-shadow: 0 0 34px rgba(255, 85, 60, 0.6); }
|
||||||
html[data-layout="glass-stage"] .stg-play svg { width: 32px; height: 32px; fill: currentColor; stroke: none; }
|
html[data-layout="glass-stage"] .stg-play svg { width: 32px; height: 32px; fill: none; stroke: currentColor; stroke-width:1.5; }
|
||||||
|
|
||||||
/* ---- 2. deck ---- */
|
/* ---- 2. deck ---- */
|
||||||
html[data-layout="glass-stage"] #controls { padding: 16px; gap: 14px; }
|
html[data-layout="glass-stage"] #controls { padding: 16px; gap: 14px; }
|
||||||
@@ -95,7 +95,7 @@ html[data-layout="glass-stage"] #controls .deck-main { display: flex; justify-co
|
|||||||
html[data-layout="glass-stage"] #controls .deck-main .ctrl { width: 46px; height: 46px; border-radius: 16px; background: var(--tile); border: 1px solid var(--glass-line); }
|
html[data-layout="glass-stage"] #controls .deck-main .ctrl { width: 46px; height: 46px; border-radius: 16px; background: var(--tile); border: 1px solid var(--glass-line); }
|
||||||
html[data-layout="glass-stage"] #controls .deck-main .ctrl.skip10 { display: grid; width: 44px; height: 44px; color: var(--text-2); }
|
html[data-layout="glass-stage"] #controls .deck-main .ctrl.skip10 { display: grid; width: 44px; height: 44px; color: var(--text-2); }
|
||||||
html[data-layout="glass-stage"] #controls .deck-main .ctrl.skip10 svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
html[data-layout="glass-stage"] #controls .deck-main .ctrl.skip10 svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
html[data-layout="glass-stage"] #controls .deck-main .ctrl.play { width: 64px; height: 64px; border-radius: 50%; border: 0; background: var(--grad); box-shadow: 0 12px 30px rgba(255, 85, 60, 0.55), 0 0 20px rgba(255, 85, 60, 0.35); }
|
html[data-layout="glass-stage"] #controls .deck-main .ctrl.play { width: 64px; height: 64px; border-radius: 50%; border: 1px solid var(--glass-line); background: var(--tile); backdrop-filter:blur(12px); box-shadow:none; }
|
||||||
html[data-layout="glass-stage"] #controls .deck-main .ctrl.play svg { width: 32px; height: 32px; }
|
html[data-layout="glass-stage"] #controls .deck-main .ctrl.play svg { width: 32px; height: 32px; }
|
||||||
html[data-layout="glass-stage"] #controls .time { font: 600 12px var(--mono); }
|
html[data-layout="glass-stage"] #controls .time { font: 600 12px var(--mono); }
|
||||||
|
|
||||||
|
|||||||
48
frontend/transport-icons.test.js
Normal file
48
frontend/transport-icons.test.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const {test}=require('node:test');
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const {readFileSync,readdirSync}=require('node:fs');
|
||||||
|
const {join}=require('node:path');
|
||||||
|
const vm=require('node:vm');
|
||||||
|
const {transport}=require('./download-actions');
|
||||||
|
test('transport symbols cover all actions and layouts with monochrome SVG',()=>{
|
||||||
|
for(const kind of ['play','pause','previous','next','back10','forward10','back15','forward']){
|
||||||
|
assert.match(transport.svg(kind),new RegExp(`href="#transport-${kind}"`));
|
||||||
|
assert.match(transport.svg(kind),/stroke="currentColor"/);
|
||||||
|
assert.equal((transport.svg(kind).match(/<use /g)||[]).length,4,'layout selection lives on visible use elements');
|
||||||
|
for(const layout of ['classic','f7-swipe','glass-stage','bento-hub'])assert.ok(transport.variants[layout][kind]);
|
||||||
|
}
|
||||||
|
assert.throws(()=>transport.svg('unknown'),/Unknown transport/);
|
||||||
|
});
|
||||||
|
test('playback UI swaps shared symbols and accessible names without changing handlers',()=>{
|
||||||
|
const buttons=Object.fromEntries(['playBtn','stgPlay','miniPlayBtn','servicePlayBtn'].map(id=>[id,{onclick:()=>id,setAttribute(k,v){this[k]=v;}}]));
|
||||||
|
const handlers=Object.values(buttons).map(b=>b.onclick);let changes=0;
|
||||||
|
const Player={master:{paused:true}},classes=[];
|
||||||
|
const app=readFileSync(join(__dirname,'app.js'),'utf8');
|
||||||
|
const body=app.slice(app.indexOf('function updatePlayBtn() {'),app.indexOf('// "⚠ Broken"'));
|
||||||
|
const sandbox={Player,TransportIcons:transport,Remote:{changed(){changes++;}},$:id=>buttons[id],document:{querySelector:()=>({classList:{toggle:(...args)=>classes.push(args)}})}};
|
||||||
|
vm.runInNewContext(body,sandbox);
|
||||||
|
for(const paused of [true,false,true]){
|
||||||
|
Player.master.paused=paused;sandbox.updatePlayBtn();
|
||||||
|
for(const b of Object.values(buttons)){assert.match(b.innerHTML,new RegExp(`#transport-${paused?'play':'pause'}`));assert.equal(b['aria-label'],paused?'Play':'Pause');}
|
||||||
|
}
|
||||||
|
assert.equal(changes,3);assert.deepEqual(Object.values(buttons).map(b=>b.onclick),handlers);
|
||||||
|
assert.deepEqual(classes,[['is-paused',true],['is-paused',false],['is-paused',true]]);
|
||||||
|
});
|
||||||
|
test('runtime sources never render legacy transport glyphs',()=>{
|
||||||
|
const failures=[];
|
||||||
|
for(const file of readdirSync(__dirname).filter(f=>/\.(js|html|css)$/.test(f)&&!f.endsWith('.test.js'))){
|
||||||
|
readFileSync(join(__dirname,file),'utf8').split('\n').forEach((line,i)=>{
|
||||||
|
// Legacy input decoder accepts old labels; its output uses shared symbols.
|
||||||
|
if(file==='glass-controls.js'&&line.startsWith(' const glyphs = '))return;
|
||||||
|
if(/[▶⏸⏮⏭⏪⏩⏯◀❚]/u.test(line))failures.push(`${file}:${i+1}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.deepEqual(failures,[]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state changes preserve named SVG nodes used by editor controls',()=>{
|
||||||
|
let icon={id:'playIcon'};
|
||||||
|
const button={querySelector(){return icon;},set innerHTML(value){this.markup=value;icon={id:''};},setAttribute(name,value){this[name]=value;}};
|
||||||
|
transport.set(button,'pause');
|
||||||
|
assert.equal(icon.id,'playIcon');assert.equal(button['aria-label'],'Pause');assert.match(button.markup,/#transport-pause-f7-swipe/);
|
||||||
|
});
|
||||||
674
frontend/vendor/jamesdsp/LICENSE.txt
vendored
Normal file
674
frontend/vendor/jamesdsp/LICENSE.txt
vendored
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
17
frontend/vendor/jamesdsp/README.md
vendored
Normal file
17
frontend/vendor/jamesdsp/README.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# JamesDSP vendor files
|
||||||
|
|
||||||
|
The browser port's worklet, 8–96 kHz streaming sample-rate adapter, matrix
|
||||||
|
convolver, HRTF/virtual-speaker spatial stage, generated IRs and IndexedDB IR
|
||||||
|
store are pinned to the source revision in VERSION.txt. Runtime files stay in
|
||||||
|
the lazy `feature:jamesdsp` group. A user must enable the engine or choose
|
||||||
|
“Download audio effects for offline” before the offline-completion job caches
|
||||||
|
them, including the WASM binary.
|
||||||
|
|
||||||
|
The adapted wrapper resolves dependencies through `Lazy.url()` and routes the
|
||||||
|
worklet through the existing AudioGraph. It rejects Liveprog, DDC and arbitrary
|
||||||
|
text effects. Impulse responses are limited to 65,536 internal frames; lower-
|
||||||
|
power devices use a 24,000-frame cap and warn before larger responses are used.
|
||||||
|
|
||||||
|
The port is GPL-3.0-or-later (`LICENSE.txt`). The upstream license snapshot says
|
||||||
|
GPL-2.0; both supplied notices remain in this directory pending upstream
|
||||||
|
clarification. See VERSION.txt for the adaptation record.
|
||||||
340
frontend/vendor/jamesdsp/UPSTREAM-MAIN-LICENSE.txt
vendored
Normal file
340
frontend/vendor/jamesdsp/UPSTREAM-MAIN-LICENSE.txt
vendored
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 2, June 1991
|
||||||
|
|
||||||
|
Copyright (C) 1989, 1991 Free Software Foundation, Inc., <http://fsf.org/>
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The licenses for most software are designed to take away your
|
||||||
|
freedom to share and change it. By contrast, the GNU General Public
|
||||||
|
License is intended to guarantee your freedom to share and change free
|
||||||
|
software--to make sure the software is free for all its users. This
|
||||||
|
General Public License applies to most of the Free Software
|
||||||
|
Foundation's software and to any other program whose authors commit to
|
||||||
|
using it. (Some other Free Software Foundation software is covered by
|
||||||
|
the GNU Lesser General Public License instead.) You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
this service if you wish), that you receive source code or can get it
|
||||||
|
if you want it, that you can change the software or use pieces of it
|
||||||
|
in new free programs; and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to make restrictions that forbid
|
||||||
|
anyone to deny you these rights or to ask you to surrender the rights.
|
||||||
|
These restrictions translate to certain responsibilities for you if you
|
||||||
|
distribute copies of the software, or if you modify it.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must give the recipients all the rights that
|
||||||
|
you have. You must make sure that they, too, receive or can get the
|
||||||
|
source code. And you must show them these terms so they know their
|
||||||
|
rights.
|
||||||
|
|
||||||
|
We protect your rights with two steps: (1) copyright the software, and
|
||||||
|
(2) offer you this license which gives you legal permission to copy,
|
||||||
|
distribute and/or modify the software.
|
||||||
|
|
||||||
|
Also, for each author's protection and ours, we want to make certain
|
||||||
|
that everyone understands that there is no warranty for this free
|
||||||
|
software. If the software is modified by someone else and passed on, we
|
||||||
|
want its recipients to know that what they have is not the original, so
|
||||||
|
that any problems introduced by others will not reflect on the original
|
||||||
|
authors' reputations.
|
||||||
|
|
||||||
|
Finally, any free program is threatened constantly by software
|
||||||
|
patents. We wish to avoid the danger that redistributors of a free
|
||||||
|
program will individually obtain patent licenses, in effect making the
|
||||||
|
program proprietary. To prevent this, we have made it clear that any
|
||||||
|
patent must be licensed for everyone's free use or not licensed at all.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. This License applies to any program or other work which contains
|
||||||
|
a notice placed by the copyright holder saying it may be distributed
|
||||||
|
under the terms of this General Public License. The "Program", below,
|
||||||
|
refers to any such program or work, and a "work based on the Program"
|
||||||
|
means either the Program or any derivative work under copyright law:
|
||||||
|
that is to say, a work containing the Program or a portion of it,
|
||||||
|
either verbatim or with modifications and/or translated into another
|
||||||
|
language. (Hereinafter, translation is included without limitation in
|
||||||
|
the term "modification".) Each licensee is addressed as "you".
|
||||||
|
|
||||||
|
Activities other than copying, distribution and modification are not
|
||||||
|
covered by this License; they are outside its scope. The act of
|
||||||
|
running the Program is not restricted, and the output from the Program
|
||||||
|
is covered only if its contents constitute a work based on the
|
||||||
|
Program (independent of having been made by running the Program).
|
||||||
|
Whether that is true depends on what the Program does.
|
||||||
|
|
||||||
|
1. You may copy and distribute verbatim copies of the Program's
|
||||||
|
source code as you receive it, in any medium, provided that you
|
||||||
|
conspicuously and appropriately publish on each copy an appropriate
|
||||||
|
copyright notice and disclaimer of warranty; keep intact all the
|
||||||
|
notices that refer to this License and to the absence of any warranty;
|
||||||
|
and give any other recipients of the Program a copy of this License
|
||||||
|
along with the Program.
|
||||||
|
|
||||||
|
You may charge a fee for the physical act of transferring a copy, and
|
||||||
|
you may at your option offer warranty protection in exchange for a fee.
|
||||||
|
|
||||||
|
2. You may modify your copy or copies of the Program or any portion
|
||||||
|
of it, thus forming a work based on the Program, and copy and
|
||||||
|
distribute such modifications or work under the terms of Section 1
|
||||||
|
above, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) You must cause the modified files to carry prominent notices
|
||||||
|
stating that you changed the files and the date of any change.
|
||||||
|
|
||||||
|
b) You must cause any work that you distribute or publish, that in
|
||||||
|
whole or in part contains or is derived from the Program or any
|
||||||
|
part thereof, to be licensed as a whole at no charge to all third
|
||||||
|
parties under the terms of this License.
|
||||||
|
|
||||||
|
c) If the modified program normally reads commands interactively
|
||||||
|
when run, you must cause it, when started running for such
|
||||||
|
interactive use in the most ordinary way, to print or display an
|
||||||
|
announcement including an appropriate copyright notice and a
|
||||||
|
notice that there is no warranty (or else, saying that you provide
|
||||||
|
a warranty) and that users may redistribute the program under
|
||||||
|
these conditions, and telling the user how to view a copy of this
|
||||||
|
License. (Exception: if the Program itself is interactive but
|
||||||
|
does not normally print such an announcement, your work based on
|
||||||
|
the Program is not required to print an announcement.)
|
||||||
|
|
||||||
|
These requirements apply to the modified work as a whole. If
|
||||||
|
identifiable sections of that work are not derived from the Program,
|
||||||
|
and can be reasonably considered independent and separate works in
|
||||||
|
themselves, then this License, and its terms, do not apply to those
|
||||||
|
sections when you distribute them as separate works. But when you
|
||||||
|
distribute the same sections as part of a whole which is a work based
|
||||||
|
on the Program, the distribution of the whole must be on the terms of
|
||||||
|
this License, whose permissions for other licensees extend to the
|
||||||
|
entire whole, and thus to each and every part regardless of who wrote it.
|
||||||
|
|
||||||
|
Thus, it is not the intent of this section to claim rights or contest
|
||||||
|
your rights to work written entirely by you; rather, the intent is to
|
||||||
|
exercise the right to control the distribution of derivative or
|
||||||
|
collective works based on the Program.
|
||||||
|
|
||||||
|
In addition, mere aggregation of another work not based on the Program
|
||||||
|
with the Program (or with a work based on the Program) on a volume of
|
||||||
|
a storage or distribution medium does not bring the other work under
|
||||||
|
the scope of this License.
|
||||||
|
|
||||||
|
3. You may copy and distribute the Program (or a work based on it,
|
||||||
|
under Section 2) in object code or executable form under the terms of
|
||||||
|
Sections 1 and 2 above provided that you also do one of the following:
|
||||||
|
|
||||||
|
a) Accompany it with the complete corresponding machine-readable
|
||||||
|
source code, which must be distributed under the terms of Sections
|
||||||
|
1 and 2 above on a medium customarily used for software interchange; or,
|
||||||
|
|
||||||
|
b) Accompany it with a written offer, valid for at least three
|
||||||
|
years, to give any third party, for a charge no more than your
|
||||||
|
cost of physically performing source distribution, a complete
|
||||||
|
machine-readable copy of the corresponding source code, to be
|
||||||
|
distributed under the terms of Sections 1 and 2 above on a medium
|
||||||
|
customarily used for software interchange; or,
|
||||||
|
|
||||||
|
c) Accompany it with the information you received as to the offer
|
||||||
|
to distribute corresponding source code. (This alternative is
|
||||||
|
allowed only for noncommercial distribution and only if you
|
||||||
|
received the program in object code or executable form with such
|
||||||
|
an offer, in accord with Subsection b above.)
|
||||||
|
|
||||||
|
The source code for a work means the preferred form of the work for
|
||||||
|
making modifications to it. For an executable work, complete source
|
||||||
|
code means all the source code for all modules it contains, plus any
|
||||||
|
associated interface definition files, plus the scripts used to
|
||||||
|
control compilation and installation of the executable. However, as a
|
||||||
|
special exception, the source code distributed need not include
|
||||||
|
anything that is normally distributed (in either source or binary
|
||||||
|
form) with the major components (compiler, kernel, and so on) of the
|
||||||
|
operating system on which the executable runs, unless that component
|
||||||
|
itself accompanies the executable.
|
||||||
|
|
||||||
|
If distribution of executable or object code is made by offering
|
||||||
|
access to copy from a designated place, then offering equivalent
|
||||||
|
access to copy the source code from the same place counts as
|
||||||
|
distribution of the source code, even though third parties are not
|
||||||
|
compelled to copy the source along with the object code.
|
||||||
|
|
||||||
|
4. You may not copy, modify, sublicense, or distribute the Program
|
||||||
|
except as expressly provided under this License. Any attempt
|
||||||
|
otherwise to copy, modify, sublicense or distribute the Program is
|
||||||
|
void, and will automatically terminate your rights under this License.
|
||||||
|
However, parties who have received copies, or rights, from you under
|
||||||
|
this License will not have their licenses terminated so long as such
|
||||||
|
parties remain in full compliance.
|
||||||
|
|
||||||
|
5. You are not required to accept this License, since you have not
|
||||||
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Program or its derivative works. These actions are
|
||||||
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Program (or any work based on the
|
||||||
|
Program), you indicate your acceptance of this License to do so, and
|
||||||
|
all its terms and conditions for copying, distributing or modifying
|
||||||
|
the Program or works based on it.
|
||||||
|
|
||||||
|
6. Each time you redistribute the Program (or any work based on the
|
||||||
|
Program), the recipient automatically receives a license from the
|
||||||
|
original licensor to copy, distribute or modify the Program subject to
|
||||||
|
these terms and conditions. You may not impose any further
|
||||||
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
|
You are not responsible for enforcing compliance by third parties to
|
||||||
|
this License.
|
||||||
|
|
||||||
|
7. If, as a consequence of a court judgment or allegation of patent
|
||||||
|
infringement or for any other reason (not limited to patent issues),
|
||||||
|
conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot
|
||||||
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you
|
||||||
|
may not distribute the Program at all. For example, if a patent
|
||||||
|
license would not permit royalty-free redistribution of the Program by
|
||||||
|
all those who receive copies directly or indirectly through you, then
|
||||||
|
the only way you could satisfy both it and this License would be to
|
||||||
|
refrain entirely from distribution of the Program.
|
||||||
|
|
||||||
|
If any portion of this section is held invalid or unenforceable under
|
||||||
|
any particular circumstance, the balance of the section is intended to
|
||||||
|
apply and the section as a whole is intended to apply in other
|
||||||
|
circumstances.
|
||||||
|
|
||||||
|
It is not the purpose of this section to induce you to infringe any
|
||||||
|
patents or other property right claims or to contest validity of any
|
||||||
|
such claims; this section has the sole purpose of protecting the
|
||||||
|
integrity of the free software distribution system, which is
|
||||||
|
implemented by public license practices. Many people have made
|
||||||
|
generous contributions to the wide range of software distributed
|
||||||
|
through that system in reliance on consistent application of that
|
||||||
|
system; it is up to the author/donor to decide if he or she is willing
|
||||||
|
to distribute software through any other system and a licensee cannot
|
||||||
|
impose that choice.
|
||||||
|
|
||||||
|
This section is intended to make thoroughly clear what is believed to
|
||||||
|
be a consequence of the rest of this License.
|
||||||
|
|
||||||
|
8. If the distribution and/or use of the Program is restricted in
|
||||||
|
certain countries either by patents or by copyrighted interfaces, the
|
||||||
|
original copyright holder who places the Program under this License
|
||||||
|
may add an explicit geographical distribution limitation excluding
|
||||||
|
those countries, so that distribution is permitted only in or among
|
||||||
|
countries not thus excluded. In such case, this License incorporates
|
||||||
|
the limitation as if written in the body of this License.
|
||||||
|
|
||||||
|
9. The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program
|
||||||
|
specifies a version number of this License which applies to it and "any
|
||||||
|
later version", you have the option of following the terms and conditions
|
||||||
|
either of that version or of any later version published by the Free
|
||||||
|
Software Foundation. If the Program does not specify a version number of
|
||||||
|
this License, you may choose any version ever published by the Free Software
|
||||||
|
Foundation.
|
||||||
|
|
||||||
|
10. If you wish to incorporate parts of the Program into other free
|
||||||
|
programs whose distribution conditions are different, write to the author
|
||||||
|
to ask for permission. For software which is copyrighted by the Free
|
||||||
|
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||||
|
make exceptions for this. Our decision will be guided by the two goals
|
||||||
|
of preserving the free status of all derivatives of our free software and
|
||||||
|
of promoting the sharing and reuse of software generally.
|
||||||
|
|
||||||
|
NO WARRANTY
|
||||||
|
|
||||||
|
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||||
|
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||||
|
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||||
|
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||||
|
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||||
|
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||||
|
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||||
|
REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||||
|
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||||
|
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||||
|
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||||
|
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||||
|
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
convey the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
{description}
|
||||||
|
Copyright (C) {year} {fullname}
|
||||||
|
|
||||||
|
This program is free software; you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation; either version 2 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along
|
||||||
|
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program is interactive, make it output a short notice like this
|
||||||
|
when it starts in an interactive mode:
|
||||||
|
|
||||||
|
Gnomovision version 69, Copyright (C) year name of author
|
||||||
|
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, the commands you use may
|
||||||
|
be called something other than `show w' and `show c'; they could even be
|
||||||
|
mouse-clicks or menu items--whatever suits your program.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or your
|
||||||
|
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||||
|
necessary. Here is a sample; alter the names:
|
||||||
|
|
||||||
|
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||||
|
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||||
|
|
||||||
|
{signature of Ty Coon}, 1 April 1989
|
||||||
|
Ty Coon, President of Vice
|
||||||
|
|
||||||
|
This General Public License does not permit incorporating your program into
|
||||||
|
proprietary programs. If your program is a subroutine library, you may
|
||||||
|
consider it more useful to permit linking proprietary applications with the
|
||||||
|
library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License.
|
||||||
|
|
||||||
18
frontend/vendor/jamesdsp/VERSION.txt
vendored
Normal file
18
frontend/vendor/jamesdsp/VERSION.txt
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
JamesDSP browser port source revision: a982b53c4ab347d1d9c50f4ce7ee3c2735eaa817
|
||||||
|
Repository: /home/josh/development/personal/jamesdsp-web (main)
|
||||||
|
Vendored source files: web/jamesdsp-node.js, params.js, ir-utils.js,
|
||||||
|
ir-builtins.js, ir-store.js, worklet.js, dsp/jamesdsp.js and dsp/jamesdsp.wasm.
|
||||||
|
|
||||||
|
Integration adaptations: wrapper dependencies and WASM URLs use the page's
|
||||||
|
manifest-hashed Lazy.url map; the worklet statically imports the pinned
|
||||||
|
jamesdsp-a982b53.mjs module because AudioWorkletGlobalScope rejects dynamic
|
||||||
|
import. The wrapper keeps the shared AudioGraph route and does not create a
|
||||||
|
MediaElementSource. Liveprog, DDC and arbitrary text effects are rejected and
|
||||||
|
not serialized in ytplayer. All runtime code remains in lazy feature:jamesdsp;
|
||||||
|
its offline completion membership requires the explicit jamesDsp opt-in.
|
||||||
|
|
||||||
|
The generated engine, parameter tables, IR utilities/store and built-in IR set
|
||||||
|
are copied from the pinned revision. The uploaded/generated IR cap is 65,536
|
||||||
|
frames at the internal 44.1/48 kHz rate (low-power devices use 24,000 frames).
|
||||||
|
License provenance: LICENSE.txt is the port's GPL-3.0-or-later license. The
|
||||||
|
upstream snapshot at upstream/Main/LICENSE contains GPL-2.0 text; see README.md.
|
||||||
2
frontend/vendor/jamesdsp/dsp/jamesdsp-a982b53.mjs
vendored
Normal file
2
frontend/vendor/jamesdsp/dsp/jamesdsp-a982b53.mjs
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
frontend/vendor/jamesdsp/dsp/jamesdsp.wasm
vendored
Executable file
BIN
frontend/vendor/jamesdsp/dsp/jamesdsp.wasm
vendored
Executable file
Binary file not shown.
22
frontend/vendor/jamesdsp/ir-builtins.mjs
vendored
Normal file
22
frontend/vendor/jamesdsp/ir-builtins.mjs
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
const definitions={
|
||||||
|
'Small room':{seconds:.5,rt60:.25,seed:0x51a11},
|
||||||
|
Hall:{seconds:2,rt60:1.4,seed:0x4a11},
|
||||||
|
Plate:{seconds:1.5,rt60:1,seed:0x91a7e},
|
||||||
|
Cathedral:{seconds:5,rt60:4,seed:0xca7ed},
|
||||||
|
};
|
||||||
|
export const BUILTIN_IRS=Object.freeze(Object.fromEntries(Object.entries(definitions).map(([name,d])=>[name,Object.freeze({...d,version:1})])));
|
||||||
|
export function generateIR(name,sampleRate,channels=2,{maxFrames=65536}={}) {
|
||||||
|
const spec=BUILTIN_IRS[name];if(!spec||!Number.isInteger(sampleRate)||sampleRate<8000||sampleRate>96000||![1,2,4].includes(channels)) throw new RangeError('Unsupported generated IR');
|
||||||
|
const frames=Math.max(1,Math.min(Math.round(spec.seconds*sampleRate),maxFrames));let seed=spec.seed>>>0;
|
||||||
|
const paths=Array.from({length:channels},()=>new Float32Array(frames));
|
||||||
|
const next=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/0x100000000*2-1;};
|
||||||
|
for(let path=0;path<channels;path++) {
|
||||||
|
const x=paths[path];x[0]=1;
|
||||||
|
for(let tap=1;tap<Math.min(24,frames);tap++) {const at=Math.min(frames-1,Math.round(sampleRate*(.004+tap*.009+next()*.001)));x[at]+=(next()*.22)/(1+tap*.25);}
|
||||||
|
for(let i=1;i<frames;i++) {const t=i/sampleRate, fade=Math.min(1,t/.012,(frames-i)/Math.max(1,sampleRate*.02));x[i]+=next()*.045*Math.exp(-6.91*t/spec.rt60)*Math.max(0,fade);}
|
||||||
|
}
|
||||||
|
let peak=0;for(const path of paths) for(const value of path) peak=Math.max(peak,Math.abs(value));
|
||||||
|
for(const path of paths) for(let i=0;i<path.length;i++) path[i]*=.5/peak;
|
||||||
|
return {name,sampleRate,numberOfChannels:channels,length:frames,seed:spec.seed,version:1,truncated:frames<spec.seconds*sampleRate,getChannelData:i=>paths[i]};
|
||||||
|
}
|
||||||
24
frontend/vendor/jamesdsp/ir-store.mjs
vendored
Normal file
24
frontend/vendor/jamesdsp/ir-store.mjs
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
const DB='jamesdsp-ir', STORE='irs', VERSION=1;
|
||||||
|
function openDB() {
|
||||||
|
if(!globalThis.indexedDB) return Promise.reject(new Error('IndexedDB is unavailable'));
|
||||||
|
return new Promise((resolve,reject)=>{const request=indexedDB.open(DB,VERSION);request.onupgradeneeded=()=>request.result.createObjectStore(STORE,{keyPath:'name'});request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||||
|
}
|
||||||
|
async function transaction(mode,action) {
|
||||||
|
const db=await openDB();return new Promise((resolve,reject)=>{const tx=db.transaction(STORE,mode),store=tx.objectStore(STORE);let result;try {result=action(store);} catch(error){db.close();reject(error);return;}tx.oncomplete=()=>{db.close();resolve(result);};tx.onerror=()=>{db.close();reject(tx.error);};tx.onabort=()=>{db.close();reject(tx.error||new Error('IR storage transaction aborted'));};});
|
||||||
|
}
|
||||||
|
async function digest(data) {const hash=await crypto.subtle.digest('SHA-256',data);return [...new Uint8Array(hash)].map(x=>x.toString(16).padStart(2,'0')).join('');}
|
||||||
|
export async function saveIR(name,buffer,metadata={}) {
|
||||||
|
if(typeof name!=='string'||!name.trim()||name.length>100||![1,2,4].includes(buffer?.numberOfChannels)||!Number.isFinite(buffer.sampleRate)||buffer.sampleRate<8000||buffer.sampleRate>96000||!buffer.length) throw new TypeError('Invalid named impulse response');
|
||||||
|
const internalRate=buffer.sampleRate===44100?44100:48000;
|
||||||
|
if(Math.round(buffer.length*internalRate/buffer.sampleRate)>65536) throw new RangeError('Stored impulse response exceeds the 65,536-frame internal limit');
|
||||||
|
const channels=Array.from({length:buffer.numberOfChannels},(_,i)=>new Float32Array(buffer.getChannelData(i)));
|
||||||
|
const pcm=new Float32Array(channels.length*buffer.length);for(let i=0;i<buffer.length;i++)for(let c=0;c<channels.length;c++)pcm[i*channels.length+c]=channels[c][i];
|
||||||
|
const record={name:name.trim(),sampleRate:buffer.sampleRate,channels:channels.length,frames:buffer.length,pcm,metadata:{...metadata},hash:await digest(pcm.buffer),version:1};
|
||||||
|
await transaction('readwrite',s=>s.put(record));return {name:record.name,hash:record.hash};
|
||||||
|
}
|
||||||
|
export async function getIR(name) {
|
||||||
|
const db=await openDB();return new Promise((resolve,reject)=>{const r=db.transaction(STORE,'readonly').objectStore(STORE).get(name);r.onsuccess=async()=>{try {const x=r.result;if(!x){resolve(null);return;}if(await digest(x.pcm.buffer)!==x.hash)throw new Error('Stored IR hash mismatch');const channels=Array.from({length:x.channels},()=>new Float32Array(x.frames));for(let i=0;i<x.frames;i++)for(let c=0;c<x.channels;c++)channels[c][i]=x.pcm[i*x.channels+c];resolve({...x,buffer:{sampleRate:x.sampleRate,numberOfChannels:x.channels,length:x.frames,getChannelData:i=>channels[i]}});}catch(e){reject(e);}finally{db.close();}};r.onerror=()=>{db.close();reject(r.error);};});
|
||||||
|
}
|
||||||
|
export async function listIRs() {const db=await openDB();return new Promise((resolve,reject)=>{const r=db.transaction(STORE,'readonly').objectStore(STORE).getAll();r.onsuccess=()=>{db.close();resolve(r.result.map(({name,hash,sampleRate,channels,frames,metadata})=>({name,hash,sampleRate,channels,frames,metadata})));};r.onerror=()=>{db.close();reject(r.error);};});}
|
||||||
|
export async function deleteIR(name) {await transaction('readwrite',s=>s.delete(name));}
|
||||||
43
frontend/vendor/jamesdsp/ir-utils.mjs
vendored
Normal file
43
frontend/vendor/jamesdsp/ir-utils.mjs
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
const copyBuffer=(buffer,rate=buffer.sampleRate)=>{
|
||||||
|
const channels=Array.from({length:buffer.numberOfChannels},(_,i)=>new Float32Array(buffer.getChannelData(i)));
|
||||||
|
return {sampleRate:rate,numberOfChannels:channels.length,length:channels[0]?.length||0,getChannelData:i=>channels[i]};
|
||||||
|
};
|
||||||
|
export async function decodeIR(context,bytes) {
|
||||||
|
const decoded=await context.decodeAudioData(bytes.slice(0));
|
||||||
|
return resampleIR(decoded,context.sampleRate);
|
||||||
|
}
|
||||||
|
export function resampleIR(buffer,targetRate) {
|
||||||
|
if(!Number.isFinite(targetRate)||targetRate<8000||targetRate>96000||!buffer?.numberOfChannels||buffer.numberOfChannels>4||!buffer.length) throw new RangeError('Invalid IR or target sample rate');
|
||||||
|
if(buffer.sampleRate===targetRate) return copyBuffer(buffer,targetRate);
|
||||||
|
const length=Math.max(1,Math.round(buffer.length*targetRate/buffer.sampleRate));
|
||||||
|
const channels=[];
|
||||||
|
for(let ch=0;ch<buffer.numberOfChannels;ch++) {
|
||||||
|
const input=buffer.getChannelData(ch), output=new Float32Array(length), ratio=buffer.sampleRate/targetRate;
|
||||||
|
for(let i=0;i<length;i++) {const x=i*ratio, a=Math.min(Math.floor(x),input.length-1), b=Math.min(a+1,input.length-1), f=x-a;output[i]=input[a]*(1-f)+input[b]*f;}
|
||||||
|
channels.push(output);
|
||||||
|
}
|
||||||
|
return {sampleRate:targetRate,numberOfChannels:channels.length,length,getChannelData:i=>channels[i]};
|
||||||
|
}
|
||||||
|
export function trimIR(buffer,{thresholdDb=-80}={}) {
|
||||||
|
if(!Number.isFinite(thresholdDb)||thresholdDb>0||thresholdDb<-160) throw new RangeError('Invalid trim threshold');
|
||||||
|
const channels=Array.from({length:buffer.numberOfChannels},(_,i)=>buffer.getChannelData(i));
|
||||||
|
const threshold=10**(thresholdDb/20);let first=buffer.length,last=-1;
|
||||||
|
for(let i=0;i<buffer.length;i++) {let peak=0;for(const channel of channels) peak=Math.max(peak,Math.abs(channel[i]));if(peak>=threshold){first=Math.min(first,i);last=i;}}
|
||||||
|
if(last<0) first=last=0;
|
||||||
|
const result=channels.map(channel=>channel.slice(first,last+1));
|
||||||
|
return {buffer:{sampleRate:buffer.sampleRate,numberOfChannels:result.length,length:result[0]?.length||0,getChannelData:i=>result[i]},startFrame:first,endFrame:last+1};
|
||||||
|
}
|
||||||
|
export function normalizeIR(buffer,{peak=0.5}={}) {
|
||||||
|
if(!Number.isFinite(peak)||peak<=0||peak>1) throw new RangeError('Peak must be in (0, 1]');
|
||||||
|
const channels=Array.from({length:buffer.numberOfChannels},(_,i)=>new Float32Array(buffer.getChannelData(i)));
|
||||||
|
let max=0;for(const channel of channels) for(const value of channel) max=Math.max(max,Math.abs(value));
|
||||||
|
const gain=max===0?1:peak/max;for(const channel of channels) for(let i=0;i<channel.length;i++) channel[i]*=gain;
|
||||||
|
return {sampleRate:buffer.sampleRate,numberOfChannels:channels.length,length:channels[0]?.length||0,getChannelData:i=>channels[i]};
|
||||||
|
}
|
||||||
|
export function prepareIR(context,buffer,{trim=false,normalize=true,peak=0.5,thresholdDb=-80}={}) {
|
||||||
|
let result=resampleIR(buffer,context.sampleRate);
|
||||||
|
if(trim) result=trimIR(result,{thresholdDb}).buffer;
|
||||||
|
if(normalize) result=normalizeIR(result,{peak});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
246
frontend/vendor/jamesdsp/jamesdsp-feature.js
vendored
Normal file
246
frontend/vendor/jamesdsp/jamesdsp-feature.js
vendored
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
/* Lazy classic-script bridge for the optional JamesDSP ES module and worklet. */
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let irToolsPromise;
|
||||||
|
const irTools = () => irToolsPromise ||= Promise.all([
|
||||||
|
import(root.Lazy.url('/vendor/jamesdsp/ir-utils.mjs')),
|
||||||
|
import(root.Lazy.url('/vendor/jamesdsp/ir-builtins.mjs')),
|
||||||
|
import(root.Lazy.url('/vendor/jamesdsp/ir-store.mjs')),
|
||||||
|
]).then(([utils,builtins,store])=>({utils,builtins,store}));
|
||||||
|
|
||||||
|
const PRESETS = Object.freeze({
|
||||||
|
flat: { name: 'Flat', params: {} },
|
||||||
|
bass: { name: 'Bass', params: { 'eq.enabled': 1, 'eq.band0': 4, 'eq.band1': 3, 'bass.enabled': 1, 'bass.boost': 5, gain: -3 } },
|
||||||
|
vocal: { name: 'Vocal', params: { 'eq.enabled': 1, 'eq.band5': 2, 'eq.band7': 3, 'eq.band9': 2, 'compressor.enabled': 1 } },
|
||||||
|
loudness: { name: 'Loudness', params: { 'limiter.threshold': -3, gain: 2 } },
|
||||||
|
headphone: { name: 'Headphone', params: { 'crossfeed.enabled': 1, 'crossfeed.mode': 1 } },
|
||||||
|
bright: { name: 'Bright', params: { 'eq.enabled': 1, 'eq.band9': 2, 'eq.band10': 3, 'eq.band11': 3, 'eq.band12': 2 } },
|
||||||
|
warm: { name: 'Warm', params: { 'eq.enabled': 1, 'eq.band0': 3, 'eq.band1': 2, 'eq.band7': -1, gain: -2 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const clampGain = value => Math.max(-24, Math.min(12, Number(value) || 0));
|
||||||
|
|
||||||
|
function createController(options) {
|
||||||
|
const graph = options.graph;
|
||||||
|
let desired = false, active = false, bypassed = false, nodeEntry = null, gainNode = null, context = null;
|
||||||
|
let elements = [], standardInput = null, levelDb = 0, settings = null;
|
||||||
|
let tail = Promise.resolve(), maxLiveHeaps = 0, liveHeaps = 0, nodesCreated = 0, nodesDestroyed = 0, loadedIRRef = null;
|
||||||
|
let faultListener = null, notifying = false, dspAnalyser = null;
|
||||||
|
|
||||||
|
const serial = fn => {
|
||||||
|
const result = tail.then(fn, fn);
|
||||||
|
tail = result.catch(() => {});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
function snapshot() { return { active, desired, bypassed, route: active && !bypassed ? 'jamesdsp' : 'standard', liveHeaps, maxLiveHeaps, nodesCreated, nodesDestroyed, sampleRate: context?.sampleRate || null, irRef: loadedIRRef }; }
|
||||||
|
|
||||||
|
function routeStandard() {
|
||||||
|
if (!elements.length) return;
|
||||||
|
try {
|
||||||
|
graph.routeAll(elements, standardInput || context?.destination);
|
||||||
|
} catch (error) {
|
||||||
|
try { if (context?.destination) graph.routeAll(elements, context.destination); } catch { /* direct graph route is the last audible fallback */ }
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseNode() {
|
||||||
|
const entry = nodeEntry;
|
||||||
|
nodeEntry = null;
|
||||||
|
if (faultListener && entry) entry.node.removeEventListener('error', faultListener);
|
||||||
|
faultListener = null;
|
||||||
|
try { entry?.node.node.disconnect(); } catch { /* already disconnected */ }
|
||||||
|
try { gainNode?.disconnect(); } catch { /* already disconnected */ }
|
||||||
|
gainNode = null;
|
||||||
|
try { dspAnalyser?.disconnect(); } catch { /* already disconnected */ }
|
||||||
|
dspAnalyser = null;
|
||||||
|
if (entry) {
|
||||||
|
try { entry.node.destroy(); } catch { /* continue freeing references */ }
|
||||||
|
loadedIRRef = null;
|
||||||
|
if (entry.live) { entry.live = false; liveHeaps = Math.max(0, liveHeaps - 1); nodesDestroyed++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(error) {
|
||||||
|
desired = false;
|
||||||
|
active = false;
|
||||||
|
bypassed = false;
|
||||||
|
try { routeStandard(); } catch { /* graph route has already tried a direct audible fallback */ }
|
||||||
|
releaseNode();
|
||||||
|
if (!notifying) {
|
||||||
|
notifying = true;
|
||||||
|
try { options.onError?.(error instanceof Error ? error : new Error(String(error || 'JamesDSP failed'))); }
|
||||||
|
finally { notifying = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rampGain(value) {
|
||||||
|
if (!gainNode || !context) return;
|
||||||
|
const param = gainNode.gain;
|
||||||
|
const now = context.currentTime || 0;
|
||||||
|
if (context.state !== 'running' || typeof param.linearRampToValueAtTime !== 'function') {
|
||||||
|
param.value = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
param.cancelScheduledValues?.(now);
|
||||||
|
param.setValueAtTime?.(param.value, now);
|
||||||
|
param.linearRampToValueAtTime(value, now + 0.018);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configure(instance, nextSettings, nextLevelDb) {
|
||||||
|
const preset = PRESETS[nextSettings?.preset] || PRESETS.flat;
|
||||||
|
const params = { ...preset.params, ...(nextSettings?.params || {}) };
|
||||||
|
if (Number(params['spatial.enabled']) && Number(params['crossfeed.enabled'])) params['crossfeed.enabled'] = 0;
|
||||||
|
params.gain = clampGain((Number(params.gain) || 0) + (Number(nextLevelDb) || 0));
|
||||||
|
params.bypass = 1;
|
||||||
|
if (active && context?.state === 'running') {
|
||||||
|
rampGain(0);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
await instance.setBypass(true);
|
||||||
|
const ref = nextSettings?.irRef;
|
||||||
|
const needsIR = ref && (loadedIRRef?.name !== ref.name || loadedIRRef?.hash !== ref.hash);
|
||||||
|
await instance.applyPreset(needsIR ? { params, irRef: ref } : params);
|
||||||
|
if (needsIR) loadedIRRef = { name: ref.name, hash: ref.hash };
|
||||||
|
await instance.setParam('gain', params.gain);
|
||||||
|
if (desired) await instance.setBypass(bypassed);
|
||||||
|
if (desired) rampGain(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNode(audioContext) {
|
||||||
|
const moduleURL = root.Lazy.url('/vendor/jamesdsp/jamesdsp-node.mjs');
|
||||||
|
const { JamesDSPNode } = await import(moduleURL);
|
||||||
|
return JamesDSPNode.create(audioContext, {
|
||||||
|
wasmURL: root.Lazy.url('/vendor/jamesdsp/dsp/jamesdsp.wasm'),
|
||||||
|
paramsURL: root.Lazy.url('/vendor/jamesdsp/params.mjs'),
|
||||||
|
workletURL: root.Lazy.url('/vendor/jamesdsp/worklet.mjs'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enable(nextSettings, nextLevelDb = levelDb) {
|
||||||
|
desired = true;
|
||||||
|
settings = nextSettings || {};
|
||||||
|
levelDb = Number(nextLevelDb) || 0;
|
||||||
|
return serial(async () => {
|
||||||
|
if (!desired) return false;
|
||||||
|
const prepared = await options.prepareGraph();
|
||||||
|
context = prepared.context;
|
||||||
|
elements = prepared.elements;
|
||||||
|
standardInput = prepared.standardInput;
|
||||||
|
if (!Number.isFinite(context?.sampleRate) || context.sampleRate < 8000 || context.sampleRate > 96000) throw new RangeError(`JamesDSP supports 8–96 kHz; this device uses ${context?.sampleRate || 'unknown'} Hz`);
|
||||||
|
if (!desired) return false;
|
||||||
|
let instance = nodeEntry?.node;
|
||||||
|
if (!instance) {
|
||||||
|
instance = await (options.loadNode || loadNode)(context);
|
||||||
|
if (!desired) { try { instance.destroy(); } catch {} return false; }
|
||||||
|
nodeEntry = { node: instance, live: true };
|
||||||
|
liveHeaps++; nodesCreated++; maxLiveHeaps = Math.max(maxLiveHeaps, liveHeaps);
|
||||||
|
faultListener = event => fail(event.detail || new Error('JamesDSP processing failed'));
|
||||||
|
instance.addEventListener('error', faultListener);
|
||||||
|
gainNode = context.createGain();
|
||||||
|
gainNode.gain.value = 1;
|
||||||
|
instance.node.connect(gainNode);
|
||||||
|
gainNode.connect(context.destination);
|
||||||
|
}
|
||||||
|
if (!desired) { releaseNode(); return false; }
|
||||||
|
await configure(instance, settings, levelDb);
|
||||||
|
if (!desired) { releaseNode(); return false; }
|
||||||
|
if (context.state === 'suspended') await context.resume().catch(() => {});
|
||||||
|
graph.routeAll(elements, bypassed ? standardInput : instance.node);
|
||||||
|
active = true;
|
||||||
|
return true;
|
||||||
|
}).catch(error => { fail(error); throw error; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function disable() {
|
||||||
|
desired = false;
|
||||||
|
bypassed = false;
|
||||||
|
if (active) {
|
||||||
|
active = false;
|
||||||
|
try { routeStandard(); } catch { /* routeStandard tried its direct fallback */ }
|
||||||
|
}
|
||||||
|
return serial(async () => { releaseNode(); return true; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLevelDb(value) {
|
||||||
|
levelDb = Number(value) || 0;
|
||||||
|
if (!desired) return Promise.resolve(false);
|
||||||
|
return enable(settings, levelDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeBypass(value) {
|
||||||
|
if (!nodeEntry) return Promise.resolve(false);
|
||||||
|
return serial(async () => {
|
||||||
|
const next = Boolean(value);
|
||||||
|
if (next === bypassed) return bypassed;
|
||||||
|
// A/B bypass is an exact route around the worklet. This preserves the
|
||||||
|
// Standard graph sample-for-sample instead of relying on DSP bypass math.
|
||||||
|
if (!next) await nodeEntry.node.setBypass(false);
|
||||||
|
if (active) {
|
||||||
|
rampGain(0);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
graph.routeAll(elements, next ? standardInput : nodeEntry.node);
|
||||||
|
rampGain(next ? 0 : 1);
|
||||||
|
}
|
||||||
|
if (next) await nodeEntry.node.setBypass(true);
|
||||||
|
bypassed = next;
|
||||||
|
return bypassed;
|
||||||
|
}).catch(error => { fail(error); throw error; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBypass(value) { return changeBypass(value); }
|
||||||
|
function toggleBypass() { return changeBypass(!bypassed); }
|
||||||
|
|
||||||
|
function loadIR(buffer, reference = null) {
|
||||||
|
if (!active || !nodeEntry) return Promise.reject(new Error('Enable JamesDSP before loading an impulse response'));
|
||||||
|
return serial(async () => {
|
||||||
|
if (!active || !nodeEntry) throw new Error('JamesDSP is no longer active');
|
||||||
|
rampGain(0);
|
||||||
|
try { await nodeEntry.node.loadIR(buffer, reference); loadedIRRef = reference ? { name: reference.name, hash: reference.hash } : null; await nodeEntry.node.setParam('convolver.enabled', 1); }
|
||||||
|
finally { if (active && desired) rampGain(1); }
|
||||||
|
return true;
|
||||||
|
}).catch(error => { fail(error); throw error; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyser() {
|
||||||
|
if (!active || !gainNode || !context) return null;
|
||||||
|
if (!dspAnalyser) {
|
||||||
|
dspAnalyser = context.createAnalyser();
|
||||||
|
dspAnalyser.fftSize = 128;
|
||||||
|
dspAnalyser.smoothingTimeConstant = 0.78;
|
||||||
|
gainNode.disconnect();
|
||||||
|
gainNode.connect(dspAnalyser);
|
||||||
|
dspAnalyser.connect(context.destination);
|
||||||
|
}
|
||||||
|
return dspAnalyser;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enable, disable, setLevelDb, setBypass, toggleBypass, loadIR, analyser, get active() { return active; }, get desired() { return desired; }, state: snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateIR(name, sampleRate, channels = 2, maxFrames = 65536) {
|
||||||
|
const {builtins}=await irTools();
|
||||||
|
return builtins.generateIR(name,sampleRate,channels,{maxFrames});
|
||||||
|
}
|
||||||
|
async function decodeIR(context, bytes) { const {utils}=await irTools();return utils.decodeIR(context,bytes); }
|
||||||
|
async function prepareIR(context, buffer, maxFrames = 65536) {
|
||||||
|
const {utils}=await irTools();
|
||||||
|
const rate=context.sampleRate===44100?44100:48000;
|
||||||
|
const prepared=utils.resampleIR(utils.prepareIR(context,buffer,{normalize:false}),rate);
|
||||||
|
if (![1,2,4].includes(prepared.numberOfChannels) || prepared.length<1 || prepared.length>Math.min(65536,maxFrames)) throw new RangeError(`Impulse response exceeds the ${Math.min(65536,maxFrames)}-frame internal limit`);
|
||||||
|
return prepared;
|
||||||
|
}
|
||||||
|
async function saveIR(name, buffer, metadata) { const {store}=await irTools();return store.saveIR(name,buffer,metadata); }
|
||||||
|
async function getIR(name) { const {store}=await irTools();return store.getIR(name); }
|
||||||
|
async function listIRs() { const {store}=await irTools();return store.listIRs(); }
|
||||||
|
async function deleteIR(name) { const {store}=await irTools();return store.deleteIR(name); }
|
||||||
|
|
||||||
|
const api = { PRESETS, createController, generateIR, decodeIR, prepareIR, saveIR, getIR, listIRs, deleteIR };
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
else {
|
||||||
|
root.YT = root.YT || {};
|
||||||
|
root.YT.JamesDSPFeature = api;
|
||||||
|
}
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
84
frontend/vendor/jamesdsp/jamesdsp-node.mjs
vendored
Normal file
84
frontend/vendor/jamesdsp/jamesdsp-node.mjs
vendored
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
function assetURL(path, fallback) { return globalThis.Lazy?.url?.(path) || new URL(fallback, import.meta.url); }
|
||||||
|
const [parameterAPI, irUtils, irStore] = await Promise.all([
|
||||||
|
import(assetURL('/vendor/jamesdsp/params.mjs', './params.mjs')),
|
||||||
|
import(assetURL('/vendor/jamesdsp/ir-utils.mjs', './ir-utils.mjs')),
|
||||||
|
import(assetURL('/vendor/jamesdsp/ir-store.mjs', './ir-store.mjs')),
|
||||||
|
]);
|
||||||
|
const {DEFAULT_PARAMS, validateParam} = parameterAPI;
|
||||||
|
const {prepareIR,resampleIR} = irUtils;
|
||||||
|
const {getIR} = irStore;
|
||||||
|
const moduleLoads = new WeakMap();
|
||||||
|
export class JamesDSPNode extends EventTarget {
|
||||||
|
static async create(context, {wasmURL=assetURL('/vendor/jamesdsp/dsp/jamesdsp.wasm','./dsp/jamesdsp.wasm'), workletURL=assetURL('/vendor/jamesdsp/worklet.mjs','./worklet.mjs')}={}) {
|
||||||
|
if (!Number.isFinite(context.sampleRate)||context.sampleRate<8000||context.sampleRate>96000) throw new RangeError('AudioContext rate must be 8–96 kHz');
|
||||||
|
if (!moduleLoads.has(context)) moduleLoads.set(context,new Map());
|
||||||
|
const cache=moduleLoads.get(context), key=String(workletURL);
|
||||||
|
if (!cache.has(key)) cache.set(key,context.audioWorklet.addModule(workletURL).catch(e=>{cache.delete(key);throw e;}));
|
||||||
|
const response=await fetch(wasmURL);
|
||||||
|
if (!response.ok) throw new Error(`WASM fetch failed: ${response.status}`);
|
||||||
|
const wasmBytes=await response.arrayBuffer();
|
||||||
|
await cache.get(key);
|
||||||
|
const wrapper=new JamesDSPNode(context,wasmBytes);
|
||||||
|
try { await wrapper.ready; return wrapper; } catch(e) { wrapper.destroy(); throw e; }
|
||||||
|
}
|
||||||
|
constructor(context,wasmBytes) {
|
||||||
|
super(); this.context=context; this.defaultParams=Object.fromEntries(Object.entries(DEFAULT_PARAMS).filter(([name])=>!/^(?:liveprog|ddc|arbitrary)\./.test(name))); this.params={...this.defaultParams}; this.pending=new Map(); this.serial=0; this.destroyed=false; this.source=null; this.irRef=null;
|
||||||
|
this.node=new AudioWorkletNode(context,'jamesdsp',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2],channelCount:2,channelCountMode:'explicit',processorOptions:{wasmBytes}});
|
||||||
|
this.ready=new Promise((resolve,reject)=> {
|
||||||
|
const timer=setTimeout(()=>reject(new Error('DSP initialization timed out')),15000);
|
||||||
|
this.node.onprocessorerror=()=>{const error=new Error('DSP processor stopped');clearTimeout(timer);reject(error);this.fail(error);};
|
||||||
|
this.node.port.onmessage=({data})=> {
|
||||||
|
if(data.type==='ready') {clearTimeout(timer);resolve();}
|
||||||
|
else if(data.type==='reply') { const p=this.pending.get(data.id); if(p) {this.pending.delete(data.id);clearTimeout(p.timer);data.error?p.reject(new Error(data.error)):p.resolve(data.result);} }
|
||||||
|
else if(data.type==='error') {const error=new Error(data.error); clearTimeout(timer); reject(error); this.fail(error);}
|
||||||
|
else this.dispatchEvent(new CustomEvent(data.type,{detail:data}));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fail(error) { for(const p of this.pending.values()) {clearTimeout(p.timer);p.reject(error);} this.pending.clear();this.dispatchEvent(new CustomEvent('error',{detail:error})); }
|
||||||
|
request(type,data={},transfer=[]) {
|
||||||
|
if(this.destroyed) return Promise.reject(new Error('DSP has been destroyed'));
|
||||||
|
const id=++this.serial;
|
||||||
|
return new Promise((resolve,reject)=> {
|
||||||
|
const timer=setTimeout(()=>{this.pending.delete(id);reject(new Error(`DSP ${type} timed out`));},15000);
|
||||||
|
this.pending.set(id,{resolve,reject,timer});this.node.port.postMessage({type,id,...data},transfer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// One source → this DSP → destination. For arbitrary graphs use .node directly.
|
||||||
|
connect(source,destination=this.context.destination) {
|
||||||
|
if(this.destroyed) throw new Error('DSP has been destroyed');
|
||||||
|
if(this.source) this.source.disconnect(this.node);
|
||||||
|
this.node.disconnect();this.source=source;source.connect(this.node);this.node.connect(destination);return destination;
|
||||||
|
}
|
||||||
|
async setParam(name,value) {if(/^(?:liveprog|ddc|arbitrary)\./.test(name))throw new Error('Text effects are disabled in this player version');validateParam(name,value);await this.request('param',{name,value});this.params[name]=value;}
|
||||||
|
setBypass(enabled) {return this.setParam('bypass',Number(Boolean(enabled)));}
|
||||||
|
async setText(effect,text) {
|
||||||
|
throw new Error('Text effects are disabled in this player version');
|
||||||
|
}
|
||||||
|
async loadIR(buffer,reference=null) {
|
||||||
|
buffer=prepareIR(this.context,buffer,{normalize:false});
|
||||||
|
const internalRate=this.context.sampleRate===44100?44100:48000;
|
||||||
|
buffer=resampleIR(buffer,internalRate);
|
||||||
|
if(![1,2,4].includes(buffer.numberOfChannels) || buffer.length>65536 || buffer.length<1) throw new RangeError('IR must have 1, 2, or 4 channels and at most 65536 frames');
|
||||||
|
const pcm=new Float32Array(buffer.length*buffer.numberOfChannels);
|
||||||
|
for(let ch=0;ch<buffer.numberOfChannels;ch++) {const data=buffer.getChannelData(ch);for(let i=0;i<buffer.length;i++) pcm[i*buffer.numberOfChannels+ch]=data[i];}
|
||||||
|
await this.request('ir',{pcm,frames:buffer.length,channels:buffer.numberOfChannels,sourceRate:internalRate},[pcm.buffer]);this.params['convolver.enabled']=0;this.irRef=reference?{name:reference.name,hash:reference.hash}:null;
|
||||||
|
}
|
||||||
|
async getInfo() {const {bytes,heapBytes}=await this.request('info');const view=new DataView(bytes);return {abiVersion:view.getUint32(0,true),structBytes:view.getUint32(4,true),contextRate:view.getFloat32(8,true),internalRate:view.getFloat32(12,true),srcBackend:view.getUint32(16,true),latencyFrames:view.getUint32(20,true),irChannels:view.getUint32(24,true),irFrames:view.getUint32(28,true),irCapFrames:view.getUint32(32,true),headFrames:view.getUint32(36,true),tailFrames:view.getUint32(40,true),liveBytes:view.getUint32(44,true),peakBytes:view.getUint32(48,true),faults:view.getUint32(52,true),underruns:view.getUint32(56,true),overflows:view.getUint32(60,true),heapBytes};}
|
||||||
|
async applyPreset(preset) {
|
||||||
|
// All keys validated before mutation; applying a preset resets unspecified params.
|
||||||
|
if(preset?.texts && Object.keys(preset.texts).length) throw new Error('Text effects are disabled in this player version');
|
||||||
|
const params={...this.defaultParams,...(preset.params||preset)};
|
||||||
|
for(const [name,value] of Object.entries(params)) {if(/^(?:liveprog|ddc|arbitrary)\./.test(name))throw new Error('Text effects are disabled in this player version');validateParam(name,value);}
|
||||||
|
if(preset.irRef) {const saved=await getIR(preset.irRef.name);if(!saved||saved.hash!==preset.irRef.hash)throw new Error(`Preset IR is missing or changed: ${preset.irRef.name}`);await this.loadIR(saved.buffer,preset.irRef);}
|
||||||
|
// Configure before enabling; the referenced IR is loaded first.
|
||||||
|
for(const [name,value] of Object.entries(params).sort(([a],[b])=>Number(a.endsWith('.enabled'))-Number(b.endsWith('.enabled')))) await this.setParam(name,value);
|
||||||
|
}
|
||||||
|
toJSON() {return {version:2,params:{...this.params},...(this.irRef?{irRef:{...this.irRef}}:{})};}
|
||||||
|
destroy() {
|
||||||
|
if(this.destroyed) return;
|
||||||
|
if(this.source) {try {this.source.disconnect(this.node);} catch {}} this.node.disconnect();
|
||||||
|
this.node.port.postMessage({type:'destroy'});this.destroyed=true;this.fail(new Error('DSP destroyed'));this.node.port.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
31
frontend/vendor/jamesdsp/params.mjs
vendored
Normal file
31
frontend/vendor/jamesdsp/params.mjs
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
export const EQ_FREQUENCIES = [25,40,63,100,160,250,400,630,1000,1600,2500,4000,6300,10000,16000];
|
||||||
|
export const COMP_FREQUENCIES = [95,200,400,800,1600,3400,7500];
|
||||||
|
export const EFFECTS = ['eq','compressor','bass','stereo','reverb','crossfeed','spatial','tube','convolver','liveprog','ddc','arbitrary'];
|
||||||
|
export const PARAMS = {
|
||||||
|
bypass:[0,1,0], gain:[-24,12,0],
|
||||||
|
'limiter.threshold':[-24,0,-0.1], 'limiter.release':[10,1000,100],
|
||||||
|
'eq.mode':[0,5,1], 'eq.interpolation':[0,1,0],
|
||||||
|
'compressor.time':[0.01,2,0.1], 'compressor.mode':[0,3,3], 'compressor.granularity':[0,4,0],
|
||||||
|
'bass.boost':[0,12,6], 'stereo.mix':[0,1,0.5], 'reverb.preset':[0,18,0],
|
||||||
|
'convolver.mix':[0,1,1], 'convolver.predelayMs':[0,250,0],
|
||||||
|
'crossfeed.mode':[0,5,0], 'tube.drive':[-12,12,0],
|
||||||
|
'spatial.mode':[2,6,2], 'spatial.azimuth':[-90,90,0], 'spatial.spread':[0,120,60],
|
||||||
|
'spatial.distance':[0.5,5,1], 'spatial.room':[0,1,0], 'spatial.centerDb':[0,6,0], 'spatial.mix':[0,1,1],
|
||||||
|
};
|
||||||
|
for (const effect of EFFECTS) PARAMS[`${effect}.enabled`] = [0,1,0];
|
||||||
|
EQ_FREQUENCIES.forEach((_, i) => PARAMS[`eq.band${i}`] = [-12,12,0]);
|
||||||
|
COMP_FREQUENCIES.forEach((_, i) => PARAMS[`compressor.band${i}`] = [-12,12,0]);
|
||||||
|
export const DEFAULT_PARAMS = Object.fromEntries(Object.entries(PARAMS).map(([k,v])=>[k,v[2]]));
|
||||||
|
export function validateParam(name, value) {
|
||||||
|
const spec=PARAMS[name];
|
||||||
|
if (!spec || typeof value!=='number' || !Number.isFinite(value) || value<spec[0] || value>spec[1]) throw new RangeError(`Invalid parameter ${name}: ${value}`);
|
||||||
|
if ((name.endsWith('.enabled') || /mode|preset|granularity|interpolation|bypass/.test(name)) && !Number.isInteger(value)) throw new RangeError(`${name} requires an integer`);
|
||||||
|
}
|
||||||
|
export const PRESETS = {
|
||||||
|
Flat: {},
|
||||||
|
'Warm lows': {'eq.band0':4,'eq.band1':4,'eq.band2':3,'eq.band3':2,'eq.enabled':1,'gain':-3},
|
||||||
|
'Headphones': {'crossfeed.enabled':1,'crossfeed.mode':1,'eq.band8':-2,'eq.enabled':1},
|
||||||
|
'Small room': {'reverb.preset':7,'reverb.enabled':1,'gain':-3},
|
||||||
|
'Virtual speakers': {'spatial.enabled':1,'spatial.mode':6,'spatial.spread':90},
|
||||||
|
};
|
||||||
57
frontend/vendor/jamesdsp/worklet.mjs
vendored
Normal file
57
frontend/vendor/jamesdsp/worklet.mjs
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
import createJamesDSP from './dsp/jamesdsp-a982b53.mjs';
|
||||||
|
class JamesDSPProcessor extends AudioWorkletProcessor {
|
||||||
|
constructor(options) {
|
||||||
|
super(); this.closed=false;this.failed=false;
|
||||||
|
this.initialize(options);
|
||||||
|
}
|
||||||
|
async initialize(options) {
|
||||||
|
try {
|
||||||
|
this.wasm=await createJamesDSP({wasmBinary:options.processorOptions.wasmBytes,locateFile:()=>'jamesdsp.wasm',print:()=>{},printErr:()=>{}});
|
||||||
|
this.handle=this.wasm._jdsp_create(sampleRate);
|
||||||
|
this.pointer=this.wasm._malloc(128*2*4);
|
||||||
|
if(!this.handle || !this.pointer) throw new Error('DSP allocation failed or sample rate unsupported');
|
||||||
|
this.audio=this.wasm.HEAPF32.subarray(this.pointer/4,this.pointer/4+256);
|
||||||
|
this.port.onmessage=({data})=>this.message(data);
|
||||||
|
this.port.postMessage({type:'ready'});
|
||||||
|
} catch(error) {this.failed=true;this.port.postMessage({type:'error',error:String(error)});}
|
||||||
|
}
|
||||||
|
string(value,fn) {
|
||||||
|
const utf8=encodeURIComponent(value+'\0').replace(/%([0-9A-F]{2})/g,(_,hex)=>String.fromCharCode(parseInt(hex,16)));
|
||||||
|
const bytes=Uint8Array.from(utf8,c=>c.charCodeAt(0)), p=this.wasm._malloc(bytes.length);
|
||||||
|
if(!p) throw new Error('DSP text allocation failed');
|
||||||
|
try {this.wasm.HEAPU8.set(bytes,p);return fn(p);} finally {this.wasm._free(p);}
|
||||||
|
}
|
||||||
|
message(data) {
|
||||||
|
if(data.type==='destroy') {if(this.handle) this.wasm._jdsp_destroy(this.handle);if(this.pointer) this.wasm._free(this.pointer);this.closed=true;return;}
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
if(data.type==='param') result=this.string(data.name,p=>this.wasm._jdsp_set_param(this.handle,p,data.value));
|
||||||
|
else if(data.type==='ir') {
|
||||||
|
const p=this.wasm._malloc(data.pcm.byteLength);if(!p) throw new Error('IR allocation failed');
|
||||||
|
try {this.wasm.HEAPF32.set(data.pcm,p/4);result=this.wasm._jdsp_load_ir_ex(this.handle,p,data.frames,data.channels,data.sourceRate);} finally {this.wasm._free(p);}
|
||||||
|
} else if(data.type==='info') {
|
||||||
|
const p=this.wasm._malloc(64);if(!p)throw new Error('DSP info allocation failed');
|
||||||
|
try {result=this.wasm._jdsp_get_info(this.handle,p);if(result===0){const bytes=this.wasm.HEAPU8.slice(p,p+64);this.port.postMessage({type:'reply',id:data.id,result:{bytes:bytes.buffer,heapBytes:this.wasm.HEAPU8.buffer.byteLength}},[bytes.buffer]);return;}} finally {this.wasm._free(p);}
|
||||||
|
} else throw new Error('Unknown DSP message');
|
||||||
|
if(result!==0) throw new Error(`DSP rejected ${data.name||data.effect||data.type}`);
|
||||||
|
this.port.postMessage({type:'reply',id:data.id,result});
|
||||||
|
} catch(error) {this.port.postMessage({type:'reply',id:data.id,error:String(error)});}
|
||||||
|
}
|
||||||
|
process(inputs,outputs) {
|
||||||
|
if(this.closed) return false;
|
||||||
|
const out=outputs[0], input=inputs[0];
|
||||||
|
if(!out?.length || this.failed || !this.handle) return true;
|
||||||
|
// Mono duplicates to stereo; missing input becomes silence, preserving tails.
|
||||||
|
for(let offset=0;offset<out[0].length;offset+=128) {
|
||||||
|
const n=Math.min(128,out[0].length-offset);
|
||||||
|
for(let i=0;i<n;i++) {this.audio[i*2]=input[0]?.[offset+i]||0;this.audio[i*2+1]=(input[1]||input[0])?.[offset+i]||0;}
|
||||||
|
try {
|
||||||
|
if(this.wasm._jdsp_process(this.handle,this.pointer,n)!==0) throw new Error('DSP produced nonfinite output');
|
||||||
|
for(let i=0;i<n;i++) {out[0][offset+i]=this.audio[i*2];if(out[1]) out[1][offset+i]=this.audio[i*2+1];}
|
||||||
|
} catch(error) {out.forEach(channel=>channel.fill(0));this.failed=true;this.port.postMessage({type:'error',error:String(error)});break;}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registerProcessor('jamesdsp',JamesDSPProcessor);
|
||||||
@@ -1,4 +1,18 @@
|
|||||||
/* Eager UI definitions. Read app lexical state only when called after initialization. */
|
/* Eager UI definitions. Read app lexical state only when called after initialization. */
|
||||||
|
function parseJamesDspPreset(text) {
|
||||||
|
const imported = typeof text === 'string' ? JSON.parse(text) : text;
|
||||||
|
const allowed = /^(?:eq\.band(?:[0-9]|1[0-4])|compressor\.band[0-6]|(?:eq|compressor|bass|stereo|reverb|crossfeed|tube|convolver)\.enabled|(?:gain|limiter\.threshold|limiter\.release|eq\.mode|eq\.interpolation|compressor\.time|compressor\.mode|compressor\.granularity|bass\.boost|stereo\.mix|reverb\.preset|crossfeed\.mode|tube\.drive))$/;
|
||||||
|
if (imported?.version !== 1 || imported?.texts || !imported?.params || typeof imported.params !== 'object' || Array.isArray(imported.params)) throw new Error('This file is not a supported preset (text effects are disabled).');
|
||||||
|
const range = key => key.startsWith('eq.band') || key.startsWith('compressor.band') ? [-12, 12] : ({ gain: [-24, 12], 'limiter.threshold': [-24, 0], 'limiter.release': [10, 1000], 'eq.mode': [0, 5], 'eq.interpolation': [0, 1], 'compressor.time': [0.01, 2], 'compressor.mode': [0, 3], 'compressor.granularity': [0, 4], 'bass.boost': [0, 12], 'stereo.mix': [0, 1], 'reverb.preset': [0, 18], 'crossfeed.mode': [0, 5], 'tube.drive': [-12, 12] }[key] || (key.endsWith('.enabled') ? [0, 1] : null));
|
||||||
|
for (const [key, value] of Object.entries(imported.params)) {
|
||||||
|
const bounds = range(key);
|
||||||
|
if (!allowed.test(key) || typeof value !== 'number' || !Number.isFinite(value) || !bounds || value < bounds[0] || value > bounds[1] || (key === 'convolver.enabled' && value !== 0)) throw new Error(`Unsupported preset setting: ${key}`);
|
||||||
|
if ((key.endsWith('.enabled') || /mode|preset|granularity|interpolation/.test(key)) && !Number.isInteger(value)) throw new Error(`Preset setting must be a whole number: ${key}`);
|
||||||
|
}
|
||||||
|
const presets = ['flat', 'bass', 'vocal', 'loudness', 'headphone', 'bright', 'warm'];
|
||||||
|
return { preset: presets.includes(imported.preset) ? imported.preset : 'flat', params: { ...imported.params } };
|
||||||
|
}
|
||||||
|
|
||||||
async function renderLibrary() {
|
async function renderLibrary() {
|
||||||
els.listTitle.textContent = 'Library';
|
els.listTitle.textContent = 'Library';
|
||||||
resetListHeaderExtras();
|
resetListHeaderExtras();
|
||||||
@@ -44,7 +58,7 @@ function renderNotesView() {
|
|||||||
// Most recently played first (history order), the rest after.
|
// Most recently played first (history order), the rest after.
|
||||||
const order = new Map(data.history.map((v, i) => [v.id, i]));
|
const order = new Map(data.history.map((v, i) => [v.id, i]));
|
||||||
groups.sort((a, b) => (order.has(a.id) ? order.get(a.id) : 1e9) - (order.has(b.id) ? order.get(b.id) : 1e9));
|
groups.sort((a, b) => (order.has(a.id) ? order.get(a.id) : 1e9) - (order.has(b.id) ? order.get(b.id) : 1e9));
|
||||||
els.listActions.innerHTML = groups.length ? '<button class="np-btn" id="notesExportBtn" type="button">⤓ Export Markdown</button>' : '';
|
els.listActions.innerHTML = groups.length ? `<button class="np-btn" id="notesExportBtn" type="button">${MediaIcons.svg('download')} Export Markdown</button>` : '';
|
||||||
const c = els.cards;
|
const c = els.cards;
|
||||||
if (!groups.length) {
|
if (!groups.length) {
|
||||||
c.innerHTML = `
|
c.innerHTML = `
|
||||||
@@ -130,8 +144,8 @@ function setListPaneHidden(hidden) {
|
|||||||
const LIST_ICONS = {
|
const LIST_ICONS = {
|
||||||
rename: '<path d="M4 20h4L19 9l-4-4L4 16v4zM14 6l4 4"/>',
|
rename: '<path d="M4 20h4L19 9l-4-4L4 16v4zM14 6l4 4"/>',
|
||||||
share: '<path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1"/><path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1"/>',
|
share: '<path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1"/><path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1"/>',
|
||||||
offline: '<path d="M12 4v11M7 10l5 5 5-5M5 20h14"/>',
|
offline: '<use href="#media-download"/>',
|
||||||
offlineDone: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
offlineDone: '<use href="#media-offline"/>',
|
||||||
pin: '<path d="M9 3h6l-1 6 4 4H6l4-4zM12 13v8"/>',
|
pin: '<path d="M9 3h6l-1 6 4 4H6l4-4zM12 13v8"/>',
|
||||||
select: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 12l3 3 5-6"/>',
|
select: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 12l3 3 5-6"/>',
|
||||||
del: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>',
|
del: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>',
|
||||||
@@ -222,7 +236,7 @@ function renderList() {
|
|||||||
els.listTitle.textContent = 'Queue';
|
els.listTitle.textContent = 'Queue';
|
||||||
if (list.length) {
|
if (list.length) {
|
||||||
const playAll = document.createElement('button');
|
const playAll = document.createElement('button');
|
||||||
playAll.textContent = '▶ Play queue';
|
playAll.innerHTML = TransportIcons.svg('play') + ' Play queue';
|
||||||
playAll.onclick = () => playQueue(0);
|
playAll.onclick = () => playQueue(0);
|
||||||
const clear = document.createElement('button');
|
const clear = document.createElement('button');
|
||||||
clear.textContent = 'Clear';
|
clear.textContent = 'Clear';
|
||||||
@@ -234,7 +248,7 @@ function renderList() {
|
|||||||
els.listTitle.textContent = sp ? sp.label : 'Auto Playlist';
|
els.listTitle.textContent = sp ? sp.label : 'Auto Playlist';
|
||||||
if (list.length) {
|
if (list.length) {
|
||||||
const playAll = document.createElement('button');
|
const playAll = document.createElement('button');
|
||||||
playAll.textContent = '▶ Play all';
|
playAll.innerHTML = TransportIcons.svg('play') + ' Play all';
|
||||||
playAll.onclick = () => playFromList(list.slice(), 0, 'smart:' + view.smartType);
|
playAll.onclick = () => playFromList(list.slice(), 0, 'smart:' + view.smartType);
|
||||||
const queueAll = document.createElement('button');
|
const queueAll = document.createElement('button');
|
||||||
queueAll.textContent = '+ Queue all';
|
queueAll.textContent = '+ Queue all';
|
||||||
@@ -245,7 +259,7 @@ function renderList() {
|
|||||||
els.listTitle.textContent = channelData.name || 'Channel';
|
els.listTitle.textContent = channelData.name || 'Channel';
|
||||||
if (list.length) {
|
if (list.length) {
|
||||||
const playAll = document.createElement('button');
|
const playAll = document.createElement('button');
|
||||||
playAll.textContent = '▶ Play all';
|
playAll.innerHTML = TransportIcons.svg('play') + ' Play all';
|
||||||
playAll.onclick = () => playFromList(channelData.results.slice(), 0, 'channel');
|
playAll.onclick = () => playFromList(channelData.results.slice(), 0, 'channel');
|
||||||
const queueAll = document.createElement('button');
|
const queueAll = document.createElement('button');
|
||||||
queueAll.textContent = '+ Queue all';
|
queueAll.textContent = '+ Queue all';
|
||||||
@@ -261,11 +275,11 @@ function renderList() {
|
|||||||
$('listSub').textContent = `${pl.videos.length} track${pl.videos.length === 1 ? '' : 's'}${secs ? ' · ' + (h ? h + 'h ' : '') + m + 'm' : ''}`; }
|
$('listSub').textContent = `${pl.videos.length} track${pl.videos.length === 1 ? '' : 's'}${secs ? ' · ' + (h ? h + 'h ' : '') + m + 'm' : ''}`; }
|
||||||
renderP2pStrip();
|
renderP2pStrip();
|
||||||
const playAll = document.createElement('button');
|
const playAll = document.createElement('button');
|
||||||
playAll.textContent = '▶ Play all';
|
playAll.innerHTML = TransportIcons.svg('play') + ' Play all';
|
||||||
playAll.className = 'la-primary';
|
playAll.className = 'la-primary';
|
||||||
playAll.onclick = () => { if (pl.videos.length) playFromList(pl.videos, 0, 'playlist:' + pl.id); };
|
playAll.onclick = () => { if (pl.videos.length) playFromList(pl.videos, 0, 'playlist:' + pl.id); };
|
||||||
const playFull = document.createElement('button');
|
const playFull = document.createElement('button');
|
||||||
playFull.textContent = '▶ Play in full';
|
playFull.innerHTML = TransportIcons.svg('play') + ' Play in full';
|
||||||
playFull.className = 'la-main';
|
playFull.className = 'la-main';
|
||||||
playFull.dataset.sub = 'Start to finish';
|
playFull.dataset.sub = 'Start to finish';
|
||||||
playFull.title = 'Play every video start to finish — ignores saved positions and A-B loops, stops after the last one';
|
playFull.title = 'Play every video start to finish — ignores saved positions and A-B loops, stops after the last one';
|
||||||
@@ -360,7 +374,7 @@ function renderList() {
|
|||||||
addBrowseCta(empty, '🔍 Search videos');
|
addBrowseCta(empty, '🔍 Search videos');
|
||||||
} else if (view.type === 'queue') {
|
} else if (view.type === 'queue') {
|
||||||
empty.innerHTML = `
|
empty.innerHTML = `
|
||||||
<div class="empty-icon">▶</div>
|
<div class="empty-icon">${TransportIcons.svg('play')}</div>
|
||||||
<h3 class="empty-title">Your queue is empty</h3>
|
<h3 class="empty-title">Your queue is empty</h3>
|
||||||
<p class="empty-desc">Use <strong>+ Queue</strong> on any video to line it up. The queue is temporary and plays in order.</p>
|
<p class="empty-desc">Use <strong>+ Queue</strong> on any video to line it up. The queue is temporary and plays in order.</p>
|
||||||
`;
|
`;
|
||||||
@@ -416,7 +430,7 @@ async function renderP2pStrip() {
|
|||||||
const v = { id: it.id, title: it.title || videoTitleById(it.id) || it.id, channel: it.channel || '', duration: it.duration || 0, thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg` };
|
const v = { id: it.id, title: it.title || videoTitleById(it.id) || it.id, channel: it.channel || '', duration: it.duration || 0, thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg` };
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'ps-row';
|
row.className = 'ps-row';
|
||||||
row.innerHTML = '<span class="ps-ic">📡</span><span class="ps-txt"><b></b><small></small></span><button type="button" class="ps-dl" aria-label="Save to this device">⤓</button>';
|
row.innerHTML = `<span class="ps-ic">📡</span><span class="ps-txt"><b></b><small></small></span><button type="button" class="ps-dl" aria-label="Save to this device">${MediaIcons.svg('download')}</button>`;
|
||||||
row.querySelector('b').textContent = v.title;
|
row.querySelector('b').textContent = v.title;
|
||||||
row.querySelector('small').textContent = `${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''} · ${it.online ? 'Online' : 'Offline'}`;
|
row.querySelector('small').textContent = `${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''} · ${it.online ? 'Online' : 'Offline'}`;
|
||||||
row.querySelector('.ps-dl').onclick = () => exportToDevice(v);
|
row.querySelector('.ps-dl').onclick = () => exportToDevice(v);
|
||||||
@@ -492,7 +506,7 @@ async function renderP2pList() {
|
|||||||
refresh.setAttribute('aria-label', 'Refresh');
|
refresh.setAttribute('aria-label', 'Refresh');
|
||||||
refresh.onclick = () => renderP2pList();
|
refresh.onclick = () => renderP2pList();
|
||||||
const playAll = document.createElement('button');
|
const playAll = document.createElement('button');
|
||||||
playAll.textContent = '▶ Play all';
|
playAll.innerHTML = TransportIcons.svg('play') + ' Play all';
|
||||||
playAll.className = 'la-primary la-inline';
|
playAll.className = 'la-primary la-inline';
|
||||||
playAll.onclick = () => { if (list.length) playFromList(list, 0, 'p2p'); };
|
playAll.onclick = () => { if (list.length) playFromList(list, 0, 'p2p'); };
|
||||||
els.listActions.append(playAll, refresh);
|
els.listActions.append(playAll, refresh);
|
||||||
@@ -517,7 +531,7 @@ async function renderP2pList() {
|
|||||||
<div class="card-title"></div>
|
<div class="card-title"></div>
|
||||||
<div class="card-channel">${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · <i>not checked lately</i>' : ''}</div>
|
<div class="card-channel">${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · <i>not checked lately</i>' : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="card-act" data-act="export" title="Save to this device" aria-label="Save to this device">⤓</button>
|
<button class="card-act" data-act="export" title="Save to this device" aria-label="Save to this device">${MediaIcons.svg('download')}</button>
|
||||||
<button class="card-act" data-act="add" title="Add to a playlist" aria-label="Add to a playlist">+</button>`;
|
<button class="card-act" data-act="add" title="Add to a playlist" aria-label="Add to a playlist">+</button>`;
|
||||||
row.querySelector('.card-title').textContent = v.title;
|
row.querySelector('.card-title').textContent = v.title;
|
||||||
row.addEventListener('click', (e) => {
|
row.addEventListener('click', (e) => {
|
||||||
@@ -550,7 +564,7 @@ async function renderSaved() {
|
|||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'empty-state';
|
empty.className = 'empty-state';
|
||||||
empty.innerHTML = `
|
empty.innerHTML = `
|
||||||
<div class="empty-icon">💾</div>
|
<div class="empty-icon">${MediaIcons.svg('offline')}</div>
|
||||||
<h3 class="empty-title">Offline cache unavailable</h3>
|
<h3 class="empty-title">Offline cache unavailable</h3>
|
||||||
<p class="empty-desc">This build doesn't support saving videos for offline playback.</p>`;
|
<p class="empty-desc">This build doesn't support saving videos for offline playback.</p>`;
|
||||||
c.appendChild(empty);
|
c.appendChild(empty);
|
||||||
@@ -566,9 +580,9 @@ async function renderSaved() {
|
|||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'empty-state';
|
empty.className = 'empty-state';
|
||||||
empty.innerHTML = `
|
empty.innerHTML = `
|
||||||
<div class="empty-icon">💾</div>
|
<div class="empty-icon">${MediaIcons.svg('offline')}</div>
|
||||||
<h3 class="empty-title">Nothing saved yet</h3>
|
<h3 class="empty-title">Nothing saved yet</h3>
|
||||||
<p class="empty-desc">Use <strong>⬇ Save</strong> while playing, or add videos to a playlist to keep them offline.</p>`;
|
<p class="empty-desc">Use <strong>${MediaIcons.svg('download')} Save</strong> while playing, or add videos to a playlist to keep them offline.</p>`;
|
||||||
addBrowseCta(empty, '🔍 Find videos');
|
addBrowseCta(empty, '🔍 Find videos');
|
||||||
c.appendChild(empty);
|
c.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
@@ -718,10 +732,19 @@ async function renderSettings() {
|
|||||||
const qOptions = qualities
|
const qOptions = qualities
|
||||||
.map((q) => `<option value="${q}"${data.settings.quality === q ? ' selected' : ''}>${q === 'auto' ? 'Auto' : q}</option>`)
|
.map((q) => `<option value="${q}"${data.settings.quality === q ? ' selected' : ''}>${q === 'auto' ? 'Auto' : q}</option>`)
|
||||||
.join('');
|
.join('');
|
||||||
|
const jamesDsp = data.settings.jamesDsp || { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null };
|
||||||
|
const dspCores=Number(navigator.hardwareConcurrency), dspMemory=Number(navigator.deviceMemory);
|
||||||
|
const dspLowEnd=(dspCores>0&&dspCores<=4)||(dspMemory>0&&dspMemory<=4);
|
||||||
|
const dspBands = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000];
|
||||||
|
const dspParamDefaults = { 'bass.boost': 6, 'stereo.mix': 0.5, 'limiter.threshold': -0.1, 'convolver.mix': 1, 'convolver.predelayMs': 0, 'spatial.mode': 6, 'spatial.spread': 60, 'spatial.distance': 1, 'spatial.mix': 1 };
|
||||||
|
const dspParam = key => Number.isFinite(Number(jamesDsp.params?.[key])) ? Number(jamesDsp.params[key]) : (dspParamDefaults[key] ?? 0);
|
||||||
|
const dspRange = (key, label, min, max, step = 1, unit = '') => `<label class="set-row dsp-param-row"><span>${label}<small>${dspParam(key)}${unit}</small></span><input type="range" data-dsp-param="${key}" min="${min}" max="${max}" step="${step}" value="${dspParam(key)}" /></label>`;
|
||||||
|
const dspBandRows = dspBands.map((hz, i) => dspRange(`eq.band${i}`, `${hz >= 1000 ? (hz / 1000) + ' kHz' : hz + ' Hz'}`, -12, 12, 0.5, ' dB')).join('');
|
||||||
|
|
||||||
const sel = (id, val, opts) => `<select id="${id}" class="set-select">` +
|
const sel = (id, val, opts) => `<span class="set-control-wrap"><select id="${id}" class="set-select">` +
|
||||||
opts.map(([v, label]) => `<option value="${v}"${val === v ? ' selected' : ''}>${label}</option>`).join('') +
|
opts.map(([v, label]) => `<option value="${v}"${val === v ? ' selected' : ''}>${label}</option>`).join('') +
|
||||||
`</select>`;
|
`</select></span>`;
|
||||||
|
const dspBuiltinIRSelect = `<select id="setDspBuiltinIR" class="set-select">${['Small room','Hall','Plate','Cathedral'].map(name=>`<option value="${name}"${dspLowEnd&&name!=='Small room'?' disabled':''}>${name}</option>`).join('')}</select>`;
|
||||||
|
|
||||||
wrap.innerHTML = `
|
wrap.innerHTML = `
|
||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
@@ -788,6 +811,50 @@ async function renderSettings() {
|
|||||||
</span>
|
</span>
|
||||||
<input id="setLiveAudioIOS" type="checkbox" ${data.settings.liveAudioIOS ? 'checked' : ''} />
|
<input id="setLiveAudioIOS" type="checkbox" ${data.settings.liveAudioIOS ? 'checked' : ''} />
|
||||||
</label>` : ''}
|
</label>` : ''}
|
||||||
|
<div class="dsp-settings" id="jamesDspSettings">
|
||||||
|
<div class="set-group-title">Sound engine</div>
|
||||||
|
<label class="set-row"><span>Engine<small>Standard is the default. JamesDSP replaces the Standard EQ and vocal/stereo stages.</small></span>
|
||||||
|
${sel('setSoundEngine', jamesDsp.enabled ? 'jamesdsp' : 'standard', [['standard', 'Standard'], ['jamesdsp', 'JamesDSP']])}
|
||||||
|
</label>
|
||||||
|
<label class="set-row"><span>Download audio effects for offline<small>Includes the optional JamesDSP engine and its large WASM file in the offline cache.</small></span><input id="setDspOffline" type="checkbox" ${jamesDsp.downloadForOffline ? 'checked' : ''} /></label>
|
||||||
|
<div id="jamesDspControls" ${jamesDsp.enabled ? '' : 'hidden'}>
|
||||||
|
<div class="set-row"><span>Live processing<small>${EQ.isIOS() ? 'On iPhone, enable Live sound processing above first. Audio may stop when the screen locks.' : 'Processing runs on this device.'}</small></span></div>
|
||||||
|
${dspLowEnd ? '<p class="dsp-risk" role="note">This device may have limited audio headroom. Long impulse responses are disabled and the short room response is capped at 0.5 seconds.</p>' : ''}
|
||||||
|
<label class="set-row"><span>Preset</span>${sel('setDspPreset', jamesDsp.preset || 'flat', [['flat', 'Flat'], ['bass', 'Bass boost'], ['vocal', 'Vocal'], ['loudness', 'Loudness'], ['headphone', 'Headphone crossfeed'], ['bright', 'Bright'], ['warm', 'Warm']])}</label>
|
||||||
|
<div class="dsp-param-list" aria-label="15-band equalizer">${dspBandRows}</div>
|
||||||
|
<label class="set-row"><span>Bass boost<small>${dspParam('bass.boost')} dB</small></span><input type="range" data-dsp-param="bass.boost" min="0" max="12" step="0.5" value="${dspParam('bass.boost')}" /></label>
|
||||||
|
<label class="set-row"><span>Stereo width<small>${Math.round(dspParam('stereo.mix') * 100)}%</small></span><input type="range" data-dsp-param="stereo.mix" min="0" max="1" step="0.05" value="${dspParam('stereo.mix')}" /></label>
|
||||||
|
<label class="set-row"><span>Crossfeed mode</span>${sel('setDspCrossfeed', String(dspParam('crossfeed.mode')), [['0', 'Off'], ['1', 'Gentle'], ['2', 'Natural'], ['3', 'Wide'], ['4', 'Diffuse'], ['5', 'Strong']])}</label>
|
||||||
|
<label class="set-row"><span>Reverb preset</span>${sel('setDspReverb', String(dspParam('reverb.preset')), Array.from({ length: 19 }, (_, i) => [String(i), i === 0 ? 'Off' : `Room ${i}`]))}</label>
|
||||||
|
<label class="set-row"><span>Tube drive<small>${dspParam('tube.drive')} dB</small></span><input type="range" data-dsp-param="tube.drive" min="-12" max="12" step="0.5" value="${dspParam('tube.drive')}" /></label>
|
||||||
|
<label class="set-row"><span>Limiter threshold<small>${dspParam('limiter.threshold')} dB</small></span><input type="range" data-dsp-param="limiter.threshold" min="-24" max="0" step="0.5" value="${dspParam('limiter.threshold')}" /></label>
|
||||||
|
<label class="set-row"><span>Output gain<small>${dspParam('gain')} dB</small></span><input type="range" data-dsp-param="gain" min="-24" max="12" step="0.5" value="${dspParam('gain')}" /></label>
|
||||||
|
<label class="set-row"><span>Compressor</span><input id="setDspCompressor" type="checkbox" ${dspParam('compressor.enabled') ? 'checked' : ''} /></label>
|
||||||
|
<div class="dsp-actions"><button id="dspPresetImportBtn" class="btn" type="button">Import preset JSON</button><button id="dspPresetExportBtn" class="btn" type="button">Export preset JSON</button><button id="dspResetBtn" class="btn" type="button">Reset DSP</button></div>
|
||||||
|
<input id="dspPresetFile" type="file" accept="application/json,.json" hidden />
|
||||||
|
<section class="dsp-subsection" aria-labelledby="dspConvolverTitle">
|
||||||
|
<h4 id="dspConvolverTitle">Convolver</h4>
|
||||||
|
<label class="set-row"><span>Enable convolution</span><input id="setDspConvolver" type="checkbox" ${dspParam('convolver.enabled') ? 'checked' : ''} /></label>
|
||||||
|
<label class="set-row"><span>Dry / wet<small>${Math.round(dspParam('convolver.mix')*100)}%</small></span><input type="range" data-dsp-param="convolver.mix" min="0" max="1" step="0.01" value="${dspParam('convolver.mix')}" /></label>
|
||||||
|
<label class="set-row"><span>Pre-delay<small>${dspParam('convolver.predelayMs')} ms</small></span><input type="range" data-dsp-param="convolver.predelayMs" min="0" max="250" step="1" value="${dspParam('convolver.predelayMs')}" /></label>
|
||||||
|
<label class="set-row"><span>Built-in response</span><span class="set-control-wrap">${dspBuiltinIRSelect}</span></label>
|
||||||
|
<button id="setDspLoadBuiltin" class="btn" type="button">Generate and load</button>
|
||||||
|
<label class="set-row dsp-upload-row"><span>Upload impulse response<small>WAV or other browser-decodable audio; up to 65,536 frames at the internal sample rate.</small></span><span class="dsp-file-control"><input id="dspIrFile" type="file" accept="audio/*,.wav" aria-label="Upload impulse response" /><button id="dspIrChooseFile" class="dsp-file-button" type="button">Choose file</button><span class="dsp-file-name" id="dspIrFileName" aria-live="polite">No file chosen</span></span></label>
|
||||||
|
<label class="set-row"><span>Stored responses</span><span class="set-control-wrap"><select id="setDspSavedIR" class="set-select"><option value="">Choose a saved response</option></select></span></label>
|
||||||
|
<button id="setDspLoadSavedIR" class="btn" type="button">Load saved response</button>
|
||||||
|
<p id="dspIrStatus" class="sp-note">${jamesDsp.irRef?.name ? `Loaded: ${jamesDsp.irRef.name}` : 'No impulse response loaded.'}</p>
|
||||||
|
</section>
|
||||||
|
<section class="dsp-subsection" aria-labelledby="dspSpatialTitle">
|
||||||
|
<h4 id="dspSpatialTitle">3D spatial stage</h4>
|
||||||
|
<label class="set-row"><span>Enable 3D spatial stage</span><input id="setDspSpatial" type="checkbox" ${dspParam('spatial.enabled') ? 'checked' : ''} /></label>
|
||||||
|
<label class="set-row"><span>HRTF / renderer mode</span>${sel('setDspSpatialMode',String(dspParam('spatial.mode')),[['2','HRTF crossfeed'],['3','HRTF surround 1'],['4','HRTF surround 2'],['5','HRTF surround 3'],['6','Virtual speakers']])}</label>
|
||||||
|
${dspRange('spatial.azimuth','Azimuth',-90,90,1,'°')}
|
||||||
|
${dspRange('spatial.spread','Speaker spread',0,120,1,'°')}
|
||||||
|
${dspRange('spatial.distance','Distance',0.5,5,0.1,' m')}
|
||||||
|
${dspRange('spatial.centerDb','Center boost',0,6,0.1,' dB')}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="set-row">
|
<label class="set-row">
|
||||||
<span>Default volume</span>
|
<span>Default volume</span>
|
||||||
<input id="setVolume" type="range" min="0" max="1" step="0.01" value="${data.settings.volume}" />
|
<input id="setVolume" type="range" min="0" max="1" step="0.01" value="${data.settings.volume}" />
|
||||||
@@ -843,6 +910,7 @@ async function renderSettings() {
|
|||||||
|
|
||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<div class="set-group-title">Offline cache</div>
|
<div class="set-group-title">Offline cache</div>
|
||||||
|
<div class="set-row"><span>App & features</span><small id="offlineAssetStatus" role="status">Checking offline readiness…</small></div>
|
||||||
<label class="set-row">
|
<label class="set-row">
|
||||||
<span>
|
<span>
|
||||||
Auto-save playlist videos
|
Auto-save playlist videos
|
||||||
@@ -903,7 +971,7 @@ async function renderSettings() {
|
|||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<div class="set-group-title">Playlists</div>
|
<div class="set-group-title">Playlists</div>
|
||||||
<div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
|
<div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
|
||||||
<button id="importYtBtn" class="btn">⬇ Import from YouTube</button>
|
<button id="importYtBtn" class="btn">${MediaIcons.svg('download')} Import from YouTube</button>
|
||||||
<button id="exportBtn" class="btn">Export playlists</button>
|
<button id="exportBtn" class="btn">Export playlists</button>
|
||||||
<button id="importBtn" class="btn">Import playlists</button>
|
<button id="importBtn" class="btn">Import playlists</button>
|
||||||
<input id="fileInput" type="file" accept=".json" style="display:none" />
|
<input id="fileInput" type="file" accept=".json" style="display:none" />
|
||||||
@@ -975,7 +1043,7 @@ async function renderSettings() {
|
|||||||
<button id="profileShareBtn" class="btn"${profileShareUrl() ? '' : ' style="display:none"'}>↗ Share</button>
|
<button id="profileShareBtn" class="btn"${profileShareUrl() ? '' : ' style="display:none"'}>↗ Share</button>
|
||||||
<button id="profileUnlinkBtn" class="btn danger"${data.profile && data.profile.name ? '' : ' style="display:none"'}>Unlink</button>
|
<button id="profileUnlinkBtn" class="btn danger"${data.profile && data.profile.name ? '' : ' style="display:none"'}>Unlink</button>
|
||||||
<button id="profileProtectBtn" class="btn"${data.profile && data.profile.name ? '' : ' style="display:none"'}>🔒 Protect…</button>
|
<button id="profileProtectBtn" class="btn"${data.profile && data.profile.name ? '' : ' style="display:none"'}>🔒 Protect…</button>
|
||||||
<button id="profileExportCredBtn" class="btn" style="display:none">⤓ Credentials file</button>
|
<button id="profileExportCredBtn" class="btn" style="display:none">${MediaIcons.svg('download')} Credentials file</button>
|
||||||
<button id="profileImportCredBtn" class="btn">⤒ Import credentials</button>
|
<button id="profileImportCredBtn" class="btn">⤒ Import credentials</button>
|
||||||
<input id="profileCredFileInput" type="file" accept=".json,application/json" hidden />
|
<input id="profileCredFileInput" type="file" accept=".json,application/json" hidden />
|
||||||
</div>
|
</div>
|
||||||
@@ -1054,6 +1122,124 @@ async function renderSettings() {
|
|||||||
if (e.target.checked) toast('Live sound processing on — streamed songs go silent when the screen locks', { duration: 4500 });
|
if (e.target.checked) toast('Live sound processing on — streamed songs go silent when the screen locks', { duration: 4500 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dspControls = $('jamesDspControls');
|
||||||
|
const dspEngine = $('setSoundEngine');
|
||||||
|
if (dspEngine) dspEngine.addEventListener('change', e => {
|
||||||
|
const enable = e.target.value === 'jamesdsp';
|
||||||
|
if (enable && EQ.isIOS() && !data.settings.liveAudioIOS) {
|
||||||
|
e.target.value = 'standard';
|
||||||
|
toast('On iPhone, turn on Live sound processing first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.settings.jamesDsp = data.settings.jamesDsp || { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null };
|
||||||
|
data.settings.jamesDsp.enabled = enable;
|
||||||
|
if (enable) data.settings.jamesDsp.downloadForOffline = true;
|
||||||
|
if (enable && $('setDspOffline')) $('setDspOffline').checked = true;
|
||||||
|
if (dspControls) dspControls.hidden = !enable;
|
||||||
|
persist();
|
||||||
|
reportDspOfflineOptIn();
|
||||||
|
EQ.apply();
|
||||||
|
});
|
||||||
|
if ($('setDspOffline')) $('setDspOffline').addEventListener('change', e => {
|
||||||
|
data.settings.jamesDsp = data.settings.jamesDsp || { enabled: false, downloadForOffline: false, preset: 'flat', params: {} };
|
||||||
|
data.settings.jamesDsp.downloadForOffline = e.target.checked;
|
||||||
|
persist();
|
||||||
|
reportDspOfflineOptIn();
|
||||||
|
toast(e.target.checked ? 'Preparing audio effects for offline use' : 'Audio effects removed from the offline completion list');
|
||||||
|
});
|
||||||
|
const dspConfig = data.settings.jamesDsp || (data.settings.jamesDsp = { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null });
|
||||||
|
let dspApplyTimer = null;
|
||||||
|
const applyDspSettings = () => {
|
||||||
|
clearTimeout(dspApplyTimer);
|
||||||
|
persist();
|
||||||
|
dspApplyTimer = setTimeout(() => { if (data.settings.jamesDsp?.enabled) EQ.apply(); }, 120);
|
||||||
|
};
|
||||||
|
if ($('setDspPreset')) $('setDspPreset').addEventListener('change', e => {
|
||||||
|
dspConfig.preset = e.target.value;
|
||||||
|
dspConfig.params = {};
|
||||||
|
wrap.querySelectorAll('[data-dsp-param]').forEach(input => { input.value = input.dataset.dspParam === 'limiter.threshold' ? '-0.1' : '0'; });
|
||||||
|
applyDspSettings();
|
||||||
|
});
|
||||||
|
wrap.querySelectorAll('[data-dsp-param]').forEach(input => input.addEventListener('input', () => {
|
||||||
|
const value = Number(input.value);
|
||||||
|
dspConfig.params[input.dataset.dspParam] = value;
|
||||||
|
const small = input.closest('.set-row')?.querySelector('small');
|
||||||
|
if (small) small.textContent = `${value}${input.dataset.dspParam === 'stereo.mix' ? '%' : input.dataset.dspParam === 'bass.boost' || input.dataset.dspParam === 'tube.drive' || input.dataset.dspParam === 'limiter.threshold' || input.dataset.dspParam === 'gain' || input.dataset.dspParam.startsWith('eq.band') ? ' dB' : ''}`;
|
||||||
|
applyDspSettings();
|
||||||
|
}));
|
||||||
|
if ($('setDspCrossfeed')) $('setDspCrossfeed').addEventListener('change', e => { dspConfig.params['crossfeed.mode'] = Number(e.target.value); dspConfig.params['crossfeed.enabled'] = Number(e.target.value) ? 1 : 0; applyDspSettings(); });
|
||||||
|
if ($('setDspConvolver')) $('setDspConvolver').addEventListener('change', e => {
|
||||||
|
const hasIR=dspConfig.irRef||EQ.state().jamesDsp?.irRef;
|
||||||
|
if(e.target.checked&&!hasIR){e.target.checked=false;toast('Load an impulse response first.');return;}
|
||||||
|
dspConfig.params['convolver.enabled']=e.target.checked?1:0;applyDspSettings();
|
||||||
|
});
|
||||||
|
if ($('setDspSpatial')) $('setDspSpatial').addEventListener('change', e => {
|
||||||
|
dspConfig.params['spatial.enabled']=e.target.checked?1:0;
|
||||||
|
if(e.target.checked){dspConfig.params['crossfeed.enabled']=0;if($('setDspCrossfeed'))$('setDspCrossfeed').value='0';}
|
||||||
|
applyDspSettings();
|
||||||
|
});
|
||||||
|
if ($('setDspSpatialMode')) $('setDspSpatialMode').addEventListener('change', e => {dspConfig.params['spatial.mode']=Number(e.target.value);applyDspSettings();});
|
||||||
|
if ($('setDspReverb')) $('setDspReverb').addEventListener('change', e => { dspConfig.params['reverb.preset'] = Number(e.target.value); dspConfig.params['reverb.enabled'] = Number(e.target.value) ? 1 : 0; applyDspSettings(); });
|
||||||
|
if ($('setDspCompressor')) $('setDspCompressor').addEventListener('change', e => { dspConfig.params['compressor.enabled'] = e.target.checked ? 1 : 0; applyDspSettings(); });
|
||||||
|
const refreshDspIrList=async()=>{
|
||||||
|
const select=$('setDspSavedIR');if(!select)return;
|
||||||
|
const selected=select.value;select.replaceChildren(new Option('Choose a saved response',''));
|
||||||
|
try{for(const item of await EQ.listDspIRs())select.add(new Option(`${item.name} · ${item.channels} ch · ${item.frames} frames`,item.name));if([...select.options].some(o=>o.value===selected))select.value=selected;}
|
||||||
|
catch(error){if($('dspIrStatus'))$('dspIrStatus').textContent=`Saved response list unavailable: ${error.message}`;}
|
||||||
|
};
|
||||||
|
if ($('setDspLoadBuiltin')) $('setDspLoadBuiltin').addEventListener('click',async()=>{
|
||||||
|
try{const name=$('setDspBuiltinIR').value;const ref=await EQ.loadDspBuiltinIR(name);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded ${name}${ref?' and saved it for this device':' for this session only'}.`;$('setDspConvolver').checked=true;await refreshDspIrList();}
|
||||||
|
catch(error){toast(`Impulse response failed: ${error.message}`);}
|
||||||
|
});
|
||||||
|
if ($('setDspLoadSavedIR')) $('setDspLoadSavedIR').addEventListener('click',async()=>{
|
||||||
|
const name=$('setDspSavedIR').value;if(!name){await refreshDspIrList();return;}
|
||||||
|
try{const ref=await EQ.loadDspSavedIR(name);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded saved response: ${name}`;$('setDspConvolver').checked=true;await refreshDspIrList();}
|
||||||
|
catch(error){toast(`Saved response failed: ${error.message}`);}
|
||||||
|
});
|
||||||
|
if ($('dspIrChooseFile')) $('dspIrChooseFile').addEventListener('click', e => { e.preventDefault(); $('dspIrFile')?.click(); });
|
||||||
|
if ($('dspIrFile')) $('dspIrFile').addEventListener('change',async e=>{
|
||||||
|
const file=e.target.files?.[0];if(!file)return;
|
||||||
|
if($('dspIrFileName'))$('dspIrFileName').textContent=file.name;
|
||||||
|
try{const ref=await EQ.loadDspIR(file);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded ${ref?.name||file.name}${ref?' and saved it for this device':' for this session only'}.`;$('setDspConvolver').checked=true;await refreshDspIrList();}
|
||||||
|
catch(error){toast(`Impulse response failed: ${error.message}`);}
|
||||||
|
finally{e.target.value='';}
|
||||||
|
});
|
||||||
|
if(jamesDsp.enabled||jamesDsp.downloadForOffline)refreshDspIrList();
|
||||||
|
if ($('dspResetBtn')) $('dspResetBtn').addEventListener('click', () => {
|
||||||
|
dspConfig.preset = 'flat'; dspConfig.params = {};
|
||||||
|
if ($('setDspPreset')) $('setDspPreset').value = 'flat';
|
||||||
|
wrap.querySelectorAll('[data-dsp-param]').forEach(input => { input.value = input.dataset.dspParam === 'limiter.threshold' ? '-0.1' : '0'; });
|
||||||
|
if ($('setDspCrossfeed')) $('setDspCrossfeed').value = '0';
|
||||||
|
if ($('setDspReverb')) $('setDspReverb').value = '0';
|
||||||
|
if ($('setDspCompressor')) $('setDspCompressor').checked = false;
|
||||||
|
applyDspSettings();
|
||||||
|
toast('JamesDSP settings reset');
|
||||||
|
});
|
||||||
|
if ($('dspPresetExportBtn')) $('dspPresetExportBtn').addEventListener('click', () => {
|
||||||
|
const params = { ...(dspConfig.params || {}) }; delete params['convolver.enabled'];
|
||||||
|
const blob = new Blob([JSON.stringify({ version: 1, preset: dspConfig.preset || 'flat', params }, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob), a = document.createElement('a');
|
||||||
|
a.href = url; a.download = 'ytplayer-jamesdsp-preset.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
});
|
||||||
|
if ($('dspPresetImportBtn')) $('dspPresetImportBtn').addEventListener('click', () => $('dspPresetFile')?.click());
|
||||||
|
if ($('dspPresetFile')) $('dspPresetFile').addEventListener('change', async e => {
|
||||||
|
const file = e.target.files?.[0]; if (!file) return;
|
||||||
|
try {
|
||||||
|
if (file.size > 65536) throw new Error('Preset files must be 64 KB or smaller.');
|
||||||
|
const imported = parseJamesDspPreset(await file.text());
|
||||||
|
dspConfig.preset = imported.preset; dspConfig.params = imported.params;
|
||||||
|
if ($('setDspPreset')) $('setDspPreset').value = dspConfig.preset;
|
||||||
|
applyDspSettings(); toast('JamesDSP preset imported');
|
||||||
|
} catch (error) { toast(error?.message || 'Could not import preset'); }
|
||||||
|
finally { e.target.value = ''; }
|
||||||
|
});
|
||||||
|
if ($('dspIrFile')) $('dspIrFile').addEventListener('change', async e => {
|
||||||
|
const file = e.target.files?.[0]; if (!file) return;
|
||||||
|
try { await EQ.loadDspIR(file); dspConfig.params['convolver.enabled'] = 1; applyDspSettings(); toast('Impulse response loaded for this session'); }
|
||||||
|
catch (error) { toast(error?.message || 'Could not load impulse response'); }
|
||||||
|
finally { e.target.value = ''; }
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// ---- Saved searches manager ----
|
// ---- Saved searches manager ----
|
||||||
{
|
{
|
||||||
@@ -1186,6 +1372,8 @@ async function renderSettings() {
|
|||||||
fold.addEventListener('toggle', () => { try { localStorage.setItem('ytpCacheFoldOpen', fold.open ? '1' : '0'); } catch { /* blocked */ } });
|
fold.addEventListener('toggle', () => { try { localStorage.setItem('ytpCacheFoldOpen', fold.open ? '1' : '0'); } catch { /* blocked */ } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.SwUpdate?.offlineStatus().catch(() => {});
|
||||||
|
|
||||||
// ---- Populate live cache stats ----
|
// ---- Populate live cache stats ----
|
||||||
try {
|
try {
|
||||||
const res = await API.cacheList();
|
const res = await API.cacheList();
|
||||||
@@ -1336,7 +1524,7 @@ function renderCard(v, index, list) {
|
|||||||
<div class="thumb">
|
<div class="thumb">
|
||||||
<img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || '')}" alt="" />
|
<img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || '')}" alt="" />
|
||||||
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
|
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
|
||||||
<span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${isDownloading ? '' : '⬇'}</span>
|
<span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${MediaIcons.svg('offline', 'badge-offline')}${MediaIcons.svg('progress', 'badge-progress')}</span>
|
||||||
${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''}
|
${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-info">
|
<div class="card-info">
|
||||||
|
|||||||
@@ -347,7 +347,7 @@
|
|||||||
const s = st.state && st.state.v;
|
const s = st.state && st.state.v;
|
||||||
panel.querySelector('.pt-body').innerHTML = `
|
panel.querySelector('.pt-body').innerHTML = `
|
||||||
${st.code ? `<div class="pt-share"><code>${escapeHtml(link())}</code><button class="np-btn" type="button" data-act="copy">Copy</button>${navigator.share ? '<button class="np-btn" type="button" data-act="share">↗</button>' : ''}</div>` : ''}
|
${st.code ? `<div class="pt-share"><code>${escapeHtml(link())}</code><button class="np-btn" type="button" data-act="copy">Copy</button>${navigator.share ? '<button class="np-btn" type="button" data-act="share">↗</button>' : ''}</div>` : ''}
|
||||||
${!isHost() && s ? `<div class="pt-now">▶ ${escapeHtml(s.title)}</div>` : ''}
|
${!isHost() && s ? `<div class="pt-now">${TransportIcons.svg('play')} ${escapeHtml(s.title)}</div>` : ''}
|
||||||
${!isHost() && st.detached ? '<button class="rv-primary" type="button" data-act="resync">↻ Re-sync with the host</button>' : ''}
|
${!isHost() && st.detached ? '<button class="rv-primary" type="button" data-act="resync">↻ Re-sync with the host</button>' : ''}
|
||||||
<div class="pt-members">${st.members.map((m) => `<span class="pt-member${m.pid === pid ? ' me' : ''}">${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}</span>`).join('')}</div>
|
<div class="pt-members">${st.members.map((m) => `<span class="pt-member${m.pid === pid ? ' me' : ''}">${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}</span>`).join('')}</div>
|
||||||
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
|
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
|
||||||
|
|||||||
199
perf/README.md
199
perf/README.md
@@ -74,6 +74,11 @@ Each scenario records the following metrics across **5 independent runs** (repor
|
|||||||
|
|
||||||
## 4. How to Run
|
## 4. How to Run
|
||||||
|
|
||||||
|
### Optional JamesDSP integration check
|
||||||
|
`node perf/dsp.mjs --browser all` builds short local H.264/AAC test tones, exercises Standard and lazy JamesDSP presets through the real app graph, verifies A/B bypass routes directly to the unchanged Standard chain, measures generated-IR effects and the four-channel/spatial stages in an offline render, checks an arbitrary 32 kHz context, and tests stored-IR offline reload, one worklet heap, no new media sources, and corrupt-WASM fallback. The default install must omit the optional WASM; the harness then opts in through Settings and confirms completion. Detailed measurements stay in ignored `perf/.tmp/dsp-results.json`.
|
||||||
|
|
||||||
|
`node perf/settings-persist.mjs --browser all` also round-trips a disabled, custom JamesDSP preset and the offline-audio opt-in through local storage and confirms the Standard selector remains selected.
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
Symlink dependencies and ensure server dependencies are installed:
|
Symlink dependencies and ensure server dependencies are installed:
|
||||||
```bash
|
```bash
|
||||||
@@ -311,3 +316,197 @@ plus the source frontend build tag. WebKit does not expose the long-task API.
|
|||||||
Migration supports `--out` to preserve earlier phase evidence. Existing lazy
|
Migration supports `--out` to preserve earlier phase evidence. Existing lazy
|
||||||
harness results are copied to `phase4-lazy-2026-10-08.json` after each Phase 4 run
|
harness results are copied to `phase4-lazy-2026-10-08.json` after each Phase 4 run
|
||||||
and the Phase 3 result is restored. No production requests or deploys are needed.
|
and the Phase 3 result is restored. No production requests or deploys are needed.
|
||||||
|
|
||||||
|
## Phase 5 complete measurements
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test perf/measurement.test.mjs
|
||||||
|
node perf/baseline.mjs --runs 5 --browser all --profile all --scenario all \
|
||||||
|
--out perf/results/final-2026-10-08.json \
|
||||||
|
--compare perf/results/baseline-2026-10-07.json
|
||||||
|
node perf/lazy.mjs --browser all --out perf/results/phase5-lazy-2026-10-08.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Warm reloads now wait for verified idle asset caching before counters reset and
|
||||||
|
assert that no application asset body transfers. CSS update totals now include
|
||||||
|
activation and the changed inactive stylesheet's verified background cache fill,
|
||||||
|
matching the feature-update boundary. `readyWireBytes`/`readyDuration` retain the
|
||||||
|
blocking readiness snapshot separately. Core updates stop at blocking readiness
|
||||||
|
because app.js is blocking. Every update asserts that the only application asset
|
||||||
|
payload is the edited file; worker script checks/imports and index/manifest count
|
||||||
|
as overhead in the total. Historical Phase 3/4 CSS numbers were readiness only.
|
||||||
|
No product behavior changes are involved in these measurement corrections.
|
||||||
|
|
||||||
|
Keep browser/build/test processes idle during full timing measurements. WebKit's
|
||||||
|
zero long-task field means unavailable, not zero work. Autoplay-blocked/media-ready
|
||||||
|
null results do not establish playback performance or iPhone audio continuity.
|
||||||
|
|
||||||
|
## UI geometry and media icons
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node perf/ui-geometry.mjs
|
||||||
|
node perf/ui-icons.mjs
|
||||||
|
node --test frontend/media-icons.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Both harnesses run Chromium and WebKit at mobile dimensions with stubbed SpeechRecognition and local Saved/Queue metadata. They serve only the frontend, block service workers and external requests, and never download media. Geometry tests cold and switched layouts, six widths (320–430), mic visible/hidden and update icon visible/hidden: center spread ≤1px, touch targets ≥40px, Saved image/text gap ≥8px, no horizontal overflow or clipped car control. `--quick` limits widths and speech cases; `--browser` selects an engine; `--frontend` selects historical fixtures. `--measure-only --label before` records known broken geometry without exiting nonzero.
|
||||||
|
|
||||||
|
Icon checks switch the same page through four layouts × dark/light/contrast, assert SVG definitions, inherited colour, stroke family and save/progress/offline state, and save Queue/Now Playing/navigation shots. Glass Stage intentionally hides bottom navigation, so its full page is recorded instead. Any assertion failure exits nonzero. Shots and raw measurements live in git-ignored plans/ui-shots/. Full production inventory: plans/ui-icon-inventory.md. Native speech recognition, installed-PWA safe-area insets and iPhone rendering still need device verification.
|
||||||
|
|
||||||
|
### Cold waterfall diagnosis
|
||||||
|
|
||||||
|
`--source-root perf/.tmp/<historical-worktree>` runs that worktree's matching
|
||||||
|
frontend **and server**; `--frontend-commit` instead combines archived frontend
|
||||||
|
with the current backend. Run historical comparisons sequentially to avoid CPU
|
||||||
|
contention. The result records `sourceCommit`, `sourceRoot` and the served build.
|
||||||
|
Every cold sample has `waterfalls`: proxy request order/start/headers/end times
|
||||||
|
and page Resource Timing discovery/start/end times. Proxy times start when
|
||||||
|
tracking begins; page times start at navigation. Compare within each clock,
|
||||||
|
then use the page FCP to locate the rendering dependency.
|
||||||
|
|
||||||
|
Pass `--assert-cold` on new builds to require exactly one positive-byte app.js
|
||||||
|
response in every cold sample, including worker installation. Omit this assertion
|
||||||
|
when diagnosing historical regressions.
|
||||||
|
|
||||||
|
Phase 6 counts its fetch-owned app.js response in initial JS/CSS bytes. Since the
|
||||||
|
bootstrap consumes that response asynchronously, DOMContentLoaded no longer means
|
||||||
|
app execution is complete; compare the existing actual boot-done landmark.
|
||||||
|
|
||||||
|
`--scenario warm,offline,update-js,update-css,update-feature` runs a regression
|
||||||
|
sweep without repeating an already-completed cold acceptance run.
|
||||||
|
|
||||||
|
## Runtime manifest hygiene
|
||||||
|
|
||||||
|
`server/asset-manifest.js` excludes unit tests (`*.test.js`, `*.test.mjs`),
|
||||||
|
Markdown, text licences/data (`*.txt`), C build sources, `*.entry.js` build
|
||||||
|
entries, `/admin.html`, and files below any `perf/` directory from the runtime
|
||||||
|
manifest and both build-tag modes. They remain directly servable with the usual
|
||||||
|
hash and cache headers. If a matching file is genuinely needed at runtime, add
|
||||||
|
its exact root-relative path to the optional `include` array in
|
||||||
|
`frontend/assets.json`; group declarations referencing excluded files fail at boot
|
||||||
|
unless explicitly included.
|
||||||
|
|
||||||
|
The offline completeness harness compares cache contents with the runtime
|
||||||
|
manifest dynamically; it must not hard-code the old 151-file inventory. See
|
||||||
|
`plans/manifest-hygiene-report.md` and `perf/results/hygiene-*.json` for the
|
||||||
|
before/after measurements and compatibility checks.
|
||||||
|
|
||||||
|
## Existing-install settings preservation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node perf/settings-persist.mjs --browser all --target main --allow-unsupported-webkit
|
||||||
|
node perf/settings-persist.mjs --browser all --target 0b8e398 --allow-unsupported-webkit --out perf/results/settings-persist-live-base.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The harness archives b77938a and the requested target into worktree-local scratch,
|
||||||
|
serves actual Bun servers at the same origin, sets values through Settings UI
|
||||||
|
handlers, reloads, invokes the old banner's actual Refresh UI button, and diffs
|
||||||
|
every `_ytpdata.settings` key and control. A real local H.264/AAC clip verifies
|
||||||
|
OPFS storage and blob playback before/after in Chromium. A local HTTP proxy
|
||||||
|
mocks profile timestamps for worker-owned fetches on both engines and tests
|
||||||
|
backup import. There are no external profile writes or media requests.
|
||||||
|
|
||||||
|
Failures exit 1. Linux WebKit has no native OPFS; without the explicit allowance
|
||||||
|
the unverified storage capability exits 2. The allowance only skips unsupported
|
||||||
|
storage assertions; every preservation/profile/import assertion still runs.
|
||||||
|
No storage shim substitutes for a genuine iPhone test. Snapshot results and the
|
||||||
|
per-key table are in `plans/settings-persist-investigation.md`.
|
||||||
|
|
||||||
|
## Device autosave investigation (2026-10-09)
|
||||||
|
|
||||||
|
`autosave.mjs` starts an isolated real Bun server inside this worktree, seeds its
|
||||||
|
media-cache DB with a three-second H.264/AAC clip, and uses 414×896 Playwright
|
||||||
|
contexts with the Framework7 Swipe layout. It checks OPFS bytes, `cachedIds`,
|
||||||
|
Saved and Downloads views, and both Settings handlers. Public network requests
|
||||||
|
are blocked. No production access or YouTube download is needed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash perf/make-shell-fixture.sh b77938a 1175f1a1d2c1
|
||||||
|
node perf/autosave.mjs --frontend perf/fixtures/shell-1175f1a1d2c1 \
|
||||||
|
--asset-hashing 0 --asset-sync 0 --allow-unsupported-webkit \
|
||||||
|
--out perf/results/autosave-b77938a.json
|
||||||
|
node perf/autosave.mjs --frontend-commit main --allow-unsupported-webkit \
|
||||||
|
--out perf/results/autosave-main.json
|
||||||
|
node perf/autosave.mjs --browser chromium --out perf/results/autosave-current.json
|
||||||
|
node --test frontend/autosave.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Options: `--frontend <directory>` or `--frontend-commit <revision>`;
|
||||||
|
`--browser chromium|webkit|all`; `--scenarios <comma-separated names>`;
|
||||||
|
`--asset-hashing 0|1`, `--asset-sync 0|1`; `--out <json>`.
|
||||||
|
A failed assertion exits 1. Missing native OPFS exits 2 unless explicitly
|
||||||
|
allowed with `--allow-unsupported-webkit`. On this Linux Playwright WebKit build,
|
||||||
|
`navigator.storage` is absent, including in persistent contexts; enabling its
|
||||||
|
MiniBrowser storage feature flags also did not expose it. WebKit therefore
|
||||||
|
checks real UI/settings/save guards and records `supported:false`; those results
|
||||||
|
**do not establish a successful WebKit OPFS save**. No storage shim is installed.
|
||||||
|
Run without the allowance on a Safari-capable environment to require native OPFS.
|
||||||
|
|
||||||
|
Observed contracts in both b77938a and current code:
|
||||||
|
|
||||||
|
| Action | Device save |
|
||||||
|
| --- | --- |
|
||||||
|
| Open a video with default settings | No |
|
||||||
|
| Open with `saveBeforePlay:true`, video mode | Yes |
|
||||||
|
| Open audio-only, or `preferStream:true` | No |
|
||||||
|
| Create/add to a playlist, even with `autoPreload:false` | Yes |
|
||||||
|
| Launch with playlist videos and `autoPreload:true` | Yes |
|
||||||
|
| Launch with `autoPreload:false`, unpinned playlist | No |
|
||||||
|
| Launch with a pinned playlist, even with auto-preload off | Yes |
|
||||||
|
| Enable either save setting through Settings | Saves as above; persisted |
|
||||||
|
|
||||||
|
Chromium: all 12 cases pass for old/current builds and Phase 1/2 tips.
|
||||||
|
Phase 3/4/5 tips and both rollback flags pass the six positive cases
|
||||||
|
(`open-save-first,playlist-add,playlist-boot,pinned-boot,settings-enable,settings-save-first`).
|
||||||
|
JSON evidence is in `perf/results/autosave-*.json`. No old=saves/new=doesn't
|
||||||
|
transition was found, so there is no defensible breaking commit or product fix.
|
||||||
|
The unit tests cover playlist/pinning policy, duplicate saves, and failed or
|
||||||
|
paused saves remaining retryable without being marked Saved.
|
||||||
|
|
||||||
|
Neighbour audit: Downloads, Saved, OPFS, download controls and error helpers
|
||||||
|
remain eager core assets. Export awaits `feature:export`; Share/External await
|
||||||
|
`feature:share-external`; worker creation is synchronous and receives build-local
|
||||||
|
hash/resume URLs. DirectMedia's unloaded `hasSource()` facade returns false, so
|
||||||
|
an early paired-source lookup can choose the server instead; this does not
|
||||||
|
prevent the server save. P2P settings counts queried before its delayed lazy
|
||||||
|
startup can initially show no registration. These remain follow-up candidates,
|
||||||
|
not demonstrated causes of missing OPFS saves. The scout's proposed Data Saver
|
||||||
|
cause was rejected: its guards affect static warming and stream pre-resolution,
|
||||||
|
not `preload()` or `saveBeforePlay`.
|
||||||
|
|
||||||
|
Still required on the owner's installed iPhone PWA: record the running build,
|
||||||
|
check Save before playing / Audio-only / Auto-save settings, open a fresh video,
|
||||||
|
add it to a playlist, and inspect Downloads failure details and Saved after a
|
||||||
|
relaunch/offline playback. Verify available storage and whether iOS evicted
|
||||||
|
previous copies. A failing iPhone sequence is needed to continue the bisect.
|
||||||
|
|
||||||
|
Transport controls use the same shared-definition approach as media/save icons.
|
||||||
|
`node perf/ui-icons.mjs` also checks all four layouts × dark/light/contrast in
|
||||||
|
Chromium and WebKit: shared monochrome symbols, accessible labels, visible
|
||||||
|
44px targets, F7 filled glyphs, runtime layout changes, main/stage/mini/service
|
||||||
|
play-pause state swaps, fullscreen and Car mode refreshes, and actual paired
|
||||||
|
remote toggles against a mocked relay. Transport previews and remote screenshots
|
||||||
|
are saved in ignored `plans/transport-shots/`. Run `node perf/ui-geometry.mjs`
|
||||||
|
for the 320–430px search-bar and Saved-row geometry matrix. OS lock-screen buttons
|
||||||
|
remain platform-rendered through the existing MediaSession handlers.
|
||||||
|
|
||||||
|
Run `node perf/settings-layout.mjs` for the Settings workspace and JamesDSP
|
||||||
|
control matrix: Chromium and WebKit, all four layouts, dark/light/contrast, and
|
||||||
|
390/768/1280/1440/1920px widths. It checks horizontal overflow, responsive row
|
||||||
|
geometry, themed selects and switches, the clipped native file picker, and
|
||||||
|
button wrapping; desktop screenshots and measurements are written to
|
||||||
|
`plans/settings-shots/`.
|
||||||
|
|
||||||
|
## Transient retry verification
|
||||||
|
|
||||||
|
`retry.mjs` starts the real Bun server behind the local traffic-shaping proxy, then
|
||||||
|
injects one HTTP 503 into search, one dropped related-video request, and one lazy
|
||||||
|
chunk load failure. Each path must recover automatically without exposing the
|
||||||
|
manual related/player Retry affordance. It runs Chromium and WebKit by default;
|
||||||
|
choose `--browser chromium|webkit` and `--profile lte|lossy|unthrottled` to narrow
|
||||||
|
it. Retry logic is limited to idempotent GET requests and chunk loads; explicit
|
||||||
|
save/retry POST actions remain user initiated.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node perf/retry.mjs --browser all --profile lossy
|
||||||
|
```
|
||||||
|
|||||||
128
perf/autosave.mjs
Normal file
128
perf/autosave.mjs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Device autosave checks against a real, isolated Bun server and a local H.264 clip.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawn, execFileSync } from 'node:child_process';
|
||||||
|
import { mkdirSync, mkdtempSync, symlinkSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { chromium, webkit } from 'playwright';
|
||||||
|
const root = path.resolve(import.meta.dirname, '..');
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { createClient } = require(path.join(root, 'server/node_modules/@libsql/client'));
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
function option(name, fallback) { const i = args.indexOf(name); return i < 0 ? fallback : args[i + 1]; }
|
||||||
|
const target = option('--browser', 'all');
|
||||||
|
assert.ok(['all', 'chromium', 'webkit'].includes(target));
|
||||||
|
mkdirSync(path.join(root, 'perf/.tmp'), { recursive: true });
|
||||||
|
const scratch = mkdtempSync(path.join(root, 'perf/.tmp/autosave-'));
|
||||||
|
const revision = option('--frontend-commit', null);
|
||||||
|
let tree = path.resolve(option('--frontend', path.join(root, 'frontend')));
|
||||||
|
if (revision) {
|
||||||
|
const archive = path.join(scratch, 'frontend.tar');
|
||||||
|
writeFileSync(archive, execFileSync('git', ['archive', revision, 'frontend'], { cwd: root, maxBuffer: 30 * 1024 * 1024 }));
|
||||||
|
execFileSync('tar', ['-xf', archive, '-C', scratch]);
|
||||||
|
tree = path.join(scratch, 'frontend');
|
||||||
|
}
|
||||||
|
const portProbe = createServer();
|
||||||
|
await new Promise(r => portProbe.listen(0, '127.0.0.1', r));
|
||||||
|
const port = portProbe.address().port;
|
||||||
|
await new Promise(r => portProbe.close(r));
|
||||||
|
const origin = `http://127.0.0.1:${port}`;
|
||||||
|
const clip = { id: 'benchmedia01', title: 'Autosave local clip', channel: 'Regression', duration: 3, thumbnail: '' };
|
||||||
|
const media = path.join(scratch, 'data/media');
|
||||||
|
mkdirSync(media, { recursive: true });
|
||||||
|
symlinkSync(tree, path.join(scratch, 'public'));
|
||||||
|
const mp4 = path.join(media, clip.id + '.1.mp4');
|
||||||
|
const dbPath = path.join(scratch, 'data/test.db');
|
||||||
|
const child = spawn('bun', [path.join(root, 'server/server.js')], { cwd: scratch, env: { ...process.env, PORT: String(port), DB_PATH: dbPath, MEDIA_DIR: media, UPLOAD_DIR: path.join(scratch, 'uploads'), DATA_DIR: path.join(scratch, 'data'), ASSET_HASHING: option('--asset-hashing', '1'), ASSET_SYNC: option('--asset-sync', '1'), BUILD_TAG: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let serverLog = '';
|
||||||
|
for (const stream of [child.stdout, child.stderr]) stream.on('data', c => { serverLog = (serverLog + c).slice(-5000); });
|
||||||
|
const results = { revision: revision || tree, browsers: {}, failures: [], unsupported: [] };
|
||||||
|
const scenarios = ['open-default', 'open-save-first', 'open-audio-only', 'open-prefer-stream', 'playlist-add', 'playlist-add-setting-off', 'playlist-existing-add', 'playlist-boot', 'playlist-boot-off', 'pinned-boot', 'settings-enable', 'settings-save-first'];
|
||||||
|
try {
|
||||||
|
for (let i = 0; ; i++) {
|
||||||
|
try { if ((await fetch(origin + '/api/version')).ok) break; } catch {}
|
||||||
|
if (i === 100 || child.exitCode !== null) throw Error('Bun server failed: ' + serverLog);
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
results.buildTag = (await (await fetch(origin + '/api/version')).json()).buildTag;
|
||||||
|
execFileSync('ffmpeg', ['-y', '-f', 'lavfi', '-i', 'color=c=blue:s=160x90:r=10:d=3', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', '-shortest', mp4], { stdio: 'ignore' });
|
||||||
|
execFileSync('ffmpeg', ['-y', '-i', mp4, '-vn', '-c:a', 'copy', path.join(media, clip.id + '.1.m4a')], { stdio: 'ignore' });
|
||||||
|
const db = createClient({ url: 'file:' + dbPath });
|
||||||
|
await db.execute({ sql: 'INSERT OR REPLACE INTO media_cache (video_id,status,gen,size,height,vcodec,acodec,duration,meta,last_access) VALUES (?,?,?,?,?,?,?,?,?,?)', args: [clip.id, 'ready', 1, statSync(mp4).size, 90, 'h264', 'aac', 3, JSON.stringify(clip), Date.now()] });
|
||||||
|
db.close();
|
||||||
|
for (const [name, type] of Object.entries({ chromium, webkit })) {
|
||||||
|
if (target !== 'all' && target !== name) continue;
|
||||||
|
const browser = await type.launch({ headless: true });
|
||||||
|
results.browsers[name] = [];
|
||||||
|
try {
|
||||||
|
for (const scenario of scenarios.filter(s => !option('--scenarios', null) || option('--scenarios').split(',').includes(s))) {
|
||||||
|
const context = await browser.newContext({ viewport: { width: 414, height: 896 }, serviceWorkers: 'allow' });
|
||||||
|
const page = await context.newPage(), errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
const autoPreload = !['pinned-boot', 'playlist-boot-off', 'playlist-add-setting-off', 'settings-enable'].includes(scenario);
|
||||||
|
const settings = { layout: 'f7-swipe', autoPreload, saveBeforePlay: ['open-save-first', 'open-audio-only', 'open-prefer-stream'].includes(scenario), audioOnly: scenario === 'open-audio-only' };
|
||||||
|
const hasPlaylist = scenario.endsWith('boot') || ['playlist-boot-off', 'settings-enable', 'playlist-existing-add'].includes(scenario);
|
||||||
|
const playlists = hasPlaylist ? [{ id: 'test', name: 'Autosave playlist', videos: scenario === 'playlist-existing-add' ? [] : [clip] }] : [];
|
||||||
|
await context.addInitScript(({ settings, playlists, clip, scenario }) => localStorage.setItem('_ytpdata', JSON.stringify({ settings, playlists, history: [clip], pinnedPlaylists: scenario === 'pinned-boot' ? ['test'] : [] })), { settings, playlists, clip, scenario });
|
||||||
|
// No public service/YouTube requests; the local clip uses real prepare/range endpoints.
|
||||||
|
await context.route('**/*', r => r.request().url().startsWith(origin) ? r.continue() : r.abort());
|
||||||
|
await context.route('**/api/streams/warm**', r => r.fulfill({ json: { ok: true } }));
|
||||||
|
try {
|
||||||
|
await page.goto(origin, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => typeof data !== 'undefined' && window.SettingsSections?.get('downloads-storage'));
|
||||||
|
const supported = await page.evaluate(() => OPFS.isSupported());
|
||||||
|
if (!supported && !results.unsupported.includes(name)) results.unsupported.push(name);
|
||||||
|
// Count invocations in browsers without native OPFS; never treat this as stored bytes.
|
||||||
|
await page.evaluate(() => { window.__saveCalls = 0; const original = API.cacheDownload; API.cacheDownload = (...a) => { window.__saveCalls++; return original(...a); }; });
|
||||||
|
if (scenario.startsWith('open')) await page.evaluate(({ clip, scenario }) => { Player.loadVideo(clip, { preferStream: scenario === 'open-prefer-stream' }); }, { clip, scenario });
|
||||||
|
if (scenario.startsWith('playlist-add')) {
|
||||||
|
await page.evaluate(clip => newPlaylist(clip), clip);
|
||||||
|
await page.locator('#modal input').fill('Autosave playlist');
|
||||||
|
await page.getByRole('button', { name: 'Create', exact: true }).evaluate(b => b.click());
|
||||||
|
}
|
||||||
|
if (scenario === 'playlist-existing-add') {
|
||||||
|
await page.evaluate(clip => openCardMenu(clip), clip);
|
||||||
|
await page.getByRole('button', { name: '+ Autosave playlist', exact: true }).evaluate(b => b.click());
|
||||||
|
}
|
||||||
|
if (scenario.startsWith('settings-')) {
|
||||||
|
await page.evaluate(() => { view = { type: 'settings' }; renderList(); });
|
||||||
|
const id = scenario === 'settings-enable' ? 'setAutoPreload' : 'setSaveBeforePlay';
|
||||||
|
await page.locator('#' + id).evaluate(e => { e.checked = true; e.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||||
|
const key = scenario === 'settings-enable' ? 'autoPreload' : 'saveBeforePlay';
|
||||||
|
await page.waitForFunction(key => JSON.parse(localStorage.getItem('_ytpdata')).settings[key] === true, key);
|
||||||
|
if (scenario === 'settings-save-first') await page.evaluate(clip => { Player.loadVideo(clip); }, clip);
|
||||||
|
}
|
||||||
|
const expected = !['open-default', 'open-audio-only', 'open-prefer-stream', 'playlist-boot-off'].includes(scenario);
|
||||||
|
if (supported && expected) await page.waitForFunction(id => cachedIds.has(id), clip.id, { timeout: 15000 });
|
||||||
|
else await page.waitForTimeout(1000);
|
||||||
|
const state = await page.evaluate(async id => ({ cached: cachedIds.has(id), files: OPFS.isSupported() ? await OPFS.listVideos() : [], queue: SaveQueue.pending(), calls: window.__saveCalls }), clip.id);
|
||||||
|
assert.equal(state.cached, expected && supported, JSON.stringify(state));
|
||||||
|
assert.equal(state.files.some(f => f.id === clip.id && f.size > 0), expected && supported, JSON.stringify(state));
|
||||||
|
if (!supported && expected && !['open-save-first', 'settings-save-first', 'playlist-boot', 'pinned-boot'].includes(scenario)) assert.ok(state.calls > 0, 'save action was not invoked');
|
||||||
|
if (supported && expected) {
|
||||||
|
assert.equal(state.files.find(f => f.id === clip.id).size, statSync(mp4).size, 'stored file is incomplete');
|
||||||
|
await page.evaluate(() => { view = { type: 'saved' }; renderList(); });
|
||||||
|
await page.waitForFunction(() => document.getElementById('cards').textContent.includes('Autosave local clip'));
|
||||||
|
await page.evaluate(() => { view = { type: 'downloads' }; renderList(); });
|
||||||
|
await page.waitForFunction(() => document.getElementById('listTitle').textContent === 'Downloads');
|
||||||
|
}
|
||||||
|
const result = { scenario, supported, saved: state.cached, bytes: state.files.find(f => f.id === clip.id)?.size || 0, calls: state.calls, errors };
|
||||||
|
results.browsers[name].push(result);
|
||||||
|
console.log(`${name} ${scenario}: PASS ${supported ? 'saved=' + state.cached : 'UI/save guard only; OPFS unavailable'}`);
|
||||||
|
} catch (e) {
|
||||||
|
results.failures.push({ browser: name, scenario, error: e.message, errors });
|
||||||
|
console.log(`${name} ${scenario}: FAIL ${e.message.split('\n')[0]}`);
|
||||||
|
} finally { await context.close(); }
|
||||||
|
}
|
||||||
|
} finally { await browser.close(); }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (child.exitCode === null) { child.kill('SIGTERM'); await new Promise(r => child.once('exit', r)); }
|
||||||
|
rmSync(scratch, { recursive: true, force: true });
|
||||||
|
const out = option('--out', null);
|
||||||
|
if (out) { mkdirSync(path.dirname(path.resolve(out)), { recursive: true }); writeFileSync(out, JSON.stringify(results, null, 2) + '\n'); }
|
||||||
|
}
|
||||||
|
if (results.failures.length) process.exitCode = 1;
|
||||||
|
else if (results.unsupported.length && !args.includes('--allow-unsupported-webkit')) { console.error('Native OPFS not verified in: ' + results.unsupported.join(', ')); process.exitCode = 2; }
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { assertColdAssets, assertChangedAssets, assertWarmAssets, waitForIdleAssets } from './measurement.mjs';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { spawn, spawnSync, execFileSync } from 'node:child_process';
|
import { spawn, spawnSync, execFileSync } from 'node:child_process';
|
||||||
import { existsSync, mkdirSync, rmSync, cpSync, appendFileSync, statSync, writeFileSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs';
|
import { existsSync, mkdirSync, rmSync, cpSync, appendFileSync, statSync, writeFileSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs';
|
||||||
@@ -61,6 +62,8 @@ function parseArgs() {
|
|||||||
out: null,
|
out: null,
|
||||||
compare: null,
|
compare: null,
|
||||||
frontendCommit: null,
|
frontendCommit: null,
|
||||||
|
sourceRoot: null,
|
||||||
|
assertCold: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
@@ -70,6 +73,8 @@ function parseArgs() {
|
|||||||
else if (a === '--profile' && i + 1 < args.length) options.profile = args[++i];
|
else if (a === '--profile' && i + 1 < args.length) options.profile = args[++i];
|
||||||
else if (a === '--scenario' && i + 1 < args.length) options.scenario = args[++i];
|
else if (a === '--scenario' && i + 1 < args.length) options.scenario = args[++i];
|
||||||
else if (a === '--out' && i + 1 < args.length) options.out = args[++i];
|
else if (a === '--out' && i + 1 < args.length) options.out = args[++i];
|
||||||
|
else if (a === '--assert-cold') options.assertCold = true;
|
||||||
|
else if (a === '--source-root' && i + 1 < args.length) options.sourceRoot = path.resolve(args[++i]);
|
||||||
else if (a === '--frontend-commit' && i + 1 < args.length) options.frontendCommit = args[++i];
|
else if (a === '--frontend-commit' && i + 1 < args.length) options.frontendCommit = args[++i];
|
||||||
else if (a === '--compare' && i + 1 < args.length) options.compare = args[++i];
|
else if (a === '--compare' && i + 1 < args.length) options.compare = args[++i];
|
||||||
}
|
}
|
||||||
@@ -150,7 +155,8 @@ async function seedDatabase(dbPath, mediaFiles) {
|
|||||||
* Bun Server Manager
|
* Bun Server Manager
|
||||||
*/
|
*/
|
||||||
class ServerManager {
|
class ServerManager {
|
||||||
constructor(scratchDir, publicDir) {
|
constructor(scratchDir, publicDir, sourceRoot = REPO_ROOT) {
|
||||||
|
this.sourceRoot = sourceRoot;
|
||||||
this.scratchDir = scratchDir;
|
this.scratchDir = scratchDir;
|
||||||
this.publicDir = publicDir;
|
this.publicDir = publicDir;
|
||||||
this.dataDir = path.join(scratchDir, 'data');
|
this.dataDir = path.join(scratchDir, 'data');
|
||||||
@@ -183,7 +189,7 @@ class ServerManager {
|
|||||||
DB_PATH: this.dbPath,
|
DB_PATH: this.dbPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
const serverScript = path.join(REPO_ROOT, 'server', 'server.js');
|
const serverScript = path.join(this.sourceRoot, 'server', 'server.js');
|
||||||
this.serverProcess = spawn('bun', [serverScript], {
|
this.serverProcess = spawn('bun', [serverScript], {
|
||||||
cwd: srvCwd,
|
cwd: srvCwd,
|
||||||
env,
|
env,
|
||||||
@@ -371,8 +377,8 @@ async function runColdScenario({ browserType, server, proxy, profile }) {
|
|||||||
const fcp = paint.find((p) => p.name === 'first-contentful-paint');
|
const fcp = paint.find((p) => p.name === 'first-contentful-paint');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).reduce((sum,entry)=>sum+entry.encodedBodySize,0),
|
initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).reduce((sum,entry)=>sum+entry.encodedBodySize,0),
|
||||||
initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).map(entry=>({url:entry.name,bytes:entry.encodedBodySize})),
|
initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).map(entry=>({url:entry.name,bytes:entry.encodedBodySize,start:entry.startTime,responseStart:entry.responseStart,end:entry.responseEnd,initiator:entry.initiatorType})),
|
||||||
fcp: fcp ? Math.round(fcp.startTime) : null,
|
fcp: fcp ? Math.round(fcp.startTime) : null,
|
||||||
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
||||||
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||||
@@ -461,6 +467,8 @@ async function runWarmScenario({ browserType, server, proxy, profile }) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await waitForIdleAssets(page);
|
||||||
|
|
||||||
// Now measure warm re-navigation
|
// Now measure warm re-navigation
|
||||||
proxy.resetStats();
|
proxy.resetStats();
|
||||||
proxy.startTracking();
|
proxy.startTracking();
|
||||||
@@ -470,6 +478,7 @@ async function runWarmScenario({ browserType, server, proxy, profile }) {
|
|||||||
|
|
||||||
proxy.stopTracking();
|
proxy.stopTracking();
|
||||||
const stats = proxy.getStats();
|
const stats = proxy.getStats();
|
||||||
|
assertWarmAssets(stats.requests);
|
||||||
|
|
||||||
const pageMetrics = await page.evaluate(() => {
|
const pageMetrics = await page.evaluate(() => {
|
||||||
const nav = performance.getEntriesByType('navigation')[0];
|
const nav = performance.getEntriesByType('navigation')[0];
|
||||||
@@ -477,8 +486,8 @@ async function runWarmScenario({ browserType, server, proxy, profile }) {
|
|||||||
const fcp = paint.find((p) => p.name === 'first-contentful-paint');
|
const fcp = paint.find((p) => p.name === 'first-contentful-paint');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).reduce((sum,entry)=>sum+entry.encodedBodySize,0),
|
initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).reduce((sum,entry)=>sum+entry.encodedBodySize,0),
|
||||||
initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).map(entry=>({url:entry.name,bytes:entry.encodedBodySize})),
|
initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).map(entry=>({url:entry.name,bytes:entry.encodedBodySize,start:entry.startTime,responseStart:entry.responseStart,end:entry.responseEnd,initiator:entry.initiatorType})),
|
||||||
fcp: fcp ? Math.round(fcp.startTime) : null,
|
fcp: fcp ? Math.round(fcp.startTime) : null,
|
||||||
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
||||||
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||||
@@ -558,8 +567,8 @@ async function runOfflineScenario({ browserType, server, proxy, profile }) {
|
|||||||
const fcp = paint.find((p) => p.name === 'first-contentful-paint');
|
const fcp = paint.find((p) => p.name === 'first-contentful-paint');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).reduce((sum,entry)=>sum+entry.encodedBodySize,0),
|
initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).reduce((sum,entry)=>sum+entry.encodedBodySize,0),
|
||||||
initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).map(entry=>({url:entry.name,bytes:entry.encodedBodySize})),
|
initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).map(entry=>({url:entry.name,bytes:entry.encodedBodySize,start:entry.startTime,responseStart:entry.responseStart,end:entry.responseEnd,initiator:entry.initiatorType})),
|
||||||
fcp: fcp ? Math.round(fcp.startTime) : null,
|
fcp: fcp ? Math.round(fcp.startTime) : null,
|
||||||
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
||||||
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||||
@@ -622,24 +631,7 @@ async function runUpdateScenario({ browserType, server, proxy, profile, updateKi
|
|||||||
|
|
||||||
// Phase 3 activation precedes idle feature caching. Measure updates from a
|
// Phase 3 activation precedes idle feature caching. Measure updates from a
|
||||||
// genuinely warm build, rather than counting unfinished first-install bytes.
|
// genuinely warm build, rather than counting unfinished first-install bytes.
|
||||||
if (await page.evaluate(()=>!!window.Lazy && !!window.Lazy.warm)) {
|
await waitForIdleAssets(page);
|
||||||
await page.evaluate(()=>Lazy.warm());
|
|
||||||
const end=Date.now()+90000;
|
|
||||||
while (true) {
|
|
||||||
const ready=await page.evaluate(async()=>{
|
|
||||||
const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');
|
|
||||||
if(!r)return false;
|
|
||||||
const m=(await r.json()).current;
|
|
||||||
return (await Promise.all(Object.values(m.groups).flatMap(g=>g.files).map(async p=>{
|
|
||||||
const response=await c.match(p+'?v='+m.files[p].h);
|
|
||||||
return response?.headers.get('X-Asset-Hash')===m.files[p].h;
|
|
||||||
}))).every(Boolean);
|
|
||||||
});
|
|
||||||
if(ready)break;
|
|
||||||
if(Date.now()>end)throw Error('Idle warm did not complete before update benchmark');
|
|
||||||
await sleep(100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch server to Tree N+1 (app.js or theme-glass.css modification)
|
// Switch server to Tree N+1 (app.js or theme-glass.css modification)
|
||||||
const targetTree = updateKind === 'feature' ? trees.treeUpdateFeature : updateKind === 'js' ? trees.treeUpdateJs : trees.treeUpdateCss;
|
const targetTree = updateKind === 'feature' ? trees.treeUpdateFeature : updateKind === 'js' ? trees.treeUpdateJs : trees.treeUpdateCss;
|
||||||
@@ -662,20 +654,23 @@ async function runUpdateScenario({ browserType, server, proxy, profile, updateKi
|
|||||||
await sleep(50);
|
await sleep(50);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updateKind === 'feature') {
|
const readyDuration = Date.now() - t0;
|
||||||
|
const readyWireBytes = proxy.getStats().totalWireBytes;
|
||||||
|
if (updateKind === 'feature' || updateKind === 'css') {
|
||||||
|
const changedPath=updateKind==='feature'?'/presenter.js':'/theme-glass.css';
|
||||||
// Readiness excludes background chunks. Include activation and the changed
|
// Readiness excludes background chunks. Include activation and the changed
|
||||||
// feature's actual idle transfer rather than reporting readiness as its cost.
|
// feature's actual idle transfer rather than reporting readiness as its cost.
|
||||||
const expected = createHash('sha256').update(readFileSync(path.join(targetTree,'presenter.js'))).digest('hex').slice(0,10);
|
const expected = createHash('sha256').update(readFileSync(path.join(targetTree,changedPath.slice(1)))).digest('hex').slice(0,10);
|
||||||
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.waiting.postMessage({type:'SKIP_WAITING'});});
|
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.waiting.postMessage({type:'SKIP_WAITING'});});
|
||||||
const end=Date.now()+90000;
|
const end=Date.now()+90000;
|
||||||
while(true) {
|
while(true) {
|
||||||
const complete=await page.evaluate(async h=>{
|
const complete=await page.evaluate(async ({h,p})=>{
|
||||||
const r=await navigator.serviceWorker.getRegistration();
|
const r=await navigator.serviceWorker.getRegistration();
|
||||||
if(r.waiting || r.installing)return false;
|
if(r.waiting || r.installing)return false;
|
||||||
r.active?.postMessage({type:'WARM_ASSETS',saveData:false});
|
r.active?.postMessage({type:'WARM_ASSETS',saveData:false});
|
||||||
const c=await caches.open('ytplayer-assets'),entry=await c.match('/presenter.js?v='+h);
|
const c=await caches.open('ytplayer-assets'),entry=await c.match(p+'?v='+h);
|
||||||
return entry?.headers.get('X-Asset-Hash')===h;
|
return entry?.headers.get('X-Asset-Hash')===h;
|
||||||
},expected);
|
},{h:expected,p:changedPath});
|
||||||
if(complete)break;
|
if(complete)break;
|
||||||
if(Date.now()>end)throw Error('Changed feature was not cached after activation');
|
if(Date.now()>end)throw Error('Changed feature was not cached after activation');
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
@@ -684,14 +679,13 @@ async function runUpdateScenario({ browserType, server, proxy, profile, updateKi
|
|||||||
const duration = Date.now() - t0;
|
const duration = Date.now() - t0;
|
||||||
proxy.stopTracking();
|
proxy.stopTracking();
|
||||||
const stats = proxy.getStats();
|
const stats = proxy.getStats();
|
||||||
if(updateKind==='feature') {
|
assertChangedAssets(stats.requests, updateKind==='feature'?'/presenter.js':updateKind==='css'?'/theme-glass.css':'/app.js');
|
||||||
const assetDownloads=stats.requests.filter(r=>r.bytes>0).map(r=>new URL(r.url,navUrl).pathname).filter(p=>/\.(?:js|css|woff2?|png|svg)$/.test(p)&&!['/sw.js','/asset-sync-core.js'].includes(p));
|
|
||||||
assert.deepEqual([...new Set(assetDownloads)],['/presenter.js'],'feature edit transfers only its chunk; no app/core/font/icon payload');
|
|
||||||
}
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updateKind,
|
updateKind,
|
||||||
|
readyDuration,
|
||||||
|
readyWireBytes,
|
||||||
requests: stats.requests,
|
requests: stats.requests,
|
||||||
duration,
|
duration,
|
||||||
requestCount: stats.requestCount,
|
requestCount: stats.requestCount,
|
||||||
@@ -715,6 +709,8 @@ async function main() {
|
|||||||
if (options.browser === 'all' || options.browser === 'chromium') browsers.push(pw.chromium);
|
if (options.browser === 'all' || options.browser === 'chromium') browsers.push(pw.chromium);
|
||||||
if (options.browser === 'all' || options.browser === 'webkit') browsers.push(pw.webkit);
|
if (options.browser === 'all' || options.browser === 'webkit') browsers.push(pw.webkit);
|
||||||
|
|
||||||
|
const requestedScenarios=new Set(options.scenario.split(','));
|
||||||
|
const wants=name=>requestedScenarios.has('all')||requestedScenarios.has(name);
|
||||||
const profiles = [];
|
const profiles = [];
|
||||||
if (options.profile === 'all' || options.profile === 'lte') profiles.push('lte');
|
if (options.profile === 'all' || options.profile === 'lte') profiles.push('lte');
|
||||||
if (options.profile === 'all' || options.profile === 'lossy') profiles.push('lossy');
|
if (options.profile === 'all' || options.profile === 'lossy') profiles.push('lossy');
|
||||||
@@ -728,7 +724,7 @@ async function main() {
|
|||||||
const treeUpdateCss = path.join(tmpRoot, 'tree-update-css');
|
const treeUpdateCss = path.join(tmpRoot, 'tree-update-css');
|
||||||
const treeUpdateFeature = path.join(tmpRoot, 'tree-update-feature');
|
const treeUpdateFeature = path.join(tmpRoot, 'tree-update-feature');
|
||||||
|
|
||||||
let frontendSource=path.join(REPO_ROOT,'frontend');
|
let frontendSource=path.join(options.sourceRoot || REPO_ROOT,'frontend');
|
||||||
if(options.frontendCommit) {
|
if(options.frontendCommit) {
|
||||||
const archive=path.join(tmpRoot,'frontend.tar');
|
const archive=path.join(tmpRoot,'frontend.tar');
|
||||||
writeFileSync(archive,execFileSync('git',['archive',options.frontendCommit,'frontend'],{cwd:REPO_ROOT,maxBuffer:20*1024*1024}));
|
writeFileSync(archive,execFileSync('git',['archive',options.frontendCommit,'frontend'],{cwd:REPO_ROOT,maxBuffer:20*1024*1024}));
|
||||||
@@ -746,13 +742,15 @@ async function main() {
|
|||||||
const trees = { treeBase, treeUpdateJs, treeUpdateCss, treeUpdateFeature };
|
const trees = { treeBase, treeUpdateJs, treeUpdateCss, treeUpdateFeature };
|
||||||
|
|
||||||
const serverScratch = path.join(tmpRoot, 'server-run');
|
const serverScratch = path.join(tmpRoot, 'server-run');
|
||||||
const server = new ServerManager(serverScratch, treeBase);
|
const server = new ServerManager(serverScratch, treeBase, options.sourceRoot || REPO_ROOT);
|
||||||
await server.start();
|
await server.start();
|
||||||
console.log(`Bun server running on ephemeral port ${server.port}`);
|
console.log(`Bun server running on ephemeral port ${server.port}`);
|
||||||
|
|
||||||
const results = {
|
const results = {
|
||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
frontendCommit: options.frontendCommit,
|
frontendCommit: options.frontendCommit,
|
||||||
|
sourceRoot: options.sourceRoot,
|
||||||
|
sourceCommit: spawnSync('git', ['rev-parse','HEAD'], { cwd: options.sourceRoot || REPO_ROOT }).stdout.toString().trim(),
|
||||||
frontendBuildTag: (await (await fetch('http://127.0.0.1:'+server.port+'/api/version')).json()).buildTag,
|
frontendBuildTag: (await (await fetch('http://127.0.0.1:'+server.port+'/api/version')).json()).buildTag,
|
||||||
commit: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT }).stdout.toString().trim(),
|
commit: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT }).stdout.toString().trim(),
|
||||||
runsConfigured: options.runs,
|
runsConfigured: options.runs,
|
||||||
@@ -765,6 +763,9 @@ async function main() {
|
|||||||
results.browsers[bName] = {};
|
results.browsers[bName] = {};
|
||||||
|
|
||||||
for (const prof of profiles) {
|
for (const prof of profiles) {
|
||||||
|
// The preceding profile can end on a synthetic feature-update tree.
|
||||||
|
// Always measure the actual base build on the next cold visit.
|
||||||
|
await server.restartWithPublic(treeBase);
|
||||||
console.log(`\n>>> Running Benchmark: Browser=[${bName}] Profile=[${prof}]`);
|
console.log(`\n>>> Running Benchmark: Browser=[${bName}] Profile=[${prof}]`);
|
||||||
results.browsers[bName][prof] = {};
|
results.browsers[bName][prof] = {};
|
||||||
|
|
||||||
@@ -774,14 +775,15 @@ async function main() {
|
|||||||
console.log(`Proxy listening on port ${proxy.port} (mode: ${proxyProfile})`);
|
console.log(`Proxy listening on port ${proxy.port} (mode: ${proxyProfile})`);
|
||||||
|
|
||||||
// 1. Cold Scenario
|
// 1. Cold Scenario
|
||||||
if (options.scenario === 'all' || options.scenario === 'cold') {
|
if (wants('cold')) {
|
||||||
console.log(` [Scenario: Cold Load] running ${options.runs} iterations...`);
|
console.log(` [Scenario: Cold Load] running ${options.runs} iterations...`);
|
||||||
const coldRuns = [];
|
const coldRuns = [];
|
||||||
for (let r = 0; r < options.runs; r++) {
|
for (let r = 0; r < options.runs; r++) {
|
||||||
process.stdout.write(` run ${r + 1}/${options.runs}... `);
|
process.stdout.write(` run ${r + 1}/${options.runs}... `);
|
||||||
const res = await runColdScenario({ browserType: bType, server, proxy, profile: prof });
|
const res = await runColdScenario({ browserType: bType, server, proxy, profile: prof });
|
||||||
|
if(options.assertCold)assertColdAssets(res.requests);
|
||||||
coldRuns.push(res);
|
coldRuns.push(res);
|
||||||
console.log(`done (${res.wireBytes} bytes, ${res.requestCount} reqs, boot=${res.bootDone}ms)`);
|
console.log(`done (${res.wireBytes} bytes, ${res.requestCount} reqs, fcp=${res.fcp}ms, boot=${res.bootDone}ms)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
results.browsers[bName][prof].cold = {
|
results.browsers[bName][prof].cold = {
|
||||||
@@ -802,11 +804,12 @@ async function main() {
|
|||||||
requests: coldRuns[0]?.requests || [],
|
requests: coldRuns[0]?.requests || [],
|
||||||
initialJsCss: summarizeList(coldRuns.map(r=>r.initialJsCss)),
|
initialJsCss: summarizeList(coldRuns.map(r=>r.initialJsCss)),
|
||||||
initialAssetRequests: coldRuns[0]?.initialAssetRequests || [],
|
initialAssetRequests: coldRuns[0]?.initialAssetRequests || [],
|
||||||
|
waterfalls: coldRuns.map(r=>({fcp:r.fcp,requests:r.requests,page:r.initialAssetRequests})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Warm Scenario
|
// 2. Warm Scenario
|
||||||
if (options.scenario === 'all' || options.scenario === 'warm') {
|
if (wants('warm')) {
|
||||||
console.log(` [Scenario: Warm Load] running ${options.runs} iterations...`);
|
console.log(` [Scenario: Warm Load] running ${options.runs} iterations...`);
|
||||||
const warmRuns = [];
|
const warmRuns = [];
|
||||||
for (let r = 0; r < options.runs; r++) {
|
for (let r = 0; r < options.runs; r++) {
|
||||||
@@ -828,7 +831,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Offline Scenario
|
// 3. Offline Scenario
|
||||||
if (options.scenario === 'all' || options.scenario === 'offline') {
|
if (wants('offline')) {
|
||||||
console.log(` [Scenario: Offline Reload] running ${options.runs} iterations...`);
|
console.log(` [Scenario: Offline Reload] running ${options.runs} iterations...`);
|
||||||
const offRuns = [];
|
const offRuns = [];
|
||||||
for (let r = 0; r < options.runs; r++) {
|
for (let r = 0; r < options.runs; r++) {
|
||||||
@@ -850,7 +853,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Update N -> N+1 (JS)
|
// 4. Update N -> N+1 (JS)
|
||||||
if (options.scenario === 'all' || options.scenario === 'update-js') {
|
if (wants('update-js')) {
|
||||||
console.log(` [Scenario: Update N->N+1 (1-line app.js)] running ${options.runs} iterations...`);
|
console.log(` [Scenario: Update N->N+1 (1-line app.js)] running ${options.runs} iterations...`);
|
||||||
const updJsRuns = [];
|
const updJsRuns = [];
|
||||||
for (let r = 0; r < options.runs; r++) {
|
for (let r = 0; r < options.runs; r++) {
|
||||||
@@ -877,7 +880,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Update N -> N+1 (CSS)
|
// 5. Update N -> N+1 (CSS)
|
||||||
if (options.scenario === 'all' || options.scenario === 'update-css') {
|
if (wants('update-css')) {
|
||||||
console.log(` [Scenario: Update N->N+1 (1-line theme-glass.css)] running ${options.runs} iterations...`);
|
console.log(` [Scenario: Update N->N+1 (1-line theme-glass.css)] running ${options.runs} iterations...`);
|
||||||
const updCssRuns = [];
|
const updCssRuns = [];
|
||||||
for (let r = 0; r < options.runs; r++) {
|
for (let r = 0; r < options.runs; r++) {
|
||||||
@@ -895,6 +898,8 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
results.browsers[bName][prof].updateCss = {
|
results.browsers[bName][prof].updateCss = {
|
||||||
|
readyDuration: summarizeList(updCssRuns.map(r=>r.readyDuration)),
|
||||||
|
readyWireBytes: summarizeList(updCssRuns.map(r=>r.readyWireBytes)),
|
||||||
requestCount: summarizeList(updCssRuns.map((r) => r.requestCount)),
|
requestCount: summarizeList(updCssRuns.map((r) => r.requestCount)),
|
||||||
wireBytes: summarizeList(updCssRuns.map((r) => r.wireBytes)),
|
wireBytes: summarizeList(updCssRuns.map((r) => r.wireBytes)),
|
||||||
duration: summarizeList(updCssRuns.map((r) => r.duration)),
|
duration: summarizeList(updCssRuns.map((r) => r.duration)),
|
||||||
@@ -903,7 +908,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((options.scenario === 'all' || options.scenario === 'update-feature') && existsSync(path.join(treeBase,'presenter.js'))) {
|
if ((wants('update-feature')) && existsSync(path.join(treeBase,'presenter.js'))) {
|
||||||
console.log(` [Scenario: Update split-out presenter, including idle cache fill] running ${options.runs} iterations...`);
|
console.log(` [Scenario: Update split-out presenter, including idle cache fill] running ${options.runs} iterations...`);
|
||||||
const runs=[];
|
const runs=[];
|
||||||
for(let r=0;r<options.runs;r++) {
|
for(let r=0;r<options.runs;r++) {
|
||||||
|
|||||||
165
perf/dsp.mjs
Normal file
165
perf/dsp.mjs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// End-to-end JamesDSP checks against the real local server, manifest cache and media fixture.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawn, execFileSync } from 'node:child_process';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { mkdtempSync, mkdirSync, cpSync, writeFileSync, statSync, rmSync, existsSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { chromium, webkit } from 'playwright';
|
||||||
|
import { createThrottleProxy } from './proxy.mjs';
|
||||||
|
|
||||||
|
const repo = resolve(import.meta.dirname, '..');
|
||||||
|
const requested = process.argv.includes('--browser') ? process.argv[process.argv.indexOf('--browser') + 1] : 'all';
|
||||||
|
assert.ok(['all', 'chromium', 'webkit'].includes(requested));
|
||||||
|
const scratch = mkdtempSync(join(repo, 'perf/.tmp/dsp-'));
|
||||||
|
const results = { results: [], failures: [] };
|
||||||
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
async function poll(fn, timeout = 90000) { const end = Date.now() + timeout; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; }
|
||||||
|
|
||||||
|
async function run(engine) {
|
||||||
|
const cwd = join(scratch, engine); mkdirSync(cwd); cpSync(join(repo, 'frontend'), join(cwd, 'public'), { recursive: true }); mkdirSync(join(cwd, 'media'));
|
||||||
|
const portProbe = createServer(); await new Promise(resolve => portProbe.listen(0, '127.0.0.1', resolve)); const port = portProbe.address().port; await new Promise(resolve => portProbe.close(resolve));
|
||||||
|
let child, browser, proxy, context, log = '';
|
||||||
|
let origin = `http://127.0.0.1:${port}`;
|
||||||
|
const result = { engine, errors: [], failures: [], rms: [], checks: [] }; results.results.push(result);
|
||||||
|
async function stop() { if (!child) return; const proc = child; child = null; if (proc.exitCode === null) { proc.kill('SIGTERM'); await new Promise(resolve => proc.once('exit', resolve)); } }
|
||||||
|
try {
|
||||||
|
const clips = [
|
||||||
|
{ id: 'dspfixture01', title: 'DSP test tone 110 Hz', channel: 'Local test', duration: 5, thumbnail: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=' },
|
||||||
|
{ id: 'dspfixture02', title: 'DSP test tone 660 Hz', channel: 'Local test', duration: 5, thumbnail: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=' },
|
||||||
|
];
|
||||||
|
child = spawn('bun', [join(repo, 'server/server.js')], { cwd, env: { ...process.env, PORT: String(port), DB_PATH: join(cwd, 'db.sqlite'), MEDIA_DIR: join(cwd, 'media'), UPLOAD_DIR: join(cwd, 'uploads'), DATA_DIR: join(cwd, 'data'), ASSET_HASHING: '1', ASSET_SYNC: '1', BUILD_TAG: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => { log = (log + chunk).slice(-8000); });
|
||||||
|
if (!await poll(async () => { if (child.exitCode !== null) throw Error(log); try { return (await fetch(origin + '/api/version')).ok; } catch { return false; } })) throw Error('Server did not start: ' + log);
|
||||||
|
for (const [index, clip] of clips.entries()) {
|
||||||
|
const mp4 = join(cwd, 'media', clip.id + '.1.mp4');
|
||||||
|
execFileSync('ffmpeg', ['-y', '-f', 'lavfi', '-i', 'color=c=blue:s=160x90:r=10:d=5', '-f', 'lavfi', '-i', `sine=frequency=${index ? 660 : 110}:duration=5`, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', '-shortest', mp4], { stdio: 'ignore' });
|
||||||
|
execFileSync('ffmpeg', ['-y', '-i', mp4, '-vn', '-c:a', 'copy', join(cwd, 'media', clip.id + '.1.m4a')], { stdio: 'ignore' });
|
||||||
|
assert.ok(existsSync(mp4), `generated local clip ${mp4}`);
|
||||||
|
}
|
||||||
|
const require = createRequire(import.meta.url); const { createClient } = require(join(repo, 'server/node_modules/@libsql/client'));
|
||||||
|
const db = createClient({ url: 'file:' + join(cwd, 'db.sqlite') });
|
||||||
|
for (const clip of clips) { const file = join(cwd, 'media', clip.id + '.1.mp4'); await db.execute({ sql: 'INSERT OR REPLACE INTO media_cache (video_id,status,gen,size,height,vcodec,acodec,duration,meta,last_access) VALUES (?,?,?,?,?,?,?,?,?,?)', args: [clip.id, 'ready', 1, statSync(file).size, 90, 'h264', 'aac', 5, JSON.stringify(clip), Date.now()] }); }
|
||||||
|
db.close();
|
||||||
|
proxy = createThrottleProxy({ targetPort: port, profile: 'unthrottled' }); origin = `http://127.0.0.1:${await proxy.listen()}`;
|
||||||
|
const type = engine === 'chromium' ? chromium : webkit; browser = await type.launch({ headless: true, ...(engine === 'chromium' ? { args: ['--autoplay-policy=no-user-gesture-required'] } : {}) });
|
||||||
|
context = await browser.newContext({ viewport: { width: 414, height: 896 } });
|
||||||
|
await context.addInitScript(() => { window.__dspToasts = []; new MutationObserver(() => { const text = document.querySelector('#toastContainer')?.textContent?.trim(); if (text && window.__dspToasts.at(-1) !== text) window.__dspToasts.push(text); }).observe(document, { childList: true, subtree: true, characterData: true }); });
|
||||||
|
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 200, contentType: 'image/gif', body: Buffer.from('R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', 'base64') }));
|
||||||
|
const page = await context.newPage(); let offlinePhase = false, failedApiTransports = 0; page.on('pageerror', error => { if (offlinePhase && failedApiTransports && /TypeError: Load failed|\/api\//.test(error.message)) (result.expectedApiErrors ||= []).push(error.message); else result.errors.push(error.message); });
|
||||||
|
page.on('requestfailed', request => { const path = new URL(request.url()).pathname; if (path.startsWith('/api/')) failedApiTransports++; else if (!(offlinePhase && path === '/sw.js')) result.errors.push(`request failed ${request.url()}: ${request.failure()?.errorText}`); });
|
||||||
|
page.on('response', response => {
|
||||||
|
const url = new URL(response.url()), path = url.pathname;
|
||||||
|
// The local fixture IDs have no upstream YouTube artwork. These requests
|
||||||
|
// are unrelated to the local media or DSP asset path under test.
|
||||||
|
if (url.hostname === 'i.ytimg.com' && /\/vi\/dspfixture0[12]\//.test(path)) {
|
||||||
|
(result.fixtureResponses ||= []).push({ url: response.url(), status: response.status() });
|
||||||
|
} else if (!path.startsWith('/api/') && response.status() >= 400) result.errors.push(`asset response ${response.status()} ${response.url()}`);
|
||||||
|
});
|
||||||
|
page.on('response', response => { if (/jamesdsp\/(?:worklet\.mjs|dsp\/jamesdsp[^/?]*\.mjs)/.test(response.url())) result.requests ||= [], result.requests.push({ url: response.url(), status: response.status(), type: response.headers()['content-type'] }); });
|
||||||
|
page.on('requestfailed', request => { if (/jamesdsp\//.test(request.url())) result.requestsFailed ||= [], result.requestsFailed.push({ url: request.url(), error: request.failure()?.errorText }); });
|
||||||
|
const boot = async () => { await page.goto(origin); await page.waitForFunction(() => typeof data !== 'undefined' && document.querySelector('#sidebarToggle')); };
|
||||||
|
await boot();
|
||||||
|
const manifest = await (await fetch(origin + '/api/manifest')).json();
|
||||||
|
const checkComplete = () => page.evaluate(async ({ files, groups }) => { const cache = await caches.open('ytplayer-assets'), opted = !!(data.settings.jamesDsp?.enabled || data.settings.jamesDsp?.downloadForOffline); const optional = new Set(Object.values(groups).filter(group => group.offlineOptIn && !(opted && group.offlineOptIn === 'jamesDsp')).flatMap(group => group.files)); const missing = []; for (const [path, file] of Object.entries(files)) { if (optional.has(path)) continue; const r = await cache.match(path + '?v=' + file.h); if (!r || r.headers.get('X-Asset-Hash') !== file.h) missing.push(path); } return missing; }, manifest);
|
||||||
|
assert.equal((await page.evaluate(async () => { const shell = await caches.open('ytplayer-shell'); return (await shell.keys()).some(r => r.url.includes('jamesdsp')); })), false, 'DSP is never in the shell cache');
|
||||||
|
assert.equal(await poll(async () => !(await checkComplete()).length, 120000), true, 'offline completion job fills manifest before offline test');
|
||||||
|
const optionalCount = Object.values(manifest.groups || {}).filter(group => group.offlineOptIn).reduce((n, group) => n + group.files.filter(path => manifest.files[path]).length, 0);
|
||||||
|
result.checks.push({ name: 'required files cached; optional DSP deferred', files: Object.keys(manifest.files).length - optionalCount, optionalDeferred: optionalCount, manifestFiles: Object.keys(manifest.files).length });
|
||||||
|
await page.evaluate(async () => { await Lazy.load('feature:eq-core'); await Lazy.load('feature:vocal-reducer'); data.settings.jamesDsp = { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null }; EQ.setLevel(1); EQ.setLevel(0); EQ.apply(); });
|
||||||
|
await page.evaluate(clip => Player.loadVideo(clip), clips[0]);
|
||||||
|
await page.locator('#playBtn').click({ force: true }).catch(() => {});
|
||||||
|
if (!await poll(() => page.evaluate(() => Player.master?.currentTime > 0.4), 12000)) { await page.evaluate(() => Player.master?.play().catch(() => {})); assert.equal(await poll(() => page.evaluate(() => Player.master?.currentTime > 0.4), 12000), true, 'local test clip starts'); }
|
||||||
|
await page.evaluate(() => { if (Player.master) Player.master.loop = true; });
|
||||||
|
const rms = async () => page.evaluate(async () => {
|
||||||
|
const analyser = EQ.analyser(); if (!analyser) throw new Error('EQ analyser unavailable');
|
||||||
|
const values = new Float32Array(analyser.fftSize); let sum = 0, count = 0, end = performance.now() + 600;
|
||||||
|
while (performance.now() < end) { analyser.getFloatTimeDomainData(values); for (const v of values) { sum += v * v; count++; } await new Promise(resolve => setTimeout(resolve, 18)); }
|
||||||
|
return Math.sqrt(sum / Math.max(1, count));
|
||||||
|
});
|
||||||
|
await page.evaluate(() => { EQ.apply(); }); await sleep(250);
|
||||||
|
const standardRms = await rms(); assert.ok(standardRms > 0, `Standard output has nonzero RMS (${standardRms})`);
|
||||||
|
await page.locator('#bottomNav [data-view="settings"]').click(); await page.evaluate(() => document.querySelector('[data-section="playback"]').click());
|
||||||
|
await page.selectOption('#setSoundEngine', 'jamesdsp');
|
||||||
|
const active = await poll(() => page.evaluate(() => !!EQ.state().jamesDsp?.active), 12000);
|
||||||
|
if (!active) result.diagnostics = await page.evaluate(() => ({ enabled: data.settings.jamesDsp?.enabled, state: EQ.state(), lazyLoaded: Lazy.loaded('feature:jamesdsp'), toast: document.querySelector('#toastContainer')?.textContent || '', toastHistory: window.__dspToasts }));
|
||||||
|
assert.equal(active, true, `JamesDSP worklet activates ${JSON.stringify(result.diagnostics || {})}`);
|
||||||
|
assert.equal(await poll(async () => !(await checkComplete()).length, 120000), true, 'engine opt-in completes optional WASM and module cache');
|
||||||
|
await sleep(200); const flat = await rms(); result.rms.push({ effect: 'flat', rms: flat });
|
||||||
|
for (const preset of ['bass', 'vocal', 'loudness', 'headphone', 'bright', 'warm']) {
|
||||||
|
await page.selectOption('#setDspPreset', preset); await sleep(900);
|
||||||
|
const value = await rms(); assert.ok(Number.isFinite(value), `${preset} RMS is finite`); result.rms.push({ effect: preset, rms: value });
|
||||||
|
}
|
||||||
|
const preConvolverRms = await rms();
|
||||||
|
result.irLoad = await page.evaluate(async () => { const reference = await EQ.loadDspBuiltinIR('Small room'); await new Promise(resolve => setTimeout(resolve, 300)); return { reference, settings: data.settings.jamesDsp, state: EQ.state().jamesDsp, toast: document.querySelector('#toastContainer')?.textContent || '' }; });
|
||||||
|
const convolverRms = await rms();
|
||||||
|
assert.equal(result.irLoad.state.irRef?.name, 'Small room', 'generated IR is loaded into the active worklet');
|
||||||
|
result.checks.push({ name: 'live generated IR loaded', beforeRms: preConvolverRms, afterRms: convolverRms, ir: result.irLoad.state.irRef });
|
||||||
|
await page.evaluate(clip => { Player.pause(); Player.loadVideo(clip); }, clips[1]); await sleep(500); await page.evaluate(() => Player.master?.play().catch(() => {}));
|
||||||
|
const trackSwapActive = await poll(() => page.evaluate(() => Player.master?.currentSrc.includes('dspfixture02') && Player.master?.readyState >= 2 && EQ.state().jamesDsp?.active), 12000);
|
||||||
|
if (!trackSwapActive) result.trackSwapDiagnostics = await page.evaluate(() => ({ time: Player.master?.currentTime, paused: Player.master?.paused, readyState: Player.master?.readyState, state: EQ.state().jamesDsp, graph: AudioGraph.snapshot(), source: Player.master?.currentSrc }));
|
||||||
|
assert.equal(trackSwapActive, true, 'DSP remains active on the replacement media source');
|
||||||
|
result.checks.push({ name: 'track switch retains DSP route', graph: await page.evaluate(() => AudioGraph.snapshot()) });
|
||||||
|
await page.evaluate(() => { data.settings.jamesDsp.params['bass.enabled'] = 1; data.settings.jamesDsp.params['eq.enabled'] = 1; EQ.apply(); }); await sleep(800);
|
||||||
|
const activeRms = await rms();
|
||||||
|
await page.evaluate(async () => { await EQ.toggleDspBypass(); }); await sleep(200); const bypassRms = await rms();
|
||||||
|
const bypassState = await page.evaluate(() => EQ.state().jamesDsp);
|
||||||
|
assert.equal(bypassState.route, 'standard', 'A/B bypass routes directly to the unchanged Standard graph');
|
||||||
|
result.checks.push({ name: 'A/B exact Standard route', standardRms, activeRms, bypassRms, ...bypassState });
|
||||||
|
await page.evaluate(async () => { const state = EQ.state().jamesDsp, graph = AudioGraph.snapshot(); return { state, graph }; }).then(({ state, graph }) => {
|
||||||
|
assert.equal(state.liveHeaps, 1); assert.equal(state.maxLiveHeaps, 1); assert.ok(graph.sourceCount <= 2, 'only existing player media sources'); result.checks.push({ name: 'one worklet heap / single graph', ...state, graph });
|
||||||
|
});
|
||||||
|
const dspRender = await page.evaluate(async () => {
|
||||||
|
const { JamesDSPNode } = await import(Lazy.url('/vendor/jamesdsp/jamesdsp-node.mjs'));
|
||||||
|
async function render(effect, rate = 32000) {
|
||||||
|
const frames = 32768, context = new OfflineAudioContext(2, frames, rate);
|
||||||
|
const engine = await JamesDSPNode.create(context, { wasmURL: Lazy.url('/vendor/jamesdsp/dsp/jamesdsp.wasm'), workletURL: Lazy.url('/vendor/jamesdsp/worklet.mjs') });
|
||||||
|
const buffer = context.createBuffer(2, 2048, rate);
|
||||||
|
for (let i = 0; i < buffer.length; i++) { const sample = Math.sin(2 * Math.PI * 440 * i / rate) * 0.25; buffer.getChannelData(0)[i] = sample; buffer.getChannelData(1)[i] = sample; }
|
||||||
|
if (effect === 'convolver') {
|
||||||
|
const ir = context.createBuffer(4, 4096, rate); ir.getChannelData(0)[0] = 0.5; ir.getChannelData(1)[97] = 0.25; ir.getChannelData(2)[211] = -0.2; ir.getChannelData(3)[0] = 0.5;
|
||||||
|
await engine.loadIR(ir); await engine.setParam('convolver.mix', 1); await engine.setParam('convolver.enabled', 1);
|
||||||
|
} else if (effect === 'spatial') {
|
||||||
|
await engine.setParam('spatial.enabled', 1); await engine.setParam('spatial.mode', 6); await engine.setParam('spatial.spread', 100); await engine.setParam('spatial.azimuth', 45);
|
||||||
|
}
|
||||||
|
await engine.setBypass(false);
|
||||||
|
const source = context.createBufferSource(); source.buffer = buffer; source.connect(engine.node); engine.node.connect(context.destination); source.start();
|
||||||
|
const rendered = await context.startRendering();
|
||||||
|
const left = rendered.getChannelData(0), right = rendered.getChannelData(1);
|
||||||
|
let l = 0, r = 0, diff = 0; for (let i = 0; i < frames; i++) { l += left[i] ** 2; r += right[i] ** 2; diff += (left[i] - right[i]) ** 2; }
|
||||||
|
const metrics = { leftRms: Math.sqrt(l / frames), rightRms: Math.sqrt(r / frames), interauralRms: Math.sqrt(diff / frames), rate: rendered.sampleRate };
|
||||||
|
engine.destroy(); return metrics;
|
||||||
|
}
|
||||||
|
const dry = await render('dry'), convolver = await render('convolver'), spatial = await render('spatial');
|
||||||
|
return { dry, convolver, spatial };
|
||||||
|
});
|
||||||
|
assert.equal(dspRender.dry.rate, 32000, 'ASRC accepts an arbitrary AudioContext rate');
|
||||||
|
assert.ok(dspRender.convolver.interauralRms > dspRender.dry.interauralRms + 0.00001, `four-channel IR changes rendered stereo output (${JSON.stringify(dspRender)})`);
|
||||||
|
assert.ok(dspRender.spatial.interauralRms > dspRender.dry.interauralRms + 0.00001, `3D stage changes interaural difference (${JSON.stringify(dspRender)})`);
|
||||||
|
result.checks.push({ name: 'offline four-channel convolver, spatial difference and 32 kHz ASRC', ...dspRender });
|
||||||
|
|
||||||
|
await page.evaluate(() => persist()); await sleep(500);
|
||||||
|
offlinePhase = true; if (engine === 'chromium') await context.setOffline(true); else await proxy.setOffline(true);
|
||||||
|
await page.reload(); await page.waitForFunction(() => typeof data !== 'undefined' && document.querySelector('#sidebarToggle'));
|
||||||
|
assert.equal(await poll(() => page.evaluate(() => !!EQ.state().jamesDsp?.active), 30000), true, 'offline reload restores enabled DSP from cached modules');
|
||||||
|
assert.equal(await page.evaluate(() => EQ.state().jamesDsp?.irRef?.name), 'Small room', 'stored generated IR is restored offline');
|
||||||
|
result.checks.push({ name: 'offline reload with engine and stored IR enabled', missing: await checkComplete(), ir: await page.evaluate(() => EQ.state().jamesDsp?.irRef) });
|
||||||
|
|
||||||
|
// A separate fresh profile corrupts the optional wasm response and verifies that Standard stays audible.
|
||||||
|
offlinePhase = false; failedApiTransports = 0; if (engine === 'chromium') await context.setOffline(false); else await proxy.setOffline(false);
|
||||||
|
const brokenContext = await browser.newContext({ viewport: { width: 414, height: 896 } });
|
||||||
|
await brokenContext.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 200, contentType: 'image/gif', body: Buffer.from('R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', 'base64') }));
|
||||||
|
await brokenContext.addInitScript(() => { const realFetch = window.fetch.bind(window); window.fetch = (input, init) => String(input).includes('/vendor/jamesdsp/dsp/jamesdsp.wasm') ? Promise.resolve(new Response('corrupt', { status: 503 })) : realFetch(input, init); });
|
||||||
|
const brokenPage = await brokenContext.newPage(); brokenPage.on('pageerror', e => result.errors.push('corrupt test: ' + e.message)); await brokenPage.goto(origin); await brokenPage.waitForFunction(() => typeof data !== 'undefined');
|
||||||
|
await brokenPage.locator('#bottomNav [data-view="settings"]').click(); await brokenPage.evaluate(() => document.querySelector('[data-section="playback"]').click()); await brokenPage.selectOption('#setSoundEngine', 'jamesdsp');
|
||||||
|
assert.equal(await poll(() => brokenPage.evaluate(() => data.settings.jamesDsp.enabled === false && EQ.state().wired), 30000), true, 'corrupt wasm falls back to Standard');
|
||||||
|
result.checks.push({ name: 'corrupt wasm returns to Standard', enabled: await brokenPage.evaluate(() => data.settings.jamesDsp.enabled), graph: await brokenPage.evaluate(() => AudioGraph.snapshot()) });
|
||||||
|
await brokenContext.close();
|
||||||
|
} catch (error) { result.failures.push(error.stack || error.message); }
|
||||||
|
finally { await context?.close(); await browser?.close(); await proxy?.close(); await stop(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
try { for (const engine of requested === 'all' ? ['chromium', 'webkit'] : [requested]) await run(engine); }
|
||||||
|
finally { writeFileSync(join(repo, 'perf/.tmp/dsp-results.json'), JSON.stringify(results, null, 2) + '\n'); rmSync(scratch, { recursive: true, force: true }); }
|
||||||
|
if (results.failures.length || results.results.some(result => result.failures.length || result.errors.length)) process.exitCode = 1;
|
||||||
@@ -42,7 +42,8 @@ async function run(name){
|
|||||||
console.log(name,'initial boot');await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn'));
|
console.log(name,'initial boot');await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn'));
|
||||||
await wait(page,async()=>!!(await navigator.serviceWorker.getRegistration())?.active);
|
await wait(page,async()=>!!(await navigator.serviceWorker.getRegistration())?.active);
|
||||||
await page.evaluate(()=>Lazy.warm());
|
await page.evaluate(()=>Lazy.warm());
|
||||||
await wait(page,async()=>{const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');if(!r)return false;const s=await r.json();return !(await Promise.all(Object.values(s.current.groups).flatMap(g=>g.files).map(async p=>!!await c.match(p+'?v='+s.current.files[p].h)))).includes(false);});
|
await wait(page,async()=>{const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');if(!r)return false;const s=await r.json(),files=Object.entries(s.current.groups).filter(([,g])=>!g.offlineOptIn).flatMap(([,g])=>g.files);return !(await Promise.all(files.map(async p=>!!await c.match(p+'?v='+s.current.files[p].h)))).includes(false);});
|
||||||
|
assert.equal(await page.evaluate(async()=>{const m=Lazy.manifest,path='/vendor/jamesdsp/dsp/jamesdsp.wasm',cache=await caches.open('ytplayer-assets');return !await cache.match(path+'?v='+m.files[path].h);}),true,'optional DSP WASM stays uncached on a default install');
|
||||||
console.log(name,'warm complete');assert.equal(await page.evaluate(()=>Lazy.loaded('feature:car-mode')),false);
|
console.log(name,'warm complete');assert.equal(await page.evaluate(()=>Lazy.loaded('feature:car-mode')),false);
|
||||||
assert.equal(await page.evaluate(()=>[...document.querySelectorAll('.stg-overlay,.deck-chips,.vol-pct,.mode-name,.stg-bot,.ch-badge,.upnext-clear,.list-sub,.p2p-strip,.sleep-status .sl-ic,.sleep-status .sl-sub,.mc-tune')].every(n=>getComputedStyle(n).display==='none')),true,'classic retains shared visibility defaults without loading Glass CSS');
|
assert.equal(await page.evaluate(()=>[...document.querySelectorAll('.stg-overlay,.deck-chips,.vol-pct,.mode-name,.stg-bot,.ch-badge,.upnext-clear,.list-sub,.p2p-strip,.sleep-status .sl-ic,.sleep-status .sl-sub,.mc-tune')].every(n=>getComputedStyle(n).display==='none')),true,'classic retains shared visibility defaults without loading Glass CSS');
|
||||||
await checkCssOrder(page);
|
await checkCssOrder(page);
|
||||||
@@ -84,6 +85,23 @@ async function run(name){
|
|||||||
await page.evaluate(async()=>{await Lazy.load('feature:export');await Lazy.load('feature:video-edit');});
|
await page.evaluate(async()=>{await Lazy.load('feature:export');await Lazy.load('feature:video-edit');});
|
||||||
assert.equal(proxy.getStats().requestCount,before,'all warmed extension assets stay offline');
|
assert.equal(proxy.getStats().requestCount,before,'all warmed extension assets stay offline');
|
||||||
await start();disconnected=false;await proxy.setOffline(false);await page.evaluate(()=>localStorage.removeItem('perf-offline'));
|
await start();disconnected=false;await proxy.setOffline(false);await page.evaluate(()=>localStorage.removeItem('perf-offline'));
|
||||||
|
// Change only app bytes, then prove loss of its current cache entry can boot
|
||||||
|
// the compatible N-1 core offline without weakening the inline CSP policy.
|
||||||
|
await stop();writeFileSync(join(cwd,'public/app.js'),readFileSync(join(cwd,'public/app.js'),'utf8')+'\n// phase6 app eviction probe\n');await start();
|
||||||
|
const appTarget=await(await fetch(origin+'/api/manifest')).json();
|
||||||
|
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();await r.update();});
|
||||||
|
await wait(page,async tag=>{const r=await navigator.serviceWorker.getRegistration();return r?.waiting&&(await SwUpdate.askCacheStatus(r.waiting))?.version===tag;},appTarget.buildTag);
|
||||||
|
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.waiting.postMessage({type:'SKIP_WAITING'});});
|
||||||
|
await wait(page,async tag=>{const c=await caches.open('ytplayer-assets');return(await(await c.match('/__ytp_asset_state')).json()).current.buildTag===tag;},appTarget.buildTag);
|
||||||
|
const appKey='/app.js?v='+appTarget.files['/app.js'].h;
|
||||||
|
await page.evaluate(async key=>{await(await caches.open('ytplayer-assets')).delete(key);},appKey);
|
||||||
|
await stop();disconnected=true;await proxy.setOffline(true);
|
||||||
|
const probe=await context.newPage();probe.on('pageerror',e=>errors.push({message:e.message,disconnected}));
|
||||||
|
const offlineBefore=proxy.getStats().requestCount;await probe.goto(origin);await probe.waitForFunction(()=>typeof data!=='undefined'&&typeof Player!=='undefined'&&document.querySelector('#carModeBtn'));
|
||||||
|
assert.equal(await probe.evaluate(async key=>!!await(await caches.open('ytplayer-assets')).match(key),appKey),false,'N-1 never poisons the current app key');
|
||||||
|
assert.equal(proxy.getStats().requestCount,offlineBefore,'N-1 app boot is entirely offline');
|
||||||
|
await probe.close();await start();disconnected=false;await proxy.setOffline(false);
|
||||||
|
await page.evaluate(async key=>{const r=await fetch(key+'&__ytpfresh=1');if(r.headers.get('X-Asset-Hash')!==key.split('v=')[1])throw Error('Wrong repair body');await(await caches.open('ytplayer-assets')).put(key,r);},appKey);
|
||||||
// Keep an old tab whose core and group contracts remain those of build N.
|
// Keep an old tab whose core and group contracts remain those of build N.
|
||||||
const old=await context.newPage();old.on('pageerror',e=>errors.push({message:e.message,disconnected}));await old.goto(origin);await old.waitForSelector('#carModeBtn');
|
const old=await context.newPage();old.on('pageerror',e=>errors.push({message:e.message,disconnected}));await old.goto(origin);await old.waitForSelector('#carModeBtn');
|
||||||
await old.evaluate(async()=>{window.__saveData=true;await Lazy.load('feature:setlist-import');window.__pinned=window.SetlistImport;});
|
await old.evaluate(async()=>{window.__saveData=true;await Lazy.load('feature:setlist-import');window.__pinned=window.SetlistImport;});
|
||||||
@@ -108,8 +126,12 @@ async function run(name){
|
|||||||
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.waiting.postMessage({type:'SKIP_WAITING'});});
|
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.waiting.postMessage({type:'SKIP_WAITING'});});
|
||||||
await wait(page,async tag=>{const c=await caches.open('ytplayer-assets');return(await(await c.match('/__ytp_asset_state')).json()).current.buildTag===tag;},target.buildTag);
|
await wait(page,async tag=>{const c=await caches.open('ytplayer-assets');return(await(await c.match('/__ytp_asset_state')).json()).current.buildTag===tag;},target.buildTag);
|
||||||
await page.reload();await page.waitForSelector('#carModeBtn');assert.equal(await page.evaluate(()=>Lazy.manifest.buildTag),target.buildTag);
|
await page.reload();await page.waitForSelector('#carModeBtn');assert.equal(await page.evaluate(()=>Lazy.manifest.buildTag),target.buildTag);
|
||||||
// No warm: the new page gets a same-contract N-1 file immediately from disk.
|
// Completion now runs even with Save-Data. Evict a real completed entry to
|
||||||
|
// exercise the same-contract N-1 fallback, rather than suppressing the job.
|
||||||
|
await wait(page,async()=>{const r=await navigator.serviceWorker.getRegistration();return (await SwUpdate.askCacheStatus(r.active))?.offlineReady;});
|
||||||
|
disconnected=true;await proxy.setOffline(true);await stop();
|
||||||
const cacheKey='/video-edit.js?v='+target.files['/video-edit.js'].h;
|
const cacheKey='/video-edit.js?v='+target.files['/video-edit.js'].h;
|
||||||
|
await page.evaluate(async key=>{localStorage.setItem('perf-offline','1');await(await caches.open('ytplayer-assets')).delete(key);},cacheKey);
|
||||||
assert.equal(await page.evaluate(async key=>!!await(await caches.open('ytplayer-assets')).match(key),cacheKey),false);
|
assert.equal(await page.evaluate(async key=>!!await(await caches.open('ytplayer-assets')).match(key),cacheKey),false);
|
||||||
const stale=await page.evaluate(async key=>{const r=await fetch(key);return {h:r.headers.get('X-Asset-Hash'),body:await r.text()};},cacheKey);
|
const stale=await page.evaluate(async key=>{const r=await fetch(key);return {h:r.headers.get('X-Asset-Hash'),body:await r.text()};},cacheKey);
|
||||||
assert.equal(stale.h,previous.files['/video-edit.js'].h);assert.ok(!stale.body.includes('// phase3 new file'));
|
assert.equal(stale.h,previous.files['/video-edit.js'].h);assert.ok(!stale.body.includes('// phase3 new file'));
|
||||||
@@ -121,6 +143,7 @@ async function run(name){
|
|||||||
assert.ok(oldRequests.some(u=>u.includes('/car-mode.js?v='+previous.files['/car-mode.js'].h)));
|
assert.ok(oldRequests.some(u=>u.includes('/car-mode.js?v='+previous.files['/car-mode.js'].h)));
|
||||||
await old.evaluate(()=>Lazy.load('feature:setlist-import'));assert.equal(await old.evaluate(()=>window.SetlistImport===window.__pinned),true);
|
await old.evaluate(()=>Lazy.load('feature:setlist-import'));assert.equal(await old.evaluate(()=>window.SetlistImport===window.__pinned),true);
|
||||||
// Warm new files explicitly; compatible, unexecuted old-page group selects new cached URL.
|
// Warm new files explicitly; compatible, unexecuted old-page group selects new cached URL.
|
||||||
|
await start();disconnected=false;await proxy.setOffline(false);await page.evaluate(()=>localStorage.removeItem('perf-offline'));
|
||||||
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.active.postMessage({type:'WARM_ASSETS',saveData:false});});
|
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.active.postMessage({type:'WARM_ASSETS',saveData:false});});
|
||||||
await wait(page,async key=>!!await(await caches.open('ytplayer-assets')).match(key),cacheKey);
|
await wait(page,async key=>!!await(await caches.open('ytplayer-assets')).match(key),cacheKey);
|
||||||
await old.evaluate(()=>Lazy.load('feature:video-edit'));assert.ok(oldRequests.some(u=>u.includes(cacheKey)));
|
await old.evaluate(()=>Lazy.load('feature:video-edit'));assert.ok(oldRequests.some(u=>u.includes(cacheKey)));
|
||||||
@@ -128,8 +151,8 @@ async function run(name){
|
|||||||
const offlineTransportErrors=errors.filter(error=>name==='webkit'&&error.disconnected&&(error.message==='TypeError: Load failed'||/\/api\/(?:recommendations\?[^\s]*|version)\.$/.test(error.message)));
|
const offlineTransportErrors=errors.filter(error=>name==='webkit'&&error.disconnected&&(error.message==='TypeError: Load failed'||/\/api\/(?:recommendations\?[^\s]*|version)\.$/.test(error.message)));
|
||||||
assert.deepEqual(errors.filter(error=>!offlineTransportErrors.includes(error)),[]);
|
assert.deepEqual(errors.filter(error=>!offlineTransportErrors.includes(error)),[]);
|
||||||
if(offlineTransportErrors.length)assert.ok(failedRequests.some(url=>/\/api\/(?:recommendations|version)/.test(url)));
|
if(offlineTransportErrors.length)assert.ok(failedRequests.some(url=>/\/api\/(?:recommendations|version)/.test(url)));
|
||||||
results.push({browser:name,sourceBuildTag:previous.buildTag,classicSharedDefaults:true,cssOrderPreserved:true,settingsSearch:true,carControls:4,offlineFeature:true,firstUseNetwork:0,layoutsOffline:['glass-stage','bento-hub','f7-swipe','classic'],pinned:true,compatibleUnexecutedNewURL:true,incompatibleN1ReloadRequired:true,stagedN1:true,playbackGuard:true,prePaintLayout:true,offlineSettingsPanels:true,scriptErrors:[],offlineTransportErrors});
|
results.push({browser:name,sourceBuildTag:previous.buildTag,classicSharedDefaults:true,cssOrderPreserved:true,settingsSearch:true,carControls:4,offlineFeature:true,firstUseNetwork:0,layoutsOffline:['glass-stage','bento-hub','f7-swipe','classic'],pinned:true,compatibleUnexecutedNewURL:true,incompatibleN1ReloadRequired:true,stagedN1:true,appEvictionN1Offline:true,playbackGuard:true,prePaintLayout:true,offlineSettingsPanels:true,scriptErrors:[],offlineTransportErrors});
|
||||||
console.log(name,'PASS');
|
console.log(name,'PASS');
|
||||||
} finally {await browser?.close();await proxy?.close();await stop();}
|
} catch(error){console.error(name,error.stack);throw error;} finally {await browser?.close();proxy?.server.closeAllConnections();await proxy?.close();await stop();}
|
||||||
}
|
}
|
||||||
try{for(const name of requested==='all'?['chromium','webkit']:[requested])await run(name);writeFileSync(join(repo,'perf/results/phase3-lazy-2026-10-08.json'),JSON.stringify({results},null,2)+'\n');}finally{rmSync(scratch,{recursive:true,force:true});}
|
try{for(const name of requested==='all'?['chromium','webkit']:[requested])await run(name);writeFileSync(args.includes('--out')?resolve(args[args.indexOf('--out')+1]):join(repo,'perf/results/phase3-lazy-2026-10-08.json'),JSON.stringify({results},null,2)+'\n');}finally{rmSync(scratch,{recursive:true,force:true});}
|
||||||
|
|||||||
34
perf/measurement.mjs
Normal file
34
perf/measurement.mjs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
export function assertChangedAssets(requests, expected) {
|
||||||
|
const paths=requests.filter(r=>r.bytes>0).map(r=>new URL(r.url,'http://localhost').pathname).filter(p=>/\.(?:js|css|woff2?|png|svg)$/.test(p)&&!['/sw.js','/asset-sync-core.js'].includes(p));
|
||||||
|
assert.deepEqual([...new Set(paths)],[expected],'only the changed application asset transfers; worker checks and derived index/manifest are separate overhead');
|
||||||
|
}
|
||||||
|
export async function waitForIdleAssets(page) {
|
||||||
|
if(!await page.evaluate(()=>!!window.Lazy?.warm))return;
|
||||||
|
await page.evaluate(()=>Lazy.warm());
|
||||||
|
const end=Date.now()+120000;
|
||||||
|
while(true) {
|
||||||
|
const ready=await page.evaluate(async()=>{
|
||||||
|
const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');
|
||||||
|
// Legacy rollback has no manifest state and installs its complete shell instead.
|
||||||
|
if(!r)return (await caches.keys()).some(k=>/^ytplayer-(?!assets|thumbs|fonts)/.test(k));
|
||||||
|
const m=(await r.json()).current;
|
||||||
|
return (await Promise.all(Object.values(m.groups).flatMap(g=>g.files).map(async p=>{
|
||||||
|
const response=await c.match(p+'?v='+m.files[p].h);
|
||||||
|
return response?.headers.get('X-Asset-Hash')===m.files[p].h;
|
||||||
|
}))).every(Boolean);
|
||||||
|
});
|
||||||
|
if(ready)return;
|
||||||
|
if(Date.now()>end)throw Error('Idle warm did not complete before benchmark');
|
||||||
|
await new Promise(r=>setTimeout(r,100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function assertWarmAssets(requests) {
|
||||||
|
const assets=requests.filter(r=>r.bytes>0&&/\.(?:js|css|woff2?|png|svg)(?:[?#]|$)/.test(r.url)&&!new URL(r.url,'http://localhost').pathname.endsWith('/sw.js'));
|
||||||
|
assert.deepEqual(assets,[],'warm reload does not transfer unfinished shell assets');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertColdAssets(requests) {
|
||||||
|
const transferred=requests.filter(r=>r.bytes>0&&new URL(r.url,'http://localhost').pathname==='/app.js');
|
||||||
|
assert.equal(transferred.length,1,'cold app.js transfers its body exactly once across page and worker');
|
||||||
|
}
|
||||||
23
perf/measurement.test.mjs
Normal file
23
perf/measurement.test.mjs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { assertChangedAssets, assertWarmAssets, assertColdAssets } from './measurement.mjs';
|
||||||
|
test('changed CSS and feature measurements reject unrelated application transfers',()=>{
|
||||||
|
const requests=[{url:'/sw.js',bytes:7000},{url:'/asset-sync-core.js',bytes:2000},{url:'/index.html',bytes:12000},{url:'/api/manifest',bytes:3000},{url:'/theme-glass.css?v=new',bytes:5000},{url:'/fonts/a.woff2',bytes:0}];
|
||||||
|
assertChangedAssets(requests,'/theme-glass.css');
|
||||||
|
assert.throws(()=>assertChangedAssets([...requests,{url:'/app.js?v=new',bytes:10000}],'/theme-glass.css'));
|
||||||
|
assert.throws(()=>assertChangedAssets(requests.filter(r=>!r.url.includes('theme-glass')),'/theme-glass.css'));
|
||||||
|
assertChangedAssets([{url:'/presenter.js?v=new',bytes:3200}],'/presenter.js');
|
||||||
|
assert.throws(()=>assertChangedAssets([{url:'/presenter.js?v=new',bytes:3200},{url:'/icons/192.png',bytes:1000}],'/presenter.js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warm measurements reject unfinished shell downloads but allow API checks',()=>{
|
||||||
|
assertWarmAssets([{url:'/api/version',bytes:62},{url:'/app.js?v=current',bytes:0}]);
|
||||||
|
assert.throws(()=>assertWarmAssets([{url:'/theme-glass.css?v=current',bytes:5000}]));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('cold acceptance rejects a second positive app response while allowing cache revalidation',()=>{
|
||||||
|
assertColdAssets([{url:'/app.js?v=current',bytes:12000},{url:'/app.js?v=current',bytes:0}]);
|
||||||
|
assert.throws(()=>assertColdAssets([{url:'/app.js?v=current',bytes:12000},{url:'/app.js?v=current',bytes:12000}]));
|
||||||
|
assert.throws(()=>assertColdAssets([]));
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import { chromium, webkit } from 'playwright';
|
|||||||
const repo = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
const repo = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||||
const commit = 'b77938a', oldTag = '1175f1a1d2c1';
|
const commit = 'b77938a', oldTag = '1175f1a1d2c1';
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
const targetFrontend = args.includes('--frontend-source') ? resolve(args[args.indexOf('--frontend-source')+1]) : join(repo,'frontend');
|
||||||
const profile = args.includes('--profile') ? args[args.indexOf('--profile')+1] : 'lossy';
|
const profile = args.includes('--profile') ? args[args.indexOf('--profile')+1] : 'lossy';
|
||||||
const browserName = args.includes('--browser') ? args[args.indexOf('--browser') + 1] : 'all';
|
const browserName = args.includes('--browser') ? args[args.indexOf('--browser') + 1] : 'all';
|
||||||
assert.ok(['all', 'chromium', 'webkit'].includes(browserName));
|
assert.ok(['all', 'chromium', 'webkit'].includes(browserName));
|
||||||
@@ -55,7 +56,7 @@ async function run(name) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function start(old) {
|
async function start(old) {
|
||||||
symlinkSync(old ? join(repo, `perf/fixtures/shell-${oldTag}`) : join(repo, 'frontend'), join(cwd, 'public'));
|
symlinkSync(old ? join(repo, `perf/fixtures/shell-${oldTag}`) : targetFrontend, join(cwd, 'public'));
|
||||||
output = '';
|
output = '';
|
||||||
child = spawn('bun', [old ? join(scratch, 'server/server.js') : join(repo, 'server/server.js')], {
|
child = spawn('bun', [old ? join(scratch, 'server/server.js') : join(repo, 'server/server.js')], {
|
||||||
cwd, env: { ...process.env, BUILD_TAG: '', ASSET_HASHING: old ? '0' : '1', PORT: String(port),
|
cwd, env: { ...process.env, BUILD_TAG: '', ASSET_HASHING: old ? '0' : '1', PORT: String(port),
|
||||||
@@ -102,7 +103,9 @@ async function run(name) {
|
|||||||
await page.waitForFunction(() => !!navigator.serviceWorker.controller, { timeout: 90000 });
|
await page.waitForFunction(() => !!navigator.serviceWorker.controller, { timeout: 90000 });
|
||||||
assert.equal(await page.locator('meta[name="ytp-build"]').getAttribute('content'), oldTag);
|
assert.equal(await page.locator('meta[name="ytp-build"]').getAttribute('content'), oldTag);
|
||||||
assert.equal(banners, 0, 'no first-install banner');
|
assert.equal(banners, 0, 'no first-install banner');
|
||||||
|
console.log(name,'old shell installed');
|
||||||
await stop(); unlinkSync(join(cwd, 'public')); await start(false);
|
await stop(); unlinkSync(join(cwd, 'public')); await start(false);
|
||||||
|
console.log(name,'new server started');
|
||||||
const backend = `http://127.0.0.1:${port}`;
|
const backend = `http://127.0.0.1:${port}`;
|
||||||
const manifest = await (await fetch(`${backend}/api/manifest`)).json();
|
const manifest = await (await fetch(`${backend}/api/manifest`)).json();
|
||||||
assert.notEqual(manifest.buildTag, oldTag);
|
assert.notEqual(manifest.buildTag, oldTag);
|
||||||
@@ -112,17 +115,19 @@ async function run(name) {
|
|||||||
const response = await fetch(`${backend}${path}${query}`);
|
const response = await fetch(`${backend}${path}${query}`);
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
assert.equal(response.headers.get('x-asset-hash'), manifest.files[path].h);
|
assert.equal(response.headers.get('x-asset-hash'), manifest.files[path].h);
|
||||||
assert.deepEqual(Buffer.from(await response.arrayBuffer()), readFileSync(join(repo, 'frontend', path.slice(1))));
|
assert.deepEqual(Buffer.from(await response.arrayBuffer()), readFileSync(join(targetFrontend, path.slice(1))));
|
||||||
if (query.includes('?v=')) assert.equal(response.headers.get('cache-control'), 'no-store');
|
if (query.includes('?v=')) assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
proxy.resetStats(); proxy.startTracking();
|
proxy.resetStats(); proxy.startTracking();
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
// Use the old application's public functions; no simulated update helper.
|
// Use the old application's public functions; no simulated update helper.
|
||||||
|
console.log(name,'requesting old-client update');
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
await checkBuildTag();
|
await checkBuildTag();
|
||||||
await (await navigator.serviceWorker.getRegistration()).update();
|
await (await navigator.serviceWorker.getRegistration()).update();
|
||||||
});
|
});
|
||||||
|
console.log(name,'old-client update check returned');
|
||||||
await waitAsync(page,async () => !!(await navigator.serviceWorker.getRegistration())?.waiting);
|
await waitAsync(page,async () => !!(await navigator.serviceWorker.getRegistration())?.waiting);
|
||||||
await page.waitForFunction(() => /Update available/.test(document.querySelector('#modalTitle')?.textContent || '') && !document.querySelector('#modal').classList.contains('hidden'));
|
await page.waitForFunction(() => /Update available/.test(document.querySelector('#modalTitle')?.textContent || '') && !document.querySelector('#modal').classList.contains('hidden'));
|
||||||
assert.equal(banners, 1, 'exactly one offer before refresh');
|
assert.equal(banners, 1, 'exactly one offer before refresh');
|
||||||
@@ -137,17 +142,26 @@ async function run(name) {
|
|||||||
return !reg.installing && reg.waiting?.state==='installed' &&
|
return !reg.installing && reg.waiting?.state==='installed' &&
|
||||||
!!await cache.match('/__ytp_candidate/'+tag);
|
!!await cache.match('/__ytp_candidate/'+tag);
|
||||||
}, manifest.buildTag);
|
}, manifest.buildTag);
|
||||||
|
console.log(name,'candidate installed');
|
||||||
const status = await page.evaluate(async()=>SwUpdate.askCacheStatus((await navigator.serviceWorker.getRegistration()).waiting,{timeoutMs:4000}));
|
const status = await page.evaluate(async()=>SwUpdate.askCacheStatus((await navigator.serviceWorker.getRegistration()).waiting,{timeoutMs:4000}));
|
||||||
assert.equal(status?.ready,true,'new worker confirms persistent cache to old helper');
|
assert.equal(status?.ready,true,'new worker confirms persistent cache to old helper');
|
||||||
assert.equal(status.version,manifest.buildTag,'status belongs to the target build');
|
assert.equal(status.version,manifest.buildTag,'status belongs to the target build');
|
||||||
|
console.log(name,'ready status received');
|
||||||
const oldTab = await context.newPage();
|
const oldTab = await context.newPage();
|
||||||
await oldTab.goto(origin,{waitUntil:'load'});
|
await oldTab.goto(origin,{waitUntil:'load'});
|
||||||
assert.equal(await oldTab.locator('meta[name="ytp-build"]').getAttribute('content'),oldTag);
|
assert.equal(await oldTab.locator('meta[name="ytp-build"]').getAttribute('content'),oldTag);
|
||||||
const oldBytes = await oldTab.evaluate(async()=>await (await fetch('/app.js')).text());
|
const oldBytes = await oldTab.evaluate(async()=>await (await fetch('/app.js')).text());
|
||||||
await page.locator('#modal').getByRole('button', { name: 'Refresh UI', exact: true }).click();
|
// Bring the updating page forward after checking the retained old tab.
|
||||||
|
await page.bringToFront();
|
||||||
|
console.log(name,'clicking legacy refresh');
|
||||||
|
await page.locator('#modal').getByRole('button', { name: 'Refresh UI', exact: true }).click({noWaitAfter:true,timeout:15000});
|
||||||
|
console.log(name,'legacy refresh clicked');
|
||||||
await page.waitForFunction(tag => document.querySelector('meta[name="ytp-build"]')?.content === tag && typeof checkBuildTag === 'function', manifest.buildTag, { timeout: 90000 });
|
await page.waitForFunction(tag => document.querySelector('meta[name="ytp-build"]')?.content === tag && typeof checkBuildTag === 'function', manifest.buildTag, { timeout: 90000 });
|
||||||
|
console.log(name,'new page loaded');
|
||||||
await page.waitForFunction(() => !sessionStorage.getItem('ytpUpdateAttempt'));
|
await page.waitForFunction(() => !sessionStorage.getItem('ytpUpdateAttempt'));
|
||||||
|
console.log(name,'update outcome cleared');
|
||||||
await page.evaluate(async () => { await checkBuildTag(); await checkBuildTag(); await checkBuildTag(); });
|
await page.evaluate(async () => { await checkBuildTag(); await checkBuildTag(); await checkBuildTag(); });
|
||||||
|
console.log(name,'banner rechecks complete');
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
assert.equal(navigations - before, 1, 'one reload, no update loop');
|
assert.equal(navigations - before, 1, 'one reload, no update loop');
|
||||||
assert.equal(banners, 1, 'no banner after applying the current build');
|
assert.equal(banners, 1, 'no banner after applying the current build');
|
||||||
@@ -225,7 +239,7 @@ async function run(name) {
|
|||||||
const result = { browser: name, oldTag, newTag: manifest.buildTag, banners, updateReloads: 1, cachedFiles: cacheResult.count, durationMs, wireBytes: stats.totalWireBytes, offline: true, legacyURLs: 21, profile, userInitiatedOnly: true, playbackGuard: true, evictionRepair: true, incrementalRefresh: true, offlineTransportErrors: errors.length };
|
const result = { browser: name, oldTag, newTag: manifest.buildTag, banners, updateReloads: 1, cachedFiles: cacheResult.count, durationMs, wireBytes: stats.totalWireBytes, offline: true, legacyURLs: 21, profile, userInitiatedOnly: true, playbackGuard: true, evictionRepair: true, incrementalRefresh: true, offlineTransportErrors: errors.length };
|
||||||
console.log(JSON.stringify(result));
|
console.log(JSON.stringify(result));
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} catch(error) { console.error(name,error.stack); throw error; } finally {
|
||||||
await browser?.close();
|
await browser?.close();
|
||||||
if (proxy) { proxy.server.closeAllConnections(); await proxy.close(); }
|
if (proxy) { proxy.server.closeAllConnections(); await proxy.close(); }
|
||||||
await stop();
|
await stop();
|
||||||
|
|||||||
134
perf/offline-complete.mjs
Normal file
134
perf/offline-complete.mjs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Real-server completeness and offline UI regression gate. All scratch stays in this worktree.
|
||||||
|
import {spawn} from 'node:child_process';
|
||||||
|
import {mkdtempSync,cpSync,mkdirSync,writeFileSync,rmSync} from 'node:fs';
|
||||||
|
import {resolve,join} from 'node:path';
|
||||||
|
import {createServer} from 'node:net';
|
||||||
|
import {chromium,webkit} from 'playwright';
|
||||||
|
import {createThrottleProxy} from './proxy.mjs';
|
||||||
|
const repo=resolve(new URL('..',import.meta.url).pathname), args=process.argv.slice(2);
|
||||||
|
const option=(key,fallback)=>args.includes(key)?args[args.indexOf(key)+1]:fallback;
|
||||||
|
const source=resolve(option('--source',join(repo,'frontend'))),legacy=args.includes('--legacy');
|
||||||
|
const scratch=mkdtempSync(join(repo,'perf/.tmp/offline-')),results=[];
|
||||||
|
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
||||||
|
async function poll(fn,timeout=60000){const end=Date.now()+timeout;while(Date.now()<end){if(await fn())return true;await sleep(100);}return false;}
|
||||||
|
async function run(engine){
|
||||||
|
const cwd=join(scratch,engine);mkdirSync(cwd);cpSync(source,join(cwd,'public'),{recursive:true});
|
||||||
|
const socket=createServer();await new Promise(r=>socket.listen(0,'127.0.0.1',r));const port=socket.address().port;await new Promise(r=>socket.close(r));
|
||||||
|
let origin='http://127.0.0.1:'+port, log='',browser,proxy,context;
|
||||||
|
const child=spawn('bun',[join(repo,'server/server.js')],{cwd,env:{...process.env,PORT:String(port),ASSET_HASHING:legacy?'0':'1',ASSET_SYNC:legacy?'0':'1',BUILD_TAG:legacy?'1175f1a1d2c1':'',DB_PATH:join(cwd,'db.sqlite'),DATA_DIR:join(cwd,'data'),MEDIA_DIR:join(cwd,'media'),UPLOAD_DIR:join(cwd,'uploads')},stdio:['ignore','pipe','pipe']});
|
||||||
|
for(const stream of [child.stdout,child.stderr])stream.on('data',c=>log=(log+c).slice(-6000));
|
||||||
|
const result={engine,legacy,failures:[],events:[],layouts:[],conditions:[]};results.push(result);
|
||||||
|
try{
|
||||||
|
if(!await poll(async()=>{try{return(await fetch(origin+'/api/version')).ok;}catch{return false;}}))throw Error(log);
|
||||||
|
proxy=createThrottleProxy({targetPort:port,profile:'unthrottled'});proxy.startTracking();origin='http://127.0.0.1:'+await proxy.listen();
|
||||||
|
const manifest=await(await fetch(origin+'/api/manifest')).json();result.build=manifest.buildTag;
|
||||||
|
const type={chromium,webkit}[engine];
|
||||||
|
const openContext=async()=>{if(engine==='webkit'){browser ||= await type.launch();return browser.newContext();}return type.launchPersistentContext(join(cwd,'profile'),{headless:true});};
|
||||||
|
context=await openContext();
|
||||||
|
const configure=async()=>{await context.route('**/*',r=>r.continue());return context.addInitScript(()=>{Object.defineProperty(navigator,'onLine',{configurable:true,get:()=>localStorage.getItem('perf-offline')!=='1'});Object.defineProperty(navigator,'connection',{configurable:true,value:{get saveData(){return localStorage.getItem('perf-save-data')==='1';}}});});};
|
||||||
|
await configure();if(args.includes('--save-data'))await context.addInitScript(()=>localStorage.setItem('perf-save-data','1'));
|
||||||
|
let offline=false;
|
||||||
|
const attach=page=>{
|
||||||
|
page.on('response',r=>{if(offline&&r.status()>=400&&!new URL(r.url()).pathname.startsWith('/api/'))result.failures.push({kind:'http',url:r.url(),status:r.status()});});
|
||||||
|
page.on('requestfailed',r=>{if(offline)result.events.push({kind:'requestfailed',url:r.url(),error:r.failure()?.errorText});});
|
||||||
|
page.on('console',m=>{if(!offline&&['warning','error'].includes(m.type()))console.log(engine,'install console:',m.text());if(offline&&m.type()==='error')result.events.push({kind:'console',message:m.text()});});
|
||||||
|
page.on('pageerror',e=>{if(offline)result.events.push({kind:'pageerror',message:e.message,stack:e.stack});});
|
||||||
|
};
|
||||||
|
let page=await context.newPage();attach(page);
|
||||||
|
const boot=async()=>{await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('[aria-label="Car mode"]'));};
|
||||||
|
const inventory=(includeOptional=false)=>page.evaluate(async({manifest,legacy,includeOptional})=>{
|
||||||
|
const names=await caches.keys(),cache=await caches.open(legacy?names.find(n=>n.startsWith('ytplayer-')&&!['ytplayer-assets','ytplayer-fonts','ytplayer-thumbs'].includes(n)):'ytplayer-assets');
|
||||||
|
const optional=new Set(Object.values(manifest.groups||{}).filter(group=>group.offlineOptIn).flatMap(group=>group.files||[]));
|
||||||
|
const expected=Object.entries(manifest.files).filter(([path])=>legacy||includeOptional||!optional.has(path));
|
||||||
|
const missing=[];for(const [path,file]of expected){const r=await cache.match(legacy?path:path+'?v='+file.h);if(!r||(!legacy&&r.headers.get('X-Asset-Hash')!==file.h))missing.push(path);}
|
||||||
|
return{total:expected.length,manifestTotal:Object.keys(manifest.files).length,optionalExcluded:Object.keys(manifest.files).length-expected.length,missing};
|
||||||
|
},{manifest,legacy,includeOptional});
|
||||||
|
const started=Date.now();console.log(engine,'install boot');await boot();console.log(engine,'waiting for worker activation');if(!await poll(()=>page.evaluate(async()=>!!(await navigator.serviceWorker.getRegistration())?.active),30000))throw Error('Worker did not activate: '+JSON.stringify(await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();return {active:r?.active?.state,waiting:r?.waiting?.state,installing:r?.installing?.state,caches:await caches.keys(),keys:(await(await caches.open('ytplayer-assets')).keys()).map(r=>new URL(r.url).pathname)};})));await page.evaluate(()=>navigator.serviceWorker.ready);console.log(engine,'worker active');await page.evaluate(()=>window.Lazy?.warm());
|
||||||
|
await poll(async()=>!(await inventory()).missing.length,Number(option('--warm-timeout','15000')));
|
||||||
|
if(!legacy){const wasmPath='/vendor/jamesdsp/dsp/jamesdsp.wasm',wasm=manifest.files[wasmPath];if(wasm){const cached=await page.evaluate(async key=>!!await(await caches.open('ytplayer-assets')).match(key),wasmPath+'?v='+wasm.h);if(cached)result.failures.push({kind:'opt-in-wasm-cached-by-default',path:wasmPath});}}
|
||||||
|
if(!legacy){
|
||||||
|
await page.evaluate(()=>{data.settings.jamesDsp={...data.settings.jamesDsp,downloadForOffline:true};persist();reportDspOfflineOptIn();});
|
||||||
|
const dspReady=await poll(async()=>{const inventoryNow=await inventory(true);return !inventoryNow.missing.length;},120000);
|
||||||
|
result.conditions.push({name:'explicit audio-effects offline opt-in completes optional assets',complete:dspReady});
|
||||||
|
if(!dspReady)result.failures.push({kind:'optional-dsp-completion',paths:(await inventory(true)).missing});
|
||||||
|
}
|
||||||
|
console.log(engine,'inventory complete');result.installMs=Date.now()-started;result.inventory=await inventory();result.installWire=proxy.getStats();result.saveData=await page.evaluate(()=>navigator.connection?.saveData);
|
||||||
|
if(result.inventory.missing.length)result.failures.push({kind:'incomplete',paths:result.inventory.missing});
|
||||||
|
// Offline navigation always starts from a controlled document.
|
||||||
|
await page.reload();await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('[aria-label="Car mode"]'));
|
||||||
|
await page.evaluate(()=>localStorage.setItem('perf-offline','1'));if(engine==='chromium')await context.setOffline(true);await proxy.setOffline(true);offline=true;result.offlineTransport=engine==='chromium'?'context.setOffline + proxy':'proxy (Linux WebKit setOffline breaks cached navigation)';
|
||||||
|
for(const layout of ['classic','f7-swipe','glass-stage','bento-hub']){
|
||||||
|
const check={layout,opened:[],errors:[]};result.layouts.push(check);
|
||||||
|
const attempt=async(name,fn)=>{console.log(engine,layout,name);try{await Promise.race([fn(),new Promise((_,reject)=>{const t=setTimeout(()=>reject(Error('Action timed out')),10000);t.unref();})]);check.opened.push(name);}catch(e){check.errors.push({name,error:e.message});result.failures.push({layout,name,error:e.message});}};
|
||||||
|
await attempt('reload',async()=>{await page.evaluate(layout=>{data.settings.layout=layout;localStorage.setItem('_ytpdata',JSON.stringify(data));},layout);await page.reload();await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('[aria-label="Car mode"]'),null,{timeout:10000});});
|
||||||
|
for(const view of ['search','queue','history','saved','downloads','settings','nowPlaying'])await attempt(view,()=>page.evaluate(async type=>{view={type};if(type==='settings')await renderSettings();else render();await new Promise(r=>setTimeout(r,150));},view));
|
||||||
|
await attempt('sidebar',()=>page.evaluate(()=>{document.querySelector('#menuBtn')?.click();document.querySelector('#sidebar')?.classList.remove('open');}));
|
||||||
|
await attempt('all lazy groups',()=>page.evaluate(async()=>{if(window.Lazy)for(const [name,group] of Object.entries(Lazy.manifest.groups))if(group.background)await Lazy.load(name);}));
|
||||||
|
for(const section of ['piano','midi','floating-lyrics','vocal-reducer','equalizer','setlist-import','broadcast','party-dj','direct-transfer'])await attempt(section,async()=>{await page.evaluate(async()=>{view={type:'settings'};await renderSettings();});await page.waitForSelector('[data-section="'+section+'"]',{timeout:5000,state:'attached'});await page.evaluate(id=>document.querySelector('[data-section="'+id+'"]').click(),section);await page.waitForTimeout(100);});
|
||||||
|
await attempt('presenter',async()=>{await page.evaluate(async()=>{history.replaceState(null,'','/?present=');await Presenter.boot();});await page.waitForSelector('#presenterView:not(.hidden)',{timeout:5000});await page.locator('#pvCode').fill('x');await page.locator('#pvPairBtn').click();if(!/6-digit/.test(await page.locator('#pvMsg').textContent()))throw Error('Presenter validation did not run');await page.evaluate(()=>{document.querySelector('#presenterView').classList.add('hidden');document.body.classList.remove('presenter-open');history.replaceState(null,'','/');});});
|
||||||
|
await attempt('video editor',async()=>{await page.evaluate(()=>openVideoEditor({id:'upl_0123456789ab',title:'Offline edit',duration:120}));if(!await page.evaluate(()=>window.OPFS?.isSupported())){if(!/Editing needs offline storage/.test(await page.locator('#toastContainer').textContent()))throw Error('Missing unsupported-storage explanation');result.unsupportedFeatures ||= [];if(!result.unsupportedFeatures.includes('video editor: OPFS unavailable in Linux WebKit'))result.unsupportedFeatures.push('video editor: OPFS unavailable in Linux WebKit');return;}await page.waitForSelector('.video-editor',{timeout:5000});await page.locator('.ve-from').fill('0:10');await page.locator('.ve-to').fill('0:20');await page.locator('.ve-add').click();if(!/10/.test(await page.locator('.ve-cuts').textContent()))throw Error('Cut was not added');await page.evaluate(()=>closeModal());});
|
||||||
|
await attempt('lyrics editor',async()=>{await page.evaluate(async()=>{data.profile={name:'offline-test'};await Notes.load({id:'abcdefghijk',title:'Offline lyrics'});document.querySelector('#lyricsEditBtn').click();});await page.waitForSelector('.lyr-editor-text',{timeout:5000});await page.locator('.lyr-editor-text').fill('[0:10] Offline lyric');await page.evaluate(()=>closeModal());});
|
||||||
|
await attempt('car',async()=>{await page.evaluate(()=>document.querySelector('[aria-label="Car mode"]').click());await page.waitForSelector('.car-mode',{timeout:5000});await page.getByRole('button',{name:'Exit Car mode'}).click();});
|
||||||
|
await attempt('remote',async()=>{await page.evaluate(()=>document.querySelector('#navRemoteBtn').click());await page.waitForSelector('#modal:not(.hidden)',{timeout:5000});await page.evaluate(()=>closeModal());});
|
||||||
|
await attempt('party',async()=>{await page.evaluate(()=>document.querySelector('#navPartyBtn').click());await page.waitForSelector('#partyPanel:not(.hidden)',{timeout:5000});await page.evaluate(()=>document.querySelector('#partyPanel').classList.add('hidden'));});
|
||||||
|
await attempt('share/external',()=>page.evaluate(async()=>{current={meta:{id:'benchmedia01',title:'Offline fixture'}};await Share.open();closeModal();await External.open();closeModal();}));
|
||||||
|
await attempt('stats',async()=>{await page.evaluate(()=>{view={type:'stats'};render();});await page.waitForSelector('.stats-empty, .stats',{timeout:5000});});
|
||||||
|
await attempt('service',()=>page.evaluate(()=>{enterServiceMode();exitServiceMode();}));
|
||||||
|
await attempt('runtime URLs',async()=>{const failures=await page.evaluate(async manifest=>{const paths=Object.keys(manifest.files).filter(p=>/\.(?:woff2|png|webmanifest)$/.test(p)||/\/(?:opfs-worker|hash-worker|direct-recv-worker|p2p-recv-worker|sha256-wasm)\.js$/.test(p)||p==='/piano-engine.mjs'||p==='/vendor/framework7-swipe.min.js');const bad=[];for(const path of paths){const r=await fetch(path);await r.arrayBuffer();if(!r.ok)bad.push(path+': '+r.status);}return bad;},manifest);if(failures.length)throw Error(failures.join(', '));});
|
||||||
|
}
|
||||||
|
const apiFailures=result.events.filter(e=>e.kind==='requestfailed'&&new URL(e.url).pathname.startsWith('/api/'));
|
||||||
|
result.expectedTransportErrors=result.events.filter(e=>e.kind==='pageerror'&&engine==='webkit'&&!e.stack&&apiFailures.length&&(e.message==='TypeError: Load failed'||e.message.includes('/api/')));
|
||||||
|
result.staticFailures=result.events.filter(e=>e.kind==='requestfailed'&&new URL(e.url).origin===origin&&!new URL(e.url).pathname.startsWith('/api/')&&new URL(e.url).pathname!=='/sw.js');
|
||||||
|
result.scriptErrors=result.events.filter(e=>e.kind==='pageerror'&&!result.expectedTransportErrors.includes(e));
|
||||||
|
result.failures.push(...result.staticFailures,...result.scriptErrors);
|
||||||
|
// Resumption and Save-Data/two-tab checks use the same persisted CacheStorage.
|
||||||
|
offline=false;if(engine==='chromium')await context.setOffline(false);await proxy.setOffline(false);await page.evaluate(()=>localStorage.removeItem('perf-offline'));
|
||||||
|
if(!legacy&&args.includes('--conditions')){
|
||||||
|
await proxy.setOffline(true);
|
||||||
|
await page.evaluate(async manifest=>{const c=await caches.open('ytplayer-assets');for(const p of Object.keys(manifest.files).filter(p=>manifest.files[p].g!=='core'))await c.delete(p+'?v='+manifest.files[p].h);localStorage.setItem('perf-save-data','1');},manifest);
|
||||||
|
await page.evaluate(()=>navigator.serviceWorker.controller.postMessage({type:'COMPLETE_ASSETS'}));
|
||||||
|
await poll(()=>page.evaluate(async()=>{const c=await caches.open('ytplayer-assets'),s=await(await c.match('/__ytp_asset_state')).json(),r=await c.match('/__ytp_completion/'+s.current.buildTag);return r&&(await r.json()).running;}),10000);
|
||||||
|
const interrupted=await inventory();
|
||||||
|
if(!interrupted.missing.length)throw Error('Interruption did not leave missing assets');
|
||||||
|
if(engine==='chromium'){const cdp=await context.newCDPSession(page);await cdp.send('ServiceWorker.enable');await cdp.send('ServiceWorker.stopAllWorkers');await cdp.detach();}
|
||||||
|
if(engine==='chromium'){
|
||||||
|
await page.close();await context.close();await proxy.setOffline(false);
|
||||||
|
context=await openContext();await configure();page=await context.newPage();attach(page);await boot();
|
||||||
|
}else{
|
||||||
|
// Linux WebKit persistent contexts do not share page/worker CacheStorage.
|
||||||
|
// Exercise a real worker re-registration in the same browser context.
|
||||||
|
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();await r.unregister();});
|
||||||
|
await page.close();await proxy.setOffline(false);page=await context.newPage();attach(page);await boot();
|
||||||
|
}
|
||||||
|
const other=await context.newPage();await other.goto(origin);await other.waitForSelector('#carModeBtn');
|
||||||
|
const complete=await poll(async()=>!(await inventory()).missing.length);
|
||||||
|
result.conditions.push({name:engine==='webkit'?'page closed, worker re-registration, Save-Data, two tabs':'interrupted job, page closed, browser restarted with same profile, Save-Data, two tabs',interruptedMissing:interrupted.missing.length,storageMode:engine==='webkit'?'same-context worker re-registration; Linux WebKit persistent CacheStorage is broken':'persistent disk profile',complete});
|
||||||
|
if(!complete)result.failures.push({kind:'resume',inventory:await inventory(),status:await page.evaluate(async()=>({registration:!!(await navigator.serviceWorker.getRegistration())?.active}))});
|
||||||
|
// Evict one optional file and require the online event alone to restart work.
|
||||||
|
const path='/piano-engine.mjs';await page.evaluate(async({path,manifest})=>{await(await caches.open('ytplayer-assets')).delete(path+'?v='+manifest.files[path].h);localStorage.removeItem('perf-save-data');window.dispatchEvent(new Event('online'));},{path,manifest});
|
||||||
|
const onlineResume=await poll(async()=>!(await inventory()).missing.length);result.conditions.push({name:'online event, Save-Data off',complete:onlineResume});if(!onlineResume)result.failures.push({kind:'online-resume'});
|
||||||
|
if(engine==='chromium'){
|
||||||
|
await sleep(100);await proxy.setOffline(true);
|
||||||
|
await context.route('**/admin.html?offline-inspector=1',route=>route.fulfill({contentType:'text/html',body:'<!doctype html><title>Cache inspector</title>'}));
|
||||||
|
const inspector=await context.newPage();await inspector.goto(origin+'/admin.html?offline-inspector=1');const cdp=await context.newCDPSession(inspector);
|
||||||
|
const {caches:names}=await cdp.send('CacheStorage.requestCacheNames',{securityOrigin:origin});
|
||||||
|
const cacheId=names.find(c=>c.cacheName==='ytplayer-assets').cacheId;
|
||||||
|
await page.evaluate(async manifest=>{const c=await caches.open('ytplayer-assets');for(const path of Object.keys(manifest.files).filter(p=>manifest.files[p].g!=='core'))await c.delete(path+'?v='+manifest.files[path].h);navigator.serviceWorker.controller.postMessage({type:'COMPLETE_ASSETS'});},manifest);
|
||||||
|
await poll(()=>page.evaluate(async()=>{const c=await caches.open('ytplayer-assets'),s=await(await c.match('/__ytp_asset_state')).json(),r=await c.match('/__ytp_completion/'+s.current.buildTag);return r&&(await r.json()).running;}),5000);
|
||||||
|
await page.close();await other.close();await proxy.setOffline(false);
|
||||||
|
const optionalPaths=new Set(Object.values(manifest.groups||{}).filter(group=>group.offlineOptIn==='jamesDsp').flatMap(group=>group.files||[]));
|
||||||
|
const includeOptional=await page.evaluate(async()=>{const cache=await caches.open('ytplayer-assets'),response=await cache.match('/__ytp_offline_opt_ins');return (await response?.json()||[]).includes('jamesDsp');});
|
||||||
|
const requiredFiles=Object.entries(manifest.files).filter(([path])=>includeOptional||!optionalPaths.has(path));
|
||||||
|
const withoutPage=await poll(async()=>{const {cacheDataEntries:entries}=await cdp.send('CacheStorage.requestEntries',{cacheId,skipCount:0,pageSize:1000});return requiredFiles.every(([path,file])=>entries.some(e=>e.requestURL===origin+path+'?v='+file.h&&e.responseHeaders.some(h=>h.name.toLowerCase()==='x-asset-hash'&&h.value===file.h)));});
|
||||||
|
result.conditions.push({name:'worker completes with every application page closed; passive same-origin inspector only',complete:withoutPage});
|
||||||
|
if(!withoutPage)result.failures.push({kind:'completion-without-page'});
|
||||||
|
await cdp.detach();await inspector.close();
|
||||||
|
}else await other.close();
|
||||||
|
}
|
||||||
|
result.inventory=await inventory(!legacy);
|
||||||
|
console.log(engine,JSON.stringify({missing:result.inventory.missing,inventory:result.inventory.total,manifestFiles:result.inventory.manifestTotal,failures:result.failures,conditions:result.conditions}));
|
||||||
|
}catch(e){result.failures.push({kind:'harness',error:e.stack});console.log(engine,e.message);}finally{await context?.close();await browser?.close();await proxy?.close();child.kill('SIGTERM');if(child.exitCode===null)await new Promise(r=>child.once('exit',r));}
|
||||||
|
}
|
||||||
|
try{for(const engine of option('--browser','all')==='all'?['chromium','webkit']:[option('--browser')])await run(engine);}finally{writeFileSync(resolve(option('--out',join(repo,'perf/results/offline-complete.json'))),JSON.stringify({results},null,2)+'\n');rmSync(scratch,{recursive:true,force:true});}
|
||||||
|
if(results.some(r=>r.failures.length))process.exitCode=1;
|
||||||
@@ -47,6 +47,7 @@ export function categorizeContentType(contentType, urlPath) {
|
|||||||
*/
|
*/
|
||||||
export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
||||||
let activeTracking = false;
|
let activeTracking = false;
|
||||||
|
let trackingStart = 0;
|
||||||
let requestCount = 0;
|
let requestCount = 0;
|
||||||
let requests = [];
|
let requests = [];
|
||||||
let totalWireBytes = 0;
|
let totalWireBytes = 0;
|
||||||
@@ -79,6 +80,7 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestStart = Date.now();
|
||||||
reqIndex++;
|
reqIndex++;
|
||||||
const currentReqIndex = reqIndex;
|
const currentReqIndex = reqIndex;
|
||||||
|
|
||||||
@@ -114,7 +116,8 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
|||||||
let recorded;
|
let recorded;
|
||||||
if (activeTracking) {
|
if (activeTracking) {
|
||||||
requestCount++;
|
requestCount++;
|
||||||
recorded={url:clientReq.url,status:upRes.statusCode,bytes:0};
|
recorded={url:clientReq.url,status:upRes.statusCode,bytes:0,index:currentReqIndex,startMs:requestStart-trackingStart,headersMs:Date.now()-trackingStart};
|
||||||
|
clientRes.once('finish',()=>{recorded.endMs=Date.now()-trackingStart;});
|
||||||
requests.push(recorded);
|
requests.push(recorded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +179,7 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
|||||||
server.close(resolve);
|
server.close(resolve);
|
||||||
}),
|
}),
|
||||||
startTracking: () => {
|
startTracking: () => {
|
||||||
|
trackingStart = Date.now();
|
||||||
activeTracking = true;
|
activeTracking = true;
|
||||||
},
|
},
|
||||||
stopTracking: () => {
|
stopTracking: () => {
|
||||||
|
|||||||
208
perf/results/autosave-b77938a.json
Normal file
208
perf/results/autosave-b77938a.json
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
{
|
||||||
|
"revision": "/home/josh/development/personal/ytplayer-codex/wt-autosave/perf/fixtures/shell-1175f1a1d2c1",
|
||||||
|
"browsers": {
|
||||||
|
"chromium": [
|
||||||
|
{
|
||||||
|
"scenario": "open-default",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-audio-only",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-prefer-stream",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add-setting-off",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-existing-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot-off",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "pinned-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-enable",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": [
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"webkit": [
|
||||||
|
{
|
||||||
|
"scenario": "open-default",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-save-first",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-audio-only",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-prefer-stream",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add-setting-off",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-existing-add",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot-off",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "pinned-boot",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-enable",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-save-first",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"failures": [],
|
||||||
|
"unsupported": [
|
||||||
|
"webkit"
|
||||||
|
],
|
||||||
|
"buildTag": "1175f1a1d2c1"
|
||||||
|
}
|
||||||
110
perf/results/autosave-current.json
Normal file
110
perf/results/autosave-current.json
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"revision": "/home/josh/development/personal/ytplayer-codex/wt-autosave/frontend",
|
||||||
|
"browsers": {
|
||||||
|
"chromium": [
|
||||||
|
{
|
||||||
|
"scenario": "open-default",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-audio-only",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-prefer-stream",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add-setting-off",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-existing-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot-off",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "pinned-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-enable",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": [
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": [
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"failures": [],
|
||||||
|
"unsupported": [],
|
||||||
|
"buildTag": "e93dbca4dc5a"
|
||||||
|
}
|
||||||
208
perf/results/autosave-main.json
Normal file
208
perf/results/autosave-main.json
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
{
|
||||||
|
"revision": "main",
|
||||||
|
"browsers": {
|
||||||
|
"chromium": [
|
||||||
|
{
|
||||||
|
"scenario": "open-default",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-audio-only",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-prefer-stream",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add-setting-off",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-existing-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot-off",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "pinned-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-enable",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": [
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"webkit": [
|
||||||
|
{
|
||||||
|
"scenario": "open-default",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-save-first",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-audio-only",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-prefer-stream",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add-setting-off",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-existing-add",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot-off",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "pinned-boot",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-enable",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-save-first",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"failures": [],
|
||||||
|
"unsupported": [
|
||||||
|
"webkit"
|
||||||
|
],
|
||||||
|
"buildTag": "c3dbdd7039db"
|
||||||
|
}
|
||||||
110
perf/results/autosave-phase1.json
Normal file
110
perf/results/autosave-phase1.json
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"revision": "codex/phase1",
|
||||||
|
"browsers": {
|
||||||
|
"chromium": [
|
||||||
|
{
|
||||||
|
"scenario": "open-default",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-audio-only",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-prefer-stream",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add-setting-off",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-existing-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot-off",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "pinned-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-enable",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": [
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": [
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"failures": [],
|
||||||
|
"unsupported": [],
|
||||||
|
"buildTag": "288787b9f7db"
|
||||||
|
}
|
||||||
110
perf/results/autosave-phase2.json
Normal file
110
perf/results/autosave-phase2.json
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"revision": "codex/phase2",
|
||||||
|
"browsers": {
|
||||||
|
"chromium": [
|
||||||
|
{
|
||||||
|
"scenario": "open-default",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-audio-only",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-prefer-stream",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add-setting-off",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-existing-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot-off",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "pinned-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-enable",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": [
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": [
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"failures": [],
|
||||||
|
"unsupported": [],
|
||||||
|
"buildTag": "4f7090fff60c"
|
||||||
|
}
|
||||||
60
perf/results/autosave-phase3.json
Normal file
60
perf/results/autosave-phase3.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"revision": "codex/phase3",
|
||||||
|
"browsers": {
|
||||||
|
"chromium": [
|
||||||
|
{
|
||||||
|
"scenario": "open-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "pinned-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-enable",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": [
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"failures": [],
|
||||||
|
"unsupported": [],
|
||||||
|
"buildTag": "bea1f63eba85"
|
||||||
|
}
|
||||||
60
perf/results/autosave-phase4.json
Normal file
60
perf/results/autosave-phase4.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"revision": "codex/phase4",
|
||||||
|
"browsers": {
|
||||||
|
"chromium": [
|
||||||
|
{
|
||||||
|
"scenario": "open-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "pinned-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-enable",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": [
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"failures": [],
|
||||||
|
"unsupported": [],
|
||||||
|
"buildTag": "c3dbdd7039db"
|
||||||
|
}
|
||||||
60
perf/results/autosave-phase5.json
Normal file
60
perf/results/autosave-phase5.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"revision": "codex/phase5",
|
||||||
|
"browsers": {
|
||||||
|
"chromium": [
|
||||||
|
{
|
||||||
|
"scenario": "open-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "pinned-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-enable",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": [
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"failures": [],
|
||||||
|
"unsupported": [],
|
||||||
|
"buildTag": "c3dbdd7039db"
|
||||||
|
}
|
||||||
62
perf/results/autosave-rollback.json
Normal file
62
perf/results/autosave-rollback.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"revision": "/home/josh/development/personal/ytplayer-codex/wt-autosave/frontend",
|
||||||
|
"browsers": {
|
||||||
|
"chromium": [
|
||||||
|
{
|
||||||
|
"scenario": "open-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "pinned-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-enable",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": [
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": [
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"failures": [],
|
||||||
|
"unsupported": [],
|
||||||
|
"buildTag": "2b1c433b19aa"
|
||||||
|
}
|
||||||
27562
perf/results/dsp-cold-check.json
Normal file
27562
perf/results/dsp-cold-check.json
Normal file
File diff suppressed because it is too large
Load Diff
6055
perf/results/final-2026-10-08.json
Normal file
6055
perf/results/final-2026-10-08.json
Normal file
File diff suppressed because it is too large
Load Diff
2893
perf/results/hygiene-after.json
Normal file
2893
perf/results/hygiene-after.json
Normal file
File diff suppressed because it is too large
Load Diff
260
perf/results/hygiene-assets.json
Normal file
260
perf/results/hygiene-assets.json
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
{
|
||||||
|
"before": {
|
||||||
|
"count": 151,
|
||||||
|
"rawBytes": 2044337,
|
||||||
|
"manifestBytes": 15601
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"count": 102,
|
||||||
|
"rawBytes": 1807059,
|
||||||
|
"manifestBytes": 12551
|
||||||
|
},
|
||||||
|
"excluded": [
|
||||||
|
{
|
||||||
|
"path": "/admin.html",
|
||||||
|
"rawBytes": 102005,
|
||||||
|
"brBytes": 24050
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/app-seams.test.js",
|
||||||
|
"rawBytes": 9786,
|
||||||
|
"brBytes": 2457
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/asset-sync-core.test.js",
|
||||||
|
"rawBytes": 9430,
|
||||||
|
"brBytes": 2525
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/asset-worker.test.js",
|
||||||
|
"rawBytes": 8219,
|
||||||
|
"brBytes": 1984
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/async-guard.test.js",
|
||||||
|
"rawBytes": 2543,
|
||||||
|
"brBytes": 765
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/audio-continuity.test.js",
|
||||||
|
"rawBytes": 2899,
|
||||||
|
"brBytes": 813
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/bento-hub.test.js",
|
||||||
|
"rawBytes": 868,
|
||||||
|
"brBytes": 283
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/car-mode.test.js",
|
||||||
|
"rawBytes": 2556,
|
||||||
|
"brBytes": 973
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/direct-protocol.test.js",
|
||||||
|
"rawBytes": 1279,
|
||||||
|
"brBytes": 521
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/direct-stream.test.js",
|
||||||
|
"rawBytes": 891,
|
||||||
|
"brBytes": 390
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/download-errors.test.js",
|
||||||
|
"rawBytes": 1783,
|
||||||
|
"brBytes": 566
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/downloads-page.test.js",
|
||||||
|
"rawBytes": 2453,
|
||||||
|
"brBytes": 699
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/downloads.test.js",
|
||||||
|
"rawBytes": 1608,
|
||||||
|
"brBytes": 639
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/eq-core.test.js",
|
||||||
|
"rawBytes": 1055,
|
||||||
|
"brBytes": 428
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/export.test.js",
|
||||||
|
"rawBytes": 521,
|
||||||
|
"brBytes": 259
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/f7-layout.test.js",
|
||||||
|
"rawBytes": 1400,
|
||||||
|
"brBytes": 380
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/fonts/PlusJakartaSans-OFL.txt",
|
||||||
|
"rawBytes": 4402,
|
||||||
|
"brBytes": 1428
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/fullscreen-orientation.test.js",
|
||||||
|
"rawBytes": 1278,
|
||||||
|
"brBytes": 404
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/glass-controls.test.js",
|
||||||
|
"rawBytes": 1177,
|
||||||
|
"brBytes": 412
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/glass-panel-layout.test.js",
|
||||||
|
"rawBytes": 782,
|
||||||
|
"brBytes": 259
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/lazy.test.js",
|
||||||
|
"rawBytes": 7117,
|
||||||
|
"brBytes": 2030
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/loudness.test.js",
|
||||||
|
"rawBytes": 1626,
|
||||||
|
"brBytes": 706
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/lower-third.test.js",
|
||||||
|
"rawBytes": 845,
|
||||||
|
"brBytes": 338
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/lyrics-core.test.js",
|
||||||
|
"rawBytes": 4996,
|
||||||
|
"brBytes": 1430
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/lyrics-display.test.js",
|
||||||
|
"rawBytes": 494,
|
||||||
|
"brBytes": 244
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/lyrics-window.test.js",
|
||||||
|
"rawBytes": 617,
|
||||||
|
"brBytes": 264
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/midi.test.js",
|
||||||
|
"rawBytes": 987,
|
||||||
|
"brBytes": 348
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/p2p-core.test.js",
|
||||||
|
"rawBytes": 2116,
|
||||||
|
"brBytes": 675
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/party-dj.test.js",
|
||||||
|
"rawBytes": 550,
|
||||||
|
"brBytes": 234
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/piano-core.test.js",
|
||||||
|
"rawBytes": 2102,
|
||||||
|
"brBytes": 731
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/prewarm-next.test.js",
|
||||||
|
"rawBytes": 1268,
|
||||||
|
"brBytes": 434
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/related-videos.test.js",
|
||||||
|
"rawBytes": 1514,
|
||||||
|
"brBytes": 556
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/resume-core.test.js",
|
||||||
|
"rawBytes": 1531,
|
||||||
|
"brBytes": 484
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/saved-page.test.js",
|
||||||
|
"rawBytes": 553,
|
||||||
|
"brBytes": 224
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/server-backfill.test.js",
|
||||||
|
"rawBytes": 2118,
|
||||||
|
"brBytes": 683
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/setlist-import.test.js",
|
||||||
|
"rawBytes": 1134,
|
||||||
|
"brBytes": 508
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/settings-sections.test.js",
|
||||||
|
"rawBytes": 1209,
|
||||||
|
"brBytes": 405
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/sha256.test.js",
|
||||||
|
"rawBytes": 3251,
|
||||||
|
"brBytes": 1153
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/shell-consistency.test.js",
|
||||||
|
"rawBytes": 5265,
|
||||||
|
"brBytes": 1507
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/stats-core.test.js",
|
||||||
|
"rawBytes": 3165,
|
||||||
|
"brBytes": 929
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/sw-update.test.js",
|
||||||
|
"rawBytes": 17825,
|
||||||
|
"brBytes": 4099
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/sw.test.js",
|
||||||
|
"rawBytes": 6219,
|
||||||
|
"brBytes": 1849
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/vendor/framework7-swipe.entry.js",
|
||||||
|
"rawBytes": 1040,
|
||||||
|
"brBytes": 290
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/video-edit.test.js",
|
||||||
|
"rawBytes": 3396,
|
||||||
|
"brBytes": 855
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/visible-timer.test.js",
|
||||||
|
"rawBytes": 2827,
|
||||||
|
"brBytes": 687
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/vocal-reducer.test.js",
|
||||||
|
"rawBytes": 648,
|
||||||
|
"brBytes": 235
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/wasm/loudness.c",
|
||||||
|
"rawBytes": 2132,
|
||||||
|
"brBytes": 861
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/wasm/sha256.c",
|
||||||
|
"rawBytes": 2428,
|
||||||
|
"brBytes": 1064
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/worker-imports.test.js",
|
||||||
|
"rawBytes": 1370,
|
||||||
|
"brBytes": 636
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"excludedBrBytes": 64694
|
||||||
|
}
|
||||||
5013
perf/results/hygiene-before-rollback-offline.json
Normal file
5013
perf/results/hygiene-before-rollback-offline.json
Normal file
File diff suppressed because it is too large
Load Diff
3383
perf/results/hygiene-before.json
Normal file
3383
perf/results/hygiene-before.json
Normal file
File diff suppressed because it is too large
Load Diff
41
perf/results/hygiene-migration.json
Normal file
41
perf/results/hygiene-migration.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"commit": "b77938a",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"browser": "chromium",
|
||||||
|
"oldTag": "1175f1a1d2c1",
|
||||||
|
"newTag": "604f8f8d8a68",
|
||||||
|
"banners": 1,
|
||||||
|
"updateReloads": 1,
|
||||||
|
"cachedFiles": 124,
|
||||||
|
"durationMs": 26801,
|
||||||
|
"wireBytes": 638444,
|
||||||
|
"offline": true,
|
||||||
|
"legacyURLs": 21,
|
||||||
|
"profile": "lossy",
|
||||||
|
"userInitiatedOnly": true,
|
||||||
|
"playbackGuard": true,
|
||||||
|
"evictionRepair": true,
|
||||||
|
"incrementalRefresh": true,
|
||||||
|
"offlineTransportErrors": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browser": "webkit",
|
||||||
|
"oldTag": "1175f1a1d2c1",
|
||||||
|
"newTag": "604f8f8d8a68",
|
||||||
|
"banners": 1,
|
||||||
|
"updateReloads": 1,
|
||||||
|
"cachedFiles": 124,
|
||||||
|
"durationMs": 24322,
|
||||||
|
"wireBytes": 743954,
|
||||||
|
"offline": true,
|
||||||
|
"legacyURLs": 21,
|
||||||
|
"profile": "lossy",
|
||||||
|
"userInitiatedOnly": true,
|
||||||
|
"playbackGuard": true,
|
||||||
|
"evictionRepair": true,
|
||||||
|
"incrementalRefresh": true,
|
||||||
|
"offlineTransportErrors": 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
5836
perf/results/hygiene-offline.json
Normal file
5836
perf/results/hygiene-offline.json
Normal file
File diff suppressed because it is too large
Load Diff
4817
perf/results/hygiene-rollback-offline.json
Normal file
4817
perf/results/hygiene-rollback-offline.json
Normal file
File diff suppressed because it is too large
Load Diff
208
perf/results/integration-autosave.json
Normal file
208
perf/results/integration-autosave.json
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
{
|
||||||
|
"revision": "/home/josh/development/personal/ytplayer-codex/wt-integrate/frontend",
|
||||||
|
"browsers": {
|
||||||
|
"chromium": [
|
||||||
|
{
|
||||||
|
"scenario": "open-default",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-audio-only",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-prefer-stream",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add-setting-off",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-existing-add",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot-off",
|
||||||
|
"supported": true,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "pinned-boot",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-enable",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-save-first",
|
||||||
|
"supported": true,
|
||||||
|
"saved": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": [
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"webkit": [
|
||||||
|
{
|
||||||
|
"scenario": "open-default",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-save-first",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-audio-only",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "open-prefer-stream",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-add-setting-off",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-existing-add",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "playlist-boot-off",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "pinned-boot",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-enable",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 1,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "settings-save-first",
|
||||||
|
"supported": false,
|
||||||
|
"saved": false,
|
||||||
|
"bytes": 0,
|
||||||
|
"calls": 0,
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"failures": [],
|
||||||
|
"unsupported": [
|
||||||
|
"webkit"
|
||||||
|
],
|
||||||
|
"buildTag": "7760d5727244"
|
||||||
|
}
|
||||||
41597
perf/results/integration-baseline.json
Normal file
41597
perf/results/integration-baseline.json
Normal file
File diff suppressed because it is too large
Load Diff
97
perf/results/integration-lazy.json
Normal file
97
perf/results/integration-lazy.json
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"browser": "chromium",
|
||||||
|
"sourceBuildTag": "7760d5727244",
|
||||||
|
"classicSharedDefaults": true,
|
||||||
|
"cssOrderPreserved": true,
|
||||||
|
"settingsSearch": true,
|
||||||
|
"carControls": 4,
|
||||||
|
"offlineFeature": true,
|
||||||
|
"firstUseNetwork": 0,
|
||||||
|
"layoutsOffline": [
|
||||||
|
"glass-stage",
|
||||||
|
"bento-hub",
|
||||||
|
"f7-swipe",
|
||||||
|
"classic"
|
||||||
|
],
|
||||||
|
"pinned": true,
|
||||||
|
"compatibleUnexecutedNewURL": true,
|
||||||
|
"incompatibleN1ReloadRequired": true,
|
||||||
|
"stagedN1": true,
|
||||||
|
"appEvictionN1Offline": true,
|
||||||
|
"playbackGuard": true,
|
||||||
|
"prePaintLayout": true,
|
||||||
|
"offlineSettingsPanels": true,
|
||||||
|
"scriptErrors": [],
|
||||||
|
"offlineTransportErrors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browser": "webkit",
|
||||||
|
"sourceBuildTag": "7760d5727244",
|
||||||
|
"classicSharedDefaults": true,
|
||||||
|
"cssOrderPreserved": true,
|
||||||
|
"settingsSearch": true,
|
||||||
|
"carControls": 4,
|
||||||
|
"offlineFeature": true,
|
||||||
|
"firstUseNetwork": 0,
|
||||||
|
"layoutsOffline": [
|
||||||
|
"glass-stage",
|
||||||
|
"bento-hub",
|
||||||
|
"f7-swipe",
|
||||||
|
"classic"
|
||||||
|
],
|
||||||
|
"pinned": true,
|
||||||
|
"compatibleUnexecutedNewURL": true,
|
||||||
|
"incompatibleN1ReloadRequired": true,
|
||||||
|
"stagedN1": true,
|
||||||
|
"appEvictionN1Offline": true,
|
||||||
|
"playbackGuard": true,
|
||||||
|
"prePaintLayout": true,
|
||||||
|
"offlineSettingsPanels": true,
|
||||||
|
"scriptErrors": [],
|
||||||
|
"offlineTransportErrors": [
|
||||||
|
{
|
||||||
|
"message": "TypeError: Load failed",
|
||||||
|
"disconnected": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "/127.0.0.1:39913/api/version.",
|
||||||
|
"disconnected": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "TypeError: Load failed",
|
||||||
|
"disconnected": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "/127.0.0.1:39913/api/recommendations?fp=163577c02a53bc40.",
|
||||||
|
"disconnected": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "TypeError: Load failed",
|
||||||
|
"disconnected": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "/127.0.0.1:39913/api/version.",
|
||||||
|
"disconnected": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "TypeError: Load failed",
|
||||||
|
"disconnected": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "/127.0.0.1:39913/api/recommendations?fp=163577c02a53bc40.",
|
||||||
|
"disconnected": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "TypeError: Load failed",
|
||||||
|
"disconnected": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "/127.0.0.1:39913/api/version.",
|
||||||
|
"disconnected": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
41
perf/results/integration-migration-lossy.json
Normal file
41
perf/results/integration-migration-lossy.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"commit": "b77938a",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"browser": "chromium",
|
||||||
|
"oldTag": "1175f1a1d2c1",
|
||||||
|
"newTag": "7760d5727244",
|
||||||
|
"banners": 1,
|
||||||
|
"updateReloads": 1,
|
||||||
|
"cachedFiles": 132,
|
||||||
|
"durationMs": 27587,
|
||||||
|
"wireBytes": 642140,
|
||||||
|
"offline": true,
|
||||||
|
"legacyURLs": 21,
|
||||||
|
"profile": "lossy",
|
||||||
|
"userInitiatedOnly": true,
|
||||||
|
"playbackGuard": true,
|
||||||
|
"evictionRepair": true,
|
||||||
|
"incrementalRefresh": true,
|
||||||
|
"offlineTransportErrors": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browser": "webkit",
|
||||||
|
"oldTag": "1175f1a1d2c1",
|
||||||
|
"newTag": "7760d5727244",
|
||||||
|
"banners": 1,
|
||||||
|
"updateReloads": 1,
|
||||||
|
"cachedFiles": 132,
|
||||||
|
"durationMs": 24285,
|
||||||
|
"wireBytes": 748999,
|
||||||
|
"offline": true,
|
||||||
|
"legacyURLs": 21,
|
||||||
|
"profile": "lossy",
|
||||||
|
"userInitiatedOnly": true,
|
||||||
|
"playbackGuard": true,
|
||||||
|
"evictionRepair": true,
|
||||||
|
"incrementalRefresh": true,
|
||||||
|
"offlineTransportErrors": 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
41
perf/results/integration-migration-unthrottled.json
Normal file
41
perf/results/integration-migration-unthrottled.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"commit": "b77938a",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"browser": "chromium",
|
||||||
|
"oldTag": "1175f1a1d2c1",
|
||||||
|
"newTag": "7760d5727244",
|
||||||
|
"banners": 1,
|
||||||
|
"updateReloads": 1,
|
||||||
|
"cachedFiles": 132,
|
||||||
|
"durationMs": 10957,
|
||||||
|
"wireBytes": 653223,
|
||||||
|
"offline": true,
|
||||||
|
"legacyURLs": 21,
|
||||||
|
"profile": "unthrottled",
|
||||||
|
"userInitiatedOnly": true,
|
||||||
|
"playbackGuard": true,
|
||||||
|
"evictionRepair": true,
|
||||||
|
"incrementalRefresh": true,
|
||||||
|
"offlineTransportErrors": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browser": "webkit",
|
||||||
|
"oldTag": "1175f1a1d2c1",
|
||||||
|
"newTag": "7760d5727244",
|
||||||
|
"banners": 1,
|
||||||
|
"updateReloads": 1,
|
||||||
|
"cachedFiles": 132,
|
||||||
|
"durationMs": 9972,
|
||||||
|
"wireBytes": 775494,
|
||||||
|
"offline": true,
|
||||||
|
"legacyURLs": 21,
|
||||||
|
"profile": "unthrottled",
|
||||||
|
"userInitiatedOnly": true,
|
||||||
|
"playbackGuard": true,
|
||||||
|
"evictionRepair": true,
|
||||||
|
"incrementalRefresh": true,
|
||||||
|
"offlineTransportErrors": 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8051
perf/results/integration-offline.json
Normal file
8051
perf/results/integration-offline.json
Normal file
File diff suppressed because it is too large
Load Diff
708
perf/results/integration-settings.json
Normal file
708
perf/results/integration-settings.json
Normal file
@@ -0,0 +1,708 @@
|
|||||||
|
{
|
||||||
|
"revision": "6ae81d1024caa42931d47abe820417cf63d0fe17",
|
||||||
|
"oldTag": "1175f1a1d2c1",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"engine": "chromium",
|
||||||
|
"errors": [
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')",
|
||||||
|
"Cannot read properties of null (reading 'addEventListener')"
|
||||||
|
],
|
||||||
|
"profileChecks": [
|
||||||
|
{
|
||||||
|
"stage": "older timestamp ignored",
|
||||||
|
"serverUpdatedAt": 100,
|
||||||
|
"localSyncedAt": 200,
|
||||||
|
"saveBeforePlay": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "older settings with newer timestamp adopted",
|
||||||
|
"serverUpdatedAt": 300,
|
||||||
|
"localSyncedAt": 200,
|
||||||
|
"saveBeforePlay": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"behaviour": [
|
||||||
|
{
|
||||||
|
"stage": "old",
|
||||||
|
"supported": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"cached": true,
|
||||||
|
"localPlayback": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "upgraded",
|
||||||
|
"supported": true,
|
||||||
|
"bytes": 30290,
|
||||||
|
"cached": true,
|
||||||
|
"localPlayback": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"diff": [
|
||||||
|
{
|
||||||
|
"key": "quality",
|
||||||
|
"before": "360p",
|
||||||
|
"after": "360p",
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "volume",
|
||||||
|
"before": 1,
|
||||||
|
"after": 1,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "audioOnly",
|
||||||
|
"before": false,
|
||||||
|
"after": false,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "autoPreload",
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "p2pShare",
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "p2pReceive",
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "saveBeforePlay",
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "repeatMode",
|
||||||
|
"before": "all",
|
||||||
|
"after": "all",
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "loopOne",
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "shuffle",
|
||||||
|
"before": false,
|
||||||
|
"after": false,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "theme",
|
||||||
|
"before": "light",
|
||||||
|
"after": "light",
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "fontScale",
|
||||||
|
"before": "large",
|
||||||
|
"after": "large",
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "levelVolume",
|
||||||
|
"before": false,
|
||||||
|
"after": false,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "liveAudioIOS",
|
||||||
|
"before": false,
|
||||||
|
"after": false,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "parallelSaves",
|
||||||
|
"before": 2,
|
||||||
|
"after": 2,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "density",
|
||||||
|
"before": "compact",
|
||||||
|
"after": "compact",
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "perfMode",
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "reduceMotion",
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "layout",
|
||||||
|
"before": "f7-swipe",
|
||||||
|
"after": "f7-swipe",
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "autoBackupEnabled",
|
||||||
|
"before": false,
|
||||||
|
"after": false,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "autoBackupIntervalDays",
|
||||||
|
"before": 7,
|
||||||
|
"after": 7,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "serviceVideo",
|
||||||
|
"before": false,
|
||||||
|
"after": false,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "foldedCards",
|
||||||
|
"before": {},
|
||||||
|
"after": {},
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "eq",
|
||||||
|
"before": {
|
||||||
|
"preset": "bass",
|
||||||
|
"g": [
|
||||||
|
5,
|
||||||
|
7,
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"preset": "bass",
|
||||||
|
"g": [
|
||||||
|
5,
|
||||||
|
7,
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"old": {
|
||||||
|
"quality": "360p",
|
||||||
|
"volume": 1,
|
||||||
|
"audioOnly": false,
|
||||||
|
"autoPreload": true,
|
||||||
|
"p2pShare": true,
|
||||||
|
"p2pReceive": true,
|
||||||
|
"saveBeforePlay": true,
|
||||||
|
"repeatMode": "all",
|
||||||
|
"loopOne": true,
|
||||||
|
"shuffle": false,
|
||||||
|
"theme": "light",
|
||||||
|
"fontScale": "large",
|
||||||
|
"levelVolume": false,
|
||||||
|
"liveAudioIOS": false,
|
||||||
|
"parallelSaves": 2,
|
||||||
|
"density": "compact",
|
||||||
|
"perfMode": true,
|
||||||
|
"reduceMotion": true,
|
||||||
|
"layout": "f7-swipe",
|
||||||
|
"autoBackupEnabled": false,
|
||||||
|
"autoBackupIntervalDays": 7,
|
||||||
|
"serviceVideo": false,
|
||||||
|
"foldedCards": {},
|
||||||
|
"eq": {
|
||||||
|
"preset": "bass",
|
||||||
|
"g": [
|
||||||
|
5,
|
||||||
|
7,
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oldUI": {
|
||||||
|
"setSaveBeforePlay": true,
|
||||||
|
"setAutoPreload": true,
|
||||||
|
"setAudioOnly": false,
|
||||||
|
"setQuality": "360p",
|
||||||
|
"setRepeat": true,
|
||||||
|
"setLoopOne": true,
|
||||||
|
"setTheme": "light",
|
||||||
|
"setFont": "large",
|
||||||
|
"setDensity": "compact",
|
||||||
|
"setPerf": true,
|
||||||
|
"setMotion": true,
|
||||||
|
"setParallelSaves": "2",
|
||||||
|
"setLevelVolume": false,
|
||||||
|
"setLayout": "f7-swipe"
|
||||||
|
},
|
||||||
|
"oldRaw": "{\"playlists\":[],\"history\":[],\"queue\":[],\"customVideos\":[],\"pinnedPlaylists\":[],\"settings\":{\"quality\":\"360p\",\"volume\":1,\"audioOnly\":false,\"autoPreload\":true,\"p2pShare\":true,\"p2pReceive\":true,\"saveBeforePlay\":true,\"repeatMode\":\"all\",\"loopOne\":true,\"shuffle\":false,\"theme\":\"light\",\"fontScale\":\"large\",\"levelVolume\":false,\"liveAudioIOS\":false,\"parallelSaves\":2,\"density\":\"compact\",\"perfMode\":true,\"reduceMotion\":true,\"layout\":\"f7-swipe\",\"autoBackupEnabled\":false,\"autoBackupIntervalDays\":7,\"serviceVideo\":false,\"foldedCards\":{},\"eq\":{\"preset\":\"bass\",\"g\":[5,7,5,3,1,0,0,0,0,0]}},\"resumePositions\":{},\"rememberPos\":{},\"playCount\":{},\"abMarkers\":{},\"bookmarks\":{},\"lyricOffsets\":{},\"stats\":null,\"lastAutoBackup\":0,\"profile\":null}",
|
||||||
|
"newTag": "7760d5727244",
|
||||||
|
"upgraded": {
|
||||||
|
"quality": "360p",
|
||||||
|
"volume": 1,
|
||||||
|
"audioOnly": false,
|
||||||
|
"autoPreload": true,
|
||||||
|
"p2pShare": true,
|
||||||
|
"p2pReceive": true,
|
||||||
|
"saveBeforePlay": true,
|
||||||
|
"repeatMode": "all",
|
||||||
|
"loopOne": true,
|
||||||
|
"shuffle": false,
|
||||||
|
"theme": "light",
|
||||||
|
"fontScale": "large",
|
||||||
|
"levelVolume": false,
|
||||||
|
"liveAudioIOS": false,
|
||||||
|
"parallelSaves": 2,
|
||||||
|
"density": "compact",
|
||||||
|
"perfMode": true,
|
||||||
|
"reduceMotion": true,
|
||||||
|
"layout": "f7-swipe",
|
||||||
|
"autoBackupEnabled": false,
|
||||||
|
"autoBackupIntervalDays": 7,
|
||||||
|
"serviceVideo": false,
|
||||||
|
"foldedCards": {},
|
||||||
|
"eq": {
|
||||||
|
"preset": "bass",
|
||||||
|
"g": [
|
||||||
|
5,
|
||||||
|
7,
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"newRaw": "{\"playlists\":[],\"history\":[{\"id\":\"benchmedia01\",\"title\":\"Persisted settings local clip\",\"channel\":\"Settings probe\",\"channelId\":\"\",\"channelUrl\":\"\",\"duration\":3,\"thumbnail\":\"\"}],\"queue\":[],\"customVideos\":[],\"pinnedPlaylists\":[],\"resumePositions\":{},\"rememberPos\":{},\"playCount\":{\"benchmedia01\":1},\"abMarkers\":{},\"bookmarks\":{},\"lyricOffsets\":{},\"stats\":null,\"lastAutoBackup\":0,\"settings\":{\"quality\":\"360p\",\"volume\":1,\"audioOnly\":false,\"autoPreload\":true,\"p2pShare\":true,\"p2pReceive\":true,\"saveBeforePlay\":true,\"repeatMode\":\"all\",\"loopOne\":true,\"shuffle\":false,\"theme\":\"light\",\"fontScale\":\"large\",\"levelVolume\":false,\"liveAudioIOS\":false,\"parallelSaves\":2,\"density\":\"compact\",\"perfMode\":true,\"reduceMotion\":true,\"layout\":\"f7-swipe\",\"autoBackupEnabled\":false,\"autoBackupIntervalDays\":7,\"serviceVideo\":false,\"foldedCards\":{},\"eq\":{\"preset\":\"bass\",\"g\":[5,7,5,3,1,0,0,0,0,0]}},\"profile\":null}",
|
||||||
|
"newUI": {
|
||||||
|
"setSaveBeforePlay": true,
|
||||||
|
"setAutoPreload": true,
|
||||||
|
"setAudioOnly": false,
|
||||||
|
"setQuality": "360p",
|
||||||
|
"setRepeat": true,
|
||||||
|
"setLoopOne": true,
|
||||||
|
"setTheme": "light",
|
||||||
|
"setFont": "large",
|
||||||
|
"setDensity": "compact",
|
||||||
|
"setPerf": true,
|
||||||
|
"setMotion": true,
|
||||||
|
"setParallelSaves": "2",
|
||||||
|
"setLevelVolume": false,
|
||||||
|
"setLayout": "f7-swipe"
|
||||||
|
},
|
||||||
|
"importPreserved": true,
|
||||||
|
"userDataGets": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"engine": "webkit",
|
||||||
|
"errors": [],
|
||||||
|
"profileChecks": [
|
||||||
|
{
|
||||||
|
"stage": "older timestamp ignored",
|
||||||
|
"serverUpdatedAt": 100,
|
||||||
|
"localSyncedAt": 200,
|
||||||
|
"saveBeforePlay": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "older settings with newer timestamp adopted",
|
||||||
|
"serverUpdatedAt": 300,
|
||||||
|
"localSyncedAt": 200,
|
||||||
|
"saveBeforePlay": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"behaviour": [
|
||||||
|
{
|
||||||
|
"stage": "old",
|
||||||
|
"supported": false,
|
||||||
|
"note": "Native OPFS unavailable; no shim"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "upgraded",
|
||||||
|
"supported": false,
|
||||||
|
"note": "Native OPFS unavailable; no shim"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"diff": [
|
||||||
|
{
|
||||||
|
"key": "quality",
|
||||||
|
"before": "360p",
|
||||||
|
"after": "360p",
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "volume",
|
||||||
|
"before": 1,
|
||||||
|
"after": 1,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "audioOnly",
|
||||||
|
"before": false,
|
||||||
|
"after": false,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "autoPreload",
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "p2pShare",
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "p2pReceive",
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "saveBeforePlay",
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "repeatMode",
|
||||||
|
"before": "all",
|
||||||
|
"after": "all",
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "loopOne",
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "shuffle",
|
||||||
|
"before": false,
|
||||||
|
"after": false,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "theme",
|
||||||
|
"before": "light",
|
||||||
|
"after": "light",
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "fontScale",
|
||||||
|
"before": "large",
|
||||||
|
"after": "large",
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "levelVolume",
|
||||||
|
"before": false,
|
||||||
|
"after": false,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "liveAudioIOS",
|
||||||
|
"before": false,
|
||||||
|
"after": false,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "parallelSaves",
|
||||||
|
"before": 2,
|
||||||
|
"after": 2,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "density",
|
||||||
|
"before": "compact",
|
||||||
|
"after": "compact",
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "perfMode",
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "reduceMotion",
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "layout",
|
||||||
|
"before": "f7-swipe",
|
||||||
|
"after": "f7-swipe",
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "autoBackupEnabled",
|
||||||
|
"before": false,
|
||||||
|
"after": false,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "autoBackupIntervalDays",
|
||||||
|
"before": 7,
|
||||||
|
"after": 7,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "serviceVideo",
|
||||||
|
"before": false,
|
||||||
|
"after": false,
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "foldedCards",
|
||||||
|
"before": {},
|
||||||
|
"after": {},
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "eq",
|
||||||
|
"before": {
|
||||||
|
"preset": "bass",
|
||||||
|
"g": [
|
||||||
|
5,
|
||||||
|
7,
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"preset": "bass",
|
||||||
|
"g": [
|
||||||
|
5,
|
||||||
|
7,
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"equal": true,
|
||||||
|
"expectedMigration": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"old": {
|
||||||
|
"quality": "360p",
|
||||||
|
"volume": 1,
|
||||||
|
"audioOnly": false,
|
||||||
|
"autoPreload": true,
|
||||||
|
"p2pShare": true,
|
||||||
|
"p2pReceive": true,
|
||||||
|
"saveBeforePlay": true,
|
||||||
|
"repeatMode": "all",
|
||||||
|
"loopOne": true,
|
||||||
|
"shuffle": false,
|
||||||
|
"theme": "light",
|
||||||
|
"fontScale": "large",
|
||||||
|
"levelVolume": false,
|
||||||
|
"liveAudioIOS": false,
|
||||||
|
"parallelSaves": 2,
|
||||||
|
"density": "compact",
|
||||||
|
"perfMode": true,
|
||||||
|
"reduceMotion": true,
|
||||||
|
"layout": "f7-swipe",
|
||||||
|
"autoBackupEnabled": false,
|
||||||
|
"autoBackupIntervalDays": 7,
|
||||||
|
"serviceVideo": false,
|
||||||
|
"foldedCards": {},
|
||||||
|
"eq": {
|
||||||
|
"preset": "bass",
|
||||||
|
"g": [
|
||||||
|
5,
|
||||||
|
7,
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oldUI": {
|
||||||
|
"setSaveBeforePlay": true,
|
||||||
|
"setAutoPreload": true,
|
||||||
|
"setAudioOnly": false,
|
||||||
|
"setQuality": "360p",
|
||||||
|
"setRepeat": true,
|
||||||
|
"setLoopOne": true,
|
||||||
|
"setTheme": "light",
|
||||||
|
"setFont": "large",
|
||||||
|
"setDensity": "compact",
|
||||||
|
"setPerf": true,
|
||||||
|
"setMotion": true,
|
||||||
|
"setParallelSaves": "2",
|
||||||
|
"setLevelVolume": false,
|
||||||
|
"setLayout": "f7-swipe"
|
||||||
|
},
|
||||||
|
"oldRaw": "{\"playlists\":[],\"history\":[],\"queue\":[],\"customVideos\":[],\"pinnedPlaylists\":[],\"settings\":{\"quality\":\"360p\",\"volume\":1,\"audioOnly\":false,\"autoPreload\":true,\"p2pShare\":true,\"p2pReceive\":true,\"saveBeforePlay\":true,\"repeatMode\":\"all\",\"loopOne\":true,\"shuffle\":false,\"theme\":\"light\",\"fontScale\":\"large\",\"levelVolume\":false,\"liveAudioIOS\":false,\"parallelSaves\":2,\"density\":\"compact\",\"perfMode\":true,\"reduceMotion\":true,\"layout\":\"f7-swipe\",\"autoBackupEnabled\":false,\"autoBackupIntervalDays\":7,\"serviceVideo\":false,\"foldedCards\":{},\"eq\":{\"preset\":\"bass\",\"g\":[5,7,5,3,1,0,0,0,0,0]}},\"resumePositions\":{},\"rememberPos\":{},\"playCount\":{},\"abMarkers\":{},\"bookmarks\":{},\"lyricOffsets\":{},\"stats\":null,\"lastAutoBackup\":0,\"profile\":null}",
|
||||||
|
"newTag": "7760d5727244",
|
||||||
|
"upgraded": {
|
||||||
|
"quality": "360p",
|
||||||
|
"volume": 1,
|
||||||
|
"audioOnly": false,
|
||||||
|
"autoPreload": true,
|
||||||
|
"p2pShare": true,
|
||||||
|
"p2pReceive": true,
|
||||||
|
"saveBeforePlay": true,
|
||||||
|
"repeatMode": "all",
|
||||||
|
"loopOne": true,
|
||||||
|
"shuffle": false,
|
||||||
|
"theme": "light",
|
||||||
|
"fontScale": "large",
|
||||||
|
"levelVolume": false,
|
||||||
|
"liveAudioIOS": false,
|
||||||
|
"parallelSaves": 2,
|
||||||
|
"density": "compact",
|
||||||
|
"perfMode": true,
|
||||||
|
"reduceMotion": true,
|
||||||
|
"layout": "f7-swipe",
|
||||||
|
"autoBackupEnabled": false,
|
||||||
|
"autoBackupIntervalDays": 7,
|
||||||
|
"serviceVideo": false,
|
||||||
|
"foldedCards": {},
|
||||||
|
"eq": {
|
||||||
|
"preset": "bass",
|
||||||
|
"g": [
|
||||||
|
5,
|
||||||
|
7,
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"newRaw": "{\"playlists\":[],\"history\":[],\"queue\":[],\"customVideos\":[],\"pinnedPlaylists\":[],\"settings\":{\"quality\":\"360p\",\"volume\":1,\"audioOnly\":false,\"autoPreload\":true,\"p2pShare\":true,\"p2pReceive\":true,\"saveBeforePlay\":true,\"repeatMode\":\"all\",\"loopOne\":true,\"shuffle\":false,\"theme\":\"light\",\"fontScale\":\"large\",\"levelVolume\":false,\"liveAudioIOS\":false,\"parallelSaves\":2,\"density\":\"compact\",\"perfMode\":true,\"reduceMotion\":true,\"layout\":\"f7-swipe\",\"autoBackupEnabled\":false,\"autoBackupIntervalDays\":7,\"serviceVideo\":false,\"foldedCards\":{},\"eq\":{\"preset\":\"bass\",\"g\":[5,7,5,3,1,0,0,0,0,0]}},\"resumePositions\":{},\"rememberPos\":{},\"playCount\":{},\"abMarkers\":{},\"bookmarks\":{},\"lyricOffsets\":{},\"stats\":null,\"lastAutoBackup\":0,\"profile\":null}",
|
||||||
|
"newUI": {
|
||||||
|
"setSaveBeforePlay": true,
|
||||||
|
"setAutoPreload": true,
|
||||||
|
"setAudioOnly": false,
|
||||||
|
"setQuality": "360p",
|
||||||
|
"setRepeat": true,
|
||||||
|
"setLoopOne": true,
|
||||||
|
"setTheme": "light",
|
||||||
|
"setFont": "large",
|
||||||
|
"setDensity": "compact",
|
||||||
|
"setPerf": true,
|
||||||
|
"setMotion": true,
|
||||||
|
"setParallelSaves": "2",
|
||||||
|
"setLevelVolume": false,
|
||||||
|
"setLayout": "f7-swipe"
|
||||||
|
},
|
||||||
|
"importPreserved": true,
|
||||||
|
"userDataGets": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"failures": [],
|
||||||
|
"unsupported": [
|
||||||
|
"webkit",
|
||||||
|
"webkit"
|
||||||
|
]
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user