Merge main into jamesdsp and register its assets
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#0a0c12" />
|
||||
<title>Lyrics Studio</title>
|
||||
<link rel="stylesheet" href="download-actions.css" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
@@ -193,7 +194,7 @@
|
||||
.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.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 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); }
|
||||
@@ -312,7 +313,9 @@
|
||||
main { padding-top: 16px; }
|
||||
}
|
||||
@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>
|
||||
<body>
|
||||
|
||||
@@ -382,7 +385,7 @@
|
||||
<canvas id="wave" height="64"></canvas>
|
||||
<div class="transport">
|
||||
<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>
|
||||
<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">
|
||||
@@ -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>
|
||||
<button class="btn sm" id="shiftBtn" type="button">Apply</button>
|
||||
<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>
|
||||
<label><input type="checkbox" id="follow" checked /> Follow playback</label>
|
||||
</div>
|
||||
@@ -508,7 +511,7 @@
|
||||
<div class="card">
|
||||
<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>
|
||||
<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>
|
||||
<pre id="revView" class="hidden" style="margin-top:12px"></pre>
|
||||
</div>
|
||||
@@ -569,6 +572,7 @@
|
||||
<nav class="nav" id="tabsBottom" role="tablist"></nav>
|
||||
</div>
|
||||
|
||||
<script src="download-actions.js"></script>
|
||||
<script src="lyrics-core.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
@@ -1169,8 +1173,8 @@
|
||||
const a = new Audio(url); a.preload = 'auto';
|
||||
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('play', () => { $('playIcon').setAttribute('d', 'M7 5h4v14H7zM13 5h4v14h-4z'); });
|
||||
a.addEventListener('pause', () => { $('playIcon').setAttribute('d', 'M8 5v14l11-7z'); });
|
||||
a.addEventListener('play', () => { TransportIcons.set($('bPlay'),'pause'); });
|
||||
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.playbackRate = Number($('rate').value);
|
||||
LE.audio = a;
|
||||
@@ -1196,7 +1200,7 @@
|
||||
<div class="ln-tools">
|
||||
<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="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>
|
||||
<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);
|
||||
});
|
||||
84
frontend/app-seams.test.js
Normal file
84
frontend/app-seams.test.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const {readFileSync}=require('node:fs');
|
||||
const {createHash}=require('node:crypto');
|
||||
const {join}=require('node:path');
|
||||
const app=readFileSync(join(__dirname,'app.js'),'utf8');
|
||||
test('Phase 4 preserves the exact playback, continuity, tracking and queue bodies',()=>{
|
||||
{const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"373df46f4f994717472d26a6bac07449f6ca0a930cb666bfe3895d1dc65aa351","Player body unchanged");}
|
||||
{const start=app.indexOf("const Transition = (() => {");assert.ok(start>=0,"Transition");const body=app.slice(start,start+8588);assert.equal(createHash('sha256').update(body).digest('hex'),"71997a537078308a41f0cbf8e29fd1658fb3a199c4f5a25d8af0931f1c2a6d50","Transition body unchanged");}
|
||||
{const start=app.indexOf("const Wave = (() => {");assert.ok(start>=0,"Wave");const body=app.slice(start,start+5241);assert.equal(createHash('sha256').update(body).digest('hex'),"7252f4e856c59aef8aabfcd394ce4bcba656110cbd01a1a8af662eeb48166a3c","Wave body unchanged");}
|
||||
{const start=app.indexOf("const StatsTrack = (() => {");assert.ok(start>=0,"StatsTrack");const body=app.slice(start,start+1462);assert.equal(createHash('sha256').update(body).digest('hex'),"9aae2631ba3bfad918319579b7120814af06c84949394d04e52c65345332d514","StatsTrack body unchanged");}
|
||||
{const start=app.indexOf("function wirePlayerEvents() {");assert.ok(start>=0,"wirePlayerEvents");const body=app.slice(start,start+4913);assert.equal(createHash('sha256').update(body).digest('hex'),"1c5a2b5ce310f6aef6764325eda85a95f47e4f507f2049036ce1da206379908c","wirePlayerEvents body unchanged");}
|
||||
{const start=app.indexOf("function persist() {");assert.ok(start>=0,"persist");const body=app.slice(start,start+147);assert.equal(createHash('sha256').update(body).digest('hex'),"01e5c5dbc80616266db09b3829dc45b66476a3256b1c169d8e44f4983b24ac45","persist body unchanged");}
|
||||
{const start=app.indexOf("function playFromList(list, index, source = '', { playFull = false } = {}) {");assert.ok(start>=0,"playFromList");const body=app.slice(start,start+401);assert.equal(createHash('sha256').update(body).digest('hex'),"f8009c9674b3478959997dca881fb7b9e1ab3708c98e5abdf1958d752e47ee41","playFromList body unchanged");}
|
||||
{const start=app.indexOf("function advanceQueue() {");assert.ok(start>=0,"advanceQueue");const body=app.slice(start,start+596);assert.equal(createHash('sha256').update(body).digest('hex'),"be405b9c163e3d41a949ef11f6022ef13115b04083e3b6ad21d97beba1f45ae6","advanceQueue body unchanged");}
|
||||
{const start=app.indexOf("function playNext() {");assert.ok(start>=0,"playNext");const body=app.slice(start,start+143);assert.equal(createHash('sha256').update(body).digest('hex'),"859d2ffd2e8c89a8d9c0dd0cf429fd403675452cedba2e34aa3dc72683d4f8c6","playNext body unchanged");}
|
||||
{const start=app.indexOf("function playPrev() {");assert.ok(start>=0,"playPrev");const body=app.slice(start,start+268);assert.equal(createHash('sha256').update(body).digest('hex'),"ece8d33bd03c8146a6a702534c8fed50ff606170685b08d730b19d9d3c68cf48","playPrev body unchanged");}
|
||||
{const start=app.indexOf("function onTrackEnded() {");assert.ok(start>=0,"onTrackEnded");const body=app.slice(start,start+702);assert.equal(createHash('sha256').update(body).digest('hex'),"d6e147238ad9656fa1c2335973b37597749bcfd02df1a66390b306fe1194ae0d","onTrackEnded body unchanged");}
|
||||
{const start=app.indexOf("function sleepFadeSec() {");assert.ok(start>=0,"sleepFadeSec");const body=app.slice(start,start+138);assert.equal(createHash('sha256').update(body).digest('hex'),"3f986c8cc474d8313476e9c209b9264e3724011ffaee887f47178bb5155a4d77","sleepFadeSec body unchanged");}
|
||||
{const start=app.indexOf("function applySleepFade(remaining) {");assert.ok(start>=0,"applySleepFade");const body=app.slice(start,start+346);assert.equal(createHash('sha256').update(body).digest('hex'),"9bd0fa697b271c6b80c128ff1d0a7b00afcb3ccf08f98b5c7e7dc86fd4329681","applySleepFade body unchanged");}
|
||||
{const start=app.indexOf("function startSleepTimer(minutes, { endOfSong = false } = {}) {");assert.ok(start>=0,"startSleepTimer");const body=app.slice(start,start+487);assert.equal(createHash('sha256').update(body).digest('hex'),"f0a93401ab90196096074cb607b28d52c5903e11dbdfb8ddb95114e143af2409","startSleepTimer body unchanged");}
|
||||
{const start=app.indexOf("function cancelSleepTimer() {");assert.ok(start>=0,"cancelSleepTimer");const body=app.slice(start,start+253);assert.equal(createHash('sha256').update(body).digest('hex'),"cf396ab758a70eede6af7444539a82ca15af801a9a57024dd5542067e9fa771a","cancelSleepTimer body unchanged");}
|
||||
{const start=app.indexOf("function finishSleep() {");assert.ok(start>=0,"finishSleep");const body=app.slice(start,start+662);assert.equal(createHash('sha256').update(body).digest('hex'),"7ee93bf760bad5f39f4644339963918c5b369b2acc554b8e561c1a1f070ac6fb","finishSleep body unchanged");}
|
||||
{const start=app.indexOf("const EQ = (() => {");assert.ok(start>=0,"EQ");const body=app.slice(start,start+7917);assert.equal(createHash('sha256').update(body).digest('hex'),"f0cba011c94f33e6fd0ab778021f4c97238262dfa0a8dd40bf3f17149c227528","EQ body unchanged");}
|
||||
{const start=app.indexOf("const EqRender = (() => {");assert.ok(start>=0,"EqRender");const body=app.slice(start,start+5912);assert.equal(createHash('sha256').update(body).digest('hex'),"f014dd41dafebc33f09e87be866074afd7a2443c5bb452103a435585353a828f","EqRender body unchanged");}
|
||||
{const start=app.indexOf("const Level = (() => {");assert.ok(start>=0,"Level");const body=app.slice(start,start+3892);assert.equal(createHash('sha256').update(body).digest('hex'),"e48ad7797384ac31bb8b864662fc2a546b04470b981042f016874ba750957226","Level body unchanged");}
|
||||
});
|
||||
|
||||
test('eager shell and Settings definitions load before app state and defer reading it',()=>{
|
||||
const html=readFileSync(join(__dirname,'index.html'),'utf8');
|
||||
for(const file of ['shell-core.js','views-core.js','section-rail.js']) {
|
||||
assert.ok(html.indexOf('src="'+file+'"')<=html.indexOf('src="section-rail.js"'));
|
||||
const vm=require('node:vm');const doc={readyState:'complete',addEventListener(){},createElement:()=>({}),head:{append(){}}};const globals={document:doc,Lazy:{manifest:{appCache:false},url:()=>'/app.js'},console};globals.window=globals;const context=vm.createContext(globals);
|
||||
// Evaluation before any data/$/els/Player declaration must be safe.
|
||||
vm.runInContext(readFileSync(join(__dirname,file),'utf8'),context);
|
||||
}
|
||||
const views=readFileSync(join(__dirname,'views-core.js'),'utf8');
|
||||
assert.ok(views.includes('async function renderSettings()'));
|
||||
});
|
||||
|
||||
test('remote facade preserves synchronous no-session and loaded changed hooks',()=>{
|
||||
const source=app.match(/\/\* Remote facade \*\/([\s\S]*?)\/\* End remote facade \*\//)?.[1];
|
||||
assert.ok(source,'remote facade is present');
|
||||
const context=require('node:vm').createContext({window:{},Lazy:{proxy:()=>({}),onLoad(){}}});
|
||||
require('node:vm').runInContext(source+';globalThis.api=Remote;',context);
|
||||
assert.equal(context.api.changed(),undefined);
|
||||
let calls=0;context.window.YT={Remote:{changed:()=>{calls++;return 'sync';}}};
|
||||
assert.equal(context.api.changed(),'sync');assert.equal(calls,1);
|
||||
});
|
||||
|
||||
test('watch-party facade keeps playback decisions synchronous before and after loading',()=>{
|
||||
const source=app.match(/\/\* Party facade \*\/([\s\S]*?)\/\* End party facade \*\//)?.[1];assert.ok(source);
|
||||
const context=require('node:vm').createContext({window:{},Lazy:{proxy:()=>({}),onLoad(){}}});
|
||||
require('node:vm').runInContext(source+';globalThis.api=Party;',context);
|
||||
for(const method of ['guestActive','intercept','nextDJ'])assert.equal(context.api[method]('next'),false);
|
||||
context.window.YT={Party:{guestActive:()=>true,intercept:action=>action==='next',nextDJ:()=>true}};
|
||||
for(const method of ['guestActive','intercept','nextDJ'])assert.equal(context.api[method]('next'),true);
|
||||
});
|
||||
|
||||
test('Share and External stay behind lazy entry proxies',()=>{
|
||||
assert.match(app,/const Share = Lazy\.proxy\('feature:share-external'/);
|
||||
assert.match(app,/const External = Lazy\.proxy\('feature:share-external'/);
|
||||
const chunk=readFileSync(join(__dirname,'share-external.js'),'utf8');
|
||||
const context=require('node:vm').createContext({window:{},navigator:{userAgent:''},location:{origin:'https://fixture.test'},isUploadId:()=>false});
|
||||
require('node:vm').runInContext(chunk,context);
|
||||
assert.equal(typeof context.window.YT.Share.open,'function');
|
||||
const text=context.window.YT.External.m3u([{id:'benchmedia01',title:'Fixture'}]);
|
||||
assert.match(text,/#EXTM3U/);assert.match(text,/benchmedia01/);
|
||||
});
|
||||
|
||||
test('listening tracking does not pull in the lazy Stats view',()=>{
|
||||
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||
assert.deepEqual(groups['feature:stats-view'].files,['/stats-view.js']);
|
||||
assert.deepEqual(groups['feature:stats-view'].dependencies,['feature:stats']);
|
||||
assert.deepEqual(groups['feature:stats'].files,['/stats-core.js']);
|
||||
assert.match(app,/Lazy\.proxy\('feature:stats-view', \['render'\], 'StatsView'\)/);
|
||||
});
|
||||
|
||||
test('an unavailable Stats chunk reports an error without rejecting the UI navigation',async()=>{
|
||||
const source=app.match(/function renderStats\(\) \{[\s\S]*?\n\}/)?.[0];assert.ok(source);
|
||||
const seen=[],context=require('node:vm').createContext({Lazy:{proxy:()=>({render:()=>Promise.reject(new Error('Unavailable fixture chunk'))})},toast:text=>seen.push(text)});
|
||||
require('node:vm').runInContext(source+';globalThis.render=renderStats;',context);
|
||||
await context.render();assert.deepEqual(seen,['Unavailable fixture chunk']);
|
||||
});
|
||||
3924
frontend/app.js
3924
frontend/app.js
File diff suppressed because it is too large
Load Diff
204
frontend/asset-sync-core.js
Normal file
204
frontend/asset-sync-core.js
Normal file
@@ -0,0 +1,204 @@
|
||||
/* Exact, resumable asset synchronization shared by pages and workers. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const CACHE = 'ytplayer-assets';
|
||||
const STATE = '/__ytp_asset_state';
|
||||
const url = (path, file) => path + '?v=' + file.h;
|
||||
|
||||
function paths(manifest) {
|
||||
return Object.keys(manifest.files).sort();
|
||||
}
|
||||
|
||||
function blocking(manifest, previous, activeLayout) {
|
||||
const names = new Set(Object.entries(manifest.groups)
|
||||
.filter(([name, group]) => name === 'core' || !group.background ||
|
||||
name === 'layout:' + activeLayout ||
|
||||
(previous?.groups[name] && previous.groups[name].contract !== group.contract))
|
||||
.map(([name]) => name));
|
||||
for (const name of names) for (const dependency of manifest.groups[name].dependencies || []) names.add(dependency);
|
||||
return [...new Set([...names].flatMap(name => manifest.groups[name].files))].sort();
|
||||
}
|
||||
|
||||
function plan(manifest, keys) {
|
||||
const held = new Set(keys);
|
||||
return {
|
||||
missing: paths(manifest).map(path => url(path, manifest.files[path])).filter(key => !held.has(key)),
|
||||
blocking: blocking(manifest).map(path => url(path, manifest.files[path])),
|
||||
};
|
||||
}
|
||||
|
||||
function retained(current, previous) {
|
||||
const currentPaths = new Set(paths(current));
|
||||
const keep = new Set([...currentPaths].map(path => url(path, current.files[path])));
|
||||
if (previous) for (const path of paths(previous)) {
|
||||
if (!currentPaths.has(path) || current.files[path]?.h !== previous.files[path].h) keep.add(url(path, previous.files[path]));
|
||||
}
|
||||
return keep;
|
||||
}
|
||||
|
||||
function fallback(manifest, previous, path) {
|
||||
if (!manifest.files[path] || !previous?.files[path]) return null;
|
||||
const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path));
|
||||
if (!group || !previous.groups[group]?.files.includes(path) ||
|
||||
manifest.groups[group].contract !== previous.groups[group].contract) return null;
|
||||
// A stale core must understand every group contract in the new shell.
|
||||
if (path === "/app.js" && Object.entries(manifest.groups).some(([name, entry]) =>
|
||||
previous.groups[name]?.contract !== entry.contract)) return null;
|
||||
return url(path, previous.files[path]);
|
||||
}
|
||||
|
||||
async function state(cache) {
|
||||
const response = await cache.match(STATE);
|
||||
return response ? response.json() : null;
|
||||
}
|
||||
|
||||
function verifiedProbe(response, hash) {
|
||||
if (!response) return false;
|
||||
const valid = response.headers.get('X-Asset-Hash') === hash;
|
||||
// Metadata checks must release Blob/data-pipe readers. They never consume
|
||||
// asset bytes, and cancelling this response does not remove its cache entry.
|
||||
response.body?.cancel().catch(() => {});
|
||||
return valid;
|
||||
}
|
||||
|
||||
async function status(manifest, cache, { previous, activeLayout, offline = true } = {}) {
|
||||
const found = await Promise.all(blocking(manifest, previous, activeLayout).map(async path => {
|
||||
const response = await cache.match(url(path, manifest.files[path]));
|
||||
return verifiedProbe(response, manifest.files[path].h);
|
||||
}));
|
||||
const missing = found.filter(value => !value).length;
|
||||
return { ready: missing === 0, missing, version: manifest.buildTag, ...(offline ? await completeness(manifest, cache) : {}) };
|
||||
}
|
||||
|
||||
// All concurrent messages/layout jobs in this worker share six download slots.
|
||||
let downloads = 0;
|
||||
const waiters = [];
|
||||
async function downloadSlot(task) {
|
||||
if (downloads >= 6) await new Promise(resolve => waiters.push(resolve));
|
||||
else downloads++;
|
||||
try { return await task(); }
|
||||
finally { const next = waiters.shift(); if (next) next(); else downloads--; }
|
||||
}
|
||||
|
||||
const pendingDownloads = new Map();
|
||||
async function sharedDownload(key, task) {
|
||||
while (pendingDownloads.has(key)) await pendingDownloads.get(key).catch(() => {});
|
||||
const promise = downloadSlot(task);
|
||||
pendingDownloads.set(key, promise);
|
||||
try { return await promise; }
|
||||
finally { pendingDownloads.delete(key); }
|
||||
}
|
||||
|
||||
async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups, all = false }) {
|
||||
const previous = (await state(cache))?.current;
|
||||
const missing = [];
|
||||
const selected = all ? paths(manifest) : groups ? [...new Set(groups.flatMap(name => manifest.groups[name]?.files || []))].sort()
|
||||
: blocking(manifest, previous, activeLayout);
|
||||
for (const path of selected) {
|
||||
const key = url(path, manifest.files[path]);
|
||||
const response = await cache.match(key);
|
||||
if (!verifiedProbe(response, manifest.files[path].h)) missing.push(key);
|
||||
}
|
||||
const count = missing.length;
|
||||
let failure;
|
||||
await Promise.all(Array.from({ length: Math.max(1, Math.min(6, concurrency, count)) }, async () => {
|
||||
while (missing.length) {
|
||||
const key = missing.shift();
|
||||
try { await sharedDownload(key, async () => {
|
||||
const held = await cache.match(key);
|
||||
if (verifiedProbe(held, key.split('v=')[1])) return;
|
||||
let error;
|
||||
for (let attempt = 0; attempt < Math.min(3, attempts); attempt++) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 30000);
|
||||
try {
|
||||
const response = await fetchFn(key, { credentials: 'same-origin', signal: controller.signal });
|
||||
if (!response.ok || response.headers.get('X-Asset-Hash') !== key.split('v=')[1]) {
|
||||
throw new Error('Asset hash mismatch: ' + key);
|
||||
}
|
||||
// Keep the timeout through body consumption, not just response headers.
|
||||
await cache.put(key, response);
|
||||
error = null;
|
||||
break;
|
||||
} catch (err) { error = err; }
|
||||
finally { clearTimeout(timer); }
|
||||
}
|
||||
if (error) throw error;
|
||||
}); } catch (error) { failure = error; }
|
||||
}
|
||||
}));
|
||||
if (failure) throw failure;
|
||||
if (!groups && !all && !(await status(manifest, cache, { previous, activeLayout, offline: false })).ready) throw new Error('Incomplete blocking assets');
|
||||
return { refreshed: count, caches: 1 };
|
||||
}
|
||||
|
||||
|
||||
// Verified cache entries are the durable progress journal. Counters are rebuilt
|
||||
// after worker termination or browser eviction; never trust a stored ready bit.
|
||||
async function completeness(manifest, cache) {
|
||||
const files = paths(manifest);
|
||||
const missingFiles = (await Promise.all(files.map(async path => {
|
||||
const response = await cache.match(url(path, manifest.files[path]));
|
||||
return verifiedProbe(response, manifest.files[path].h) ? null : path;
|
||||
}))).filter(Boolean);
|
||||
const total = files.length;
|
||||
return { offlineReady: missingFiles.length === 0, cached: total - missingFiles.length, total, missingFiles };
|
||||
}
|
||||
|
||||
const completionKey = manifest => '/__ytp_completion/' + manifest.buildTag;
|
||||
async function complete(manifest, { cache, fetchFn, cycles = 4, now = Date.now,
|
||||
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), baseDelay = 1000,
|
||||
maxDelay = 30000, notify = () => {}, force = false } = {}) {
|
||||
const key = completionKey(manifest), saved = await cache.match(key);
|
||||
let job = saved ? await saved.json() : { failures: 0, nextRetryAt: 0 };
|
||||
let snapshot = await completeness(manifest, cache);
|
||||
const publish = async running => {
|
||||
job = { ...job, ...snapshot, running, version: manifest.buildTag };
|
||||
await cache.put(key, Response.json(job));
|
||||
await notify(job);
|
||||
};
|
||||
if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; await publish(false); return job; }
|
||||
if (!force && job.nextRetryAt > now()) await sleep(Math.min(maxDelay, job.nextRetryAt - now()));
|
||||
for (let cycle = 0; cycle < cycles; cycle++) {
|
||||
await publish(true);
|
||||
let error;
|
||||
try { await syncAssets(manifest, { cache, fetchFn, all: true }); }
|
||||
catch (failure) { error = failure; }
|
||||
snapshot = await completeness(manifest, cache);
|
||||
if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; job.error = null; await publish(false); return job; }
|
||||
job.failures++;
|
||||
const delay = Math.min(maxDelay, baseDelay * 2 ** Math.min(job.failures - 1, 10));
|
||||
job.nextRetryAt = now() + delay;
|
||||
job.error = String(error?.message || 'Incomplete offline cache');
|
||||
await publish(cycle + 1 < cycles);
|
||||
if (cycle + 1 < cycles) await sleep(delay);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
async function commit(manifest, cache, { previousClients, activeLayout } = {}) {
|
||||
const old = await state(cache);
|
||||
if (!(await status(manifest, cache, { previous: old?.current, activeLayout, offline: false })).ready) {
|
||||
throw new Error('Incomplete blocking assets');
|
||||
}
|
||||
const sameBuild = old?.current?.buildTag === manifest.buildTag;
|
||||
const previous = sameBuild ? old.previous : old?.current;
|
||||
const next = {
|
||||
current: manifest, previous,
|
||||
previousClients: previousClients || (sameBuild ? old.previousClients : []),
|
||||
};
|
||||
// One state write publishes the complete build and its old-tab affinity.
|
||||
await cache.put(STATE, new Response(JSON.stringify(next), { headers: { 'Content-Type': 'application/json' } }));
|
||||
const keep = retained(manifest, previous);
|
||||
for (const request of await cache.keys()) {
|
||||
const parsed = new URL(request.url);
|
||||
const key = parsed.pathname + parsed.search;
|
||||
if (parsed.searchParams.has('v') && !keep.has(key)) await cache.delete(key);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
const api = { CACHE, STATE, url, paths, blocking, plan, retained, fallback, status, completeness, completionKey, complete, syncAssets, state, commit };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.AssetSyncCore = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
104
frontend/asset-sync-core.test.js
Normal file
104
frontend/asset-sync-core.test.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const core=require('./asset-sync-core');
|
||||
const manifest=(a='a',b='b')=>({buildTag:a+b,files:{'/a.js':{h:a},'/index.html':{h:b}},groups:{core:{files:['/a.js','/index.html']}}});
|
||||
function cache(){const entries=new Map();return {entries,async match(k){return entries.get(k)?.clone()},async put(k,r){entries.set(k,r.clone())},async keys(){return [...entries.keys()].map(url=>({url:'http://local'+url}))},async delete(k){return entries.delete(k)}}}
|
||||
const response=h=>new Response(h,{headers:{'X-Asset-Hash':h}});
|
||||
test('exact diff shares unchanged URLs; retains N-1 and prunes N-2',()=>{assert.deepEqual(core.plan(manifest(),['/a.js?v=a']).missing,['/index.html?v=b']);assert.deepEqual([...core.retained(manifest('c'),manifest())].sort(),['/a.js?v=a','/a.js?v=c','/index.html?v=b']);});
|
||||
test('interrupted sync resumes verified files and refuses a mismatched hash',async()=>{const c=cache();let fail=true;let calls=[];const fetchFn=async u=>{calls.push(u);if(u.includes('index')&&fail)return response('wrong');return response(u.split('=')[1])};await assert.rejects(core.syncAssets(manifest(),{cache:c,fetchFn}),/hash/);assert.ok(await c.match('/a.js?v=a'));fail=false;calls=[];await core.syncAssets(manifest(),{cache:c,fetchFn});assert.deepEqual(calls,['/index.html?v=b']);await c.delete('/a.js?v=a');assert.equal((await core.status(manifest(),c)).missing,1);});
|
||||
test('commit is atomic and pruning keeps only current and previous changed versions',async()=>{const c=cache();await c.put('/a.js?v=z',response('z'));await core.syncAssets(manifest(),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest(),c);await core.syncAssets(manifest('c'),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest('c'),c);assert.ok(await c.match('/a.js?v=a'));assert.equal(await c.match('/a.js?v=z'),undefined);await core.syncAssets(manifest('d'),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest('d'),c);assert.equal(await c.match('/a.js?v=a'),undefined);});
|
||||
test('downloads are capped at six and each failure gets three attempts',async()=>{const c=cache(),files={},list=[];for(let i=0;i<13;i++){const p='/'+i+'.js';files[p]={h:String(i)};list.push(p)}const m={buildTag:'pool',files,groups:{core:{files:list}}};let active=0,max=0;const tries={};await core.syncAssets(m,{cache:c,concurrency:20,fetchFn:async u=>{active++;max=Math.max(max,active);await new Promise(r=>setTimeout(r,5));active--;tries[u]=(tries[u]||0)+1;if(tries[u]<3)throw Error('drop');return response(u.split('=')[1])}});assert.equal(max,6);assert.ok(Object.values(tries).every(n=>n===3));});
|
||||
test('background files do not block, but active layout and contract changes do',()=>{const m=manifest();m.groups.extra={background:true,contract:2,files:['/extra.js']};m.files['/extra.js']={h:'e'};assert.deepEqual(core.blocking(m),['/a.js','/index.html']);assert.ok(core.blocking(m,{groups:{extra:{contract:1}}}).includes('/extra.js'));m.groups['layout:classic']={background:true,files:['/classic.css']};m.files['/classic.css']={h:'c'};assert.ok(core.blocking(m,null,'classic').includes('/classic.css'));});
|
||||
test('removed files survive one previous build for open tabs',()=>{const previous=manifest(),current=manifest('c');delete current.files['/a.js'];current.groups.core.files=['/index.html'];assert.ok(core.retained(current,previous).has('/a.js?v=a'));});
|
||||
|
||||
test('staged fallback permits only the previous same-contract version of a shipped group file', () => {
|
||||
const old = manifest(), next = manifest('c');
|
||||
old.groups.core.contract = next.groups.core.contract = 1;
|
||||
assert.equal(core.fallback(next, old, '/a.js'), '/a.js?v=a');
|
||||
next.groups.core.contract = 2;
|
||||
assert.equal(core.fallback(next, old, '/a.js'), null);
|
||||
assert.equal(core.fallback(next, old, '/missing.js'), null);
|
||||
});
|
||||
|
||||
test('background synchronization fills all groups without publishing a new current manifest', async () => {
|
||||
const c = cache(), m = manifest();
|
||||
m.groups.optional = { contract: 1, background: true, files: ['/extra.js'] };
|
||||
m.files['/extra.js'] = { h: 'e' };
|
||||
const fetchFn = async key => response(key.split('=')[1]);
|
||||
await core.syncAssets(m, { cache: c, fetchFn });
|
||||
assert.equal(await c.match('/extra.js?v=e'), undefined);
|
||||
await core.commit(m, c);
|
||||
const before = await core.state(c);
|
||||
const result = await core.syncAssets(m, { cache: c, fetchFn, groups: ['optional'] });
|
||||
assert.equal(result.refreshed, 1);
|
||||
assert.ok(await c.match('/extra.js?v=e'));
|
||||
assert.deepEqual(await core.state(c), before);
|
||||
});
|
||||
|
||||
test('active layouts also block on shared dependencies, while other layouts stay background', () => {
|
||||
const m=manifest(); m.groups['layout:glass-stage']={contract:1,background:true,files:['/glass.css'],dependencies:['feature:controls']}; m.groups['feature:controls']={contract:1,background:true,files:['/controls.js']}; m.groups['layout:bento-hub']={contract:1,background:true,files:['/bento.css']};
|
||||
for(const path of ['/glass.css','/controls.js','/bento.css'])m.files[path]={h:'h'};
|
||||
assert.deepEqual(core.blocking(m,null,'glass-stage'),['/a.js','/controls.js','/glass.css','/index.html']);
|
||||
});
|
||||
|
||||
test('removing group membership retains the old tab URL even if the physical file is still shipped unchanged',()=>{
|
||||
const previous=manifest(),current=manifest();current.groups.core.files=['/index.html'];
|
||||
assert.ok(core.retained(current,previous).has('/a.js?v=a'));
|
||||
});
|
||||
|
||||
test('completion includes ungrouped runtime files and exposes verified missing paths', async () => {
|
||||
const c=cache(), m=manifest();m.files['/worker.js']={h:'w'};
|
||||
await core.syncAssets(m,{cache:c,fetchFn:async u=>response(u.split('=')[1])});
|
||||
const status=await core.status(m,c);
|
||||
assert.equal(status.ready,true);assert.equal(status.offlineReady,false);
|
||||
assert.deepEqual(status.missingFiles,['/worker.js']);assert.equal(status.cached,2);assert.equal(status.total,3);
|
||||
await core.complete(m,{cache:c,fetchFn:async u=>response(u.split('=')[1])});
|
||||
assert.equal((await core.status(m,c)).offlineReady,true);
|
||||
});
|
||||
|
||||
test('completion persists backoff and resumes only missing files after a worker restart',async()=>{
|
||||
const c=cache(),m=manifest();let clock=1000;const calls=[];
|
||||
await core.complete(m,{cache:c,cycles:1,now:()=>clock,baseDelay:10,fetchFn:async u=>{if(u.includes('index'))throw Error('offline');return response('a');}});
|
||||
const checkpoint=await(await c.match(core.completionKey(m))).json();
|
||||
assert.equal(checkpoint.running,false);assert.equal(checkpoint.failures,1);assert.equal(checkpoint.nextRetryAt,1010);
|
||||
await core.complete(m,{cache:c,now:()=>clock,sleep:async ms=>{assert.equal(ms,10);clock+=ms;},fetchFn:async u=>{calls.push(u);return response(u.split('=')[1]);}});
|
||||
assert.deepEqual(calls,['/index.html?v=b']);
|
||||
const status=await(await c.match(core.completionKey(m))).json();assert.equal(status.offlineReady,true);assert.equal(status.failures,0);
|
||||
await c.delete('/a.js?v=a');assert.equal((await core.status(m,c)).offlineReady,false);
|
||||
});
|
||||
|
||||
test('completion retries with exponential backoff and keeps making progress past a failed asset',async()=>{
|
||||
const c=cache(),m=manifest(),delays=[];let tries=0;
|
||||
const job=await core.complete(m,{cache:c,baseDelay:5,sleep:async ms=>delays.push(ms),fetchFn:async u=>{if(u.includes('a.js')&&++tries<=6)throw Error('drop');return response(u.split('=')[1]);}});
|
||||
assert.deepEqual(delays,[5,10]);assert.equal(job.offlineReady,true);assert.ok(await c.match('/index.html?v=b'));
|
||||
});
|
||||
|
||||
test('overlapping layout and completion jobs share a six-download ceiling',async()=>{
|
||||
const c=cache(),m=manifest();m.groups.extra={background:true,files:[]};
|
||||
for(let i=0;i<15;i++){const p='/extra'+i+'.js';m.files[p]={h:String(i)};m.groups.extra.files.push(p);}
|
||||
let active=0,peak=0;const calls=new Map();
|
||||
const fetchFn=async u=>{active++;peak=Math.max(active,peak);calls.set(u,(calls.get(u)||0)+1);await new Promise(r=>setTimeout(r,3));active--;return response(u.split('=')[1]);};
|
||||
await Promise.all([core.complete(m,{cache:c,fetchFn}),core.syncAssets(m,{cache:c,fetchFn,groups:['extra']})]);
|
||||
assert.ok(peak<=6);assert.ok([...calls.values()].every(count=>count===1));
|
||||
});
|
||||
|
||||
test('blocking commit never waits on an optional file readiness probe',{timeout:1000},async()=>{
|
||||
const c=cache(),m=manifest();m.files['/extra.js']={h:'extra'};m.groups.extra={files:['/extra.js'],background:true,contract:1};
|
||||
await c.put('/a.js?v=a',response('a'));await c.put('/index.html?v=b',response('b'));
|
||||
const match=c.match;let extraProbes=0;c.match=async key=>{if(key==='/extra.js?v=extra'){extraProbes++;return new Promise(()=>{});}return match(key);};
|
||||
await core.commit(m,c);assert.equal(extraProbes,0);assert.equal((await core.state(c)).current.buildTag,m.buildTag);
|
||||
c.match=match;assert.equal((await core.status(m,c)).offlineReady,false);
|
||||
});
|
||||
|
||||
test('metadata probes release response streams while stored asset bodies remain readable',async()=>{
|
||||
const c=cache(),m=manifest();await c.put('/a.js?v=a',response('a'));await c.put('/index.html?v=b',response('b'));
|
||||
const match=c.match,probes=[];c.match=async key=>{const r=await match(key);if(r)probes.push(r);return r;};
|
||||
assert.equal((await core.status(m,c)).offlineReady,true);assert.ok(probes.length>0);assert.ok(probes.every(r=>r.bodyUsed));
|
||||
assert.equal(await(await match('/a.js?v=a')).text(),'a');assert.equal(await(await match('/index.html?v=b')).text(),'b');
|
||||
});
|
||||
|
||||
test('stale app core requires every feature contract to match the new shell',()=>{
|
||||
const old={files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}};
|
||||
const next=structuredClone(old);next.files['/app.js'].h='new';next.groups.extra.contract=2;
|
||||
assert.equal(core.fallback(next,old,'/app.js'),null);next.groups.extra.contract=1;assert.equal(core.fallback(next,old,'/app.js'),'/app.js?v=old');
|
||||
});
|
||||
77
frontend/asset-worker.test.js
Normal file
77
frontend/asset-worker.test.js
Normal file
@@ -0,0 +1,77 @@
|
||||
const {test}=require('node:test');const assert=require('node:assert/strict');const vm=require('node:vm');const fs=require('node:fs');const core=require('./asset-sync-core');
|
||||
function environment(){
|
||||
const stores=new Map(),listeners={},messages=[],skips=[],fetches=[],selfMessages=[];const m={buildTag:'next',files:{'/index.html':{h:'index'},'/app.js':{h:'app'}},groups:{core:{files:['/index.html','/app.js']}}};
|
||||
const storage={async keys(){return [...stores.keys()]},async delete(n){return stores.delete(n)},async open(n){if(!stores.has(n)){const map=new Map();stores.set(n,{async match(k){return map.get(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)?.clone()},async put(k,r){map.set(k,r.clone())},async keys(){return [...map.keys()].map(k=>({url:'https://local'+k}))},async delete(k){return map.delete(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)}})}return stores.get(n)}};
|
||||
const sandbox={__BUILD_TAG__:'next',__ASSET_SYNC__:true,AssetSyncCore:core,importScripts:()=>{},caches:storage,URL,Response,Request,Headers,setTimeout,clearTimeout,crypto:require('node:crypto').webcrypto,console,fetch:async u=>{fetches.push(u);return u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':m.files[u.split('?')[0]]?.h || ''}})},self:{registration:{active:{postMessage:data=>selfMessages.push(data)}},location:{origin:'https://local'},addEventListener:(t,f)=>listeners[t]=f,skipWaiting:()=>skips.push(1),clients:{claim:async()=>{},matchAll:async()=>[{id:'old',postMessage:x=>messages.push(x)}]}}};
|
||||
vm.runInNewContext(fs.readFileSync(__dirname+'/sw.js','utf8'),sandbox);
|
||||
async function dispatch(t,e={}){const tasks=[];listeners[t]({...e,waitUntil:v=>tasks.push(v)});await Promise.all(tasks)}
|
||||
async function request(url,mode='cors',clientId='new'){let p;const tasks=[];listeners.fetch({request:{url:'https://local'+url,method:'GET',mode},clientId,waitUntil:v=>tasks.push(v),respondWith:v=>p=v});const response=await p;await Promise.all(tasks);return response;}
|
||||
return {storage,m,skips,fetches,dispatch,request,sandbox,selfMessages,async complete(){for(const data of selfMessages.splice(0))await dispatch('message',{data});}};
|
||||
}
|
||||
test('install does not activate or publish; CACHE_STATUS is honest; activation commits and exact requests self-heal',async()=>{const e=environment();await e.dispatch('install');assert.equal(e.skips.length,0);const cache=await e.storage.open(core.CACHE);assert.equal(await core.state(cache),null);let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);await e.dispatch('activate');await e.complete();assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await e.request('/playlist/x','navigate')).headers.get('X-Asset-Hash'),'index');await cache.delete('/app.js?v=app');assert.equal((await core.status(e.m,cache)).ready,false);await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal((await core.status(e.m,cache)).offlineReady,true);assert.equal(reply.version,'next');assert.equal((await e.request('/app.js')).headers.get('X-Asset-Hash'),'app');assert.ok(await cache.match('/app.js?v=app'));assert.equal(await cache.match('/app.js?v=stale'),undefined);assert.equal((await e.request('/app.js?v=stale')).status,409);});
|
||||
test('reported playback defers explicit activation until pause; install never calls skipWaiting',async()=>{const e=environment(),source={id:'p'};await e.dispatch('message',{data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:true}});await e.dispatch('message',{source,data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:false}});assert.equal(e.skips.length,1);});
|
||||
|
||||
test('staged same-contract responses use verified N-1 without poisoning the new URL',async()=>{
|
||||
const e=environment(); const cache=await e.storage.open(core.CACHE);
|
||||
const old={buildTag:'old',files:{'/extra.js':{h:'old'}},groups:{core:{files:[],contract:1},'feature:extra':{files:['/extra.js'],contract:1,background:true}}};
|
||||
e.m.groups.core.contract=1; e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true}; e.m.files['/extra.js']={h:'new'};
|
||||
await cache.put(core.STATE,Response.json({current:old})); await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old','Cache-Control':'public, max-age=31536000, immutable','Content-Encoding':'gzip','Content-Length':'9'}}));
|
||||
await e.dispatch('install'); await e.dispatch('activate');await e.complete();
|
||||
assert.ok(await cache.match('/extra.js?v=new'));await cache.delete('/extra.js?v=new');
|
||||
assert.equal(await cache.match('/extra.js?v=new'),undefined);
|
||||
const stale=await e.request('/extra.js?v=new'); assert.equal(await stale.text(),'old body');assert.equal(stale.headers.get('X-Asset-Hash'),'old');assert.equal(stale.headers.get('Cache-Control'),'no-store');assert.equal(stale.headers.get('Content-Encoding'),null);assert.equal(stale.headers.get('Content-Length'),null);assert.equal(await cache.match('/extra.js?v=new'),undefined);
|
||||
await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:true}}); assert.ok(await cache.match('/extra.js?v=new'));
|
||||
await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}); assert.equal((await cache.match('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
|
||||
assert.equal((await e.request('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
|
||||
});
|
||||
|
||||
test('a changed background contract is downloaded before ready and cannot fall back after commit',async()=>{
|
||||
const e=environment(),cache=await e.storage.open(core.CACHE);
|
||||
const old={buildTag:'old',files:{'/extra.js':{h:'old'}},groups:{core:{files:[],contract:1},'feature:extra':{files:['/extra.js'],contract:1,background:true}}};
|
||||
e.m.groups.core.contract=1;e.m.groups['feature:extra']={files:['/extra.js'],contract:2,background:true};e.m.files['/extra.js']={h:'new'};
|
||||
await cache.put(core.STATE,Response.json({current:old}));await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old'}}));
|
||||
await e.dispatch('install');assert.equal((await core.state(cache)).current.buildTag,'old');
|
||||
assert.equal((await cache.match('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
|
||||
let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);
|
||||
await e.dispatch('activate');await e.complete();await cache.delete('/extra.js?v=new');
|
||||
const response=await e.request('/extra.js?v=new');assert.equal(response.headers.get('X-Asset-Hash'),'new');assert.notEqual(await response.text(),'old body');
|
||||
});
|
||||
|
||||
test('overlapping idle warm messages share one download pool',async()=>{
|
||||
const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true};
|
||||
await e.dispatch('install');await e.dispatch('activate');await e.complete();
|
||||
await Promise.all([e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}),e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}})]);
|
||||
assert.equal(e.fetches.filter(url=>url==='/extra.js?v=extra').length,1);
|
||||
});
|
||||
|
||||
test('waiting status does not start completion and activation delegates it to its own event',async()=>{
|
||||
const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true};
|
||||
await e.dispatch('install');let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});
|
||||
assert.equal(reply.ready,true);assert.equal(reply.offlineReady,false);assert.ok(!e.fetches.includes('/extra.js?v=extra'));
|
||||
let release,started;const gate=new Promise(r=>release=r),begin=new Promise(r=>started=r),fetch=e.sandbox.fetch;
|
||||
e.sandbox.fetch=async url=>{if(url==='/extra.js?v=extra'){started();await gate;}return fetch(url);};
|
||||
await e.dispatch('activate');const cache=await e.storage.open(core.CACHE);
|
||||
assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await core.status(e.m,cache)).ready,true);
|
||||
assert.equal((await core.status(e.m,cache)).offlineReady,false);assert.equal(e.selfMessages[0].type,'COMPLETE_ASSETS');
|
||||
const job=e.complete();await begin;assert.equal(await cache.match('/extra.js?v=extra'),undefined);release();await job;
|
||||
assert.equal((await core.status(e.m,cache)).offlineReady,true);
|
||||
});
|
||||
|
||||
test('page-captured app is reused on first install without a worker download',async()=>{
|
||||
const e=environment(),cache=await e.storage.open(core.CACHE);
|
||||
await cache.put('/app.js?v=app',new Response('page captured app',{headers:{'X-Asset-Hash':'app'}}));
|
||||
await e.dispatch('install');assert.equal(e.fetches.includes('/app.js?v=app'),false);
|
||||
assert.equal(await(await cache.match('/app.js?v=app')).text(),'page captured app');
|
||||
assert.equal(e.skips.length,0);assert.equal(await core.state(cache),null);
|
||||
});
|
||||
|
||||
|
||||
test('worker never supplies stale app to a native script when a feature contract changed',async()=>{
|
||||
const e=environment(),cache=await e.storage.open(core.CACHE);
|
||||
const old={buildTag:'old',files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}};
|
||||
e.m.groups.core.contract=1;e.m.groups.extra={contract:2,background:true,files:[]};
|
||||
await cache.put(core.STATE,Response.json({current:old}));await cache.put('/app.js?v=old',new Response('old app',{headers:{'X-Asset-Hash':'old'}}));
|
||||
await e.dispatch('install');await e.dispatch('activate');await cache.delete('/app.js?v=app');
|
||||
e.sandbox.fetch=async()=>{throw Error('offline')};
|
||||
const response=await e.request('/app.js?v=app');assert.equal(response.status,503);assert.equal(await response.text(),'Offline');
|
||||
});
|
||||
377
frontend/assets.json
Normal file
377
frontend/assets.json
Normal file
@@ -0,0 +1,377 @@
|
||||
{
|
||||
"groups": {
|
||||
"core": {
|
||||
"contract": 2,
|
||||
"eager": true,
|
||||
"files": [
|
||||
"/index.html",
|
||||
"/styles.css",
|
||||
"/service-ui.css",
|
||||
"/refresh.css",
|
||||
"/layout-base.css",
|
||||
"/recommendations.css",
|
||||
"/flag-ui.js",
|
||||
"/async-guard.js",
|
||||
"/visible-timer.js",
|
||||
"/asset-sync-core.js",
|
||||
"/sw-update.js",
|
||||
"/fingerprint.js",
|
||||
"/opfs.js",
|
||||
"/opfs-worker.js",
|
||||
"/lyrics-core.js",
|
||||
"/lyrics-display.js",
|
||||
"/sha256.js",
|
||||
"/resume-core.js",
|
||||
"/loudness-wasm.js",
|
||||
"/loudness.js",
|
||||
"/sha256-wasm.js",
|
||||
"/settings-sections.js",
|
||||
"/related-videos.js",
|
||||
"/related-videos.css",
|
||||
"/audio-continuity.js",
|
||||
"/fullscreen-orientation.js",
|
||||
"/fullscreen-orientation.css",
|
||||
"/download-actions.js",
|
||||
"/download-actions.css",
|
||||
"/download-errors.js",
|
||||
"/downloads.js",
|
||||
"/downloads-page.js",
|
||||
"/offline-pages.css",
|
||||
"/saved-page.js",
|
||||
"/server-backfill.js",
|
||||
"/saved-page.css",
|
||||
"/prewarm-next.js",
|
||||
"/settings-sections.css",
|
||||
"/app.js",
|
||||
"/manifest.webmanifest",
|
||||
"/icons/icon-192.png",
|
||||
"/icons/icon-512.png",
|
||||
"/fonts/fonts.css",
|
||||
"/fonts/BricolageGrotesque-latin-ext.woff2",
|
||||
"/fonts/BricolageGrotesque-latin.woff2",
|
||||
"/fonts/HankenGrotesk-latin-ext.woff2",
|
||||
"/fonts/HankenGrotesk-latin.woff2",
|
||||
"/fonts/JetBrainsMono-latin-ext.woff2",
|
||||
"/fonts/JetBrainsMono-latin.woff2",
|
||||
"/lazy.js",
|
||||
"/feature-bootstrap.js",
|
||||
"/eq-parameters.js",
|
||||
"/assets.json",
|
||||
"/device-db.js",
|
||||
"/shell-core.js",
|
||||
"/views-core.js",
|
||||
"/section-rail.js"
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"layout:glass-stage": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/theme-glass.css",
|
||||
"/theme-glass-panel.css",
|
||||
"/glass-panel-layout.js"
|
||||
],
|
||||
"background": true,
|
||||
"dependencies": [
|
||||
"feature:layout-controls"
|
||||
]
|
||||
},
|
||||
"layout:classic": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/theme-classic.css"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"layout:bento-hub": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/theme-bento-hub.css",
|
||||
"/bento-hub.js",
|
||||
"/fonts/PlusJakartaSans-latin.woff2",
|
||||
"/fonts/PlusJakartaSans-latin-ext.woff2"
|
||||
],
|
||||
"background": true,
|
||||
"dependencies": [
|
||||
"feature:layout-controls"
|
||||
]
|
||||
},
|
||||
"layout:f7-swipe": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/theme-f7.css",
|
||||
"/vendor/framework7-swipe.min.js",
|
||||
"/f7-layout.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:piano": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/piano-core.js",
|
||||
"/piano.js",
|
||||
"/piano-engine.mjs"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:midi": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/midi.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:party-dj": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/party-dj.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:lyrics-window": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/lyrics-window.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:lower-third": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/lower-third.js",
|
||||
"/lower-third.css"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:vocal-reducer": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/vocal-reducer.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:eq-core": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/eq-core.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:setlist-import": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/setlist-import.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:car-mode": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/car-mode.js",
|
||||
"/car-mode.css"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:p2p": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/p2p-core.js",
|
||||
"/p2p-client.js",
|
||||
"/p2p-transfer.js",
|
||||
"/p2p-recv-worker.js",
|
||||
"/hash-worker.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:direct": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/direct-protocol.js",
|
||||
"/direct-stream.js",
|
||||
"/direct-media.js",
|
||||
"/direct-media.css",
|
||||
"/direct-recv-worker.js"
|
||||
],
|
||||
"background": true,
|
||||
"dependencies": [
|
||||
"feature:p2p"
|
||||
]
|
||||
},
|
||||
"feature:video-edit": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/video-edit.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:stats": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/stats-core.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:export": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/export.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:playlist": {
|
||||
"contract": 1,
|
||||
"eager": true,
|
||||
"files": [],
|
||||
"embeddedIn": [
|
||||
"/app.js"
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"feature:settings": {
|
||||
"contract": 1,
|
||||
"eager": true,
|
||||
"files": [],
|
||||
"embeddedIn": [
|
||||
"/app.js",
|
||||
"/settings-sections.js",
|
||||
"/settings-sections.css",
|
||||
"/views-core.js"
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"feature:service-mode": {
|
||||
"contract": 1,
|
||||
"eager": true,
|
||||
"files": [],
|
||||
"embeddedIn": [
|
||||
"/app.js",
|
||||
"/service-ui.css"
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"feature:lyrics-editor": {
|
||||
"contract": 1,
|
||||
"eager": true,
|
||||
"files": [],
|
||||
"embeddedIn": [
|
||||
"/app.js",
|
||||
"/lyrics-core.js",
|
||||
"/lyrics-display.js"
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"feature:layout-controls": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/theme-glass-controls.css",
|
||||
"/glass-controls.js"
|
||||
]
|
||||
},
|
||||
"layout:sanctuary-stage": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
},
|
||||
"layout:liturgical": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
},
|
||||
"layout:living-hymnal": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
},
|
||||
"layout:broadcaster": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
},
|
||||
"layout:contemplative": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
},
|
||||
"feature:presenter": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/presenter.js"
|
||||
]
|
||||
},
|
||||
"feature:remote-client": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/remote-client.js"
|
||||
]
|
||||
},
|
||||
"feature:watch-party": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/watch-party-client.js"
|
||||
]
|
||||
},
|
||||
"feature:share-external": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/share-external.js"
|
||||
]
|
||||
},
|
||||
"feature:stats-view": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/stats-view.js"
|
||||
],
|
||||
"dependencies": [
|
||||
"feature:stats"
|
||||
]
|
||||
},
|
||||
"feature:jamesdsp": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/vendor/jamesdsp/controller.js",
|
||||
"/vendor/jamesdsp/jamesdsp-node.js",
|
||||
"/vendor/jamesdsp/worklet.js",
|
||||
"/vendor/jamesdsp/params.js",
|
||||
"/vendor/jamesdsp/dsp/jamesdsp.js",
|
||||
"/vendor/jamesdsp/dsp/jamesdsp.wasm"
|
||||
],
|
||||
"background": false
|
||||
}
|
||||
}
|
||||
}
|
||||
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-exit { align-self: flex-start; min-height: 52px; padding: 12px 20px; background: #070809; color: #fff; border: 2px solid #fff; border-radius: 8px; }
|
||||
.car-mode button:focus-visible { outline: 4px solid #61caff; outline-offset: 4px; }
|
||||
.voice-search { flex-shrink: 0; min-width: 44px; min-height: 44px; }
|
||||
|
||||
@@ -10,19 +10,19 @@
|
||||
overlay = doc.createElement('section'); overlay.className = 'car-mode'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', 'Car mode');
|
||||
const exit = doc.createElement('button'); exit.type = 'button'; exit.className = 'car-exit'; exit.textContent = 'Exit Car mode'; exit.onclick = close;
|
||||
const title = doc.createElement('h1'); const controls = doc.createElement('div'); controls.className = 'car-controls'; let toggle;
|
||||
for (const [label, action] of [['Previous', 'prev'], ['Play / Pause', 'toggle'], ['15 s back', 'back'], ['Next', 'next']]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = label; b.setAttribute('aria-label', label); b.onclick = () => adapter.action(action); if (action === 'toggle') toggle = b; controls.append(b); }
|
||||
const update = () => { const state = adapter.state(); title.textContent = state.title || 'Choose a song before entering Car mode'; toggle.textContent = state.paused ? 'Play' : 'Pause'; }; update();
|
||||
for (const [label, action] of [['Previous', 'prev'], ['Play / Pause', 'toggle'], ['15 s back', 'back'], ['Next', 'next']]) { const b = doc.createElement('button'); b.type = 'button'; b.innerHTML = root.TransportIcons.svg({prev:'previous',toggle:'play',back:'back15',next:'next'}[action]) + '<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'; 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.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); } else if (e.key === 'Tab') { const buttons = [...overlay.querySelectorAll('button')], first = buttons[0], last = buttons.at(-1); if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus(); } } e.stopPropagation(); };
|
||||
}
|
||||
function configure(options) {
|
||||
adapter = options; const doc = root.document, Recognition = root.SpeechRecognition || root.webkitSpeechRecognition;
|
||||
if (Recognition) {
|
||||
const b = doc.createElement('button'); b.type = 'button'; b.className = 'icon-btn voice-search'; b.title = 'Voice search'; b.setAttribute('aria-label', 'Voice search'); b.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></svg>'; adapter.input.after(b);
|
||||
const b = doc.getElementById('voiceSearchBtn') || doc.createElement('button'); b.id='voiceSearchBtn'; b.type = 'button'; b.className = 'icon-btn voice-search'; b.title = 'Voice search'; b.setAttribute('aria-label', 'Voice search'); b.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></svg>'; if(!b.isConnected)adapter.input.after(b);
|
||||
let recognition, listening = false;
|
||||
b.onclick = () => { if (listening) { recognition.abort(); return; } recognition = new Recognition(); recognition.lang = doc.documentElement.lang || root.navigator.language || 'en-US'; recognition.continuous = false; recognition.interimResults = false; recognition.onstart = () => { listening = true; b.setAttribute('aria-pressed', 'true'); b.title = 'Listening… tap to cancel'; }; recognition.onend = () => { listening = false; b.setAttribute('aria-pressed', 'false'); b.title = 'Voice search'; }; recognition.onerror = e => adapter.toast(`Voice search: ${e.error === 'not-allowed' ? 'microphone permission was denied' : e.error}`); recognition.onresult = e => { const query = transcript(e); if (query) { adapter.input.value = query; adapter.search(); } }; try { recognition.start(); } catch (error) { adapter.toast(error.message); } };
|
||||
}
|
||||
const car = doc.createElement('button'); car.type = 'button'; car.className = 'icon-btn'; car.title = 'Car mode'; car.setAttribute('aria-label', 'Car mode'); car.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="m5 8 2-5h10l2 5M3 8h18v10H3zM5 18v3m14-3v3M6 12h2m8 0h2"/></svg>'; doc.getElementById('searchForm').after(car); car.onclick = open;
|
||||
const car = doc.getElementById('carModeBtn') || doc.createElement('button'); car.id='carModeBtn'; car.type = 'button'; car.className = 'icon-btn'; car.title = 'Car mode'; car.setAttribute('aria-label', 'Car mode'); car.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="m5 8 2-5h10l2 5M3 8h18v10H3zM5 18v3m14-3v3M6 12h2m8 0h2"/></svg>'; if(!car.isConnected)doc.getElementById('searchForm').after(car); car.onclick = open;
|
||||
root.SettingsSections.register({ id: 'voice-car', title: 'Voice & Car mode', cluster: 'Listening', summary: () => Recognition ? 'Voice search available · Car mode' : 'Car mode · voice unavailable', render(container) { const p = doc.createElement('p'); p.textContent = Recognition ? 'Use the microphone beside Search to speak a query. Recognition availability and processing depend on your browser.' : 'Voice search is unavailable in this browser.'; const b = doc.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Enter Car mode'; b.onclick = open; container.append(p, b); } });
|
||||
}
|
||||
const api = { transcript, configure, open, close }; if (typeof module !== 'undefined') module.exports = api; else root.CarMode = api;
|
||||
|
||||
@@ -1,2 +1,27 @@
|
||||
const { test } = require('node:test'), assert = require('node:assert/strict'); const { transcript } = require('./car-mode');
|
||||
test('voice search submits only final results from the result index', () => { const interim = [{transcript:'wrong'}]; interim.isFinal = false; const final = [{transcript:'Amazing Grace'}]; final.isFinal = true; assert.equal(transcript({results:[interim, final]}), 'Amazing Grace'); assert.equal(transcript({resultIndex:2,results:[final,final]}), ''); assert.equal(transcript({}), ''); });
|
||||
|
||||
test('lazy Car mode keeps four independent transport buttons and the voice entry in place', () => {
|
||||
const vm=require('node:vm'),fs=require('node:fs');
|
||||
class Element {
|
||||
constructor(tag){this.tag=tag;this.children=[];this.inert=false;}
|
||||
append(...nodes){for(const node of nodes){node.remove();node.parent=this;this.children.push(node);}}
|
||||
remove(){if(this.parent){this.parent.children=this.parent.children.filter(n=>n!==this);this.parent=null;}}
|
||||
after(node){this.parent.append(node);}
|
||||
setAttribute(name,value){this[name]=value;}
|
||||
focus(){}
|
||||
insertAdjacentHTML(position,html){this.innerHTML+=html;}
|
||||
get isConnected(){return !!this.parent;}
|
||||
}
|
||||
const body=new Element('body'),input=new Element('input'),form=new Element('form'),voice=new Element('button');form.id='searchForm';voice.id='voiceSearchBtn';body.append(form,input,voice);
|
||||
const all=node=>[node,...node.children.flatMap(all)],doc={body,documentElement:{lang:'en'},createElement:tag=>new Element(tag),getElementById:id=>all(body).find(n=>n.id===id)};
|
||||
let section;const actions=[],root={document:doc,TransportIcons:require('./download-actions').transport,navigator:{},SpeechRecognition:function(){},SettingsSections:{register:s=>section=s}};
|
||||
vm.runInNewContext(fs.readFileSync(__dirname+'/car-mode.js','utf8'),{window:root,setInterval:()=>1,clearInterval:()=>{}});
|
||||
root.CarMode.configure({input,action:action=>actions.push(action),state:()=>({paused:true,title:'Song'})});
|
||||
root.CarMode.open();const overlay=body.children.find(n=>n.className==='car-mode'),controls=overlay.children.find(n=>n.className==='car-controls');
|
||||
assert.equal(controls.children.length,4);assert.equal(new Set(controls.children).size,4);assert.equal(voice.parent,body);
|
||||
controls.children.forEach(b=>b.onclick());assert.deepEqual(actions,['prev','toggle','back','next']);
|
||||
root.CarMode.close();assert.equal(voice.parent,body);assert.equal(body.inert,false);
|
||||
const panel=new Element('section');body.append(panel);section.render(panel);
|
||||
assert.equal(voice.parent,body,'settings must not steal the voice entry');assert.equal(panel.children.at(-1).textContent,'Enter Car mode');
|
||||
});
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
s.preview = m.file.streamMime ? root.DirectStream?.create(m.file.streamMime, m.file.title) : null;
|
||||
const wait = waiting.get(m.file.id); if (wait) clearTimeout(wait.timer);
|
||||
send(key, { type: 'direct', action: 'accept', token: m.token });
|
||||
s.worker = new Worker('/direct-recv-worker.js');
|
||||
s.worker = window.Lazy ? window.Lazy.worker('/direct-recv-worker.js') : new Worker('/direct-recv-worker.js');
|
||||
s.worker.onmessage = async e => {
|
||||
const result = e.data;
|
||||
if (result.op === 'opened') {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use strict';
|
||||
importScripts('/sha256.js');
|
||||
let dir, handle, access, hash, pos = 0, claim, name;
|
||||
let chain = Promise.resolve();
|
||||
self.onmessage = e => { chain = chain.then(() => run(e.data)); };
|
||||
async function close() { if (access) { access.flush(); access.close(); access = null; } }
|
||||
async function run(m) {
|
||||
try {
|
||||
if (!self.Sha256) { self.__ASSET_URLS__=m.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); }
|
||||
if (m.op === 'open') {
|
||||
claim = m.file;
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(claim.id) || !/^[a-f0-9]{64}$/.test(claim.cid)) throw Error('Invalid file identity');
|
||||
|
||||
@@ -9,3 +9,30 @@ html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action { bo
|
||||
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:hover { border-color:var(--glass-line); background:var(--tile-hi); color:var(--text); }
|
||||
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action.danger { color:var(--accent-bright); }
|
||||
html[data-layout="glass-stage"] :is(.dl-row,.download-job) .download-action:focus-visible { outline-color:var(--accent-bright); }
|
||||
|
||||
/* Transport drawings and touch targets survive layout/state changes. */
|
||||
.transport-icon { display:inline-block; flex:none; width:24px; height:24px; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; }
|
||||
.transport-shape { display:none; }
|
||||
.transport-shape-classic { display:inline; }
|
||||
html:is([data-layout="f7-swipe"],[data-layout="glass-stage"],[data-layout="bento-hub"]) .transport-shape-classic { display:none; }
|
||||
html[data-layout="f7-swipe"] .transport-shape-f7-swipe,
|
||||
html[data-layout="glass-stage"] .transport-shape-glass-stage,
|
||||
html[data-layout="bento-hub"] .transport-shape-bento-hub { display:inline; }
|
||||
html[data-layout="f7-swipe"] .transport-shape-f7-swipe { fill:currentColor; stroke-width:.7; }
|
||||
html[data-layout="f7-swipe"] .transport-icon:is([data-transport-icon="play"],[data-transport-icon="pause"],[data-transport-icon="previous"],[data-transport-icon="next"],[data-transport-icon="forward"]),
|
||||
html[data-layout="f7-swipe"] #controls .transport-icon:is([data-transport-icon="play"],[data-transport-icon="pause"],[data-transport-icon="previous"],[data-transport-icon="next"],[data-transport-icon="forward"]) { fill:currentColor; stroke-width:.7; }
|
||||
html[data-layout="f7-swipe"] :is([data-transport-icon="back10"],[data-transport-icon="forward10"],[data-transport-icon="back15"]) .transport-shape { fill:none; stroke-width:2; }
|
||||
html[data-layout="glass-stage"] .transport-icon,
|
||||
html[data-layout="glass-stage"] #controls .transport-icon { stroke-width:1.5; }
|
||||
html[data-layout="bento-hub"] .transport-icon { stroke-width:2; stroke-linejoin:round; }
|
||||
button:has(> .transport-icon), a.np-btn:has(> .transport-icon) { min-width:44px; min-height:44px; }
|
||||
button:has(> .transport-icon) { align-items:center; justify-content:center; }
|
||||
html[data-layout="f7-swipe"] :is(#playBtn,#stgPlay,.service-btn-play,.rv-play) { border-radius:50%; }
|
||||
html[data-layout="f7-swipe"] #playBtn { width:64px; height:64px; }
|
||||
html[data-layout="glass-stage"] :is(#playBtn,.mini-btn,.rv-tbtn):has(> .transport-icon) { background:var(--tile,rgba(255,255,255,.08)); border:1px solid var(--line); backdrop-filter:blur(12px); }
|
||||
html[data-layout="bento-hub"] :is(#playBtn,.mini-btn,.service-btn,.rv-tbtn):has(> .transport-icon) { border-radius:16px; }
|
||||
.car-controls .transport-icon { width:36px; height:36px; margin-right:12px; }
|
||||
.gh-seek .transport-icon, .empty-icon .transport-icon { width:48px; height:48px; }
|
||||
.ph-logo .transport-icon { width:60%; height:60%; }
|
||||
|
||||
.home-pl-play:has(> .transport-icon) { min-width:44px; min-height:44px; }
|
||||
|
||||
@@ -1,6 +1,83 @@
|
||||
/* One action control for the Downloads page and Settings download manager. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
// One SVG definition per media concept; every instance inherits its control colour.
|
||||
const mediaPaths = {
|
||||
download: '<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 = {
|
||||
retry: '<path d="M3 11a9 9 0 1 1 2.6 7M3 3v8h8"/>',
|
||||
cancel: '<path d="m6 6 12 12M6 18 18 6"/>',
|
||||
@@ -19,6 +96,6 @@
|
||||
const text = doc.createElement('span'); text.textContent = label;
|
||||
button.append(icon, text); return button;
|
||||
}
|
||||
const api = { create };
|
||||
const api = { create, media, transport };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.DownloadActions = api;
|
||||
})(globalThis);
|
||||
|
||||
@@ -119,11 +119,7 @@
|
||||
empty.className = 'empty-state dl-empty-state';
|
||||
empty.innerHTML = `
|
||||
<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">
|
||||
<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>
|
||||
<svg class="dl-empty-svg media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
let adapter, total = 0;
|
||||
function configure(options) {
|
||||
adapter = options;
|
||||
root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: '<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 heading = doc.createElement('h3'); heading.textContent = 'Downloads & storage';
|
||||
const jobs = doc.createElement('div'); jobs.className = 'download-manager'; jobs.setAttribute('aria-label', 'Download manager');
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
/* Shared live/offline EQ definitions and legacy curve migration. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const frequencies = [31,62,125,250,500,1000,2000,4000,8000,16000];
|
||||
const bands = frequencies.map((f,i) => ({ f, label: f >= 1000 ? `${f/1000}k` : String(f), type: i === 0 ? 'lowshelf' : i === 9 ? 'highshelf' : 'peaking' }));
|
||||
const clamp = n => Math.max(-12, Math.min(12, Number(n) || 0));
|
||||
function migrate(values) {
|
||||
if (!Array.isArray(values)) return frequencies.map(() => 0);
|
||||
if (values.length !== 5) return frequencies.map((_,i) => clamp(values[i]));
|
||||
const old = [60,250,1000,4000,12000], curve = values.map(clamp);
|
||||
return frequencies.map(f => { if (f <= old[0]) return curve[0]; if (f >= old[4]) return curve[4]; const i = old.findIndex(x => x >= f), weight = Math.log(f / old[i-1]) / Math.log(old[i] / old[i-1]); return Math.round((curve[i-1] * (1-weight) + curve[i] * weight) * 10) / 10; });
|
||||
}
|
||||
const presets = {
|
||||
flat: { name: 'Flat', g: frequencies.map(() => 0) },
|
||||
bass: { name: 'Bass boost', g: [5,7,5,3,1,0,0,0,0,0] },
|
||||
vocal: { name: 'Vocal clarity', g: [-3,-4,-2,0,1,2,3,4,1,0] },
|
||||
acoustic: { name: 'Acoustic live', g: [-2,-1,0,-2,-1,1,2,2,1,0] },
|
||||
classical: { name: 'Classical', g: migrate([4,2,-1,1,3]) },
|
||||
treble: { name: 'Treble boost', g: [0,0,0,0,0,0,1,3,5,6] },
|
||||
};
|
||||
const width = value => Math.max(0, Math.min(100, Number(value) || 0));
|
||||
const { bands, presets, migrate, width } = typeof module !== 'undefined' ? require('./eq-parameters') : root.EqParameters;
|
||||
function createWidener(context, source, value = 0) {
|
||||
const splitter = context.createChannelSplitter(2), output = context.createChannelMerger(2), delay = context.createDelay(.05);
|
||||
source.connect(splitter); splitter.connect(output, 0, 0); splitter.connect(delay, 1); delay.connect(output, 0, 1);
|
||||
|
||||
25
frontend/eq-parameters.js
Normal file
25
frontend/eq-parameters.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/* Eager settings values; audio graphs remain in lazy feature groups. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const frequencies = [31,62,125,250,500,1000,2000,4000,8000,16000];
|
||||
const bands = frequencies.map((f,i) => ({ f, label: f >= 1000 ? `${f/1000}k` : String(f), type: i === 0 ? 'lowshelf' : i === 9 ? 'highshelf' : 'peaking' }));
|
||||
const clamp = n => Math.max(-12, Math.min(12, Number(n) || 0));
|
||||
function migrate(values) {
|
||||
if (!Array.isArray(values)) return frequencies.map(() => 0);
|
||||
if (values.length !== 5) return frequencies.map((_,i) => clamp(values[i]));
|
||||
const old = [60,250,1000,4000,12000], curve = values.map(clamp);
|
||||
return frequencies.map(f => { if (f <= old[0]) return curve[0]; if (f >= old[4]) return curve[4]; const i = old.findIndex(x => x >= f), weight = Math.log(f / old[i-1]) / Math.log(old[i] / old[i-1]); return Math.round((curve[i-1] * (1-weight) + curve[i] * weight) * 10) / 10; });
|
||||
}
|
||||
const presets = {
|
||||
flat: { name: 'Flat', g: frequencies.map(() => 0) },
|
||||
bass: { name: 'Bass boost', g: [5,7,5,3,1,0,0,0,0,0] },
|
||||
vocal: { name: 'Vocal clarity', g: [-3,-4,-2,0,1,2,3,4,1,0] },
|
||||
acoustic: { name: 'Acoustic live', g: [-2,-1,0,-2,-1,1,2,2,1,0] },
|
||||
classical: { name: 'Classical', g: migrate([4,2,-1,1,3]) },
|
||||
treble: { name: 'Treble boost', g: [0,0,0,0,0,0,1,3,5,6] },
|
||||
};
|
||||
const width = value => Math.max(0, Math.min(100, Number(value) || 0));
|
||||
const amount = value => Math.max(0, Math.min(100, Number.isFinite(Number(value)) ? Number(value) : 100));
|
||||
const api = { bands, presets, migrate, width, amount };
|
||||
if (typeof module !== 'undefined') module.exports = api; else root.EqParameters = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
function loadF7(){
|
||||
if(root.Framework7)return Promise.resolve(root.Framework7);
|
||||
if(!loading)loading=new Promise((res,rej)=>{const s=doc.createElement('script');s.src=F7_SRC;s.onload=()=>res(root.Framework7);s.onerror=()=>{loading=null;rej(new Error('Framework7 failed to load'));};doc.head.append(s);});
|
||||
if(!loading)loading=new Promise((res,rej)=>{const s=doc.createElement('script');s.src=root.Lazy?.url('/vendor/framework7-swipe.min.js')||F7_SRC;s.onload=()=>res(root.Framework7);s.onerror=()=>{loading=null;rej(new Error('Framework7 failed to load'));};doc.head.append(s);});
|
||||
return loading;
|
||||
}
|
||||
function ensureApp(){
|
||||
|
||||
77
frontend/feature-bootstrap.js
Normal file
77
frontend/feature-bootstrap.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/* Thin eager entries and configuration for optional, stateful feature modules. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const Lazy = root.Lazy, doc = root.document;
|
||||
if (!Lazy) return;
|
||||
const specs = [
|
||||
['Piano','piano','configure','piano','Piano transcription','Live & worship','Off · load model on request'],
|
||||
['MidiPedals','midi','configure','midi','MIDI pedalboard','Live & worship','Off'],
|
||||
['PartyDJ','party-dj','register','party-dj','Watch party','Live & worship','Shared suggestions · host approval'],
|
||||
['LyricsWindow','lyrics-window','configure','floating-lyrics','Floating lyrics','Live & worship','Floating lyrics window'],
|
||||
['LowerThird','lower-third','register','broadcast','Presenter & broadcast','Live & worship','Projector · OBS lower third'],
|
||||
['EqCore','eq-core','register','equalizer','Equalizer & stereo','Listening','Flat · Original stereo'],
|
||||
['VocalReducer','vocal-reducer','register','vocal-reducer','Vocal reducer','Listening','100% · centre-panned vocals'],
|
||||
['SetlistImport','setlist-import','configure','setlist-import','Setlist import','Library & storage','Planning Center · SongSelect · ChordPro'],
|
||||
['CarMode','car-mode','configure','voice-car','Voice & Car mode','Listening','Voice search · Car mode'],
|
||||
['DirectMedia','direct','configure','direct-transfer','Direct device transfer','Library & storage','Paired devices only'],
|
||||
];
|
||||
function button(id, text, anchor, name, action, className='np-btn') {
|
||||
if (!anchor || doc.getElementById(id)) return;
|
||||
const b=doc.createElement('button'); b.id=id; b.type='button'; b.className=className; b.textContent=text; b.setAttribute('aria-label',text);
|
||||
if(id==='carModeBtn')b.innerHTML='<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="m5 8 2-5h10l2 5M3 8h18v10H3zM5 18v3m14-3v3M6 12h2m8 0h2"/></svg>';
|
||||
if(id==='voiceSearchBtn')b.innerHTML='<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></svg>';
|
||||
if(id.startsWith('floating-')){b.title='Open floating lyrics';b.setAttribute('aria-label','Open floating lyrics window');}
|
||||
b.title ||= text;
|
||||
b.onclick=()=>Lazy.load(name).then(action).catch(error=>root.dispatchEvent(new CustomEvent('ytp-lazy-error',{detail:{error}})));
|
||||
anchor.after(b); Lazy.intent(b,name);
|
||||
}
|
||||
for (const [globalName, suffix, setup, id, title, cluster, summary] of specs) {
|
||||
const group='feature:'+suffix;
|
||||
const facade=Lazy.proxy(group,['open','close','obtain','publish','attach','handle','offer','paint','fade'],globalName);
|
||||
let configured=false;
|
||||
facade[setup]=options=>{
|
||||
if (configured) return; configured=true;
|
||||
Lazy.onLoad(group,()=>root[globalName][setup](options));
|
||||
root.SettingsSections.register({id,title,cluster,lazyGroup:group,summary:()=>{
|
||||
const s=options?.settings?.() || {};
|
||||
if(globalName==='Piano')return s.pianoEnabled?'On · browser or optional worker':'Off · load model on request';
|
||||
if(globalName==='MidiPedals')return s.midiEnabled?'On · pedal mappings':'Off';
|
||||
if(globalName==='EqCore')return `${root.EqParameters.presets[s.eq?.preset]?.name || 'Custom'} · ${root.EqParameters.width(s.stereoWidth)?'Widened':'Original stereo'}`;
|
||||
if(globalName==='VocalReducer')return `${root.EqParameters.amount(s.vocalLevel ?? 100)}% · centre-panned vocals`;
|
||||
if(globalName==='DirectMedia')return facade.enabled()?'On · paired devices only':'Off or unsupported';
|
||||
if(globalName==='CarMode')return root.SpeechRecognition||root.webkitSpeechRecognition?'Voice search available · Car mode':'Car mode · voice unavailable';
|
||||
if(globalName==='LyricsWindow')return root.documentPictureInPicture?.requestWindow?'Interactive window available':root.HTMLCanvasElement?.prototype.captureStream&&doc.pictureInPictureEnabled?'Lyric video PiP available':'Not supported on this browser';
|
||||
return summary;
|
||||
},
|
||||
render(container,context) {
|
||||
let disposed=false, cleanup;
|
||||
container.textContent='Loading…';
|
||||
Lazy.load(group).then(()=>{
|
||||
if (disposed || !container.isConnected) return;
|
||||
container.replaceChildren(); cleanup=root.SettingsSections.get(id).render(container,context);
|
||||
}).catch(error=>{if(!disposed)container.textContent=error.message;});
|
||||
return ()=>{disposed=true; if(typeof cleanup==='function')cleanup();};
|
||||
},
|
||||
});
|
||||
if (globalName==='CarMode') {
|
||||
button('carModeBtn','Car mode',doc.getElementById('searchForm'),group,()=>root.CarMode.open(),'icon-btn');
|
||||
if(root.SpeechRecognition||root.webkitSpeechRecognition)button('voiceSearchBtn','Voice search',options.input,group,()=>doc.getElementById('voiceSearchBtn').click(),'icon-btn voice-search');
|
||||
}
|
||||
if (globalName==='LyricsWindow' && (root.documentPictureInPicture?.requestWindow || (root.HTMLCanvasElement?.prototype.captureStream && doc.pictureInPictureEnabled))) {
|
||||
for(const anchor of ['stageLyricsAuto','serviceAutoscrollBtn','pipBtn'])button('floating-'+anchor,'⧉ Lyrics',doc.getElementById(anchor),group,()=>doc.getElementById('floating-'+anchor).click());
|
||||
}
|
||||
};
|
||||
if(globalName==='LowerThird') {
|
||||
facade.active=()=>new URLSearchParams(root.location.search).get('overlay')==='lowerthird';
|
||||
facade.url=(origin,code)=>{const u=new URL('/',origin);u.searchParams.set('overlay','lowerthird');u.searchParams.set('code',String(code));return u.href;};
|
||||
}
|
||||
if(globalName==='DirectMedia') {
|
||||
let settings=()=>({}); const configure=facade.configure;
|
||||
facade.configure=options=>{settings=options.settings;configure(options);};
|
||||
facade.enabled=()=>settings().directTransfer!==false && !!root.RTCPeerConnection;
|
||||
facade.hasSource=()=>false;
|
||||
}
|
||||
if(globalName==='LyricsWindow')facade.element=()=>null;
|
||||
root[globalName]=facade;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -24,7 +24,7 @@
|
||||
function lift() { if (home || stage.parentElement === document.body) return; home = { parent: stage.parentElement, next: stage.nextSibling }; document.body.append(stage); }
|
||||
function unlift() { if (!home) return; const { parent, next } = home; home = null; if (next && next.parentNode === parent) parent.insertBefore(stage, next); else parent.append(stage); }
|
||||
const hud = document.createElement('div'); hud.className = 'fullscreen-hud'; hud.hidden = true;
|
||||
hud.innerHTML = '<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);
|
||||
const play = hud.querySelector('button[aria-label="Play or pause"]'), orientation = hud.querySelector('.fullscreen-orientation'), fitButton = hud.querySelector('.fullscreen-fit'), exit = hud.querySelector('button[aria-label="Exit fullscreen"]'), range = hud.querySelector('input');
|
||||
const title = hud.querySelector('.fullscreen-title');
|
||||
@@ -109,9 +109,7 @@
|
||||
range.style.setProperty('--seek-progress', `${s.duration ? Math.min(100, s.time / s.duration * 100) : 0}%`);
|
||||
play.setAttribute('aria-label', s.paused ? 'Play video' : 'Pause video');
|
||||
play.title = s.paused ? 'Play' : 'Pause';
|
||||
play.innerHTML = s.paused
|
||||
? '<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>';
|
||||
root.TransportIcons.set(play, s.paused ? 'play' : 'pause', s.paused ? 'Play video' : 'Pause video');
|
||||
}, 250);
|
||||
}
|
||||
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"/>',
|
||||
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"/>',
|
||||
save:'<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>',
|
||||
play:'<path d="m8 5 11 7-11 7z"/>', pause:'<path d="M8 5v14M16 5v14"/>',
|
||||
next:'<path d="m5 5 10 7-10 7zM19 5v14"/>', previous:'<path d="m19 5-10 7 10 7zM5 5v14"/>',
|
||||
save:'<use href="#media-download"/>',
|
||||
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:'<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"/>',
|
||||
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"/>',
|
||||
@@ -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"/>',
|
||||
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) {
|
||||
const mapping=bento?{...glyphs,'📺':'television','⏱':'clock','⇅':'settings','📌':'pin','🎧':'music','📚':'folder'}:glyphs;
|
||||
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 {leadingGlyph}=require('./glass-controls.js');
|
||||
test('glass icon substitution preserves leading whitespace and remaining labels',()=>{
|
||||
assert.deepEqual(leadingGlyph(' ⬇ Save'),{kind:'save',original:' ⬇',rest:' Save'});
|
||||
assert.equal(leadingGlyph('Save'),null);
|
||||
assert.deepEqual(leadingGlyph('⚙️ Settings'),{kind:'settings',original:'⚙️',rest:' Settings'});
|
||||
assert.equal(leadingGlyph('A song with 🔍 in its title'),null);
|
||||
assert.equal(leadingGlyph('Clear all'),null);
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
^ * Out: { ok: true, sha256, size, engine } | { ok: false, error }
|
||||
* ========================================================================== */
|
||||
'use strict';
|
||||
importScripts('/sha256.js');
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const { name } = e.data || {};
|
||||
try {
|
||||
if (!self.Sha256) { self.__ASSET_URLS__=e.data.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); }
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const dir = await root.getDirectoryHandle('videos');
|
||||
const file = await (await dir.getFileHandle(String(name))).getFile();
|
||||
|
||||
File diff suppressed because one or more lines are too long
11
frontend/layout-base.css
Normal file
11
frontend/layout-base.css
Normal file
@@ -0,0 +1,11 @@
|
||||
/* Shared markup defaults stay eager when individual layouts load on demand. */
|
||||
.stg-overlay, .skip10, .deck-chips, .vol-pct, .mode-name { display: none; }
|
||||
.np-btn .ti, .np-btn .tl { pointer-events: none; }
|
||||
.np-btn .ti { width: 20px; height: 20px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.np-btn .i-done { display: none; }
|
||||
.np-btn.done .i-save { display: none; }
|
||||
.np-btn.done .i-done { display: inline; }
|
||||
.stg-bot, .ch-badge, .upnext-clear, .list-sub, .p2p-strip { display: none; }
|
||||
.sleep-status .sl-ic, .sleep-status .sl-sub { display: none; }
|
||||
.ab-group { display: contents; }
|
||||
.mc-tune { display: none; }
|
||||
164
frontend/lazy.js
Normal file
164
frontend/lazy.js
Normal file
@@ -0,0 +1,164 @@
|
||||
/* Build-local classic-script loader. The inert manifest precedes this head script. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const doc = root.document;
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(doc.getElementById('ytp-assets').textContent); }
|
||||
catch { return; } // The index must supply this map; never fetch one during boot.
|
||||
const assets = new Map(), groups = new Map(), hooks = new Map();
|
||||
const css = new Set([...doc.querySelectorAll('link[data-lazy-css]')].map(link => link.dataset.lazyCss));
|
||||
const selected = new Map();
|
||||
const url = path => selected.get(path) || (manifest.files[path]?.h ? path + '?v=' + manifest.files[path].h : './' + path.replace(/^\//,''));
|
||||
function reloadRequired(name) {
|
||||
api.reloadRequired = true;
|
||||
if (root.CustomEvent) root.dispatchEvent?.(new root.CustomEvent('ytp-reload-required', { detail: { group: name } }));
|
||||
}
|
||||
function compatible(name, current, seen = new Set()) {
|
||||
if(seen.has(name))return true;
|
||||
seen.add(name);
|
||||
const built=manifest.groups[name], newer=current.groups[name];
|
||||
if(!built || !newer || built.contract!==newer.contract)return false;
|
||||
return (newer.dependencies || []).every(dependency=>(built.dependencies || []).includes(dependency)&&compatible(dependency,current,seen));
|
||||
}
|
||||
async function select(name) {
|
||||
if (!root.caches) return;
|
||||
try {
|
||||
const cache = await root.caches.open('ytplayer-assets');
|
||||
const response = await cache.match('/__ytp_asset_state');
|
||||
const current = response && (await response.json()).current;
|
||||
const built = manifest.groups[name], newer = current?.groups[name];
|
||||
// The active layout's CSS already executed in the parser before app boot.
|
||||
if(name === 'layout:'+api.layout) return;
|
||||
if (!newer || current.buildTag === manifest.buildTag) return;
|
||||
if (!compatible(name,current)) { reloadRequired(name); return; }
|
||||
const changedList = newer.files.join('\n') !== built.files.join('\n');
|
||||
const available = new Map();
|
||||
for (const path of changedList ? newer.files : built.files) {
|
||||
// Membership and contract must both match the running core's expectation.
|
||||
if (!newer.files.includes(path) || !current.files[path]) continue;
|
||||
const key = path + '?v=' + current.files[path].h;
|
||||
const held = await cache.match(key);
|
||||
if (held?.headers.get('X-Asset-Hash') === current.files[path].h) available.set(path, key);
|
||||
}
|
||||
if(changedList && available.size !== newer.files.length)return;
|
||||
for(const [path,key] of available)selected.set(path,key);
|
||||
if(changedList)return newer;
|
||||
} catch { /* Private browsing/cache eviction: keep the embedded build-local map. */ }
|
||||
}
|
||||
function inject(path) {
|
||||
if (assets.has(path)) return assets.get(path);
|
||||
const style = /\.css$/.test(path);
|
||||
if (style && css.has(path)) return Promise.resolve();
|
||||
if (!style && !/\.js$/.test(path)) return Promise.resolve(); // workers/imports/fonts are not page scripts
|
||||
const task = new Promise((resolve, reject) => {
|
||||
const node = doc.createElement(style ? 'link' : 'script');
|
||||
if (style) { node.rel = 'stylesheet'; node.href = url(path); }
|
||||
else { node.async = false; node.src = url(path); }
|
||||
node.onload = resolve;
|
||||
node.onerror = () => reject(new Error('Unable to load ' + path));
|
||||
doc.head.append(node);
|
||||
});
|
||||
assets.set(path, task);
|
||||
task.catch(() => assets.delete(path));
|
||||
return task;
|
||||
}
|
||||
function load(name) {
|
||||
if (groups.has(name)) return groups.get(name);
|
||||
const group = manifest.groups[name];
|
||||
if (!group) return Promise.reject(new Error('Unknown asset group: ' + name));
|
||||
const task = (async () => {
|
||||
for (const dependency of group.dependencies || []) await load(dependency);
|
||||
const selectedGroup = await select(name) || group;
|
||||
for (const path of selectedGroup.files) if (!/-worker\.js$/.test(path)) await inject(path);
|
||||
for (const fn of hooks.get(name) || []) await fn();
|
||||
group.executed = true;
|
||||
})();
|
||||
groups.set(name, task);
|
||||
task.catch(error => {
|
||||
groups.delete(name);
|
||||
if (root.CustomEvent) root.dispatchEvent?.(new root.CustomEvent('ytp-lazy-error', { detail: { group: name, error } }));
|
||||
});
|
||||
return task;
|
||||
}
|
||||
const api = {
|
||||
load, prefetch: load, loaded: name => !!manifest.groups[name]?.executed, url,
|
||||
manifest, layout: 'classic', reloadRequired: false,
|
||||
worker(path) {
|
||||
const worker=new root.Worker(url(path)), post=worker.postMessage.bind(worker); let first=true;
|
||||
worker.postMessage=(message,transfer)=>{
|
||||
if(first) { first=false; message={...message,assetUrls:{sha256:url('/sha256.js'),wasm:url('/sha256-wasm.js'),resume:url('/resume-core.js')}}; }
|
||||
post(message,transfer);
|
||||
};
|
||||
return worker;
|
||||
},
|
||||
reportLayout(registration) {
|
||||
const send = reg => {
|
||||
const workers = new Set([root.navigator.serviceWorker.controller, reg?.installing, reg?.waiting, reg?.active]);
|
||||
for(const worker of workers) worker?.postMessage({type:'LAYOUT',value:api.layout});
|
||||
};
|
||||
if(registration)send(registration);
|
||||
else root.navigator.serviceWorker?.getRegistration().then(send).catch(()=>{});
|
||||
},
|
||||
warm() {
|
||||
if (!root.navigator.serviceWorker || 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(()=>{});
|
||||
},
|
||||
onLoad(name, fn) {
|
||||
if(api.loaded(name)) { Promise.resolve().then(fn).catch(error=>{if(root.CustomEvent)root.dispatchEvent?.(new root.CustomEvent('ytp-lazy-error',{detail:{group:name,error}}));}); return; }
|
||||
const list = hooks.get(name) || []; list.push(fn); hooks.set(name, list);
|
||||
},
|
||||
proxy(name, methods, globalName) {
|
||||
return Object.fromEntries(methods.map(method => [method, async (...args) => {
|
||||
await load(name);
|
||||
const key = globalName || name.split(':')[1];
|
||||
const target = root.YT?.[key] || root[key];
|
||||
if (typeof target?.[method] !== 'function') throw new Error('Missing lazy method: ' + method);
|
||||
return target[method](...args);
|
||||
}]));
|
||||
},
|
||||
intent(element, name) {
|
||||
if (!element) return;
|
||||
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.navigator.serviceWorker?.addEventListener('message',event=>{
|
||||
if(event.data?.type==='ASSET_LAYOUT_REQUEST') event.source?.postMessage({type:'LAYOUT',value:api.layout});
|
||||
if(event.data?.type==='SW_UPDATE_AVAILABLE') api.warm();
|
||||
});
|
||||
root.navigator.serviceWorker?.addEventListener('controllerchange',()=>{api.reportLayout();api.warm();});
|
||||
root.addEventListener?.('online',()=>api.warm());
|
||||
api.warm();
|
||||
let settings={};
|
||||
try { settings=JSON.parse(root.localStorage.getItem('_ytpdata') || '{}').settings || {}; api.layout=settings.layout || 'classic'; } catch {}
|
||||
Object.assign(doc.documentElement.dataset,{layout:api.layout,theme:settings.theme || 'dark',font:settings.fontScale || 'normal',density:settings.density || 'comfortable',perf:settings.perfMode?'on':'off',motion:settings.reduceMotion?'reduced':'full'});
|
||||
const layoutName = manifest.groups['layout:' + api.layout] ? 'layout:'+api.layout : 'layout:classic';
|
||||
const layoutGroups = new Set([layoutName]);
|
||||
for(const name of layoutGroups) for(const dependency of manifest.groups[name]?.dependencies || []) layoutGroups.add(dependency);
|
||||
if (doc.readyState === 'loading') for (const path of [...layoutGroups].flatMap(name=>manifest.groups[name]?.files || [])) {
|
||||
if (!/\.css$/.test(path) || css.has(path)) continue;
|
||||
// Parser-inserted CSS blocks rendering; dynamic head links do not on all WebKit versions.
|
||||
css.add(path);
|
||||
doc.write('<link rel="stylesheet" href="' + url(path) + '" data-lazy-css="' + path + '">');
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
125
frontend/lazy.test.js
Normal file
125
frontend/lazy.test.js
Normal file
@@ -0,0 +1,125 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
function fixture(current, held = [], appCache = false, userAgent = 'AppleWebKit/605.1 Safari/605.1', controlled = false) {
|
||||
const inserted = [], listeners = {}, writes = [], fetched = [];
|
||||
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 };
|
||||
doc.head = { append: node => { inserted.push(node); queueMicrotask(() => node.onload?.()); } };
|
||||
const root = { document:doc, localStorage:{ getItem:() => '{"settings":{"layout":"glass-stage"}}' }, console, Promise, URL, crypto:{subtle:{}}, 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 }) };
|
||||
root.window=root; root.globalThis=root;
|
||||
vm.runInNewContext(readFileSync(require.resolve('./lazy.js'),'utf8'), root);
|
||||
return { root, inserted, writes, listeners, manifest, fetched };
|
||||
}
|
||||
test('external head bootstrap selects remembered CSS before paint without fetching a manifest', () => {
|
||||
const {root,writes}=fixture();
|
||||
assert.equal(root.document.documentElement.dataset.layout,'glass-stage');
|
||||
assert.deepEqual(writes,['<link rel="stylesheet" href="/glass.css?v=g" data-lazy-css="/glass.css">']);
|
||||
});
|
||||
test('lazy groups execute ordered classic scripts once, sharing concurrent calls and dependencies', async () => {
|
||||
const {root,inserted}=fixture();
|
||||
const one=root.Lazy.load('feature:test'), two=root.Lazy.load('feature:test');
|
||||
await Promise.all([one,two]);
|
||||
assert.deepEqual(inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);
|
||||
assert.equal(root.Lazy.loaded('feature:test'),true);
|
||||
await root.Lazy.load('layout:glass-stage');
|
||||
assert.equal(inserted.filter(n=>n.tagName==='SCRIPT').length,2);
|
||||
});
|
||||
test('failed injection can retry and proxy dispatches only after execution', async () => {
|
||||
const {root,inserted}=fixture();
|
||||
const append=root.document.head.append;
|
||||
root.document.head.append=n=>{inserted.push(n);queueMicrotask(()=>n.onerror?.());};
|
||||
await assert.rejects(root.Lazy.load('feature:test'),/one.js/);
|
||||
root.document.head.append=append;
|
||||
root.TestApi={open:value=>value+1};
|
||||
const proxy=root.Lazy.proxy('feature:test',['open'],'TestApi');
|
||||
assert.equal(await proxy.open(4),5);
|
||||
assert.equal(root.Lazy.url('/two.js'),'/two.js?v=2');
|
||||
await assert.rejects(root.Lazy.load('feature:missing'),/Unknown/);
|
||||
});
|
||||
|
||||
test('executing groups are pinned even after a newer compatible manifest arrives', async () => {
|
||||
const f=fixture(); await f.root.Lazy.load('feature:test');
|
||||
const newer=JSON.parse(JSON.stringify(f.manifest)); newer.files['/one.js'].h='new';
|
||||
f.root.caches={open:async()=>({match:async key=>key==='/__ytp_asset_state'?new Response(JSON.stringify({current:newer})):new Response('',{headers:{'X-Asset-Hash':'new'}})})};
|
||||
await f.root.Lazy.load('feature:test');
|
||||
assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);
|
||||
});
|
||||
test('unexecuted compatible group immediately uses newly cached file URLs', async () => {
|
||||
const next=fixture().manifest; next.buildTag='new'; next.files['/one.js'].h='new';
|
||||
const f=fixture(next,['/one.js?v=new','/two.js?v=2']);
|
||||
await f.root.Lazy.load('feature:test');
|
||||
assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=new','/two.js?v=2']);
|
||||
assert.equal(f.root.Lazy.reloadRequired,false);
|
||||
});
|
||||
test('unexecuted incompatible group keeps its running-build N-1 URLs and requires reload', async () => {
|
||||
const next=fixture().manifest; next.buildTag='new'; next.files['/one.js'].h='new'; next.groups['feature:test'].contract=2;
|
||||
const f=fixture(next,['/one.js?v=new','/two.js?v=2']);
|
||||
const events=[]; f.root.CustomEvent=class {constructor(type,opts){this.type=type;this.detail=opts.detail}}; f.root.dispatchEvent=e=>events.push(e.type);
|
||||
await f.root.Lazy.load('feature:test');
|
||||
assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);
|
||||
assert.equal(f.root.Lazy.reloadRequired,true);
|
||||
assert.ok(events.includes('ytp-reload-required'));
|
||||
});
|
||||
|
||||
test('workers receive the pinned import URL map in their first message', () => {
|
||||
const f=fixture(), sent=[]; f.root.Worker=class { constructor(url){this.url=url;} postMessage(message,transfer){sent.push({message,transfer});} };
|
||||
f.manifest.files['/worker.js']={h:'w'}; f.root.Lazy.manifest.files['/worker.js']={h:'w'};
|
||||
const worker=f.root.Lazy.worker('/worker.js');worker.postMessage({op:'open'},['transfer']);worker.postMessage({op:'finish'});
|
||||
assert.equal(worker.url,'/worker.js?v=w');assert.equal(sent[0].message.assetUrls.sha256,f.root.Lazy.url('/sha256.js'));assert.equal(sent[0].message.assetUrls.wasm,f.root.Lazy.url('/sha256-wasm.js'));assert.deepEqual(sent[0].transfer,['transfer']);assert.equal(sent[1].message.assetUrls,undefined);
|
||||
});
|
||||
|
||||
test('new group files with a changed dependency contract cannot run against the old dependency',async()=>{
|
||||
const next=fixture().manifest;next.buildTag='next';next.groups['feature:test'].dependencies=['feature:dep'];next.groups['feature:dep']={contract:2,files:[]};next.files['/one.js'].h='new';
|
||||
const f=fixture(next,['/one.js?v=new','/two.js?v=2']);f.root.Lazy.manifest.groups['feature:test'].dependencies=['feature:dep'];f.root.Lazy.manifest.groups['feature:dep']={contract:1,files:[]};
|
||||
await f.root.Lazy.load('feature:test');assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);assert.equal(f.root.Lazy.reloadRequired,true);
|
||||
});
|
||||
|
||||
test('classic chunk registry proxies use window.YT and keep the executed singleton pinned',async()=>{
|
||||
const {root,inserted}=fixture();root.YT={TestApi:{open:n=>n+2}};
|
||||
const proxy=root.Lazy.proxy('feature:test',['open'],'TestApi');
|
||||
assert.equal(await proxy.open(3),5);
|
||||
const singleton=root.YT.TestApi;await root.Lazy.load('feature:test');
|
||||
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.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); }
|
||||
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);
|
||||
doc.body.append(root.document.querySelector('#transport-play').ownerSVGElement.cloneNode(true));
|
||||
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;
|
||||
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();
|
||||
} else {
|
||||
const canvas = root.document.createElement('canvas'); canvas.width = 840; canvas.height = 500; const ctx = canvas.getContext('2d');
|
||||
@@ -33,7 +42,7 @@
|
||||
}
|
||||
function configure(options) {
|
||||
adapter = options; const supported = !!mode();
|
||||
for (const id of ['stageLyricsAuto', 'serviceAutoscrollBtn', 'pipBtn']) { const anchor = root.document.getElementById(id); if (!anchor || !supported) continue; const button = root.document.createElement('button'); button.type = 'button'; button.className = 'np-btn'; button.textContent = '⧉ Lyrics'; button.title = 'Open floating lyrics'; button.setAttribute('aria-label', 'Open floating lyrics window'); button.onclick = open; anchor.after(button); }
|
||||
for (const id of ['stageLyricsAuto', 'serviceAutoscrollBtn', 'pipBtn']) { const anchor = root.document.getElementById(id); if (!anchor || !supported) continue; const button = root.document.getElementById('floating-'+id) || root.document.createElement('button'); button.id='floating-'+id; button.type = 'button'; button.className = 'np-btn'; button.textContent = '⧉ Lyrics'; button.title = 'Open floating lyrics'; button.setAttribute('aria-label', 'Open floating lyrics window'); button.onclick = open; if(!button.isConnected)anchor.after(button); }
|
||||
root.SettingsSections.register({ id: 'floating-lyrics', title: 'Floating lyrics', cluster: 'Live & worship', summary: () => mode() === 'document' ? 'Interactive window available' : mode() ? 'Lyric video PiP available' : 'Not supported on this browser', render(container) { const p = container.ownerDocument.createElement('p'); p.textContent = mode() === 'document' ? 'Open synced lyrics with seeking, Play/Pause and Next in an always-on-top window.' : mode() ? 'This browser supports a lyric-video window. Playback controls remain in the player.' : 'Floating lyrics are not supported on this browser.'; container.append(p); if (supported) { const b = container.ownerDocument.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Open floating lyrics'; b.onclick = open; container.append(b); } } });
|
||||
}
|
||||
const api = { configure, support, wrap, element: id => pipWindow?.document.getElementById(id) || null }; if (typeof module !== 'undefined') module.exports = api; else root.LyricsWindow = api;
|
||||
|
||||
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,[]);
|
||||
});
|
||||
@@ -34,8 +34,7 @@
|
||||
|
||||
// Incremental SHA-256 (frontend/sha256.js): the file is hashed while it is
|
||||
// written, so the device knows its content id without re-reading the file.
|
||||
try { importScripts('/sha256.js'); } catch { /* hashing unavailable — save still works */ }
|
||||
importScripts('/resume-core.js');
|
||||
|
||||
|
||||
async function getVideosDir() {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
@@ -203,6 +202,10 @@ async function writeFile(dirName, name, buffer) {
|
||||
}
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
self.__ASSET_URLS__=e.data?.assetUrls || {};
|
||||
try { if(!self.Sha256)importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); } catch { /* hashing unavailable — legacy save still works */ }
|
||||
try { if(!self.ResumeCore)importScripts(self.__ASSET_URLS__.resume || '/resume-core.js'); }
|
||||
catch(error) { self.postMessage({type:'error',error:error.message}); return; }
|
||||
if (e.data && e.data.op === 'write') {
|
||||
try { await writeFile(e.data.dir, e.data.name, e.data.buffer); self.postMessage({ type: 'written' }); }
|
||||
catch (err) { self.postMessage({ type: 'error', error: err && err.message ? err.message : String(err) }); }
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
return new Promise((resolve) => {
|
||||
let worker;
|
||||
try {
|
||||
worker = new Worker('/opfs-worker.js');
|
||||
worker = window.Lazy ? window.Lazy.worker('/opfs-worker.js') : new Worker('/opfs-worker.js');
|
||||
} catch {
|
||||
resolve({ ok: false, fallback: true });
|
||||
return;
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
function hashInWorker(name) {
|
||||
return new Promise((resolve) => {
|
||||
let w;
|
||||
try { w = new Worker('/hash-worker.js'); } catch { resolve(null); return; }
|
||||
try { w = window.Lazy ? window.Lazy.worker('/hash-worker.js') : new Worker('/hash-worker.js'); } catch { resolve(null); return; }
|
||||
w.onmessage = (e) => { w.terminate(); resolve(e.data && e.data.ok ? e.data : null); };
|
||||
w.onerror = () => { w.terminate(); resolve(null); };
|
||||
w.postMessage({ name });
|
||||
@@ -296,7 +296,7 @@
|
||||
function start(h) {
|
||||
hooks = { ...hooks, ...(h || {}) };
|
||||
const idle = window.requestIdleCallback || ((fn) => setTimeout(fn, 1));
|
||||
setTimeout(() => idle(() => sync()), 8000);
|
||||
setTimeout(() => idle(() => sync()), h?.initialDelay ?? 8000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && Date.now() - lastSync > RESYNC_MS) sync();
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
* The ".part" suffix keeps OPFS.listVideos() from ever listing a partial file.
|
||||
* ========================================================================== */
|
||||
'use strict';
|
||||
importScripts('/sha256.js');
|
||||
|
||||
let dir = null;
|
||||
let handle = null;
|
||||
@@ -36,6 +35,7 @@ self.onmessage = (e) => { chain = chain.then(() => onOp(e.data || {})); };
|
||||
|
||||
async function onOp(m) {
|
||||
try {
|
||||
if (!self.Sha256) { self.__ASSET_URLS__=m.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); }
|
||||
if (m.op === 'open') {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
dir = await root.getDirectoryHandle('videos', { create: true });
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
return new Promise((resolve) => {
|
||||
const xid = newXid();
|
||||
const pc = new RTCPeerConnection({ iceServers });
|
||||
const worker = new Worker('/p2p-recv-worker.js');
|
||||
const worker = window.Lazy ? window.Lazy.worker('/p2p-recv-worker.js') : new Worker('/p2p-recv-worker.js');
|
||||
let settled = false;
|
||||
let idle = null;
|
||||
let expected = size;
|
||||
|
||||
@@ -200,7 +200,7 @@
|
||||
button('Transcribe in browser', () =>
|
||||
run(async (id) => {
|
||||
const buffer = await audio(id, file.files[0]);
|
||||
const engine = await import('./piano-engine.mjs');
|
||||
const engine = await import(root.Lazy?.url('/piano-engine.mjs') || './piano-engine.mjs');
|
||||
const result = await engine.transcribe(buffer, (text) => {
|
||||
if (alive) status.textContent = text;
|
||||
});
|
||||
|
||||
210
frontend/presenter.js
Normal file
210
frontend/presenter.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/* Lazy classic chunk; its executing singleton stays pinned until reload. */
|
||||
(window.YT ||= {}).Presenter = (() => {
|
||||
const noop = { boot() {}, active: () => false };
|
||||
const view = $('presenterView');
|
||||
if (!WEB || !view || typeof WebSocket === 'undefined') return noop;
|
||||
const overlay = LowerThird.active();
|
||||
const KEY = overlay ? `ytpOverlayPair:${new URLSearchParams(location.search).get('code') || ''}` : 'ytpPresenterPair';
|
||||
const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } };
|
||||
const save = (v) => { try { if (v) localStorage.setItem(KEY, JSON.stringify(v)); else localStorage.removeItem(KEY); } catch { /* blocked */ } };
|
||||
const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/remote?${new URLSearchParams(q)}`;
|
||||
const list = $('pvList');
|
||||
const st = { ws: null, state: null, stateAt: 0, hostName: '', vid: null, all: [], docOff: 0, idx: -2, retry: null, notes: new Map(), wake: null, idleTimer: null, seq: 0 };
|
||||
|
||||
function setStatus(t) { $('pvStatus').textContent = t; }
|
||||
|
||||
function show() {
|
||||
view.classList.remove('hidden');
|
||||
document.body.classList.add('presenter-open');
|
||||
// The app underneath may have focused its search box; keys belong here now.
|
||||
try { if (document.activeElement && !view.contains(document.activeElement)) document.activeElement.blur(); } catch { /* fine */ }
|
||||
try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ }
|
||||
keepAwake();
|
||||
bumpIdle();
|
||||
}
|
||||
|
||||
async function keepAwake() {
|
||||
try { if ('wakeLock' in navigator && !st.wake) { st.wake = await navigator.wakeLock.request('screen'); st.wake.addEventListener('release', () => { st.wake = null; }); } } catch { /* not allowed */ }
|
||||
}
|
||||
|
||||
function showPairForm(msg = '') {
|
||||
$('pvPair').classList.remove('hidden');
|
||||
$('pvMsg').textContent = msg;
|
||||
setStatus('Not paired');
|
||||
}
|
||||
|
||||
async function pair(code) {
|
||||
code = String(code || '').replace(/\D/g, '');
|
||||
if (code.length !== 6) { showPairForm('Enter the 6-digit code from the playing screen.'); return; }
|
||||
$('pvMsg').textContent = 'Connecting…';
|
||||
try {
|
||||
const res = await fetch('/api/remote/pair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name: '🖥 Presenter' }) });
|
||||
const j = await res.json().catch(() => null);
|
||||
if (!j || !j.ok) { showPairForm('⚠ ' + ((j && j.error) || `HTTP ${res.status}`)); return; }
|
||||
save({ roomId: j.roomId, remoteId: j.remoteId, token: j.token, hostName: j.hostName });
|
||||
$('pvPair').classList.add('hidden');
|
||||
connect();
|
||||
} catch {
|
||||
showPairForm('⚠ Network error — try again');
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const p = load();
|
||||
if (!p) { showPairForm(); return; }
|
||||
clearTimeout(st.retry);
|
||||
try { if (st.ws) st.ws.close(); } catch { /* gone */ }
|
||||
setStatus('Connecting…');
|
||||
const ws = new WebSocket(wsUrl({ role: 'remote', room: p.roomId, rid: p.remoteId, token: p.token, name: '🖥 Presenter' }));
|
||||
st.ws = ws;
|
||||
ws.onmessage = (e) => {
|
||||
let m; try { m = JSON.parse(e.data); } catch { return; }
|
||||
if (!overlay) { DirectMedia.attach('presenter', { send: m => ws.send(JSON.stringify(m)), peers: () => [{ id: 'host', name: st.hostName || 'Screen' }] }); }
|
||||
if (m.type === 'direct') { if (!overlay) DirectMedia.handle('presenter', m); return; }
|
||||
if (m.type === 'hello') { st.hostName = m.hostName || 'screen'; if (m.state) onState(m.state); else setStatus(`Following ${st.hostName}`); }
|
||||
else if (m.type === 'state') onState(m.state);
|
||||
else if (m.type === 'host-offline') { if (overlay) { st.all = []; LowerThird.paint([], -1); } setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); }
|
||||
};
|
||||
ws.onclose = (e) => {
|
||||
if (st.ws !== ws) return;
|
||||
st.ws = null;
|
||||
if (e.code === 4001) { save(null); showPairForm('This presenter was unpaired — enter a new code.'); return; }
|
||||
setStatus(e.code === 4004 ? 'Screen offline — retrying…' : 'Reconnecting…');
|
||||
st.retry = setTimeout(connect, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
function onState(s) {
|
||||
st.state = s;
|
||||
st.stateAt = Date.now();
|
||||
const v = s && s.v;
|
||||
setStatus(v ? `● Following ${st.hostName} · ${v.title}` : `● Following ${st.hostName} · nothing playing`);
|
||||
const id = v && v.id;
|
||||
if (id !== st.vid) loadSong(id, v);
|
||||
}
|
||||
|
||||
async function loadSong(id, v) {
|
||||
const seq = ++st.seq;
|
||||
st.vid = id;
|
||||
st.all = [];
|
||||
st.docOff = 0;
|
||||
render(v);
|
||||
if (!id || !isMediaId(id)) return;
|
||||
let notes = st.notes.get(id);
|
||||
if (!notes) {
|
||||
try {
|
||||
const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}`)).json();
|
||||
notes = j && j.ok ? j : null;
|
||||
if (notes) { st.notes.set(id, notes); if (st.notes.size > 50) st.notes.delete(st.notes.keys().next().value); }
|
||||
} catch { notes = null; }
|
||||
}
|
||||
if (seq !== st.seq) return;
|
||||
const doc = notes && notes.lyrics && notes.lyrics.data;
|
||||
st.all = (doc && doc.lines) || [];
|
||||
st.docOff = (doc && Number(doc.offset)) || 0;
|
||||
render(v);
|
||||
}
|
||||
|
||||
function render(v) {
|
||||
if (overlay) { LowerThird.paint([], -1); st.idx = -2; tick(true); return; }
|
||||
const sung = st.all.map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line');
|
||||
const title = $('pvTitle');
|
||||
if (!sung.length) {
|
||||
list.innerHTML = '';
|
||||
title.innerHTML = v ? `${escapeHtml(v.title)}${v.channel ? `<small>${escapeHtml(v.channel)}</small>` : ''}` : '';
|
||||
title.classList.toggle('hidden', !v);
|
||||
return;
|
||||
}
|
||||
title.classList.add('hidden');
|
||||
const pad = Math.round(list.clientHeight / 2);
|
||||
list.style.paddingTop = pad + 'px';
|
||||
list.style.paddingBottom = pad + 'px';
|
||||
list.innerHTML = sung.map((l) => `<div class="sl-line" data-i="${l.i}">${LyricsDisplay.html(l)}</div>`).join('');
|
||||
refit();
|
||||
st.idx = -2;
|
||||
tick(true);
|
||||
setTimeout(refit, 900); // once more after fonts/layout settle
|
||||
}
|
||||
|
||||
function refit() {
|
||||
fitLyricLines(list, { maxShare: 0.2, maxPx: 240 });
|
||||
}
|
||||
|
||||
function liveTime() {
|
||||
const s = st.state;
|
||||
if (!s) return 0;
|
||||
return s.paused ? s.cur : s.cur + ((Date.now() - st.stateAt) / 1000) * (s.rate || 1);
|
||||
}
|
||||
|
||||
function tick(force = false) {
|
||||
if (view.classList.contains('hidden') || !st.all.length || !st.state) return;
|
||||
if (overlay) { LowerThird.paint(st.all, LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0))); return; }
|
||||
const idx = LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0));
|
||||
if (idx === st.idx && !force) return;
|
||||
st.idx = idx;
|
||||
const rows = [...list.querySelectorAll('.sl-line')];
|
||||
const pos = rows.findIndex((el) => Number(el.dataset.i) === idx);
|
||||
rows.forEach((el, k) => {
|
||||
const d = pos < 0 ? 99 : Math.abs(k - pos);
|
||||
el.classList.toggle('active', d === 0);
|
||||
el.classList.toggle('near', d === 1);
|
||||
el.classList.toggle('past', pos >= 0 && k < pos);
|
||||
});
|
||||
const target = pos >= 0 ? rows[pos] : rows[0];
|
||||
if (target) {
|
||||
const go = () => list.scrollTo({ top: Math.max(0, target.offsetTop - list.clientHeight / 2 + target.offsetHeight / 2), behavior: 'smooth' });
|
||||
go();
|
||||
setTimeout(go, 380);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
|
||||
else view.requestFullscreen?.().catch(() => {});
|
||||
}
|
||||
|
||||
// The status bar and cursor hide after 3 s without mouse movement.
|
||||
function bumpIdle() {
|
||||
view.classList.remove('idle');
|
||||
clearTimeout(st.idleTimer);
|
||||
st.idleTimer = setTimeout(() => view.classList.add('idle'), 3000);
|
||||
}
|
||||
|
||||
function boot() {
|
||||
let param = null;
|
||||
try { param = new URLSearchParams(location.search).get(overlay ? 'code' : 'present'); } catch { /* no URL */ }
|
||||
if (param === null) return;
|
||||
try {
|
||||
const u = new URL(location.href);
|
||||
u.searchParams.delete('present');
|
||||
history.replaceState(null, '', u.pathname + (u.search || '') + u.hash);
|
||||
} catch { /* keep */ }
|
||||
show();
|
||||
const code = String(param).replace(/\D/g, '');
|
||||
if (overlay && load()) connect();
|
||||
else if (code.length === 6) pair(code);
|
||||
else if (load()) connect();
|
||||
else showPairForm();
|
||||
setInterval(() => tick(), 200);
|
||||
window.addEventListener('resize', () => { refit(); tick(true); });
|
||||
if (document.fonts) {
|
||||
document.fonts.ready.then(refit);
|
||||
document.fonts.addEventListener('loadingdone', refit);
|
||||
}
|
||||
view.addEventListener('mousemove', bumpIdle);
|
||||
view.addEventListener('dblclick', toggleFullscreen);
|
||||
$('pvFull').addEventListener('click', toggleFullscreen);
|
||||
$('pvPairBtn').addEventListener('click', () => pair($('pvCode').value));
|
||||
$('pvCode').addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter') pair($('pvCode').value); });
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (view.classList.contains('hidden') || (e.target.closest && e.target.closest('#pvPair'))) return;
|
||||
if (e.key === 'f' || e.key === 'F') { e.preventDefault(); e.stopImmediatePropagation(); toggleFullscreen(); }
|
||||
else if (e.key === 'b' || e.key === 'B') { e.preventDefault(); e.stopImmediatePropagation(); $('pvBlank').classList.toggle('hidden'); }
|
||||
}, true);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') { keepAwake(); if (!st.ws && load()) connect(); }
|
||||
});
|
||||
}
|
||||
|
||||
return { boot, active: () => !view.classList.contains('hidden') };
|
||||
})();
|
||||
37
frontend/prewarm-next.js
Normal file
37
frontend/prewarm-next.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/* Pure queue candidate selection for stream prewarming. */
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory();
|
||||
else root.PrewarmNext = factory();
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
function candidates(queue, index, state = {}, n = 2, known = {}) {
|
||||
if (!Array.isArray(queue) || !queue.length || !Number.isInteger(index) || index < 0 || index >= queue.length) return [];
|
||||
if (state.loopOne) return [];
|
||||
const count = Math.max(0, Math.floor(Number(n) || 0));
|
||||
if (!count) return [];
|
||||
const repeatAll = state.repeatMode === 'all' || state.repeatAll === true;
|
||||
const offline = new Set(known.offline || []);
|
||||
const serverCached = new Set(known.serverCached || []);
|
||||
const current = queue[index];
|
||||
const currentId = typeof current === 'string' ? current : current && current.id;
|
||||
const seen = new Set(currentId ? [currentId] : []);
|
||||
const ids = [];
|
||||
for (let step = 1; step < queue.length + (repeatAll ? 1 : 0) && ids.length < count; step++) {
|
||||
let at = index + step;
|
||||
if (at >= queue.length) {
|
||||
if (!repeatAll) break;
|
||||
at %= queue.length;
|
||||
}
|
||||
const item = queue[at];
|
||||
const id = typeof item === 'string' ? item : item && item.id;
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
if (offline.has(id) || serverCached.has(id) || (item && item.serverCached)) continue;
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
return { candidates };
|
||||
});
|
||||
26
frontend/prewarm-next.test.js
Normal file
26
frontend/prewarm-next.test.js
Normal file
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { candidates } = require('./prewarm-next.js');
|
||||
|
||||
const q = ['a', 'b', 'c', 'd'].map(id => ({ id }));
|
||||
|
||||
test('returns the next n queue ids in existing order', () => {
|
||||
assert.deepEqual(candidates(q, 0, {}, 2), ['b', 'c']);
|
||||
});
|
||||
test('repeat all wraps and does not duplicate ids', () => {
|
||||
assert.deepEqual(candidates(q, 3, { repeatMode: 'all' }, 2), ['a', 'b']);
|
||||
assert.deepEqual(candidates([{ id: 'x' }, { id: 'x' }], 0, { repeatMode: 'all' }, 3), []);
|
||||
});
|
||||
test('repeat off stops at the end and loop one has no candidates', () => {
|
||||
assert.deepEqual(candidates(q, 3, {}, 2), []);
|
||||
assert.deepEqual(candidates(q, 0, { loopOne: true, repeatMode: 'all' }, 2), []);
|
||||
});
|
||||
test('skips offline and known server cached ids and deduplicates', () => {
|
||||
assert.deepEqual(candidates(q, 0, {}, 3, { offline: ['b'], serverCached: ['c'] }), ['d']);
|
||||
assert.deepEqual(candidates([{ id: 'a' }, { id: 'b', serverCached: true }, { id: 'c' }], 0, {}, 2), ['c']);
|
||||
});
|
||||
test('shuffle follows the queue order selected before advance', () => {
|
||||
const shuffled = ['a', 'd', 'b', 'c'].map(id => ({ id }));
|
||||
assert.deepEqual(candidates(shuffled, 0, { shuffle: true }, 2), ['d', 'b']);
|
||||
});
|
||||
673
frontend/remote-client.js
Normal file
673
frontend/remote-client.js
Normal file
@@ -0,0 +1,673 @@
|
||||
/* Lazy classic chunk; its executing singleton stays pinned until reload. */
|
||||
(window.YT ||= {}).Remote = (() => {
|
||||
const noop = { boot() {}, changed() {} };
|
||||
if (!WEB || typeof WebSocket === 'undefined') return noop;
|
||||
|
||||
const HOST_KEY = 'ytpRemoteHost';
|
||||
const PAIR_KEY = 'ytpRemotePair';
|
||||
const load = (k) => { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch { return null; } };
|
||||
const save = (k, v) => { try { if (v) localStorage.setItem(k, JSON.stringify(v)); else localStorage.removeItem(k); } catch { /* storage blocked */ } };
|
||||
const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/remote?${new URLSearchParams(q)}`;
|
||||
const newSecret = () => {
|
||||
const b = new Uint8Array(32);
|
||||
crypto.getRandomValues(b);
|
||||
return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
};
|
||||
const guessName = (fallback) => {
|
||||
const ua = navigator.userAgent || '';
|
||||
if (/iPhone/.test(ua)) return 'iPhone';
|
||||
if (/iPad/.test(ua)) return 'iPad';
|
||||
if (/Android/.test(ua)) return 'Android phone';
|
||||
if (/Windows/.test(ua)) return 'Windows PC';
|
||||
if (/Mac/.test(ua)) return 'Mac';
|
||||
return fallback;
|
||||
};
|
||||
const clock = (t) => (window.LyricsCore ? LyricsCore.fmtClock(t) : fmtTime(t));
|
||||
|
||||
// ==========================================================================
|
||||
// Host (the screen being controlled)
|
||||
// ==========================================================================
|
||||
const host = { ws: null, remotes: [], code: '', codeExpires: 0, retryMs: 2000, timer: null, lastState: '', lastQueue: '', replaced: false, pending: false };
|
||||
|
||||
function hostCfg() {
|
||||
let cfg = load(HOST_KEY);
|
||||
if (!cfg || !cfg.secret) cfg = { secret: newSecret(), name: guessName('Desktop'), enabled: false };
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function hostConnect() {
|
||||
const cfg = hostCfg();
|
||||
if (!cfg.enabled) return;
|
||||
try { if (host.ws) host.ws.close(); } catch { /* already closed */ }
|
||||
const ws = new WebSocket(wsUrl({ role: 'host', secret: cfg.secret, name: cfg.name }));
|
||||
host.ws = ws;
|
||||
host.replaced = false;
|
||||
ws.onopen = () => { host.retryMs = 2000; };
|
||||
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onHostMessage(m); };
|
||||
ws.onclose = (e) => {
|
||||
if (host.ws !== ws) return;
|
||||
host.ws = null;
|
||||
host.remotes = [];
|
||||
renderChip();
|
||||
renderHostModal();
|
||||
if (e.code === 4000) { host.replaced = true; renderHostModal(); return; } // another tab hosts now
|
||||
if (hostCfg().enabled) {
|
||||
clearTimeout(host.timer);
|
||||
host.timer = setTimeout(hostConnect, host.retryMs);
|
||||
host.retryMs = Math.min(host.retryMs * 2, 30000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const hostSend = (m) => { if (host.ws && host.ws.readyState === 1) host.ws.send(JSON.stringify(m)); };
|
||||
|
||||
function onHostMessage(m) {
|
||||
DirectMedia.attach('remote-host', { send: hostSend, peers: () => host.remotes.map(r => ({ id: r.rid, name: r.name })) });
|
||||
if (m.type === 'direct') { DirectMedia.handle('remote-host', m); return; }
|
||||
if (m.type === 'hello') {
|
||||
host.code = m.code; host.codeExpires = m.codeExpires; host.remotes = m.remotes || [];
|
||||
host.lastState = ''; host.lastQueue = '';
|
||||
pushState(); pushQueue();
|
||||
} else if (m.type === 'code') {
|
||||
host.code = m.code; host.codeExpires = m.codeExpires;
|
||||
} else if (m.type === 'remotes') {
|
||||
host.remotes = m.remotes || [];
|
||||
if (m.joined) { toast(`📱 ${m.joined} connected as a remote`); host.lastState = ''; host.lastQueue = ''; pushState(); pushQueue(); }
|
||||
if (m.left) toast(`📱 ${m.left} disconnected`);
|
||||
} else if (m.type === 'ping') {
|
||||
hostSend({ type: 'pong', rid: m.rid, t: m.t });
|
||||
return;
|
||||
} else if (m.type === 'cmd') {
|
||||
runCommand(m);
|
||||
return;
|
||||
}
|
||||
renderChip();
|
||||
renderHostModal();
|
||||
}
|
||||
|
||||
function stateNow() {
|
||||
const meta = current && current.meta;
|
||||
const M = Player.master;
|
||||
return {
|
||||
directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false,
|
||||
v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '' } : null,
|
||||
cur: M ? Math.floor((M.currentTime || 0) * 10) / 10 : 0,
|
||||
dur: (M && isFinite(M.duration) && M.duration) || (meta && meta.duration) || 0,
|
||||
paused: !M || M.paused,
|
||||
volume: data.settings.volume ?? 1,
|
||||
service: isServiceModeOpen(),
|
||||
sleep: sleepTimerRemaining || 0,
|
||||
hostName: hostCfg().name,
|
||||
// For the presenter screen: this screen's personal lyric offset for the
|
||||
// song and the playback speed (to extrapolate between updates).
|
||||
off: meta && data.lyricOffsets ? Number(data.lyricOffsets[meta.id]) || 0 : 0,
|
||||
rate: (M && M.playbackRate) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
function pushState(force = false) {
|
||||
if (!host.ws || !host.remotes.length) return;
|
||||
const st = stateNow();
|
||||
const key = JSON.stringify({ ...st, cur: Math.floor(st.cur) });
|
||||
if (!force && key === host.lastState) return;
|
||||
host.lastState = key;
|
||||
hostSend({ type: 'state', state: { ...st, at: Date.now() } });
|
||||
}
|
||||
|
||||
function pushQueue() {
|
||||
if (!host.ws || !host.remotes.length) return;
|
||||
const items = queue.slice(0, 200).map((v) => ({
|
||||
id: v.id, title: v.title || '', channel: v.channel || '', duration: v.duration || 0, thumbnail: v.thumbnail || '',
|
||||
}));
|
||||
const key = queueIndex + '|' + items.map((v) => v.id).join(',');
|
||||
if (key === host.lastQueue) return;
|
||||
host.lastQueue = key;
|
||||
hostSend({ type: 'queue', queue: { items, idx: queueIndex } });
|
||||
}
|
||||
|
||||
// Coalesce bursts (play event + renderUpNext + afterLoad) into one push.
|
||||
function changed() {
|
||||
if (!host.ws || host.pending) return;
|
||||
host.pending = true;
|
||||
setTimeout(() => { host.pending = false; pushState(); pushQueue(); }, 50);
|
||||
}
|
||||
|
||||
const validIdx = (i) => Number.isInteger(i) && i > queueIndex && i < queue.length;
|
||||
|
||||
function runCommand(m) {
|
||||
const who = m.from || 'Phone';
|
||||
switch (m.cmd) {
|
||||
case 'toggle': Player.toggle(); break;
|
||||
case 'play': Player.play(); break;
|
||||
case 'pause': Player.pause(); break;
|
||||
case 'next': playNext(); break;
|
||||
case 'prev': playPrev(); break;
|
||||
case 'seek': {
|
||||
const t = Number(m.t);
|
||||
if (isFinite(t) && t >= 0) Player.seek(t);
|
||||
break;
|
||||
}
|
||||
case 'volume': {
|
||||
const v = Math.max(0, Math.min(1, Number(m.v)));
|
||||
if (!isFinite(v)) break;
|
||||
data.settings.volume = v;
|
||||
els.volume.value = String(v);
|
||||
Player.applyVolume();
|
||||
syncMuteIcon();
|
||||
persist();
|
||||
break;
|
||||
}
|
||||
case 'jump': {
|
||||
const i = Number(m.i);
|
||||
if (Number.isInteger(i) && i >= 0 && i < queue.length) { queueIndex = i - 1; playNext(); }
|
||||
break;
|
||||
}
|
||||
case 'remove': {
|
||||
const i = Number(m.i);
|
||||
if (validIdx(i)) { queue.splice(i, 1); renderUpNext(); }
|
||||
break;
|
||||
}
|
||||
case 'move': {
|
||||
const from = Number(m.from), to = Number(m.to);
|
||||
if (validIdx(from) && validIdx(to) && from !== to) {
|
||||
const [v] = queue.splice(from, 1);
|
||||
queue.splice(to, 0, v);
|
||||
renderUpNext();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'add': {
|
||||
const v = m.video;
|
||||
if (!v || typeof v.id !== 'string' || !isMediaId(v.id)) break;
|
||||
const item = slim({ ...v, title: String(v.title || v.id).slice(0, 300), channel: String(v.channel || '').slice(0, 200) });
|
||||
if (!current || !queue.length) { playFromList([item], 0, 'remote'); }
|
||||
else if (!queue.slice(queueIndex + 1).some((x) => x.id === item.id)) { queue.push(item); renderUpNext(); }
|
||||
toast(`📱 ${who} added “${item.title}”`);
|
||||
break;
|
||||
}
|
||||
case 'sleep': {
|
||||
const min = Number(m.min) || 0;
|
||||
if (min > 0) startSleepTimer(Math.min(min, 240)); else cancelSleepTimer();
|
||||
break;
|
||||
}
|
||||
case 'service':
|
||||
if (m.on) enterServiceMode(); else exitServiceMode();
|
||||
break;
|
||||
default: return;
|
||||
}
|
||||
changed();
|
||||
}
|
||||
|
||||
function startHosting() {
|
||||
const cfg = hostCfg();
|
||||
cfg.enabled = true;
|
||||
save(HOST_KEY, cfg);
|
||||
hostConnect();
|
||||
renderHostModal();
|
||||
}
|
||||
|
||||
function stopHosting() {
|
||||
const cfg = hostCfg();
|
||||
cfg.enabled = false;
|
||||
save(HOST_KEY, cfg);
|
||||
clearTimeout(host.timer);
|
||||
const ws = host.ws;
|
||||
host.ws = null;
|
||||
host.remotes = [];
|
||||
try { if (ws) ws.close(); } catch { /* gone */ }
|
||||
renderChip();
|
||||
renderHostModal();
|
||||
}
|
||||
|
||||
function unpairAll() {
|
||||
hostSend({ type: 'unpair-all' });
|
||||
const cfg = hostCfg();
|
||||
cfg.secret = newSecret(); // every phone's token was derived from the old one
|
||||
save(HOST_KEY, cfg);
|
||||
setTimeout(hostConnect, 300);
|
||||
toast('All phones unpaired');
|
||||
}
|
||||
|
||||
function renderChip() {
|
||||
const chip = $('remoteChip');
|
||||
if (!chip) return;
|
||||
const n = host.remotes.length;
|
||||
chip.classList.toggle('hidden', !host.ws || !n);
|
||||
if (n) {
|
||||
$('remoteChipText').textContent = n === 1
|
||||
? `Remote connected — ${host.remotes[0].name}`
|
||||
: `${n} remotes connected`;
|
||||
}
|
||||
}
|
||||
|
||||
let hostModalOpen = false;
|
||||
function renderHostModal() {
|
||||
if (!hostModalOpen) return;
|
||||
const body = $('modalBody') && $('modalBody').querySelector('.remote-host');
|
||||
if (!body) { hostModalOpen = false; return; }
|
||||
const cfg = hostCfg();
|
||||
const live = !!host.ws;
|
||||
const mins = host.codeExpires ? Math.max(0, Math.round((host.codeExpires - Date.now()) / 60000)) : 0;
|
||||
body.querySelector('.rh-body').innerHTML = !cfg.enabled ? `
|
||||
<p class="modal-text">Let a phone drive this screen — play/pause, seek, volume, the queue and service mode —
|
||||
while this computer plays to the TV or speakers. Pairing uses a one-time 6-digit code; nothing to sign in to.</p>`
|
||||
: host.replaced ? `<p class="modal-text">Another tab on this computer took over as the remote-controlled screen.</p>`
|
||||
: !live ? `<p class="modal-text">Connecting to the server…</p>`
|
||||
: `
|
||||
<div class="rh-pair">
|
||||
<img class="rh-qr" alt="QR code to pair a phone" src="/api/remote/qr/${host.code}" />
|
||||
<div>
|
||||
<div class="rh-label">Pairing code</div>
|
||||
<div class="rh-code">${host.code.slice(0, 3)} ${host.code.slice(3)}</div>
|
||||
<div class="rh-sub">Valid ${mins} more min · one use</div>
|
||||
<div class="rh-sub">On the phone: open this app → 📱 Remote → <b>Use this phone as a remote</b>, or scan the QR code.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rh-label">Presenter / projector screen</div>
|
||||
<div class="rh-present">
|
||||
<img class="rh-qr rh-qr-sm" alt="QR code for the presenter screen" src="/api/remote/qr/${host.code}?kind=present" />
|
||||
<div>
|
||||
<div class="rh-sub">Lyrics-only display that follows this screen. On the projector's computer or TV browser open
|
||||
<code>${escapeHtml(location.origin)}/?present=${host.code}</code> (same code), or scan the QR.</div>
|
||||
<button class="np-btn" type="button" data-act="present">🖥 Open presenter window here</button>
|
||||
<button class="np-btn" type="button" data-act="obs">Copy OBS browser-source URL</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rh-label">Connected phones & screens</div>
|
||||
${host.remotes.length ? host.remotes.map((r) => `
|
||||
<div class="rh-remote"><span>📱 ${escapeHtml(r.name)}</span><button class="mk-btn" type="button" data-kick="${escapeHtml(r.rid)}" title="Disconnect">✕</button></div>`).join('')
|
||||
: '<div class="rh-sub">None yet.</div>'}`;
|
||||
const nameIn = body.querySelector('.rh-name');
|
||||
if (document.activeElement !== nameIn) nameIn.value = cfg.name;
|
||||
body.querySelector('[data-act="start"]').classList.toggle('hidden', cfg.enabled && !host.replaced);
|
||||
body.querySelector('[data-act="stop"]').classList.toggle('hidden', !cfg.enabled);
|
||||
body.querySelector('[data-act="code"]').classList.toggle('hidden', !live);
|
||||
body.querySelector('[data-act="unpair"]').classList.toggle('hidden', !live);
|
||||
}
|
||||
|
||||
async function openHostModal() {
|
||||
try { await Lazy.load('feature:direct'); } catch(error) { toast(error.message); return; }
|
||||
const body = document.createElement('div');
|
||||
body.className = 'remote-host';
|
||||
body.innerHTML = `
|
||||
<div class="rh-body"></div>
|
||||
<label class="rh-name-row">This screen's name <input class="rh-name" maxlength="40" /></label>
|
||||
<div class="rh-buttons">
|
||||
<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="unpair">Unpair all phones</button>
|
||||
<button class="np-btn" type="button" data-act="stop">Stop</button>
|
||||
</div>`;
|
||||
body.addEventListener('click', (e) => {
|
||||
const kick = e.target.closest('[data-kick]');
|
||||
if (kick) { hostSend({ type: 'kick', rid: kick.dataset.kick }); return; }
|
||||
const act = e.target.closest('[data-act]');
|
||||
if (!act) return;
|
||||
if (act.dataset.act === 'start') startHosting();
|
||||
else if (act.dataset.act === 'stop') stopHosting();
|
||||
else if (act.dataset.act === 'code') hostSend({ type: 'new-code' });
|
||||
else if (act.dataset.act === 'unpair') unpairAll();
|
||||
else if (act.dataset.act === 'obs') { navigator.clipboard.writeText(LowerThird.url(location.origin, host.code)).then(() => toast('OBS URL copied'), () => toast('Copy blocked')); }
|
||||
else if (act.dataset.act === 'present') {
|
||||
// A second window on this computer (drag it to the projector display).
|
||||
window.open(`/?present=${host.code}`, 'ytp-presenter', 'popup,width=1280,height=720');
|
||||
}
|
||||
});
|
||||
body.querySelector('.rh-name').addEventListener('change', (e) => {
|
||||
const cfg = hostCfg();
|
||||
cfg.name = e.target.value.trim().slice(0, 40) || guessName('Desktop');
|
||||
save(HOST_KEY, cfg);
|
||||
if (cfg.enabled) hostConnect(); // the server learns names on connect
|
||||
});
|
||||
showModal('📱 Control this screen from a phone', body, [
|
||||
{ label: 'Done', primary: true, onClick: () => { hostModalOpen = false; closeModal(); } },
|
||||
]);
|
||||
hostModalOpen = true;
|
||||
renderHostModal();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Remote (this phone drives a host)
|
||||
// ==========================================================================
|
||||
const rc = { ws: null, open: false, state: null, stateAt: 0, queue: { items: [], idx: -1 }, retry: null, ping: null, seekTimer: null, latency: null, online: false, seeking: false, volTimer: null, drag: null };
|
||||
|
||||
const rcSend = (m) => { if (rc.ws && rc.ws.readyState === 1) rc.ws.send(JSON.stringify(m)); };
|
||||
const cmd = (name, args = {}) => rcSend({ type: 'cmd', cmd: name, ...args });
|
||||
|
||||
function setStatus(text, ok = false) {
|
||||
$('rvStatus').textContent = text;
|
||||
$('rvStatus').classList.toggle('ok', ok);
|
||||
}
|
||||
|
||||
function showPairForm(msg = '') {
|
||||
$('rvPair').classList.remove('hidden');
|
||||
$('rvControl').classList.add('hidden');
|
||||
$('rvHostName').textContent = 'Remote';
|
||||
setStatus('Not paired');
|
||||
$('rvPairMsg').textContent = msg;
|
||||
if (!$('rvName').value) $('rvName').value = (load(PAIR_KEY) || {}).name || guessName('Phone');
|
||||
}
|
||||
|
||||
function rcConnect() {
|
||||
const p = load(PAIR_KEY);
|
||||
if (!p || !rc.open) return;
|
||||
clearTimeout(rc.retry);
|
||||
try { if (rc.ws) rc.ws.close(); } catch { /* gone */ }
|
||||
$('rvPair').classList.add('hidden');
|
||||
$('rvControl').classList.remove('hidden');
|
||||
$('rvHostName').textContent = p.hostName || 'Screen';
|
||||
setStatus('Connecting…');
|
||||
const ws = new WebSocket(wsUrl({ role: 'remote', room: p.roomId, rid: p.remoteId, token: p.token, name: p.name || guessName('Phone') }));
|
||||
rc.ws = ws;
|
||||
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onRemoteMessage(m); };
|
||||
ws.onclose = (e) => {
|
||||
if (rc.ws !== ws) return;
|
||||
rc.ws = null;
|
||||
rc.online = false;
|
||||
clearInterval(rc.ping);
|
||||
if (e.code === 4001) { save(PAIR_KEY, null); showPairForm('This phone was unpaired by the screen — pair again.'); return; }
|
||||
if (!rc.open) return;
|
||||
setStatus(e.code === 4004 ? 'Screen offline — retrying…' : 'Reconnecting…');
|
||||
rc.retry = setTimeout(rcConnect, 4000);
|
||||
};
|
||||
}
|
||||
|
||||
function onRemoteMessage(m) {
|
||||
DirectMedia.attach('remote-client', { send: rcSend, peers: () => [{ id: 'host', name: 'Paired screen', files: rc.state?.directAvailable && rc.state?.v ? [rc.state.v.id] : [] }] });
|
||||
if (m.type === 'direct') { DirectMedia.handle('remote-client', m); return; }
|
||||
if (m.type === 'hello') {
|
||||
rc.online = true;
|
||||
$('rvHostName').textContent = m.hostName || 'Screen';
|
||||
if (m.state) { rc.state = m.state; rc.stateAt = Date.now(); }
|
||||
if (m.queue) rc.queue = m.queue;
|
||||
setStatus('Synced', true);
|
||||
clearInterval(rc.ping);
|
||||
const ping = () => rcSend({ type: 'ping', t: Date.now() });
|
||||
ping();
|
||||
rc.ping = setInterval(ping, 5000);
|
||||
renderRemote(); renderQueue();
|
||||
} else if (m.type === 'state') {
|
||||
rc.state = m.state; rc.stateAt = Date.now();
|
||||
renderRemote();
|
||||
} else if (m.type === 'queue') {
|
||||
rc.queue = m.queue || { items: [], idx: -1 };
|
||||
renderQueue();
|
||||
} else if (m.type === 'pong') {
|
||||
rc.latency = Date.now() - m.t;
|
||||
if (rc.online) setStatus(`Synced · ${rc.latency} ms`, true);
|
||||
} else if (m.type === 'host-offline') {
|
||||
rc.online = false;
|
||||
setStatus('Screen offline — waiting…');
|
||||
}
|
||||
}
|
||||
|
||||
function liveCur() {
|
||||
const s = rc.state;
|
||||
if (!s) return 0;
|
||||
const cur = s.paused ? s.cur : s.cur + (Date.now() - rc.stateAt) / 1000;
|
||||
return Math.min(cur, s.dur || cur);
|
||||
}
|
||||
|
||||
function renderRemote() {
|
||||
const s = rc.state;
|
||||
const v = s && s.v;
|
||||
$('rvTitle').textContent = v ? v.title : 'Nothing playing';
|
||||
$('rvChannel').textContent = v ? v.channel : '';
|
||||
let directPlay = $('rvDirectPlay');
|
||||
if (!directPlay) { directPlay = document.createElement('button'); directPlay.id = 'rvDirectPlay'; directPlay.className = 'np-btn'; directPlay.style.minHeight = '44px'; directPlay.innerHTML = TransportIcons.svg('play') + ' Play here from paired screen'; $('rvChannel').after(directPlay); }
|
||||
directPlay.hidden = !v || !s.directAvailable || !DirectMedia.enabled();
|
||||
directPlay.onclick = () => v && playVideoAt({ ...v, duration: s.dur || 0 }, liveCur());
|
||||
|
||||
const thumb = $('rvThumb');
|
||||
if (v && v.thumbnail) { if (thumb.getAttribute('src') !== v.thumbnail) thumb.src = v.thumbnail; thumb.classList.remove('hidden'); }
|
||||
else thumb.classList.add('hidden');
|
||||
TransportIcons.set($('rvToggle'), s && !s.paused ? 'pause' : 'play');
|
||||
$('rvDur').textContent = clock((s && s.dur) || 0);
|
||||
if (!rc.volTimer && document.activeElement !== $('rvVolume')) {
|
||||
const pct = Math.round(((s && s.volume) ?? 1) * 100);
|
||||
$('rvVolume').value = String(pct);
|
||||
$('rvVolVal').textContent = pct + '%';
|
||||
}
|
||||
const sleepMin = s && s.sleep ? Math.ceil(s.sleep / 60) : 0;
|
||||
$('rvSleep').textContent = sleepMin ? `⏱ ${sleepMin} min` : '⏱ Sleep';
|
||||
$('rvSleep').classList.toggle('on', !!sleepMin);
|
||||
$('rvService').classList.toggle('on', !!(s && s.service));
|
||||
renderSeek();
|
||||
}
|
||||
|
||||
function renderSeek() {
|
||||
if (rc.seeking || !rc.open) return;
|
||||
const s = rc.state;
|
||||
const cur = liveCur();
|
||||
$('rvCur').textContent = clock(cur);
|
||||
$('rvSeek').value = s && s.dur ? String(Math.round((cur / s.dur) * 1000)) : '0';
|
||||
}
|
||||
|
||||
function renderQueue() {
|
||||
const q = rc.queue || { items: [], idx: -1 };
|
||||
const up = q.items.map((v, i) => ({ ...v, i })).filter((v) => v.i > q.idx);
|
||||
$('rvQueueCount').textContent = up.length ? `${up.length} song${up.length === 1 ? '' : 's'}` : '';
|
||||
const list = $('rvQueue');
|
||||
list.innerHTML = up.length ? up.map((v) => `
|
||||
<div class="rv-row" data-i="${v.i}">
|
||||
<span class="rv-handle" aria-label="Drag to reorder">⠿</span>
|
||||
<div class="rv-row-text">
|
||||
<div class="rv-row-title">${escapeHtml(v.title)}</div>
|
||||
<div class="rv-row-sub">${escapeHtml(v.channel)}</div>
|
||||
</div>
|
||||
<span class="rv-row-dur">${v.duration ? clock(v.duration) : ''}</span>
|
||||
<button class="rv-row-x" type="button" data-rm="${v.i}" aria-label="Remove">✕</button>
|
||||
</div>`).join('') : '<div class="rv-empty">Nothing queued after this song.</div>';
|
||||
}
|
||||
|
||||
// ---- drag to reorder (pointer events, works for touch + mouse) ----
|
||||
function onDragStart(e) {
|
||||
const handle = e.target.closest('.rv-handle');
|
||||
if (!handle) return;
|
||||
const row = handle.closest('.rv-row');
|
||||
e.preventDefault();
|
||||
const rows = [...$('rvQueue').querySelectorAll('.rv-row')];
|
||||
rc.drag = { row, rows, startY: e.clientY, from: Number(row.dataset.i), to: Number(row.dataset.i), h: row.offsetHeight };
|
||||
row.classList.add('dragging');
|
||||
row.setPointerCapture(e.pointerId);
|
||||
row.addEventListener('pointermove', onDragMove);
|
||||
row.addEventListener('pointerup', onDragEnd, { once: true });
|
||||
row.addEventListener('pointercancel', onDragEnd, { once: true });
|
||||
}
|
||||
function onDragMove(e) {
|
||||
const d = rc.drag;
|
||||
if (!d) return;
|
||||
const dy = e.clientY - d.startY;
|
||||
d.row.style.transform = `translateY(${dy}px)`;
|
||||
const pos = d.rows.indexOf(d.row);
|
||||
const target = Math.max(0, Math.min(d.rows.length - 1, pos + Math.round(dy / d.h)));
|
||||
d.to = Number(d.rows[target].dataset.i);
|
||||
}
|
||||
function onDragEnd() {
|
||||
const d = rc.drag;
|
||||
rc.drag = null;
|
||||
if (!d) return;
|
||||
d.row.removeEventListener('pointermove', onDragMove);
|
||||
d.row.classList.remove('dragging');
|
||||
d.row.style.transform = '';
|
||||
if (d.to !== d.from) {
|
||||
// Optimistic: reorder locally now, the host's queue push confirms it.
|
||||
const items = rc.queue.items.slice();
|
||||
const [v] = items.splice(d.from, 1);
|
||||
items.splice(d.to, 0, v);
|
||||
rc.queue = { ...rc.queue, items };
|
||||
renderQueue();
|
||||
cmd('move', { from: d.from, to: d.to });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- add to queue: search sheet ----
|
||||
function openAddSheet() {
|
||||
const body = document.createElement('div');
|
||||
body.className = 'rv-add';
|
||||
body.innerHTML = `
|
||||
<form class="rv-add-form"><input class="rv-add-q" type="search" placeholder="Search YouTube…" /><button class="np-btn" type="submit">Search</button></form>
|
||||
<div class="rv-add-results"></div>`;
|
||||
const results = body.querySelector('.rv-add-results');
|
||||
let found = [];
|
||||
body.querySelector('form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const q = body.querySelector('.rv-add-q').value.trim();
|
||||
if (!q) return;
|
||||
results.innerHTML = '<div class="rv-empty">Searching…</div>';
|
||||
try {
|
||||
const res = await API.search(q);
|
||||
found = (res && res.ok && res.results) || [];
|
||||
} catch { found = []; }
|
||||
results.innerHTML = found.length ? found.slice(0, 20).map((v, i) => `
|
||||
<button class="rv-add-item" type="button" data-k="${i}">
|
||||
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" decoding="async" />
|
||||
<span><b>${escapeHtml(v.title || '')}</b><small>${escapeHtml(v.channel || '')}${v.duration ? ' · ' + clock(v.duration) : ''}</small></span>
|
||||
</button>`).join('') : '<div class="rv-empty">No results.</div>';
|
||||
});
|
||||
results.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-k]');
|
||||
if (!b) return;
|
||||
const v = found[Number(b.dataset.k)];
|
||||
if (!v) return;
|
||||
cmd('add', { video: { id: v.id, title: v.title, channel: v.channel, duration: v.duration, thumbnail: v.thumbnail } });
|
||||
b.classList.add('added');
|
||||
toast(`Added “${v.title}”`);
|
||||
});
|
||||
showModal('+ Add to the screen’s queue', body, [{ label: 'Done', primary: true, onClick: closeModal }]);
|
||||
setTimeout(() => body.querySelector('.rv-add-q').focus(), 50);
|
||||
}
|
||||
|
||||
async function doPair(codeRaw) {
|
||||
const code = String(codeRaw || '').replace(/\D/g, '');
|
||||
const name = $('rvName').value.trim() || guessName('Phone');
|
||||
if (code.length !== 6) { $('rvPairMsg').textContent = 'Enter the 6-digit code from the screen.'; return; }
|
||||
$('rvPairMsg').textContent = 'Pairing…';
|
||||
try {
|
||||
const res = await fetch('/api/remote/pair', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name }),
|
||||
});
|
||||
const j = await res.json().catch(() => null);
|
||||
if (!j || !j.ok) { $('rvPairMsg').textContent = '⚠ ' + ((j && j.error) || `HTTP ${res.status}`); return; }
|
||||
save(PAIR_KEY, { roomId: j.roomId, remoteId: j.remoteId, token: j.token, hostName: j.hostName, name: j.name });
|
||||
$('rvCode').value = '';
|
||||
rcConnect();
|
||||
} catch {
|
||||
$('rvPairMsg').textContent = '⚠ Network error — try again';
|
||||
}
|
||||
}
|
||||
|
||||
async function openRemoteView({ code = '' } = {}) {
|
||||
try { await Lazy.load('feature:direct'); } catch(error) { toast(error.message); return; }
|
||||
rc.open = true;
|
||||
rc.seekTimer = VisibleTimer.setIntervalWhenVisible(renderSeek, 500);
|
||||
// This phone is the remote now — don't also play here.
|
||||
try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ }
|
||||
$('remoteView').classList.remove('hidden');
|
||||
document.body.classList.add('remote-open');
|
||||
if (code) { save(PAIR_KEY, null); showPairForm(); $('rvCode').value = code; doPair(code); return; }
|
||||
if (load(PAIR_KEY)) rcConnect(); else showPairForm();
|
||||
}
|
||||
|
||||
function closeRemoteView() {
|
||||
rc.open = false;
|
||||
rc.seekTimer?.clear();
|
||||
rc.seekTimer = null;
|
||||
clearTimeout(rc.retry);
|
||||
clearInterval(rc.ping);
|
||||
const ws = rc.ws;
|
||||
rc.ws = null;
|
||||
try { if (ws) ws.close(); } catch { /* gone */ }
|
||||
$('remoteView').classList.add('hidden');
|
||||
document.body.classList.remove('remote-open');
|
||||
}
|
||||
|
||||
function openChooser() {
|
||||
const hosting = hostCfg().enabled;
|
||||
const paired = load(PAIR_KEY);
|
||||
const body = document.createElement('div');
|
||||
body.innerHTML = `<p class="modal-text">Play on a computer hooked to the TV or speakers, and drive it from your phone.</p>`;
|
||||
showModal('📱 Remote', body, [
|
||||
{ label: paired ? `Control “${paired.hostName}”` : 'Use this phone as a remote', onClick: () => { closeModal(); openRemoteView(); } },
|
||||
{ label: hosting ? 'This screen: remote on' : 'Control this screen from a phone', primary: true, onClick: () => { closeModal(); openHostModal(); } },
|
||||
]);
|
||||
}
|
||||
|
||||
function bindUi() {
|
||||
$('rvClose').addEventListener('click', closeRemoteView);
|
||||
$('rvMenu').addEventListener('click', () => {
|
||||
const p = load(PAIR_KEY);
|
||||
showModal('Remote options', document.createTextNode(p ? `Paired with “${p.hostName}”.` : 'Not paired.'), [
|
||||
{ label: 'Close', onClick: closeModal },
|
||||
...(p ? [{ label: 'Unpair this phone', danger: true, onClick: () => { closeModal(); save(PAIR_KEY, null); try { rc.ws && rc.ws.close(); } catch { /* gone */ } showPairForm('Unpaired.'); } }] : []),
|
||||
]);
|
||||
});
|
||||
$('rvPairBtn').addEventListener('click', () => doPair($('rvCode').value));
|
||||
$('rvCode').addEventListener('keydown', (e) => { if (e.key === 'Enter') doPair($('rvCode').value); });
|
||||
$('rvToggle').addEventListener('click', () => {
|
||||
cmd('toggle');
|
||||
if (rc.state) { rc.state.cur = liveCur(); rc.stateAt = Date.now(); rc.state.paused = !rc.state.paused; renderRemote(); }
|
||||
});
|
||||
$('rvPrev').addEventListener('click', () => cmd('prev'));
|
||||
$('rvNext').addEventListener('click', () => cmd('next'));
|
||||
const seek = $('rvSeek');
|
||||
seek.addEventListener('input', () => {
|
||||
rc.seeking = true;
|
||||
const dur = (rc.state && rc.state.dur) || 0;
|
||||
$('rvCur').textContent = clock((Number(seek.value) / 1000) * dur);
|
||||
});
|
||||
seek.addEventListener('change', () => {
|
||||
const dur = (rc.state && rc.state.dur) || 0;
|
||||
const t = (Number(seek.value) / 1000) * dur;
|
||||
cmd('seek', { t });
|
||||
if (rc.state) { rc.state.cur = t; rc.stateAt = Date.now(); }
|
||||
rc.seeking = false;
|
||||
});
|
||||
$('rvVolume').addEventListener('input', (e) => {
|
||||
const pct = Number(e.target.value);
|
||||
$('rvVolVal').textContent = pct + '%';
|
||||
clearTimeout(rc.volTimer);
|
||||
rc.volTimer = setTimeout(() => { rc.volTimer = null; cmd('volume', { v: pct / 100 }); }, 120);
|
||||
});
|
||||
$('rvQueue').addEventListener('pointerdown', onDragStart);
|
||||
$('rvQueue').addEventListener('click', (e) => {
|
||||
const rm = e.target.closest('[data-rm]');
|
||||
if (rm) { cmd('remove', { i: Number(rm.dataset.rm) }); return; }
|
||||
if (e.target.closest('.rv-handle')) return;
|
||||
const row = e.target.closest('.rv-row');
|
||||
if (row) cmd('jump', { i: Number(row.dataset.i) });
|
||||
});
|
||||
$('rvAdd').addEventListener('click', openAddSheet);
|
||||
$('rvSleep').addEventListener('click', () => {
|
||||
const options = [0, 15, 30, 45, 60, 90];
|
||||
const curMin = rc.state && rc.state.sleep ? Math.ceil(rc.state.sleep / 60) : 0;
|
||||
const next = options.find((o) => o > curMin) ?? 0;
|
||||
cmd('sleep', { min: next });
|
||||
toast(next ? `Screen pauses in ${next} min` : 'Sleep timer off');
|
||||
});
|
||||
$('rvService').addEventListener('click', () => cmd('service', { on: !(rc.state && rc.state.service) }));
|
||||
// Hosts keep phones current once a second while anything plays.
|
||||
setInterval(() => { if (host.ws && host.remotes.length) { pushState(); pushQueue(); } }, 1000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && rc.open && !rc.ws) rcConnect();
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
bindUi();
|
||||
if (hostCfg().enabled) hostConnect();
|
||||
// A scanned QR / shared link: /?pair=482913
|
||||
let code = '';
|
||||
try { code = (new URLSearchParams(location.search).get('pair') || '').replace(/\D/g, ''); } catch { /* no URL */ }
|
||||
if (code) {
|
||||
try {
|
||||
const u = new URL(location.href);
|
||||
u.searchParams.delete('pair');
|
||||
history.replaceState(null, '', u.pathname + u.search + u.hash);
|
||||
} catch { /* leave the URL */ }
|
||||
openRemoteView({ code });
|
||||
}
|
||||
}
|
||||
|
||||
return { boot, changed, openChooser, openHostModal };
|
||||
})();
|
||||
@@ -2,9 +2,10 @@
|
||||
.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; }
|
||||
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-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; }
|
||||
.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; }
|
||||
@@ -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: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; }
|
||||
@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';
|
||||
const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase());
|
||||
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"/>'
|
||||
};
|
||||
function button(doc, kind, label, action) {
|
||||
const b = doc.createElement('button'); b.type = 'button';
|
||||
b.className = `saved-file-action${kind === 'delete' ? ' danger' : ''}`;
|
||||
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;
|
||||
}
|
||||
function actions(row, video, exportFile, deleteFile) {
|
||||
|
||||
321
frontend/section-rail.js
Normal file
321
frontend/section-rail.js
Normal file
@@ -0,0 +1,321 @@
|
||||
/* Eager UI definitions. Read app lexical state only when called after initialization. */
|
||||
const SectionRail = (() => {
|
||||
const CLASSIC_ICONS = {
|
||||
player: '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>',
|
||||
meta: '<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"/>',
|
||||
notes: '<path d="M14 3H4v18h16V11M9 15l2-5 7-7 3 3-7 7z"/>',
|
||||
related: '<path d="m10 14 4-4m-5 6-1 1a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0m0 10a4 4 0 0 0 6 0l4-4a4 4 0 0 0-6-6l-1 1"/>',
|
||||
list: '<path d="M9 6h12M9 12h12M9 18h12M3 6h1M3 12h1M3 18h1"/>',
|
||||
};
|
||||
const SECTIONS = [
|
||||
{ id: 'player', sel: '.player-stage', icon: '📺', label: 'Player' },
|
||||
{ id: 'meta', sel: '#nowPlayingMeta', icon: '🎵', label: 'Now playing', fold: true },
|
||||
{ id: 'notes', sel: '#notesPanel', icon: '📝', label: 'Lyrics', fold: true },
|
||||
{ id: 'related', sel: '#relatedPanel', icon: '🔗', label: 'Related', fold: true },
|
||||
{ id: 'list', sel: '#listPane', icon: '📋', label: 'List' },
|
||||
];
|
||||
const st = { active: '', btns: new Map(), labelTimer: null, raf: 0, bottom: -1 };
|
||||
const rail = () => document.getElementById('sectionRail');
|
||||
const folds = () => (data.settings.foldedCards ||= {});
|
||||
const isFolded = (id) => !!folds()[id];
|
||||
const elOf = (s) => document.querySelector(s.sel);
|
||||
// Only cards that are actually on screen belong in the rail.
|
||||
const visible = () => SECTIONS.filter((s) => {
|
||||
const el = elOf(s);
|
||||
return el && !el.classList.contains('hidden') && el.offsetParent !== null;
|
||||
});
|
||||
|
||||
function applyFold(sec) {
|
||||
const el = elOf(sec);
|
||||
if (!el || !sec.fold) return;
|
||||
el.classList.toggle('folded', isFolded(sec.id));
|
||||
if (sec.id === 'notes') {
|
||||
const b = document.getElementById('notesFoldBtn');
|
||||
if (b) b.setAttribute('aria-expanded', String(!isFolded(sec.id)));
|
||||
}
|
||||
// The Related card already had its own +/− button; keep the two in step.
|
||||
if (sec.id === 'related') {
|
||||
relatedCollapsed = isFolded(sec.id);
|
||||
const rl = document.getElementById('relatedList');
|
||||
const rb = document.getElementById('relatedToggleBtn');
|
||||
if (rl) rl.classList.toggle('hidden', relatedCollapsed);
|
||||
if (rb) rb.textContent = relatedCollapsed ? '+' : '−';
|
||||
}
|
||||
}
|
||||
|
||||
function setFolded(id, on) {
|
||||
const sec = SECTIONS.find((x) => x.id === id);
|
||||
if (!sec || !sec.fold) return;
|
||||
folds()[id] = !!on;
|
||||
persist();
|
||||
applyFold(sec);
|
||||
paint(); // NOT render() — folding a card must not rebuild the list
|
||||
}
|
||||
|
||||
function flashLabel(btn) {
|
||||
clearTimeout(st.labelTimer);
|
||||
[...st.btns.values()].forEach((b) => b.classList.remove('show-label'));
|
||||
btn.classList.add('show-label');
|
||||
st.labelTimer = setTimeout(() => btn.classList.remove('show-label'), 1400);
|
||||
}
|
||||
|
||||
function onTap(sec, btn) {
|
||||
const el = elOf(sec);
|
||||
if (!el) return;
|
||||
flashLabel(btn);
|
||||
if (sec.id === 'player' || sec.id === 'meta') restoreNowPlayingList();
|
||||
if (st.active === sec.id && sec.fold) {
|
||||
// Already here: this is the second tap — fold or unfold the card.
|
||||
setFolded(sec.id, !isFolded(sec.id));
|
||||
if (isFolded(sec.id)) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return;
|
||||
}
|
||||
st.active = sec.id; // claim it now so the next tap folds
|
||||
paint();
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function build() {
|
||||
const r = rail();
|
||||
if (!r) return;
|
||||
const items = visible();
|
||||
// A rail with one entry is noise; so is one on the empty home screen.
|
||||
if (items.length < 2) { r.classList.add('hidden'); r.innerHTML = ''; st.btns.clear(); return; }
|
||||
const key = items.map((s) => s.id).join(',');
|
||||
if (r.dataset.key !== key) {
|
||||
r.dataset.key = key;
|
||||
r.innerHTML = '';
|
||||
st.btns.clear();
|
||||
for (const sec of items) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'sec-rail-btn';
|
||||
btn.dataset.sec = sec.id;
|
||||
btn.setAttribute('aria-label', sec.label);
|
||||
btn.innerHTML = `<span class="legacy-nav-icon" aria-hidden="true">${sec.icon}</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true">${CLASSIC_ICONS[sec.id]}</svg><span class="sec-rail-label">${escapeHtml(sec.label)}</span>`;
|
||||
btn.addEventListener('click', () => onTap(sec, btn));
|
||||
r.appendChild(btn);
|
||||
st.btns.set(sec.id, btn);
|
||||
}
|
||||
}
|
||||
r.classList.remove('hidden');
|
||||
placeRail();
|
||||
paint();
|
||||
}
|
||||
|
||||
// The page does NOT scroll the document: `.player-pane` is the scroller in
|
||||
// the browser, but the installed PWA in portrait scrolls `.body` instead
|
||||
// (`.player-pane` goes overflow:visible there). So find the real scroller
|
||||
// rather than assuming one.
|
||||
function scrollerOf(el) {
|
||||
for (let e = el && el.parentElement; e; e = e.parentElement) {
|
||||
const cs = getComputedStyle(e);
|
||||
if (/(auto|scroll)/.test(cs.overflowY) && e.scrollHeight > e.clientHeight + 4) return e;
|
||||
}
|
||||
return document.scrollingElement || document.documentElement;
|
||||
}
|
||||
|
||||
// The visible box to measure against — the scroller's own rect, or the
|
||||
// window when the document itself scrolls.
|
||||
function viewBox() {
|
||||
const first = elOf(visible()[0] || SECTIONS[0]);
|
||||
const sc = scrollerOf(first);
|
||||
if (!sc || sc === document.scrollingElement || sc === document.documentElement) {
|
||||
return { top: 0, height: window.innerHeight };
|
||||
}
|
||||
const r = sc.getBoundingClientRect();
|
||||
return { top: r.top, height: r.height };
|
||||
}
|
||||
|
||||
// Which card is the reader looking at? The one whose top is closest to a
|
||||
// line a third of the way down the visible box — steadier than "first
|
||||
// visible", which flickers between two cards at a boundary.
|
||||
function currentId() {
|
||||
const items = visible();
|
||||
if (!items.length) return '';
|
||||
const box = viewBox();
|
||||
const line = box.top + box.height / 3;
|
||||
let best = '', bestD = Infinity;
|
||||
let near = items[0].id, nearD = Infinity; // fallback: closest card overall
|
||||
for (const sec of items) {
|
||||
const el = elOf(sec);
|
||||
if (!el) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
const d = Math.abs(r.top - line);
|
||||
// Distance to the visible band, for the fallback below.
|
||||
const outside = Math.max(box.top - r.bottom, r.top - (box.top + box.height), 0);
|
||||
if (outside < nearD || (outside === nearD && d < bestD)) { nearD = outside; near = sec.id; }
|
||||
if (r.bottom < box.top || r.top > box.top + box.height) continue;
|
||||
if (d < bestD) { bestD = d; best = sec.id; }
|
||||
}
|
||||
// Scrolled past the last card (or a card is taller than the viewport and
|
||||
// its top has gone by): never leave the rail with nothing highlighted.
|
||||
return best || near;
|
||||
}
|
||||
|
||||
// The mini player and the bottom nav are both position:fixed bottom:0, and
|
||||
// either can be absent (nothing playing / landscape). Measure whatever is
|
||||
// actually pinned down there and park the rail above it, rather than
|
||||
// guessing an offset that is wrong on the next phone.
|
||||
function placeRail() {
|
||||
const r = rail();
|
||||
let occupied = 0;
|
||||
for (const id of ['bottomNav', 'miniBar']) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el || el.classList.contains('hidden')) continue;
|
||||
const box = el.getBoundingClientRect();
|
||||
if (!box.height) continue;
|
||||
if (getComputedStyle(el).position !== 'fixed') continue;
|
||||
// Count any fixed bar sitting in the lower half. It must NOT be required
|
||||
// to touch the viewport bottom: on an iPhone XR the bottom nav ends
|
||||
// above the home-indicator gap, so a "bottom >= innerHeight" test missed
|
||||
// it entirely and the rail was parked underneath it.
|
||||
if (box.top > window.innerHeight * 0.5) occupied = Math.max(occupied, window.innerHeight - box.top);
|
||||
}
|
||||
// 14px was too tight: the mini player is a floating CARD with a shadow and
|
||||
// rounded corners, so its visual edge reads higher than its box.
|
||||
const want = Math.round(occupied + 24);
|
||||
if (st.bottom !== want) {
|
||||
st.bottom = want;
|
||||
if (r) r.style.setProperty('--rail-bottom', want + 'px');
|
||||
// Published for anything else that must sit above the bottom bars —
|
||||
// the watch-party panel's leave button was hidden behind them.
|
||||
document.documentElement.style.setProperty('--bottom-chrome', Math.round(occupied) + 'px');
|
||||
}
|
||||
}
|
||||
|
||||
function paint() {
|
||||
for (const [id, btn] of st.btns) {
|
||||
btn.classList.toggle('active', id === st.active);
|
||||
btn.classList.toggle('folded', isFolded(id));
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (st.raf) return;
|
||||
st.raf = requestAnimationFrame(() => {
|
||||
st.raf = 0;
|
||||
// Panels appear and disappear outside render() too (the Related card
|
||||
// shows once a video's related list arrives), and a card with no icon
|
||||
// would leave the rail with nothing highlighted. build() is a no-op
|
||||
// unless the set of visible cards actually changed.
|
||||
build();
|
||||
placeRail();
|
||||
const id = currentId();
|
||||
if (id && id !== st.active) { st.active = id; paint(); }
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
const fb = document.getElementById('notesFoldBtn');
|
||||
if (fb) fb.addEventListener('click', () => setFolded('notes', !isFolded('notes')));
|
||||
SECTIONS.forEach(applyFold);
|
||||
// Scroll events from an element do NOT bubble, so a window listener never
|
||||
// fires here. A capture-phase listener on document catches them all, and
|
||||
// keeps working when the scroller changes between browser and installed
|
||||
// PWA (different elements scroll in each).
|
||||
document.addEventListener('scroll', onScroll, { passive: true, capture: true });
|
||||
window.addEventListener('resize', () => { build(); placeRail(); onScroll(); });
|
||||
// The mini player appears AFTER the scroll that reveals it, so a scroll
|
||||
// handler alone measures the layout as it was a moment ago and parks the
|
||||
// rail underneath it. Watch the two bottom bars for real.
|
||||
const watch = ['bottomNav', 'miniBar'].map((id) => document.getElementById(id)).filter(Boolean);
|
||||
if (watch.length && typeof MutationObserver !== 'undefined') {
|
||||
// The mini bar slides in over 0.25s, so a measurement taken the instant
|
||||
// it appears catches it mid-animation and lands the rail a few pixels
|
||||
// too low. Measure now and again once it has settled.
|
||||
const mo = new MutationObserver(() => { placeRail(); setTimeout(placeRail, 320); });
|
||||
watch.forEach((el) => mo.observe(el, { attributes: true, attributeFilter: ['class', 'style'] }));
|
||||
}
|
||||
if (watch.length && typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(() => placeRail());
|
||||
watch.forEach((el) => ro.observe(el));
|
||||
}
|
||||
build();
|
||||
onScroll();
|
||||
}
|
||||
|
||||
// Called from render(): cards appear and disappear as videos load.
|
||||
function refresh() {
|
||||
SECTIONS.forEach(applyFold);
|
||||
build();
|
||||
placeRail();
|
||||
onScroll();
|
||||
}
|
||||
|
||||
return { boot, refresh, setFolded, isFolded };
|
||||
})();
|
||||
|
||||
/* 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);
|
||||
34
frontend/settings-persist.test.js
Normal file
34
frontend/settings-persist.test.js
Normal file
@@ -0,0 +1,34 @@
|
||||
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] } };
|
||||
const context = vm.createContext({ WEB: true, data: { settings, profile: { name: 'test', syncedAt: 200 } }, DEFAULT_SETTINGS: { saveBeforePlay: false, autoPreload: true, layout: 'classic' }, 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('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]);
|
||||
});
|
||||
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.equal(c.data.profile.syncedAt, 300);
|
||||
});
|
||||
@@ -24,7 +24,8 @@
|
||||
const icon = paths => `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${paths || '<circle cx="12" cy="12" r="7"/>'}</svg>`;
|
||||
function mount(host, context = {}) {
|
||||
dispose();
|
||||
const document = host.ownerDocument, win = document.defaultView, sections = registry.list(), panels = new Map(), rows = new Map(), cleanups = [];
|
||||
const document = host.ownerDocument, win = document.defaultView, sections = registry.list(), panels = new Map(), rows = new Map(), cleanups = [], searchLoads = new Set();
|
||||
let mounted = true;
|
||||
let remembered = ''; try { remembered = win.sessionStorage.getItem('ytpSettingsSection') || ''; } catch {}
|
||||
let active = registry.get(remembered) ? remembered : sections[0]?.id;
|
||||
host.classList.add('settings-browser');
|
||||
@@ -45,14 +46,23 @@
|
||||
const text = document.createElement('span'), title = document.createElement('b'), summary = document.createElement('small'); title.textContent = section.title; text.append(title, summary); button.append(text);
|
||||
button.setAttribute('aria-controls', 'settings-section-' + section.id); navigation.append(button); rows.set(section.id, { button, summary });
|
||||
const panel = document.createElement('section'); panel.id = 'settings-section-' + section.id; panel.className = 'settings-section'; panel.setAttribute('aria-label', section.title); panels.set(section.id, panel); detail.append(panel);
|
||||
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
|
||||
if (!section.lazyGroup) {
|
||||
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
|
||||
}
|
||||
button.addEventListener('click', () => open(section.id));
|
||||
if(section.lazyGroup)root.Lazy.intent(button,section.lazyGroup);
|
||||
}
|
||||
}
|
||||
grid.append(navigation, detail); host.replaceChildren(toolbar, results, empty, grid);
|
||||
function summaries() { for (const section of sections) { try { rows.get(section.id).summary.textContent = section.summary(context) || ''; } catch {} } }
|
||||
function show(id, inDetail, focusId) {
|
||||
if (registry.get(id)) active = id;
|
||||
const section = sections.find(section => section.id === active);
|
||||
const panel = panels.get(active);
|
||||
if ((inDetail || win.matchMedia?.('(min-width: 861px)').matches) && section?.lazyGroup && !panel.dataset.rendered) {
|
||||
panel.dataset.rendered = '1';
|
||||
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
|
||||
}
|
||||
host.dataset.detail = inDetail ? '1' : '0';
|
||||
for (const [key, panel] of panels) panel.hidden = key !== active;
|
||||
for (const [key, row] of rows) row.button.setAttribute('aria-current', key === active ? 'true' : 'false');
|
||||
@@ -71,6 +81,19 @@
|
||||
function filter() {
|
||||
const query = input.value.trim(); results.replaceChildren(); results.hidden = !query; grid.hidden = !!query;
|
||||
if (!query) { empty.hidden = true; return; }
|
||||
// Searching is explicit feature intent. Mount optional controls so their
|
||||
// existing labels/help remain searchable without parsing them on opening Settings.
|
||||
for (const section of sections) {
|
||||
const panel=panels.get(section.id);
|
||||
if(!section.lazyGroup || panel.dataset.rendered || searchLoads.has(section.id))continue;
|
||||
searchLoads.add(section.id);
|
||||
root.Lazy.load(section.lazyGroup).then(()=>{
|
||||
if(!mounted || !host.isConnected || panel.dataset.rendered)return;
|
||||
panel.dataset.rendered='1';
|
||||
const cleanup=registry.get(section.id).render(panel,context);if(typeof cleanup==='function')cleanups.push(cleanup);
|
||||
filter();
|
||||
}).catch(()=>searchLoads.delete(section.id));
|
||||
}
|
||||
const entries = [];
|
||||
for (const section of sections) {
|
||||
entries.push({ title: section.title, description: section.description || '', section: section.id });
|
||||
@@ -92,11 +115,11 @@
|
||||
const pop = event => { if (!host.isConnected) return; show(event.state?.ytpSettingsSection || active, !!event.state?.ytpSettingsSection); if (!event.state?.ytpSettingsSection) rows.get(active)?.button.focus({ preventScroll: true }); };
|
||||
win.addEventListener('popstate', pop); input.addEventListener('input', filter);
|
||||
host.addEventListener('change', summaries); host.addEventListener('input', summaries);
|
||||
const observer = new win.MutationObserver(summaries); observer.observe(detail, { childList: true, subtree: true, characterData: true });
|
||||
const observer = new win.MutationObserver(()=>{summaries();if(input.value.trim())filter();}); observer.observe(detail, { childList: true, subtree: true, characterData: true });
|
||||
const state = win.history.state?.ytpSettingsSection;
|
||||
if (!state) win.history.replaceState({ ...win.history.state, ytpSettingsSection: null }, '', win.location.href);
|
||||
show(state || active, !!state); summaries();
|
||||
dispose = () => { observer.disconnect(); win.removeEventListener('popstate', pop); cleanups.forEach(fn => fn()); };
|
||||
dispose = () => { mounted=false;observer.disconnect(); win.removeEventListener('popstate', pop); cleanups.forEach(fn => fn()); };
|
||||
return { open, refresh: summaries, dispose };
|
||||
}
|
||||
const api = { ...registry, mount, search, normalize, createRegistry, clusters };
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
wasm = null;
|
||||
try {
|
||||
if (typeof WebAssembly === 'undefined') return wasm;
|
||||
if (!root.SHA256_WASM_B64 && typeof importScripts === 'function') importScripts('/sha256-wasm.js');
|
||||
if (!root.SHA256_WASM_B64 && typeof importScripts === 'function') importScripts(root.__ASSET_URLS__?.wasm || '/sha256-wasm.js');
|
||||
let b64 = root.SHA256_WASM_B64;
|
||||
if (!b64 && typeof require === 'function') b64 = require('./sha256-wasm.js');
|
||||
if (!b64) return wasm;
|
||||
|
||||
221
frontend/share-external.js
Normal file
221
frontend/share-external.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/* Lazy classic chunk; its executing singleton stays pinned until reload. */
|
||||
(window.YT ||= {}).Share = (() => {
|
||||
const appLink = (id, t) => `${location.origin}/?v=${encodeURIComponent(id)}${t >= 1 ? `&t=${Math.floor(t)}` : ''}`;
|
||||
const ytLink = (id, t) => `https://youtu.be/${encodeURIComponent(id)}${t >= 1 ? `?t=${Math.floor(t)}` : ''}`;
|
||||
|
||||
async function copy(text, label) {
|
||||
try { await navigator.clipboard.writeText(text); toast(`${label} copied ✓`); }
|
||||
catch { toast('Copy blocked — long-press the link to copy it'); }
|
||||
}
|
||||
|
||||
function open() {
|
||||
const meta = current && current.meta;
|
||||
if (!meta || !meta.id || meta.custom) { toast('Play a YouTube video first'); return; }
|
||||
const t0 = Math.floor(Player.master.currentTime || 0);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'share-moment';
|
||||
body.innerHTML = `
|
||||
<label class="sm-row">Moment <input class="sm-time" inputmode="decimal" value="${LyricsCore.fmtClock(t0)}" /></label>
|
||||
<div class="sm-links">
|
||||
<div class="sm-link"><span>This app</span><code class="sm-app"></code><button class="np-btn" type="button" data-act="app">Copy</button></div>
|
||||
${isUploadId(meta.id) ? '' : '<div class="sm-link"><span>YouTube</span><code class="sm-yt"></code><button class="np-btn" type="button" data-act="yt">Copy</button></div>'}
|
||||
</div>
|
||||
<div class="sm-gif">
|
||||
<div class="sm-gif-controls">
|
||||
<label>GIF length <select class="sm-len"><option value="2">2 s</option><option value="3" selected>3 s</option><option value="5">5 s</option></select></label>
|
||||
<button class="np-btn" type="button" data-act="gif">🎞 Make GIF</button>
|
||||
</div>
|
||||
<div class="sm-gif-out"></div>
|
||||
</div>
|
||||
<div class="sm-gif sm-bite">
|
||||
<div class="sm-gif-controls">
|
||||
<label>🔔 Soundbite <input class="sm-b-start" inputmode="decimal" /></label>
|
||||
<label>to <input class="sm-b-end" inputmode="decimal" /></label>
|
||||
<select class="sm-b-fmt"><option value="mp3">MP3</option><option value="m4r">iPhone ringtone (.m4r)</option></select>
|
||||
<button class="np-btn" type="button" data-act="bite">Make</button>
|
||||
</div>
|
||||
<div class="sm-bite-out"></div>
|
||||
</div>`;
|
||||
const timeIn = body.querySelector('.sm-time');
|
||||
const cur = () => { const v = LyricsCore.parseStamp(timeIn.value); return v === null ? t0 : v; };
|
||||
const refresh = () => {
|
||||
body.querySelector('.sm-app').textContent = appLink(meta.id, cur());
|
||||
const yt = body.querySelector('.sm-yt');
|
||||
if (yt) yt.textContent = ytLink(meta.id, cur());
|
||||
};
|
||||
timeIn.addEventListener('input', refresh);
|
||||
timeIn.addEventListener('keydown', (e) => e.stopPropagation());
|
||||
refresh();
|
||||
// Soundbite range: the A-B loop when one is set, else the next 20 s.
|
||||
const bStart = abA !== null ? abA : t0;
|
||||
const bEnd = abB !== null && abB > bStart ? abB : bStart + 20;
|
||||
body.querySelector('.sm-b-start').value = LyricsCore.fmtClock(bStart);
|
||||
body.querySelector('.sm-b-end').value = LyricsCore.fmtClock(bEnd);
|
||||
body.querySelectorAll('.sm-bite input').forEach((i) => i.addEventListener('keydown', (e) => e.stopPropagation()));
|
||||
let biteBlob = null;
|
||||
let gifBlob = null;
|
||||
body.addEventListener('click', async (e) => {
|
||||
const act = e.target.closest('[data-act]');
|
||||
if (!act) return;
|
||||
if (act.dataset.act === 'app') copy(appLink(meta.id, cur()), 'App link');
|
||||
else if (act.dataset.act === 'yt') copy(ytLink(meta.id, cur()), 'YouTube link');
|
||||
else if (act.dataset.act === 'gif') {
|
||||
const out = body.querySelector('.sm-gif-out');
|
||||
out.innerHTML = '<div class="sm-busy">Making the GIF…</div>';
|
||||
act.disabled = true;
|
||||
try {
|
||||
const d = body.querySelector('.sm-len').value;
|
||||
const res = await fetch(`/api/media/${encodeURIComponent(meta.id)}/gif?t=${cur()}&d=${d}&w=480`);
|
||||
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); }
|
||||
gifBlob = await res.blob();
|
||||
const url = URL.createObjectURL(gifBlob);
|
||||
const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(cur())}s.gif`;
|
||||
out.innerHTML = `<img class="sm-preview" alt="GIF preview" src="${url}" />
|
||||
<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>`;
|
||||
out.dataset.name = name;
|
||||
} catch (err) {
|
||||
out.innerHTML = `<div class="sm-err">⚠ ${escapeHtml(err.message)}</div>`;
|
||||
} finally {
|
||||
act.disabled = false;
|
||||
}
|
||||
} else if (act.dataset.act === 'bite') {
|
||||
const out = body.querySelector('.sm-bite-out');
|
||||
const a = LyricsCore.parseStamp(body.querySelector('.sm-b-start').value);
|
||||
const b = LyricsCore.parseStamp(body.querySelector('.sm-b-end').value);
|
||||
const fmt = body.querySelector('.sm-b-fmt').value;
|
||||
const max = fmt === 'm4r' ? 40 : 60;
|
||||
if (a === null || b === null || b <= a) { out.innerHTML = '<div class="sm-err">⚠ Times must look like 1:23, end after start</div>'; return; }
|
||||
if (b - a > max) { out.innerHTML = `<div class="sm-err">⚠ Max ${max} s for ${fmt === 'm4r' ? 'an iPhone ringtone' : 'a soundbite'}</div>`; return; }
|
||||
out.innerHTML = '<div class="sm-busy">Cutting the soundbite…</div>';
|
||||
act.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/api/media/${encodeURIComponent(meta.id)}/clip?start=${a}&end=${b}&fmt=${fmt}`);
|
||||
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); }
|
||||
biteBlob = await res.blob();
|
||||
const url = URL.createObjectURL(biteBlob);
|
||||
const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(a)}s.${fmt}`;
|
||||
out.dataset.name = name;
|
||||
out.dataset.type = fmt === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
|
||||
out.innerHTML = `<audio controls src="${url}" class="sm-audio"></audio>
|
||||
<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>
|
||||
${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) {
|
||||
out.innerHTML = `<div class="sm-err">⚠ ${escapeHtml(err.message)}</div>`;
|
||||
} finally {
|
||||
act.disabled = false;
|
||||
}
|
||||
} else if (act.dataset.act === 'sharebite' && biteBlob) {
|
||||
const o = body.querySelector('.sm-bite-out');
|
||||
try { await navigator.share({ files: [new File([biteBlob], o.dataset.name, { type: o.dataset.type })], title: meta.title }); } catch { /* dismissed */ }
|
||||
} else if (act.dataset.act === 'sharegif' && gifBlob) {
|
||||
const name = body.querySelector('.sm-gif-out').dataset.name;
|
||||
try { await navigator.share({ files: [new File([gifBlob], name, { type: 'image/gif' })], title: meta.title }); } catch { /* dismissed */ }
|
||||
}
|
||||
});
|
||||
const actions = [{ label: 'Done', onClick: closeModal }];
|
||||
if (navigator.share) {
|
||||
actions.push({ label: '↗ Share link', primary: true, onClick: async () => {
|
||||
try { await navigator.share({ title: meta.title, text: `${meta.title} @ ${LyricsCore.fmtClock(cur())}`, url: appLink(meta.id, cur()) }); } catch { /* dismissed */ }
|
||||
} });
|
||||
} else {
|
||||
actions.push({ label: 'Copy app link', primary: true, onClick: () => copy(appLink(meta.id, cur()), 'App link') });
|
||||
}
|
||||
showModal('🔗 Share this moment', body, actions);
|
||||
}
|
||||
|
||||
// /?v=<id>&t=<sec> — open that video at that moment.
|
||||
async function bootFromUrl() {
|
||||
let id = '', t = 0;
|
||||
try {
|
||||
const q = new URLSearchParams(location.search);
|
||||
id = (q.get('v') || '').trim();
|
||||
t = Math.max(0, parseFloat(q.get('t')) || 0);
|
||||
} catch { return; }
|
||||
if (!isMediaId(id)) return;
|
||||
try {
|
||||
const u = new URL(location.href);
|
||||
u.searchParams.delete('v');
|
||||
u.searchParams.delete('t');
|
||||
history.replaceState(null, '', u.pathname + u.search + u.hash);
|
||||
} catch { /* keep */ }
|
||||
await playVideoAt(data.history.find((x) => x.id === id) || { id, title: '' }, t);
|
||||
}
|
||||
|
||||
return { open, bootFromUrl };
|
||||
})();
|
||||
|
||||
/* Lazy classic chunk; its executing singleton stays pinned until reload. */
|
||||
(window.YT ||= {}).External = (() => {
|
||||
const isAndroid = /Android/i.test(navigator.userAgent);
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const streamUrl = (id) => `${location.origin}${isUploadId(id) ? '/api/uploads/' : '/api/media/'}${encodeURIComponent(id)}`;
|
||||
async function cached(id) {
|
||||
if (isUploadId(id)) return true; // uploads are always a single file on the server
|
||||
try { const j = await (await fetch(`/api/media/${encodeURIComponent(id)}/status`)).json(); return j && j.status === 'ready'; } catch { return false; }
|
||||
}
|
||||
const intent = (url, title, pkg) => `intent:${url}#Intent;action=android.intent.action.VIEW;type=video/*;${pkg ? `package=${pkg};` : ''}S.title=${encodeURIComponent(title || '')};end`;
|
||||
function m3u(items) {
|
||||
return ['#EXTM3U', ...items.flatMap((v) => [`#EXTINF:${Math.round(v.duration || -1)},${(v.title || v.id).replace(/[\r\n]/g, ' ')}`, streamUrl(v.id)])].join('\n') + '\n';
|
||||
}
|
||||
function download(name, text) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([text], { type: 'audio/x-mpegurl' }));
|
||||
a.download = name;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
|
||||
}
|
||||
|
||||
async function open() {
|
||||
const meta = current && current.meta;
|
||||
if (!meta || !meta.id || meta.custom) { toast('Play a YouTube video first'); return; }
|
||||
const body = document.createElement('div');
|
||||
body.className = 'ext-panel';
|
||||
body.innerHTML = '<div class="sm-busy">Checking the server copy…</div>';
|
||||
showModal('📺 Open in another player', body, [{ label: 'Close', primary: true, onClick: closeModal }]);
|
||||
const ok = await cached(meta.id);
|
||||
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 ${MediaIcons.svg('download')} Save), then try again.</p>`;
|
||||
return;
|
||||
}
|
||||
const url = streamUrl(meta.id);
|
||||
const title = meta.title || meta.id;
|
||||
const inList = queue.length > 1 && queueIndex >= 0;
|
||||
body.innerHTML = `
|
||||
<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">
|
||||
${isAndroid ? `
|
||||
<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'))}">${TransportIcons.svg('play')} MX Player</a>
|
||||
<a class="np-btn" href="${escapeHtml(intent(url, title))}">${TransportIcons.svg('play')} Open with…</a>`
|
||||
: isIOS ? `
|
||||
<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)}">${TransportIcons.svg('play')} Infuse</a>`
|
||||
: `<a class="np-btn" href="vlc://${escapeHtml(url)}">${TransportIcons.svg('play')} VLC</a>`}
|
||||
<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">${MediaIcons.svg('download')} .m3u (whole list)</button>` : ''}
|
||||
</div>
|
||||
<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.'
|
||||
: 'Desktop VLC may not register vlc:// links — the .m3u file opens in VLC, mpv, IINA or any media player.'}</p>`;
|
||||
body.addEventListener('click', async (e) => {
|
||||
const act = e.target.closest('[data-act]');
|
||||
if (!act) return;
|
||||
if (act.dataset.act === 'copy') {
|
||||
try { await navigator.clipboard.writeText(url); toast('Stream URL copied ✓'); } catch { toast('Copy blocked'); }
|
||||
} else if (act.dataset.act === 'm3u') {
|
||||
download(`${title.replace(/[^\w\- ]+/g, '').trim().slice(0, 50) || meta.id}.m3u`, m3u([meta]));
|
||||
} else if (act.dataset.act === 'm3u-list') {
|
||||
act.disabled = true;
|
||||
const items = queue.slice(Math.max(0, queueIndex));
|
||||
const flags = await Promise.all(items.map((v) => (v.custom ? false : cached(v.id))));
|
||||
const ready = items.filter((_, i) => flags[i]);
|
||||
download('ytplayer-queue.m3u', m3u(ready));
|
||||
toast(`Playlist with ${ready.length} of ${items.length} songs${ready.length < items.length ? ' (the rest aren’t cached on the server yet)' : ''}`, { duration: 5000 });
|
||||
act.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
return { open, m3u, intent };
|
||||
})();
|
||||
@@ -27,3 +27,96 @@ test('every SHELL asset exists on disk and there are no duplicate entries', () =
|
||||
assert.equal(new Set(shell).size, shell.length, 'SHELL has no duplicate entries');
|
||||
for (const asset of shell) assert.ok(existsSync(join(__dirname, asset.slice(1))), `${asset} exists`);
|
||||
});
|
||||
|
||||
test('assets.json describes the complete existing shell exactly once with eager core features', () => {
|
||||
const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
|
||||
const described = new Set();
|
||||
for (const [name, group] of Object.entries(groups)) {
|
||||
assert.equal(group.contract, name === 'core' ? 2 : 1, `${name} contract (core boot packing changed)`);
|
||||
assert.equal(typeof group.eager, 'boolean');
|
||||
for (const asset of group.files) {
|
||||
assert.ok(!described.has(asset), `${asset} has one group`);
|
||||
described.add(asset);
|
||||
assert.ok(existsSync(join(__dirname, asset.slice(1))), `${asset} exists`);
|
||||
}
|
||||
}
|
||||
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.
|
||||
for (const asset of described) assert.ok(listed.has(asset) || asset === '/piano-engine.mjs', `${asset} shell or documented dynamic import`);
|
||||
assert.equal(groups.core.eager, true);
|
||||
for (const name of ['playlist', 'settings', 'service-mode', 'lyrics-editor']) {
|
||||
assert.equal(groups[`feature:${name}`].eager, true);
|
||||
for (const asset of groups[`feature:${name}`].embeddedIn) assert.ok(groups.core.files.includes(asset), `${name} remains in core`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the selected glass-stage layout includes its controls and panel dependencies', () => {
|
||||
const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
|
||||
assert.deepEqual(groups['layout:glass-stage'].files, [
|
||||
'/theme-glass.css', '/theme-glass-panel.css', '/glass-panel-layout.js',
|
||||
]);
|
||||
assert.deepEqual(groups['layout:glass-stage'].dependencies,['feature:layout-controls']);
|
||||
assert.ok(groups['feature:layout-controls'].files.includes('/glass-controls.js'));
|
||||
});
|
||||
|
||||
test('the shared sync core is eager and loads before the update helper',()=>{
|
||||
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||
assert.ok(groups.core.files.includes('/asset-sync-core.js'));
|
||||
assert.ok(html.indexOf('src="asset-sync-core.js"')<html.indexOf('src="sw-update.js"'));
|
||||
assert.ok(listed.has('/asset-sync-core.js'));
|
||||
});
|
||||
|
||||
test('only core scripts and styles remain eager; native fallback and active layout bootstrap agree', () => {
|
||||
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||
const background=new Set(Object.values(groups).filter(g=>g.background).flatMap(g=>g.files));
|
||||
for(const match of html.matchAll(/<(?:script\b[^>]*\bsrc|link\b[^>]*\bhref)="([^"]+)"/gi)) {
|
||||
// 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];
|
||||
assert.ok(!background.has(path),`${path} is not eager`);
|
||||
}
|
||||
assert.ok(html.indexOf('id="ytp-assets"')<html.indexOf('src="lazy.js"'));
|
||||
assert.ok(html.indexOf('src="lazy.js"')<html.indexOf('</head>'));
|
||||
assert.ok(html.indexOf('src="lazy.js"')<html.indexOf('rel="stylesheet"'),'head bootstrap must not wait for common CSS before requesting the selected layout');
|
||||
assert.ok(html.indexOf('rel="preload"')<html.indexOf('src="lazy.js"'),'font preloads must start before the render-blocking bootstrap');
|
||||
assert.deepEqual(JSON.parse(html.match(/id="ytp-assets">([^<]+)<\/script>/)[1]).groups,groups);
|
||||
for(const name of ['playlist','settings','service-mode','lyrics-editor'])assert.ok(groups['feature:'+name].eager);
|
||||
});
|
||||
|
||||
test('layout-independent visibility and transport defaults stay in eager CSS',()=>{
|
||||
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||
assert.ok(groups.core.files.includes('/layout-base.css'));
|
||||
const shared=readFileSync(join(__dirname,'layout-base.css'),'utf8');
|
||||
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"'));
|
||||
});
|
||||
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
305
frontend/shell-core.js
Normal file
305
frontend/shell-core.js
Normal file
@@ -0,0 +1,305 @@
|
||||
/* Eager UI definitions. Read app lexical state only when called after initialization. */
|
||||
const Recommendations = (() => {
|
||||
let cards = [], fetchedAt = 0, loading = false, error = false, actor = '', queuedRefresh = false;
|
||||
let listHost = null;
|
||||
const deviceFingerprint = () => window.getFingerprint ? window.getFingerprint() : 'unknown';
|
||||
const fingerprint = () => data.profile?.name ? 'profile:' + data.profile.name.toLowerCase() : 'device:' + deviceFingerprint();
|
||||
function load() {
|
||||
const fp = fingerprint();
|
||||
if (fp === actor) return;
|
||||
actor = fp; cards = []; fetchedAt = 0;
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('ytpRecommendations:' + fp) || 'null');
|
||||
if (saved && Array.isArray(saved.cards)) cards = saved.cards;
|
||||
} catch { /* no saved recommendations */ }
|
||||
}
|
||||
function paint(host) {
|
||||
if (!host) return;
|
||||
host.replaceChildren();
|
||||
const heading = document.createElement('div'); heading.className = 'rec-heading';
|
||||
const copy = document.createElement('div');
|
||||
const title = document.createElement('h2'); title.textContent = 'Recommended for you';
|
||||
const sub = document.createElement('p'); sub.textContent = error && cards.length ? 'Saved picks · reconnect to refresh' : 'Familiar favorites and something new, inspired by your listening.';
|
||||
copy.append(title, sub);
|
||||
const refresh = document.createElement('button'); refresh.type = 'button'; refresh.className = 'rec-refresh';
|
||||
refresh.textContent = loading ? 'Updating…' : 'Refresh'; refresh.disabled = loading;
|
||||
refresh.onclick = () => update(true);
|
||||
heading.append(copy, refresh); host.appendChild(heading);
|
||||
if (!cards.length) {
|
||||
const empty = document.createElement('p'); empty.className = 'rec-empty'; empty.setAttribute('role', 'status');
|
||||
empty.textContent = loading ? 'Finding your next listen…' : error ? 'Recommendations are unavailable. You can still search or play your playlists.' : 'Search or play a few videos to start discovering recommendations here.';
|
||||
host.appendChild(empty); return;
|
||||
}
|
||||
const grid = document.createElement('div'); grid.className = 'rec-grid';
|
||||
cards.forEach((v, i) => {
|
||||
const item = document.createElement('article'); item.className = 'rec-item';
|
||||
const play = document.createElement('button'); play.type = 'button'; play.className = 'rec-play';
|
||||
play.setAttribute('aria-label', 'Play ' + v.title);
|
||||
const cover = document.createElement('span'); cover.className = 'rec-cover';
|
||||
const img = document.createElement('img'); img.src = v.thumbnail || ''; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; cover.appendChild(img);
|
||||
const title = document.createElement('span'); title.className = 'rec-title'; title.textContent = v.title;
|
||||
const channel = document.createElement('span'); channel.className = 'rec-channel'; channel.textContent = v.channel || '';
|
||||
play.append(cover, title, channel); play.onclick = () => { setHomeOverPlayer(false); playFromList(cards.slice(), i, 'recommendations'); };
|
||||
const reason = document.createElement('div'); reason.className = 'rec-reason'; reason.textContent = v.reason || 'Recommended for you';
|
||||
const actions = document.createElement('div'); actions.className = 'rec-actions';
|
||||
const queue = document.createElement('button'); queue.type = 'button'; queue.textContent = '+ Queue'; queue.setAttribute('aria-label', 'Queue ' + v.title); queue.onclick = () => addToQueue(v);
|
||||
const more = document.createElement('button'); more.type = 'button'; more.textContent = '•••'; more.setAttribute('aria-label', 'More actions for ' + v.title); more.onclick = () => openCardMenu(v);
|
||||
actions.append(queue, more); item.append(play, reason, actions); grid.appendChild(item);
|
||||
});
|
||||
host.appendChild(grid);
|
||||
}
|
||||
function repaint() {
|
||||
if (!WEB) return;
|
||||
const home = $('homeRecommendations');
|
||||
if (home) { home.classList.remove('hidden'); paint(home); }
|
||||
els.playerPane?.classList.add('has-recommendations');
|
||||
if (listHost?.isConnected) paint(listHost);
|
||||
requestAnimationFrame(updateBottomChrome);
|
||||
}
|
||||
async function update(force = false) {
|
||||
load();
|
||||
if (!WEB) return;
|
||||
if (loading) { if (force) queuedRefresh = true; return; }
|
||||
if (!force && Date.now() - fetchedAt < 300000) return;
|
||||
const fp = actor; loading = true; error = false; repaint();
|
||||
try {
|
||||
const name = data.profile?.name || '';
|
||||
const result = await webFetch('/api/recommendations?fp=' + encodeURIComponent(deviceFingerprint()) + (name ? '&profile=' + encodeURIComponent(name) : ''), { headers: name ? profileHeaders(name) : {} });
|
||||
if (fp !== fingerprint()) return;
|
||||
if (!result.ok || !Array.isArray(result.results)) throw new Error('invalid recommendations');
|
||||
cards = result.results;
|
||||
try { localStorage.setItem('ytpRecommendations:' + fp, JSON.stringify({ cards })); } catch { /* full */ }
|
||||
} catch { error = true; }
|
||||
finally {
|
||||
loading = false; fetchedAt = fp === fingerprint() ? Date.now() : 0; repaint();
|
||||
if (queuedRefresh || fp !== fingerprint()) { queuedRefresh = false; update(true); }
|
||||
}
|
||||
}
|
||||
function home() { if (WEB) { load(); repaint(); update(); } }
|
||||
function inList() {
|
||||
if (!WEB) return;
|
||||
listHost = document.createElement('section'); listHost.className = 'recommendations'; listHost.setAttribute('aria-label', 'Recommended videos');
|
||||
els.cards.appendChild(listHost); load(); paint(listHost); update();
|
||||
}
|
||||
return { home, inList, update };
|
||||
})();
|
||||
|
||||
function renderSidebar() {
|
||||
els.playlistList.innerHTML = '';
|
||||
for (const pl of data.playlists) {
|
||||
const stats = playlistOfflineStats(pl);
|
||||
let offlineBadge = '';
|
||||
if (stats.total > 0) {
|
||||
if (stats.cached === stats.total) {
|
||||
offlineBadge = `<span class="pl-offline-badge all" title="Available offline">${MediaIcons.svg('offline')}</span>`;
|
||||
} 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>`;
|
||||
}
|
||||
}
|
||||
const item = document.createElement('div');
|
||||
item.className = 'playlist-item' + (view.type === 'playlist' && view.id === pl.id ? ' active' : '');
|
||||
item.innerHTML = `<span class="pl-name"></span>${offlineBadge}<span class="pl-count">${pl.videos.length}</span>`;
|
||||
item.querySelector('.pl-name').textContent = pl.name;
|
||||
item.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); });
|
||||
els.playlistList.appendChild(item);
|
||||
}
|
||||
document.querySelectorAll('.nav-item').forEach((b) => {
|
||||
b.classList.toggle('active', b.dataset.view === view.type);
|
||||
});
|
||||
// Keep bottom-nav in sync with sidebar nav active state
|
||||
document.querySelectorAll('.bottom-nav-btn').forEach((b) => {
|
||||
b.classList.toggle('active', b.dataset.view === view.type);
|
||||
});
|
||||
updateQueueBadge();
|
||||
updateDownloadBadge();
|
||||
renderHomePlaylists();
|
||||
}
|
||||
|
||||
function renderHomePlaylists() {
|
||||
if (window.BentoHub) BentoHub.playlists = () => data.playlists || [];
|
||||
const wrap = document.getElementById('homePlaylists');
|
||||
const hero = document.querySelector('#playerPlaceholder .hero');
|
||||
const grid = document.getElementById('homePlGrid');
|
||||
if (!wrap || !hero || !grid) return;
|
||||
Recommendations.home();
|
||||
|
||||
const lists = data.playlists || [];
|
||||
wrap.classList.toggle('hidden', !lists.length);
|
||||
hero.classList.toggle('hidden', !!lists.length);
|
||||
// The hero is centered art; the grid wants the full box, so the placeholder
|
||||
// switches from centering to stretching (and tightens its padding), and the
|
||||
// stage drops its 16:9 lock so the grid gets the pane's real height.
|
||||
if (els.placeholder) els.placeholder.classList.toggle('showing-playlists', !!lists.length);
|
||||
if (els.playerPane) els.playerPane.classList.toggle('home-playlists', !!lists.length);
|
||||
if (!lists.length) { grid.innerHTML = ''; return; }
|
||||
|
||||
grid.innerHTML = '';
|
||||
for (const pl of lists) {
|
||||
const count = pl.videos.length;
|
||||
const thumb = (pl.videos.find((v) => v && v.thumbnail) || {}).thumbnail || '';
|
||||
const stats = playlistOfflineStats(pl);
|
||||
let offlineHtml = '';
|
||||
if (stats.total > 0) {
|
||||
if (stats.cached === stats.total) {
|
||||
offlineHtml = `<span class="home-pl-offline all" title="Available offline">${MediaIcons.svg('offline')}</span>`;
|
||||
} 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>`;
|
||||
}
|
||||
}
|
||||
const card = document.createElement('button');
|
||||
card.className = 'home-pl-card';
|
||||
card.type = 'button';
|
||||
card.innerHTML = `
|
||||
<span class="home-pl-thumb">
|
||||
${thumb ? `<img src="${escapeHtml(thumb)}" alt="" loading="lazy" decoding="async" />`
|
||||
: '<span class="home-pl-thumb-fallback">🎵</span>'}
|
||||
${offlineHtml}
|
||||
<span class="home-pl-play" title="Play all">${TransportIcons.svg('play')}</span>
|
||||
</span>
|
||||
<span class="home-pl-name"></span>
|
||||
<span class="home-pl-count">${count} video${count === 1 ? '' : 's'}</span>`;
|
||||
card.querySelector('.home-pl-name').textContent = pl.name;
|
||||
card.title = pl.name;
|
||||
card.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); });
|
||||
card.querySelector('.home-pl-play').addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // the play badge plays; the rest of the card opens the list
|
||||
if (count) playFromList(pl.videos, 0, 'playlist:' + pl.id);
|
||||
else toast('That playlist is empty');
|
||||
});
|
||||
grid.appendChild(card);
|
||||
}
|
||||
// Measure after layout — scrollHeight is meaningless until the cards lay out.
|
||||
requestAnimationFrame(updateHomeGridScrollState);
|
||||
}
|
||||
|
||||
function updateHomeGridScrollState() {
|
||||
const grid = document.getElementById('homePlGrid');
|
||||
if (!grid) return;
|
||||
const overflows = grid.scrollHeight > grid.clientHeight + 1;
|
||||
grid.classList.toggle('is-scrollable', overflows);
|
||||
grid.classList.toggle('at-end', !overflows ||
|
||||
grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 2);
|
||||
}
|
||||
|
||||
function updateBottomChrome() {
|
||||
let occupied = 0;
|
||||
for (const id of ['bottomNav', 'miniBar']) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el || el.classList.contains('hidden')) continue;
|
||||
const box = el.getBoundingClientRect();
|
||||
if (!box.height || getComputedStyle(el).position !== 'fixed') continue;
|
||||
if (box.top > window.innerHeight * 0.5) occupied = Math.max(occupied, window.innerHeight - box.top);
|
||||
}
|
||||
document.documentElement.style.setProperty('--bottom-chrome', Math.round(occupied) + 'px');
|
||||
}
|
||||
|
||||
function setHomeOverPlayer(on) {
|
||||
const pane = els.playerPane;
|
||||
if (!pane) return;
|
||||
on = !!on && !!(current && current.meta);
|
||||
// Service mode hides the bottom bar and mini bar (and, in its Video view,
|
||||
// lives inside this pane): leave it first or the home screen is a dead end.
|
||||
if (on && isServiceModeOpen()) exitServiceMode();
|
||||
if (pane.classList.contains('home-over') === on) return;
|
||||
pane.classList.toggle('home-over', on);
|
||||
pane.classList.toggle('empty', on);
|
||||
if (els.placeholder) els.placeholder.classList.toggle('hidden', !on);
|
||||
if (on) {
|
||||
renderHomePlaylists();
|
||||
showMiniBar();
|
||||
const body = document.querySelector('.body');
|
||||
if (body) body.scrollTo({ top: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
function showMiniBar() {
|
||||
if (!current || !current.meta) return;
|
||||
$('miniTitle').textContent = current.meta.title;
|
||||
const miniCh = $('miniChannel');
|
||||
miniCh.textContent = current.meta.channel || '';
|
||||
miniCh.classList.toggle('link', !!channelKeyOf(current.meta));
|
||||
$('miniBar').classList.remove('hidden');
|
||||
updateMiniBar();
|
||||
requestAnimationFrame(updateBottomChrome);
|
||||
}
|
||||
|
||||
function updateMiniBar() {
|
||||
if (!current || !current.meta) return;
|
||||
const cur = Player.master.currentTime || 0;
|
||||
const dur = Player.master.duration || 0;
|
||||
$('miniTime').textContent = fmtTime(cur);
|
||||
if (dur > 0) {
|
||||
$('miniProgressFill').style.width = Math.min(100, (cur / dur) * 100) + '%';
|
||||
}
|
||||
}
|
||||
|
||||
function hideMiniBar() {
|
||||
$('miniBar').classList.add('hidden');
|
||||
requestAnimationFrame(updateBottomChrome);
|
||||
}
|
||||
|
||||
function setTile(btn, label) {
|
||||
const tl = btn.querySelector('.tl');
|
||||
if (tl) tl.textContent = label; else btn.textContent = label;
|
||||
}
|
||||
|
||||
function updateNowPlayingActions() {
|
||||
if (!current || !current.meta) return;
|
||||
const id = current.meta.id;
|
||||
renderAvailability(id);
|
||||
// Custom (edited) videos are already a saved-offline cut of a source video;
|
||||
// re-saving or re-editing them makes no sense, so hide those actions.
|
||||
const isCustom = !!current.meta.custom;
|
||||
if (els.editBtn) els.editBtn.hidden = isCustom;
|
||||
if (els.saveBtn) els.saveBtn.hidden = isCustom;
|
||||
// The server cache exists only behind the web PWA backend.
|
||||
if (els.brokenBtn) {
|
||||
els.brokenBtn.hidden = isCustom || !WEB;
|
||||
els.brokenBtn.disabled = reportingBroken.has(id);
|
||||
els.brokenBtn.textContent = reportingBroken.has(id) ? '⏳ Reporting…' : '⚠ Broken';
|
||||
}
|
||||
{ const ch = $('npChannel'); if (ch) { const ok = isUploadId(id) || cachedIds.has(id); ch.classList.toggle('verified', ok); ch.title = ok ? 'Available on this device or the server' : ''; } }
|
||||
const btn = els.saveBtn;
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('saving', downloading.has(id));
|
||||
if (removing.has(id)) {
|
||||
setTile(btn, 'Removing…');
|
||||
btn.classList.remove('done');
|
||||
btn.disabled = true;
|
||||
} else if (downloading.has(id)) {
|
||||
setTile(btn, 'Saving…');
|
||||
btn.classList.remove('done');
|
||||
btn.disabled = true;
|
||||
} else if (cachedIds.has(id)) {
|
||||
setTile(btn, 'Saved');
|
||||
btn.classList.add('done');
|
||||
btn.disabled = false;
|
||||
btn.title = 'Saved for offline — click to remove from cache';
|
||||
} else {
|
||||
setTile(btn, 'Save');
|
||||
btn.classList.remove('done');
|
||||
btn.disabled = false;
|
||||
btn.title = 'Save this video for offline playback';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSmartSidebar() {
|
||||
const list = $('smartPlaylistList');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
SMART_PLAYLISTS.forEach((sp) => {
|
||||
const item = document.createElement('div');
|
||||
const isActive = view.type === 'smart' && view.smartType === sp.id;
|
||||
item.className = 'playlist-item smart-item' + (isActive ? ' active' : '');
|
||||
item.innerHTML = `<span class="pl-name">${sp.label}</span>`;
|
||||
item.addEventListener('click', () => { view = { type: 'smart', smartType: sp.id }; render(); });
|
||||
list.appendChild(item);
|
||||
});
|
||||
if (WEB && data.settings.p2pReceive !== false) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'playlist-item smart-item' + (view.type === 'p2p' ? ' active' : '');
|
||||
item.innerHTML = '<span class="pl-name">📡 On other devices</span>';
|
||||
item.addEventListener('click', () => { view = { type: 'p2p' }; render(); });
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
124
frontend/stats-view.js
Normal file
124
frontend/stats-view.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/* Lazy classic chunk; its executing singleton stays pinned until reload. */
|
||||
(window.YT ||= {}).StatsView = { render: async function renderStats() {
|
||||
try { await Lazy.load('feature:stats'); } catch (error) { toast(error.message); return; }
|
||||
if (view.type !== 'stats') return;
|
||||
StatsTrack.flush();
|
||||
els.listTitle.textContent = 'Stats';
|
||||
resetListHeaderExtras();
|
||||
els.listActions.innerHTML = '';
|
||||
els.status.classList.add('hidden');
|
||||
const c = els.cards;
|
||||
const S = StatsCore.summarize(data.stats);
|
||||
if (!S.totalPlays && S.totalSec < 60) {
|
||||
c.innerHTML = `
|
||||
<div class="empty-state stats-empty">
|
||||
<div class="empty-icon">📊</div>
|
||||
<h3 class="empty-title">No listening stats yet</h3>
|
||||
<p class="empty-desc">Stats start counting now — a song counts as played after 30 seconds. They sync with your online profile.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
const hrs = (sec) => StatsCore.fmtHours(sec);
|
||||
const delta = S.thisMonthSec - S.lastMonthSec;
|
||||
const deltaTxt = S.lastMonthSec || S.thisMonthSec
|
||||
? `${delta >= 0 ? '▲ +' : '▼ −'}${(Math.abs(delta) / 3600).toFixed(1)}h vs last month`
|
||||
: '';
|
||||
const maxArtist = Math.max(1, ...S.topArtists.map((a) => a.plays));
|
||||
const spark = (vals) => {
|
||||
const w = 96, h = 26, max = Math.max(1, ...vals);
|
||||
const pts = vals.map((v, i) => `${(i / (vals.length - 1)) * w},${h - 3 - (v / max) * (h - 6)}`).join(' ');
|
||||
return `<svg class="st-spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline points="${pts}" /></svg>`;
|
||||
};
|
||||
const heatMax = Math.max(1, ...S.heat.flat().map((x) => x.minutes));
|
||||
const level = (m) => (m <= 0 ? 0 : Math.min(4, 1 + Math.floor((m / heatMax) * 3.999)));
|
||||
const fmtDay = (k) => new Date(`${k}T12:00:00`).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
// Month label over the first week column of each month.
|
||||
const months = S.heat.map((col, w) => {
|
||||
const d = new Date(`${col[0].date}T12:00:00`);
|
||||
const prev = w ? new Date(`${S.heat[w - 1][0].date}T12:00:00`) : null;
|
||||
return !prev || prev.getMonth() !== d.getMonth() ? d.toLocaleDateString(undefined, { month: 'short' }) : '';
|
||||
});
|
||||
const playlistsWith = data.playlists.length;
|
||||
|
||||
c.innerHTML = `
|
||||
<div class="stats">
|
||||
<div class="st-tiles">
|
||||
<div class="st-tile">
|
||||
<div class="st-label">⏱ Listening this month</div>
|
||||
<div class="st-big">${hrs(S.thisMonthSec)}</div>
|
||||
<div class="st-sub">${escapeHtml(deltaTxt)}${deltaTxt ? ' · ' : ''}${hrs(S.totalSec)} all time</div>
|
||||
</div>
|
||||
<div class="st-tile">
|
||||
<div class="st-label">${TransportIcons.svg('play')} Track plays</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>
|
||||
<div class="st-tile">
|
||||
<div class="st-label">🔥 Practice streak</div>
|
||||
<div class="st-big">${S.streak.current}-day</div>
|
||||
<div class="st-sub">${S.streak.current ? 'Streak active' : 'Listen 5 min today to start one'} · best ${S.streak.best} day${S.streak.best === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="st-grid">
|
||||
<section class="st-panel">
|
||||
<div class="st-panel-head"><span>🎤 Top artists</span><small>plays this month</small></div>
|
||||
${S.topArtists.length ? S.topArtists.map((a) => `
|
||||
<div class="st-bar-row" data-tip="${escapeHtml(a.name)} — ${a.plays} play${a.plays === 1 ? '' : 's'} this month">
|
||||
<span class="st-bar-name">${escapeHtml(a.name)}</span>
|
||||
<span class="st-bar-track"><span class="st-bar" style="width:${Math.max(3, (a.plays / maxArtist) * 100)}%"></span></span>
|
||||
<span class="st-bar-val">${a.plays}</span>
|
||||
</div>`).join('') : '<div class="st-none">No plays this month yet.</div>'}
|
||||
</section>
|
||||
<section class="st-panel">
|
||||
<div class="st-panel-head"><span>🎵 Top songs</span><small>last 30 days</small></div>
|
||||
${S.topSongs.length ? S.topSongs.map((t, i) => `
|
||||
<div class="st-song" data-tip="${escapeHtml(t.title)} — ${t.plays} play${t.plays === 1 ? '' : 's'} in 30 days" data-id="${escapeHtml(t.id)}">
|
||||
<span class="st-rank">${String(i + 1).padStart(2, '0')}</span>
|
||||
<span class="st-song-text"><b>${escapeHtml(t.title)}</b><small>${escapeHtml(t.channel)}</small></span>
|
||||
${spark(t.spark)}
|
||||
<span class="st-bar-val">${t.plays}</span>
|
||||
</div>`).join('') : '<div class="st-none">No plays in the last 30 days.</div>'}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="st-panel">
|
||||
<div class="st-panel-head"><span>🗓 Listening habits</span><small>last 16 weeks${S.peak ? ` · peak day: ${S.peak.day}s (avg ${S.peak.avgHours.toFixed(1)} h)` : ''}</small></div>
|
||||
<div class="st-heat-wrap">
|
||||
<div class="st-heat-days"><span></span><span>Mon</span><span></span><span>Wed</span><span></span><span>Fri</span><span></span><span>Sun</span></div>
|
||||
<div class="st-heat-main">
|
||||
<div class="st-heat-months">${months.map((m) => `<span>${m}</span>`).join('')}</div>
|
||||
<div class="st-heat">${S.heat.map((col) => col.map((x) => x.future
|
||||
? '<span class="st-cell future"></span>'
|
||||
: `<span class="st-cell l${level(x.minutes)}" data-tip="${fmtDay(x.date)} · ${x.minutes} min"></span>`).join('')).join('')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-legend"><span>Less</span>${[0, 1, 2, 3, 4].map((l) => `<span class="st-cell l${l}"></span>`).join('')}<span>More</span></div>
|
||||
<details class="st-table"><summary>Show as table</summary>
|
||||
<table><thead><tr><th>Day</th><th>Minutes</th></tr></thead><tbody>
|
||||
${S.heat.flat().filter((x) => !x.future && x.minutes > 0).reverse().map((x) => `<tr><td>${fmtDay(x.date)}</td><td>${x.minutes}</td></tr>`).join('') || '<tr><td colspan="2">No listening in this window.</td></tr>'}
|
||||
</tbody></table>
|
||||
</details>
|
||||
</section>
|
||||
<div id="statsTip" class="st-tip hidden"></div>
|
||||
</div>`;
|
||||
|
||||
// One tooltip for every mark (bars, songs, heat cells).
|
||||
const root = c.querySelector('.stats');
|
||||
const tip = $('statsTip');
|
||||
root.addEventListener('pointermove', (e) => {
|
||||
const el = e.target.closest('[data-tip]');
|
||||
if (!el) { tip.classList.add('hidden'); return; }
|
||||
tip.textContent = el.dataset.tip;
|
||||
tip.classList.remove('hidden');
|
||||
const r = root.getBoundingClientRect();
|
||||
const half = tip.offsetWidth / 2 + 4; // keep the whole tip inside the panel
|
||||
tip.style.left = `${Math.min(r.width - half, Math.max(half, e.clientX - r.left))}px`;
|
||||
tip.style.top = `${e.clientY - r.top - 14}px`;
|
||||
});
|
||||
root.addEventListener('pointerleave', () => tip.classList.add('hidden'));
|
||||
root.querySelectorAll('.st-song').forEach((row) => row.addEventListener('click', () => {
|
||||
const v = data.history.find((x) => x.id === row.dataset.id) || { id: row.dataset.id, title: row.querySelector('b').textContent };
|
||||
Player.loadVideo(v);
|
||||
}));
|
||||
} };
|
||||
@@ -273,7 +273,7 @@ body::after {
|
||||
background: linear-gradient(180deg, rgba(16,16,19,0.7), transparent);
|
||||
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) */
|
||||
.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: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 {
|
||||
font-family: var(--display);
|
||||
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);
|
||||
}
|
||||
.card.playing::after {
|
||||
content: "▶ NOW PLAYING";
|
||||
content: "NOW PLAYING";
|
||||
position: absolute; top: 9px; right: 44px;
|
||||
font-family: var(--mono); font-size: 8px; font-weight: 700;
|
||||
letter-spacing: 0.1em; color: var(--accent);
|
||||
@@ -1097,19 +1097,20 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
||||
display: none;
|
||||
align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px;
|
||||
color: #7ee0a8;
|
||||
color: var(--accent-bright);
|
||||
font-size: 12px; font-weight: 700;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.85);
|
||||
}
|
||||
.card.cached .saved-badge { display: flex; }
|
||||
.card.downloading .saved-badge {
|
||||
display: flex;
|
||||
color: #ffc850;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
color: var(--accent-bright);
|
||||
}
|
||||
.card.downloading .saved-badge::before { content: "⏳"; }
|
||||
.card.downloading .saved-badge { font-size: 0; }
|
||||
.card.downloading .saved-badge::before { font-size: 12px; }
|
||||
/* State changes reveal SVGs without rewriting badges or their handlers. */
|
||||
.saved-badge .badge-progress { display:none; }
|
||||
.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 */
|
||||
.dl-progress {
|
||||
@@ -4257,6 +4258,30 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
|
||||
.search-form { 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) */
|
||||
html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
|
||||
|
||||
@@ -4307,3 +4332,27 @@ html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
|
||||
|
||||
.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; }
|
||||
|
||||
/* 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; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* ============================================================================
|
||||
* sw-update — makes "Refresh UI" actually land the new build.
|
||||
*
|
||||
* Framework-free and dependency-free on purpose (same pattern as
|
||||
* Framework-free, with a shared sync core and a legacy fallback (same pattern as
|
||||
* async-guard.js):
|
||||
* • Loads as a plain <script> under CSP `script-src 'self'` (browser
|
||||
* global `window.SwUpdate`).
|
||||
@@ -17,7 +17,7 @@
|
||||
* already current (a failed install leaves a partial cache that the
|
||||
* activate handler then mistook for a previous deploy).
|
||||
*
|
||||
* The fix no longer depends on the service-worker install lifecycle:
|
||||
* Legacy fallback (retained for ASSET_SYNC=0):
|
||||
* 1. refreshShellInPlace() downloads a fresh copy of every file the shell
|
||||
* caches hold (cache-busted, so even an old worker's cache-first
|
||||
* handler can't answer with the stale copy) — ALL of them or nothing —
|
||||
@@ -25,6 +25,10 @@
|
||||
* serves the next load, it serves the new build.
|
||||
* 2. Only then is a waiting worker (if any) activated, and the page
|
||||
* reloaded once.
|
||||
* Incremental mode uses AssetSyncCore to fetch only missing verified hash URLs
|
||||
* and publishes a complete blocking set. Playback guards cover activation and
|
||||
* reload callbacks; PLAYING reports provide an additional worker guard.
|
||||
*
|
||||
* The banner itself only opens when the build the page is running differs
|
||||
* from the server's (see app.js maybeShowUpdateBanner), so no lifecycle
|
||||
* event can re-open it once the page is current.
|
||||
@@ -64,7 +68,7 @@
|
||||
* @param {Function} [opts.setTimeout]
|
||||
* @returns {Promise<{refreshed: number, caches: number, resumed: number}>}
|
||||
*/
|
||||
async function refreshShellInPlace({ cachesApi, fetchFn, isShellCache, bust, timeoutMs, attempts, attemptTimeoutMs, concurrency, stagingKey, setTimeout: setTimeoutFn }) {
|
||||
async function refreshLegacyShell({ cachesApi, fetchFn, isShellCache, bust, timeoutMs, attempts, attemptTimeoutMs, concurrency, stagingKey, setTimeout: setTimeoutFn }) {
|
||||
const names = (await cachesApi.keys()).filter(isShellCache);
|
||||
if (!names.length) return { refreshed: 0, caches: 0, resumed: 0 }; // uncontrolled page: a reload already hits the network
|
||||
|
||||
@@ -145,6 +149,46 @@
|
||||
return { refreshed: urls.size, caches: names.length, resumed };
|
||||
}
|
||||
|
||||
async function refreshShellInPlace(opts) {
|
||||
const core = root.AssetSyncCore;
|
||||
if (core) {
|
||||
const response = await opts.fetchFn('/api/manifest', { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('Manifest unavailable');
|
||||
const m = await response.json();
|
||||
const cache = await opts.cachesApi.open(core.CACHE);
|
||||
// Only upgraded workers can route the persistent scheme. A rollback
|
||||
// worker explicitly advertises the legacy path through CACHE_STATUS.
|
||||
const reg = root.navigator && await root.navigator.serviceWorker.getRegistration();
|
||||
const worker = reg && (reg.waiting || reg.active);
|
||||
const status = await askCacheStatus(worker);
|
||||
// Unknown protocol is a retryable failure, never an empty successful
|
||||
// legacy refresh followed by a reload into the old persistent manifest.
|
||||
if (worker && !status) throw new Error('Worker update status unavailable; try again');
|
||||
if (status && status.assetSync) {
|
||||
const activeLayout = root.Lazy?.layout || 'classic';
|
||||
const result = await core.syncAssets(m,{cache,fetchFn:opts.fetchFn,activeLayout});
|
||||
if (!reg.waiting) await core.commit(m,cache,{activeLayout});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return refreshLegacyShell({ ...opts, isShellCache: n => n !== 'ytplayer-assets' && opts.isShellCache(n) });
|
||||
}
|
||||
|
||||
function playing() { return typeof Player !== 'undefined' && !!Player._wantsPlaying; }
|
||||
function reportPlaying(value = playing()) {
|
||||
const sw = root.navigator && root.navigator.serviceWorker;
|
||||
if (!sw) return;
|
||||
sw.getRegistration().then(reg => {
|
||||
for (const worker of [sw.controller, reg && reg.waiting]) if(worker) worker.postMessage({type:'PLAYING',value});
|
||||
}).catch(()=>{});
|
||||
}
|
||||
if (root.document && root.navigator && root.navigator.serviceWorker) {
|
||||
if (root.VisibleTimer) root.VisibleTimer.setIntervalWhenVisible(()=>reportPlaying(),1000);
|
||||
for (const event of ['play','pause','ended']) root.document.addEventListener(event,()=>reportPlaying(),true);
|
||||
root.document.addEventListener('visibilitychange',()=>reportPlaying());
|
||||
root.navigator.serviceWorker.addEventListener('controllerchange',()=>reportPlaying());
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks a worker whether it has already precached its whole shell.
|
||||
* Resolves to null on any problem (no reply, no MessageChannel, timeout) —
|
||||
@@ -167,7 +211,7 @@
|
||||
try {
|
||||
const ch = new Ctor();
|
||||
ch.port1.onmessage = (ev) => finish(ev && ev.data ? ev.data : null);
|
||||
worker.postMessage({ type: 'CACHE_STATUS' }, [ch.port2]);
|
||||
worker.postMessage({ type: 'CACHE_STATUS', activeLayout: root.Lazy?.layout }, [ch.port2]);
|
||||
} catch { finish(null); return; }
|
||||
if (schedule) schedule(() => finish(null), timeoutMs);
|
||||
});
|
||||
@@ -191,7 +235,14 @@
|
||||
* @param {Function} [opts.setTimeout] injectable for tests
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function applyUpdate({ reg, container, reload, refreshShell, askStatus, onPhase, setTimeout: setTimeoutFn }) {
|
||||
async function applyUpdate({ reg, container, reload, refreshShell, askStatus, onPhase, isPlaying = playing, confirmOverride = root.confirm && root.confirm.bind(root), setTimeout: setTimeoutFn }) {
|
||||
let overridden = false;
|
||||
const guard = () => {
|
||||
if (!isPlaying() || overridden) return true;
|
||||
if (confirmOverride && confirmOverride('Playing - update after this song / when paused. Update now anyway? Playback may stop.')) { overridden = true; return true; }
|
||||
return false;
|
||||
};
|
||||
if (!guard()) throw new Error('Playing - update after this song / when paused');
|
||||
const scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null);
|
||||
const waiting = reg && reg.waiting;
|
||||
|
||||
@@ -199,16 +250,18 @@
|
||||
// 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
|
||||
// forever if the caller injected a setTimeout that never fires.)
|
||||
let precached = false;
|
||||
let precached = false, offlineStatus;
|
||||
if (waiting && askStatus) {
|
||||
try {
|
||||
const status = await askStatus(waiting);
|
||||
precached = !!(status && status.ready);
|
||||
offlineStatus = status;
|
||||
} catch { precached = false; }
|
||||
}
|
||||
if (onPhase) onPhase(precached ? 'ready' : 'downloading');
|
||||
if (onPhase) onPhase(precached ? 'ready' : 'downloading', offlineStatus);
|
||||
if (!precached && refreshShell) await refreshShell();
|
||||
|
||||
if (!guard()) throw new Error('Playing - update after this song / when paused');
|
||||
if (!waiting) {
|
||||
reload();
|
||||
return;
|
||||
@@ -216,7 +269,7 @@
|
||||
|
||||
let reloaded = false;
|
||||
const reloadOnce = () => {
|
||||
if (reloaded) return;
|
||||
if (reloaded || (isPlaying() && !overridden)) return;
|
||||
reloaded = true;
|
||||
reload();
|
||||
};
|
||||
@@ -224,10 +277,29 @@
|
||||
// either way; reload on controllerchange, or after a short safety net.
|
||||
container.addEventListener('controllerchange', reloadOnce, { once: true });
|
||||
if (scheduleTimeout) scheduleTimeout(reloadOnce, 4000);
|
||||
if (overridden) waiting.postMessage({type:'PLAYING',value:false});
|
||||
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) {
|
||||
module.exports = SwUpdate;
|
||||
|
||||
@@ -349,3 +349,41 @@ test('askCacheStatus returns the worker reply', async () => {
|
||||
const got = await askCacheStatus(worker, { setTimeout: () => {}, Channel: FakeChannel });
|
||||
assert.deepStrictEqual(got, { ready: true, version: 'abc' });
|
||||
});
|
||||
|
||||
test('playback guard blocks activation and reload unless explicit override is accepted',async()=>{
|
||||
let messages=[],reloads=0;const reg={waiting:{postMessage:m=>messages.push(m)}};
|
||||
await assert.rejects(applyUpdate({reg,container:fakeContainer(),reload:()=>reloads++,isPlaying:()=>true,confirmOverride:()=>false}),/Playing/);
|
||||
assert.equal(messages.length,0);assert.equal(reloads,0);
|
||||
let playing=false;const container=fakeContainer();await applyUpdate({reg,container,reload:()=>reloads++,isPlaying:()=>playing,askStatus:async()=>({ready:true}),setTimeout:()=>{}});playing=true;container.fireControllerChange();assert.equal(reloads,0);
|
||||
});
|
||||
test('explicit playback override clears the worker guard and reloads once',async()=>{
|
||||
const messages=[];let reloads=0;const container=fakeContainer();
|
||||
await applyUpdate({reg:{waiting:{postMessage:m=>messages.push(m)}},container,reload:()=>reloads++,isPlaying:()=>true,confirmOverride:()=>true,askStatus:async()=>({ready:true}),setTimeout:()=>{}});
|
||||
assert.deepStrictEqual(messages,[{type:'PLAYING',value:false},{type:'SKIP_WAITING'}]);
|
||||
container.fireControllerChange();container.fireControllerChange();assert.equal(reloads,1);
|
||||
});
|
||||
test('an unanswered worker status cannot fall through to an empty legacy refresh',async()=>{
|
||||
const vm=require('node:vm'),fs=require('node:fs');
|
||||
const sandbox={AssetSyncCore:{CACHE:'ytplayer-assets'},navigator:{serviceWorker:{getRegistration:async()=>({active:{postMessage(){}}})}}};
|
||||
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/);
|
||||
});
|
||||
|
||||
|
||||
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)');
|
||||
});
|
||||
|
||||
216
frontend/sw.js
216
frontend/sw.js
@@ -2,7 +2,7 @@
|
||||
* sw.js — YT Player Service Worker
|
||||
*
|
||||
* Strategy:
|
||||
* App shell (HTML/CSS/JS) → cache-first, versioned cache
|
||||
* App shell (HTML/CSS/JS) → cache-first, exact per-file hash keys
|
||||
* /api/* requests → network-only (never cache yt-dlp results)
|
||||
* /api/download/* → network-only (streamed binary, never cache)
|
||||
* YouTube thumbnails (i.ytimg.com) → stale-while-revalidate
|
||||
@@ -14,9 +14,9 @@
|
||||
* 2. The client (app.js) shows the "Update available" banner only when the
|
||||
* build it is running differs from the server's /api/version buildTag —
|
||||
* a waiting worker or a broadcast is merely a prompt to re-check.
|
||||
* 3. "Refresh UI" (sw-update.js) re-downloads every shell file into every
|
||||
* versioned shell cache (all-or-nothing), activates a waiting worker if
|
||||
* there is one (SKIP_WAITING), and reloads once.
|
||||
* 3. Install syncs missing assets into ytplayer-assets. "Refresh UI" activates
|
||||
* the verified waiting worker (SKIP_WAITING) and reloads once. The legacy
|
||||
* full-shell path remains available through ASSET_SYNC=0.
|
||||
* 4. activate: if an older *versioned shell cache* is found (i.e. this
|
||||
* activation is genuinely replacing a previous deploy, not just the
|
||||
* first-ever install of a freshly (re)registered worker), broadcast
|
||||
@@ -27,13 +27,15 @@
|
||||
// It changes on every deploy/restart so the cache is busted automatically
|
||||
// without any manual version bump.
|
||||
const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.4';
|
||||
const ASSET_SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true;
|
||||
if (ASSET_SYNC) importScripts('/asset-sync-core.js');
|
||||
const CACHE = 'ytplayer-' + VERSION;
|
||||
|
||||
// Prefix shared by every versioned app-shell cache (ytplayer-<VERSION>).
|
||||
// Utility caches (ytplayer-thumbs, ytplayer-fonts) intentionally do NOT
|
||||
// match this — they aren't versioned and must survive every activate.
|
||||
const SHELL_CACHE_PREFIX = 'ytplayer-';
|
||||
const UTILITY_CACHES = new Set(['ytplayer-thumbs', 'ytplayer-fonts']);
|
||||
const UTILITY_CACHES = new Set(['ytplayer-thumbs', 'ytplayer-fonts', 'ytplayer-assets']);
|
||||
const THUMB_CACHE = 'ytplayer-thumbs';
|
||||
|
||||
// Hosts serving video thumbnails and channel avatars. yt-dlp hands back
|
||||
@@ -58,6 +60,7 @@ const SHELL = [
|
||||
'/styles.css',
|
||||
'/service-ui.css',
|
||||
'/refresh.css',
|
||||
'/layout-base.css',
|
||||
'/theme-glass.css',
|
||||
'/theme-bento-hub.css',
|
||||
'/theme-f7.css',
|
||||
@@ -71,6 +74,11 @@ const SHELL = [
|
||||
'/export.js',
|
||||
'/async-guard.js',
|
||||
'/visible-timer.js',
|
||||
'/asset-sync-core.js',
|
||||
'/lazy.js',
|
||||
'/feature-bootstrap.js',
|
||||
'/eq-parameters.js',
|
||||
'/assets.json',
|
||||
'/sw-update.js',
|
||||
'/fingerprint.js',
|
||||
'/opfs.js',
|
||||
@@ -131,9 +139,18 @@ const SHELL = [
|
||||
'/piano.js',
|
||||
'/setlist-import.js',
|
||||
'/car-mode.js',
|
||||
'/prewarm-next.js',
|
||||
'/car-mode.css',
|
||||
'/lower-third.css',
|
||||
'/settings-sections.css',
|
||||
'/shell-core.js',
|
||||
'/views-core.js',
|
||||
'/section-rail.js',
|
||||
'/presenter.js',
|
||||
'/remote-client.js',
|
||||
'/watch-party-client.js',
|
||||
'/share-external.js',
|
||||
'/stats-view.js',
|
||||
'/app.js',
|
||||
'/manifest.webmanifest',
|
||||
'/icons/icon-192.png',
|
||||
@@ -151,7 +168,7 @@ const SHELL = [
|
||||
// skipWaiting() is NOT called here — the page activates a waiting worker
|
||||
// from "Refresh UI" (see sw-update.js).
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(precacheShell());
|
||||
e.waitUntil(ASSET_SYNC ? installAssets() : precacheShell());
|
||||
});
|
||||
|
||||
// Fresh requests (`cache: 'reload'` skips the browser HTTP cache, which could
|
||||
@@ -185,6 +202,7 @@ async function precacheShell() {
|
||||
// ---- Activate: evict old caches, claim clients, notify about update ----
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil((async () => {
|
||||
if (ASSET_SYNC) { await activateAssets(); return; }
|
||||
const keys = await caches.keys();
|
||||
|
||||
// Was there a *previous deploy's* app-shell cache lying around? If so,
|
||||
@@ -252,6 +270,8 @@ self.addEventListener('fetch', (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ASSET_SYNC && request.mode === 'navigate') e.waitUntil(completeAssets());
|
||||
|
||||
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') return;
|
||||
|
||||
@@ -283,13 +303,62 @@ self.addEventListener('fetch', (e) => {
|
||||
}
|
||||
|
||||
// App shell → cache-first, then network, then generic offline fallback
|
||||
e.respondWith(cacheFirst(request));
|
||||
e.respondWith(cacheFirst(request, e.clientId));
|
||||
});
|
||||
|
||||
// ---- Message: handle SKIP_WAITING from the client ----
|
||||
const playingClients = new Map(), deferredClients = new Set();
|
||||
let activeLayout = 'classic', layoutReply;
|
||||
let completionTask;
|
||||
function completeAssets(force = false) {
|
||||
if (!ASSET_SYNC) return Promise.resolve();
|
||||
completionTask ||= (async () => {
|
||||
const cache = await caches.open(AssetSyncCore.CACHE);
|
||||
const m = (await AssetSyncCore.state(cache))?.current;
|
||||
if (!m || m.buildTag !== VERSION) return;
|
||||
const job = await AssetSyncCore.complete(m, { cache, fetchFn: fetch, force,
|
||||
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) => {
|
||||
if (e.data && e.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
if (ASSET_SYNC && e.data?.type === 'LAYOUT' && e.source && typeof e.data.value === 'string') {
|
||||
activeLayout = e.data.value;
|
||||
layoutReply?.();
|
||||
e.waitUntil((async()=>{
|
||||
const cache=await caches.open(AssetSyncCore.CACHE);
|
||||
await cache.put('/__ytp_layout',Response.json({layout:activeLayout}));
|
||||
const m=await candidate(cache);
|
||||
if(m && m.activeLayout!==activeLayout) {
|
||||
await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetch,activeLayout});
|
||||
m.activeLayout=activeLayout; await cache.put(candidateKey,Response.json(m));
|
||||
}
|
||||
})());
|
||||
}
|
||||
if (ASSET_SYNC && ['WARM_ASSETS', 'COMPLETE_ASSETS'].includes(e.data?.type)) {
|
||||
e.waitUntil(completeAssets(e.data.type === 'COMPLETE_ASSETS'));
|
||||
}
|
||||
if (e.data && e.data.type === 'PLAYING' && e.source) {
|
||||
playingClients.set(e.source.id, !!e.data.value);
|
||||
if (!e.data.value && deferredClients.delete(e.source.id)) self.skipWaiting();
|
||||
}
|
||||
if (e.data && e.data.type === 'SKIP_WAITING' && e.source && e.source.id) {
|
||||
const id = e.source && e.source.id;
|
||||
if (playingClients.get(id)) deferredClients.add(id);
|
||||
else self.skipWaiting();
|
||||
}
|
||||
// "Is your shell already downloaded?" — a waiting worker has precached the
|
||||
// whole new build during install, so "Refresh UI" can swap to it instantly
|
||||
@@ -297,7 +366,19 @@ self.addEventListener('message', (e) => {
|
||||
// deletes its cache on failure, but a cache can also be evicted under
|
||||
// storage pressure, so the files are actually checked.
|
||||
if (e.data && e.data.type === 'CACHE_STATUS') {
|
||||
if (ASSET_SYNC) e.waitUntil(completeAssets());
|
||||
e.waitUntil((async () => {
|
||||
if (ASSET_SYNC) {
|
||||
let reply = { ready: false, missing: -1, version: VERSION };
|
||||
try {
|
||||
const cache = await caches.open(AssetSyncCore.CACHE), state = await AssetSyncCore.state(cache);
|
||||
const pending = await candidate(cache), m = pending || state?.current;
|
||||
if(m) reply = await AssetSyncCore.status(m,cache,{previous:pending ? state?.current : state?.previous,activeLayout:e.data.activeLayout || (pending ? m.activeLayout : activeLayout) || 'classic'});
|
||||
} catch {}
|
||||
reply.type = 'CACHE_STATUS'; reply.assetSync = true; reply.completing = !!completionTask && !reply.offlineReady;
|
||||
if(e.ports && e.ports[0]) e.ports[0].postMessage(reply); else if(e.source) e.source.postMessage(reply);
|
||||
return;
|
||||
}
|
||||
let missing = SHELL.length;
|
||||
try {
|
||||
const cache = await caches.open(CACHE);
|
||||
@@ -316,7 +397,8 @@ self.addEventListener('message', (e) => {
|
||||
// ============================================================================
|
||||
|
||||
// Cache-first: serve from cache; if missing, fetch, cache, return.
|
||||
async function cacheFirst(request) {
|
||||
async function cacheFirst(request, clientId) {
|
||||
if (ASSET_SYNC) return assetFetch(request, clientId);
|
||||
const cache = await caches.open(CACHE);
|
||||
// Shell files are precached under their plain URL; index.html asks for them as
|
||||
// `file?v=<build>` (so the HTTP cache can keep them forever) — match ignoring it.
|
||||
@@ -422,3 +504,117 @@ async function staleWhileRevalidate(request, cacheName) {
|
||||
// surface a real network error instead.
|
||||
return cached || (await networkFetch) || Response.error();
|
||||
}
|
||||
|
||||
// Installation stores a candidate only. Activation publishes one atomic state record.
|
||||
const candidateKey = '/__ytp_candidate/' + VERSION;
|
||||
async function candidate(cache) { const r = await cache.match(candidateKey); return r ? r.json() : null; }
|
||||
async function installAssets() {
|
||||
const r = await fetch('/api/manifest', { cache: 'no-store' });
|
||||
if (!r.ok) throw Error('Manifest unavailable');
|
||||
const m = await r.json();
|
||||
if (m.buildTag !== VERSION) throw Error('Deployment changed during install');
|
||||
const cache = await caches.open(AssetSyncCore.CACHE);
|
||||
const remembered = await cache.match('/__ytp_layout');
|
||||
if(remembered)activeLayout=(await remembered.json()).layout || 'classic';
|
||||
let layoutKnown = !!remembered;
|
||||
const clients = await self.clients.matchAll({type:'window',includeUncontrolled:true});
|
||||
await new Promise(resolve=>{
|
||||
const timer=setTimeout(resolve,500); layoutReply=()=>{layoutKnown=true;clearTimeout(timer);resolve();};
|
||||
for(const client of clients)client.postMessage({type:'ASSET_LAYOUT_REQUEST'});
|
||||
});
|
||||
layoutReply=null; m.activeLayout=activeLayout;
|
||||
const freshInstall = !(await AssetSyncCore.state(cache));
|
||||
const fetchAsset = async (url,init) => {
|
||||
// Fonts/icons still use plain URLs in CSS/manifest. On first install reuse
|
||||
// their HTTP-cache responses, but only if the hash proves current bytes.
|
||||
if(freshInstall && /^(?:\/fonts\/|\/icons\/|\/manifest.webmanifest)/.test(url)) {
|
||||
const r = await fetch(url.split('?')[0],{...init,cache:'force-cache'});
|
||||
if(r.ok && r.headers.get('X-Asset-Hash') === url.split('v=')[1]) return r;
|
||||
}
|
||||
return fetch(url,init);
|
||||
};
|
||||
await AssetSyncCore.syncAssets(m, { cache, fetchFn: fetchAsset, activeLayout });
|
||||
// Older pages cannot report their saved choice. Keep every layout ready for
|
||||
// that one migration so an immediate offline reload cannot lose its theme.
|
||||
if(!layoutKnown && ((await AssetSyncCore.state(cache)) || (await caches.keys()).some(isVersionedShellCache))) {
|
||||
const layouts=new Set(Object.keys(m.groups).filter(name=>name.startsWith('layout:')));
|
||||
for(const name of layouts)for(const dependency of m.groups[name].dependencies || [])layouts.add(dependency);
|
||||
await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetchAsset,groups:[...layouts]});
|
||||
}
|
||||
await cache.put(candidateKey, new Response(JSON.stringify(m)));
|
||||
}
|
||||
async function migrateLegacy(cache, m) {
|
||||
// Legacy responses lack hash headers. Verify their actual bytes locally before
|
||||
// retaining them; this also preserves old whole-build URLs in already-open tabs.
|
||||
const names = (await caches.keys()).filter(isVersionedShellCache);
|
||||
let previous = null;
|
||||
for (const name of names) {
|
||||
const old = await caches.open(name);
|
||||
const files = {}, legacyURLs = {};
|
||||
for (const req of await old.keys()) {
|
||||
const u = new URL(req.url), p = u.pathname === '/' ? '/index.html' : u.pathname;
|
||||
if (!m.files[p]) continue;
|
||||
const r = await old.match(req);
|
||||
if (!r || !r.ok) continue;
|
||||
const bytes = await r.clone().arrayBuffer();
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
const h = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2,'0')).join('').slice(0,10);
|
||||
const headers = new Headers(r.headers); headers.set('X-Asset-Hash', h);
|
||||
files[p] = { h }; legacyURLs[p] = p + '?v=' + h;
|
||||
await cache.put(legacyURLs[p], new Response(bytes, { headers }));
|
||||
}
|
||||
if (Object.keys(files).length) previous = { buildTag:name.slice(SHELL_CACHE_PREFIX.length),files,groups:{core:{files:Object.keys(files)}},legacyURLs };
|
||||
}
|
||||
if (previous && !(await AssetSyncCore.state(cache))) await cache.put(AssetSyncCore.STATE, new Response(JSON.stringify({current:previous})));
|
||||
return names;
|
||||
}
|
||||
async function activateAssets() {
|
||||
const cache = await caches.open(AssetSyncCore.CACHE), m = await candidate(cache);
|
||||
if (!m) throw Error('Missing candidate manifest');
|
||||
const names = await migrateLegacy(cache,m), old = await AssetSyncCore.state(cache);
|
||||
const previousClients = (await self.clients.matchAll({type:'window'})).map(c=>c.id);
|
||||
await AssetSyncCore.commit(m,cache,{previousClients,activeLayout:m.activeLayout || activeLayout});
|
||||
// Legacy deletion occurs strictly after verified commit.
|
||||
await Promise.all(names.map(n => caches.delete(n)));
|
||||
for (const req of await cache.keys()) if(new URL(req.url).pathname.startsWith('/__ytp_candidate/')) await cache.delete(req);
|
||||
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});
|
||||
// Completion owns a message-event lifetime, not the activation barrier.
|
||||
// In particular, a legacy client's four-second reload must not wait for it.
|
||||
self.registration.active.postMessage({ type: 'COMPLETE_ASSETS' });
|
||||
try { console.info('[asset-sync] storage', await self.navigator.storage.estimate()); } catch {}
|
||||
}
|
||||
async function assetFetch(request, clientId) {
|
||||
const cache = await caches.open(AssetSyncCore.CACHE), state = await AssetSyncCore.state(cache);
|
||||
if(!state) return fetch(request);
|
||||
const u = new URL(request.url);
|
||||
let m = state.current;
|
||||
if(u.origin !== self.location.origin) return fetch(request);
|
||||
if(u.searchParams.has('__ytpfresh')) return fetch(request);
|
||||
const nav = request.mode === 'navigate' || u.pathname === '/' || u.pathname === '/index.html';
|
||||
if(!nav && state.previousClients?.includes(clientId) && state.previous?.files[u.pathname]) m = state.previous;
|
||||
let path = nav ? '/index.html' : u.pathname;
|
||||
let key;
|
||||
if(u.searchParams.has('v') && state.current.files[u.pathname]) {
|
||||
key = u.pathname + u.search;
|
||||
if(state.previous?.legacyURLs && u.searchParams.get('v') === state.previous.buildTag) key = state.previous.legacyURLs[path] || key;
|
||||
} else if(m.files[path]) key = AssetSyncCore.url(path,m.files[path]);
|
||||
if(key) {
|
||||
const cached = await cache.match(key);
|
||||
const h = new URL(key,self.location.origin).searchParams.get('v');
|
||||
if(cached && cached.headers.get('X-Asset-Hash') === h) return cached;
|
||||
if(state.current.files[path]?.h === h) {
|
||||
const previousKey=AssetSyncCore.fallback(state.current,state.previous,path);
|
||||
const previousResponse=previousKey && await cache.match(previousKey);
|
||||
if(previousResponse && previousResponse.headers.get('X-Asset-Hash')===state.previous.files[path].h) {
|
||||
// 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 { return await fetch(request); } catch { return new Response('Offline',{status:503}); }
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ function loadSw(initialCacheKeys, { fetchImpl } = {}) {
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
__ASSET_SYNC__: false,
|
||||
self: self_,
|
||||
caches: caches_,
|
||||
fetch: fetchImpl || (async () => { throw new Error('fetch not mocked'); }),
|
||||
|
||||
@@ -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"] #controls { gap:12px; }
|
||||
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 svg { width:28px; height:28px; }
|
||||
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"] .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"] #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"] #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); }
|
||||
|
||||
@@ -14,12 +14,6 @@
|
||||
* ========================================================================== */
|
||||
|
||||
/* Elements that only exist for this theme stay hidden elsewhere. */
|
||||
.stg-overlay, .skip10, .deck-chips, .vol-pct, .mode-name { display: none; }
|
||||
.np-btn .ti, .np-btn .tl { pointer-events: none; }
|
||||
.np-btn .ti { width: 20px; height: 20px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.np-btn .i-done { display: none; }
|
||||
.np-btn.done .i-save { display: none; }
|
||||
.np-btn.done .i-done { display: inline; }
|
||||
|
||||
html[data-layout="glass-stage"] {
|
||||
--bg: #121317;
|
||||
@@ -93,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 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 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 ---- */
|
||||
html[data-layout="glass-stage"] #controls { padding: 16px; gap: 14px; }
|
||||
@@ -101,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.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.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 .time { font: 600 12px var(--mono); }
|
||||
|
||||
@@ -202,8 +196,6 @@ html[data-layout="glass-stage"] .stg-overlay { justify-content: flex-start; }
|
||||
html[data-layout="glass-stage"] .stg-mid { margin: auto 0; }
|
||||
|
||||
/* =========================== fidelity pass (matches the approved mockup) =========================== */
|
||||
.stg-bot, .ch-badge, .upnext-clear, .list-sub, .p2p-strip { display: none; }
|
||||
.sleep-status .sl-ic, .sleep-status .sl-sub { display: none; }
|
||||
|
||||
/* stage HUD */
|
||||
html[data-layout="glass-stage"] .stg-bot { display: flex; position: absolute; left: 12px; right: 12px; bottom: 12px; justify-content: space-between; pointer-events: none; }
|
||||
@@ -292,7 +284,6 @@ html[data-layout="glass-stage"] .ps-txt small { font: 400 10px var(--mono); colo
|
||||
html[data-layout="glass-stage"] .ps-dl { width: 34px; height: 34px; border-radius: 12px; border: 0; background: rgba(255, 255, 255, 0.07); color: var(--accent-bright); font-size: 16px; cursor: pointer; }
|
||||
|
||||
/* ===== quick row (clean rebuild): [A | B | ↺] [Fullscreen] [More] ===== */
|
||||
.ab-group { display: contents; }
|
||||
html[data-layout="glass-stage"] #controls .dk-quick { display: flex; flex-wrap: nowrap; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
html[data-layout="glass-stage"] #controls .ab-group { display: inline-flex; align-items: stretch; gap: 2px; padding: 3px; border-radius: 14px; background: var(--tile); border: 1px solid var(--glass-line); flex: 0 1 auto; min-width: 0; }
|
||||
html[data-layout="glass-stage"] #controls .ab-group .ctrl {
|
||||
@@ -339,7 +330,6 @@ html[data-layout="glass-stage"] #controls .ab-group .ctrl { padding: 0 7px !impo
|
||||
html[data-layout="glass-stage"] #controls .ab-group #abClearBtn { padding: 0 7px !important; font-size: 13px !important; }
|
||||
|
||||
/* ===== A-B · Fullscreen · More — styled like the mockup ===== */
|
||||
.mc-tune { display: none; }
|
||||
html[data-layout="glass-stage"] #controls .dk-quick { flex-wrap: wrap; justify-content: space-between; gap: 8px; }
|
||||
html[data-layout="glass-stage"] #controls .ab-group { padding: 2px; border-radius: 12px; gap: 0; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.05); }
|
||||
html[data-layout="glass-stage"] #controls .ab-group .ctrl { height: 26px !important; padding: 0 10px !important; border-radius: 8px !important; font: 500 10px var(--mono) !important; color: var(--text-dim); }
|
||||
|
||||
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/);
|
||||
});
|
||||
1523
frontend/views-core.js
Normal file
1523
frontend/views-core.js
Normal file
File diff suppressed because it is too large
Load Diff
458
frontend/watch-party-client.js
Normal file
458
frontend/watch-party-client.js
Normal file
@@ -0,0 +1,458 @@
|
||||
/* Lazy classic chunk; its executing singleton stays pinned until reload. */
|
||||
(window.YT ||= {}).Party = (() => {
|
||||
const noop = { boot() {}, guestActive: () => false, intercept: () => false, afterLoad() {} };
|
||||
if (!WEB || typeof WebSocket === 'undefined') return noop;
|
||||
const PID_KEY = 'ytpPartyPid', HOST_KEY = 'ytpPartyHost', NAME_KEY = 'ytpPartyName';
|
||||
const ls = {
|
||||
get: (k) => { try { return localStorage.getItem(k); } catch { return null; } },
|
||||
set: (k, v) => { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch { /* blocked */ } },
|
||||
};
|
||||
let pid = ls.get(PID_KEY);
|
||||
if (!pid) { pid = 'p' + Math.random().toString(36).slice(2, 12) + Date.now().toString(36); ls.set(PID_KEY, pid); }
|
||||
const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/party?${new URLSearchParams(q)}`;
|
||||
|
||||
const st = {
|
||||
ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
|
||||
dj: { enabled: false, entries: [] }, djPending: false,
|
||||
allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '',
|
||||
voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
|
||||
minimized: false,
|
||||
};
|
||||
const inParty = () => !!st.role;
|
||||
const isHost = () => st.role === 'host';
|
||||
const guestActive = () => st.role === 'guest' && !st.detached;
|
||||
const send = (m) => { if (st.ws && st.ws.readyState === 1) st.ws.send(JSON.stringify(m)); };
|
||||
const serverNow = () => Date.now() + st.offset;
|
||||
const myName = () => st.name || (data.profile && data.profile.name) || ls.get(NAME_KEY) || '';
|
||||
|
||||
// ---------- connection ----------
|
||||
function connect() {
|
||||
clearTimeout(st.retry);
|
||||
const q = { role: st.role, pid, name: myName() || 'Guest' };
|
||||
if (st.code) q.code = st.code;
|
||||
if (isHost() && st.secret) q.secret = st.secret;
|
||||
const ws = new WebSocket(wsUrl(q));
|
||||
st.ws = ws;
|
||||
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onMessage(m); };
|
||||
ws.onclose = (e) => {
|
||||
if (st.ws !== ws) return;
|
||||
st.ws = null;
|
||||
if (!inParty()) return;
|
||||
if (e.code === 4010 || e.code === 4004 || e.code === 4003) {
|
||||
const why = e.code === 4004 ? 'That party code doesn’t exist (or has ended).' : e.code === 4003 ? 'This party belongs to another host.' : 'The party ended.';
|
||||
leave(false);
|
||||
toast(`🎉 ${why}`, { duration: 5000 });
|
||||
return;
|
||||
}
|
||||
setStatus('Reconnecting…');
|
||||
st.retry = setTimeout(connect, 3000);
|
||||
};
|
||||
ws.onerror = () => { if (!st.code && isHost()) setStatus('Could not reach the server'); };
|
||||
}
|
||||
|
||||
function onMessage(m) {
|
||||
DirectMedia.attach('party', { send, peers: () => st.members.filter(m => m.pid !== pid).map(m => ({ id: m.pid, name: m.name, files: m.host && st.state?.directAvailable && st.state?.v ? [st.state.v.id] : [] })) });
|
||||
if (m.type === 'direct') { DirectMedia.handle('party', m); return; }
|
||||
if (m.type === 'hello') {
|
||||
st.code = m.code;
|
||||
if (m.secret) { st.secret = m.secret; ls.set(HOST_KEY, JSON.stringify({ code: m.code, secret: m.secret })); }
|
||||
st.offset = (m.now || Date.now()) - Date.now();
|
||||
st.members = m.members || [];
|
||||
st.chat = m.chat || [];
|
||||
st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
|
||||
st.allowControl = !!m.allowControl;
|
||||
setStatus(isHost() ? 'You’re hosting' : 'Synced with the host');
|
||||
render();
|
||||
if (isHost()) pushState(true);
|
||||
else if (m.state) applyState(m.state);
|
||||
if (st.voice.on) { send({ type: 'voice', on: true }); connectVoicePeers(); }
|
||||
} else if (m.type === 'dj') {
|
||||
st.dj = { enabled: m.enabled, entries: m.entries || [] }; render();
|
||||
} else if (m.type === 'dj-play' && isHost()) {
|
||||
st.djPending = false; if (m.video) { Player._handoff = null; playVideoAt(m.video); } else { st.dj.entries = st.dj.entries.filter(e => !e.approved); advanceQueue(); }
|
||||
} else if (m.type === 'state') {
|
||||
st.state = m.state;
|
||||
if (st.role === 'guest') applyState(m.state);
|
||||
} else if (m.type === 'members') {
|
||||
st.members = m.members || [];
|
||||
if (m.joined) sysMsg(`${m.joined} joined`);
|
||||
if (m.left) sysMsg(`${m.left} left${m.hostAway ? ' — waiting for the host to come back' : ''}`);
|
||||
render();
|
||||
syncVoicePeers();
|
||||
} else if (m.type === 'chat') {
|
||||
st.chat.push(m.msg);
|
||||
if (st.chat.length > 100) st.chat.shift();
|
||||
renderChat();
|
||||
if (m.msg.pid !== pid && (st.minimized || $('partyPanel').classList.contains('hidden'))) toast(`💬 ${m.msg.from}: ${m.msg.text}`);
|
||||
} else if (m.type === 'settings') {
|
||||
st.allowControl = !!m.allowControl;
|
||||
render();
|
||||
if (!isHost()) sysMsg(st.allowControl ? 'The host lets everyone control playback' : 'Only the host controls playback now');
|
||||
} else if (m.type === 'cmd' && isHost()) {
|
||||
runCommand(m);
|
||||
} else if (m.type === 'rtc') {
|
||||
onRtc(m.from, m.data);
|
||||
} else if (m.type === 'ended') {
|
||||
toast(`🎉 ${m.reason || 'The party ended'}`, { duration: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- host: broadcast ----------
|
||||
function stateNow() {
|
||||
const meta = current && current.meta;
|
||||
const M = Player.master;
|
||||
return {
|
||||
directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false,
|
||||
v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '', duration: meta.duration || 0 } : null,
|
||||
cur: M ? M.currentTime || 0 : 0,
|
||||
paused: !M || M.paused,
|
||||
rate: (M && M.playbackRate) || 1,
|
||||
};
|
||||
}
|
||||
function pushState(force = false) {
|
||||
if (!isHost() || !st.ws) return;
|
||||
const s = stateNow();
|
||||
const key = JSON.stringify({ ...s, cur: Math.round(s.cur) });
|
||||
if (!force && key === st.lastPush) return;
|
||||
st.lastPush = key;
|
||||
send({ type: 'state', state: s });
|
||||
}
|
||||
function runCommand(m) {
|
||||
switch (m.cmd) {
|
||||
case 'toggle': Player.toggle(); break;
|
||||
case 'play': Player.play(); break;
|
||||
case 'pause': Player.pause(); break;
|
||||
case 'seek': if (Number.isFinite(Number(m.t))) Player.seek(Math.max(0, Number(m.t))); break;
|
||||
case 'next': playNext(); break;
|
||||
case 'prev': playPrev(); break;
|
||||
default: return;
|
||||
}
|
||||
toast(`🎉 ${m.from}: ${m.cmd === 'seek' ? `jumped to ${fmtTime(Number(m.t))}` : m.cmd}`);
|
||||
setTimeout(() => pushState(true), 200);
|
||||
}
|
||||
|
||||
// ---------- guest: follow ----------
|
||||
const quiet = (ms = 1500) => { st.quietUntil = Date.now() + ms; };
|
||||
function targetTime(s) {
|
||||
if (!s) return 0;
|
||||
return s.paused ? s.cur : s.cur + ((serverNow() - (s.ts || serverNow())) / 1000) * (s.rate || 1);
|
||||
}
|
||||
async function applyState(s) {
|
||||
st.state = s;
|
||||
if (st.role !== 'guest' || st.detached || !s || !s.v) { render(); return; }
|
||||
const target = targetTime(s);
|
||||
if (!current || !current.meta || current.meta.id !== s.v.id) {
|
||||
quiet(6000);
|
||||
await playVideoAt({ ...s.v }, target);
|
||||
return;
|
||||
}
|
||||
const M = Player.master;
|
||||
if (!M) return;
|
||||
if ((M.playbackRate || 1) !== (s.rate || 1)) { els.speed.value = String(s.rate || 1); Player.applySpeed(); }
|
||||
if (Math.abs((M.currentTime || 0) - target) > 1.5) { quiet(); Player.seek(target); }
|
||||
if (s.paused && !M.paused) { quiet(); Player.pause(); updatePlayBtn(); }
|
||||
else if (!s.paused && M.paused) { quiet(); Player.play(); updatePlayBtn(); }
|
||||
}
|
||||
// A guest's own actions: forwarded when allowed, else they go off sync.
|
||||
function onLocalAction(kind) {
|
||||
if (st.role !== 'guest' || st.detached || Date.now() < st.quietUntil) return;
|
||||
if (st.allowControl) {
|
||||
const M = Player.master;
|
||||
if (kind === 'seeked') send({ type: 'cmd', cmd: 'seek', t: M.currentTime });
|
||||
else send({ type: 'cmd', cmd: kind === 'pause' ? 'pause' : 'play' });
|
||||
return;
|
||||
}
|
||||
st.detached = true;
|
||||
setStatus('Off sync — you paused or skipped on your own');
|
||||
render();
|
||||
}
|
||||
// playNext/playPrev guard: guests don't change songs themselves.
|
||||
function intercept(cmd) {
|
||||
if (st.role !== 'guest' || st.detached) return false;
|
||||
if (st.allowControl) send({ type: 'cmd', cmd });
|
||||
else toast('🎉 The host picks the songs in a watch party');
|
||||
return true;
|
||||
}
|
||||
function resync() {
|
||||
st.detached = false;
|
||||
setStatus('Synced with the host');
|
||||
render();
|
||||
applyState(st.state);
|
||||
}
|
||||
|
||||
// ---------- voice (WebRTC mesh) ----------
|
||||
const ICE = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
|
||||
function peer(other) {
|
||||
let pc = st.voice.pcs.get(other);
|
||||
if (pc) return pc;
|
||||
pc = new RTCPeerConnection({ iceServers: ICE });
|
||||
st.voice.pcs.set(other, pc);
|
||||
if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => pc.addTrack(t, st.voice.stream));
|
||||
pc.onicecandidate = (e) => { if (e.candidate) send({ type: 'rtc', to: other, data: { ice: e.candidate } }); };
|
||||
pc.ontrack = (e) => {
|
||||
let a = st.voice.audios.get(other);
|
||||
if (!a) { a = new Audio(); a.autoplay = true; a.setAttribute('playsinline', ''); st.voice.audios.set(other, a); }
|
||||
a.srcObject = e.streams[0];
|
||||
a.play().catch(() => {});
|
||||
};
|
||||
pc.onconnectionstatechange = () => { if (['failed', 'closed'].includes(pc.connectionState)) dropPeer(other); render(); };
|
||||
return pc;
|
||||
}
|
||||
function dropPeer(other) {
|
||||
const pc = st.voice.pcs.get(other);
|
||||
if (pc) { try { pc.close(); } catch { /* closed */ } st.voice.pcs.delete(other); }
|
||||
const a = st.voice.audios.get(other);
|
||||
if (a) { a.srcObject = null; st.voice.audios.delete(other); }
|
||||
}
|
||||
async function offerTo(other) {
|
||||
const pc = peer(other);
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
send({ type: 'rtc', to: other, data: { sdp: pc.localDescription } });
|
||||
}
|
||||
async function onRtc(from, d) {
|
||||
if (!st.voice.on || !d) return;
|
||||
try {
|
||||
if (d.sdp) {
|
||||
const pc = peer(from);
|
||||
await pc.setRemoteDescription(d.sdp);
|
||||
if (d.sdp.type === 'offer') {
|
||||
const ans = await pc.createAnswer();
|
||||
await pc.setLocalDescription(ans);
|
||||
send({ type: 'rtc', to: from, data: { sdp: pc.localDescription } });
|
||||
}
|
||||
} else if (d.ice) {
|
||||
await peer(from).addIceCandidate(d.ice);
|
||||
}
|
||||
} catch { /* a glare/ICE hiccup — the next members update retries */ }
|
||||
}
|
||||
// The member with the smaller pid makes the offer, so each pair connects once.
|
||||
function connectVoicePeers() {
|
||||
if (!st.voice.on) return;
|
||||
for (const m of st.members) if (m.voice && m.pid !== pid && !st.voice.pcs.has(m.pid) && pid < m.pid) offerTo(m.pid).catch(() => {});
|
||||
}
|
||||
function syncVoicePeers() {
|
||||
const voiced = new Set(st.members.filter((m) => m.voice).map((m) => m.pid));
|
||||
for (const other of [...st.voice.pcs.keys()]) if (!voiced.has(other)) dropPeer(other);
|
||||
connectVoicePeers();
|
||||
}
|
||||
async function toggleVoice() {
|
||||
if (st.voice.on) {
|
||||
st.voice.on = false;
|
||||
for (const other of [...st.voice.pcs.keys()]) dropPeer(other);
|
||||
if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => t.stop());
|
||||
st.voice.stream = null;
|
||||
send({ type: 'voice', on: false });
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof RTCPeerConnection === 'undefined') { toast('Voice chat isn’t supported in this browser'); return; }
|
||||
try {
|
||||
st.voice.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
|
||||
} catch (e) {
|
||||
toast('🎤 Microphone blocked: ' + (e.message || e.name));
|
||||
return;
|
||||
}
|
||||
st.voice.on = true;
|
||||
st.voice.muted = false;
|
||||
send({ type: 'voice', on: true });
|
||||
toast('🎤 Voice on — headphones stop the music echoing into your mic', { duration: 5000 });
|
||||
render();
|
||||
connectVoicePeers();
|
||||
}
|
||||
function toggleMute() {
|
||||
if (!st.voice.stream) return;
|
||||
st.voice.muted = !st.voice.muted;
|
||||
st.voice.stream.getAudioTracks().forEach((t) => { t.enabled = !st.voice.muted; });
|
||||
render();
|
||||
}
|
||||
|
||||
// ---------- lifecycle ----------
|
||||
function start() {
|
||||
st.role = 'host';
|
||||
st.code = ''; st.secret = ''; st.chat = []; st.members = [];
|
||||
ls.set(HOST_KEY, null);
|
||||
showPanel();
|
||||
setStatus('Starting…');
|
||||
connect();
|
||||
beginTimers();
|
||||
}
|
||||
function join(code, name) {
|
||||
code = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
if (code.length !== 6) { toast('Party codes have 6 letters/numbers'); return; }
|
||||
if (name) { st.name = name; ls.set(NAME_KEY, name); }
|
||||
st.role = 'guest';
|
||||
st.code = code; st.detached = false; st.chat = []; st.members = [];
|
||||
showPanel();
|
||||
setStatus('Joining…');
|
||||
connect();
|
||||
beginTimers();
|
||||
}
|
||||
function beginTimers() {
|
||||
clearInterval(st.pushTimer); clearInterval(st.driftTimer);
|
||||
st.pushTimer = setInterval(() => pushState(), 3000);
|
||||
st.driftTimer = setInterval(() => { if (st.role === 'guest' && st.state && !st.detached) applyState(st.state); }, 2500);
|
||||
}
|
||||
function leave(sendEnd = true) {
|
||||
if (isHost() && sendEnd) send({ type: 'end' });
|
||||
if (st.voice.on) toggleVoice();
|
||||
const ws = st.ws;
|
||||
st.ws = null;
|
||||
st.role = null; st.code = ''; st.secret = '';
|
||||
clearTimeout(st.retry); clearInterval(st.pushTimer); clearInterval(st.driftTimer);
|
||||
try { if (ws) ws.close(); } catch { /* gone */ }
|
||||
ls.set(HOST_KEY, null);
|
||||
$('partyPanel').classList.add('hidden');
|
||||
render();
|
||||
}
|
||||
|
||||
// ---------- UI ----------
|
||||
// Lyrics-instead-of-video is a per-device view: each guest chooses it for
|
||||
// themselves, and the host's playback is untouched (the video keeps playing
|
||||
// behind the lyrics, so sync and audio carry on).
|
||||
const lyricsOn = () => !!(Notes.stageLyrics && Notes.stageLyrics.on());
|
||||
function renderLyricsToggle() {
|
||||
const b = document.querySelector('#partyPanel .pt-lyrics');
|
||||
if (!b) return;
|
||||
b.textContent = lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video';
|
||||
b.classList.toggle('on', lyricsOn());
|
||||
}
|
||||
function setStatus(t) { const el = $('ptStatus'); if (el) el.textContent = t; }
|
||||
function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); renderChat(); }
|
||||
function link() { return `${location.origin}/?party=${st.code}`; }
|
||||
function showPanel() { Lazy.load('feature:party-dj').then(render).catch(error=>toast(error.message)); Lazy.load('feature:direct').catch(error=>toast(error.message)); $('partyPanel').classList.remove('hidden'); st.minimized = false; $('partyPanel').classList.remove('min'); render(); }
|
||||
|
||||
function render() {
|
||||
const panel = $('partyPanel');
|
||||
const chip = $('navPartyBtn');
|
||||
if (chip) {
|
||||
const label = chip.querySelector('.nav-label');
|
||||
if (label) label.textContent = inParty() ? `Party · ${st.members.length}` : 'Watch party';
|
||||
else chip.textContent = inParty() ? `🎉 Party · ${st.members.length}` : '🎉 Watch party';
|
||||
}
|
||||
if (!panel || panel.classList.contains('hidden')) return;
|
||||
const nameOk = !!myName();
|
||||
if (!inParty()) {
|
||||
panel.querySelector('.pt-body').innerHTML = `
|
||||
<p class="modal-text">Watch together: everyone's player follows the host, with chat and optional voice.</p>
|
||||
${nameOk ? '' : '<label class="pt-row">Your name <input class="pt-name" maxlength="40" placeholder="e.g. Rex" /></label>'}
|
||||
<button class="rv-primary" type="button" data-act="start">Start a party</button>
|
||||
<div class="pt-or">or join one</div>
|
||||
<div class="pt-row"><input class="pt-code" maxlength="7" placeholder="CODE" autocapitalize="characters" /><button class="np-btn" type="button" data-act="join">Join</button></div>`;
|
||||
$('ptTitle').textContent = '🎉 Watch party';
|
||||
setStatus('');
|
||||
return;
|
||||
}
|
||||
$('ptTitle').textContent = `🎉 Party ${st.code || '…'}`;
|
||||
const s = st.state && st.state.v;
|
||||
panel.querySelector('.pt-body').innerHTML = `
|
||||
${st.code ? `<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">${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>' : ''}
|
||||
<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>` : ''}
|
||||
<button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
|
||||
<div class="pt-voice">
|
||||
<button class="np-btn${st.voice.on ? ' on' : ''}" type="button" data-act="voice">${st.voice.on ? '🎤 Leave voice' : '🎤 Join voice'}</button>
|
||||
${st.voice.on ? `<button class="np-btn" type="button" data-act="mute">${st.voice.muted ? '🔇 Unmute' : '🔈 Mute'}</button>` : ''}
|
||||
<span class="pt-voice-n">${st.voice.on ? `${st.voice.pcs.size} connected` : ''}</span>
|
||||
</div>
|
||||
<div class="pt-chat" id="ptChat"></div>
|
||||
<form class="pt-send"><input class="pt-msg" maxlength="500" placeholder="Message…" autocomplete="off" /><button class="np-btn" type="submit">Send</button></form>
|
||||
<button class="pt-leave" type="button" data-act="leave">${isHost() ? 'End the party' : 'Leave the party'}</button>`;
|
||||
const dj = document.createElement('div'); panel.querySelector('.pt-body').append(dj);
|
||||
if (Lazy.loaded('feature:party-dj')) PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } });
|
||||
renderChat();
|
||||
}
|
||||
function renderChat() {
|
||||
const box = $('ptChat');
|
||||
if (!box) return;
|
||||
box.innerHTML = st.chat.map((c) => c.sys
|
||||
? `<div class="pt-sys">${escapeHtml(c.text)}</div>`
|
||||
: `<div class="pt-line${c.pid === pid ? ' me' : ''}"><b>${escapeHtml(c.from)}</b> ${escapeHtml(c.text)}</div>`).join('');
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
function open() {
|
||||
const panel = $('partyPanel');
|
||||
if (typeof closeSidebar === 'function') { try { closeSidebar(); } catch { /* not open */ } }
|
||||
if (panel.classList.contains('hidden')) showPanel();
|
||||
else if (st.minimized) { st.minimized = false; panel.classList.remove('min'); }
|
||||
else if (!inParty()) panel.classList.add('hidden');
|
||||
else { st.minimized = true; panel.classList.add('min'); }
|
||||
}
|
||||
|
||||
function bindUi() {
|
||||
const panel = $('partyPanel');
|
||||
$('ptMin').addEventListener('click', () => {
|
||||
if (!inParty()) { panel.classList.add('hidden'); return; }
|
||||
st.minimized = !st.minimized;
|
||||
panel.classList.toggle('min', st.minimized);
|
||||
});
|
||||
panel.addEventListener('keydown', (e) => e.stopPropagation());
|
||||
panel.addEventListener('click', async (e) => {
|
||||
const a = e.target.closest('[data-act]');
|
||||
if (!a || a.tagName === 'INPUT') return;
|
||||
const act = a.dataset.act;
|
||||
const nameIn = panel.querySelector('.pt-name');
|
||||
const name = nameIn ? nameIn.value.trim() : '';
|
||||
if ((act === 'start' || act === 'join') && !myName() && !name) { toast('Enter your name first'); if (nameIn) nameIn.focus(); return; }
|
||||
if (name) { st.name = name; ls.set(NAME_KEY, name); }
|
||||
if (act === 'start') start();
|
||||
else if (act === 'join') join(panel.querySelector('.pt-code').value);
|
||||
else if (act === 'copy') { try { await navigator.clipboard.writeText(link()); toast('Party link copied ✓'); } catch { toast('Copy blocked'); } }
|
||||
else if (act === 'share') { try { await navigator.share({ title: 'Join my watch party', url: link() }); } catch { /* dismissed */ } }
|
||||
else if (act === 'resync') resync();
|
||||
else if (act === 'voice') toggleVoice();
|
||||
else if (act === 'mute') toggleMute();
|
||||
else if (act === 'lyrics') { Notes.stageLyrics.set(!lyricsOn()); renderLyricsToggle(); }
|
||||
else if (act === 'leave') leave(true);
|
||||
});
|
||||
panel.addEventListener('change', (e) => {
|
||||
if (e.target.dataset.act === 'allow') { st.allowControl = e.target.checked; send({ type: 'settings', allowControl: st.allowControl }); }
|
||||
});
|
||||
panel.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const input = panel.querySelector('.pt-msg');
|
||||
const text = input.value.trim();
|
||||
if (text) { send({ type: 'chat', text }); input.value = ''; }
|
||||
});
|
||||
// Host: push on every player change; guests: detect their own actions.
|
||||
['play', 'pause', 'seeked', 'ratechange', 'loadedmetadata'].forEach((ev) => {
|
||||
[els.video, els.audio].forEach((el) => el.addEventListener(ev, (e) => {
|
||||
if (el !== Player.master) return;
|
||||
if (isHost()) pushState(true);
|
||||
else if (ev === 'play' || ev === 'pause' || ev === 'seeked') onLocalAction(ev);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
bindUi();
|
||||
render();
|
||||
// Resume hosting after a reload.
|
||||
try {
|
||||
const h = JSON.parse(ls.get(HOST_KEY) || 'null');
|
||||
if (h && h.code && h.secret) { st.role = 'host'; st.code = h.code; st.secret = h.secret; showPanel(); connect(); beginTimers(); }
|
||||
} catch { /* none */ }
|
||||
let code = '';
|
||||
try { code = (new URLSearchParams(location.search).get('party') || '').toUpperCase(); } catch { /* no URL */ }
|
||||
if (code) {
|
||||
try { const u = new URL(location.href); u.searchParams.delete('party'); history.replaceState(null, '', u.pathname + u.search + u.hash); } catch { /* keep */ }
|
||||
if (myName()) join(code);
|
||||
else { showPanel(); const c = $('partyPanel').querySelector('.pt-code'); if (c) c.value = code; toast('Enter your name, then tap Join'); }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nextDJ: () => { if (!isHost() || !st.dj.enabled || !st.dj.entries.some(e => e.approved)) return false; if (!st.djPending) { st.djPending = true; send({ type: 'dj', action: 'take' }); } return true; },
|
||||
open, boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true),
|
||||
state: () => ({
|
||||
role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on,
|
||||
peers: st.voice.pcs.size, peerStates: [...st.voice.pcs.values()].map((pc) => pc.connectionState),
|
||||
remoteAudio: [...st.voice.audios.values()].filter((a) => a.srcObject && a.srcObject.getAudioTracks().some((t) => t.readyState === 'live')).length,
|
||||
chat: st.chat.length, allowControl: st.allowControl,
|
||||
}),
|
||||
_join: join, _start: start,
|
||||
};
|
||||
})();
|
||||
12
frontend/worker-imports.test.js
Normal file
12
frontend/worker-imports.test.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const {test}=require('node:test'), assert=require('node:assert/strict'), vm=require('node:vm'), fs=require('node:fs');
|
||||
for(const name of ['hash-worker','direct-recv-worker','p2p-recv-worker','opfs-worker'])test(name+' imports its originating page hashes before handling the first operation',async()=>{
|
||||
const urls=[], replies=[];const self={postMessage:m=>replies.push(m)};
|
||||
const sandbox={self,console,URL,setTimeout,clearTimeout,Promise,TextEncoder,FileSystemFileHandle:class {createSyncAccessHandle(){}},navigator:{storage:{getDirectory:async()=>{throw Error('fixture: no disk writes')}}},fetch:async()=>{throw Error('fixture: no network')},importScripts:u=>{urls.push(u);if(u.includes('/sha256.js'))self.Sha256={create:()=>({})};if(u.includes('/resume-core.js'))self.ResumeCore={};}};
|
||||
vm.runInNewContext(fs.readFileSync(__dirname+'/'+name+'.js','utf8'),sandbox);
|
||||
assert.deepEqual(urls,[]);
|
||||
await self.onmessage({data:{name:'x',op:'open',videoId:'x',url:'/fixture',file:{id:'x',cid:'a'.repeat(64)},assetUrls:{sha256:'/sha256.js?v=sha',wasm:'/sha256-wasm.js?v=wasm',resume:'/resume-core.js?v=resume'}}});
|
||||
await new Promise(r=>setImmediate(r));
|
||||
assert.equal(urls[0],'/sha256.js?v=sha');assert.equal(self.__ASSET_URLS__.wasm,'/sha256-wasm.js?v=wasm');
|
||||
if(name==='opfs-worker')assert.ok(urls.includes('/resume-core.js?v=resume'));
|
||||
assert.ok(replies.length>0);
|
||||
});
|
||||
Reference in New Issue
Block a user