Compare commits
10 Commits
cd4a25ae23
...
f133e1bb9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f133e1bb9f | ||
|
|
a7473cbca2 | ||
|
|
2c5003575c | ||
|
|
b335f95f45 | ||
|
|
0475a1826c | ||
|
|
94ea211605 | ||
|
|
8e7fa1959a | ||
|
|
5fb9564468 | ||
|
|
3c9a24ae45 | ||
|
|
cdd26b6ae5 |
85
frontend/app-seams.test.js
Normal file
85
frontend/app-seams.test.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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 player, 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 updatePlayBtn() {");assert.ok(start>=0,"updatePlayBtn");const body=app.slice(start,start+522);assert.equal(createHash('sha256').update(body).digest('hex'),"1326233af07ae92a5ecc81cc211bd98d21ed5f291dc4eb59d040b8df9baba8c8","updatePlayBtn body unchanged");}
|
||||||
|
{const start=app.indexOf("function persist() {");assert.ok(start>=0,"persist");const body=app.slice(start,start+147);assert.equal(createHash('sha256').update(body).digest('hex'),"01e5c5dbc80616266db09b3829dc45b66476a3256b1c169d8e44f4983b24ac45","persist body unchanged");}
|
||||||
|
{const start=app.indexOf("function 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="app.js"'));
|
||||||
|
const vm=require('node:vm');const context=vm.createContext({window:{},document:{}});
|
||||||
|
// 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']);
|
||||||
|
});
|
||||||
3766
frontend/app.js
3766
frontend/app.js
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,10 @@
|
|||||||
"/feature-bootstrap.js",
|
"/feature-bootstrap.js",
|
||||||
"/eq-parameters.js",
|
"/eq-parameters.js",
|
||||||
"/assets.json",
|
"/assets.json",
|
||||||
"/device-db.js"
|
"/device-db.js",
|
||||||
|
"/shell-core.js",
|
||||||
|
"/views-core.js",
|
||||||
|
"/section-rail.js"
|
||||||
],
|
],
|
||||||
"background": false
|
"background": false
|
||||||
},
|
},
|
||||||
@@ -249,7 +252,8 @@
|
|||||||
"embeddedIn": [
|
"embeddedIn": [
|
||||||
"/app.js",
|
"/app.js",
|
||||||
"/settings-sections.js",
|
"/settings-sections.js",
|
||||||
"/settings-sections.css"
|
"/settings-sections.css",
|
||||||
|
"/views-core.js"
|
||||||
],
|
],
|
||||||
"background": false
|
"background": false
|
||||||
},
|
},
|
||||||
@@ -312,6 +316,49 @@
|
|||||||
"eager": false,
|
"eager": false,
|
||||||
"background": true,
|
"background": true,
|
||||||
"files": []
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -111,7 +111,8 @@
|
|||||||
proxy(name, methods, globalName) {
|
proxy(name, methods, globalName) {
|
||||||
return Object.fromEntries(methods.map(method => [method, async (...args) => {
|
return Object.fromEntries(methods.map(method => [method, async (...args) => {
|
||||||
await load(name);
|
await load(name);
|
||||||
const target = root[globalName || name.split(':')[1]];
|
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);
|
if (typeof target?.[method] !== 'function') throw new Error('Missing lazy method: ' + method);
|
||||||
return target[method](...args);
|
return target[method](...args);
|
||||||
}]));
|
}]));
|
||||||
|
|||||||
@@ -76,3 +76,11 @@ test('new group files with a changed dependency contract cannot run against the
|
|||||||
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:[]};
|
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);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
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') };
|
||||||
|
})();
|
||||||
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">▶ 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.textContent = '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');
|
||||||
|
$('rvToggle').textContent = s && !s.paused ? '⏸' : '▶';
|
||||||
|
$('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 };
|
||||||
|
})();
|
||||||
247
frontend/section-rail.js
Normal file
247
frontend/section-rail.js
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
/* 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 };
|
||||||
|
})();
|
||||||
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)}">⤓ 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)}">⤓ 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 ⬇ 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'))}">▶ VLC</a>
|
||||||
|
<a class="np-btn" href="${escapeHtml(intent(url, title, 'com.mxtech.videoplayer.ad'))}">▶ MX Player</a>
|
||||||
|
<a class="np-btn" href="${escapeHtml(intent(url, title))}">▶ Open with…</a>`
|
||||||
|
: isIOS ? `
|
||||||
|
<a class="np-btn" href="vlc-x-callback://x-callback-url/stream?url=${encodeURIComponent(url)}">▶ VLC</a>
|
||||||
|
<a class="np-btn" href="infuse://x-callback-url/play?url=${encodeURIComponent(url)}">▶ Infuse</a>`
|
||||||
|
: `<a class="np-btn" href="vlc://${escapeHtml(url)}">▶ VLC</a>`}
|
||||||
|
<button class="np-btn" type="button" data-act="m3u">⤓ .m3u (this video)</button>
|
||||||
|
${inList ? '<button class="np-btn" type="button" data-act="m3u-list">⤓ .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 };
|
||||||
|
})();
|
||||||
304
frontend/shell-core.js
Normal file
304
frontend/shell-core.js
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
/* 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">⬇</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">⬇</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">▶</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 ▶ 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;
|
||||||
|
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">▶ 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);
|
||||||
|
}));
|
||||||
|
} };
|
||||||
@@ -137,6 +137,14 @@ const SHELL = [
|
|||||||
'/car-mode.css',
|
'/car-mode.css',
|
||||||
'/lower-third.css',
|
'/lower-third.css',
|
||||||
'/settings-sections.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',
|
'/app.js',
|
||||||
'/manifest.webmanifest',
|
'/manifest.webmanifest',
|
||||||
'/icons/icon-192.png',
|
'/icons/icon-192.png',
|
||||||
|
|||||||
1501
frontend/views-core.js
Normal file
1501
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">▶ ${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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -271,3 +271,43 @@ Legacy pages cannot report their selected layout. Their one-time migration block
|
|||||||
all layouts to preserve immediate offline selection; modern pages block only the
|
all layouts to preserve immediate offline selection; modern pages block only the
|
||||||
reported layout and its shared controls. Executing groups never re-evaluate live.
|
reported layout and its shared controls. Executing groups never re-evaluate live.
|
||||||
See plans/phase3-staged-apply-decision.md: Phase 4 must add disposal/state handoff.
|
See plans/phase3-staged-apply-decision.md: Phase 4 must add disposal/state handoff.
|
||||||
|
|
||||||
|
## Phase 4 seams and verification
|
||||||
|
|
||||||
|
The dependency inventory in `plans/phase4-seams.md` records Phase 3 declarations,
|
||||||
|
resolved outside references and byte/coupling rankings. Reproduce its source using
|
||||||
|
`npm install --prefix perf/.tmp/analysis --no-audit --no-fund acorn@8 eslint-scope@8`
|
||||||
|
and `node perf/analyze-app.cjs cd4a25a` (development scratch dependencies only).
|
||||||
|
Shell/shared view/Settings/section-rail definitions remain eager before app.js.
|
||||||
|
Presenter, Remote client, Watch party, Share/External and Stats view register
|
||||||
|
classic singletons on `window.YT`; synchronous playback hooks remain facades.
|
||||||
|
Executing chunks remain pinned: disposal/state handoff is still required before
|
||||||
|
any live replacement can be enabled.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test frontend/*.test.js
|
||||||
|
(cd server && bun install && bun run test)
|
||||||
|
node perf/hidden-timers.mjs
|
||||||
|
node perf/seams.mjs --browser all
|
||||||
|
node perf/lazy.mjs --browser all
|
||||||
|
node perf/migration.mjs --browser all --profile lossy \
|
||||||
|
--out perf/results/phase4-migration-lossy-2026-10-08.json
|
||||||
|
node perf/migration.mjs --browser all --profile unthrottled \
|
||||||
|
--out perf/results/phase4-migration-unthrottled-2026-10-08.json
|
||||||
|
node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario all \
|
||||||
|
--out perf/results/phase4-final-2026-10-08.json \
|
||||||
|
--compare perf/results/after-phase0-2026-10-07.json
|
||||||
|
node perf/baseline.mjs --runs 3 --browser chromium --profile lte --scenario cold \
|
||||||
|
--out perf/results/phase4-cold-isolated-2026-10-08.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`update-feature` appends one comment to presenter.js in scratch, installs the worker,
|
||||||
|
explicitly activates, then waits for the changed background chunk to be verified
|
||||||
|
in CacheStorage. It asserts that no other application JS/CSS/font/icon payload
|
||||||
|
transfers; derived index/manifest and worker script checks are included in totals.
|
||||||
|
`all` includes this scenario when the source contains presenter.js. Cold records
|
||||||
|
now include individual long-task timing without changing the aggregate metric,
|
||||||
|
plus the source frontend build tag. WebKit does not expose the long-task API.
|
||||||
|
Migration supports `--out` to preserve earlier phase evidence. Existing lazy
|
||||||
|
harness results are copied to `phase4-lazy-2026-10-08.json` after each Phase 4 run
|
||||||
|
and the Phase 3 result is restored. No production requests or deploys are needed.
|
||||||
|
|||||||
14
perf/analyze-app.cjs
Normal file
14
perf/analyze-app.cjs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const fs=require('fs'), acorn=require('./.tmp/analysis/node_modules/acorn'), scope=require('./.tmp/analysis/node_modules/eslint-scope');
|
||||||
|
const {execFileSync}=require('node:child_process');
|
||||||
|
const commit=process.argv[2];
|
||||||
|
const read=file=>commit?execFileSync('git',['show',commit+':'+file],{encoding:'utf8',maxBuffer:2000000}):fs.readFileSync(file,'utf8');
|
||||||
|
const src=read('frontend/app.js');
|
||||||
|
function parse(s){return acorn.parse(s,{ecmaVersion:2024,sourceType:'script',locations:true,ranges:true});}
|
||||||
|
const ast=parse(src), mgr=scope.analyze(ast,{ecmaVersion:2024,sourceType:'script',optimistic:true,ignoreEval:true});
|
||||||
|
const vars=mgr.globalScope.variables, names=new Set(vars.map(v=>v.name));
|
||||||
|
const rows=vars.map(v=>{const decl=v.defs[0].parent||v.defs[0].node;const stmt=ast.body.find(n=>n.start<=decl.start&&n.end>=decl.end);return {name:v.name,start:stmt.start,end:stmt.end,line:stmt.loc.start.line,bytes:Buffer.byteLength(src.slice(stmt.start,stmt.end)),outside:v.references.filter(r=>r.identifier.start<stmt.start||r.identifier.start>=stmt.end).map(r=>r.identifier.loc.start.line),imports:[]};});
|
||||||
|
for(const row of rows){const deps=new Set();for(const v of vars)if(v.name!==row.name&&v.references.some(r=>r.identifier.start>=row.start&&r.identifier.start<row.end))deps.add(v.name);row.imports=[...deps];}
|
||||||
|
for(const file of (commit?execFileSync('git',['ls-tree','--name-only',commit+':frontend'],{encoding:'utf8'}).trim().split('\n'):fs.readdirSync('frontend')).filter(f=>f.endsWith('.js')&&!f.endsWith('.test.js')&&f!=='app.js')){try{const other=scope.analyze(parse(read('frontend/'+file)),{ecmaVersion:2024,sourceType:'script',optimistic:true,ignoreEval:true});for(const r of other.globalScope.through){if(names.has(r.identifier.name))rows.find(x=>x.name===r.identifier.name).outside.push(file+':'+r.identifier.loc.start.line);}}catch(e){if(!file.includes('entry'))console.error(file,e.message);}}
|
||||||
|
fs.writeFileSync('perf/.tmp/analysis/declarations.json',JSON.stringify(rows,null,2));
|
||||||
|
const candidates={shell:['Recommendations','renderSidebar','renderHomePlaylists','updateHomeGridScrollState','updateBottomChrome','setHomeOverPlayer','showMiniBar','updateMiniBar','hideMiniBar','setTile','updateNowPlayingActions','renderSmartSidebar'],presenter:['Presenter'],remote:['Remote'],party:['Party'],share:['Share','External'],stats:['renderStats'],notes:['Notes'],settings:['renderSettings'],service:['ServiceVisual','enterServiceMode','exitServiceMode'],editor:['openVideoEditor'],export:['exportToDevice'],sectionRail:['SectionRail'],gestures:['Gestures']};
|
||||||
|
const summary=Object.entries(candidates).map(([name,list])=>{const chosen=rows.filter(r=>list.includes(r.name));const coupling=new Set(chosen.flatMap(r=>r.imports).filter(n=>!list.includes(n)));return {name,bytes:chosen.reduce((s,r)=>s+r.bytes,0),coupling:coupling.size,ratio:Math.round(chosen.reduce((s,r)=>s+r.bytes,0)/Math.max(1,coupling.size)),imports:[...coupling],outside:chosen.flatMap(r=>r.outside),list};}).sort((a,b)=>b.ratio-a.ratio);fs.writeFileSync('perf/.tmp/analysis/candidates.json',JSON.stringify(summary,null,2));console.log(summary.map(r=>[r.name,r.bytes,r.coupling,r.ratio].join(' ')).join('\n'));
|
||||||
@@ -25,6 +25,8 @@
|
|||||||
* --compare <json-path> Compare results against a previous baseline JSON
|
* --compare <json-path> Compare results against a previous baseline JSON
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { spawn, spawnSync, execFileSync } from 'node:child_process';
|
import { spawn, spawnSync, execFileSync } from 'node:child_process';
|
||||||
import { existsSync, mkdirSync, rmSync, cpSync, appendFileSync, statSync, writeFileSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs';
|
import { existsSync, mkdirSync, rmSync, cpSync, appendFileSync, statSync, writeFileSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -244,6 +246,7 @@ async function setupPageMetrics(page) {
|
|||||||
window.__fcp = null;
|
window.__fcp = null;
|
||||||
window.__lcp = null;
|
window.__lcp = null;
|
||||||
window.__longTasksTotal = 0;
|
window.__longTasksTotal = 0;
|
||||||
|
window.__longTasks = [];
|
||||||
|
|
||||||
// Observe boot completion landmark (#cards view set by renderList())
|
// Observe boot completion landmark (#cards view set by renderList())
|
||||||
const markBoot = () => {
|
const markBoot = () => {
|
||||||
@@ -263,6 +266,7 @@ async function setupPageMetrics(page) {
|
|||||||
new PerformanceObserver((list) => {
|
new PerformanceObserver((list) => {
|
||||||
for (const entry of list.getEntries()) {
|
for (const entry of list.getEntries()) {
|
||||||
window.__longTasksTotal += entry.duration;
|
window.__longTasksTotal += entry.duration;
|
||||||
|
window.__longTasks.push({start:Math.round(entry.startTime),duration:Math.round(entry.duration)});
|
||||||
}
|
}
|
||||||
}).observe({ type: 'longtask', buffered: true });
|
}).observe({ type: 'longtask', buffered: true });
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -374,6 +378,7 @@ async function runColdScenario({ browserType, server, proxy, profile }) {
|
|||||||
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||||
bootDone: window.__bootDone ? Math.round(window.__bootDone) : null,
|
bootDone: window.__bootDone ? Math.round(window.__bootDone) : null,
|
||||||
longTaskTotal: Math.round(window.__longTasksTotal || 0),
|
longTaskTotal: Math.round(window.__longTasksTotal || 0),
|
||||||
|
longTasks: window.__longTasks || [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -396,6 +401,7 @@ async function runColdScenario({ browserType, server, proxy, profile }) {
|
|||||||
domContentLoaded: pageMetrics.domContentLoaded,
|
domContentLoaded: pageMetrics.domContentLoaded,
|
||||||
bootDone: pageMetrics.bootDone,
|
bootDone: pageMetrics.bootDone,
|
||||||
longTaskTotal: pageMetrics.longTaskTotal,
|
longTaskTotal: pageMetrics.longTaskTotal,
|
||||||
|
longTasks: pageMetrics.longTasks,
|
||||||
requests: stats.requests,
|
requests: stats.requests,
|
||||||
initialJsCss: pageMetrics.initialJsCss,
|
initialJsCss: pageMetrics.initialJsCss,
|
||||||
initialAssetRequests: pageMetrics.initialAssetRequests,
|
initialAssetRequests: pageMetrics.initialAssetRequests,
|
||||||
@@ -478,6 +484,7 @@ async function runWarmScenario({ browserType, server, proxy, profile }) {
|
|||||||
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||||
bootDone: window.__bootDone ? Math.round(window.__bootDone) : null,
|
bootDone: window.__bootDone ? Math.round(window.__bootDone) : null,
|
||||||
longTaskTotal: Math.round(window.__longTasksTotal || 0),
|
longTaskTotal: Math.round(window.__longTasksTotal || 0),
|
||||||
|
longTasks: window.__longTasks || [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -492,6 +499,7 @@ async function runWarmScenario({ browserType, server, proxy, profile }) {
|
|||||||
domContentLoaded: pageMetrics.domContentLoaded,
|
domContentLoaded: pageMetrics.domContentLoaded,
|
||||||
bootDone: pageMetrics.bootDone,
|
bootDone: pageMetrics.bootDone,
|
||||||
longTaskTotal: pageMetrics.longTaskTotal,
|
longTaskTotal: pageMetrics.longTaskTotal,
|
||||||
|
longTasks: pageMetrics.longTasks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,7 +642,7 @@ async function runUpdateScenario({ browserType, server, proxy, profile, updateKi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Switch server to Tree N+1 (app.js or theme-glass.css modification)
|
// Switch server to Tree N+1 (app.js or theme-glass.css modification)
|
||||||
const targetTree = updateKind === 'js' ? trees.treeUpdateJs : trees.treeUpdateCss;
|
const targetTree = updateKind === 'feature' ? trees.treeUpdateFeature : updateKind === 'js' ? trees.treeUpdateJs : trees.treeUpdateCss;
|
||||||
await server.restartWithPublic(targetTree);
|
await server.restartWithPublic(targetTree);
|
||||||
|
|
||||||
// Measure wire bytes and duration from reg.update() until new SW is waiting/installed
|
// Measure wire bytes and duration from reg.update() until new SW is waiting/installed
|
||||||
@@ -654,10 +662,32 @@ async function runUpdateScenario({ browserType, server, proxy, profile, updateKi
|
|||||||
await sleep(50);
|
await sleep(50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (updateKind === 'feature') {
|
||||||
|
// Readiness excludes background chunks. Include activation and the changed
|
||||||
|
// feature's actual idle transfer rather than reporting readiness as its cost.
|
||||||
|
const expected = createHash('sha256').update(readFileSync(path.join(targetTree,'presenter.js'))).digest('hex').slice(0,10);
|
||||||
|
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.waiting.postMessage({type:'SKIP_WAITING'});});
|
||||||
|
const end=Date.now()+90000;
|
||||||
|
while(true) {
|
||||||
|
const complete=await page.evaluate(async h=>{
|
||||||
|
const r=await navigator.serviceWorker.getRegistration();
|
||||||
|
if(r.waiting || r.installing)return false;
|
||||||
|
r.active?.postMessage({type:'WARM_ASSETS',saveData:false});
|
||||||
|
const c=await caches.open('ytplayer-assets'),entry=await c.match('/presenter.js?v='+h);
|
||||||
|
return entry?.headers.get('X-Asset-Hash')===h;
|
||||||
|
},expected);
|
||||||
|
if(complete)break;
|
||||||
|
if(Date.now()>end)throw Error('Changed feature was not cached after activation');
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
const duration = Date.now() - t0;
|
const duration = Date.now() - t0;
|
||||||
proxy.stopTracking();
|
proxy.stopTracking();
|
||||||
const stats = proxy.getStats();
|
const stats = proxy.getStats();
|
||||||
|
if(updateKind==='feature') {
|
||||||
|
const assetDownloads=stats.requests.filter(r=>r.bytes>0).map(r=>new URL(r.url,navUrl).pathname).filter(p=>/\.(?:js|css|woff2?|png|svg)$/.test(p)&&!['/sw.js','/asset-sync-core.js'].includes(p));
|
||||||
|
assert.deepEqual([...new Set(assetDownloads)],['/presenter.js'],'feature edit transfers only its chunk; no app/core/font/icon payload');
|
||||||
|
}
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -696,6 +726,7 @@ async function main() {
|
|||||||
const treeBase = path.join(tmpRoot, 'tree-base');
|
const treeBase = path.join(tmpRoot, 'tree-base');
|
||||||
const treeUpdateJs = path.join(tmpRoot, 'tree-update-js');
|
const treeUpdateJs = path.join(tmpRoot, 'tree-update-js');
|
||||||
const treeUpdateCss = path.join(tmpRoot, 'tree-update-css');
|
const treeUpdateCss = path.join(tmpRoot, 'tree-update-css');
|
||||||
|
const treeUpdateFeature = path.join(tmpRoot, 'tree-update-feature');
|
||||||
|
|
||||||
let frontendSource=path.join(REPO_ROOT,'frontend');
|
let frontendSource=path.join(REPO_ROOT,'frontend');
|
||||||
if(options.frontendCommit) {
|
if(options.frontendCommit) {
|
||||||
@@ -704,14 +735,15 @@ async function main() {
|
|||||||
execFileSync('tar',['-xf',archive,'-C',tmpRoot]);
|
execFileSync('tar',['-xf',archive,'-C',tmpRoot]);
|
||||||
frontendSource=path.join(tmpRoot,'frontend');
|
frontendSource=path.join(tmpRoot,'frontend');
|
||||||
}
|
}
|
||||||
for(const tree of [treeBase,treeUpdateJs,treeUpdateCss])cpSync(frontendSource,tree,{recursive:true});
|
for(const tree of [treeBase,treeUpdateJs,treeUpdateCss,treeUpdateFeature])cpSync(frontendSource,tree,{recursive:true});
|
||||||
|
|
||||||
// Append 1 comment line to app.js in treeUpdateJs
|
// Append 1 comment line to app.js in treeUpdateJs
|
||||||
appendFileSync(path.join(treeUpdateJs, 'app.js'), '\n// baseline bench: 1-line app.js comment\n');
|
appendFileSync(path.join(treeUpdateJs, 'app.js'), '\n// baseline bench: 1-line app.js comment\n');
|
||||||
// Append 1 rule to theme-glass.css in treeUpdateCss
|
// Append 1 rule to theme-glass.css in treeUpdateCss
|
||||||
appendFileSync(path.join(treeUpdateCss, 'theme-glass.css'), '\n/* baseline bench: 1-line css rule */\n.baseline-marker { opacity: 0.99; }\n');
|
appendFileSync(path.join(treeUpdateCss, 'theme-glass.css'), '\n/* baseline bench: 1-line css rule */\n.baseline-marker { opacity: 0.99; }\n');
|
||||||
|
|
||||||
const trees = { treeBase, treeUpdateJs, treeUpdateCss };
|
if(existsSync(path.join(treeUpdateFeature,'presenter.js')))appendFileSync(path.join(treeUpdateFeature,'presenter.js'),'\n// baseline bench: 1-line presenter chunk comment\n');
|
||||||
|
const trees = { treeBase, treeUpdateJs, treeUpdateCss, treeUpdateFeature };
|
||||||
|
|
||||||
const serverScratch = path.join(tmpRoot, 'server-run');
|
const serverScratch = path.join(tmpRoot, 'server-run');
|
||||||
const server = new ServerManager(serverScratch, treeBase);
|
const server = new ServerManager(serverScratch, treeBase);
|
||||||
@@ -721,6 +753,7 @@ async function main() {
|
|||||||
const results = {
|
const results = {
|
||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
frontendCommit: options.frontendCommit,
|
frontendCommit: options.frontendCommit,
|
||||||
|
frontendBuildTag: (await (await fetch('http://127.0.0.1:'+server.port+'/api/version')).json()).buildTag,
|
||||||
commit: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT }).stdout.toString().trim(),
|
commit: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT }).stdout.toString().trim(),
|
||||||
runsConfigured: options.runs,
|
runsConfigured: options.runs,
|
||||||
browsers: {},
|
browsers: {},
|
||||||
@@ -759,6 +792,7 @@ async function main() {
|
|||||||
domContentLoaded: summarizeList(coldRuns.map((r) => r.domContentLoaded)),
|
domContentLoaded: summarizeList(coldRuns.map((r) => r.domContentLoaded)),
|
||||||
bootDone: summarizeList(coldRuns.map((r) => r.bootDone)),
|
bootDone: summarizeList(coldRuns.map((r) => r.bootDone)),
|
||||||
longTaskTotal: summarizeList(coldRuns.map((r) => r.longTaskTotal)),
|
longTaskTotal: summarizeList(coldRuns.map((r) => r.longTaskTotal)),
|
||||||
|
longTasks: coldRuns.map(r=>r.longTasks),
|
||||||
swInstallDuration: summarizeList(coldRuns.map((r) => r.swInstallDuration)),
|
swInstallDuration: summarizeList(coldRuns.map((r) => r.swInstallDuration)),
|
||||||
swInstallBytes: summarizeList(coldRuns.map((r) => r.swInstallBytes)),
|
swInstallBytes: summarizeList(coldRuns.map((r) => r.swInstallBytes)),
|
||||||
storageEstimate: coldRuns[0]?.storageEstimate || null,
|
storageEstimate: coldRuns[0]?.storageEstimate || null,
|
||||||
@@ -869,6 +903,16 @@ async function main() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((options.scenario === 'all' || options.scenario === 'update-feature') && existsSync(path.join(treeBase,'presenter.js'))) {
|
||||||
|
console.log(` [Scenario: Update split-out presenter, including idle cache fill] running ${options.runs} iterations...`);
|
||||||
|
const runs=[];
|
||||||
|
for(let r=0;r<options.runs;r++) {
|
||||||
|
const res=await runUpdateScenario({browserType:bType,server,proxy,profile:prof,updateKind:'feature',trees});runs.push(res);
|
||||||
|
console.log(` run ${r+1}/${options.runs}: ${res.wireBytes} bytes, ${res.requestCount} reqs, dur=${res.duration}ms`);
|
||||||
|
}
|
||||||
|
results.browsers[bName][prof].updateFeature={requestCount:summarizeList(runs.map(r=>r.requestCount)),wireBytes:summarizeList(runs.map(r=>r.wireBytes)),duration:summarizeList(runs.map(r=>r.duration)),wireBytesByType:runs[0]?.wireBytesByType||{},requests:runs[0]?.requests||[]};
|
||||||
|
}
|
||||||
|
|
||||||
await proxy.close();
|
await proxy.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,5 +234,5 @@ async function run(name) {
|
|||||||
try {
|
try {
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const name of browserName === 'all' ? ['chromium', 'webkit'] : [browserName]) results.push(await run(name));
|
for (const name of browserName === 'all' ? ['chromium', 'webkit'] : [browserName]) results.push(await run(name));
|
||||||
writeFileSync(join(repo, `perf/results/phase3-migration-${profile}-2026-10-08.json`), JSON.stringify({ commit, results }, null, 2) + '\n');
|
writeFileSync(args.includes('--out') ? resolve(args[args.indexOf('--out')+1]) : join(repo, `perf/results/phase3-migration-${profile}-2026-10-08.json`), JSON.stringify({ commit, results }, null, 2) + '\n');
|
||||||
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
||||||
|
|||||||
931
perf/results/phase4-cold-isolated-2026-10-08.json
Normal file
931
perf/results/phase4-cold-isolated-2026-10-08.json
Normal file
@@ -0,0 +1,931 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-10-07T20:55:14.741Z",
|
||||||
|
"frontendCommit": null,
|
||||||
|
"frontendBuildTag": "c3dbdd7039db",
|
||||||
|
"commit": "2c5003575c641fddb128c6d1d14bb5d1e3999efb",
|
||||||
|
"runsConfigured": 3,
|
||||||
|
"browsers": {
|
||||||
|
"chromium": {
|
||||||
|
"lte": {
|
||||||
|
"cold": {
|
||||||
|
"requestCount": {
|
||||||
|
"median": 115,
|
||||||
|
"min": 115,
|
||||||
|
"max": 115,
|
||||||
|
"values": [
|
||||||
|
115,
|
||||||
|
115,
|
||||||
|
115
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"wireBytes": {
|
||||||
|
"median": 651012,
|
||||||
|
"min": 651012,
|
||||||
|
"max": 651012,
|
||||||
|
"values": [
|
||||||
|
651012,
|
||||||
|
651012,
|
||||||
|
651012
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fcp": {
|
||||||
|
"median": 1352,
|
||||||
|
"min": 1352,
|
||||||
|
"max": 1384,
|
||||||
|
"values": [
|
||||||
|
1384,
|
||||||
|
1352,
|
||||||
|
1352
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lcp": {
|
||||||
|
"median": 1352,
|
||||||
|
"min": 1352,
|
||||||
|
"max": 1384,
|
||||||
|
"values": [
|
||||||
|
1384,
|
||||||
|
1352,
|
||||||
|
1352
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"domContentLoaded": {
|
||||||
|
"median": 6992,
|
||||||
|
"min": 6973,
|
||||||
|
"max": 7373,
|
||||||
|
"values": [
|
||||||
|
7373,
|
||||||
|
6992,
|
||||||
|
6973
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bootDone": {
|
||||||
|
"median": 7010,
|
||||||
|
"min": 6989,
|
||||||
|
"max": 7389,
|
||||||
|
"values": [
|
||||||
|
7389,
|
||||||
|
7010,
|
||||||
|
6989
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"longTaskTotal": {
|
||||||
|
"median": 275,
|
||||||
|
"min": 274,
|
||||||
|
"max": 297,
|
||||||
|
"values": [
|
||||||
|
297,
|
||||||
|
274,
|
||||||
|
275
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"longTasks": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"start": 1232,
|
||||||
|
"duration": 135
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 7216,
|
||||||
|
"duration": 162
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"start": 1223,
|
||||||
|
"duration": 109
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 6834,
|
||||||
|
"duration": 165
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"start": 1225,
|
||||||
|
"duration": 111
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 6814,
|
||||||
|
"duration": 164
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"swInstallDuration": {
|
||||||
|
"median": 36619,
|
||||||
|
"min": 36567,
|
||||||
|
"max": 36956,
|
||||||
|
"values": [
|
||||||
|
36956,
|
||||||
|
36567,
|
||||||
|
36619
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"swInstallBytes": {
|
||||||
|
"median": 651012,
|
||||||
|
"min": 651012,
|
||||||
|
"max": 651012,
|
||||||
|
"values": [
|
||||||
|
651012,
|
||||||
|
651012,
|
||||||
|
651012
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"storageEstimate": {
|
||||||
|
"usage": 2801669,
|
||||||
|
"quota": 5371510789
|
||||||
|
},
|
||||||
|
"mediaReady": {
|
||||||
|
"median": null,
|
||||||
|
"min": null,
|
||||||
|
"max": null,
|
||||||
|
"values": [
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"autoplayBlocked": true,
|
||||||
|
"wireBytesByType": {
|
||||||
|
"html": 22882,
|
||||||
|
"js": 296653,
|
||||||
|
"css": 65916,
|
||||||
|
"fonts": 254048,
|
||||||
|
"images": 6278,
|
||||||
|
"media": 0,
|
||||||
|
"api": 4604,
|
||||||
|
"other": 631
|
||||||
|
},
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/?v=benchmedia01",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 11441
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fonts/HankenGrotesk-latin.woff2",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 34704
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fonts/BricolageGrotesque-latin.woff2",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 76888
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/lazy.js?v=99caff8ebd",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 2543
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/theme-classic.css?v=d420fcfb12",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 3529
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fonts/fonts.css?v=dda3dfb804",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 391
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/styles.css?v=9f3a5dea41",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 27364
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/service-ui.css?v=b9bf96a3a8",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 3736
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/refresh.css?v=62498384f1",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 5027
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/layout-base.css?v=4f3307cd3b",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 278
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/recommendations.css?v=055101368d",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 790
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/settings-sections.css?v=055507ab85",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 873
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/offline-pages.css?v=2889456261",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 859
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/saved-page.css?v=3ff576a784",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 537
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fullscreen-orientation.css?v=91f2d84453",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1415
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/related-videos.css?v=5a616b1be8",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 404
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/download-actions.css?v=12d3bcfe3e",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 430
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fingerprint.js?v=a635143269",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1034
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/opfs.js?v=df7a8d0391",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 3920
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/lyrics-core.js?v=4dcdac2edd",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 2632
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/lyrics-display.js?v=48e8550175",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 571
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/flag-ui.js?v=eeae1c1d1a",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 2036
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/sha256-wasm.js?v=393c50ba56",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1521
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/sha256.js?v=cd9b980e6f",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 2733
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/loudness-wasm.js?v=c6102d7844",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1130
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/loudness.js?v=01b3da88dd",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1818
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/device-db.js?v=880c5cd70e",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1050
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/async-guard.js?v=e4afa1f2be",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 725
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/asset-sync-core.js?v=155be9339b",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1690
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/sw-update.js?v=772c123c20",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 4411
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/related-videos.js?v=3eec0ebd4c",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 545
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/audio-continuity.js?v=63b5464dd5",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 583
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fullscreen-orientation.js?v=c8e44eb66b",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 2494
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/settings-sections.js?v=1101425859",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 2690
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/download-actions.js?v=388c7edecb",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 550
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/download-errors.js?v=bb6a4c887a",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 727
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/downloads.js?v=f2d3e7d212",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1764
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/downloads-page.js?v=c1a3a218c0",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 3103
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/saved-page.js?v=f1518c50fb",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 652
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/server-backfill.js?v=294b103023",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 918
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/visible-timer.js?v=5322e86ef4",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 380
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/prewarm-next.js?v=ed16a51424",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 567
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/eq-parameters.js?v=dc2c6a8e8d",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 722
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/feature-bootstrap.js?v=33779ecef7",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 2056
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/shell-core.js?v=a04305c2ad",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 3882
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/views-core.js?v=49be16274c",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 16310
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/section-rail.js?v=07417c1bcc",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 3552
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/app.js?v=f485286cbc",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 95477
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/api/recommendations?fp=c83c12c79b483c87",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/api/version",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/sw.js",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 7766
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/api/user/sync",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/asset-sync-core.js?v=155be9339b",
|
||||||
|
"status": 304,
|
||||||
|
"bytes": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/api/manifest",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 3210
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/assets.json?v=6f517e2aa4",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1058
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fonts/BricolageGrotesque-latin-ext.woff2",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 30736
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fonts/HankenGrotesk-latin-ext.woff2",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 19588
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fonts/JetBrainsMono-latin-ext.woff2",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 11624
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fonts/JetBrainsMono-latin.woff2",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 31432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fonts/fonts.css",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 391
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/icons/icon-192.png",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1669
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/icons/icon-512.png",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 4609
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/index.html?v=b556b50667",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 11441
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/manifest.webmanifest",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 631
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/opfs-worker.js?v=17cfe8fb26",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 3487
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/api/recommendations?fp=c83c12c79b483c87",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/resume-core.js?v=df42e94600",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 762
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/sw.js",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 7766
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/asset-sync-core.js?v=155be9339b",
|
||||||
|
"status": 304,
|
||||||
|
"bytes": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/bento-hub.js?v=c867882d7e",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1294
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/car-mode.css?v=7d421630b3",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 385
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/car-mode.js?v=ebd6588f35",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1727
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/direct-media.css?v=19b8428bfd",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 291
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/direct-media.js?v=035420e8fb",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 3697
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/direct-protocol.js?v=87a73be90f",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1079
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/eq-core.js?v=cb6d079933",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 875
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/direct-recv-worker.js?v=caa45eb981",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1050
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/direct-stream.js?v=d7f9871605",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1249
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/f7-layout.js?v=fe7e94a4a9",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 4158
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fonts/PlusJakartaSans-latin-ext.woff2?v=38e3b8fd80",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 21728
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fonts/PlusJakartaSans-latin.woff2?v=153fc85b70",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 27348
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/export.js?v=5b7cdcd7a3",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1305
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/glass-controls.js?v=a60ed1fe84",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 2763
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/glass-panel-layout.js?v=d5417786d0",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 965
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/hash-worker.js?v=5cccc78057",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 542
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/lower-third.css?v=031f8e0ead",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 375
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/lower-third.js?v=7705dcbfb4",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 927
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/lyrics-window.js?v=8f333e2442",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 2048
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/p2p-transfer.js?v=e508302618",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 2431
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/p2p-core.js?v=168a879219",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 989
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/p2p-recv-worker.js?v=9d435c6cbb",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1267
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/midi.js?v=dbecfa74e1",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1713
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/p2p-client.js?v=74fbd79661",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 3850
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/piano-engine.mjs?v=2d2f29b38e",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 2580
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/piano-core.js?v=77c8269761",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 756
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/piano.js?v=e82bc30884",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 3453
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/presenter.js?v=e5b34ff758",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 3094
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/party-dj.js?v=1677c38a88",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1184
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/remote-client.js?v=a2b5acb20f",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 7926
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/setlist-import.js?v=13bc5cc4a4",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 2462
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/stats-core.js?v=3de0591d83",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 2336
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/share-external.js?v=9de67de710",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 3653
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/theme-f7.css?v=02ba226f82",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 4727
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/theme-glass-controls.css?v=3354ad9631",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/theme-glass-panel.css?v=25aadb0bb7",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 848
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/stats-view.js?v=beb2343b30",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 2294
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/theme-bento-hub.css?v=f529dc4bb7",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 6718
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/vendor/framework7-swipe.min.js?v=22b954749c",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 38592
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/vocal-reducer.js?v=ddb2fc1bbe",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 1146
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/video-edit.js?v=90c5dd1eb6",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 2168
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/watch-party-client.js?v=0210655848",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 6513
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/theme-glass.css?v=09feb15c53",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 5068
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/api/p2p/config",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/api/p2p/device",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 121
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/api/p2p/holdings",
|
||||||
|
"status": 200,
|
||||||
|
"bytes": 54
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"initialJsCss": {
|
||||||
|
"median": 210419,
|
||||||
|
"min": 210419,
|
||||||
|
"max": 210419,
|
||||||
|
"values": [
|
||||||
|
210419,
|
||||||
|
210419,
|
||||||
|
210419
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"initialAssetRequests": [
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/lazy.js?v=99caff8ebd",
|
||||||
|
"bytes": 2543
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/theme-classic.css?v=d420fcfb12",
|
||||||
|
"bytes": 3529
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fonts/fonts.css?v=dda3dfb804",
|
||||||
|
"bytes": 391
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/styles.css?v=9f3a5dea41",
|
||||||
|
"bytes": 27364
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/service-ui.css?v=b9bf96a3a8",
|
||||||
|
"bytes": 3736
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/refresh.css?v=62498384f1",
|
||||||
|
"bytes": 5027
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/layout-base.css?v=4f3307cd3b",
|
||||||
|
"bytes": 278
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/recommendations.css?v=055101368d",
|
||||||
|
"bytes": 790
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/settings-sections.css?v=055507ab85",
|
||||||
|
"bytes": 873
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/offline-pages.css?v=2889456261",
|
||||||
|
"bytes": 859
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/saved-page.css?v=3ff576a784",
|
||||||
|
"bytes": 537
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fullscreen-orientation.css?v=91f2d84453",
|
||||||
|
"bytes": 1415
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/related-videos.css?v=5a616b1be8",
|
||||||
|
"bytes": 404
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/download-actions.css?v=12d3bcfe3e",
|
||||||
|
"bytes": 430
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fingerprint.js?v=a635143269",
|
||||||
|
"bytes": 1034
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/opfs.js?v=df7a8d0391",
|
||||||
|
"bytes": 3920
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/lyrics-core.js?v=4dcdac2edd",
|
||||||
|
"bytes": 2632
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/lyrics-display.js?v=48e8550175",
|
||||||
|
"bytes": 571
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/flag-ui.js?v=eeae1c1d1a",
|
||||||
|
"bytes": 2036
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/sha256-wasm.js?v=393c50ba56",
|
||||||
|
"bytes": 1521
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/sha256.js?v=cd9b980e6f",
|
||||||
|
"bytes": 2733
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/loudness-wasm.js?v=c6102d7844",
|
||||||
|
"bytes": 1130
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/loudness.js?v=01b3da88dd",
|
||||||
|
"bytes": 1818
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/device-db.js?v=880c5cd70e",
|
||||||
|
"bytes": 1050
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/async-guard.js?v=e4afa1f2be",
|
||||||
|
"bytes": 725
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/asset-sync-core.js?v=155be9339b",
|
||||||
|
"bytes": 1690
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/sw-update.js?v=772c123c20",
|
||||||
|
"bytes": 4411
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/related-videos.js?v=3eec0ebd4c",
|
||||||
|
"bytes": 545
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/audio-continuity.js?v=63b5464dd5",
|
||||||
|
"bytes": 583
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/fullscreen-orientation.js?v=c8e44eb66b",
|
||||||
|
"bytes": 2494
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/settings-sections.js?v=1101425859",
|
||||||
|
"bytes": 2690
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/download-actions.js?v=388c7edecb",
|
||||||
|
"bytes": 550
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/download-errors.js?v=bb6a4c887a",
|
||||||
|
"bytes": 727
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/downloads.js?v=f2d3e7d212",
|
||||||
|
"bytes": 1764
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/downloads-page.js?v=c1a3a218c0",
|
||||||
|
"bytes": 3103
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/saved-page.js?v=f1518c50fb",
|
||||||
|
"bytes": 652
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/server-backfill.js?v=294b103023",
|
||||||
|
"bytes": 918
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/visible-timer.js?v=5322e86ef4",
|
||||||
|
"bytes": 380
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/prewarm-next.js?v=ed16a51424",
|
||||||
|
"bytes": 567
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/eq-parameters.js?v=dc2c6a8e8d",
|
||||||
|
"bytes": 722
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/feature-bootstrap.js?v=33779ecef7",
|
||||||
|
"bytes": 2056
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/shell-core.js?v=a04305c2ad",
|
||||||
|
"bytes": 3882
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/views-core.js?v=49be16274c",
|
||||||
|
"bytes": 16310
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/section-rail.js?v=07417c1bcc",
|
||||||
|
"bytes": 3552
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:6199/app.js?v=f485286cbc",
|
||||||
|
"bytes": 95477
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2619
perf/results/phase4-final-2026-10-08.json
Normal file
2619
perf/results/phase4-final-2026-10-08.json
Normal file
File diff suppressed because it is too large
Load Diff
133
perf/results/phase4-hashing-rollback-2026-10-08.json
Normal file
133
perf/results/phase4-hashing-rollback-2026-10-08.json
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-10-07T21:00:01.096Z",
|
||||||
|
"frontendCommit": null,
|
||||||
|
"frontendBuildTag": "1146a2a9ceca",
|
||||||
|
"commit": "2c5003575c641fddb128c6d1d14bb5d1e3999efb",
|
||||||
|
"runsConfigured": 1,
|
||||||
|
"browsers": {
|
||||||
|
"chromium": {
|
||||||
|
"lte": {
|
||||||
|
"offline": {
|
||||||
|
"requestCount": {
|
||||||
|
"median": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 0,
|
||||||
|
"values": [
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"wireBytes": {
|
||||||
|
"median": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 0,
|
||||||
|
"values": [
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fcp": {
|
||||||
|
"median": 48,
|
||||||
|
"min": 48,
|
||||||
|
"max": 48,
|
||||||
|
"values": [
|
||||||
|
48
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lcp": {
|
||||||
|
"median": 48,
|
||||||
|
"min": 48,
|
||||||
|
"max": 48,
|
||||||
|
"values": [
|
||||||
|
48
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"domContentLoaded": {
|
||||||
|
"median": 152,
|
||||||
|
"min": 152,
|
||||||
|
"max": 152,
|
||||||
|
"values": [
|
||||||
|
152
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bootDone": {
|
||||||
|
"median": 156,
|
||||||
|
"min": 156,
|
||||||
|
"max": 156,
|
||||||
|
"values": [
|
||||||
|
156
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reloadDuration": {
|
||||||
|
"median": 173,
|
||||||
|
"min": 173,
|
||||||
|
"max": 173,
|
||||||
|
"values": [
|
||||||
|
173
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"webkit": {
|
||||||
|
"lte": {
|
||||||
|
"offline": {
|
||||||
|
"requestCount": {
|
||||||
|
"median": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 0,
|
||||||
|
"values": [
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"wireBytes": {
|
||||||
|
"median": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 0,
|
||||||
|
"values": [
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fcp": {
|
||||||
|
"median": 53,
|
||||||
|
"min": 53,
|
||||||
|
"max": 53,
|
||||||
|
"values": [
|
||||||
|
53
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lcp": {
|
||||||
|
"median": 81,
|
||||||
|
"min": 81,
|
||||||
|
"max": 81,
|
||||||
|
"values": [
|
||||||
|
81
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"domContentLoaded": {
|
||||||
|
"median": 80,
|
||||||
|
"min": 80,
|
||||||
|
"max": 80,
|
||||||
|
"values": [
|
||||||
|
80
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bootDone": {
|
||||||
|
"median": 83,
|
||||||
|
"min": 83,
|
||||||
|
"max": 83,
|
||||||
|
"values": [
|
||||||
|
83
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reloadDuration": {
|
||||||
|
"median": 100,
|
||||||
|
"min": 100,
|
||||||
|
"max": 100,
|
||||||
|
"values": [
|
||||||
|
100
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
perf/results/phase4-lazy-2026-10-08.json
Normal file
79
perf/results/phase4-lazy-2026-10-08.json
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"browser": "chromium",
|
||||||
|
"sourceBuildTag": "09d1ee0f9c80",
|
||||||
|
"classicSharedDefaults": true,
|
||||||
|
"cssOrderPreserved": true,
|
||||||
|
"settingsSearch": true,
|
||||||
|
"carControls": 4,
|
||||||
|
"offlineFeature": true,
|
||||||
|
"firstUseNetwork": 0,
|
||||||
|
"layoutsOffline": [
|
||||||
|
"glass-stage",
|
||||||
|
"bento-hub",
|
||||||
|
"f7-swipe",
|
||||||
|
"classic"
|
||||||
|
],
|
||||||
|
"pinned": true,
|
||||||
|
"compatibleUnexecutedNewURL": true,
|
||||||
|
"incompatibleN1ReloadRequired": true,
|
||||||
|
"stagedN1": true,
|
||||||
|
"playbackGuard": true,
|
||||||
|
"prePaintLayout": true,
|
||||||
|
"offlineSettingsPanels": true,
|
||||||
|
"scriptErrors": [],
|
||||||
|
"offlineTransportErrors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browser": "webkit",
|
||||||
|
"sourceBuildTag": "09d1ee0f9c80",
|
||||||
|
"classicSharedDefaults": true,
|
||||||
|
"cssOrderPreserved": true,
|
||||||
|
"settingsSearch": true,
|
||||||
|
"carControls": 4,
|
||||||
|
"offlineFeature": true,
|
||||||
|
"firstUseNetwork": 0,
|
||||||
|
"layoutsOffline": [
|
||||||
|
"glass-stage",
|
||||||
|
"bento-hub",
|
||||||
|
"f7-swipe",
|
||||||
|
"classic"
|
||||||
|
],
|
||||||
|
"pinned": true,
|
||||||
|
"compatibleUnexecutedNewURL": true,
|
||||||
|
"incompatibleN1ReloadRequired": true,
|
||||||
|
"stagedN1": true,
|
||||||
|
"playbackGuard": true,
|
||||||
|
"prePaintLayout": true,
|
||||||
|
"offlineSettingsPanels": true,
|
||||||
|
"scriptErrors": [],
|
||||||
|
"offlineTransportErrors": [
|
||||||
|
{
|
||||||
|
"message": "TypeError: Load failed",
|
||||||
|
"disconnected": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "/127.0.0.1:38295/api/version.",
|
||||||
|
"disconnected": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "TypeError: Load failed",
|
||||||
|
"disconnected": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "/127.0.0.1:38295/api/recommendations?fp=163577c02a53bc40.",
|
||||||
|
"disconnected": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "TypeError: Load failed",
|
||||||
|
"disconnected": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "/127.0.0.1:38295/api/version.",
|
||||||
|
"disconnected": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
41
perf/results/phase4-migration-lossy-2026-10-08.json
Normal file
41
perf/results/phase4-migration-lossy-2026-10-08.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"commit": "b77938a",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"browser": "chromium",
|
||||||
|
"oldTag": "1175f1a1d2c1",
|
||||||
|
"newTag": "c3dbdd7039db",
|
||||||
|
"banners": 1,
|
||||||
|
"updateReloads": 1,
|
||||||
|
"cachedFiles": 122,
|
||||||
|
"durationMs": 25699,
|
||||||
|
"wireBytes": 628797,
|
||||||
|
"offline": true,
|
||||||
|
"legacyURLs": 21,
|
||||||
|
"profile": "lossy",
|
||||||
|
"userInitiatedOnly": true,
|
||||||
|
"playbackGuard": true,
|
||||||
|
"evictionRepair": true,
|
||||||
|
"incrementalRefresh": true,
|
||||||
|
"offlineTransportErrors": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browser": "webkit",
|
||||||
|
"oldTag": "1175f1a1d2c1",
|
||||||
|
"newTag": "c3dbdd7039db",
|
||||||
|
"banners": 1,
|
||||||
|
"updateReloads": 1,
|
||||||
|
"cachedFiles": 122,
|
||||||
|
"durationMs": 23376,
|
||||||
|
"wireBytes": 737972,
|
||||||
|
"offline": true,
|
||||||
|
"legacyURLs": 21,
|
||||||
|
"profile": "lossy",
|
||||||
|
"userInitiatedOnly": true,
|
||||||
|
"playbackGuard": true,
|
||||||
|
"evictionRepair": true,
|
||||||
|
"incrementalRefresh": true,
|
||||||
|
"offlineTransportErrors": 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
41
perf/results/phase4-migration-unthrottled-2026-10-08.json
Normal file
41
perf/results/phase4-migration-unthrottled-2026-10-08.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"commit": "b77938a",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"browser": "chromium",
|
||||||
|
"oldTag": "1175f1a1d2c1",
|
||||||
|
"newTag": "c3dbdd7039db",
|
||||||
|
"banners": 1,
|
||||||
|
"updateReloads": 1,
|
||||||
|
"cachedFiles": 122,
|
||||||
|
"durationMs": 9805,
|
||||||
|
"wireBytes": 646234,
|
||||||
|
"offline": true,
|
||||||
|
"legacyURLs": 21,
|
||||||
|
"profile": "unthrottled",
|
||||||
|
"userInitiatedOnly": true,
|
||||||
|
"playbackGuard": true,
|
||||||
|
"evictionRepair": true,
|
||||||
|
"incrementalRefresh": true,
|
||||||
|
"offlineTransportErrors": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browser": "webkit",
|
||||||
|
"oldTag": "1175f1a1d2c1",
|
||||||
|
"newTag": "c3dbdd7039db",
|
||||||
|
"banners": 1,
|
||||||
|
"updateReloads": 1,
|
||||||
|
"cachedFiles": 122,
|
||||||
|
"durationMs": 9554,
|
||||||
|
"wireBytes": 751943,
|
||||||
|
"offline": true,
|
||||||
|
"legacyURLs": 21,
|
||||||
|
"profile": "unthrottled",
|
||||||
|
"userInitiatedOnly": true,
|
||||||
|
"playbackGuard": true,
|
||||||
|
"evictionRepair": true,
|
||||||
|
"incrementalRefresh": true,
|
||||||
|
"offlineTransportErrors": 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
28
perf/results/phase4-seams-2026-10-08.json
Normal file
28
perf/results/phase4-seams-2026-10-08.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"browser": "chromium",
|
||||||
|
"checked": [
|
||||||
|
"shell",
|
||||||
|
"presenter",
|
||||||
|
"remote",
|
||||||
|
"party",
|
||||||
|
"share",
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"offlineNetworkRequests": 0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browser": "webkit",
|
||||||
|
"checked": [
|
||||||
|
"shell",
|
||||||
|
"presenter",
|
||||||
|
"remote",
|
||||||
|
"party",
|
||||||
|
"share",
|
||||||
|
"stats"
|
||||||
|
],
|
||||||
|
"offlineNetworkRequests": 0,
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
]
|
||||||
133
perf/results/phase4-sync-rollback-2026-10-08.json
Normal file
133
perf/results/phase4-sync-rollback-2026-10-08.json
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-10-07T20:58:10.947Z",
|
||||||
|
"frontendCommit": null,
|
||||||
|
"frontendBuildTag": "9b741452f44e",
|
||||||
|
"commit": "2c5003575c641fddb128c6d1d14bb5d1e3999efb",
|
||||||
|
"runsConfigured": 1,
|
||||||
|
"browsers": {
|
||||||
|
"chromium": {
|
||||||
|
"lte": {
|
||||||
|
"offline": {
|
||||||
|
"requestCount": {
|
||||||
|
"median": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 0,
|
||||||
|
"values": [
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"wireBytes": {
|
||||||
|
"median": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 0,
|
||||||
|
"values": [
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fcp": {
|
||||||
|
"median": 40,
|
||||||
|
"min": 40,
|
||||||
|
"max": 40,
|
||||||
|
"values": [
|
||||||
|
40
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lcp": {
|
||||||
|
"median": 40,
|
||||||
|
"min": 40,
|
||||||
|
"max": 40,
|
||||||
|
"values": [
|
||||||
|
40
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"domContentLoaded": {
|
||||||
|
"median": 136,
|
||||||
|
"min": 136,
|
||||||
|
"max": 136,
|
||||||
|
"values": [
|
||||||
|
136
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bootDone": {
|
||||||
|
"median": 137,
|
||||||
|
"min": 137,
|
||||||
|
"max": 137,
|
||||||
|
"values": [
|
||||||
|
137
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reloadDuration": {
|
||||||
|
"median": 154,
|
||||||
|
"min": 154,
|
||||||
|
"max": 154,
|
||||||
|
"values": [
|
||||||
|
154
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"webkit": {
|
||||||
|
"lte": {
|
||||||
|
"offline": {
|
||||||
|
"requestCount": {
|
||||||
|
"median": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 0,
|
||||||
|
"values": [
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"wireBytes": {
|
||||||
|
"median": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 0,
|
||||||
|
"values": [
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fcp": {
|
||||||
|
"median": 60,
|
||||||
|
"min": 60,
|
||||||
|
"max": 60,
|
||||||
|
"values": [
|
||||||
|
60
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lcp": {
|
||||||
|
"median": 96,
|
||||||
|
"min": 96,
|
||||||
|
"max": 96,
|
||||||
|
"values": [
|
||||||
|
96
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"domContentLoaded": {
|
||||||
|
"median": 94,
|
||||||
|
"min": 94,
|
||||||
|
"max": 94,
|
||||||
|
"values": [
|
||||||
|
94
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bootDone": {
|
||||||
|
"median": 98,
|
||||||
|
"min": 98,
|
||||||
|
"max": 98,
|
||||||
|
"values": [
|
||||||
|
98
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reloadDuration": {
|
||||||
|
"median": 115,
|
||||||
|
"min": 115,
|
||||||
|
"max": 115,
|
||||||
|
"values": [
|
||||||
|
115
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
perf/seams.mjs
Normal file
85
perf/seams.mjs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Phase 3 checks against the real Bun server, a same-origin restart and real workers.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdtempSync, cpSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { resolve, join } from 'node:path';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { chromium, webkit } from 'playwright';
|
||||||
|
import { createThrottleProxy } from './proxy.mjs';
|
||||||
|
const repo=resolve(new URL('..',import.meta.url).pathname), scratch=mkdtempSync(join(repo,'perf/.tmp/seams-'));
|
||||||
|
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
||||||
|
async function wait(page,fn,arg,timeout=90000){const end=Date.now()+timeout;while(Date.now()<end){if(await page.evaluate(fn,arg))return;await sleep(100);}throw Error('Timed out: '+fn.toString());}
|
||||||
|
const args=process.argv.slice(2), requested=args.includes('--browser')?args[args.indexOf('--browser')+1]:'all';
|
||||||
|
const results=[];
|
||||||
|
async function run(name){
|
||||||
|
const cwd=join(scratch,name);mkdirSync(cwd);cpSync(join(repo,'frontend'),join(cwd,'public'),{recursive:true});
|
||||||
|
const portServer=createServer();await new Promise(r=>portServer.listen(0,'127.0.0.1',r));const port=portServer.address().port;await new Promise(r=>portServer.close(r));
|
||||||
|
let child,browser,proxy,log='';
|
||||||
|
async function stop(){if(!child)return;const p=child;child=null;if(p.exitCode===null){p.kill('SIGTERM');await new Promise(r=>p.once('exit',r));}}
|
||||||
|
async function start(){log='';child=spawn('bun',[join(repo,'server/server.js')],{cwd,env:{...process.env,PORT:String(port),ASSET_HASHING:'1',ASSET_SYNC:'1',BUILD_TAG:'',DB_PATH:join(cwd,'db.sqlite'),DATA_DIR:join(cwd,'data'),MEDIA_DIR:join(cwd,'media'),UPLOAD_DIR:join(cwd,'uploads')},stdio:['ignore','pipe','pipe']});for(const stream of [child.stdout,child.stderr])stream.on('data',c=>log=(log+c).slice(-15000));for(let i=0;i<200;i++){if(child.exitCode!==null)throw Error(log);try{if((await fetch('http://127.0.0.1:'+port+'/api/version')).ok)return;}catch{}await sleep(100);}throw Error(log);}
|
||||||
|
try {
|
||||||
|
await start();proxy=createThrottleProxy({targetPort:port,profile:'unthrottled'});const origin='http://127.0.0.1:'+await proxy.listen();proxy.startTracking();
|
||||||
|
browser=await ({chromium,webkit}[name]).launch({headless:true});const context=await browser.newContext();
|
||||||
|
await context.route('**/*',r=>r.request().url().startsWith(origin)?r.continue():r.abort());
|
||||||
|
await context.addInitScript(()=>{Object.defineProperty(navigator,'onLine',{configurable:true,get:()=>!window.__offline});});
|
||||||
|
const page=await context.newPage(),errors=[],failedRequests=[];page.on('pageerror',e=>errors.push(e.message));page.on('requestfailed',r=>failedRequests.push(r.url()));
|
||||||
|
await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn'));
|
||||||
|
await wait(page,async()=>!!(await navigator.serviceWorker.getRegistration())?.active);
|
||||||
|
await page.evaluate(()=>Lazy.warm());
|
||||||
|
await wait(page,async()=>{const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');if(!r)return false;const s=await r.json();return !(await Promise.all(Object.values(s.current.groups).flatMap(g=>g.files).map(async p=>!!await c.match(p+'?v='+s.current.files[p].h)))).includes(false);});
|
||||||
|
await page.reload();await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn'));
|
||||||
|
await page.evaluate(()=>{window.__offline=true;window.__shellBuild=document.querySelector('meta[name="ytp-build"]').content;});
|
||||||
|
await stop();await proxy.setOffline(true);const before=proxy.getStats().requestCount;
|
||||||
|
await page.evaluate(()=>{data.playlists=[{id:'smoke',name:'Cached playlist',videos:[]}];render();});
|
||||||
|
assert.equal(await page.locator('#homePlGrid .home-pl-name').textContent(),'Cached playlist');
|
||||||
|
await page.click('[data-view="settings"]');await page.waitForSelector('.settings');
|
||||||
|
await page.evaluate(()=>{view={type:'search'};render();});
|
||||||
|
const groups=await page.evaluate(()=>Object.keys(Lazy.manifest.groups));
|
||||||
|
const checked=['shell'];
|
||||||
|
if(groups.includes('feature:presenter')) {
|
||||||
|
await page.evaluate(async()=>{history.replaceState(null,'','/?present=');await Presenter.boot();});
|
||||||
|
await page.waitForSelector('#presenterView:not(.hidden)');
|
||||||
|
await page.locator('#pvCode').fill('x');await page.locator('#pvPairBtn').click();assert.match(await page.locator('#pvMsg').textContent(),/6-digit/);
|
||||||
|
await page.evaluate(()=>document.activeElement?.blur());await page.keyboard.press('b');
|
||||||
|
assert.equal(await page.locator('#pvBlank').evaluate(n=>!n.classList.contains('hidden')),true);
|
||||||
|
await page.evaluate(()=>{document.querySelector('#presenterView').classList.add('hidden');document.body.classList.remove('presenter-open');});checked.push('presenter');
|
||||||
|
}
|
||||||
|
if(groups.includes('feature:remote-client')) {
|
||||||
|
await page.evaluate(()=>document.querySelector('#navRemoteBtn').click());
|
||||||
|
await page.waitForSelector('#modal:not(.hidden)');assert.match(await page.locator('#modal').textContent(),/remote|screen|phone/i);
|
||||||
|
await page.getByRole('button',{name:'Use this phone as a remote',exact:true}).click();
|
||||||
|
await page.waitForSelector('#remoteView:not(.hidden)');await page.locator('#rvCode').fill('x');await page.locator('#rvPairBtn').click();
|
||||||
|
assert.match(await page.locator('#rvPairMsg').textContent(),/6-digit/);await page.locator('#rvClose').click();checked.push('remote');
|
||||||
|
}
|
||||||
|
if(groups.includes('feature:watch-party')) {
|
||||||
|
await page.evaluate(()=>document.querySelector('#navPartyBtn').click());await page.waitForSelector('#partyPanel:not(.hidden)');
|
||||||
|
await page.locator('.pt-name').fill('Cached guest');assert.equal(await page.locator('.pt-name').inputValue(),'Cached guest');checked.push('party');
|
||||||
|
}
|
||||||
|
if(groups.includes('feature:share-external')) {
|
||||||
|
await page.evaluate(async()=>{current={meta:{id:'benchmedia01',title:'Cached smoke',channel:'Fixture'}};await Share.open();});
|
||||||
|
await page.waitForSelector('.share-moment');assert.match(await page.locator('.sm-app').textContent(),/v=benchmedia01/);
|
||||||
|
await page.locator('.sm-time').fill('0:30');await page.locator('.sm-time').dispatchEvent('input');
|
||||||
|
assert.match(await page.locator('.sm-app').textContent(),/t=30/);
|
||||||
|
const m3u=await page.evaluate(async()=>{await Lazy.load('feature:share-external');return window.YT.External.m3u([{id:'benchmedia01',title:'Cached smoke'}]);});assert.match(m3u,/#EXTM3U/);assert.match(m3u,/benchmedia01/);
|
||||||
|
await page.evaluate(async()=>{closeModal();current={meta:{id:'upl_0123456789ab',title:'Cached upload'}};await External.open();});
|
||||||
|
await page.waitForSelector('.ext-panel');assert.match(await page.locator('.ext-panel').textContent(),/api\/uploads\/upl_0123456789ab/);
|
||||||
|
await page.evaluate(()=>closeModal());checked.push('share');
|
||||||
|
}
|
||||||
|
if(groups.includes('feature:stats-view')) {
|
||||||
|
await page.evaluate(async()=>{view={type:'stats'};render();});await page.waitForSelector('.stats-empty');
|
||||||
|
await page.evaluate(async()=>{data.stats=StatsCore.record(null,{id:'benchmedia01',title:'Cached stats',channel:'Fixture'},600,true);await renderStats();});
|
||||||
|
await page.waitForSelector('.stats .st-tiles');assert.match(await page.locator('.st-song-text').first().textContent(),/Cached stats/);checked.push('stats');
|
||||||
|
}
|
||||||
|
if(groups.includes('feature:share-external')) {
|
||||||
|
await page.evaluate(()=>{Share.open=()=>Promise.reject(new Error('Unavailable fixture chunk'));document.querySelector('#shareMomentBtn').click();});
|
||||||
|
await page.getByText('Unavailable fixture chunk',{exact:true}).waitFor();
|
||||||
|
}
|
||||||
|
assert.equal(proxy.getStats().requestCount,before,'all warmed seam code loads from SW without network');
|
||||||
|
// Ignore only verified disconnected version/recommendation API transports in WebKit.
|
||||||
|
const scriptErrors=errors.filter(e=>!(name==='webkit'&&(e==='TypeError: Load failed'||/\/api\/(?:recommendations\?[^\s]*|version)\.$/.test(e))));assert.deepEqual(scriptErrors,[]);
|
||||||
|
if(errors.length>scriptErrors.length)assert.ok(failedRequests.some(url=>/\/api\/(?:recommendations|version)/.test(url)),'ignored WebKit transport errors require a recorded disconnected API request');
|
||||||
|
results.push({browser:name,checked,offlineNetworkRequests:proxy.getStats().requestCount-before,errors:scriptErrors});console.log(name,JSON.stringify(results.at(-1)));
|
||||||
|
}finally{await browser?.close();await stop();await proxy?.close();}
|
||||||
|
}
|
||||||
|
try{for(const name of requested==='all'?['chromium','webkit']:[requested])await run(name);writeFileSync(join(repo,'perf/results/phase4-seams-2026-10-08.json'),JSON.stringify(results,null,2)+'\n');}finally{rmSync(scratch,{recursive:true,force:true});}
|
||||||
13
plans/phase4-plan.md
Normal file
13
plans/phase4-plan.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Phase 4 plan
|
||||||
|
|
||||||
|
Start: cd4a25a, codex/phase3; branch codex/phase4. Master §§3.4, 2c.5, 6/6b and accepted answer03 apply.
|
||||||
|
|
||||||
|
First perform scope-resolved declaration/reference analysis and record rankings in phase4-seams.md. Commit this plan before source changes.
|
||||||
|
|
||||||
|
Implement at most six seams, one green commit each: eager shell/shared renderer files first, then Presenter, Remote, Watch party, Share/External, Stats view. Exact declarations/bytes and synchronous hooks are in phase4-seams.md. Add groups (contract 1), preserve old SHELL compatibility, mirror inert native JSON, extend Lazy.proxy to window.YT registration. Keep player, queue, persistence, boot and required eager features in RAM. Maintain native/Web fallbacks and URL-driven/resumed sessions.
|
||||||
|
|
||||||
|
Tests first: protected playback source fingerprints, eager load-order/manifest consistency, registry proxies and browser cache smoke coverage. After every seam run frontend tests, server bun install/test, hidden-timers, and both-engine cached feature smoke. Use isolated Bun/proxy scratch data, not production. Then run existing lazy/offline/staged guard checks and reduced LTE baseline; add a split-feature update scenario that waits for its background download and proves no unrelated assets transfer. Save actual results and compare with Phase 3/after-phase0.
|
||||||
|
|
||||||
|
Targets: app.js ≥30% raw-byte reduction from 587,029; lower Chromium long-task total; a chunk edit downloads only that chunk plus derived index/manifest/worker checks. WebKit has no long-task observer; report that limit and existing paint regression honestly.
|
||||||
|
|
||||||
|
Risks: classic-script TDZ; synchronous playback hooks must forward without promises; duplicate UI boot/event listeners; stale singleton replacement forbidden. No live replacement without disposal/state handoff. Rollback each seam by reverting its commit; ASSET_SYNC=0 and ASSET_HASHING=0 still supported. Finish self-review against master 6/6b, ≤350-word phase4-report, commit, then overwrite DONE04 signal. Real iPhone audio/quota checks remain manual. No deployment.
|
||||||
48
plans/phase4-report.md
Normal file
48
plans/phase4-report.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Phase 4 report
|
||||||
|
|
||||||
|
Shipped on `codex/phase4`: plan cdd26b6; six seams 3c9a24a, 5fb9564,
|
||||||
|
8e7fa19, 94ea211, 0475a18, b335f95; failure handling 2c50035.
|
||||||
|
Scope-resolved dependency inventory and byte/coupling rankings are in
|
||||||
|
phase4-seams.md; commands, output excerpts and results in phase4-verification.md.
|
||||||
|
|
||||||
|
Original source bytes moved:
|
||||||
|
|
||||||
|
| Seam | Bytes |
|
||||||
|
|---|---:|
|
||||||
|
| Eager shell/shared views/Settings/rail | 98,378 |
|
||||||
|
| Presenter | 9,492 |
|
||||||
|
| Remote client | 30,421 |
|
||||||
|
| Watch party | 22,833 |
|
||||||
|
| Share/External | 13,334 |
|
||||||
|
| Stats view | 7,074 |
|
||||||
|
|
||||||
|
`app.js`: **587,029 → 408,473 bytes, −30.42%** (178,556 net; 2,976 bytes
|
||||||
|
facade overhead). Shell definitions load before app; required eager features
|
||||||
|
stay in RAM. Lazy classic clients register on window.YT. Synchronous playback
|
||||||
|
hooks remain synchronous; configured/URL sessions resume. Stats tracking loads
|
||||||
|
without parsing its view. New chunks are manifested and retained in legacy SHELL.
|
||||||
|
|
||||||
|
Player, queue, persistence, boot, Notes/editor, service mode, EQ/level, sleep/drift,
|
||||||
|
Gestures and remaining editor/export glue stay in app.js. Exact source fingerprints
|
||||||
|
protect playback/tracking bodies. Now-playing metadata mutations inside Player
|
||||||
|
remain there; markup, action paint and mini bar are eager. Stateful groups remain
|
||||||
|
pinned; live replacement stays disabled without disposal/state handoff.
|
||||||
|
|
||||||
|
Actual LTE measurements, Chromium/WebKit: initial compressed JS/CSS
|
||||||
|
**210,419/255,200** versus Phase 3 221,307/274,813; matched Phase 0
|
||||||
|
276,414/338,940. Isolated three-run Chromium median boot **7,010 ms**, long tasks
|
||||||
|
**275 ms** versus Phase 0 10,480/454 (Phase 3 7,193/287).
|
||||||
|
WebKit boot 2,977 ms; no long-task API. Presenter edit transfers only its chunk
|
||||||
|
(3,118/3,587 bytes), plus index/manifest/worker checks: total **33,408/41,364**.
|
||||||
|
Core updates: 125,795/147,259 bytes. Offline boot: 180/90 ms, zero network.
|
||||||
|
|
||||||
|
Verification: **198 frontend/183 server tests**, no skips; both-engine cache smoke,
|
||||||
|
hidden timers, layouts/staged contracts/pinning/playback guard, b77938a migration
|
||||||
|
with/without stalls, and both rollback flags pass. Each seam had its own green
|
||||||
|
checks/commit. No deployment.
|
||||||
|
|
||||||
|
Limits: WebKit FCP 2,401 versus Phase 0 1,100 ms still regresses. Concurrent-run
|
||||||
|
Chromium long tasks were 495 ms; isolated repeat is reported separately. Warm
|
||||||
|
counters include idle downloads; five-run comparisons await Phase 5. Owner must
|
||||||
|
check real-iPhone cached first-use/layouts, Remote/Party/Presenter, update guard,
|
||||||
|
30-minute locked audio/resume and OPFS quota coexistence.
|
||||||
447
plans/phase4-seams.md
Normal file
447
plans/phase4-seams.md
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
# Phase 4 dependency analysis and seams
|
||||||
|
|
||||||
|
Source: `cd4a25a`, 587,029 bytes. Acorn 8 parses classic scripts with source ranges; eslint-scope 8 resolves lexical references, including shadowing. Outside references are references outside the declaration’s owning top-level statement, plus unresolved references from other frontend classic scripts. Property names and strings are not lexical references. Browser globals and other scripts’ globals remain available. Positions below refer to this source, before extraction.
|
||||||
|
|
||||||
|
Reproduce: `npm install --prefix perf/.tmp/analysis --no-audit --no-fund acorn@8 eslint-scope@8`, then run `node perf/analyze-app.cjs cd4a25a`. Analysis tools are development-only; no frontend/server import is added.
|
||||||
|
|
||||||
|
## Measured candidate ranking
|
||||||
|
|
||||||
|
Coupling = distinct other app top-level bindings referenced inside the candidate; ratio = source UTF-8 bytes divided by coupling. This is a ranking, not a safety judgment.
|
||||||
|
|
||||||
|
| Candidate | Bytes | Coupling | Bytes/coupling | Decision |
|
||||||
|
|---|---:|---:|---:|---|
|
||||||
|
| notes | 56491 | 18 | 3138 | Keep eager; protected owner requirement |
|
||||||
|
| sectionRail | 10973 | 5 | 2195 | Include in eager shell renderer cut |
|
||||||
|
| presenter | 9492 | 6 | 1582 | Extract |
|
||||||
|
| party | 22833 | 18 | 1269 | Extract |
|
||||||
|
| remote | 30421 | 31 | 981 | Extract |
|
||||||
|
| share | 13334 | 14 | 952 | Extract |
|
||||||
|
| editor | 6603 | 8 | 825 | Leave for later; six seam limit |
|
||||||
|
| stats | 7074 | 9 | 786 | Extract |
|
||||||
|
| gestures | 5217 | 8 | 652 | Keep playback wiring untouched |
|
||||||
|
| settings | 31516 | 49 | 643 | Keep eager; protected owner requirement |
|
||||||
|
| shell | 14608 | 29 | 504 | Extract |
|
||||||
|
| export | 5159 | 11 | 469 | Leave for later; six seam limit |
|
||||||
|
| service | 5006 | 15 | 334 | Keep eager; protected owner requirement |
|
||||||
|
|
||||||
|
## Six cuts and ordering
|
||||||
|
|
||||||
|
1. Eager shell/renderers: `shell-core.js` contains homepage/recommendations, sidebar, bottom/mini chrome and now-playing action paint. `views-core.js` contains existing shared list/card/Settings renderers; `section-rail.js` remains eager. These all load before app.js; callbacks read the shared lexical state only after app initializes it. No new asynchronous renderer interface. Move 98,378 source bytes; keep four required features in RAM.
|
||||||
|
2. Presenter: 9,492 bytes, lazy `feature:presenter`, boot only on presenter/overlay URLs.
|
||||||
|
3. Remote: 30,421 bytes, lazy `feature:remote-client`; preserve synchronous changed hook, bind entry buttons eagerly, auto-load only for a configured host or pair URL.
|
||||||
|
4. Watch party: 22,833 bytes, lazy `feature:watch-party`; synchronous no-session defaults for guestActive/intercept/nextDJ, same instance once loaded; resume stored host or party URL.
|
||||||
|
5. Share/External: 13,334 bytes, lazy `feature:share-external`; open/bootFromUrl are the seam.
|
||||||
|
6. Stats view: 7,074 bytes, separate `feature:stats-view` depending on pure stats group, so playback tracking does not parse the view.
|
||||||
|
|
||||||
|
Total original source moved: 181,532 bytes (30.92%); facade overhead must keep final reduction ≥30%. Shared lexical scope is intentional; the broader eager view cut retains its existing renderer calls and high outbound coupling rather than inventing a context object or delaying Settings. Player, Transition, sleep/drift/resume, queue, persistence and boot stay in app.js unchanged apart from explicit lazy boot calls. The required now-playing metadata mutation inside Player remains untouched; its markup, mini bar and paint helpers load eagerly.
|
||||||
|
|
||||||
|
Executing stateful chunks remain pinned until playback-guarded reload. No live replacement: no disposal/state handoff is implemented, so Phase 4 does not enable it. New unexecuted chunks obey Phase 3 contracts/N-1 guard.
|
||||||
|
|
||||||
|
## Every top-level declaration
|
||||||
|
|
||||||
|
Outside references use app line numbers or `file:line`; imports name other app bindings (not browser globals). Multiple declarations sharing a statement share its byte count; never sum this table blindly.
|
||||||
|
|
||||||
|
| Declaration | Line | Statement bytes | Other app bindings read/written | Outside references |
|
||||||
|
|---|---:|---:|---|---|
|
||||||
|
| sanitizeId | 30 | 124 | — | 602, 603, 607, 607, 610, 611, 616, 617, 1995 |
|
||||||
|
| TAURI | 34 | 87 | — | 37, 54, 54, 649, 649, 649, 2296 |
|
||||||
|
| ZERO | 35 | 90 | — | 37, 55, 55 |
|
||||||
|
| WEB | 37 | 28 | TAURI, ZERO | 572, 575, 579, 582, 585, 590, 593, 597, 601, 606, 609, 612, 615, 618, 628, 698, 867, 873, 954, 981, 1183, 1281, 1296, 1339, 1712, 1743, 1769, 1792, 1811, 1905, 2067, 2287, 2299, 2329, 3029, 3145, 3146, 3198, 3199, 3362, 3475, 4764, 5509, 5698, 5808, 5818, 5826, 5829, 5881, 5890, 5907, 5909, 6100, 6686, 7501, 8919, 9191, 9206, 9240, 9897, 10126, 11983, 12367, 12627, 12678, 12683, 12691, 12698, 12700, 12709, 12711, 12717 |
|
||||||
|
| APP_VERSION | 38 | 28 | — | 94, 9857, 9880, 10132, 12723 |
|
||||||
|
| perfMark | 42 | 87 | — | 2265, 11167, 12564 |
|
||||||
|
| perfMeasure | 43 | 116 | — | 3031, 11217, 12614 |
|
||||||
|
| call | 53 | 258 | TAURI, ZERO | 574, 584, 587, 592, 595, 599, 603, 611, 614, 617, 620 |
|
||||||
|
| webFetch | 62 | 274 | — | 573, 580, 583, 586, 591, 5812, 5896, 12630 |
|
||||||
|
| loadDataFromStorage | 73 | 160 | — | 594 |
|
||||||
|
| saveDataToStorage | 80 | 851 | APP_VERSION, profileHeaders | 598, 9897 |
|
||||||
|
| NO_HEVC_KEY | 105 | 32 | — | 107, 1907, 2499 |
|
||||||
|
| hevcCapable | 106 | 267 | NO_HEVC_KEY | 451, 591, 9192 |
|
||||||
|
| RecentSearches | 122 | 2371 | SearchLibrary | 11168, 11171, 11212, 11251, 11253, 11266, 11281, 11294 |
|
||||||
|
| SearchLibrary | 181 | 4754 | collectSearchMetadata | 162, 165, 169, 5820, 9283, 9290, 9875, 9928, 9930, 9941, 9947, 9951, 11181, 11233, 11234, 11252, 11262, 11262, 11266 |
|
||||||
|
| SaveSlots | 291 | 800 | data | 1849, 1877, 9874, 9874, 9902 |
|
||||||
|
| saveControllers | 315 | 34 | — | 333, 381, 1845, 1875 |
|
||||||
|
| SaveQueue | 316 | 3701 | saveControllers, view, cachedIds, downloading, preload, renderList | 1844, 1846, 1851, 1852, 1857, 1859, 1872, 1876, 1878, 9541, 9541, 9542, 9542, 12688, 12688, 12688, 12689, 12689, 12689, 12698 |
|
||||||
|
| resumableOpfsSave | 401 | 2172 | — | 464 |
|
||||||
|
| opfsDownload | 440 | 2721 | hevcCapable, resumableOpfsSave | 602 |
|
||||||
|
| opfsDownloadEdited | 501 | 1261 | — | 607 |
|
||||||
|
| opfsStatus | 533 | 277 | — | 610 |
|
||||||
|
| opfsList | 543 | 344 | — | 613 |
|
||||||
|
| opfsDelete | 554 | 325 | — | 616 |
|
||||||
|
| opfsClear | 561 | 295 | — | 619 |
|
||||||
|
| _currentBlobUrl | 569 | 27 | — | 2281, 2282, 2283, 2284, 2301, 2336 |
|
||||||
|
| API | 571 | 2945 | sanitizeId, WEB, call, webFetch, loadDataFromStorage, saveDataToStorage, hevcCapable, opfsDownload, opfsDownloadEdited, opfsStatus, opfsList, opfsDelete, opfsClear | 776, 889, 910, 1696, 1852, 1910, 2010, 2050, 2297, 2347, 3117, 5279, 5515, 7861, 8669, 9383, 9427, 9487, 10036, 10056, 10073, 10572, 11194, 11203, 11459, 11917, 12572, 12630 |
|
||||||
|
| warmedAt | 625 | 27 | — | 632, 633 |
|
||||||
|
| knownServerCachedIds | 626 | 39 | — | 642, 2351 |
|
||||||
|
| warmStreams | 627 | 435 | WEB, warmedAt, cachedIds | 640, 10249, 11215 |
|
||||||
|
| warmNextQueue | 638 | 235 | knownServerCachedIds, warmStreams, data, queue, queueIndex, cachedIds | 3034, 8079 |
|
||||||
|
| toAssetUrl | 648 | 138 | TAURI | 2298 |
|
||||||
|
| DEFAULT_SETTINGS | 654 | 1577 | — | 675, 908, 12588 |
|
||||||
|
| data | 675 | 275 | DEFAULT_SETTINGS | 294, 640, 776, 788, 789, 790, 791, 792, 793, 794, 795, 796, 797, 798, 799, 867, 867, 873, 873, 877, 878, 886, 889, 897, 898, 899, 900, 901, 902, 903, 904, 905, 906, 907, 908, 909, 910, 947, 954, 954, 956, 956, 962, 967, 968, 969, 981, 981, 982, 1012, 1022, 1022, 1026, 1029, 1030, 1052, 1101, 1101, 1255, 1262, 1262, 1263, 1282, 1282, 1349, 1349, 1350, 1352, 1352, 1353, 1353, 1354, 1354, 1423, 1440, 1449, 1450, 1456, 1456, 1457, 1458, 1465, 1503, 1503, 1549, 1550, 1551, 1556, 1662, 1671, 1731, 1798, 1886, 1915, 1947, 1953, 1955, 2019, 2020, 2020, 2021, 2038, 2052, 2052, 2053, 2054, 2054, 2328, 2328, 2437, 2437, 2438, 2539, 2541, 2697, 2868, 2954, 2980, 3061, 3072, 3144, 3144, 3198, 3199, 3476, 3476, 3484, 3485, 3485, 3488, 3488, 3631, 3743, 4178, 4178, 4184, 4185, 4245, 4246, 4247, 4261, 4374, 4375, 4442, 4450, 4466, 4594, 4594, 4596, 4597, 4630, 4748, 4852, 4857, 4863, 4863, 4913, 5461, 5464, 5468, 5475, 5573, 5579, 5595, 5628, 5635, 5637, 5691, 5835, 5835, 5895, 5927, 5927, 5974, 6004, 6084, 6363, 6366, 6371, 6575, 6602, 6697, 6698, 6865, 6869, 6869, 6869, 6917, 6917, 6923, 6924, 6933, 6933, 6938, 6956, 6956, 7091, 7122, 7123, 7187, 7523, 7523, 7602, 7972, 8055, 8055, 8056, 8056, 8057, 8057, 8058, 8063, 8063, 8078, 8102, 8102, 8103, 8115, 8120, 8129, 8130, 8137, 8137, 8141, 8150, 8157, 8158, 8174, 8220, 8241, 8252, 8252, 8255, 8258, 8258, 8261, 8280, 8288, 8289, 8290, 8296, 8297, 8298, 8442, 8442, 8481, 8483, 8497, 8498, 8499, 8507, 8517, 8531, 8699, 8733, 8740, 8804, 8805, 8809, 8932, 8970, 9022, 9206, 9240, 9433, 9435, 9436, 9437, 9437, 9492, 9492, 9493, 9494, 9494, 9551, 9555, 9557, 9561, 9584, 9596, 9600, 9604, 9608, 9615, 9622, 9634, 9638, 9654, 9658, 9662, 9669, 9676, 9687, 9694, 9716, 9723, 9785, 9789, 9827, 9827, 9827, 9841, 9841, 9842, 9842, 9871, 9871, 9872, 9873, 9874, 9876, 9877, 9879, 9884, 9887, 9888, 9891, 9891, 9894, 9896, 9897, 9897, 9901, 9902, 9903, 9904, 9905, 9906, 9907, 9909, 9915, 9958, 9963, 9970, 9975, 9979, 9984, 9986, 9989, 9994, 10089, 10091, 10102, 10103, 10117, 10272, 10272, 10277, 10307, 10308, 10314, 10378, 10397, 10400, 10493, 10500, 10500, 10501, 10598, 10642, 10678, 10849, 10849, 10850, 10852, 10852, 10856, 10863, 10880, 10880, 10881, 10881, 10965, 11413, 11413, 11415, 11430, 11524, 11537, 11543, 11543, 11543, 11544, 11545, 11554, 11571, 11668, 11680, 11718, 11762, 11784, 11787, 11826, 11844, 11853, 11864, 11876, 11886, 11886, 11983, 11994, 11995, 11998, 12000, 12003, 12009, 12055, 12071, 12079, 12084, 12085, 12086, 12099, 12100, 12101, 12102, 12103, 12104, 12105, 12105, 12106, 12107, 12108, 12109, 12109, 12431, 12434, 12517, 12574, 12630, 12630, 12630, 12631, 12631, 12631, 12632, 12632, 12648, 12650, 12650, 12650, 12652, 12653, 12654, 12655, 12663, 12663, 12664, 12670, 12671, 12672, 12679, 12679, 12679, 12679, 12684, 12690, 12690, 12720, 12720, 12724, 12725, 12725, 12726, 12727 |
|
||||||
|
| view | 676 | 30 | — | 378, 1730, 1731, 1734, 1734, 1734, 1837, 1884, 1884, 1884, 1885, 1886, 1914, 1915, 2006, 2025, 2025, 2036, 2036, 2036, 2037, 2038, 2059, 2059, 2059, 5967, 6339, 8065, 8133, 8147, 8153, 8167, 8174, 8177, 8177, 8177, 8665, 8670, 8679, 8710, 8710, 8713, 8717, 8721, 8777, 8802, 8803, 8804, 8805, 8806, 8807, 8807, 8808, 8809, 8884, 8887, 8888, 8889, 8890, 8891, 8892, 8893, 8894, 8895, 8896, 8900, 8905, 8919, 8923, 8925, 8927, 8935, 8946, 8947, 8952, 8958, 8969, 8970, 9037, 9046, 9057, 9068, 9075, 9082, 9112, 9245, 9316, 9384, 9530, 9533, 10057, 10129, 10225, 10253, 10253, 10253, 10253, 10261, 10261, 10261, 10261, 10264, 10265, 10265, 10270, 10271, 10271, 10277, 10284, 10284, 10306, 10308, 10312, 10342, 10350, 10350, 10350, 10352, 10366, 10366, 10366, 10541, 10642, 10682, 10833, 10833, 10841, 10841, 10884, 11170, 11232, 11288, 11312, 11319, 11412, 11412, 11414, 11415, 11417, 11654, 11654, 11977, 11977, 11980, 11985, 11987, 12059, 12496, 12541, 12630, f7-layout.js:34 |
|
||||||
|
| searchResults | 677 | 23 | — | 8803, 8888, 11174, 11197, 11208, 11232, 11234 |
|
||||||
|
| searchAttempted | 680 | 28 | — | 8888, 9062, 11175, 11198, 11209 |
|
||||||
|
| searchSeq | 684 | 18 | — | 11166, 11172, 11195, 11205, 11220 |
|
||||||
|
| channelData | 685 | 78 | — | 8666, 8670, 8672, 8673, 8674, 8675, 8678, 8806, 8959, 8963, 8966, 9046 |
|
||||||
|
| queue | 686 | 15 | — | 640, 4879, 4895, 4922, 4927, 4933, 4934, 4943, 4944, 4944, 5467, 5468, 5525, 7451, 7478, 8072, 8080, 8091, 8092, 8093, 8098, 8098, 8107, 8109, 8141, 8142, 8185, 8209, 8218, 8220, 8228, 8314, 8377, 8377, 8403, 8403, 8404, 8802, 10314, 10315, 11956, f7-layout.js:34 |
|
||||||
|
| queueIndex | 687 | 20 | — | 639, 640, 4882, 4885, 4895, 4922, 4944, 5467, 5467, 5467, 7451, 7478, 8073, 8080, 8091, 8093, 8098, 8110, 8142, 8185, 8210, 8218, 8218, 8219, 8221, 8228, 8312, 8313, 8314, 8377, 8377, 8403, 8403, 8404, 10315, 10315, 11956, f7-layout.js:34 |
|
||||||
|
| queueSource | 688 | 21 | — | 5474, 5474, 8074, 8139, 8173, 8173, 8625, 10253, 10312, 10756, 10756, 11862, 11862, 11863, f7-layout.js:34 |
|
||||||
|
| playFullMode | 689 | 25 | — | 2434, 3060, 3069, 5468, 5475, 7971, 8075, 8220, 8241, 8243, 8243, 11956 |
|
||||||
|
| current | 692 | 19 | — | 1842, 1842, 1842, 1882, 1882, 1882, 2057, 2057, 2057, 2073, 2073, 2073, 2307, 2338, 2350, 2351, 2351, 2351, 2353, 2353, 2379, 2380, 2381, 2382, 2390, 2394, 2396, 2407, 2408, 2409, 2409, 2433, 2467, 2479, 2479, 2482, 2483, 2484, 2494, 2494, 2495, 2498, 2501, 2522, 2541, 2541, 2549, 2549, 2556, 2557, 2569, 2575, 2575, 2578, 2584, 2606, 2609, 2619, 2626, 2842, 2842, 2842, 2842, 2868, 2874, 2952, 2965, 3029, 3029, 3055, 3055, 3061, 3069, 3069, 3072, 3121, 3121, 3121, 3123, 3124, 3124, 3124, 3160, 3160, 3160, 3295, 3295, 3296, 3352, 3352, 3353, 3357, 3693, 3693, 3693, 3905, 3905, 3905, 3905, 3914, 3914, 3914, 4181, 4181, 4181, 4181, 4211, 4211, 4211, 4294, 4294, 4294, 4405, 4405, 4405, 4731, 4731, 4849, 4849, 4943, 5473, 5473, 5528, 5532, 5532, 5532, 5609, 5609, 5612, 5691, 5691, 5691, 5739, 5739, 5782, 5791, 5932, 5932, 6309, 6309, 6309, 6444, 6444, 6819, 6867, 6867, 7057, 7073, 7438, 7438, 7599, 7599, 7641, 7641, 7641, 7957, 8011, 8011, 8028, 8028, 8029, 8031, 8032, 8038, 8038, 8106, 8106, 8106, 8140, 8140, 8140, 8165, 8396, 8396, 8549, 8549, 8624, 8624, 8624, 9442, 9497, 9911, 9911, 9911, 9918, 9918, 9918, 10040, 10075, 10075, 10075, 10313, 10313, 10313, 10328, 10328, 10575, 10575, 10575, 10755, 10755, 10755, 10757, 10758, 11002, 11002, 11449, 11449, 11450, 11471, 11478, 11478, 11478, 11480, 11480, 11480, 11483, 11483, 11483, 11486, 11486, 11486, 11487, 11498, 11498, 11498, 11503, 11503, 11503, 11504, 11504, 11508, 11508, 11508, 11509, 11509, 11526, 11556, 11574, 11595, 11595, 11595, 11596, 11597, 11598, 11599, 11601, 11602, 11810, 11817, 11825, 11825, 11826, 11835, 11835, 11844, 11849, 11849, 11853, 11861, 11861, 11866, 11874, 11874, 11876, 11879, 11879, 11880, 11919, 11924, 11924, 12631, 12648, 12654, 12654, 12665 |
|
||||||
|
| IS_IOS | 693 | 131 | — | 2521, 2539 |
|
||||||
|
| _currentEqUrl | 695 | 25 | — | 697, 697, 697, 699, 699, 700 |
|
||||||
|
| useEqRender | 696 | 286 | WEB, _currentEqUrl, EqRender | 2305, 2337 |
|
||||||
|
| dragSource | 702 | 20 | — | 10288, 10303 |
|
||||||
|
| listFilter | 703 | 20 | — | 8909, 8910, 9464, 9529, 10354, 11381, 11390, 11390 |
|
||||||
|
| selectMode | 704 | 23 | — | 8902, 8903, 8915, 9038, 9039, 9040, 10252, 11611, 12024, 12032, 12032, 12035, 12036 |
|
||||||
|
| selectedIds | 705 | 28 | — | 8903, 10196, 11392, 11393, 11393, 11394, 11395, 11400, 11404, 11408, 11413, 11416, 11418, 11427, 12025, 12033, 12042, 12042, 12042, 12043, 12044 |
|
||||||
|
| sleepTimerRemaining | 706 | 28 | — | 4859, 11691, 11694, 11695, 11698, 11707, 11745, 11750, 11751, 11799 |
|
||||||
|
| sleepTimerTick | 707 | 26 | — | 11693, 11705, 11705, 11706 |
|
||||||
|
| abA | 708 | 15 | — | 2447, 2447, 2450, 4682, 5476, 6482, 6482, 7971, 7971, 7973, 7977, 11811, 11814, 11824, 11836, 11837, 11849, 11854, 11898, 11901, 11908, 12654, 12659 |
|
||||||
|
| abB | 709 | 15 | — | 5476, 6483, 6483, 6483, 7971, 7971, 7971, 11818, 11821, 11824, 11836, 11837, 11849, 11855, 11898, 11902, 11909, 12654, 12659 |
|
||||||
|
| relatedVideos | 710 | 23 | — | 11920, 11928, 11942 |
|
||||||
|
| relatedCollapsed | 711 | 29 | — | 7206, 7209, 7210, 11376, 11930 |
|
||||||
|
| saveTimer | 712 | 21 | — | 775, 776 |
|
||||||
|
| cachedIds | 713 | 28 | — | 336, 371, 631, 641, 1699, 1700, 1721, 1722, 1725, 1725, 1728, 1794, 1827, 1863, 1881, 1903, 1911, 1940, 2002, 2012, 2034, 2051, 2287, 2293, 2329, 2332, 3115, 3118, 3182, 3190, 3201, 3366, 3377, 4852, 5508, 6615, 6745, 6795, 6796, 7602, 9181, 9430, 9490, 10037, 10074, 10194, 10562, 10571, 10573, 10816, 11453, 11461, 11491, 12632, 12640 |
|
||||||
|
| downloading | 714 | 30 | — | 336, 372, 1725, 1827, 1834, 1879, 1926, 2002, 2003, 2032, 3176, 3189, 3373, 10195, 10790, 11452, 12688 |
|
||||||
|
| downloadMeta | 715 | 31 | — | 1835, 1880, 2004, 2033 |
|
||||||
|
| removing | 716 | 27 | — | 3369, 11452, 11456 |
|
||||||
|
| playlistOps | 717 | 30 | — | 10606 |
|
||||||
|
| runExclusive | 721 | 230 | — | 10606, 11456 |
|
||||||
|
| $ | 729 | 82 | — | 731, 732, 733, 734, 735, 736, 737, 738, 739, 740, 741, 742, 743, 744, 745, 746, 747, 748, 749, 750, 751, 752, 753, 754, 755, 756, 757, 758, 759, 760, 761, 762, 763, 764, 765, 766, 767, 768, 769, 770, 836, 838, 838, 839, 999, 1079, 1087, 1088, 1089, 1146, 1148, 1155, 1301, 1398, 1406, 1483, 1483, 1498, 1498, 1524, 1573, 1589, 1615, 1633, 1732, 1887, 1916, 1927, 1930, 2039, 3092, 3096, 3097, 3142, 3254, 3270, 3328, 3366, 3462, 3517, 3537, 3538, 3539, 3540, 3585, 3586, 3591, 3592, 3593, 3629, 3630, 3632, 3659, 3660, 3663, 3667, 3680, 3684, 3691, 3717, 3724, 3726, 3736, 3934, 3944, 3950, 3953, 3978, 4254, 4263, 4269, 4270, 4272, 4280, 4281, 4283, 4353, 4378, 4386, 4395, 4419, 4447, 4448, 4449, 4457, 4465, 4471, 4478, 4524, 4525, 4531, 4532, 4564, 4565, 4566, 4567, 4576, 4577, 4578, 4584, 4585, 4586, 4587, 4588, 4589, 4590, 4591, 4604, 4610, 4612, 4612, 4615, 4627, 4628, 4629, 4634, 4657, 4661, 4662, 4663, 4664, 4665, 4694, 4695, 4707, 4709, 4710, 4713, 4720, 4726, 4727, 4727, 4746, 4746, 4749, 4749, 4992, 4997, 5006, 5006, 5098, 5099, 5103, 5104, 5105, 5107, 5108, 5108, 5116, 5117, 5118, 5140, 5174, 5175, 5176, 5177, 5181, 5184, 5185, 5186, 5188, 5189, 5192, 5193, 5194, 5202, 5203, 5209, 5210, 5229, 5303, 5304, 5305, 5311, 5313, 5316, 5326, 5328, 5341, 5357, 5361, 5362, 5363, 5370, 5370, 5371, 5371, 5372, 5376, 5377, 5378, 5382, 5391, 5393, 5397, 5398, 5405, 5406, 5413, 5626, 5670, 5732, 5776, 5789, 5882, 6071, 6099, 6106, 6109, 6126, 6127, 6134, 6140, 6206, 6291, 6292, 6292, 6293, 6293, 6297, 6411, 6812, 6930, 7035, 7583, 7802, 7817, 7820, 7820, 7823, 7824, 7839, 7843, 7865, 7874, 7875, 7882, 7938, 7979, 8029, 8030, 8033, 8041, 8043, 8047, 8121, 8124, 8186, 8187, 8188, 8189, 8234, 8248, 8299, 8325, 8367, 8375, 8386, 8387, 8390, 8397, 8398, 8409, 8427, 8458, 8487, 8522, 8524, 8526, 8526, 8542, 8547, 8550, 8578, 8636, 8650, 8870, 8871, 8886, 8886, 8887, 8901, 8902, 8903, 8905, 8975, 9015, 9029, 9031, 9041, 9043, 9239, 9377, 9387, 9403, 9454, 9522, 9523, 9874, 9894, 9895, 9901, 9902, 9903, 9904, 9905, 9906, 9907, 9908, 9914, 9914, 9925, 9930, 9934, 9935, 9936, 9937, 9940, 9945, 9949, 9957, 9962, 9969, 9974, 9978, 9983, 9988, 9993, 9999, 10000, 10009, 10010, 10031, 10049, 10060, 10061, 10062, 10083, 10088, 10096, 10097, 10098, 10098, 10099, 10102, 10103, 10104, 10105, 10105, 10106, 10109, 10110, 10111, 10112, 10113, 10114, 10115, 10116, 10131, 10133, 10154, 10155, 10163, 10355, 10427, 10436, 10439, 10528, 10704, 10819, 10832, 10840, 10861, 10870, 10895, 10900, 10900, 10901, 10902, 10910, 10911, 10914, 10923, 10925, 10932, 10933, 10970, 10972, 10972, 10972, 11000, 11007, 11117, 11124, 11128, 11129, 11144, 11145, 11148, 11150, 11189, 11231, 11239, 11240, 11357, 11358, 11358, 11359, 11360, 11361, 11362, 11363, 11364, 11367, 11368, 11369, 11370, 11373, 11380, 11386, 11388, 11394, 11396, 11398, 11407, 11425, 11474, 11474, 11475, 11475, 11476, 11476, 11477, 11477, 11478, 11478, 11507, 11518, 11518, 11521, 11521, 11610, 11610, 11616, 11619, 11620, 11621, 11622, 11623, 11624, 11625, 11628, 11629, 11630, 11631, 11645, 11650, 11738, 11739, 11746, 11748, 11752, 11754, 11893, 11899, 11899, 11899, 11900, 11907, 11907, 11927, 11927, 11972, 12026, 12028, 12029, 12034, 12036, 12037, 12038, 12043, 12231, 12629, 12645, 12648, 12648, 12648, 12648, 12714, 12714, 12715, 12715 |
|
||||||
|
| els | 730 | 1141 | $ | 1549, 1550, 1551, 1607, 1609, 2245, 2261, 2279, 2363, 2369, 2372, 2387, 2388, 2389, 2390, 2391, 2395, 2397, 2398, 2398, 2398, 2399, 2399, 2522, 2525, 2526, 2527, 2528, 2532, 2532, 2558, 2577, 2578, 2586, 2608, 2609, 2618, 2627, 2701, 2704, 2764, 2772, 2782, 2831, 2836, 2844, 2844, 2878, 2878, 2948, 2966, 2970, 2978, 2980, 2985, 2985, 3091, 3209, 3210, 3221, 3229, 3358, 3358, 3359, 3359, 3361, 3362, 3363, 3364, 3367, 4914, 5537, 5884, 5911, 5969, 5971, 5972, 5973, 6326, 6328, 6329, 6330, 6336, 6341, 6353, 6354, 6361, 6362, 6373, 6374, 6808, 6919, 6920, 6996, 6997, 7014, 7015, 7037, 7123, 7648, 7917, 7917, 7958, 7959, 7960, 8009, 8018, 8018, 8167, 8171, 8172, 8296, 8296, 8297, 8297, 8298, 8298, 8459, 8680, 8681, 8682, 8698, 8714, 8746, 8746, 8747, 8747, 8819, 8820, 8831, 8874, 8875, 8876, 8877, 8878, 8879, 8884, 8888, 8914, 8915, 8917, 8919, 8924, 8926, 8928, 8933, 8936, 8944, 8948, 8956, 8959, 8967, 8971, 9007, 9008, 9009, 9010, 9011, 9012, 9030, 9052, 9053, 9061, 9062, 9099, 9102, 9104, 9277, 9279, 9280, 9281, 9300, 9301, 9301, 9308, 9310, 9311, 9312, 9337, 9373, 9375, 9376, 9378, 9516, 9518, 9519, 9520, 9571, 9573, 9574, 9575, 9959, 9964, 9971, 10352, 11090, 11149, 11223, 11224, 11225, 11243, 11244, 11246, 11252, 11254, 11267, 11269, 11270, 11271, 11272, 11274, 11277, 11302, 11307, 11325, 11351, 11352, 11448, 11479, 11482, 11482, 11485, 11485, 11497, 11501, 11501, 11502, 11514, 11515, 11516, 11517, 11519, 11520, 11521, 11523, 11523, 11529, 11531, 11533, 11536, 11537, 11542, 11545, 11551, 11553, 11554, 11570, 11571, 11590, 11591, 11640, 11653, 12521, 12522, 12556, 12557, 12665, 12665, 12670, 12671, 12672, 12706 |
|
||||||
|
| persist | 774 | 147 | API, data, saveTimer, scheduleProfilePush | 1264, 1424, 1451, 2022, 2055, 3062, 3073, 4186, 4443, 4451, 4467, 4598, 4631, 4917, 5638, 5929, 6939, 7140, 7218, 8064, 8112, 8131, 8145, 8151, 8253, 8259, 8291, 8500, 8532, 8932, 9438, 9495, 9888, 9891, 9894, 9897, 9901, 9902, 9903, 9904, 9905, 9906, 9907, 9910, 9916, 9960, 9967, 9972, 9976, 9980, 9985, 9990, 9995, 10092, 10102, 10103, 10119, 10273, 10278, 10318, 10404, 10502, 10621, 10643, 10680, 10711, 10763, 10853, 10857, 10882, 11420, 11435, 11524, 11540, 11548, 11555, 11572, 11784, 11787, 11829, 11845, 11888, 12057, 12080, 12110, 12483, 12630, 12631, 12632, 12650, 12652, 12653, 12654, 12655 |
|
||||||
|
| profilePayload | 786 | 528 | data | 878, 1415 |
|
||||||
|
| ProfileSecret | 806 | 671 | — | 817, 846, 859, 1030, 1444, 1448, 1448, 1458, 1504, 1507, 1536, 10118, 12679 |
|
||||||
|
| profileHeaders | 816 | 143 | ProfileSecret | 87, 877, 956, 1314, 1328, 5896, 12720 |
|
||||||
|
| askProfileSecret | 822 | 1307 | $, importProfileCredentials, escapeHtml, showModal, closeModal | 855 |
|
||||||
|
| fetchProfile | 845 | 740 | ProfileSecret, askProfileSecret | 1373, 1534, 1577 |
|
||||||
|
| profilePushTimer | 865 | 28 | — | 868, 869 |
|
||||||
|
| scheduleProfilePush | 866 | 179 | WEB, data, profilePushTimer, pushProfile | 777, 972 |
|
||||||
|
| pushProfile | 872 | 947 | WEB, API, data, profilePayload, profileHeaders, toast | 869 |
|
||||||
|
| applyProfileData | 895 | 1432 | API, DEFAULT_SETTINGS, data | 970, 1545 |
|
||||||
|
| _syncBusy | 917 | 18 | — | 919, 919, 921 |
|
||||||
|
| setSyncing | 918 | 192 | _syncBusy | 926, 934 |
|
||||||
|
| syncOnLaunch | 925 | 637 | setSyncing, playlistFingerprint, pullProfileIfNewer, updateProfileStatus, checkPlaylistInbox, render, renderSmartSidebar | 12618 |
|
||||||
|
| playlistFingerprint | 945 | 189 | data | 927, 937 |
|
||||||
|
| pullProfileIfNewer | 953 | 1123 | WEB, data, profileHeaders, scheduleProfilePush, applyProfileData, toast | 929 |
|
||||||
|
| profileShareUrl | 980 | 175 | WEB, data | 986, 1006, 1023, 9829, 9834, 9839, 9840 |
|
||||||
|
| copyProfileLink | 985 | 759 | $, profileShareUrl, escapeHtml, toast, showModal, closeModal | 1008, 10111, 11144 |
|
||||||
|
| shareProfileLink | 1005 | 427 | data, profileShareUrl, copyProfileLink | 10112, 11145 |
|
||||||
|
| updateProfileStatus | 1021 | 1681 | data, ProfileSecret, profileShareUrl | 941, 1425, 1452, 1555, 10120, 10123, 12666 |
|
||||||
|
| showSharedPlaylistModal | 1057 | 3747 | data, $, escapeHtml, toast, showModal, closeModal | 1174 |
|
||||||
|
| sharePlaylist | 1160 | 696 | showSharedPlaylistModal, toast | 9014 |
|
||||||
|
| adoptSharedPlaylistFromUrl | 1182 | 555 | WEB, adoptSharedPlaylistByCode | 12605 |
|
||||||
|
| adoptSharedPlaylistByCode | 1205 | 2628 | data, persist, escapeHtml, toast, uid, renderSidebar, render, slim, showModal, closeModal | 1198, 1324 |
|
||||||
|
| inboxCheckRunning | 1278 | 30 | — | 1281, 1284, 1289 |
|
||||||
|
| inboxLastCheck | 1279 | 23 | — | 1285, 1299 |
|
||||||
|
| checkPlaylistInbox | 1280 | 352 | WEB, data, inboxCheckRunning, inboxLastCheck, drainPlaylistInbox | 932, 1303 |
|
||||||
|
| INBOX_POLL_MS | 1293 | 36 | — | 1307 |
|
||||||
|
| INBOX_MIN_GAP_MS | 1294 | 35 | — | 1299 |
|
||||||
|
| startPlaylistInboxWatch | 1295 | 547 | WEB, $, inboxLastCheck, checkPlaylistInbox, INBOX_POLL_MS, INBOX_MIN_GAP_MS | 12619 |
|
||||||
|
| drainPlaylistInbox | 1310 | 853 | profileHeaders, adoptSharedPlaylistByCode | 1287 |
|
||||||
|
| adoptProfileFromUrl | 1338 | 1712 | WEB, data, fetchProfile, finishProfileLoad, escapeHtml, toast, showModal, closeModal | 12606 |
|
||||||
|
| createProfileFlow | 1385 | 1098 | $, requestCreateProfile, toast, showModal, closeModal | 10109 |
|
||||||
|
| requestCreateProfile | 1410 | 756 | data, persist, profilePayload, updateProfileStatus, toast, closeModal | 1396, 1403 |
|
||||||
|
| randomProfileKey | 1433 | 193 | — | 1476 |
|
||||||
|
| setProfileSecretOnServer | 1439 | 639 | data, persist, ProfileSecret, updateProfileStatus | 1476, 1487, 1494 |
|
||||||
|
| protectProfileFlow | 1455 | 2453 | data, $, ProfileSecret, randomProfileKey, setProfileSecretOnServer, exportProfileCredentials, escapeHtml, toast, showModal, closeModal | 10113 |
|
||||||
|
| exportProfileCredentials | 1502 | 1276 | data, ProfileSecret, toast | 1479, 10114 |
|
||||||
|
| importProfileCredentials | 1523 | 1125 | $, ProfileSecret, fetchProfile, finishProfileLoad, toast | 835, 10115 |
|
||||||
|
| finishProfileLoad | 1544 | 623 | data, els, applyProfileData, updateProfileStatus, toast, applyAppearance, preloadPlaylist, preloadPinnedPlaylists, updateQueueBadge, updateLoopRepeatButtons, renderSidebar, render, renderSmartSidebar | 1379, 1537, 1583 |
|
||||||
|
| loadProfileFlow | 1561 | 1120 | $, fetchProfile, finishProfileLoad, toast, showModal, closeModal | 10110 |
|
||||||
|
| escapeHtml | 1595 | 187 | — | 828, 997, 1061, 1063, 1233, 1241, 1360, 1465, 3589, 3613, 3616, 3664, 3665, 3673, 3674, 3779, 3841, 4018, 4019, 4295, 4406, 4552, 5031, 5038, 5038, 5215, 5216, 5284, 5285, 5285, 6012, 6030, 6031, 6039, 6039, 6041, 6041, 6209, 6209, 6389, 6390, 6390, 6392, 6394, 6394, 6506, 6510, 6533, 6537, 7263, 7453, 7456, 7457, 7458, 7462, 7846, 7847, 7849, 7868, 7869, 7869, 8195, 8768, 9296, 9354, 9469, 9834, 9932, 9933, 10202, 10479, 11257, 11258, 11259, 11259, 11945 |
|
||||||
|
| ICON_PLAY | 1601 | 113 | — | 3091, 3093 |
|
||||||
|
| ICON_PAUSE | 1602 | 130 | — | 3091, 3093 |
|
||||||
|
| lastVolume | 1605 | 19 | — | 1610, 11543, 11544, 11544 |
|
||||||
|
| syncMuteIcon | 1606 | 709 | $, els, lastVolume | 4916, 7125, 9966, 11539, 11547, 12668 |
|
||||||
|
| fmtTime | 1620 | 322 | — | 2441, 4785, 5783, 7627, 7958, 7959, 8041, 8388, 8389, 10203, 11814, 11821, 11901, 11902, 11908, 11909 |
|
||||||
|
| toast | 1629 | 1015 | $ | 882, 963, 990, 1077, 1170, 1176, 1211, 1216, 1229, 1267, 1376, 1381, 1400, 1419, 1426, 1428, 1459, 1478, 1480, 1484, 1485, 1489, 1490, 1494, 1495, 1505, 1520, 1530, 1531, 1532, 1535, 1538, 1558, 1580, 1585, 1671, 1831, 1843, 1855, 1867, 1869, 1873, 2008, 2024, 2027, 2030, 2058, 2066, 2068, 2078, 2361, 2441, 2486, 2502, 2510, 2514, 2630, 3114, 3130, 3172, 3173, 3174, 3185, 3226, 3341, 3511, 3807, 3810, 3819, 3824, 3827, 3911, 3912, 3916, 3938, 4008, 4033, 4063, 4066, 4098, 4134, 4143, 4145, 4149, 4165, 4172, 4192, 4196, 4200, 4204, 4210, 4234, 4235, 4339, 4835, 4836, 4945, 4988, 5049, 5070, 5070, 5295, 5321, 5411, 5645, 5966, 6316, 6439, 6440, 6445, 6818, 6819, 6820, 6831, 6877, 6946, 6975, 7439, 7473, 7473, 7482, 7541, 7583, 7593, 7627, 7670, 7746, 7750, 7756, 7779, 7820, 7820, 7894, 7898, 7898, 7938, 8115, 8129, 8134, 8154, 8243, 8255, 8261, 8293, 8511, 8664, 8781, 8955, 8966, 8989, 8997, 9004, 9129, 9155, 9429, 9441, 9489, 9499, 9891, 9898, 9919, 9947, 9951, 10023, 10039, 10076, 10121, 10278, 10386, 10406, 10408, 10431, 10505, 10510, 10576, 10622, 10624, 10684, 10766, 10828, 10854, 10859, 11216, 11222, 11359, 11404, 11421, 11436, 11463, 11466, 11720, 11728, 11792, 11796, 11801, 11814, 11821, 11832, 11890, 12061, 12081, 12113, 12116, 12240, 12310, 12311, 12331, 12353, 12357, 12363, 12472, 12485, 12487, 12491, 12513, 12630, 12631, 12648, 12663, 12664, 12665, 12713 |
|
||||||
|
| uid | 1657 | 91 | — | 1261, 1995, 10499, 10677, 12054, 12433, 12630 |
|
||||||
|
| applyAppearance | 1661 | 783 | data, toast | 1546, 9894, 9897, 9901, 9903, 9904, 9905, 12662 |
|
||||||
|
| fmtBytes | 1677 | 204 | — | 9162, 9257, 9290, 9357, 9452, 9473, 9930, 10060, 10061, 10070 |
|
||||||
|
| isUsableCacheItem | 1689 | 110 | — | 1697, 1719 |
|
||||||
|
| refreshCachedIds | 1693 | 340 | API, cachedIds, isUsableCacheItem | 12675 |
|
||||||
|
| cacheMutations | 1709 | 23 | — | 1715, 1718, 1864, 2013, 3183 |
|
||||||
|
| cacheResyncRunning | 1710 | 31 | — | 1712, 1713, 1738 |
|
||||||
|
| resyncCachedIds | 1711 | 1269 | WEB, data, view, cachedIds, downloading, $, isUsableCacheItem, cacheMutations, cacheResyncRunning, markCardCacheState, renderSidebar, renderList, updatePlaylistOfflineButton | 1749 |
|
||||||
|
| startCacheResyncWatch | 1742 | 356 | WEB, resyncCachedIds | 12676 |
|
||||||
|
| warmSearchThumbs | 1762 | 268 | warmThumb | 11179, 11214 |
|
||||||
|
| warmThumb | 1768 | 150 | WEB | 1765, 1866, 2016 |
|
||||||
|
| YT_ID_RE | 1774 | 39 | — | 1777 |
|
||||||
|
| UPLOAD_ID_RE | 1775 | 42 | — | 1776 |
|
||||||
|
| isUploadId | 1776 | 63 | UPLOAD_ID_RE | 1777, 1781, 3366, 3602, 3644, 4527, 6453, 6619, 7420, 7422, 9470 |
|
||||||
|
| isMediaId | 1777 | 76 | YT_ID_RE, isUploadId | 3475, 4941, 5698, 6187, 6568 |
|
||||||
|
| thumbUrlFor | 1779 | 324 | isUploadId | 1795, 1866, 2016, 8553, 9469, 9534, 9534 |
|
||||||
|
| THUMB_WARM_MAX | 1790 | 27 | — | 1801 |
|
||||||
|
| warmOfflineThumbs | 1791 | 576 | WEB, data, cachedIds, thumbUrlFor, THUMB_WARM_MAX, videoById | 12702 |
|
||||||
|
| recordDeviceFile | 1810 | 528 | WEB | 1865, 3184 |
|
||||||
|
| preload | 1821 | 3504 | SaveSlots, saveControllers, SaveQueue, API, data, view, current, cachedIds, downloading, downloadMeta, $, toast, cacheMutations, warmThumb, thumbUrlFor, recordDeviceFile, updateDownloadBadge, markCardCacheState, updateNowPlayingActions, EqRender, renderSidebar, renderList, slim, updatePlaylistOfflineButton | 374, 1921, 1948, 1957, 2330, 3128, 9223, 10578, 10619, 10679, 10831, 10858, 11434, 11471, 12056, 12633, 12689 |
|
||||||
|
| resavedAfterFailure | 1900 | 38 | — | 1919, 1920 |
|
||||||
|
| dropBrokenLocalCopy | 1901 | 871 | WEB, NO_HEVC_KEY, API, data, view, cachedIds, $, preload, resavedAfterFailure, markCardCacheState, renderSidebar, updatePlaylistOfflineButton | 2485 |
|
||||||
|
| updateDownloadBadge | 1925 | 331 | downloading, $ | 1838, 1883, 2007, 2035, 3178, 3191, 8724 |
|
||||||
|
| playlistOfflineStats | 1936 | 266 | cachedIds | 8700, 8754, 10789, 10821 |
|
||||||
|
| preloadPlaylist | 1946 | 138 | data, preload | 1556, 9986, 12684 |
|
||||||
|
| preloadPinnedPlaylists | 1952 | 299 | data, preload | 1557, 12685 |
|
||||||
|
| markCardCacheState | 1963 | 669 | — | 1728, 1836, 1881, 1912, 2005, 2034, 2056, 3119, 3177, 3190, 9498, 10574, 11462 |
|
||||||
|
| customVideoId | 1994 | 91 | sanitizeId, uid | 2221 |
|
||||||
|
| downloadEdited | 2000 | 1949 | API, data, view, cachedIds, downloading, downloadMeta, $, persist, toast, cacheMutations, warmThumb, thumbUrlFor, updateDownloadBadge, markCardCacheState, renderSidebar, renderList, videoById, slim, updatePlaylistOfflineButton | 2233 |
|
||||||
|
| deleteCustomVideo | 2049 | 620 | API, data, view, current, cachedIds, persist, toast, markCardCacheState, updateNowPlayingActions, renderList | 10566 |
|
||||||
|
| openVideoEditor | 2065 | 6603 | WEB, current, toast, customVideoId, downloadEdited, Player, showModal, closeModal | 10589, 11483 |
|
||||||
|
| Player | 2243 | 30427 | TAURI, WEB, perfMark, NO_HEVC_KEY, _currentBlobUrl, API, knownServerCachedIds, toAssetUrl, data, playFullMode, current, IS_IOS, useEqRender, abA, cachedIds, els, fmtTime, toast, preload, dropBrokenLocalCopy, showSpinner, chooseQuality, buildQualityMenu, offerPeerDownload, updateNowPlayingActions, Notes, Remote, Transition, Wave, Level, Gestures, Party, setHomeOverPlayer, showMiniBar, addToHistory, playNext, playPrev, updateServiceMode, channelKeyOf, markPlayingCard, scrollPlayerIntoViewPortrait, restoreAbMarkers, rememberPosEnabled, updateRememberPosUI, loadRelated, exitSelectMode | 2073, 2073, 2074, 2150, 2152, 2925, 2926, 2932, 2935, 2937, 2938, 2942, 2943, 2987, 2990, 2994, 2995, 2997, 2998, 3005, 3005, 3010, 3014, 3014, 3015, 3036, 3037, 3037, 3040, 3041, 3043, 3056, 3058, 3059, 3070, 3082, 3082, 3091, 3093, 3095, 3096, 3098, 3122, 3122, 3123, 3125, 3125, 3222, 3490, 3490, 3693, 3693, 3712, 3712, 4099, 4099, 4121, 4121, 4121, 4121, 4134, 4135, 4144, 4460, 4497, 4569, 4569, 4569, 4654, 4659, 4672, 4682, 4689, 4689, 4722, 4722, 4730, 4850, 4900, 4901, 4902, 4907, 4915, 5325, 5325, 5325, 5479, 5479, 5500, 5558, 5558, 5570, 5572, 5573, 5574, 5586, 5587, 5593, 5596, 5606, 5739, 5740, 5782, 5791, 5792, 5941, 5945, 6085, 6116, 6116, 6116, 6309, 6310, 6311, 6311, 6314, 6315, 6316, 6316, 6317, 6318, 6446, 6820, 6836, 6836, 6836, 6839, 6839, 6839, 7058, 7058, 7058, 7073, 7073, 7124, 7569, 7600, 7619, 7620, 7621, 7622, 7646, 7648, 7649, 7650, 7651, 7657, 7918, 7956, 7957, 7973, 7976, 7977, 8039, 8040, 8080, 8228, 8241, 8241, 8246, 8311, 8311, 8314, 8423, 8423, 8424, 8424, 8428, 8429, 8588, 8588, 9965, 11514, 11525, 11525, 11526, 11526, 11526, 11532, 11533, 11538, 11546, 11551, 11558, 11559, 11561, 11563, 11564, 11565, 11567, 11575, 11577, 11578, 11578, 11579, 11587, 11588, 11588, 11589, 11589, 11619, 11628, 11676, 11680, 11709, 11716, 11717, 11811, 11818, 11956, 12648, 12654, 12654, 12654, 12654, 12654, 12656, 12660, 12660, 12660, 12660, 12665, 12665, 12665, 12665, sw-update.js:177, sw-update.js:177 |
|
||||||
|
| showSpinner | 2947 | 75 | els | 2277, 2290, 2360, 2513, 2629, 3025, 3035, 3046 |
|
||||||
|
| chooseQuality | 2951 | 459 | data, current | 2356, 2874, 11560, 11576 |
|
||||||
|
| buildQualityMenu | 2964 | 559 | data, current, els | 2386 |
|
||||||
|
| wirePlayerEvents | 2984 | 4922 | WEB, perfMeasure, warmNextQueue, data, playFullMode, current, els, persist, Player, showSpinner, updatePlayBtn, updateProgress, onTrackEnded, rememberPosEnabled | 12565 |
|
||||||
|
| updatePlayBtn | 3089 | 530 | $, els, ICON_PLAY, ICON_PAUSE, Player, Remote | 2990, 3007, 7073, 7650, 7651, 7978, 8234, 8247, 12648, 12656, 12660, 12665 |
|
||||||
|
| reportingBroken | 3104 | 34 | — | 3107, 3108, 3132, 3363, 3364 |
|
||||||
|
| reportBroken | 3105 | 1271 | API, current, cachedIds, toast, preload, markCardCacheState, Player, reportingBroken, updateNowPlayingActions | 11494 |
|
||||||
|
| availCache | 3140 | 29 | — | 3150, 3153, 3158, 3158, 3158 |
|
||||||
|
| renderAvailability | 3141 | 1214 | WEB, data, current, $, availCache | 3354 |
|
||||||
|
| getFromPeers | 3171 | 1175 | cachedIds, downloading, toast, cacheMutations, recordDeviceFile, updateDownloadBadge, markCardCacheState, renderSidebar | 3217, 9214 |
|
||||||
|
| offerPeerDownload | 3197 | 1554 | WEB, data, cachedIds, els, toast, Player, getFromPeers | 2373 |
|
||||||
|
| openMoreControls | 3237 | 2465 | $, showModal, closeModal | 4718, 11477 |
|
||||||
|
| openAddChoice | 3294 | 998 | current, addToQueue, openCardMenu, showModal, closeModal | 11475 |
|
||||||
|
| openMoreActions | 3318 | 1347 | $, toast, showModal, closeModal | 11474 |
|
||||||
|
| setTile | 3347 | 135 | — | 3370, 3374, 3378, 3383 |
|
||||||
|
| updateNowPlayingActions | 3351 | 1537 | WEB, current, cachedIds, downloading, removing, $, els, isUploadId, reportingBroken, renderAvailability, setTile | 1842, 1882, 2057, 2400, 3109, 3133, 9442, 9497, 10040, 10075, 10575, 11457, 11469 |
|
||||||
|
| SERVICE_LYRIC_MIN_PX | 3390 | 32 | — | 4257 |
|
||||||
|
| fitLyricLines | 3400 | 2596 | — | 4257, 4411, 4414, 6225 |
|
||||||
|
| Notes | 3460 | 56491 | WEB, data, current, abA, $, persist, escapeHtml, toast, isUploadId, isMediaId, Player, SERVICE_LYRIC_MIN_PX, fitLyricLines, Party, isServiceModeOpen, updateServiceViewBtn, showModal, closeModal | 2380, 4722, 4722, 7810, 7810, 7903, 7963, 8502, 8644, 9981, 11586, 11598, 11599, 11601, 11602, 11630, 12648, 12648, 12648, 12658 |
|
||||||
|
| Remote | 4762 | 30421 | WEB, API, data, queue, queueIndex, current, sleepTimerRemaining, cachedIds, $, els, persist, escapeHtml, syncMuteIcon, fmtTime, toast, isMediaId, Player, playVideoAt, playFromList, renderUpNext, playNext, playPrev, isServiceModeOpen, enterServiceMode, exitServiceMode, slim, showModal, closeModal, closeSidebar, startSleepTimer, cancelSleepTimer | 2385, 3090, 8184, 12643 |
|
||||||
|
| TRANSITION_MODES | 5450 | 57 | — | 5636, 5636, 5636 |
|
||||||
|
| Transition | 5451 | 8619 | WEB, API, data, queue, queueIndex, queueSource, playFullMode, current, abA, abB, cachedIds, $, els, persist, toast, Player, TRANSITION_MODES, Party, advanceQueue, sleepAtTrackEnd | 2266, 7964, 8238, 11360, 12646 |
|
||||||
|
| Wave | 5668 | 5245 | WEB, data, current, $, fmtTime, isMediaId, Player | 2381, 7967, 11573 |
|
||||||
|
| CatalogSync | 5801 | 1192 | WEB, webFetch, SearchLibrary | 5829, 12730 |
|
||||||
|
| collectSearchMetadata | 5829 | 81 | WEB, CatalogSync | 251, 11180 |
|
||||||
|
| Recommendations | 5831 | 5450 | WEB, webFetch, data, $, els, profileHeaders, updateBottomChrome, setHomeOverPlayer, playFromList, addToQueue, openCardMenu | 8738, 8920, 12729 |
|
||||||
|
| StatsTrack | 5923 | 1462 | data, current, persist, Player | 5968, 7966 |
|
||||||
|
| renderStats | 5965 | 7074 | data, view, $, els, escapeHtml, toast, Player, StatsTrack, resetListHeaderExtras | 8894 |
|
||||||
|
| Presenter | 6097 | 9492 | WEB, $, escapeHtml, isMediaId, Player, fitLyricLines | 12621 |
|
||||||
|
| playVideoAt | 6308 | 546 | current, toast, Player | 5179, 6409, 6575, 7569, 7643, 12517 |
|
||||||
|
| renderLibrary | 6325 | 1254 | view, els, resetListHeaderExtras, renderCard | 8896 |
|
||||||
|
| renderNotesView | 6360 | 3892 | data, $, els, escapeHtml, playVideoAt | 8895 |
|
||||||
|
| Share | 6434 | 8739 | data, current, abA, abB, escapeHtml, toast, isUploadId, isMediaId, Player, playVideoAt, showModal, closeModal | 11361, 12624 |
|
||||||
|
| Level | 6588 | 3894 | data, cachedIds, isUploadId, EqRender, EQ | 2382, 6695, 6695, 6696, 6877, 9647, 9872, 9911, 9918 |
|
||||||
|
| EqRender | 6684 | 5914 | WEB, data, cachedIds, Level, loadSoundFeatures, EQ | 698, 699, 1862, 1862, 6594, 6594, 6942, 6942, 9912, 12652, 12653, 12700 |
|
||||||
|
| PiP | 6807 | 1774 | current, $, els, toast, Player | 11363, 11600, 12647 |
|
||||||
|
| loadSoundFeatures | 6853 | 107 | — | 6749, 6877, 6946 |
|
||||||
|
| EQ_BANDS | 6854 | 36 | — | 6882, 6921, 6959, 7022 |
|
||||||
|
| EQ_PRESETS | 6855 | 40 | — | 6869, 6934, 6934, 6958, 6973, 6975 |
|
||||||
|
| EQ | 6856 | 7927 | data, current, $, els, persist, toast, Level, EqRender, loadSoundFeatures, EQ_BANDS, EQ_PRESETS, showModal, closeModal | 6609, 6610, 6686, 6694, 6762, 8567, 8567, 9643, 9649, 9911, 9917, 11364, 11603, 12649, 12651, 12652, 12652, 12653 |
|
||||||
|
| Gestures | 7033 | 5217 | data, current, $, els, persist, syncMuteIcon, Player, updatePlayBtn | 2383 |
|
||||||
|
| SectionRail | 7170 | 10973 | data, relatedCollapsed, persist, escapeHtml, restoreNowPlayingList | 10371, 11376, 12623 |
|
||||||
|
| External | 7417 | 4595 | queue, queueIndex, current, escapeHtml, toast, isUploadId, showModal, closeModal | 11362 |
|
||||||
|
| Party | 7499 | 22833 | WEB, API, data, current, cachedIds, $, els, escapeHtml, fmtTime, toast, Player, updatePlayBtn, Notes, playVideoAt, advanceQueue, playNext, playPrev, closeSidebar | 2384, 4392, 4392, 4392, 5478, 8217, 8233, 8240, 8310, 12628 |
|
||||||
|
| updateProgress | 7955 | 1111 | data, playFullMode, current, abA, abB, $, els, fmtTime, Player, updatePlayBtn, Notes, Transition, Wave, StatsTrack, updateMiniBar, advanceQueue, updateServiceProgress, sleepAtTrackEnd, applySleepFade | 3049, 3077 |
|
||||||
|
| updateBottomChrome | 7990 | 525 | — | 5886, 8001, 8002, 8035, 8048, 12686 |
|
||||||
|
| setHomeOverPlayer | 8008 | 702 | current, els, showMiniBar, isServiceModeOpen, exitServiceMode, renderHomePlaylists | 2278, 5871, 8170, 10343, 11312, 11320 |
|
||||||
|
| showMiniBar | 8027 | 376 | current, $, updateBottomChrome, updateMiniBar, channelKeyOf | 2402, 8021, 11005 |
|
||||||
|
| updateMiniBar | 8037 | 303 | current, $, fmtTime, Player | 7961, 8034 |
|
||||||
|
| hideMiniBar | 8046 | 111 | $, updateBottomChrome | 11004, 11632, 11650 |
|
||||||
|
| addToHistory | 8054 | 535 | data, view, persist, renderList | 2379 |
|
||||||
|
| playFromList | 8071 | 403 | warmNextQueue, data, queue, queueIndex, queueSource, playFullMode, Player, unshuffledQueue, applyShuffle, renderUpNext | 4943, 5871, 8158, 8633, 8780, 8952, 8963, 8980, 8988, 9259, 9336, 9365, 9478, 10253 |
|
||||||
|
| unshuffledQueue | 8088 | 27 | — | 8076, 8092, 8092, 8105, 8107, 8108 |
|
||||||
|
| applyShuffle | 8090 | 436 | queue, queueIndex, unshuffledQueue | 8078, 8104 |
|
||||||
|
| toggleShuffle | 8101 | 558 | data, queue, queueIndex, current, persist, toast, unshuffledQueue, applyShuffle, renderUpNext, updateLoopRepeatButtons | 8290, 11517, 11594 |
|
||||||
|
| updateQueueBadge | 8119 | 339 | data, $ | 1548, 8132, 8146, 8152, 8723, 12669 |
|
||||||
|
| addToQueue | 8127 | 338 | data, view, persist, toast, updateQueueBadge, renderList, slim | 3310, 5874, 8955, 8966, 9004, 10548, 11402, 11498, 11595 |
|
||||||
|
| removeFromQueue | 8136 | 479 | data, view, queue, queueIndex, queueSource, current, persist, updateQueueBadge, renderUpNext, renderList | 10270, 11418 |
|
||||||
|
| clearQueue | 8149 | 148 | data, view, persist, toast, updateQueueBadge, renderList | 4692, 8943 |
|
||||||
|
| playQueue | 8156 | 119 | data, playFromList | 8940 |
|
||||||
|
| restoreNowPlayingList | 8164 | 845 | data, view, queueSource, current, els, setHomeOverPlayer, renderUpNext, _lastViewKey, render | 7234, 10338, 11025, 11639, 11650 |
|
||||||
|
| renderUpNext | 8182 | 1289 | queue, queueIndex, $, escapeHtml, Remote, playNext, updateServiceUpNext, openChannel, channelKeyOf | 4927, 4935, 4944, 8081, 8114, 8143, 8179, 8229, 8315, 10316, 11956 |
|
||||||
|
| advanceQueue | 8216 | 598 | data, queue, queueIndex, playFullMode, Player, Party, renderUpNext | 5574, 7569, 7974, 8234, 8242 |
|
||||||
|
| playNext | 8232 | 143 | $, updatePlayBtn, Party, advanceQueue | 2414, 4903, 4922, 7623, 8210, 11515, 11620, 12648, 12657, 12665 |
|
||||||
|
| onTrackEnded | 8237 | 702 | data, playFullMode, $, toast, Player, updatePlayBtn, Transition, Party, advanceQueue, sleepAtTrackEnd, finishSleep | 3078 |
|
||||||
|
| toggleLoopOne | 8251 | 191 | data, persist, toast, updateLoopRepeatButtons | 11519, 11592 |
|
||||||
|
| toggleRepeat | 8257 | 229 | data, persist, toast, updateLoopRepeatButtons | 11520, 11593 |
|
||||||
|
| PLAY_MODES | 8265 | 335 | — | 8286, 8293, 8302, 8305, 8305, 8306 |
|
||||||
|
| MODE_ICONS | 8272 | 456 | — | 8302 |
|
||||||
|
| playModeIndex | 8279 | 176 | data | 8286, 8301 |
|
||||||
|
| cyclePlayMode | 8285 | 416 | data, persist, toast, toggleShuffle, PLAY_MODES, playModeIndex, updateLoopRepeatButtons | 11518 |
|
||||||
|
| updateLoopRepeatButtons | 8295 | 769 | data, $, els, PLAY_MODES, MODE_ICONS, playModeIndex | 1547, 8113, 8254, 8260, 8292, 9906, 9907, 12667 |
|
||||||
|
| playPrev | 8309 | 268 | queue, queueIndex, Player, Party, renderUpNext | 2413, 4904, 7624, 11516, 11621, 12665 |
|
||||||
|
| serviceWakeLock | 8322 | 27 | — | 8332, 8337, 8337, 8338, 8341, 8343, 8344, 8349, 8355, 8357, 8358, 8361, 8369, 8369 |
|
||||||
|
| isServiceModeOpen | 8324 | 116 | $ | 2927, 2939, 2944, 4246, 4858, 8014, 8330, 8395, 8461, 8544, 11606, 11735, 12648, 12648 |
|
||||||
|
| acquireWakeLock | 8329 | 671 | serviceWakeLock, isServiceModeOpen, updateWakeStateUI | 2927, 2939, 2944, 8646 |
|
||||||
|
| releaseWakeLock | 8354 | 229 | serviceWakeLock, updateWakeStateUI | 8657, 11736 |
|
||||||
|
| updateWakeStateUI | 8366 | 268 | $, serviceWakeLock | 8333, 8345, 8351, 8363, 8432 |
|
||||||
|
| updateServiceUpNext | 8374 | 340 | queue, queueIndex, $ | 8183, 8420 |
|
||||||
|
| updateServiceProgress | 8385 | 379 | $, fmtTime | 7962, 8425 |
|
||||||
|
| updateServiceMode | 8394 | 1370 | queue, queueIndex, current, $, Player, isServiceModeOpen, updateWakeStateUI, updateServiceUpNext, updateServiceProgress, ServiceVisual, currentPlaylistEntry | 2403, 8643, 10759 |
|
||||||
|
| serviceVideoOn | 8441 | 96 | data | 8461, 8482 |
|
||||||
|
| applyServiceVideoLayout | 8457 | 351 | $, els, isServiceModeOpen, serviceVideoOn | 8468 |
|
||||||
|
| updateServiceVideoUI | 8467 | 90 | applyServiceVideoLayout, updateServiceViewBtn | 8501, 8641, 8655 |
|
||||||
|
| SERVICE_VIEWS | 8473 | 680 | — | 8489, 8489, 8507, 8511 |
|
||||||
|
| serviceView | 8480 | 213 | data, serviceVideoOn | 8489, 8508, 8544 |
|
||||||
|
| updateServiceViewBtn | 8486 | 484 | $, SERVICE_VIEWS, serviceView | 4262, 8469, 8503 |
|
||||||
|
| setServiceView | 8496 | 278 | data, persist, Notes, updateServiceVideoUI, updateServiceViewBtn, ServiceVisual | 8510 |
|
||||||
|
| cycleServiceView | 8506 | 327 | data, toast, SERVICE_VIEWS, serviceView, setServiceView | 11623 |
|
||||||
|
| SERVICE_FONT_SCALES | 8515 | 85 | — | 8523 |
|
||||||
|
| serviceFontStep | 8516 | 137 | data | 8521, 8531 |
|
||||||
|
| applyServiceFont | 8520 | 402 | $, SERVICE_FONT_SCALES, serviceFontStep | 8533, 8642 |
|
||||||
|
| stepServiceFont | 8530 | 150 | data, persist, serviceFontStep, applyServiceFont | 11624, 11625 |
|
||||||
|
| ServiceVisual | 8537 | 3523 | current, $, thumbUrlFor, Player, EQ, isServiceModeOpen, serviceView | 8421, 8504, 8645, 8656 |
|
||||||
|
| enterServiceMode | 8621 | 1132 | queueSource, current, $, Notes, playFromList, acquireWakeLock, updateServiceMode, updateServiceVideoUI, applyServiceFont, ServiceVisual | 4954, 8998, 11476, 11647 |
|
||||||
|
| exitServiceMode | 8649 | 351 | $, releaseWakeLock, updateServiceVideoUI, ServiceVisual | 4954, 8014, 11606, 11622, 11735 |
|
||||||
|
| openChannel | 8663 | 954 | API, view, channelData, els, toast, renderList, render | 8206, 10220, 11504, 11509, 11952 |
|
||||||
|
| channelKeyOf | 8688 | 227 | — | 2396, 8032, 8203, 8206, 10215, 10220, 11503, 11504, 11508, 11509, 11949, 11952 |
|
||||||
|
| renderSidebar | 8697 | 1422 | data, view, els, updateDownloadBadge, playlistOfflineStats, updateQueueBadge, renderHomePlaylists, render | 1265, 1552, 1729, 1890, 1913, 2042, 3192, 10360, 10405, 10503, 10836, 10844, 10872, 12632, 12663 |
|
||||||
|
| renderHomePlaylists | 8732 | 2774 | data, view, els, escapeHtml, toast, playlistOfflineStats, Recommendations, playFromList, updateHomeGridScrollState, render | 8020, 8725 |
|
||||||
|
| updateHomeGridScrollState | 8792 | 345 | — | 8786, 11150, 11151 |
|
||||||
|
| currentList | 8801 | 495 | data, view, searchResults, channelData, queue, getSmartList | 8908, 11389, 11399, 11426 |
|
||||||
|
| showSearchSkeletons | 8815 | 750 | els, setListPaneHidden | 9047, 11184 |
|
||||||
|
| setListPaneHidden | 8839 | 143 | — | 8818, 8888 |
|
||||||
|
| LIST_ICONS | 8845 | 547 | — | 8860, 8865 |
|
||||||
|
| listIconBtn | 8854 | 392 | LIST_ICONS | 9013, 9014, 9018, 9023, 9028, 9038 |
|
||||||
|
| setListIcon | 8864 | 126 | LIST_ICONS | 10795, 10805 |
|
||||||
|
| resetListHeaderExtras | 8869 | 399 | $, els | 5970, 6327, 8885, 8916, 9278, 9309, 9374, 9517, 9572 |
|
||||||
|
| renderList | 8882 | 10974 | WEB, data, view, searchResults, searchAttempted, channelData, listFilter, selectMode, selectedIds, $, els, persist, sharePlaylist, toast, Recommendations, renderStats, renderLibrary, renderNotesView, playFromList, addToQueue, clearQueue, playQueue, enterServiceMode, currentList, showSearchSkeletons, setListPaneHidden, listIconBtn, resetListHeaderExtras, addBrowseCta, renderP2pStrip, renderSavedSearches, renderP2pList, renderSaved, renderDownloads, renderSettings, renderCard, markPlayingCard, renamePlaylist, updatePlaylistOfflineButton, makePlaylistOffline, togglePinPlaylist, deletePlaylist, SMART_PLAYLISTS, toggleSelectMode | 378, 1735, 1837, 1884, 2006, 2025, 2036, 2059, 8065, 8133, 8147, 8153, 8676, 9535, 10041, 10077, 10274, 10319, 10362, 10765, 11199, 11210, 11382, 11386, 11405, 11611, 12039 |
|
||||||
|
| addBrowseCta | 9108 | 252 | view, render | 9074, 9081, 9088, 9096, 9411 |
|
||||||
|
| exportToDevice | 9128 | 5159 | WEB, hevcCapable, data, cachedIds, toast, fmtBytes, preload, getFromPeers, videoTitleById, showModal, closeModal | 9258, 9364, 9480, 10556, 11478, 12637, 12638 |
|
||||||
|
| p2pStripAt | 9237 | 40 | p2pStripData | 9241, 9243 |
|
||||||
|
| p2pStripData | 9237 | 40 | p2pStripAt | 9241, 9242, 9242, 9246, 9246, 9246 |
|
||||||
|
| renderP2pStrip | 9238 | 1834 | WEB, data, view, $, fmtBytes, playFromList, exportToDevice, p2pStripAt, p2pStripData, videoTitleById | 8976 |
|
||||||
|
| fmtAgo | 9269 | 263 | — | 9297, 9933, 11234 |
|
||||||
|
| renderSavedSearches | 9276 | 1587 | SearchLibrary, els, escapeHtml, fmtBytes, resetListHeaderExtras, fmtAgo | 8892 |
|
||||||
|
| renderP2pList | 9307 | 3593 | view, els, escapeHtml, fmtBytes, playFromList, resetListHeaderExtras, exportToDevice, videoTitleById, markPlayingCard, openCardMenu | 8891 |
|
||||||
|
| renderSaved | 9372 | 6530 | API, data, view, current, listFilter, cachedIds, $, els, persist, escapeHtml, toast, fmtBytes, isUploadId, thumbUrlFor, markCardCacheState, updateNowPlayingActions, playFromList, resetListHeaderExtras, addBrowseCta, exportToDevice, videoById, videoTitleById, markPlayingCard, showModal, closeModal | 8890 |
|
||||||
|
| renderDownloads | 9515 | 1216 | SaveQueue, view, listFilter, $, els, thumbUrlFor, resetListHeaderExtras, renderList, render, showModal, closeModal | 8893 |
|
||||||
|
| videoById | 9550 | 533 | data | 1795, 2016, 9458, 9566, 10018, 11401, 11427 |
|
||||||
|
| videoTitleById | 9565 | 85 | videoById | 9132, 9252, 9324, 9463, 10071 |
|
||||||
|
| renderSettings | 9570 | 31516 | WEB, APP_VERSION, saveDataToStorage, SearchLibrary, SaveSlots, API, data, view, current, cachedIds, $, els, persist, ProfileSecret, profileShareUrl, copyProfileLink, shareProfileLink, updateProfileStatus, createProfileFlow, protectProfileFlow, exportProfileCredentials, importProfileCredentials, loadProfileFlow, escapeHtml, syncMuteIcon, toast, applyAppearance, fmtBytes, preloadPlaylist, Player, updateNowPlayingActions, Notes, Level, EqRender, EQ, updateLoopRepeatButtons, resetListHeaderExtras, renderList, fmtAgo, videoById, videoTitleById, exportPlaylists, importPlaylists, openImportYouTubeModal, showModal, closeModal, doAutoBackup, importBackup, hardReloadUI | 8889 |
|
||||||
|
| renderCard | 10192 | 5357 | warmStreams, data, view, queue, queueIndex, queueSource, current, dragSource, selectMode, selectedIds, cachedIds, downloading, persist, escapeHtml, fmtTime, toast, playFromList, removeFromQueue, renderUpNext, openChannel, channelKeyOf, renderList, render, openCardMenu, openSongNoteModal, toggleSelectCard | 6354, 9104 |
|
||||||
|
| markPlayingCard | 10326 | 170 | current | 2401, 9105, 9369, 9509 |
|
||||||
|
| _lastViewKey | 10348 | 24 | — | 8177, 10351, 10351, 10367, 10367, 10368 |
|
||||||
|
| render | 10349 | 1194 | view, listFilter, $, els, SectionRail, renderSidebar, renderList, _lastViewKey, scrollListIntoViewPortrait, closeSidebar, renderSmartSidebar, exitSelectMode | 939, 1266, 1554, 8178, 8667, 8713, 8777, 9112, 9533, 10278, 10344, 10504, 10627, 10644, 10683, 10712, 10885, 11176, 11183, 11289, 11312, 11321, 11420, 11980, 11987, 12060, 12112, 12497, 12542, 12613, 12630, 12705 |
|
||||||
|
| exportPlaylists | 10377 | 402 | data, toast | 10097 |
|
||||||
|
| importPlaylists | 10388 | 876 | data, persist, toast, renderSidebar | 10099 |
|
||||||
|
| openImportYouTubeModal | 10414 | 4468 | data, $, persist, escapeHtml, toast, uid, renderSidebar, render, slim, showModal, closeModal | 10096 |
|
||||||
|
| openCardMenu | 10540 | 4233 | API, data, view, current, cachedIds, playlistOps, runExclusive, persist, toast, preload, markCardCacheState, deleteCustomVideo, openVideoEditor, updateNowPlayingActions, addToQueue, exportToDevice, render, slim, newPlaylist, showModal, closeModal | 3311, 5875, 9364, 10257, 11480 |
|
||||||
|
| slim | 10651 | 487 | — | 1226, 1835, 2004, 4942, 8130, 10471, 10616, 10677, 11434, 12054, 12482, 12630 |
|
||||||
|
| newPlaylist | 10667 | 756 | data, view, persist, toast, uid, preload, render, slim, showModal, closeModal | 10635, 11148, 11325 |
|
||||||
|
| renamePlaylist | 10691 | 1109 | $, persist, render, showModal, closeModal | 9011, 9012, 9013 |
|
||||||
|
| openSongNoteModal | 10719 | 1703 | queueSource, current, persist, toast, updateServiceMode, renderList, showModal, closeModal | 10243 |
|
||||||
|
| updatePlaylistOfflineButton | 10787 | 1338 | downloading, playlistOfflineStats, setListIcon | 1733, 1888, 1917, 2040, 9020, 10834, 10842, 10871 |
|
||||||
|
| makePlaylistOffline | 10814 | 1075 | view, cachedIds, $, toast, preload, playlistOfflineStats, renderSidebar, updatePlaylistOfflineButton | 9018 |
|
||||||
|
| togglePinPlaylist | 10847 | 1045 | data, $, persist, toast, preload, renderSidebar, updatePlaylistOfflineButton | 9023 |
|
||||||
|
| deletePlaylist | 10875 | 507 | data, view, persist, render, showModal, closeModal | 9028 |
|
||||||
|
| toggleShortcutHelp | 10894 | 108 | $ | 11604 |
|
||||||
|
| wireShortcutHelp | 10899 | 272 | $ | 12567 |
|
||||||
|
| showModal | 10909 | 515 | $ | 833, 998, 1143, 1244, 1364, 1394, 1497, 1570, 2207, 3289, 3312, 3342, 3782, 3877, 4036, 4077, 4125, 4224, 5082, 5297, 5350, 5365, 6557, 6951, 6986, 7443, 9152, 9423, 9481, 9531, 9531, 10032, 10164, 10483, 10522, 10648, 10671, 10706, 10776, 10876, 11409, 11444, 11492, 11800, 12049, 12096, 12256, 12630, 12633 |
|
||||||
|
| closeModal | 10925 | 61 | $ | 832, 835, 998, 1121, 1245, 1246, 1365, 1366, 1395, 1422, 1473, 1477, 1488, 1494, 1571, 1576, 2208, 2232, 3282, 3289, 3307, 3312, 3338, 3342, 3783, 3784, 3878, 3887, 4030, 4037, 4067, 4078, 4121, 4125, 5083, 5297, 5351, 5352, 5366, 5367, 6549, 6951, 6986, 7443, 9152, 9155, 9424, 9429, 9440, 9484, 9489, 9498, 9532, 9532, 10033, 10038, 10168, 10171, 10484, 10489, 10523, 10548, 10556, 10566, 10570, 10589, 10626, 10635, 10638, 10644, 10672, 10681, 10707, 10712, 10764, 10777, 10877, 10883, 10933, 11410, 11420, 11435, 11442, 11444, 11493, 11494, 11616, 11791, 11795, 11801, 11802, 12050, 12058, 12097, 12111, 12257, 12259, 12630, 12633, 12633 |
|
||||||
|
| _portraitPwaMQ | 10944 | 172 | — | 10949, 11051 |
|
||||||
|
| isPortraitPWA | 10948 | 61 | _portraitPwaMQ | 10961, 11015, 11055 |
|
||||||
|
| isStackedPlayerLayout | 10954 | 412 | isPortraitPWA | 10339, 11026, 11039, 11637 |
|
||||||
|
| playerScrollBehavior | 10964 | 160 | data | 11029, 11046 |
|
||||||
|
| setupClassicLayout | 10968 | 2013 | current, $, showMiniBar, hideMiniBar | 12569 |
|
||||||
|
| applyPortraitPwaClass | 11012 | 223 | isPortraitPWA | 11050, 11052 |
|
||||||
|
| scrollPlayerIntoViewPortrait | 11024 | 241 | restoreNowPlayingList, isStackedPlayerLayout, playerScrollBehavior | 2421, 11637 |
|
||||||
|
| scrollListIntoViewPortrait | 11038 | 556 | isStackedPlayerLayout, playerScrollBehavior | 10367, 11181, 11188 |
|
||||||
|
| setupPortraitPwaWatcher | 11049 | 313 | _portraitPwaMQ, isPortraitPWA, applyPortraitPwaClass, closeSidebar | 12568 |
|
||||||
|
| setupViewportAnchorGuard | 11076 | 1614 | els | 12570 |
|
||||||
|
| openSidebar | 11113 | 211 | $ | 11134 |
|
||||||
|
| closeSidebar | 11120 | 216 | $ | 5358, 5358, 7876, 7876, 10358, 10358, 11055, 11133, 11137, 11609 |
|
||||||
|
| setupSidebarDrawer | 11127 | 399 | $, openSidebar, closeSidebar | 11141 |
|
||||||
|
| wireUI | 11140 | 24799 | perfMark, perfMeasure, RecentSearches, SearchLibrary, API, warmStreams, data, view, searchResults, searchAttempted, searchSeq, current, listFilter, selectMode, selectedIds, relatedCollapsed, cachedIds, downloading, removing, runExclusive, $, els, persist, copyProfileLink, shareProfileLink, escapeHtml, lastVolume, syncMuteIcon, toast, warmSearchThumbs, preload, markCardCacheState, openVideoEditor, Player, chooseQuality, reportBroken, openMoreControls, openAddChoice, openMoreActions, updateNowPlayingActions, Notes, Transition, Wave, collectSearchMetadata, Share, PiP, EQ, SectionRail, External, setHomeOverPlayer, hideMiniBar, toggleShuffle, addToQueue, removeFromQueue, restoreNowPlayingList, playNext, toggleLoopOne, toggleRepeat, cyclePlayMode, playPrev, isServiceModeOpen, cycleServiceView, stepServiceFont, enterServiceMode, exitServiceMode, openChannel, channelKeyOf, updateHomeGridScrollState, currentList, showSearchSkeletons, renderList, exportToDevice, fmtAgo, videoById, render, openCardMenu, slim, newPlaylist, toggleShortcutHelp, showModal, closeModal, isStackedPlayerLayout, scrollPlayerIntoViewPortrait, scrollListIntoViewPortrait, closeSidebar, setupSidebarDrawer, cancelSleepTimer, openSleepTimer, setAbA, setAbB, clearAb, toggleRememberPos, exitSelectMode, newPlaylistWithVideos, updateFromIcon | 12566 |
|
||||||
|
| sleepAtTrackEnd | 11664 | 28 | — | 5477, 7965, 8239, 11686, 11708, 11745, 11747, 11799 |
|
||||||
|
| sleepFading | 11665 | 24 | — | 11676, 11676, 11679, 11709, 11709, 11752 |
|
||||||
|
| sleepFadeSec | 11667 | 138 | data | 11674, 11761, 11796, 11796 |
|
||||||
|
| applySleepFade | 11673 | 346 | data, Player, sleepFading, sleepFadeSec | 7965, 11698 |
|
||||||
|
| startSleepTimer | 11683 | 487 | sleepTimerRemaining, sleepTimerTick, sleepAtTrackEnd, applySleepFade, cancelSleepTimer, finishSleep, updateSleepTimerUI | 4950, 11790, 11794 |
|
||||||
|
| cancelSleepTimer | 11704 | 253 | sleepTimerRemaining, sleepTimerTick, Player, sleepAtTrackEnd, sleepFading, updateSleepTimerUI | 4950, 11359, 11684, 11715, 11801 |
|
||||||
|
| finishSleep | 11714 | 674 | data, toast, Player, cancelSleepTimer, showSleepCurtain | 8239, 11696 |
|
||||||
|
| showSleepCurtain | 11734 | 390 | $, isServiceModeOpen, releaseWakeLock, exitServiceMode | 11724, 11726 |
|
||||||
|
| updateSleepTimerUI | 11744 | 579 | sleepTimerRemaining, $, sleepAtTrackEnd, sleepFading | 11687, 11692, 11699, 11710 |
|
||||||
|
| openSleepTimer | 11758 | 2657 | data, sleepTimerRemaining, persist, toast, showModal, closeModal, sleepAtTrackEnd, sleepFadeSec, startSleepTimer, cancelSleepTimer | 11357 |
|
||||||
|
| setAbA | 11809 | 152 | current, abA, fmtTime, toast, Player, saveAbMarkers, updateAbUI | 11367, 11596, 12659 |
|
||||||
|
| setAbB | 11816 | 152 | current, abB, fmtTime, toast, Player, saveAbMarkers, updateAbUI | 11368, 11597, 12659 |
|
||||||
|
| clearAb | 11823 | 263 | data, current, abA, abB, persist, toast, currentPlaylistEntry, updateAbUI | 11369, 12654, 12659 |
|
||||||
|
| saveAbMarkers | 11834 | 550 | data, current, abA, abB, persist, currentPlaylistEntry | 11812, 11819, 12654 |
|
||||||
|
| restoreAbMarkers | 11848 | 426 | data, current, abA, abB, currentPlaylistEntry, updateAbUI | 2423 |
|
||||||
|
| currentPlaylistEntry | 11860 | 383 | data, queueSource, current | 8405, 11827, 11842, 11852, 11875, 11882 |
|
||||||
|
| rememberPosEnabled | 11873 | 196 | data, current, currentPlaylistEntry | 2437, 3060, 3069, 11881, 11894 |
|
||||||
|
| toggleRememberPos | 11878 | 467 | data, current, persist, toast, currentPlaylistEntry, rememberPosEnabled, updateRememberPosUI | 11370 |
|
||||||
|
| updateRememberPosUI | 11892 | 134 | $, rememberPosEnabled | 2424, 11889 |
|
||||||
|
| updateAbUI | 11897 | 931 | abA, abB, $, fmtTime | 11813, 11820, 11831, 11849, 11856, 12654 |
|
||||||
|
| relatedState | 11915 | 51 | — | 11920, 11928, 11932, 11934, 11935 |
|
||||||
|
| fetchRelatedVideos | 11916 | 265 | API, current, relatedVideos, relatedState, renderRelated | 11924 |
|
||||||
|
| loadRelated | 11923 | 115 | current, fetchRelatedVideos | 2425, 11938 |
|
||||||
|
| renderRelated | 11926 | 2002 | queue, queueIndex, playFullMode, relatedVideos, relatedCollapsed, $, escapeHtml, Player, renderUpNext, openChannel, channelKeyOf, relatedState, loadRelated | 11920 |
|
||||||
|
| SMART_PLAYLISTS | 11965 | 233 | — | 8947, 11975 |
|
||||||
|
| renderSmartSidebar | 11971 | 928 | WEB, data, view, $, render, SMART_PLAYLISTS | 938, 1553, 10361, 12612 |
|
||||||
|
| getSmartList | 11991 | 926 | data | 8807 |
|
||||||
|
| exitSelectMode | 12023 | 230 | selectMode, selectedIds, $ | 2426, 10359, 11386, 11405, 11420, 11435, 11442, 11611 |
|
||||||
|
| toggleSelectMode | 12031 | 328 | selectMode, selectedIds, $, renderList | 9038 |
|
||||||
|
| toggleSelectCard | 12041 | 309 | selectedIds, $ | 10252 |
|
||||||
|
| newPlaylistWithVideos | 12046 | 721 | data, view, persist, toast, uid, preload, render, slim, showModal, closeModal | 11442 |
|
||||||
|
| doAutoBackup | 12070 | 479 | data, persist, toast | 10104, 12086 |
|
||||||
|
| checkAutoBackup | 12083 | 232 | data, doAutoBackup | 12704 |
|
||||||
|
| importBackup | 12088 | 1722 | data, persist, toast, render, showModal, closeModal | 10106 |
|
||||||
|
| RUNNING_BUILD | 12135 | 159 | — | 12159, 12239, 12350, 12350, 12350, 12353 |
|
||||||
|
| _knownBuildTag | 12141 | 26 | — | 12159, 12160, 12178, 12239 |
|
||||||
|
| fetchServerBuild | 12143 | 196 | — | 12157, 12238, 12347 |
|
||||||
|
| maybeShowUpdateBanner | 12154 | 774 | RUNNING_BUILD, _knownBuildTag, fetchServerBuild, prefetchUpdate, _updateBannerShown, _updating, showUpdateBanner | 12190, 12386, 12393, 12399 |
|
||||||
|
| _prefetchedFor | 12175 | 26 | — | 12179, 12180 |
|
||||||
|
| prefetchUpdate | 12176 | 398 | _knownBuildTag, _prefetchedFor, _swReg | 12167 |
|
||||||
|
| checkBuildTag | 12189 | 62 | maybeShowUpdateBanner | 12194, 12712, 12713 |
|
||||||
|
| pollBuildTag | 12193 | 117 | checkBuildTag | 12711 |
|
||||||
|
| hardReloadUI | 12204 | 865 | — | 10160 |
|
||||||
|
| _updateBannerShown | 12224 | 31 | — | 12155, 12248, 12249, 12334, 12345, 12352, 12361 |
|
||||||
|
| _updating | 12225 | 22 | — | 12155, 12235, 12288, 12289, 12328 |
|
||||||
|
| _updateToast | 12226 | 24 | — | 12308, 12308, 12309, 12330, 12330, 12330, 12357 |
|
||||||
|
| setUpdateIcon | 12230 | 106 | $ | 12236, 12240, 12251, 12257, 12332 |
|
||||||
|
| updateFromIcon | 12234 | 384 | toast, RUNNING_BUILD, _knownBuildTag, fetchServerBuild, _updating, setUpdateIcon, applyUpdate | 11629 |
|
||||||
|
| showUpdateBanner | 12246 | 821 | showModal, closeModal, _updateBannerShown, setUpdateIcon, applyUpdate | 12168 |
|
||||||
|
| _swReg | 12267 | 18 | — | 12183, 12295, 12372 |
|
||||||
|
| UPDATE_STATE_KEY | 12273 | 44 | — | 12278, 12282, 12283 |
|
||||||
|
| UPDATE_MAX_ATTEMPTS | 12274 | 30 | — | 12356 |
|
||||||
|
| SHELL_CACHE_RE | 12275 | 54 | — | 12316 |
|
||||||
|
| readUpdateState | 12277 | 134 | UPDATE_STATE_KEY | 12290, 12342 |
|
||||||
|
| writeUpdateState | 12280 | 251 | UPDATE_STATE_KEY | 12291, 12329, 12351, 12362 |
|
||||||
|
| applyUpdate | 12287 | 2240 | toast, _updateBannerShown, _updating, _updateToast, setUpdateIcon, _swReg, SHELL_CACHE_RE, readUpdateState, writeUpdateState | 12243, 12260, 12358 |
|
||||||
|
| checkUpdateOutcome | 12341 | 1002 | toast, RUNNING_BUILD, fetchServerBuild, _updateBannerShown, _updateToast, UPDATE_MAX_ATTEMPTS, readUpdateState, writeUpdateState, applyUpdate | 12709 |
|
||||||
|
| registerServiceWorker | 12366 | 1896 | WEB, maybeShowUpdateBanner, _swReg | 12710 |
|
||||||
|
| youtubeFromText | 12419 | 341 | — | 12515 |
|
||||||
|
| SHARE_INBOX | 12427 | 38 | — | 12464 |
|
||||||
|
| SHARED_PLAYLIST | 12428 | 41 | — | 12433, 12485 |
|
||||||
|
| sharedUploadsPlaylist | 12430 | 231 | data, uid, SHARED_PLAYLIST | 12481, 12495 |
|
||||||
|
| uploadSharedFile | 12440 | 852 | — | 12473 |
|
||||||
|
| _sharing | 12459 | 21 | — | 12461, 12462, 12499 |
|
||||||
|
| processSharedFiles | 12460 | 1649 | view, persist, toast, render, slim, SHARE_INBOX, SHARED_PLAYLIST, sharedUploadsPlaylist, uploadSharedFile, _sharing | 12525, 12526 |
|
||||||
|
| bootShareFromUrl | 12502 | 1091 | data, els, toast, playVideoAt, youtubeFromText, processSharedFiles | 12627 |
|
||||||
|
| URL_VIEWS | 12530 | 121 | — | 12540 |
|
||||||
|
| bootViewFromUrl | 12531 | 391 | view, render, URL_VIEWS | 12625 |
|
||||||
|
| bootSearchFromUrl | 12545 | 414 | els | 12626 |
|
||||||
|
| boot | 12563 | 11475 | WEB, APP_VERSION, perfMark, perfMeasure, webFetch, SaveQueue, API, DEFAULT_SETTINGS, data, view, current, abA, abB, cachedIds, downloading, $, els, persist, ProfileSecret, profileHeaders, syncOnLaunch, updateProfileStatus, adoptSharedPlaylistFromUrl, startPlaylistInboxWatch, adoptProfileFromUrl, syncMuteIcon, toast, uid, applyAppearance, refreshCachedIds, startCacheResyncWatch, warmOfflineThumbs, preload, preloadPlaylist, preloadPinnedPlaylists, Player, wirePlayerEvents, updatePlayBtn, Notes, Remote, Transition, CatalogSync, Recommendations, Presenter, Share, EqRender, PiP, EQ, SectionRail, Party, updateBottomChrome, updateQueueBadge, playNext, updateLoopRepeatButtons, playPrev, isServiceModeOpen, renderSidebar, exportToDevice, render, slim, wireShortcutHelp, showModal, closeModal, setupClassicLayout, setupPortraitPwaWatcher, setupViewportAnchorGuard, wireUI, setAbA, setAbB, clearAb, saveAbMarkers, updateAbUI, renderSmartSidebar, checkAutoBackup, checkBuildTag, pollBuildTag, checkUpdateOutcome, registerServiceWorker, bootShareFromUrl, bootViewFromUrl, bootSearchFromUrl | 12734 |
|
||||||
|
|
||||||
|
## Final cuts and review
|
||||||
|
|
||||||
|
Exactly six seams shipped: 3c9a24a (eager shell/shared renderers), 5fb9564
|
||||||
|
(Presenter), 8e7fa19 (Remote), 94ea211 (Watch party), 0475a18 (Share/External),
|
||||||
|
b335f95 (Stats view). 2c50035 handles lazy-entry failures without rejected UI
|
||||||
|
navigation promises. Final app.js: 408,473 bytes; net removal 178,556 bytes
|
||||||
|
(30.4169%), including 2,976 bytes of facade/call-site overhead.
|
||||||
|
|
||||||
|
Original bytes moved, independently counted from UTF-8 AST source slices:
|
||||||
|
98,378 eager renderer/shell/rail; 9,492 Presenter; 30,421 Remote;
|
||||||
|
22,833 Party; 13,334 Share/External; 7,074 Stats view. Published files include
|
||||||
|
headers/registry wrappers. Shared functions retain classic global lexical access;
|
||||||
|
new UI definitions evaluate safely before data/$/els/Player initialization.
|
||||||
|
|
||||||
|
Self-review against master §6/6b: all new files belong to explicit manifest
|
||||||
|
groups and legacy SHELL; source JSON matches assets.json; core scripts load first;
|
||||||
|
required eager features stay parsed. Player, Transition, Wave, EQ/level rendering,
|
||||||
|
queue, persistence, tracking and sleep bodies have exact source fingerprint tests.
|
||||||
|
Transport internals move intact; entry button bindings move to core facades to
|
||||||
|
avoid duplicate listeners. Inbound APIs remain short; synchronous boolean/hooks
|
||||||
|
never become promises. Boot URL/session triggers remain; no banner/cache-header
|
||||||
|
rule is changed. Executing groups remain pinned; no disposal/handoff or live
|
||||||
|
replacement is introduced. Contract/N-1 safeguards remain tested by existing
|
||||||
|
unit/integration checks.
|
||||||
|
|
||||||
|
Deliberately left: Notes/editor, service mode, playlist mutations, persistence,
|
||||||
|
player/queue/boot, audio watchdogs/EQ/sleep, Gestures, video editor/export glue and
|
||||||
|
other candidates beyond six seams. Settings paint moves to views-core but remains
|
||||||
|
eager. Now-playing metadata mutations inside Player stay there to honor the
|
||||||
|
untouched-playback constraint; their DOM, action paint and mini bar are eager.
|
||||||
119
plans/phase4-verification.md
Normal file
119
plans/phase4-verification.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Phase 4 verification
|
||||||
|
|
||||||
|
The served frontend is build `c3dbdd7039db`, source delivery commit `2c50035`.
|
||||||
|
`phase4-final-2026-10-08.json` records parent b335f95 because the review fix was
|
||||||
|
uncommitted when its scratch tree was copied; that tree includes exactly the
|
||||||
|
frontend subsequently committed as 2c50035. The isolated cold result records both
|
||||||
|
2c50035 and the build tag. Earlier phase result files were restored after harnesses
|
||||||
|
wrote their default paths; Phase 4 copies retain the actual new run evidence.
|
||||||
|
|
||||||
|
## Six green seams
|
||||||
|
|
||||||
|
| Commit | Seam | Original bytes moved | Frontend tests | Server tests | Both-engine cached smoke / hidden timers |
|
||||||
|
|---|---|---:|---:|---:|---|
|
||||||
|
| 3c9a24a | Eager shell/views/section rail | 98,378 | 192 | 183 | Pass |
|
||||||
|
| 5fb9564 | Presenter | 9,492 | 193 | 183 | Pass |
|
||||||
|
| 8e7fa19 | Remote client | 30,421 | 194 | 183 | Pass |
|
||||||
|
| 94ea211 | Watch party | 22,833 | 195 | 183 | Pass |
|
||||||
|
| 0475a18 | Share/External | 13,334 | 196 | 183 | Pass |
|
||||||
|
| b335f95 | Stats view | 7,074 | 197 | 183 | Pass |
|
||||||
|
| 2c50035 | Lazy-entry rejection handling | — | 198 | 183 | Pass |
|
||||||
|
|
||||||
|
After every seam, `node --test frontend/*.test.js`, `bun install` then `bun run test`
|
||||||
|
in server, `node perf/hidden-timers.mjs`, and `node perf/seams.mjs --browser all`
|
||||||
|
ran. Server tests used PATH="$PWD/../perf/.tmp/test-venv/bin:$PATH" so yt-dlp
|
||||||
|
worker tests also ran, without skips. Logs are in gitignored perf/.tmp; representative
|
||||||
|
actual output:
|
||||||
|
|
||||||
|
```
|
||||||
|
ℹ tests 198
|
||||||
|
ℹ pass 198
|
||||||
|
ℹ fail 0
|
||||||
|
ℹ skipped 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Server totals across its isolated test processes: 183 pass, 0 fail, 0 skip.
|
||||||
|
|
||||||
|
```
|
||||||
|
chromium {"browser":"chromium","checked":["shell","presenter","remote","party","share","stats"],"offlineNetworkRequests":0,"errors":[]}
|
||||||
|
webkit {"browser":"webkit","checked":["shell","presenter","remote","party","share","stats"],"offlineNetworkRequests":0,"errors":[]}
|
||||||
|
Result: ALL ASSERTIONS PASSED
|
||||||
|
```
|
||||||
|
|
||||||
|
The cache smoke stops Bun and blocks its proxy, then tests home playlists and eager
|
||||||
|
Settings, Presenter blank/pair validation, Remote pair form/close, Party local UI,
|
||||||
|
Share timestamp editing, External uploaded-media sheet/M3U, populated Stats view,
|
||||||
|
and a rejected entry action. Network relays are still required to connect parties
|
||||||
|
or remotes; offline verification proves cached code/UI, not offline relay service.
|
||||||
|
Existing lazy harness passes offline layouts, first-use, Settings extensions,
|
||||||
|
contract guards, pinned instances and update-during-playback on both engines.
|
||||||
|
|
||||||
|
One review smoke WebKit process terminated during concurrent browser checks; an
|
||||||
|
isolated rerun and the final expanded both-engine smoke passed. Initial harness
|
||||||
|
fixture errors (incorrect Settings selector, absent VM helper, timestamp format,
|
||||||
|
hidden list pane) were corrected to exercise actual UI; no product tests were
|
||||||
|
removed or weakened.
|
||||||
|
|
||||||
|
## Measurements
|
||||||
|
|
||||||
|
Actual commands:
|
||||||
|
|
||||||
|
```
|
||||||
|
node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario all --out perf/results/phase4-final-2026-10-08.json --compare perf/results/after-phase0-2026-10-07.json
|
||||||
|
node perf/baseline.mjs --runs 3 --browser chromium --profile lte --scenario cold --out perf/results/phase4-cold-isolated-2026-10-08.json --compare perf/results/after-phase0-2026-10-07.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Full reduced run overlapped other checks; its Chromium long tasks were 495 ms.
|
||||||
|
Repeat cold-only runs with no concurrent tests produced 297/274/275 ms (median
|
||||||
|
275). Keep both results: neither is rewritten. WebKit lacks the long-task API.
|
||||||
|
Historical Phase 0 is five-run data; these reduced measurements are directional.
|
||||||
|
|
||||||
|
| Metric | Phase 0 Chromium / WebKit | Phase 3 Chromium / WebKit | Phase 4 Chromium / WebKit |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| Initial compressed JS/CSS | 276,414 / 338,940* | 221,307 / 274,813 | 210,419 / 255,200 |
|
||||||
|
| Cold boot ms | 10,480 / 3,290 | 7,193 / 3,293 | 7,010** / 2,977 |
|
||||||
|
| Cold FCP ms | 1,572 / 1,100 | 1,356 / 2,467 | 1,352** / 2,401 |
|
||||||
|
| Cold long-task ms | 454 / unavailable | 287 / unavailable | 275** / unavailable |
|
||||||
|
| Warm boot ms | 692 / 139 | 419 / 81 | 467 / 132 |
|
||||||
|
| Offline boot ms | 259 / 137 | 166 / 84 | 180 / 90 |
|
||||||
|
| Core app update wire bytes | 611,338 / 680,822 | 159,905 / 194,026 | 125,795 / 147,259 |
|
||||||
|
| Inactive CSS blocking-readiness bytes | 611,476 / 680,831 | 29,724 / 28,082 | 30,263 / 28,713 |
|
||||||
|
| Feature edit through actual idle cache fill | — | — | 33,408 / 41,364 |
|
||||||
|
|
||||||
|
*Matched Phase 0 source reconstructed in Phase 3; historical JSON lacks initial
|
||||||
|
bytes. **Isolated three-run Chromium cold median. Other Phase 4 numbers are the
|
||||||
|
one-run both-browser suite. Warm counters include unfinished initial idle caching.
|
||||||
|
CSS readiness excludes its later background payload; it is not full update cost.
|
||||||
|
WebKit first paint still fails the master no-regression target. Full five-run
|
||||||
|
comparisons belong to Phase 5. Native iPhone timing/audio/quota is unverified.
|
||||||
|
|
||||||
|
Actual feature-update output:
|
||||||
|
|
||||||
|
```
|
||||||
|
run 1/1: 33408 bytes, 8 reqs, dur=1376ms
|
||||||
|
run 1/1: 41364 bytes, 7 reqs, dur=1358ms
|
||||||
|
```
|
||||||
|
|
||||||
|
Only presenter.js application payload transfers (3,118 / 3,587 compressed bytes).
|
||||||
|
Totals include changed stamped index, manifest, version checks, sw.js checks and
|
||||||
|
WebKit's 1,954-byte sync-core worker import. No app.js, unchanged core views,
|
||||||
|
fonts, icons or CSS transfer. The benchmark asserts this rather than inferring it
|
||||||
|
from the lower total. Final app.js is 408,473 versus 587,029 bytes: −178,556,
|
||||||
|
−30.4169%; original code moved totals 181,532, facade overhead 2,976 bytes.
|
||||||
|
|
||||||
|
## Migration, rollback and owner checks
|
||||||
|
|
||||||
|
Both engines pass b77938a migration with lossy/stalling and unthrottled proxies:
|
||||||
|
122 cached SHELL entries, one banner and exactly one user-initiated reload,
|
||||||
|
21 legacy URL checks, N-1 open-tab continuity, offline boot, playback guard,
|
||||||
|
eviction repair and incremental refresh. Result JSON records the real runs.
|
||||||
|
No automatic activation/reload or live singleton replacement is added.
|
||||||
|
|
||||||
|
Rollback ASSET_SYNC=0 and ASSET_HASHING=0 use the existing legacy path; commands
|
||||||
|
and results are in perf/README.md and perf/results. No new feature flag.
|
||||||
|
Revert an individual seam to restore its previous parsing behavior.
|
||||||
|
|
||||||
|
Owner must verify a real iPhone PWA: warm feature first-use, selected/offline layout,
|
||||||
|
Presenter/Remote/Party sessions, update guard/override, locked audio 30 minutes,
|
||||||
|
interruption/resume, saved OPFS music surviving cache quota pressure. Nothing was
|
||||||
|
pushed, merged or deployed.
|
||||||
Reference in New Issue
Block a user