Compare commits
15 Commits
4950d3e417
...
cd4a25ae23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd4a25ae23 | ||
|
|
b84050578b | ||
|
|
fab444b7b8 | ||
|
|
46ec9ab2ca | ||
|
|
c1a546a5c6 | ||
|
|
40b92ebc0d | ||
|
|
524a7b7f1d | ||
|
|
173eab8026 | ||
|
|
ccfb7d90ba | ||
|
|
5d6a1545df | ||
|
|
d98d444552 | ||
|
|
4853c2c977 | ||
|
|
e1cb450032 | ||
|
|
f804a23761 | ||
|
|
895f20a4c9 |
@@ -1666,7 +1666,13 @@ function applyAppearance() {
|
||||
root.dataset.density = s.density || 'comfortable';
|
||||
root.dataset.perf = s.perfMode ? 'on' : 'off';
|
||||
root.dataset.motion = s.reduceMotion ? 'reduced' : 'full';
|
||||
root.dataset.layout = s.layout || 'classic';
|
||||
const desiredLayout=s.layout || 'classic';
|
||||
if(root.dataset.layout!==desiredLayout && !Lazy.loaded('layout:'+desiredLayout)) {
|
||||
Lazy.load('layout:'+desiredLayout).then(()=>{if((data.settings.layout||'classic')===desiredLayout)applyAppearance();}).catch(error=>toast(error.message));
|
||||
} else {
|
||||
root.dataset.layout=desiredLayout;
|
||||
if(Lazy.layout!==desiredLayout) { Lazy.layout=desiredLayout; Lazy.reportLayout?.(); }
|
||||
}
|
||||
}
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B';
|
||||
@@ -2056,7 +2062,8 @@ async function deleteCustomVideo(id) {
|
||||
// Open the editor modal for a source video. `duration` seconds is needed to
|
||||
// compute keep segments; we take it from the live player when the video is
|
||||
// currently playing, else from the card metadata.
|
||||
function openVideoEditor(source) {
|
||||
async function openVideoEditor(source) {
|
||||
try { await Lazy.load('feature:video-edit'); } catch (error) { toast(error.message); return; }
|
||||
if (!(WEB && window.OPFS && window.OPFS.isSupported())) {
|
||||
toast('⚠ Editing needs offline storage, which this browser doesn’t support');
|
||||
return;
|
||||
@@ -3135,6 +3142,7 @@ async function renderAvailability(id) {
|
||||
const el = $('npAvail');
|
||||
if (!el) return;
|
||||
const off = data.settings.p2pShare === false && data.settings.p2pReceive === false;
|
||||
if (WEB && !off && navigator.onLine !== false && id && !window.P2PCore) { try { await Lazy.load('feature:p2p'); } catch {} }
|
||||
if (!WEB || !window.P2PCore || off || navigator.onLine === false || String(id).startsWith('edit_')) {
|
||||
el.classList.add('hidden');
|
||||
return;
|
||||
@@ -3161,6 +3169,7 @@ async function renderAvailability(id) {
|
||||
// Fetch a verified copy from another device (docs/p2p-architecture.md flow 6).
|
||||
// Download-then-play: on success the file is saved offline like any save.
|
||||
async function getFromPeers(videoObj, c, peers, onProgress) {
|
||||
try { await Lazy.load('feature:direct'); } catch (error) { toast(error.message); return false; }
|
||||
if (window.DirectMedia && !DirectMedia.enabled()) { toast('Direct device transfer is off. Enable it in Settings or use a server copy.'); return false; }
|
||||
if (window.DirectMedia?.enabled()) { const ok = await DirectMedia.obtain(videoObj.id); if (!ok) toast('Pair the source device through Remote or use the same profile to receive its saved copy.'); return !!ok; }
|
||||
const id = videoObj.id;
|
||||
@@ -3186,6 +3195,7 @@ async function getFromPeers(videoObj, c, peers, onProgress) {
|
||||
|
||||
// Offered when a video won't load: only if some device holding it is online now.
|
||||
async function offerPeerDownload(videoObj) {
|
||||
if (WEB && data.settings.p2pReceive !== false) { try { await Lazy.load('feature:p2p'); } catch { return; } }
|
||||
if (!WEB || !window.P2PTransfer || !window.P2PCore || data.settings.p2pReceive === false) return;
|
||||
const id = videoObj && videoObj.id;
|
||||
if (!id || videoObj.custom || cachedIds.has(id)) return;
|
||||
@@ -5035,7 +5045,8 @@ const Remote = (() => {
|
||||
body.querySelector('[data-act="unpair"]').classList.toggle('hidden', !live);
|
||||
}
|
||||
|
||||
function openHostModal() {
|
||||
async function openHostModal() {
|
||||
try { await Lazy.load('feature:direct'); } catch(error) { toast(error.message); return; }
|
||||
const body = document.createElement('div');
|
||||
body.className = 'remote-host';
|
||||
body.innerHTML = `
|
||||
@@ -5306,7 +5317,8 @@ const Remote = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
function openRemoteView({ code = '' } = {}) {
|
||||
async function openRemoteView({ code = '' } = {}) {
|
||||
try { await Lazy.load('feature:direct'); } catch(error) { toast(error.message); return; }
|
||||
rc.open = true;
|
||||
rc.seekTimer = VisibleTimer.setIntervalWhenVisible(renderSeek, 500);
|
||||
// This phone is the remote now — don't also play here.
|
||||
@@ -5918,7 +5930,8 @@ const StatsTrack = (() => {
|
||||
}
|
||||
function tick(cur) {
|
||||
const meta = current && current.meta;
|
||||
if (!meta || !meta.id || !window.StatsCore) return;
|
||||
if (!meta || !meta.id) return;
|
||||
if (!window.StatsCore) { Lazy.load('feature:stats').catch(() => {}); return; }
|
||||
if (meta.id !== st.id) {
|
||||
commit(false);
|
||||
st.id = meta.id;
|
||||
@@ -5949,7 +5962,9 @@ const StatsTrack = (() => {
|
||||
// ============================================================================
|
||||
// Stats view
|
||||
// ============================================================================
|
||||
function renderStats() {
|
||||
async function renderStats() {
|
||||
try { await Lazy.load('feature:stats'); } catch (error) { toast(error.message); return; }
|
||||
if (view.type !== 'stats') return;
|
||||
StatsTrack.flush();
|
||||
els.listTitle.textContent = 'Stats';
|
||||
resetListHeaderExtras();
|
||||
@@ -6679,8 +6694,8 @@ const EqRender = (() => {
|
||||
const g = EQ.gains();
|
||||
const lufs = Level.enabled() ? Level.remembered(id) : null;
|
||||
const levelDb = lufs == null ? 0 : Math.round(Level.gainFor(lufs) * 2) / 2;
|
||||
const vocal = VocalReducer.amount(data.settings.vocalLevel ?? 100);
|
||||
const width = EqCore.width(data.settings.stereoWidth);
|
||||
const vocal = EqParameters.amount(data.settings.vocalLevel ?? 100);
|
||||
const width = EqParameters.width(data.settings.stereoWidth);
|
||||
if (g.every((v) => v === 0) && Math.abs(levelDb) < 0.5 && vocal === 100 && width === 0) return null;
|
||||
return { gains: g, levelDb, vocal, width, sig: g.join(',') + '|' + levelDb + '|vocal:' + vocal + '|width:' + width };
|
||||
}
|
||||
@@ -6719,7 +6734,7 @@ const EqRender = (() => {
|
||||
|
||||
function writeViaWorker(name, buffer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const w = new Worker('/opfs-worker.js');
|
||||
const w = Lazy.worker('/opfs-worker.js');
|
||||
w.onmessage = (e) => { w.terminate(); (e.data && e.data.type === 'written') ? resolve() : reject(new Error((e.data && e.data.error) || 'write failed')); };
|
||||
w.onerror = () => { w.terminate(); reject(new Error('worker failed')); };
|
||||
w.postMessage({ op: 'write', dir: DIR, name, buffer }, [buffer]);
|
||||
@@ -6731,6 +6746,7 @@ const EqRender = (() => {
|
||||
const w = wanted(id);
|
||||
if (!w) { await drop(id); return; }
|
||||
if (readMap()[id] === w.sig) return;
|
||||
await loadSoundFeatures();
|
||||
const file = await window.OPFS.getFileObject(id);
|
||||
if (!file) return;
|
||||
const buf = await file.arrayBuffer();
|
||||
@@ -6834,8 +6850,9 @@ const PiP = (() => {
|
||||
// is chosen (createMediaElementSource can't be undone; afterwards "Flat"
|
||||
// simply zeroes the gains). Disabled on iOS: Safari suspends Web Audio when
|
||||
// the screen locks, which would silence background playback.
|
||||
const EQ_BANDS = EqCore.bands;
|
||||
const EQ_PRESETS = EqCore.presets;
|
||||
const loadSoundFeatures=()=>Promise.all([Lazy.load('feature:eq-core'),Lazy.load('feature:vocal-reducer')]);
|
||||
const EQ_BANDS = EqParameters.bands;
|
||||
const EQ_PRESETS = EqParameters.presets;
|
||||
const EQ = (() => {
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
@@ -6850,12 +6867,16 @@ const EQ = (() => {
|
||||
const rendered = () => !!(current && current.eqAudioUrl);
|
||||
const gains = () => {
|
||||
const g = data.settings.eq && Array.isArray(data.settings.eq.g) ? data.settings.eq.g : EQ_PRESETS.flat.g;
|
||||
return EqCore.migrate(g);
|
||||
return EqParameters.migrate(g);
|
||||
};
|
||||
const isFlat = () => gains().every((v) => v === 0);
|
||||
|
||||
function build() {
|
||||
if (st.ctx) return true;
|
||||
if (!Lazy.loaded('feature:eq-core') || !Lazy.loaded('feature:vocal-reducer')) {
|
||||
loadSoundFeatures().then(()=>{apply(); Level.apply();}).catch(error=>toast(error.message));
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
st.ctx = new AC();
|
||||
st.filters = EQ_BANDS.map((b) => {
|
||||
@@ -6893,7 +6914,7 @@ const EQ = (() => {
|
||||
// Apply the stored curve; wires the graph up the first time it's non-flat.
|
||||
function apply() {
|
||||
if (!available()) return;
|
||||
if (!st.ctx && isFlat() && VocalReducer.amount(data.settings.vocalLevel ?? 100) === 100 && EqCore.width(data.settings.stereoWidth) === 0) return;
|
||||
if (!st.ctx && isFlat() && EqParameters.amount(data.settings.vocalLevel ?? 100) === 100 && EqParameters.width(data.settings.stereoWidth) === 0) return;
|
||||
if (!build()) return;
|
||||
attach(els.video);
|
||||
attach(els.audio);
|
||||
@@ -6921,7 +6942,8 @@ const EQ = (() => {
|
||||
if (typeof EqRender !== 'undefined') EqRender.scheduleAll();
|
||||
}
|
||||
|
||||
function open() {
|
||||
async function open() {
|
||||
try { await loadSoundFeatures(); } catch(error) { toast(error.message); return; }
|
||||
const body = document.createElement('div');
|
||||
body.className = 'eq-panel';
|
||||
if (!AC) {
|
||||
@@ -7795,7 +7817,7 @@ const Party = (() => {
|
||||
function setStatus(t) { const el = $('ptStatus'); if (el) el.textContent = t; }
|
||||
function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); renderChat(); }
|
||||
function link() { return `${location.origin}/?party=${st.code}`; }
|
||||
function showPanel() { $('partyPanel').classList.remove('hidden'); st.minimized = false; $('partyPanel').classList.remove('min'); render(); }
|
||||
function showPanel() { Lazy.load('feature:party-dj').then(render).catch(error=>toast(error.message)); Lazy.load('feature:direct').catch(error=>toast(error.message)); $('partyPanel').classList.remove('hidden'); st.minimized = false; $('partyPanel').classList.remove('min'); render(); }
|
||||
|
||||
function render() {
|
||||
const panel = $('partyPanel');
|
||||
@@ -7836,7 +7858,7 @@ const Party = (() => {
|
||||
<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);
|
||||
PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } });
|
||||
if (Lazy.loaded('feature:party-dj')) PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } });
|
||||
renderChat();
|
||||
}
|
||||
function renderChat() {
|
||||
@@ -9104,6 +9126,7 @@ function addBrowseCta(empty, label) {
|
||||
// through the server. Buttons appear only when the file is ready so each one
|
||||
// runs inside its own tap (the share sheet needs a fresh user gesture).
|
||||
async function exportToDevice(video) {
|
||||
try { await Lazy.load('feature:export'); } catch(error) { toast(error.message); return; }
|
||||
if (!video || !video.id || !window.DeviceExport) return;
|
||||
const id = video.id;
|
||||
const title = video.title || videoTitleById(id) || id;
|
||||
@@ -9869,7 +9892,12 @@ async function renderSettings() {
|
||||
|
||||
// ---- Wire appearance / accessibility ----
|
||||
$('setTheme').addEventListener('change', (e) => { data.settings.theme = e.target.value; applyAppearance(); persist(); });
|
||||
$('setLayout').addEventListener('change', (e) => { data.settings.layout = e.target.value; applyAppearance(); persist(); });
|
||||
$('setLayout').addEventListener('change', async (e) => {
|
||||
const select=e.target, next=select.value, previous=data.settings.layout; select.disabled=true;
|
||||
try { await Lazy.load('layout:'+next); data.settings.layout=next; Lazy.layout=next; if(WEB)saveDataToStorage(JSON.stringify(data)); applyAppearance(); persist(); Lazy.reportLayout?.(); }
|
||||
catch(error) { select.value=previous; toast(error.message); }
|
||||
finally { select.disabled=false; }
|
||||
});
|
||||
$('setFont').addEventListener('change', (e) => { data.settings.fontScale = e.target.value; applyAppearance(); persist(); });
|
||||
$('setParallelSaves').addEventListener('change', (e) => { data.settings.parallelSaves = Number(e.target.value) || 4; persist(); SaveSlots.pump(); });
|
||||
$('setDensity').addEventListener('change', (e) => { data.settings.density = e.target.value; applyAppearance(); persist(); });
|
||||
@@ -11118,7 +11146,7 @@ function wireUI() {
|
||||
|
||||
// ---- Empty-home playlist grid ----
|
||||
$('homePlNewBtn').addEventListener('click', () => newPlaylist(null));
|
||||
const importSetlist = document.createElement('button'); importSetlist.type = 'button'; importSetlist.className = 'icon-btn'; importSetlist.textContent = '⇥'; importSetlist.title = 'Import setlist'; importSetlist.setAttribute('aria-label', 'Import setlist'); importSetlist.onclick = () => SetlistImport.open(); els.newPlaylistBtn.after(importSetlist);
|
||||
const importSetlist = document.createElement('button'); importSetlist.type = 'button'; importSetlist.className = 'icon-btn'; importSetlist.textContent = '⇥'; importSetlist.title = 'Import setlist'; importSetlist.setAttribute('aria-label', 'Import setlist'); importSetlist.onclick = () => SetlistImport.open(); els.newPlaylistBtn.after(importSetlist); Lazy.intent(importSetlist,'feature:setlist-import');
|
||||
$('homePlGrid').addEventListener('scroll', updateHomeGridScrollState, { passive: true });
|
||||
window.addEventListener('resize', updateHomeGridScrollState);
|
||||
|
||||
@@ -12267,7 +12295,7 @@ async function applyUpdate(target, { quiet = false } = {}) {
|
||||
const reg = _swReg || (('serviceWorker' in navigator) ? await navigator.serviceWorker.getRegistration() : null);
|
||||
const SU = window.SwUpdate;
|
||||
const reload = () => window.location.reload();
|
||||
if (!SU) { reload(); return; } // helper failed to load (flaky link): a reload is the best we can do
|
||||
if (!SU) throw new Error('Update helper unavailable; try Refresh UI again when paused');
|
||||
await SU.applyUpdate({
|
||||
reg,
|
||||
container: navigator.serviceWorker,
|
||||
@@ -12342,6 +12370,7 @@ async function registerServiceWorker() {
|
||||
// cached copy of sw.js — the server always returns it fresh (no-store).
|
||||
const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });
|
||||
_swReg = reg;
|
||||
Lazy.reportLayout(reg);
|
||||
|
||||
// A waiting worker only means "update pending" when this page is already
|
||||
// controlled by a previous SW. On a FIRST install (fresh visit, or right
|
||||
@@ -12588,6 +12617,7 @@ async function boot() {
|
||||
// and re-render only if something actually changed.
|
||||
syncOnLaunch();
|
||||
startPlaylistInboxWatch();
|
||||
if(LowerThird.active()) await Lazy.load('feature:lower-third');
|
||||
Presenter.boot();
|
||||
if (LowerThird.active()) return;
|
||||
SectionRail.boot();
|
||||
@@ -12617,7 +12647,7 @@ async function boot() {
|
||||
PiP.render();
|
||||
LyricsWindow.configure({ prepare: () => { if (!isServiceModeOpen()) Notes.stageLyrics.set(true); }, view: () => isServiceModeOpen() && data.settings.serviceLyrics ? $('serviceLyrics') : $('stageLyrics'), line: () => $('serviceLyricsList')?.querySelector('.active')?.textContent || $('stageLyricsList')?.querySelector('.active')?.textContent || current?.meta?.title || 'Nothing playing', toggle: () => { Player.toggle(); updatePlayBtn(); }, next: playNext, refresh: () => { Notes.stageLyrics.render(true); Notes.serviceRefresh(); }, toast });
|
||||
EQ.renderButton();
|
||||
if (data.settings.eq?.g?.length === 5) { data.settings.eq.g = EqCore.migrate(data.settings.eq.g); persist(); }
|
||||
if (data.settings.eq?.g?.length === 5) { data.settings.eq.g = EqParameters.migrate(data.settings.eq.g); persist(); }
|
||||
EQ.apply();
|
||||
EqCore.register({ settings: () => data.settings, persist, open: EQ.open, apply: () => { EQ.apply(); EqRender.scheduleAll(); } });
|
||||
VocalReducer.register({ settings: () => data.settings, persist, apply: () => { EQ.apply(); EqRender.scheduleAll(); } });
|
||||
@@ -12630,6 +12660,8 @@ async function boot() {
|
||||
else if (action === 'panic') MidiPedals.fade({ volume: Player.soundEl.volume, setVolume: v => { Player.soundEl.volume = v; }, pause: () => { Player.pause(); updatePlayBtn(); }, restore: () => Player.applyVolume() });
|
||||
} });
|
||||
applyAppearance();
|
||||
Lazy.load('layout:'+data.settings.layout).then(()=>{ if(window.BentoHub)BentoHub.playlists=()=>data.playlists||[]; renderSidebar(); }).catch(error=>toast(error.message));
|
||||
if(data.settings.midiEnabled)Lazy.load('feature:midi').catch(error=>toast(error.message));
|
||||
CarMode.configure({ input: els.searchInput, search: () => els.searchForm.requestSubmit(), toast, state: () => ({ title: current?.meta?.title || '', paused: Player.master?.paused !== false }), action: action => { if (action === 'toggle') { Player.toggle(); updatePlayBtn(); } else if (action === 'next') playNext(); else if (action === 'prev') playPrev(); else if (action === 'back') Player.seek(Math.max(0, (Player.master.currentTime || 0) - 15)); } });
|
||||
updateProfileStatus();
|
||||
updateLoopRepeatButtons();
|
||||
@@ -12643,10 +12675,12 @@ async function boot() {
|
||||
await refreshCachedIds();
|
||||
startCacheResyncWatch();
|
||||
// Peer-to-peer: report what this device holds (on by default; settings.p2pShare).
|
||||
if (WEB && window.P2PClient) {
|
||||
window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '', getProfileSecret: () => ProfileSecret.getFor(data.profile?.name) });
|
||||
if (window.P2PTransfer) window.P2PTransfer.start({ canShare: () => false }); // New scoped invitations replace the unconfirmed legacy sender.
|
||||
}
|
||||
Lazy.onLoad('feature:p2p', () => { if (WEB) {
|
||||
window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '', getProfileSecret: () => ProfileSecret.getFor(data.profile?.name), initialDelay: 0 });
|
||||
if (window.P2PTransfer) window.P2PTransfer.start({ canShare: () => false }); // Scoped invitations preserve receiver confirmation.
|
||||
} });
|
||||
// Preserve the existing eight-second sharing startup without parsing transport at boot.
|
||||
if (WEB) setTimeout(() => Lazy.load('feature:direct').catch(error=>console.warn(error.message)),8000);
|
||||
data.playlists.forEach(preloadPlaylist);
|
||||
preloadPinnedPlaylists();
|
||||
updateBottomChrome();
|
||||
@@ -12675,6 +12709,11 @@ async function boot() {
|
||||
if (WEB) checkUpdateOutcome();
|
||||
registerServiceWorker();
|
||||
if (WEB) pollBuildTag();
|
||||
window.addEventListener('ytp-reload-required',()=>checkBuildTag());
|
||||
window.addEventListener('ytp-lazy-error',event=>{ toast(event.detail?.error?.message||'Feature unavailable; try Refresh UI'); checkBuildTag(); });
|
||||
Lazy.intent($('navPartyBtn'),'feature:party-dj'); Lazy.intent($('navRemoteBtn'),'feature:direct');
|
||||
Lazy.intent($('remoteChip'),'feature:direct'); Lazy.intent($('eqBtn'),'feature:eq-core');
|
||||
Lazy.reportLayout?.(); Lazy.warm?.();
|
||||
if (WEB) {
|
||||
fetch('/api/user/sync', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
}
|
||||
|
||||
function blocking(manifest, previous, activeLayout) {
|
||||
return [...new Set(Object.entries(manifest.groups)
|
||||
const names = new Set(Object.entries(manifest.groups)
|
||||
.filter(([name, group]) => name === 'core' || !group.background ||
|
||||
name === 'layout:' + activeLayout ||
|
||||
(previous?.groups[name] && previous.groups[name].contract !== group.contract))
|
||||
.flatMap(([, group]) => group.files))].sort();
|
||||
.map(([name]) => name));
|
||||
for (const name of names) for (const dependency of manifest.groups[name].dependencies || []) names.add(dependency);
|
||||
return [...new Set([...names].flatMap(name => manifest.groups[name].files))].sort();
|
||||
}
|
||||
|
||||
function plan(manifest, keys) {
|
||||
@@ -26,13 +28,22 @@
|
||||
}
|
||||
|
||||
function retained(current, previous) {
|
||||
const keep = new Set(paths(current).map(path => url(path, current.files[path])));
|
||||
const currentPaths = new Set(paths(current));
|
||||
const keep = new Set([...currentPaths].map(path => url(path, current.files[path])));
|
||||
if (previous) for (const path of paths(previous)) {
|
||||
if (current.files[path]?.h !== previous.files[path].h) keep.add(url(path, previous.files[path]));
|
||||
if (!currentPaths.has(path) || current.files[path]?.h !== previous.files[path].h) keep.add(url(path, previous.files[path]));
|
||||
}
|
||||
return keep;
|
||||
}
|
||||
|
||||
function fallback(manifest, previous, path) {
|
||||
if (!manifest.files[path] || !previous?.files[path]) return null;
|
||||
const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path));
|
||||
if (!group || !previous.groups[group]?.files.includes(path) ||
|
||||
manifest.groups[group].contract !== previous.groups[group].contract) return null;
|
||||
return url(path, previous.files[path]);
|
||||
}
|
||||
|
||||
async function state(cache) {
|
||||
const response = await cache.match(STATE);
|
||||
return response ? response.json() : null;
|
||||
@@ -47,10 +58,12 @@
|
||||
return { ready: missing === 0, missing, version: manifest.buildTag };
|
||||
}
|
||||
|
||||
async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout }) {
|
||||
async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups }) {
|
||||
const previous = (await state(cache))?.current;
|
||||
const missing = [];
|
||||
for (const path of blocking(manifest, previous, activeLayout)) {
|
||||
const selected = groups ? [...new Set(groups.flatMap(name => manifest.groups[name]?.files || []))].sort()
|
||||
: blocking(manifest, previous, activeLayout);
|
||||
for (const path of selected) {
|
||||
const key = url(path, manifest.files[path]);
|
||||
const response = await cache.match(key);
|
||||
if (!response || response.headers.get('X-Asset-Hash') !== manifest.files[path].h) missing.push(key);
|
||||
@@ -80,7 +93,7 @@
|
||||
}
|
||||
}));
|
||||
if (failure) throw failure;
|
||||
if (!(await status(manifest, cache, { previous, activeLayout })).ready) throw new Error('Incomplete blocking assets');
|
||||
if (!groups && !(await status(manifest, cache, { previous, activeLayout })).ready) throw new Error('Incomplete blocking assets');
|
||||
return { refreshed: count, caches: 1 };
|
||||
}
|
||||
|
||||
@@ -106,7 +119,7 @@
|
||||
return next;
|
||||
}
|
||||
|
||||
const api = { CACHE, STATE, url, paths, blocking, plan, retained, status, syncAssets, state, commit };
|
||||
const api = { CACHE, STATE, url, paths, blocking, plan, retained, fallback, status, syncAssets, state, commit };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.AssetSyncCore = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
|
||||
@@ -10,3 +10,38 @@ test('commit is atomic and pruning keeps only current and previous changed versi
|
||||
test('downloads are capped at six and each failure gets three attempts',async()=>{const c=cache(),files={},list=[];for(let i=0;i<13;i++){const p='/'+i+'.js';files[p]={h:String(i)};list.push(p)}const m={buildTag:'pool',files,groups:{core:{files:list}}};let active=0,max=0;const tries={};await core.syncAssets(m,{cache:c,concurrency:20,fetchFn:async u=>{active++;max=Math.max(max,active);await new Promise(r=>setTimeout(r,5));active--;tries[u]=(tries[u]||0)+1;if(tries[u]<3)throw Error('drop');return response(u.split('=')[1])}});assert.equal(max,6);assert.ok(Object.values(tries).every(n=>n===3));});
|
||||
test('background files do not block, but active layout and contract changes do',()=>{const m=manifest();m.groups.extra={background:true,contract:2,files:['/extra.js']};m.files['/extra.js']={h:'e'};assert.deepEqual(core.blocking(m),['/a.js','/index.html']);assert.ok(core.blocking(m,{groups:{extra:{contract:1}}}).includes('/extra.js'));m.groups['layout:classic']={background:true,files:['/classic.css']};m.files['/classic.css']={h:'c'};assert.ok(core.blocking(m,null,'classic').includes('/classic.css'));});
|
||||
test('removed files survive one previous build for open tabs',()=>{const previous=manifest(),current=manifest('c');delete current.files['/a.js'];current.groups.core.files=['/index.html'];assert.ok(core.retained(current,previous).has('/a.js?v=a'));});
|
||||
|
||||
test('staged fallback permits only the previous same-contract version of a shipped group file', () => {
|
||||
const old = manifest(), next = manifest('c');
|
||||
old.groups.core.contract = next.groups.core.contract = 1;
|
||||
assert.equal(core.fallback(next, old, '/a.js'), '/a.js?v=a');
|
||||
next.groups.core.contract = 2;
|
||||
assert.equal(core.fallback(next, old, '/a.js'), null);
|
||||
assert.equal(core.fallback(next, old, '/missing.js'), null);
|
||||
});
|
||||
|
||||
test('background synchronization fills all groups without publishing a new current manifest', async () => {
|
||||
const c = cache(), m = manifest();
|
||||
m.groups.optional = { contract: 1, background: true, files: ['/extra.js'] };
|
||||
m.files['/extra.js'] = { h: 'e' };
|
||||
const fetchFn = async key => response(key.split('=')[1]);
|
||||
await core.syncAssets(m, { cache: c, fetchFn });
|
||||
assert.equal(await c.match('/extra.js?v=e'), undefined);
|
||||
await core.commit(m, c);
|
||||
const before = await core.state(c);
|
||||
const result = await core.syncAssets(m, { cache: c, fetchFn, groups: ['optional'] });
|
||||
assert.equal(result.refreshed, 1);
|
||||
assert.ok(await c.match('/extra.js?v=e'));
|
||||
assert.deepEqual(await core.state(c), before);
|
||||
});
|
||||
|
||||
test('active layouts also block on shared dependencies, while other layouts stay background', () => {
|
||||
const m=manifest(); m.groups['layout:glass-stage']={contract:1,background:true,files:['/glass.css'],dependencies:['feature:controls']}; m.groups['feature:controls']={contract:1,background:true,files:['/controls.js']}; m.groups['layout:bento-hub']={contract:1,background:true,files:['/bento.css']};
|
||||
for(const path of ['/glass.css','/controls.js','/bento.css'])m.files[path]={h:'h'};
|
||||
assert.deepEqual(core.blocking(m,null,'glass-stage'),['/a.js','/controls.js','/glass.css','/index.html']);
|
||||
});
|
||||
|
||||
test('removing group membership retains the old tab URL even if the physical file is still shipped unchanged',()=>{
|
||||
const previous=manifest(),current=manifest();current.groups.core.files=['/index.html'];
|
||||
assert.ok(core.retained(current,previous).has('/a.js?v=a'));
|
||||
});
|
||||
|
||||
@@ -1,12 +1,44 @@
|
||||
const {test}=require('node:test');const assert=require('node:assert/strict');const vm=require('node:vm');const fs=require('node:fs');const core=require('./asset-sync-core');
|
||||
function environment(){
|
||||
const stores=new Map(),listeners={},messages=[],skips=[];const m={buildTag:'next',files:{'/index.html':{h:'index'},'/app.js':{h:'app'}},groups:{core:{files:['/index.html','/app.js']}}};
|
||||
const stores=new Map(),listeners={},messages=[],skips=[],fetches=[];const m={buildTag:'next',files:{'/index.html':{h:'index'},'/app.js':{h:'app'}},groups:{core:{files:['/index.html','/app.js']}}};
|
||||
const storage={async keys(){return [...stores.keys()]},async delete(n){return stores.delete(n)},async open(n){if(!stores.has(n)){const map=new Map();stores.set(n,{async match(k){return map.get(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)?.clone()},async put(k,r){map.set(k,r.clone())},async keys(){return [...map.keys()].map(k=>({url:'https://local'+k}))},async delete(k){return map.delete(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)}})}return stores.get(n)}};
|
||||
const sandbox={__BUILD_TAG__:'next',__ASSET_SYNC__:true,AssetSyncCore:core,importScripts:()=>{},caches:storage,URL,Response,Request,Headers,crypto:require('node:crypto').webcrypto,console,fetch:async u=>u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':m.files[u.split('?')[0]]?.h || ''}}),self:{location:{origin:'https://local'},addEventListener:(t,f)=>listeners[t]=f,skipWaiting:()=>skips.push(1),clients:{claim:async()=>{},matchAll:async()=>[{id:'old',postMessage:x=>messages.push(x)}]}}};
|
||||
const sandbox={__BUILD_TAG__:'next',__ASSET_SYNC__:true,AssetSyncCore:core,importScripts:()=>{},caches:storage,URL,Response,Request,Headers,setTimeout,clearTimeout,crypto:require('node:crypto').webcrypto,console,fetch:async u=>{fetches.push(u);return u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':m.files[u.split('?')[0]]?.h || ''}})},self:{location:{origin:'https://local'},addEventListener:(t,f)=>listeners[t]=f,skipWaiting:()=>skips.push(1),clients:{claim:async()=>{},matchAll:async()=>[{id:'old',postMessage:x=>messages.push(x)}]}}};
|
||||
vm.runInNewContext(fs.readFileSync(__dirname+'/sw.js','utf8'),sandbox);
|
||||
async function dispatch(t,e={}){let p;listeners[t]({...e,waitUntil:v=>p=v});await p}
|
||||
async function request(url,mode='cors',clientId='new'){let p;listeners.fetch({request:{url:'https://local'+url,method:'GET',mode},clientId,respondWith:v=>p=v});return p}
|
||||
return {storage,m,skips,dispatch,request};
|
||||
return {storage,m,skips,fetches,dispatch,request};
|
||||
}
|
||||
test('install does not activate or publish; CACHE_STATUS is honest; activation commits and exact requests self-heal',async()=>{const e=environment();await e.dispatch('install');assert.equal(e.skips.length,0);const cache=await e.storage.open(core.CACHE);assert.equal(await core.state(cache),null);let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);await e.dispatch('activate');assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await e.request('/playlist/x','navigate')).headers.get('X-Asset-Hash'),'index');await cache.delete('/app.js?v=app');await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,false);assert.equal(reply.missing,1);assert.equal(reply.version,'next');assert.equal((await e.request('/app.js')).headers.get('X-Asset-Hash'),'app');assert.ok(await cache.match('/app.js?v=app'));assert.equal(await cache.match('/app.js?v=stale'),undefined);assert.equal((await e.request('/app.js?v=stale')).status,409);});
|
||||
test('reported playback defers explicit activation until pause; install never calls skipWaiting',async()=>{const e=environment(),source={id:'p'};await e.dispatch('message',{data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:true}});await e.dispatch('message',{source,data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:false}});assert.equal(e.skips.length,1);});
|
||||
|
||||
test('staged same-contract responses use verified N-1 without poisoning the new URL',async()=>{
|
||||
const e=environment(); const cache=await e.storage.open(core.CACHE);
|
||||
const old={buildTag:'old',files:{'/extra.js':{h:'old'}},groups:{core:{files:[],contract:1},'feature:extra':{files:['/extra.js'],contract:1,background:true}}};
|
||||
e.m.groups.core.contract=1; e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true}; e.m.files['/extra.js']={h:'new'};
|
||||
await cache.put(core.STATE,Response.json({current:old})); await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old'}}));
|
||||
await e.dispatch('install'); await e.dispatch('activate');
|
||||
assert.equal(await cache.match('/extra.js?v=new'),undefined);
|
||||
const stale=await e.request('/extra.js?v=new'); assert.equal(await stale.text(),'old body');assert.equal(stale.headers.get('X-Asset-Hash'),'old');assert.equal(await cache.match('/extra.js?v=new'),undefined);
|
||||
await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:true}}); assert.equal(await cache.match('/extra.js?v=new'),undefined);
|
||||
await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}); assert.equal((await cache.match('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
|
||||
assert.equal((await e.request('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
|
||||
});
|
||||
|
||||
test('a changed background contract is downloaded before ready and cannot fall back after commit',async()=>{
|
||||
const e=environment(),cache=await e.storage.open(core.CACHE);
|
||||
const old={buildTag:'old',files:{'/extra.js':{h:'old'}},groups:{core:{files:[],contract:1},'feature:extra':{files:['/extra.js'],contract:1,background:true}}};
|
||||
e.m.groups.core.contract=1;e.m.groups['feature:extra']={files:['/extra.js'],contract:2,background:true};e.m.files['/extra.js']={h:'new'};
|
||||
await cache.put(core.STATE,Response.json({current:old}));await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old'}}));
|
||||
await e.dispatch('install');assert.equal((await core.state(cache)).current.buildTag,'old');
|
||||
assert.equal((await cache.match('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
|
||||
let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);
|
||||
await e.dispatch('activate');await cache.delete('/extra.js?v=new');
|
||||
const response=await e.request('/extra.js?v=new');assert.equal(response.headers.get('X-Asset-Hash'),'new');assert.notEqual(await response.text(),'old body');
|
||||
});
|
||||
|
||||
test('overlapping idle warm messages share one download pool',async()=>{
|
||||
const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true};
|
||||
await e.dispatch('install');await e.dispatch('activate');
|
||||
await Promise.all([e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}),e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}})]);
|
||||
assert.equal(e.fetches.filter(url=>url==='/extra.js?v=extra').length,1);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"/styles.css",
|
||||
"/service-ui.css",
|
||||
"/refresh.css",
|
||||
"/layout-base.css",
|
||||
"/recommendations.css",
|
||||
"/flag-ui.js",
|
||||
"/async-guard.js",
|
||||
@@ -51,18 +52,26 @@
|
||||
"/fonts/HankenGrotesk-latin-ext.woff2",
|
||||
"/fonts/HankenGrotesk-latin.woff2",
|
||||
"/fonts/JetBrainsMono-latin-ext.woff2",
|
||||
"/fonts/JetBrainsMono-latin.woff2"
|
||||
]
|
||||
"/fonts/JetBrainsMono-latin.woff2",
|
||||
"/lazy.js",
|
||||
"/feature-bootstrap.js",
|
||||
"/eq-parameters.js",
|
||||
"/assets.json",
|
||||
"/device-db.js"
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"layout:glass-stage": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/theme-glass.css",
|
||||
"/theme-glass-controls.css",
|
||||
"/glass-controls.js",
|
||||
"/theme-glass-panel.css",
|
||||
"/glass-panel-layout.js"
|
||||
],
|
||||
"background": true,
|
||||
"dependencies": [
|
||||
"feature:layout-controls"
|
||||
]
|
||||
},
|
||||
"layout:classic": {
|
||||
@@ -70,7 +79,8 @@
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/theme-classic.css"
|
||||
]
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"layout:bento-hub": {
|
||||
"contract": 1,
|
||||
@@ -80,6 +90,10 @@
|
||||
"/bento-hub.js",
|
||||
"/fonts/PlusJakartaSans-latin.woff2",
|
||||
"/fonts/PlusJakartaSans-latin-ext.woff2"
|
||||
],
|
||||
"background": true,
|
||||
"dependencies": [
|
||||
"feature:layout-controls"
|
||||
]
|
||||
},
|
||||
"layout:f7-swipe": {
|
||||
@@ -87,9 +101,10 @@
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/theme-f7.css",
|
||||
"/f7-layout.js",
|
||||
"/vendor/framework7-swipe.min.js"
|
||||
]
|
||||
"/vendor/framework7-swipe.min.js",
|
||||
"/f7-layout.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:piano": {
|
||||
"contract": 1,
|
||||
@@ -98,28 +113,32 @@
|
||||
"/piano-core.js",
|
||||
"/piano.js",
|
||||
"/piano-engine.mjs"
|
||||
]
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:midi": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/midi.js"
|
||||
]
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:party-dj": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/party-dj.js"
|
||||
]
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:lyrics-window": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/lyrics-window.js"
|
||||
]
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:lower-third": {
|
||||
"contract": 1,
|
||||
@@ -127,28 +146,32 @@
|
||||
"files": [
|
||||
"/lower-third.js",
|
||||
"/lower-third.css"
|
||||
]
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:vocal-reducer": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/vocal-reducer.js"
|
||||
]
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:eq-core": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/eq-core.js"
|
||||
]
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:setlist-import": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/setlist-import.js"
|
||||
]
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:car-mode": {
|
||||
"contract": 1,
|
||||
@@ -156,19 +179,20 @@
|
||||
"files": [
|
||||
"/car-mode.js",
|
||||
"/car-mode.css"
|
||||
]
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:p2p": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/p2p-client.js",
|
||||
"/p2p-core.js",
|
||||
"/p2p-client.js",
|
||||
"/p2p-transfer.js",
|
||||
"/p2p-recv-worker.js",
|
||||
"/hash-worker.js",
|
||||
"/device-db.js"
|
||||
]
|
||||
"/hash-worker.js"
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:direct": {
|
||||
"contract": 1,
|
||||
@@ -179,6 +203,10 @@
|
||||
"/direct-media.js",
|
||||
"/direct-media.css",
|
||||
"/direct-recv-worker.js"
|
||||
],
|
||||
"background": true,
|
||||
"dependencies": [
|
||||
"feature:p2p"
|
||||
]
|
||||
},
|
||||
"feature:video-edit": {
|
||||
@@ -186,21 +214,24 @@
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/video-edit.js"
|
||||
]
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:stats": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/stats-core.js"
|
||||
]
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:export": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/export.js"
|
||||
]
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"feature:playlist": {
|
||||
"contract": 1,
|
||||
@@ -208,7 +239,8 @@
|
||||
"files": [],
|
||||
"embeddedIn": [
|
||||
"/app.js"
|
||||
]
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"feature:settings": {
|
||||
"contract": 1,
|
||||
@@ -218,7 +250,8 @@
|
||||
"/app.js",
|
||||
"/settings-sections.js",
|
||||
"/settings-sections.css"
|
||||
]
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"feature:service-mode": {
|
||||
"contract": 1,
|
||||
@@ -227,7 +260,8 @@
|
||||
"embeddedIn": [
|
||||
"/app.js",
|
||||
"/service-ui.css"
|
||||
]
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"feature:lyrics-editor": {
|
||||
"contract": 1,
|
||||
@@ -237,7 +271,47 @@
|
||||
"/app.js",
|
||||
"/lyrics-core.js",
|
||||
"/lyrics-display.js"
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"feature:layout-controls": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/theme-glass-controls.css",
|
||||
"/glass-controls.js"
|
||||
]
|
||||
},
|
||||
"layout:sanctuary-stage": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
},
|
||||
"layout:liturgical": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
},
|
||||
"layout:living-hymnal": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
},
|
||||
"layout:broadcaster": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
},
|
||||
"layout:contemplative": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
function configure(options) {
|
||||
adapter = options; const doc = root.document, Recognition = root.SpeechRecognition || root.webkitSpeechRecognition;
|
||||
if (Recognition) {
|
||||
const b = doc.createElement('button'); b.type = 'button'; b.className = 'icon-btn voice-search'; b.title = 'Voice search'; b.setAttribute('aria-label', 'Voice search'); b.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></svg>'; adapter.input.after(b);
|
||||
const b = doc.getElementById('voiceSearchBtn') || doc.createElement('button'); b.id='voiceSearchBtn'; b.type = 'button'; b.className = 'icon-btn voice-search'; b.title = 'Voice search'; b.setAttribute('aria-label', 'Voice search'); b.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></svg>'; if(!b.isConnected)adapter.input.after(b);
|
||||
let recognition, listening = false;
|
||||
b.onclick = () => { if (listening) { recognition.abort(); return; } recognition = new Recognition(); recognition.lang = doc.documentElement.lang || root.navigator.language || 'en-US'; recognition.continuous = false; recognition.interimResults = false; recognition.onstart = () => { listening = true; b.setAttribute('aria-pressed', 'true'); b.title = 'Listening… tap to cancel'; }; recognition.onend = () => { listening = false; b.setAttribute('aria-pressed', 'false'); b.title = 'Voice search'; }; recognition.onerror = e => adapter.toast(`Voice search: ${e.error === 'not-allowed' ? 'microphone permission was denied' : e.error}`); recognition.onresult = e => { const query = transcript(e); if (query) { adapter.input.value = query; adapter.search(); } }; try { recognition.start(); } catch (error) { adapter.toast(error.message); } };
|
||||
}
|
||||
const car = doc.createElement('button'); car.type = 'button'; car.className = 'icon-btn'; car.title = 'Car mode'; car.setAttribute('aria-label', 'Car mode'); car.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="m5 8 2-5h10l2 5M3 8h18v10H3zM5 18v3m14-3v3M6 12h2m8 0h2"/></svg>'; doc.getElementById('searchForm').after(car); car.onclick = open;
|
||||
const car = doc.getElementById('carModeBtn') || doc.createElement('button'); car.id='carModeBtn'; car.type = 'button'; car.className = 'icon-btn'; car.title = 'Car mode'; car.setAttribute('aria-label', 'Car mode'); car.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="m5 8 2-5h10l2 5M3 8h18v10H3zM5 18v3m14-3v3M6 12h2m8 0h2"/></svg>'; if(!car.isConnected)doc.getElementById('searchForm').after(car); car.onclick = open;
|
||||
root.SettingsSections.register({ id: 'voice-car', title: 'Voice & Car mode', cluster: 'Listening', summary: () => Recognition ? 'Voice search available · Car mode' : 'Car mode · voice unavailable', render(container) { const p = doc.createElement('p'); p.textContent = Recognition ? 'Use the microphone beside Search to speak a query. Recognition availability and processing depend on your browser.' : 'Voice search is unavailable in this browser.'; const b = doc.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Enter Car mode'; b.onclick = open; container.append(p, b); } });
|
||||
}
|
||||
const api = { transcript, configure, open, close }; if (typeof module !== 'undefined') module.exports = api; else root.CarMode = api;
|
||||
|
||||
@@ -1,2 +1,26 @@
|
||||
const { test } = require('node:test'), assert = require('node:assert/strict'); const { transcript } = require('./car-mode');
|
||||
test('voice search submits only final results from the result index', () => { const interim = [{transcript:'wrong'}]; interim.isFinal = false; const final = [{transcript:'Amazing Grace'}]; final.isFinal = true; assert.equal(transcript({results:[interim, final]}), 'Amazing Grace'); assert.equal(transcript({resultIndex:2,results:[final,final]}), ''); assert.equal(transcript({}), ''); });
|
||||
|
||||
test('lazy Car mode keeps four independent transport buttons and the voice entry in place', () => {
|
||||
const vm=require('node:vm'),fs=require('node:fs');
|
||||
class Element {
|
||||
constructor(tag){this.tag=tag;this.children=[];this.inert=false;}
|
||||
append(...nodes){for(const node of nodes){node.remove();node.parent=this;this.children.push(node);}}
|
||||
remove(){if(this.parent){this.parent.children=this.parent.children.filter(n=>n!==this);this.parent=null;}}
|
||||
after(node){this.parent.append(node);}
|
||||
setAttribute(name,value){this[name]=value;}
|
||||
focus(){}
|
||||
get isConnected(){return !!this.parent;}
|
||||
}
|
||||
const body=new Element('body'),input=new Element('input'),form=new Element('form'),voice=new Element('button');form.id='searchForm';voice.id='voiceSearchBtn';body.append(form,input,voice);
|
||||
const all=node=>[node,...node.children.flatMap(all)],doc={body,documentElement:{lang:'en'},createElement:tag=>new Element(tag),getElementById:id=>all(body).find(n=>n.id===id)};
|
||||
let section;const actions=[],root={document:doc,navigator:{},SpeechRecognition:function(){},SettingsSections:{register:s=>section=s}};
|
||||
vm.runInNewContext(fs.readFileSync(__dirname+'/car-mode.js','utf8'),{window:root,setInterval:()=>1,clearInterval:()=>{}});
|
||||
root.CarMode.configure({input,action:action=>actions.push(action),state:()=>({paused:true,title:'Song'})});
|
||||
root.CarMode.open();const overlay=body.children.find(n=>n.className==='car-mode'),controls=overlay.children.find(n=>n.className==='car-controls');
|
||||
assert.equal(controls.children.length,4);assert.equal(new Set(controls.children).size,4);assert.equal(voice.parent,body);
|
||||
controls.children.forEach(b=>b.onclick());assert.deepEqual(actions,['prev','toggle','back','next']);
|
||||
root.CarMode.close();assert.equal(voice.parent,body);assert.equal(body.inert,false);
|
||||
const panel=new Element('section');body.append(panel);section.render(panel);
|
||||
assert.equal(voice.parent,body,'settings must not steal the voice entry');assert.equal(panel.children.at(-1).textContent,'Enter Car mode');
|
||||
});
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
s.preview = m.file.streamMime ? root.DirectStream?.create(m.file.streamMime, m.file.title) : null;
|
||||
const wait = waiting.get(m.file.id); if (wait) clearTimeout(wait.timer);
|
||||
send(key, { type: 'direct', action: 'accept', token: m.token });
|
||||
s.worker = new Worker('/direct-recv-worker.js');
|
||||
s.worker = window.Lazy ? window.Lazy.worker('/direct-recv-worker.js') : new Worker('/direct-recv-worker.js');
|
||||
s.worker.onmessage = async e => {
|
||||
const result = e.data;
|
||||
if (result.op === 'opened') {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use strict';
|
||||
importScripts('/sha256.js');
|
||||
let dir, handle, access, hash, pos = 0, claim, name;
|
||||
let chain = Promise.resolve();
|
||||
self.onmessage = e => { chain = chain.then(() => run(e.data)); };
|
||||
async function close() { if (access) { access.flush(); access.close(); access = null; } }
|
||||
async function run(m) {
|
||||
try {
|
||||
if (!self.Sha256) { self.__ASSET_URLS__=m.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); }
|
||||
if (m.op === 'open') {
|
||||
claim = m.file;
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(claim.id) || !/^[a-f0-9]{64}$/.test(claim.cid)) throw Error('Invalid file identity');
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
/* Shared live/offline EQ definitions and legacy curve migration. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const frequencies = [31,62,125,250,500,1000,2000,4000,8000,16000];
|
||||
const bands = frequencies.map((f,i) => ({ f, label: f >= 1000 ? `${f/1000}k` : String(f), type: i === 0 ? 'lowshelf' : i === 9 ? 'highshelf' : 'peaking' }));
|
||||
const clamp = n => Math.max(-12, Math.min(12, Number(n) || 0));
|
||||
function migrate(values) {
|
||||
if (!Array.isArray(values)) return frequencies.map(() => 0);
|
||||
if (values.length !== 5) return frequencies.map((_,i) => clamp(values[i]));
|
||||
const old = [60,250,1000,4000,12000], curve = values.map(clamp);
|
||||
return frequencies.map(f => { if (f <= old[0]) return curve[0]; if (f >= old[4]) return curve[4]; const i = old.findIndex(x => x >= f), weight = Math.log(f / old[i-1]) / Math.log(old[i] / old[i-1]); return Math.round((curve[i-1] * (1-weight) + curve[i] * weight) * 10) / 10; });
|
||||
}
|
||||
const presets = {
|
||||
flat: { name: 'Flat', g: frequencies.map(() => 0) },
|
||||
bass: { name: 'Bass boost', g: [5,7,5,3,1,0,0,0,0,0] },
|
||||
vocal: { name: 'Vocal clarity', g: [-3,-4,-2,0,1,2,3,4,1,0] },
|
||||
acoustic: { name: 'Acoustic live', g: [-2,-1,0,-2,-1,1,2,2,1,0] },
|
||||
classical: { name: 'Classical', g: migrate([4,2,-1,1,3]) },
|
||||
treble: { name: 'Treble boost', g: [0,0,0,0,0,0,1,3,5,6] },
|
||||
};
|
||||
const width = value => Math.max(0, Math.min(100, Number(value) || 0));
|
||||
const { bands, presets, migrate, width } = typeof module !== 'undefined' ? require('./eq-parameters') : root.EqParameters;
|
||||
function createWidener(context, source, value = 0) {
|
||||
const splitter = context.createChannelSplitter(2), output = context.createChannelMerger(2), delay = context.createDelay(.05);
|
||||
source.connect(splitter); splitter.connect(output, 0, 0); splitter.connect(delay, 1); delay.connect(output, 0, 1);
|
||||
|
||||
25
frontend/eq-parameters.js
Normal file
25
frontend/eq-parameters.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/* Eager settings values; audio graphs remain in lazy feature groups. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const frequencies = [31,62,125,250,500,1000,2000,4000,8000,16000];
|
||||
const bands = frequencies.map((f,i) => ({ f, label: f >= 1000 ? `${f/1000}k` : String(f), type: i === 0 ? 'lowshelf' : i === 9 ? 'highshelf' : 'peaking' }));
|
||||
const clamp = n => Math.max(-12, Math.min(12, Number(n) || 0));
|
||||
function migrate(values) {
|
||||
if (!Array.isArray(values)) return frequencies.map(() => 0);
|
||||
if (values.length !== 5) return frequencies.map((_,i) => clamp(values[i]));
|
||||
const old = [60,250,1000,4000,12000], curve = values.map(clamp);
|
||||
return frequencies.map(f => { if (f <= old[0]) return curve[0]; if (f >= old[4]) return curve[4]; const i = old.findIndex(x => x >= f), weight = Math.log(f / old[i-1]) / Math.log(old[i] / old[i-1]); return Math.round((curve[i-1] * (1-weight) + curve[i] * weight) * 10) / 10; });
|
||||
}
|
||||
const presets = {
|
||||
flat: { name: 'Flat', g: frequencies.map(() => 0) },
|
||||
bass: { name: 'Bass boost', g: [5,7,5,3,1,0,0,0,0,0] },
|
||||
vocal: { name: 'Vocal clarity', g: [-3,-4,-2,0,1,2,3,4,1,0] },
|
||||
acoustic: { name: 'Acoustic live', g: [-2,-1,0,-2,-1,1,2,2,1,0] },
|
||||
classical: { name: 'Classical', g: migrate([4,2,-1,1,3]) },
|
||||
treble: { name: 'Treble boost', g: [0,0,0,0,0,0,1,3,5,6] },
|
||||
};
|
||||
const width = value => Math.max(0, Math.min(100, Number(value) || 0));
|
||||
const amount = value => Math.max(0, Math.min(100, Number.isFinite(Number(value)) ? Number(value) : 100));
|
||||
const api = { bands, presets, migrate, width, amount };
|
||||
if (typeof module !== 'undefined') module.exports = api; else root.EqParameters = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
function loadF7(){
|
||||
if(root.Framework7)return Promise.resolve(root.Framework7);
|
||||
if(!loading)loading=new Promise((res,rej)=>{const s=doc.createElement('script');s.src=F7_SRC;s.onload=()=>res(root.Framework7);s.onerror=()=>{loading=null;rej(new Error('Framework7 failed to load'));};doc.head.append(s);});
|
||||
if(!loading)loading=new Promise((res,rej)=>{const s=doc.createElement('script');s.src=root.Lazy?.url('/vendor/framework7-swipe.min.js')||F7_SRC;s.onload=()=>res(root.Framework7);s.onerror=()=>{loading=null;rej(new Error('Framework7 failed to load'));};doc.head.append(s);});
|
||||
return loading;
|
||||
}
|
||||
function ensureApp(){
|
||||
|
||||
77
frontend/feature-bootstrap.js
Normal file
77
frontend/feature-bootstrap.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/* Thin eager entries and configuration for optional, stateful feature modules. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const Lazy = root.Lazy, doc = root.document;
|
||||
if (!Lazy) return;
|
||||
const specs = [
|
||||
['Piano','piano','configure','piano','Piano transcription','Live & worship','Off · load model on request'],
|
||||
['MidiPedals','midi','configure','midi','MIDI pedalboard','Live & worship','Off'],
|
||||
['PartyDJ','party-dj','register','party-dj','Watch party','Live & worship','Shared suggestions · host approval'],
|
||||
['LyricsWindow','lyrics-window','configure','floating-lyrics','Floating lyrics','Live & worship','Floating lyrics window'],
|
||||
['LowerThird','lower-third','register','broadcast','Presenter & broadcast','Live & worship','Projector · OBS lower third'],
|
||||
['EqCore','eq-core','register','equalizer','Equalizer & stereo','Listening','Flat · Original stereo'],
|
||||
['VocalReducer','vocal-reducer','register','vocal-reducer','Vocal reducer','Listening','100% · centre-panned vocals'],
|
||||
['SetlistImport','setlist-import','configure','setlist-import','Setlist import','Library & storage','Planning Center · SongSelect · ChordPro'],
|
||||
['CarMode','car-mode','configure','voice-car','Voice & Car mode','Listening','Voice search · Car mode'],
|
||||
['DirectMedia','direct','configure','direct-transfer','Direct device transfer','Library & storage','Paired devices only'],
|
||||
];
|
||||
function button(id, text, anchor, name, action, className='np-btn') {
|
||||
if (!anchor || doc.getElementById(id)) return;
|
||||
const b=doc.createElement('button'); b.id=id; b.type='button'; b.className=className; b.textContent=text; b.setAttribute('aria-label',text);
|
||||
if(id==='carModeBtn')b.innerHTML='<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="m5 8 2-5h10l2 5M3 8h18v10H3zM5 18v3m14-3v3M6 12h2m8 0h2"/></svg>';
|
||||
if(id==='voiceSearchBtn')b.innerHTML='<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></svg>';
|
||||
if(id.startsWith('floating-')){b.title='Open floating lyrics';b.setAttribute('aria-label','Open floating lyrics window');}
|
||||
b.title ||= text;
|
||||
b.onclick=()=>Lazy.load(name).then(action).catch(error=>root.dispatchEvent(new CustomEvent('ytp-lazy-error',{detail:{error}})));
|
||||
anchor.after(b); Lazy.intent(b,name);
|
||||
}
|
||||
for (const [globalName, suffix, setup, id, title, cluster, summary] of specs) {
|
||||
const group='feature:'+suffix;
|
||||
const facade=Lazy.proxy(group,['open','close','obtain','publish','attach','handle','offer','paint','fade'],globalName);
|
||||
let configured=false;
|
||||
facade[setup]=options=>{
|
||||
if (configured) return; configured=true;
|
||||
Lazy.onLoad(group,()=>root[globalName][setup](options));
|
||||
root.SettingsSections.register({id,title,cluster,lazyGroup:group,summary:()=>{
|
||||
const s=options?.settings?.() || {};
|
||||
if(globalName==='Piano')return s.pianoEnabled?'On · browser or optional worker':'Off · load model on request';
|
||||
if(globalName==='MidiPedals')return s.midiEnabled?'On · pedal mappings':'Off';
|
||||
if(globalName==='EqCore')return `${root.EqParameters.presets[s.eq?.preset]?.name || 'Custom'} · ${root.EqParameters.width(s.stereoWidth)?'Widened':'Original stereo'}`;
|
||||
if(globalName==='VocalReducer')return `${root.EqParameters.amount(s.vocalLevel ?? 100)}% · centre-panned vocals`;
|
||||
if(globalName==='DirectMedia')return facade.enabled()?'On · paired devices only':'Off or unsupported';
|
||||
if(globalName==='CarMode')return root.SpeechRecognition||root.webkitSpeechRecognition?'Voice search available · Car mode':'Car mode · voice unavailable';
|
||||
if(globalName==='LyricsWindow')return root.documentPictureInPicture?.requestWindow?'Interactive window available':root.HTMLCanvasElement?.prototype.captureStream&&doc.pictureInPictureEnabled?'Lyric video PiP available':'Not supported on this browser';
|
||||
return summary;
|
||||
},
|
||||
render(container,context) {
|
||||
let disposed=false, cleanup;
|
||||
container.textContent='Loading…';
|
||||
Lazy.load(group).then(()=>{
|
||||
if (disposed || !container.isConnected) return;
|
||||
container.replaceChildren(); cleanup=root.SettingsSections.get(id).render(container,context);
|
||||
}).catch(error=>{if(!disposed)container.textContent=error.message;});
|
||||
return ()=>{disposed=true; if(typeof cleanup==='function')cleanup();};
|
||||
},
|
||||
});
|
||||
if (globalName==='CarMode') {
|
||||
button('carModeBtn','Car mode',doc.getElementById('searchForm'),group,()=>root.CarMode.open(),'icon-btn');
|
||||
if(root.SpeechRecognition||root.webkitSpeechRecognition)button('voiceSearchBtn','Voice search',options.input,group,()=>doc.getElementById('voiceSearchBtn').click(),'icon-btn voice-search');
|
||||
}
|
||||
if (globalName==='LyricsWindow' && (root.documentPictureInPicture?.requestWindow || (root.HTMLCanvasElement?.prototype.captureStream && doc.pictureInPictureEnabled))) {
|
||||
for(const anchor of ['stageLyricsAuto','serviceAutoscrollBtn','pipBtn'])button('floating-'+anchor,'⧉ Lyrics',doc.getElementById(anchor),group,()=>doc.getElementById('floating-'+anchor).click());
|
||||
}
|
||||
};
|
||||
if(globalName==='LowerThird') {
|
||||
facade.active=()=>new URLSearchParams(root.location.search).get('overlay')==='lowerthird';
|
||||
facade.url=(origin,code)=>{const u=new URL('/',origin);u.searchParams.set('overlay','lowerthird');u.searchParams.set('code',String(code));return u.href;};
|
||||
}
|
||||
if(globalName==='DirectMedia') {
|
||||
let settings=()=>({}); const configure=facade.configure;
|
||||
facade.configure=options=>{settings=options.settings;configure(options);};
|
||||
facade.enabled=()=>settings().directTransfer!==false && !!root.RTCPeerConnection;
|
||||
facade.hasSource=()=>false;
|
||||
}
|
||||
if(globalName==='LyricsWindow')facade.element=()=>null;
|
||||
root[globalName]=facade;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -6,11 +6,11 @@
|
||||
^ * Out: { ok: true, sha256, size, engine } | { ok: false, error }
|
||||
* ========================================================================== */
|
||||
'use strict';
|
||||
importScripts('/sha256.js');
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const { name } = e.data || {};
|
||||
try {
|
||||
if (!self.Sha256) { self.__ASSET_URLS__=e.data.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); }
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const dir = await root.getDirectoryHandle('videos');
|
||||
const file = await (await dir.getFileHandle(String(name))).getFile();
|
||||
|
||||
@@ -19,27 +19,21 @@
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="preload" href="fonts/BricolageGrotesque-latin.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="preload" href="fonts/HankenGrotesk-latin.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<script type="application/json" id="ytp-assets">{"buildTag":"static","groups":{"core":{"contract":1,"eager":true,"files":["/index.html","/styles.css","/service-ui.css","/refresh.css","/layout-base.css","/recommendations.css","/flag-ui.js","/async-guard.js","/visible-timer.js","/asset-sync-core.js","/sw-update.js","/fingerprint.js","/opfs.js","/opfs-worker.js","/lyrics-core.js","/lyrics-display.js","/sha256.js","/resume-core.js","/loudness-wasm.js","/loudness.js","/sha256-wasm.js","/settings-sections.js","/related-videos.js","/related-videos.css","/audio-continuity.js","/fullscreen-orientation.js","/fullscreen-orientation.css","/download-actions.js","/download-actions.css","/download-errors.js","/downloads.js","/downloads-page.js","/offline-pages.css","/saved-page.js","/server-backfill.js","/saved-page.css","/prewarm-next.js","/settings-sections.css","/app.js","/manifest.webmanifest","/icons/icon-192.png","/icons/icon-512.png","/fonts/fonts.css","/fonts/BricolageGrotesque-latin-ext.woff2","/fonts/BricolageGrotesque-latin.woff2","/fonts/HankenGrotesk-latin-ext.woff2","/fonts/HankenGrotesk-latin.woff2","/fonts/JetBrainsMono-latin-ext.woff2","/fonts/JetBrainsMono-latin.woff2","/lazy.js","/feature-bootstrap.js","/eq-parameters.js","/assets.json","/device-db.js"],"background":false},"layout:glass-stage":{"contract":1,"eager":false,"files":["/theme-glass.css","/theme-glass-panel.css","/glass-panel-layout.js"],"background":true,"dependencies":["feature:layout-controls"]},"layout:classic":{"contract":1,"eager":false,"files":["/theme-classic.css"],"background":true},"layout:bento-hub":{"contract":1,"eager":false,"files":["/theme-bento-hub.css","/bento-hub.js","/fonts/PlusJakartaSans-latin.woff2","/fonts/PlusJakartaSans-latin-ext.woff2"],"background":true,"dependencies":["feature:layout-controls"]},"layout:f7-swipe":{"contract":1,"eager":false,"files":["/theme-f7.css","/vendor/framework7-swipe.min.js","/f7-layout.js"],"background":true},"feature:piano":{"contract":1,"eager":false,"files":["/piano-core.js","/piano.js","/piano-engine.mjs"],"background":true},"feature:midi":{"contract":1,"eager":false,"files":["/midi.js"],"background":true},"feature:party-dj":{"contract":1,"eager":false,"files":["/party-dj.js"],"background":true},"feature:lyrics-window":{"contract":1,"eager":false,"files":["/lyrics-window.js"],"background":true},"feature:lower-third":{"contract":1,"eager":false,"files":["/lower-third.js","/lower-third.css"],"background":true},"feature:vocal-reducer":{"contract":1,"eager":false,"files":["/vocal-reducer.js"],"background":true},"feature:eq-core":{"contract":1,"eager":false,"files":["/eq-core.js"],"background":true},"feature:setlist-import":{"contract":1,"eager":false,"files":["/setlist-import.js"],"background":true},"feature:car-mode":{"contract":1,"eager":false,"files":["/car-mode.js","/car-mode.css"],"background":true},"feature:p2p":{"contract":1,"eager":false,"files":["/p2p-core.js","/p2p-client.js","/p2p-transfer.js","/p2p-recv-worker.js","/hash-worker.js"],"background":true},"feature:direct":{"contract":1,"eager":false,"files":["/direct-protocol.js","/direct-stream.js","/direct-media.js","/direct-media.css","/direct-recv-worker.js"],"background":true,"dependencies":["feature:p2p"]},"feature:video-edit":{"contract":1,"eager":false,"files":["/video-edit.js"],"background":true},"feature:stats":{"contract":1,"eager":false,"files":["/stats-core.js"],"background":true},"feature:export":{"contract":1,"eager":false,"files":["/export.js"],"background":true},"feature:playlist":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js"],"background":false},"feature:settings":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/settings-sections.js","/settings-sections.css"],"background":false},"feature:service-mode":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/service-ui.css"],"background":false},"feature:lyrics-editor":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/lyrics-core.js","/lyrics-display.js"],"background":false},"feature:layout-controls":{"contract":1,"eager":false,"background":true,"files":["/theme-glass-controls.css","/glass-controls.js"]},"layout:sanctuary-stage":{"contract":1,"eager":false,"background":true,"files":[]},"layout:liturgical":{"contract":1,"eager":false,"background":true,"files":[]},"layout:living-hymnal":{"contract":1,"eager":false,"background":true,"files":[]},"layout:broadcaster":{"contract":1,"eager":false,"background":true,"files":[]},"layout:contemplative":{"contract":1,"eager":false,"background":true,"files":[]}},"files":{}}</script>
|
||||
<script src="lazy.js"></script>
|
||||
<link rel="stylesheet" href="fonts/fonts.css" />
|
||||
<link rel="preconnect" href="https://i.ytimg.com" crossorigin />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<link rel="stylesheet" href="service-ui.css" />
|
||||
<link rel="stylesheet" href="refresh.css" />
|
||||
<link rel="stylesheet" href="theme-glass.css" />
|
||||
<link rel="stylesheet" href="theme-classic.css" />
|
||||
<link rel="stylesheet" href="layout-base.css" />
|
||||
<link rel="stylesheet" href="recommendations.css" />
|
||||
<link rel="stylesheet" href="settings-sections.css" />
|
||||
<link rel="stylesheet" href="direct-media.css" />
|
||||
<link rel="stylesheet" href="lower-third.css" />
|
||||
<link rel="stylesheet" href="car-mode.css" />
|
||||
<link rel="stylesheet" href="offline-pages.css" />
|
||||
<link rel="stylesheet" href="saved-page.css" />
|
||||
<link rel="stylesheet" href="theme-glass-controls.css" />
|
||||
<link rel="stylesheet" href="theme-glass-panel.css" />
|
||||
<link rel="stylesheet" href="fullscreen-orientation.css" />
|
||||
<link rel="stylesheet" href="related-videos.css" />
|
||||
<link rel="stylesheet" href="download-actions.css" />
|
||||
<link rel="stylesheet" href="theme-bento-hub.css" />
|
||||
<link rel="stylesheet" href="theme-f7.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
@@ -650,20 +644,14 @@
|
||||
|
||||
<script src="fingerprint.js"></script>
|
||||
<script src="opfs.js"></script>
|
||||
<script src="video-edit.js"></script>
|
||||
<script src="lyrics-core.js"></script>
|
||||
<script src="lyrics-display.js"></script>
|
||||
<script src="flag-ui.js"></script>
|
||||
<script src="export.js"></script>
|
||||
<script src="stats-core.js"></script>
|
||||
<script src="sha256-wasm.js"></script>
|
||||
<script src="sha256.js"></script>
|
||||
<script src="loudness-wasm.js"></script>
|
||||
<script src="loudness.js"></script>
|
||||
<script src="device-db.js"></script>
|
||||
<script src="p2p-client.js"></script>
|
||||
<script src="p2p-core.js"></script>
|
||||
<script src="p2p-transfer.js"></script>
|
||||
<script src="async-guard.js"></script>
|
||||
<script src="asset-sync-core.js"></script>
|
||||
<script src="sw-update.js"></script>
|
||||
@@ -671,31 +659,16 @@
|
||||
<script src="audio-continuity.js"></script>
|
||||
<script src="fullscreen-orientation.js"></script>
|
||||
<script src="settings-sections.js"></script>
|
||||
<script src="direct-protocol.js"></script>
|
||||
<script src="direct-stream.js"></script>
|
||||
<script src="direct-media.js"></script>
|
||||
<script src="download-actions.js"></script>
|
||||
<script src="download-errors.js"></script>
|
||||
<script src="downloads.js"></script>
|
||||
<script src="downloads-page.js"></script>
|
||||
<script src="saved-page.js"></script>
|
||||
<script src="server-backfill.js"></script>
|
||||
<script src="glass-controls.js"></script>
|
||||
<script src="bento-hub.js"></script>
|
||||
<script src="f7-layout.js"></script>
|
||||
<script src="glass-panel-layout.js"></script>
|
||||
<script src="party-dj.js"></script>
|
||||
<script src="lyrics-window.js"></script>
|
||||
<script src="midi.js"></script>
|
||||
<script src="lower-third.js"></script>
|
||||
<script src="vocal-reducer.js"></script>
|
||||
<script src="eq-core.js"></script>
|
||||
<script src="piano-core.js"></script>
|
||||
<script src="piano.js"></script>
|
||||
<script src="setlist-import.js"></script>
|
||||
<script src="car-mode.js"></script>
|
||||
<script src="visible-timer.js"></script>
|
||||
<script src="prewarm-next.js"></script>
|
||||
<script src="eq-parameters.js"></script>
|
||||
<script src="feature-bootstrap.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
11
frontend/layout-base.css
Normal file
11
frontend/layout-base.css
Normal file
@@ -0,0 +1,11 @@
|
||||
/* Shared markup defaults stay eager when individual layouts load on demand. */
|
||||
.stg-overlay, .skip10, .deck-chips, .vol-pct, .mode-name { display: none; }
|
||||
.np-btn .ti, .np-btn .tl { pointer-events: none; }
|
||||
.np-btn .ti { width: 20px; height: 20px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.np-btn .i-done { display: none; }
|
||||
.np-btn.done .i-save { display: none; }
|
||||
.np-btn.done .i-done { display: inline; }
|
||||
.stg-bot, .ch-badge, .upnext-clear, .list-sub, .p2p-strip { display: none; }
|
||||
.sleep-status .sl-ic, .sleep-status .sl-sub { display: none; }
|
||||
.ab-group { display: contents; }
|
||||
.mc-tune { display: none; }
|
||||
142
frontend/lazy.js
Normal file
142
frontend/lazy.js
Normal file
@@ -0,0 +1,142 @@
|
||||
/* Build-local classic-script loader. The inert manifest precedes this head script. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const doc = root.document;
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(doc.getElementById('ytp-assets').textContent); }
|
||||
catch { return; } // The index must supply this map; never fetch one during boot.
|
||||
const assets = new Map(), groups = new Map(), hooks = new Map();
|
||||
const css = new Set([...doc.querySelectorAll('link[data-lazy-css]')].map(link => link.dataset.lazyCss));
|
||||
const selected = new Map();
|
||||
const url = path => selected.get(path) || (manifest.files[path]?.h ? path + '?v=' + manifest.files[path].h : './' + path.replace(/^\//,''));
|
||||
function reloadRequired(name) {
|
||||
api.reloadRequired = true;
|
||||
if (root.CustomEvent) root.dispatchEvent?.(new root.CustomEvent('ytp-reload-required', { detail: { group: name } }));
|
||||
}
|
||||
function compatible(name, current, seen = new Set()) {
|
||||
if(seen.has(name))return true;
|
||||
seen.add(name);
|
||||
const built=manifest.groups[name], newer=current.groups[name];
|
||||
if(!built || !newer || built.contract!==newer.contract)return false;
|
||||
return (newer.dependencies || []).every(dependency=>(built.dependencies || []).includes(dependency)&&compatible(dependency,current,seen));
|
||||
}
|
||||
async function select(name) {
|
||||
if (!root.caches) return;
|
||||
try {
|
||||
const cache = await root.caches.open('ytplayer-assets');
|
||||
const response = await cache.match('/__ytp_asset_state');
|
||||
const current = response && (await response.json()).current;
|
||||
const built = manifest.groups[name], newer = current?.groups[name];
|
||||
// The active layout's CSS already executed in the parser before app boot.
|
||||
if(name === 'layout:'+api.layout) return;
|
||||
if (!newer || current.buildTag === manifest.buildTag) return;
|
||||
if (!compatible(name,current)) { reloadRequired(name); return; }
|
||||
const changedList = newer.files.join('\n') !== built.files.join('\n');
|
||||
const available = new Map();
|
||||
for (const path of changedList ? newer.files : built.files) {
|
||||
// Membership and contract must both match the running core's expectation.
|
||||
if (!newer.files.includes(path) || !current.files[path]) continue;
|
||||
const key = path + '?v=' + current.files[path].h;
|
||||
const held = await cache.match(key);
|
||||
if (held?.headers.get('X-Asset-Hash') === current.files[path].h) available.set(path, key);
|
||||
}
|
||||
if(changedList && available.size !== newer.files.length)return;
|
||||
for(const [path,key] of available)selected.set(path,key);
|
||||
if(changedList)return newer;
|
||||
} catch { /* Private browsing/cache eviction: keep the embedded build-local map. */ }
|
||||
}
|
||||
function inject(path) {
|
||||
if (assets.has(path)) return assets.get(path);
|
||||
const style = /\.css$/.test(path);
|
||||
if (style && css.has(path)) return Promise.resolve();
|
||||
if (!style && !/\.js$/.test(path)) return Promise.resolve(); // workers/imports/fonts are not page scripts
|
||||
const task = new Promise((resolve, reject) => {
|
||||
const node = doc.createElement(style ? 'link' : 'script');
|
||||
if (style) { node.rel = 'stylesheet'; node.href = url(path); }
|
||||
else { node.async = false; node.src = url(path); }
|
||||
node.onload = resolve;
|
||||
node.onerror = () => reject(new Error('Unable to load ' + path));
|
||||
doc.head.append(node);
|
||||
});
|
||||
assets.set(path, task);
|
||||
task.catch(() => assets.delete(path));
|
||||
return task;
|
||||
}
|
||||
function load(name) {
|
||||
if (groups.has(name)) return groups.get(name);
|
||||
const group = manifest.groups[name];
|
||||
if (!group) return Promise.reject(new Error('Unknown asset group: ' + name));
|
||||
const task = (async () => {
|
||||
for (const dependency of group.dependencies || []) await load(dependency);
|
||||
const selectedGroup = await select(name) || group;
|
||||
for (const path of selectedGroup.files) if (!/-worker\.js$/.test(path)) await inject(path);
|
||||
for (const fn of hooks.get(name) || []) await fn();
|
||||
group.executed = true;
|
||||
})();
|
||||
groups.set(name, task);
|
||||
task.catch(error => {
|
||||
groups.delete(name);
|
||||
if (root.CustomEvent) root.dispatchEvent?.(new root.CustomEvent('ytp-lazy-error', { detail: { group: name, error } }));
|
||||
});
|
||||
return task;
|
||||
}
|
||||
const api = {
|
||||
load, prefetch: load, loaded: name => !!manifest.groups[name]?.executed, url,
|
||||
manifest, layout: 'classic', reloadRequired: false,
|
||||
worker(path) {
|
||||
const worker=new root.Worker(url(path)), post=worker.postMessage.bind(worker); let first=true;
|
||||
worker.postMessage=(message,transfer)=>{
|
||||
if(first) { first=false; message={...message,assetUrls:{sha256:url('/sha256.js'),wasm:url('/sha256-wasm.js'),resume:url('/resume-core.js')}}; }
|
||||
post(message,transfer);
|
||||
};
|
||||
return worker;
|
||||
},
|
||||
reportLayout(registration) {
|
||||
const send = reg => {
|
||||
const workers = new Set([root.navigator.serviceWorker.controller, reg?.installing, reg?.waiting, reg?.active]);
|
||||
for(const worker of workers) worker?.postMessage({type:'LAYOUT',value:api.layout});
|
||||
};
|
||||
if(registration)send(registration);
|
||||
else root.navigator.serviceWorker?.getRegistration().then(send).catch(()=>{});
|
||||
},
|
||||
warm() {
|
||||
if(root.navigator.connection?.saveData || !root.navigator.serviceWorker) return;
|
||||
const idle = root.requestIdleCallback || (fn => root.setTimeout(fn,1000));
|
||||
idle(()=>root.navigator.serviceWorker.ready.then(reg=>reg.active?.postMessage({type:'WARM_ASSETS',saveData:!!root.navigator.connection?.saveData})).catch(()=>{}));
|
||||
},
|
||||
onLoad(name, fn) {
|
||||
if(api.loaded(name)) { Promise.resolve().then(fn).catch(error=>{if(root.CustomEvent)root.dispatchEvent?.(new root.CustomEvent('ytp-lazy-error',{detail:{group:name,error}}));}); return; }
|
||||
const list = hooks.get(name) || []; list.push(fn); hooks.set(name, list);
|
||||
},
|
||||
proxy(name, methods, globalName) {
|
||||
return Object.fromEntries(methods.map(method => [method, async (...args) => {
|
||||
await load(name);
|
||||
const target = root[globalName || name.split(':')[1]];
|
||||
if (typeof target?.[method] !== 'function') throw new Error('Missing lazy method: ' + method);
|
||||
return target[method](...args);
|
||||
}]));
|
||||
},
|
||||
intent(element, name) {
|
||||
if (!element) return;
|
||||
for (const type of ['pointerenter', 'focus', 'touchstart']) element.addEventListener(type, () => load(name).catch(() => {}), { once: true, passive: true });
|
||||
},
|
||||
};
|
||||
root.Lazy = api;
|
||||
root.navigator.serviceWorker?.addEventListener('message',event=>{
|
||||
if(event.data?.type==='ASSET_LAYOUT_REQUEST') event.source?.postMessage({type:'LAYOUT',value:api.layout});
|
||||
if(event.data?.type==='SW_UPDATE_AVAILABLE') api.warm();
|
||||
});
|
||||
root.navigator.serviceWorker?.addEventListener('controllerchange',()=>{api.reportLayout();api.warm();});
|
||||
let settings={};
|
||||
try { settings=JSON.parse(root.localStorage.getItem('_ytpdata') || '{}').settings || {}; api.layout=settings.layout || 'classic'; } catch {}
|
||||
Object.assign(doc.documentElement.dataset,{layout:api.layout,theme:settings.theme || 'dark',font:settings.fontScale || 'normal',density:settings.density || 'comfortable',perf:settings.perfMode?'on':'off',motion:settings.reduceMotion?'reduced':'full'});
|
||||
const layoutName = manifest.groups['layout:' + api.layout] ? 'layout:'+api.layout : 'layout:classic';
|
||||
const layoutGroups = new Set([layoutName]);
|
||||
for(const name of layoutGroups) for(const dependency of manifest.groups[name]?.dependencies || []) layoutGroups.add(dependency);
|
||||
if (doc.readyState === 'loading') for (const path of [...layoutGroups].flatMap(name=>manifest.groups[name]?.files || [])) {
|
||||
if (!/\.css$/.test(path) || css.has(path)) continue;
|
||||
// Parser-inserted CSS blocks rendering; dynamic head links do not on all WebKit versions.
|
||||
css.add(path);
|
||||
doc.write('<link rel="stylesheet" href="' + url(path) + '" data-lazy-css="' + path + '">');
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
78
frontend/lazy.test.js
Normal file
78
frontend/lazy.test.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
function fixture(current, held = []) {
|
||||
const inserted = [], listeners = {}, writes = [];
|
||||
const manifest = { buildTag: 'own', groups: { core: { files: [] }, 'layout:classic': { files: ['/classic.css'] }, 'layout:glass-stage': { files: ['/glass.css', '/one.js', '/two.js'] }, 'feature:test': { contract:1, files: ['/one.js', '/two.js'] } }, files: { '/classic.css': {h:'c'}, '/glass.css':{h:'g'}, '/one.js':{h:'1'}, '/two.js':{h:'2'} } };
|
||||
const doc = { readyState:'loading', documentElement: { dataset:{} }, getElementById: () => ({ textContent:JSON.stringify(manifest) }), querySelectorAll: () => [], createElement: tag => ({ tagName:tag.toUpperCase() }), write: value => writes.push(value), addEventListener: (t,f) => listeners[t]=f };
|
||||
doc.head = { append: node => { inserted.push(node); queueMicrotask(() => node.onload?.()); } };
|
||||
const root = { document:doc, localStorage:{ getItem:() => '{"settings":{"layout":"glass-stage"}}' }, console, Promise, URL, setTimeout, clearTimeout, navigator:{}, addEventListener(){} };
|
||||
if (current) root.caches = { open: async () => ({ match: async key => key === '/__ytp_asset_state' ? new Response(JSON.stringify({current})) : held.includes(key) ? new Response('', {headers:{'X-Asset-Hash':key.split('=')[1]}}) : undefined }) };
|
||||
root.window=root; root.globalThis=root;
|
||||
vm.runInNewContext(readFileSync(require.resolve('./lazy.js'),'utf8'), root);
|
||||
return { root, inserted, writes, listeners, manifest };
|
||||
}
|
||||
test('external head bootstrap selects remembered CSS before paint without fetching a manifest', () => {
|
||||
const {root,writes}=fixture();
|
||||
assert.equal(root.document.documentElement.dataset.layout,'glass-stage');
|
||||
assert.deepEqual(writes,['<link rel="stylesheet" href="/glass.css?v=g" data-lazy-css="/glass.css">']);
|
||||
});
|
||||
test('lazy groups execute ordered classic scripts once, sharing concurrent calls and dependencies', async () => {
|
||||
const {root,inserted}=fixture();
|
||||
const one=root.Lazy.load('feature:test'), two=root.Lazy.load('feature:test');
|
||||
await Promise.all([one,two]);
|
||||
assert.deepEqual(inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);
|
||||
assert.equal(root.Lazy.loaded('feature:test'),true);
|
||||
await root.Lazy.load('layout:glass-stage');
|
||||
assert.equal(inserted.filter(n=>n.tagName==='SCRIPT').length,2);
|
||||
});
|
||||
test('failed injection can retry and proxy dispatches only after execution', async () => {
|
||||
const {root,inserted}=fixture();
|
||||
const append=root.document.head.append;
|
||||
root.document.head.append=n=>{inserted.push(n);queueMicrotask(()=>n.onerror?.());};
|
||||
await assert.rejects(root.Lazy.load('feature:test'),/one.js/);
|
||||
root.document.head.append=append;
|
||||
root.TestApi={open:value=>value+1};
|
||||
const proxy=root.Lazy.proxy('feature:test',['open'],'TestApi');
|
||||
assert.equal(await proxy.open(4),5);
|
||||
assert.equal(root.Lazy.url('/two.js'),'/two.js?v=2');
|
||||
await assert.rejects(root.Lazy.load('feature:missing'),/Unknown/);
|
||||
});
|
||||
|
||||
test('executing groups are pinned even after a newer compatible manifest arrives', async () => {
|
||||
const f=fixture(); await f.root.Lazy.load('feature:test');
|
||||
const newer=JSON.parse(JSON.stringify(f.manifest)); newer.files['/one.js'].h='new';
|
||||
f.root.caches={open:async()=>({match:async key=>key==='/__ytp_asset_state'?new Response(JSON.stringify({current:newer})):new Response('',{headers:{'X-Asset-Hash':'new'}})})};
|
||||
await f.root.Lazy.load('feature:test');
|
||||
assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);
|
||||
});
|
||||
test('unexecuted compatible group immediately uses newly cached file URLs', async () => {
|
||||
const next=fixture().manifest; next.buildTag='new'; next.files['/one.js'].h='new';
|
||||
const f=fixture(next,['/one.js?v=new','/two.js?v=2']);
|
||||
await f.root.Lazy.load('feature:test');
|
||||
assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=new','/two.js?v=2']);
|
||||
assert.equal(f.root.Lazy.reloadRequired,false);
|
||||
});
|
||||
test('unexecuted incompatible group keeps its running-build N-1 URLs and requires reload', async () => {
|
||||
const next=fixture().manifest; next.buildTag='new'; next.files['/one.js'].h='new'; next.groups['feature:test'].contract=2;
|
||||
const f=fixture(next,['/one.js?v=new','/two.js?v=2']);
|
||||
const events=[]; f.root.CustomEvent=class {constructor(type,opts){this.type=type;this.detail=opts.detail}}; f.root.dispatchEvent=e=>events.push(e.type);
|
||||
await f.root.Lazy.load('feature:test');
|
||||
assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);
|
||||
assert.equal(f.root.Lazy.reloadRequired,true);
|
||||
assert.ok(events.includes('ytp-reload-required'));
|
||||
});
|
||||
|
||||
test('workers receive the pinned import URL map in their first message', () => {
|
||||
const f=fixture(), sent=[]; f.root.Worker=class { constructor(url){this.url=url;} postMessage(message,transfer){sent.push({message,transfer});} };
|
||||
f.manifest.files['/worker.js']={h:'w'}; f.root.Lazy.manifest.files['/worker.js']={h:'w'};
|
||||
const worker=f.root.Lazy.worker('/worker.js');worker.postMessage({op:'open'},['transfer']);worker.postMessage({op:'finish'});
|
||||
assert.equal(worker.url,'/worker.js?v=w');assert.equal(sent[0].message.assetUrls.sha256,f.root.Lazy.url('/sha256.js'));assert.equal(sent[0].message.assetUrls.wasm,f.root.Lazy.url('/sha256-wasm.js'));assert.deepEqual(sent[0].transfer,['transfer']);assert.equal(sent[1].message.assetUrls,undefined);
|
||||
});
|
||||
|
||||
test('new group files with a changed dependency contract cannot run against the old dependency',async()=>{
|
||||
const next=fixture().manifest;next.buildTag='next';next.groups['feature:test'].dependencies=['feature:dep'];next.groups['feature:dep']={contract:2,files:[]};next.files['/one.js'].h='new';
|
||||
const f=fixture(next,['/one.js?v=new','/two.js?v=2']);f.root.Lazy.manifest.groups['feature:test'].dependencies=['feature:dep'];f.root.Lazy.manifest.groups['feature:dep']={contract:1,files:[]};
|
||||
await f.root.Lazy.load('feature:test');assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);assert.equal(f.root.Lazy.reloadRequired,true);
|
||||
});
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
function configure(options) {
|
||||
adapter = options; const supported = !!mode();
|
||||
for (const id of ['stageLyricsAuto', 'serviceAutoscrollBtn', 'pipBtn']) { const anchor = root.document.getElementById(id); if (!anchor || !supported) continue; const button = root.document.createElement('button'); button.type = 'button'; button.className = 'np-btn'; button.textContent = '⧉ Lyrics'; button.title = 'Open floating lyrics'; button.setAttribute('aria-label', 'Open floating lyrics window'); button.onclick = open; anchor.after(button); }
|
||||
for (const id of ['stageLyricsAuto', 'serviceAutoscrollBtn', 'pipBtn']) { const anchor = root.document.getElementById(id); if (!anchor || !supported) continue; const button = root.document.getElementById('floating-'+id) || root.document.createElement('button'); button.id='floating-'+id; button.type = 'button'; button.className = 'np-btn'; button.textContent = '⧉ Lyrics'; button.title = 'Open floating lyrics'; button.setAttribute('aria-label', 'Open floating lyrics window'); button.onclick = open; if(!button.isConnected)anchor.after(button); }
|
||||
root.SettingsSections.register({ id: 'floating-lyrics', title: 'Floating lyrics', cluster: 'Live & worship', summary: () => mode() === 'document' ? 'Interactive window available' : mode() ? 'Lyric video PiP available' : 'Not supported on this browser', render(container) { const p = container.ownerDocument.createElement('p'); p.textContent = mode() === 'document' ? 'Open synced lyrics with seeking, Play/Pause and Next in an always-on-top window.' : mode() ? 'This browser supports a lyric-video window. Playback controls remain in the player.' : 'Floating lyrics are not supported on this browser.'; container.append(p); if (supported) { const b = container.ownerDocument.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Open floating lyrics'; b.onclick = open; container.append(b); } } });
|
||||
}
|
||||
const api = { configure, support, wrap, element: id => pipWindow?.document.getElementById(id) || null }; if (typeof module !== 'undefined') module.exports = api; else root.LyricsWindow = api;
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
|
||||
// Incremental SHA-256 (frontend/sha256.js): the file is hashed while it is
|
||||
// written, so the device knows its content id without re-reading the file.
|
||||
try { importScripts('/sha256.js'); } catch { /* hashing unavailable — save still works */ }
|
||||
importScripts('/resume-core.js');
|
||||
|
||||
|
||||
async function getVideosDir() {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
@@ -203,6 +202,10 @@ async function writeFile(dirName, name, buffer) {
|
||||
}
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
self.__ASSET_URLS__=e.data?.assetUrls || {};
|
||||
try { if(!self.Sha256)importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); } catch { /* hashing unavailable — legacy save still works */ }
|
||||
try { if(!self.ResumeCore)importScripts(self.__ASSET_URLS__.resume || '/resume-core.js'); }
|
||||
catch(error) { self.postMessage({type:'error',error:error.message}); return; }
|
||||
if (e.data && e.data.op === 'write') {
|
||||
try { await writeFile(e.data.dir, e.data.name, e.data.buffer); self.postMessage({ type: 'written' }); }
|
||||
catch (err) { self.postMessage({ type: 'error', error: err && err.message ? err.message : String(err) }); }
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
return new Promise((resolve) => {
|
||||
let worker;
|
||||
try {
|
||||
worker = new Worker('/opfs-worker.js');
|
||||
worker = window.Lazy ? window.Lazy.worker('/opfs-worker.js') : new Worker('/opfs-worker.js');
|
||||
} catch {
|
||||
resolve({ ok: false, fallback: true });
|
||||
return;
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
function hashInWorker(name) {
|
||||
return new Promise((resolve) => {
|
||||
let w;
|
||||
try { w = new Worker('/hash-worker.js'); } catch { resolve(null); return; }
|
||||
try { w = window.Lazy ? window.Lazy.worker('/hash-worker.js') : new Worker('/hash-worker.js'); } catch { resolve(null); return; }
|
||||
w.onmessage = (e) => { w.terminate(); resolve(e.data && e.data.ok ? e.data : null); };
|
||||
w.onerror = () => { w.terminate(); resolve(null); };
|
||||
w.postMessage({ name });
|
||||
@@ -296,7 +296,7 @@
|
||||
function start(h) {
|
||||
hooks = { ...hooks, ...(h || {}) };
|
||||
const idle = window.requestIdleCallback || ((fn) => setTimeout(fn, 1));
|
||||
setTimeout(() => idle(() => sync()), 8000);
|
||||
setTimeout(() => idle(() => sync()), h?.initialDelay ?? 8000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && Date.now() - lastSync > RESYNC_MS) sync();
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
* The ".part" suffix keeps OPFS.listVideos() from ever listing a partial file.
|
||||
* ========================================================================== */
|
||||
'use strict';
|
||||
importScripts('/sha256.js');
|
||||
|
||||
let dir = null;
|
||||
let handle = null;
|
||||
@@ -36,6 +35,7 @@ self.onmessage = (e) => { chain = chain.then(() => onOp(e.data || {})); };
|
||||
|
||||
async function onOp(m) {
|
||||
try {
|
||||
if (!self.Sha256) { self.__ASSET_URLS__=m.assetUrls || {}; importScripts(self.__ASSET_URLS__.sha256 || '/sha256.js'); }
|
||||
if (m.op === 'open') {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
dir = await root.getDirectoryHandle('videos', { create: true });
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
return new Promise((resolve) => {
|
||||
const xid = newXid();
|
||||
const pc = new RTCPeerConnection({ iceServers });
|
||||
const worker = new Worker('/p2p-recv-worker.js');
|
||||
const worker = window.Lazy ? window.Lazy.worker('/p2p-recv-worker.js') : new Worker('/p2p-recv-worker.js');
|
||||
let settled = false;
|
||||
let idle = null;
|
||||
let expected = size;
|
||||
|
||||
@@ -200,7 +200,7 @@
|
||||
button('Transcribe in browser', () =>
|
||||
run(async (id) => {
|
||||
const buffer = await audio(id, file.files[0]);
|
||||
const engine = await import('./piano-engine.mjs');
|
||||
const engine = await import(root.Lazy?.url('/piano-engine.mjs') || './piano-engine.mjs');
|
||||
const result = await engine.transcribe(buffer, (text) => {
|
||||
if (alive) status.textContent = text;
|
||||
});
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
const icon = paths => `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${paths || '<circle cx="12" cy="12" r="7"/>'}</svg>`;
|
||||
function mount(host, context = {}) {
|
||||
dispose();
|
||||
const document = host.ownerDocument, win = document.defaultView, sections = registry.list(), panels = new Map(), rows = new Map(), cleanups = [];
|
||||
const document = host.ownerDocument, win = document.defaultView, sections = registry.list(), panels = new Map(), rows = new Map(), cleanups = [], searchLoads = new Set();
|
||||
let mounted = true;
|
||||
let remembered = ''; try { remembered = win.sessionStorage.getItem('ytpSettingsSection') || ''; } catch {}
|
||||
let active = registry.get(remembered) ? remembered : sections[0]?.id;
|
||||
host.classList.add('settings-browser');
|
||||
@@ -45,14 +46,23 @@
|
||||
const text = document.createElement('span'), title = document.createElement('b'), summary = document.createElement('small'); title.textContent = section.title; text.append(title, summary); button.append(text);
|
||||
button.setAttribute('aria-controls', 'settings-section-' + section.id); navigation.append(button); rows.set(section.id, { button, summary });
|
||||
const panel = document.createElement('section'); panel.id = 'settings-section-' + section.id; panel.className = 'settings-section'; panel.setAttribute('aria-label', section.title); panels.set(section.id, panel); detail.append(panel);
|
||||
if (!section.lazyGroup) {
|
||||
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
|
||||
}
|
||||
button.addEventListener('click', () => open(section.id));
|
||||
if(section.lazyGroup)root.Lazy.intent(button,section.lazyGroup);
|
||||
}
|
||||
}
|
||||
grid.append(navigation, detail); host.replaceChildren(toolbar, results, empty, grid);
|
||||
function summaries() { for (const section of sections) { try { rows.get(section.id).summary.textContent = section.summary(context) || ''; } catch {} } }
|
||||
function show(id, inDetail, focusId) {
|
||||
if (registry.get(id)) active = id;
|
||||
const section = sections.find(section => section.id === active);
|
||||
const panel = panels.get(active);
|
||||
if ((inDetail || win.matchMedia?.('(min-width: 861px)').matches) && section?.lazyGroup && !panel.dataset.rendered) {
|
||||
panel.dataset.rendered = '1';
|
||||
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
|
||||
}
|
||||
host.dataset.detail = inDetail ? '1' : '0';
|
||||
for (const [key, panel] of panels) panel.hidden = key !== active;
|
||||
for (const [key, row] of rows) row.button.setAttribute('aria-current', key === active ? 'true' : 'false');
|
||||
@@ -71,6 +81,19 @@
|
||||
function filter() {
|
||||
const query = input.value.trim(); results.replaceChildren(); results.hidden = !query; grid.hidden = !!query;
|
||||
if (!query) { empty.hidden = true; return; }
|
||||
// Searching is explicit feature intent. Mount optional controls so their
|
||||
// existing labels/help remain searchable without parsing them on opening Settings.
|
||||
for (const section of sections) {
|
||||
const panel=panels.get(section.id);
|
||||
if(!section.lazyGroup || panel.dataset.rendered || searchLoads.has(section.id))continue;
|
||||
searchLoads.add(section.id);
|
||||
root.Lazy.load(section.lazyGroup).then(()=>{
|
||||
if(!mounted || !host.isConnected || panel.dataset.rendered)return;
|
||||
panel.dataset.rendered='1';
|
||||
const cleanup=registry.get(section.id).render(panel,context);if(typeof cleanup==='function')cleanups.push(cleanup);
|
||||
filter();
|
||||
}).catch(()=>searchLoads.delete(section.id));
|
||||
}
|
||||
const entries = [];
|
||||
for (const section of sections) {
|
||||
entries.push({ title: section.title, description: section.description || '', section: section.id });
|
||||
@@ -92,11 +115,11 @@
|
||||
const pop = event => { if (!host.isConnected) return; show(event.state?.ytpSettingsSection || active, !!event.state?.ytpSettingsSection); if (!event.state?.ytpSettingsSection) rows.get(active)?.button.focus({ preventScroll: true }); };
|
||||
win.addEventListener('popstate', pop); input.addEventListener('input', filter);
|
||||
host.addEventListener('change', summaries); host.addEventListener('input', summaries);
|
||||
const observer = new win.MutationObserver(summaries); observer.observe(detail, { childList: true, subtree: true, characterData: true });
|
||||
const observer = new win.MutationObserver(()=>{summaries();if(input.value.trim())filter();}); observer.observe(detail, { childList: true, subtree: true, characterData: true });
|
||||
const state = win.history.state?.ytpSettingsSection;
|
||||
if (!state) win.history.replaceState({ ...win.history.state, ytpSettingsSection: null }, '', win.location.href);
|
||||
show(state || active, !!state); summaries();
|
||||
dispose = () => { observer.disconnect(); win.removeEventListener('popstate', pop); cleanups.forEach(fn => fn()); };
|
||||
dispose = () => { mounted=false;observer.disconnect(); win.removeEventListener('popstate', pop); cleanups.forEach(fn => fn()); };
|
||||
return { open, refresh: summaries, dispose };
|
||||
}
|
||||
const api = { ...registry, mount, search, normalize, createRegistry, clusters };
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
wasm = null;
|
||||
try {
|
||||
if (typeof WebAssembly === 'undefined') return wasm;
|
||||
if (!root.SHA256_WASM_B64 && typeof importScripts === 'function') importScripts('/sha256-wasm.js');
|
||||
if (!root.SHA256_WASM_B64 && typeof importScripts === 'function') importScripts(root.__ASSET_URLS__?.wasm || '/sha256-wasm.js');
|
||||
let b64 = root.SHA256_WASM_B64;
|
||||
if (!b64 && typeof require === 'function') b64 = require('./sha256-wasm.js');
|
||||
if (!b64) return wasm;
|
||||
|
||||
@@ -53,9 +53,10 @@ test('assets.json describes the complete existing shell exactly once with eager
|
||||
test('the selected glass-stage layout includes its controls and panel dependencies', () => {
|
||||
const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
|
||||
assert.deepEqual(groups['layout:glass-stage'].files, [
|
||||
'/theme-glass.css', '/theme-glass-controls.css', '/glass-controls.js',
|
||||
'/theme-glass-panel.css', '/glass-panel-layout.js',
|
||||
'/theme-glass.css', '/theme-glass-panel.css', '/glass-panel-layout.js',
|
||||
]);
|
||||
assert.deepEqual(groups['layout:glass-stage'].dependencies,['feature:layout-controls']);
|
||||
assert.ok(groups['feature:layout-controls'].files.includes('/glass-controls.js'));
|
||||
});
|
||||
|
||||
test('the shared sync core is eager and loads before the update helper',()=>{
|
||||
@@ -64,3 +65,26 @@ test('the shared sync core is eager and loads before the update helper',()=>{
|
||||
assert.ok(html.indexOf('src="asset-sync-core.js"')<html.indexOf('src="sw-update.js"'));
|
||||
assert.ok(listed.has('/asset-sync-core.js'));
|
||||
});
|
||||
|
||||
test('only core scripts and styles remain eager; native fallback and active layout bootstrap agree', () => {
|
||||
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||
const background=new Set(Object.values(groups).filter(g=>g.background).flatMap(g=>g.files));
|
||||
for(const match of html.matchAll(/<(?:script\b[^>]*\bsrc|link\b[^>]*\bhref)="([^"]+)"/gi)) {
|
||||
const path='/'+match[1].replace(/^\//,'').split(/[?#]/)[0];
|
||||
assert.ok(!background.has(path),`${path} is not eager`);
|
||||
}
|
||||
assert.ok(html.indexOf('id="ytp-assets"')<html.indexOf('src="lazy.js"'));
|
||||
assert.ok(html.indexOf('src="lazy.js"')<html.indexOf('</head>'));
|
||||
assert.ok(html.indexOf('src="lazy.js"')<html.indexOf('rel="stylesheet"'),'head bootstrap must not wait for common CSS before requesting the selected layout');
|
||||
assert.ok(html.indexOf('rel="preload"')<html.indexOf('src="lazy.js"'),'font preloads must start before the render-blocking bootstrap');
|
||||
assert.deepEqual(JSON.parse(html.match(/id="ytp-assets">([^<]+)<\/script>/)[1]).groups,groups);
|
||||
for(const name of ['playlist','settings','service-mode','lyrics-editor'])assert.ok(groups['feature:'+name].eager);
|
||||
});
|
||||
|
||||
test('layout-independent visibility and transport defaults stay in eager CSS',()=>{
|
||||
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||
assert.ok(groups.core.files.includes('/layout-base.css'));
|
||||
const shared=readFileSync(join(__dirname,'layout-base.css'),'utf8');
|
||||
for(const selector of ['.stg-overlay','.deck-chips','.np-btn.done .i-save','.sleep-status .sl-ic','.ab-group','.mc-tune'])assert.ok(shared.includes(selector));
|
||||
assert.ok(html.includes('href="layout-base.css"'));
|
||||
});
|
||||
|
||||
@@ -165,8 +165,9 @@
|
||||
// legacy refresh followed by a reload into the old persistent manifest.
|
||||
if (worker && !status) throw new Error('Worker update status unavailable; try again');
|
||||
if (status && status.assetSync) {
|
||||
const result = await core.syncAssets(m,{cache,fetchFn:opts.fetchFn});
|
||||
if (!reg.waiting) await core.commit(m,cache);
|
||||
const activeLayout = root.Lazy?.layout || 'classic';
|
||||
const result = await core.syncAssets(m,{cache,fetchFn:opts.fetchFn,activeLayout});
|
||||
if (!reg.waiting) await core.commit(m,cache,{activeLayout});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -210,7 +211,7 @@
|
||||
try {
|
||||
const ch = new Ctor();
|
||||
ch.port1.onmessage = (ev) => finish(ev && ev.data ? ev.data : null);
|
||||
worker.postMessage({ type: 'CACHE_STATUS' }, [ch.port2]);
|
||||
worker.postMessage({ type: 'CACHE_STATUS', activeLayout: root.Lazy?.layout }, [ch.port2]);
|
||||
} catch { finish(null); return; }
|
||||
if (schedule) schedule(() => finish(null), timeoutMs);
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@ const SHELL = [
|
||||
'/styles.css',
|
||||
'/service-ui.css',
|
||||
'/refresh.css',
|
||||
'/layout-base.css',
|
||||
'/theme-glass.css',
|
||||
'/theme-bento-hub.css',
|
||||
'/theme-f7.css',
|
||||
@@ -74,6 +75,10 @@ const SHELL = [
|
||||
'/async-guard.js',
|
||||
'/visible-timer.js',
|
||||
'/asset-sync-core.js',
|
||||
'/lazy.js',
|
||||
'/feature-bootstrap.js',
|
||||
'/eq-parameters.js',
|
||||
'/assets.json',
|
||||
'/sw-update.js',
|
||||
'/fingerprint.js',
|
||||
'/opfs.js',
|
||||
@@ -287,7 +292,30 @@ self.addEventListener('fetch', (e) => {
|
||||
|
||||
// ---- Message: handle SKIP_WAITING from the client ----
|
||||
const playingClients = new Map(), deferredClients = new Set();
|
||||
let activeLayout = 'classic', layoutReply, warmTask;
|
||||
self.addEventListener('message', (e) => {
|
||||
if (ASSET_SYNC && e.data?.type === 'LAYOUT' && e.source && typeof e.data.value === 'string') {
|
||||
activeLayout = e.data.value;
|
||||
layoutReply?.();
|
||||
e.waitUntil((async()=>{
|
||||
const cache=await caches.open(AssetSyncCore.CACHE);
|
||||
await cache.put('/__ytp_layout',Response.json({layout:activeLayout}));
|
||||
const m=await candidate(cache);
|
||||
if(m && m.activeLayout!==activeLayout) {
|
||||
await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetch,activeLayout});
|
||||
m.activeLayout=activeLayout; await cache.put(candidateKey,Response.json(m));
|
||||
}
|
||||
})());
|
||||
}
|
||||
if (ASSET_SYNC && e.data?.type === 'WARM_ASSETS' && !e.data.saveData) {
|
||||
// Boot, activation and multiple tabs can report idle together. Share the
|
||||
// verified pool so they never download the same missing group twice.
|
||||
warmTask ||= (async()=>{
|
||||
const cache=await caches.open(AssetSyncCore.CACHE), m=(await AssetSyncCore.state(cache))?.current;
|
||||
if(m)await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetch,groups:Object.keys(m.groups).filter(name=>m.groups[name].background)});
|
||||
})().catch(error=>console.warn('[asset-sync] idle warm interrupted',error.message)).finally(()=>{warmTask=null;});
|
||||
e.waitUntil(warmTask);
|
||||
}
|
||||
if (e.data && e.data.type === 'PLAYING' && e.source) {
|
||||
playingClients.set(e.source.id, !!e.data.value);
|
||||
if (!e.data.value && deferredClients.delete(e.source.id)) self.skipWaiting();
|
||||
@@ -306,7 +334,11 @@ self.addEventListener('message', (e) => {
|
||||
e.waitUntil((async () => {
|
||||
if (ASSET_SYNC) {
|
||||
let reply = { ready: false, missing: -1, version: VERSION };
|
||||
try { const cache = await caches.open(AssetSyncCore.CACHE); const m = await candidate(cache) || (await AssetSyncCore.state(cache))?.current; if(m) reply = await AssetSyncCore.status(m,cache); } catch {}
|
||||
try {
|
||||
const cache = await caches.open(AssetSyncCore.CACHE), state = await AssetSyncCore.state(cache);
|
||||
const pending = await candidate(cache), m = pending || state?.current;
|
||||
if(m) reply = await AssetSyncCore.status(m,cache,{previous:pending ? state?.current : state?.previous,activeLayout:e.data.activeLayout || (pending ? m.activeLayout : activeLayout) || 'classic'});
|
||||
} catch {}
|
||||
reply.type = 'CACHE_STATUS'; reply.assetSync = true;
|
||||
if(e.ports && e.ports[0]) e.ports[0].postMessage(reply); else if(e.source) e.source.postMessage(reply);
|
||||
return;
|
||||
@@ -446,6 +478,15 @@ async function installAssets() {
|
||||
const m = await r.json();
|
||||
if (m.buildTag !== VERSION) throw Error('Deployment changed during install');
|
||||
const cache = await caches.open(AssetSyncCore.CACHE);
|
||||
const remembered = await cache.match('/__ytp_layout');
|
||||
if(remembered)activeLayout=(await remembered.json()).layout || 'classic';
|
||||
let layoutKnown = !!remembered;
|
||||
const clients = await self.clients.matchAll({type:'window',includeUncontrolled:true});
|
||||
await new Promise(resolve=>{
|
||||
const timer=setTimeout(resolve,500); layoutReply=()=>{layoutKnown=true;clearTimeout(timer);resolve();};
|
||||
for(const client of clients)client.postMessage({type:'ASSET_LAYOUT_REQUEST'});
|
||||
});
|
||||
layoutReply=null; m.activeLayout=activeLayout;
|
||||
const freshInstall = !(await AssetSyncCore.state(cache));
|
||||
const fetchAsset = async (url,init) => {
|
||||
// Fonts/icons still use plain URLs in CSS/manifest. On first install reuse
|
||||
@@ -456,7 +497,14 @@ async function installAssets() {
|
||||
}
|
||||
return fetch(url,init);
|
||||
};
|
||||
await AssetSyncCore.syncAssets(m, { cache, fetchFn: fetchAsset });
|
||||
await AssetSyncCore.syncAssets(m, { cache, fetchFn: fetchAsset, activeLayout });
|
||||
// Older pages cannot report their saved choice. Keep every layout ready for
|
||||
// that one migration so an immediate offline reload cannot lose its theme.
|
||||
if(!layoutKnown && ((await AssetSyncCore.state(cache)) || (await caches.keys()).some(isVersionedShellCache))) {
|
||||
const layouts=new Set(Object.keys(m.groups).filter(name=>name.startsWith('layout:')));
|
||||
for(const name of layouts)for(const dependency of m.groups[name].dependencies || [])layouts.add(dependency);
|
||||
await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetchAsset,groups:[...layouts]});
|
||||
}
|
||||
await cache.put(candidateKey, new Response(JSON.stringify(m)));
|
||||
}
|
||||
async function migrateLegacy(cache, m) {
|
||||
@@ -489,7 +537,7 @@ async function activateAssets() {
|
||||
if (!m) throw Error('Missing candidate manifest');
|
||||
const names = await migrateLegacy(cache,m), old = await AssetSyncCore.state(cache);
|
||||
const previousClients = (await self.clients.matchAll({type:'window'})).map(c=>c.id);
|
||||
await AssetSyncCore.commit(m,cache,{previousClients});
|
||||
await AssetSyncCore.commit(m,cache,{previousClients,activeLayout:m.activeLayout || activeLayout});
|
||||
// Legacy deletion occurs strictly after verified commit.
|
||||
await Promise.all(names.map(n => caches.delete(n)));
|
||||
for (const req of await cache.keys()) if(new URL(req.url).pathname.startsWith('/__ytp_candidate/')) await cache.delete(req);
|
||||
@@ -516,6 +564,11 @@ async function assetFetch(request, clientId) {
|
||||
const cached = await cache.match(key);
|
||||
const h = new URL(key,self.location.origin).searchParams.get('v');
|
||||
if(cached && cached.headers.get('X-Asset-Hash') === h) return cached;
|
||||
if(state.current.files[path]?.h === h) {
|
||||
const previousKey=AssetSyncCore.fallback(state.current,state.previous,path);
|
||||
const previousResponse=previousKey && await cache.match(previousKey);
|
||||
if(previousResponse && previousResponse.headers.get('X-Asset-Hash')===state.previous.files[path].h) return previousResponse;
|
||||
}
|
||||
try { const r = await fetch(key); if(r.ok && r.headers.get('X-Asset-Hash') !== h) return new Response('Asset version unavailable',{status:409}); if(r.ok && request.method !== 'HEAD') await cache.put(key,r.clone()); return r; } catch { return new Response('Offline',{status:503}); }
|
||||
}
|
||||
try { return await fetch(request); } catch { return new Response('Offline',{status:503}); }
|
||||
|
||||
@@ -14,12 +14,6 @@
|
||||
* ========================================================================== */
|
||||
|
||||
/* Elements that only exist for this theme stay hidden elsewhere. */
|
||||
.stg-overlay, .skip10, .deck-chips, .vol-pct, .mode-name { display: none; }
|
||||
.np-btn .ti, .np-btn .tl { pointer-events: none; }
|
||||
.np-btn .ti { width: 20px; height: 20px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.np-btn .i-done { display: none; }
|
||||
.np-btn.done .i-save { display: none; }
|
||||
.np-btn.done .i-done { display: inline; }
|
||||
|
||||
html[data-layout="glass-stage"] {
|
||||
--bg: #121317;
|
||||
@@ -202,8 +196,6 @@ html[data-layout="glass-stage"] .stg-overlay { justify-content: flex-start; }
|
||||
html[data-layout="glass-stage"] .stg-mid { margin: auto 0; }
|
||||
|
||||
/* =========================== fidelity pass (matches the approved mockup) =========================== */
|
||||
.stg-bot, .ch-badge, .upnext-clear, .list-sub, .p2p-strip { display: none; }
|
||||
.sleep-status .sl-ic, .sleep-status .sl-sub { display: none; }
|
||||
|
||||
/* stage HUD */
|
||||
html[data-layout="glass-stage"] .stg-bot { display: flex; position: absolute; left: 12px; right: 12px; bottom: 12px; justify-content: space-between; pointer-events: none; }
|
||||
@@ -292,7 +284,6 @@ html[data-layout="glass-stage"] .ps-txt small { font: 400 10px var(--mono); colo
|
||||
html[data-layout="glass-stage"] .ps-dl { width: 34px; height: 34px; border-radius: 12px; border: 0; background: rgba(255, 255, 255, 0.07); color: var(--accent-bright); font-size: 16px; cursor: pointer; }
|
||||
|
||||
/* ===== quick row (clean rebuild): [A | B | ↺] [Fullscreen] [More] ===== */
|
||||
.ab-group { display: contents; }
|
||||
html[data-layout="glass-stage"] #controls .dk-quick { display: flex; flex-wrap: nowrap; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
html[data-layout="glass-stage"] #controls .ab-group { display: inline-flex; align-items: stretch; gap: 2px; padding: 3px; border-radius: 14px; background: var(--tile); border: 1px solid var(--glass-line); flex: 0 1 auto; min-width: 0; }
|
||||
html[data-layout="glass-stage"] #controls .ab-group .ctrl {
|
||||
@@ -339,7 +330,6 @@ html[data-layout="glass-stage"] #controls .ab-group .ctrl { padding: 0 7px !impo
|
||||
html[data-layout="glass-stage"] #controls .ab-group #abClearBtn { padding: 0 7px !important; font-size: 13px !important; }
|
||||
|
||||
/* ===== A-B · Fullscreen · More — styled like the mockup ===== */
|
||||
.mc-tune { display: none; }
|
||||
html[data-layout="glass-stage"] #controls .dk-quick { flex-wrap: wrap; justify-content: space-between; gap: 8px; }
|
||||
html[data-layout="glass-stage"] #controls .ab-group { padding: 2px; border-radius: 12px; gap: 0; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.05); }
|
||||
html[data-layout="glass-stage"] #controls .ab-group .ctrl { height: 26px !important; padding: 0 10px !important; border-radius: 8px !important; font: 500 10px var(--mono) !important; color: var(--text-dim); }
|
||||
|
||||
12
frontend/worker-imports.test.js
Normal file
12
frontend/worker-imports.test.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const {test}=require('node:test'), assert=require('node:assert/strict'), vm=require('node:vm'), fs=require('node:fs');
|
||||
for(const name of ['hash-worker','direct-recv-worker','p2p-recv-worker','opfs-worker'])test(name+' imports its originating page hashes before handling the first operation',async()=>{
|
||||
const urls=[], replies=[];const self={postMessage:m=>replies.push(m)};
|
||||
const sandbox={self,console,URL,setTimeout,clearTimeout,Promise,TextEncoder,FileSystemFileHandle:class {createSyncAccessHandle(){}},navigator:{storage:{getDirectory:async()=>{throw Error('fixture: no disk writes')}}},fetch:async()=>{throw Error('fixture: no network')},importScripts:u=>{urls.push(u);if(u.includes('/sha256.js'))self.Sha256={create:()=>({})};if(u.includes('/resume-core.js'))self.ResumeCore={};}};
|
||||
vm.runInNewContext(fs.readFileSync(__dirname+'/'+name+'.js','utf8'),sandbox);
|
||||
assert.deepEqual(urls,[]);
|
||||
await self.onmessage({data:{name:'x',op:'open',videoId:'x',url:'/fixture',file:{id:'x',cid:'a'.repeat(64)},assetUrls:{sha256:'/sha256.js?v=sha',wasm:'/sha256-wasm.js?v=wasm',resume:'/resume-core.js?v=resume'}}});
|
||||
await new Promise(r=>setImmediate(r));
|
||||
assert.equal(urls[0],'/sha256.js?v=sha');assert.equal(self.__ASSET_URLS__.wasm,'/sha256-wasm.js?v=wasm');
|
||||
if(name==='opfs-worker')assert.ok(urls.includes('/resume-core.js?v=resume'));
|
||||
assert.ok(replies.length>0);
|
||||
});
|
||||
@@ -209,6 +209,65 @@ inspect individual responses to distinguish those from app asset downloads.
|
||||
Migration results are `phase2-migration-<profile>-2026-10-07.json`.
|
||||
`ASSET_SYNC=0` selects the legacy path while preserving the persistent utility
|
||||
cache. `ASSET_HASHING=0` also selects legacy sync because single-tag URLs cannot
|
||||
pass per-file hash checks. Current assets.json groups all block; idle loading and
|
||||
lazy parsing await Phase 3. Incremental mode now caches the manifested
|
||||
pass per-file hash checks. In Phase 2 all assets.json groups blocked; Phase 3 selects core plus the active
|
||||
layout and warms background groups at idle. Incremental mode now caches the manifested
|
||||
`piano-engine.mjs` even though it remains absent from the legacy SHELL.
|
||||
|
||||
|
||||
## Phase 3 lazy loading and staged apply
|
||||
|
||||
```bash
|
||||
node perf/lazy.mjs --browser all
|
||||
node perf/migration.mjs --browser all --profile lossy
|
||||
node perf/migration.mjs --browser all --profile unthrottled
|
||||
node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario all \
|
||||
--out perf/results/phase3-final-2026-10-08.json \
|
||||
--compare perf/results/after-phase0-2026-10-07.json
|
||||
node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario cold \
|
||||
--frontend-commit 7bc5b4f8b50d78ece3cca5100d54a97fece2748a \
|
||||
--out perf/results/phase3-phase0-initial-2026-10-08.json
|
||||
ASSET_SYNC=0 node perf/baseline.mjs --runs 1 --browser all --profile lte \
|
||||
--scenario offline --out perf/results/phase3-sync-rollback-2026-10-08.json
|
||||
ASSET_HASHING=0 node perf/baseline.mjs --runs 1 --browser all --profile lte \
|
||||
--scenario offline --out perf/results/phase3-hashing-rollback-2026-10-08.json
|
||||
```
|
||||
|
||||
`initialJsCss` sums compressed Resource Timing bodies for page JS/CSS initiated
|
||||
before DOMContentLoaded, excluding SW precache and later idle feature parsing.
|
||||
The saved Phase 0 results lack that metric: `--frontend-commit` reconstructs its
|
||||
frontend in scratch using the current delivery backend for a matched initial-byte
|
||||
comparison. Historical boot/FCP comparisons still use `after-phase0`. Reduced
|
||||
single-run measurements are directional; Phase 5 supplies repeated medians.
|
||||
Update scenarios now wait for every build-N group to finish idle caching before
|
||||
starting the counter, excluding unfinished initial downloads. Waiting-worker
|
||||
bytes represent blocking readiness; an inactive layout's changed CSS downloads
|
||||
later at idle after activation.
|
||||
|
||||
The browser harness checks pre-body layout selection, immediate offline reload,
|
||||
four distinct Car mode controls, optional Settings search, all extension panels,
|
||||
zero network for warmed first use/layout switches, contract guards and pinned
|
||||
instances. Known WebKit disconnected API transport errors are recorded separately;
|
||||
script errors still fail. Save-Data prevents automatic warming.
|
||||
|
||||
The index contains inert JSON and an external head bootstrap under unchanged
|
||||
CSP. Source JSON mirrors assets.json for native/static shells; the server replaces
|
||||
it with hashed build-local URLs. No boot-time manifest network request is added.
|
||||
Shared layout visibility defaults remain eager in layout-base.css.
|
||||
Pure EQ settings metadata and DeviceDB remain eager to preserve synchronous
|
||||
player defaults and offline file bookkeeping. P2P/direct parse on actual use or
|
||||
the existing eight-second sharing startup. Searching Settings explicitly loads
|
||||
optional panels so their original labels remain searchable.
|
||||
|
||||
| JS-requested assets | Phase 3 URL treatment |
|
||||
|---|---|
|
||||
| opfs-worker.js, hash-worker.js, direct-recv-worker.js, p2p-recv-worker.js | Lazy.worker uses embedded per-file URL |
|
||||
| Workers' sha256.js, resume-core.js, sha256-wasm.js | First postMessage carries originating build URLs; importScripts uses them |
|
||||
| piano-engine.mjs | Dynamic import uses Lazy.url; incremental cache warms it |
|
||||
| vendor/framework7-swipe.min.js | Ordered layout group and fallback use Lazy.url |
|
||||
| loudness-wasm.js | Remains an eager stamped script; embedded binary needs no separate URL |
|
||||
| fonts/fonts.css and font preloads; webmanifest/icons | Plain URLs stay aligned with CSS/manifest references; all belong to manifested cache groups |
|
||||
|
||||
Legacy pages cannot report their selected layout. Their one-time migration blocks
|
||||
all layouts to preserve immediate offline selection; modern pages block only the
|
||||
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.
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
* --compare <json-path> Compare results against a previous baseline JSON
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } 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 path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -58,6 +58,7 @@ function parseArgs() {
|
||||
scenario: 'all', // 'cold', 'warm', 'offline', 'update-js', 'update-css', or 'all'
|
||||
out: null,
|
||||
compare: null,
|
||||
frontendCommit: null,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
@@ -67,6 +68,7 @@ function parseArgs() {
|
||||
else if (a === '--profile' && i + 1 < args.length) options.profile = args[++i];
|
||||
else if (a === '--scenario' && i + 1 < args.length) options.scenario = args[++i];
|
||||
else if (a === '--out' && i + 1 < args.length) options.out = args[++i];
|
||||
else if (a === '--frontend-commit' && i + 1 < args.length) options.frontendCommit = args[++i];
|
||||
else if (a === '--compare' && i + 1 < args.length) options.compare = args[++i];
|
||||
}
|
||||
|
||||
@@ -365,6 +367,8 @@ async function runColdScenario({ browserType, server, proxy, profile }) {
|
||||
const fcp = paint.find((p) => p.name === 'first-contentful-paint');
|
||||
|
||||
return {
|
||||
initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).reduce((sum,entry)=>sum+entry.encodedBodySize,0),
|
||||
initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).map(entry=>({url:entry.name,bytes:entry.encodedBodySize})),
|
||||
fcp: fcp ? Math.round(fcp.startTime) : null,
|
||||
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
||||
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||
@@ -393,6 +397,8 @@ async function runColdScenario({ browserType, server, proxy, profile }) {
|
||||
bootDone: pageMetrics.bootDone,
|
||||
longTaskTotal: pageMetrics.longTaskTotal,
|
||||
requests: stats.requests,
|
||||
initialJsCss: pageMetrics.initialJsCss,
|
||||
initialAssetRequests: pageMetrics.initialAssetRequests,
|
||||
swInstallDuration,
|
||||
swInstallBytes: stats.totalWireBytes,
|
||||
storageEstimate: storage,
|
||||
@@ -465,6 +471,8 @@ async function runWarmScenario({ browserType, server, proxy, profile }) {
|
||||
const fcp = paint.find((p) => p.name === 'first-contentful-paint');
|
||||
|
||||
return {
|
||||
initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).reduce((sum,entry)=>sum+entry.encodedBodySize,0),
|
||||
initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).map(entry=>({url:entry.name,bytes:entry.encodedBodySize})),
|
||||
fcp: fcp ? Math.round(fcp.startTime) : null,
|
||||
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
||||
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||
@@ -542,6 +550,8 @@ async function runOfflineScenario({ browserType, server, proxy, profile }) {
|
||||
const fcp = paint.find((p) => p.name === 'first-contentful-paint');
|
||||
|
||||
return {
|
||||
initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).reduce((sum,entry)=>sum+entry.encodedBodySize,0),
|
||||
initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).map(entry=>({url:entry.name,bytes:entry.encodedBodySize})),
|
||||
fcp: fcp ? Math.round(fcp.startTime) : null,
|
||||
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
||||
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||
@@ -602,6 +612,27 @@ async function runUpdateScenario({ browserType, server, proxy, profile, updateKi
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
// Phase 3 activation precedes idle feature caching. Measure updates from a
|
||||
// genuinely warm build, rather than counting unfinished first-install bytes.
|
||||
if (await page.evaluate(()=>!!window.Lazy && !!window.Lazy.warm)) {
|
||||
await page.evaluate(()=>Lazy.warm());
|
||||
const end=Date.now()+90000;
|
||||
while (true) {
|
||||
const ready=await page.evaluate(async()=>{
|
||||
const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');
|
||||
if(!r)return false;
|
||||
const m=(await r.json()).current;
|
||||
return (await Promise.all(Object.values(m.groups).flatMap(g=>g.files).map(async p=>{
|
||||
const response=await c.match(p+'?v='+m.files[p].h);
|
||||
return response?.headers.get('X-Asset-Hash')===m.files[p].h;
|
||||
}))).every(Boolean);
|
||||
});
|
||||
if(ready)break;
|
||||
if(Date.now()>end)throw Error('Idle warm did not complete before update benchmark');
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
// Switch server to Tree N+1 (app.js or theme-glass.css modification)
|
||||
const targetTree = updateKind === 'js' ? trees.treeUpdateJs : trees.treeUpdateCss;
|
||||
await server.restartWithPublic(targetTree);
|
||||
@@ -666,9 +697,14 @@ async function main() {
|
||||
const treeUpdateJs = path.join(tmpRoot, 'tree-update-js');
|
||||
const treeUpdateCss = path.join(tmpRoot, 'tree-update-css');
|
||||
|
||||
cpSync(path.join(REPO_ROOT, 'frontend'), treeBase, { recursive: true });
|
||||
cpSync(path.join(REPO_ROOT, 'frontend'), treeUpdateJs, { recursive: true });
|
||||
cpSync(path.join(REPO_ROOT, 'frontend'), treeUpdateCss, { recursive: true });
|
||||
let frontendSource=path.join(REPO_ROOT,'frontend');
|
||||
if(options.frontendCommit) {
|
||||
const archive=path.join(tmpRoot,'frontend.tar');
|
||||
writeFileSync(archive,execFileSync('git',['archive',options.frontendCommit,'frontend'],{cwd:REPO_ROOT,maxBuffer:20*1024*1024}));
|
||||
execFileSync('tar',['-xf',archive,'-C',tmpRoot]);
|
||||
frontendSource=path.join(tmpRoot,'frontend');
|
||||
}
|
||||
for(const tree of [treeBase,treeUpdateJs,treeUpdateCss])cpSync(frontendSource,tree,{recursive:true});
|
||||
|
||||
// Append 1 comment line to app.js in treeUpdateJs
|
||||
appendFileSync(path.join(treeUpdateJs, 'app.js'), '\n// baseline bench: 1-line app.js comment\n');
|
||||
@@ -684,6 +720,7 @@ async function main() {
|
||||
|
||||
const results = {
|
||||
date: new Date().toISOString(),
|
||||
frontendCommit: options.frontendCommit,
|
||||
commit: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT }).stdout.toString().trim(),
|
||||
runsConfigured: options.runs,
|
||||
browsers: {},
|
||||
@@ -729,6 +766,8 @@ async function main() {
|
||||
autoplayBlocked: coldRuns.some((r) => r.autoplayBlocked),
|
||||
wireBytesByType: coldRuns[0]?.wireBytesByType || {},
|
||||
requests: coldRuns[0]?.requests || [],
|
||||
initialJsCss: summarizeList(coldRuns.map(r=>r.initialJsCss)),
|
||||
initialAssetRequests: coldRuns[0]?.initialAssetRequests || [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
135
perf/lazy.mjs
Normal file
135
perf/lazy.mjs
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/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/lazy-'));
|
||||
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());}
|
||||
async function checkCssOrder(page){
|
||||
const changes=await page.evaluate(async()=>{
|
||||
await Lazy.load('layout:'+Lazy.layout);
|
||||
const settle=()=>new Promise(resolve=>setTimeout(resolve,300));await settle();
|
||||
const nodes=[...document.querySelectorAll('body,body *')],props=['display','position','color','background-color','font-family','font-size','padding','margin','border-radius','--bg','--text','--radius'];
|
||||
const snapshot=()=>nodes.map(n=>props.map(p=>getComputedStyle(n).getPropertyValue(p)));
|
||||
const links=[...document.querySelectorAll('link[data-lazy-css]')],positions=links.map(n=>[n,n.nextSibling]),before=snapshot();
|
||||
const loaded=()=>new Promise((resolve,reject)=>{let tries=0;function poll(){if(links.every(n=>n.sheet))return resolve();if(tries++>120)return reject(Error('Reordered CSS did not load'));requestAnimationFrame(poll);}poll();});
|
||||
links.forEach(n=>document.head.append(n));await loaded();await settle();const after=snapshot();
|
||||
for(const [n,next] of positions.reverse())document.head.insertBefore(n,next);
|
||||
await loaded();await settle();
|
||||
return before.flatMap((values,i)=>values.flatMap((v,j)=>v===after[i][j]?[]:[{element:nodes[i].id||nodes[i].className,property:props[j],before:v,after:after[i][j]}]));
|
||||
});
|
||||
assert.deepEqual(changes,[],'requesting scoped layout CSS earlier preserves its computed appearance');
|
||||
}
|
||||
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();let disconnected=false;
|
||||
await context.route('**/*',r=>r.request().url().startsWith(origin)?r.continue():r.abort());
|
||||
await context.addInitScript(()=>{window.__saveData=localStorage.getItem('perf-save-data')==='1';const observer=new MutationObserver(()=>{if(document.body){window.__firstBodyLayout=document.documentElement.dataset.layout;observer.disconnect();}});observer.observe(document,{childList:true,subtree:true});Object.defineProperty(navigator,'onLine',{configurable:true,get:()=>localStorage.getItem('perf-offline')!=='1'});Object.defineProperty(navigator,'connection',{configurable:true,value:{get saveData(){return window.__saveData;}}});});
|
||||
const page=await context.newPage(),errors=[],failedRequests=[];page.on('requestfailed',request=>{if(disconnected)failedRequests.push(request.url());});page.on('pageerror',e=>errors.push({message:e.message,disconnected}));page.on('console',m=>{if(m.type()==='error')console.log(name,'console:',m.text());});
|
||||
console.log(name,'initial boot');await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn'));
|
||||
await wait(page,async()=>!!(await navigator.serviceWorker.getRegistration())?.active);
|
||||
await 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);});
|
||||
console.log(name,'warm complete');assert.equal(await page.evaluate(()=>Lazy.loaded('feature:car-mode')),false);
|
||||
assert.equal(await page.evaluate(()=>[...document.querySelectorAll('.stg-overlay,.deck-chips,.vol-pct,.mode-name,.stg-bot,.ch-badge,.upnext-clear,.list-sub,.p2p-strip,.sleep-status .sl-ic,.sleep-status .sl-sub,.mc-tune')].every(n=>getComputedStyle(n).display==='none')),true,'classic retains shared visibility defaults without loading Glass CSS');
|
||||
await checkCssOrder(page);
|
||||
assert.equal(await page.evaluate(()=>Lazy.loaded('feature:piano')),false);
|
||||
assert.equal(await page.evaluate(()=>[typeof newPlaylist,typeof renderSettings,typeof enterServiceMode,typeof Notes.load,typeof LyricsCore.fromText].every(type=>type==='function')),true,'playlist, settings, service mode and lyric core remain in RAM');
|
||||
// Profile/imported settings use applyAppearance, not the Settings select handler.
|
||||
await page.evaluate(()=>{data.settings.layout='glass-stage';applyAppearance();});
|
||||
await wait(page,()=>document.documentElement.dataset.layout==='glass-stage'&&Lazy.layout==='glass-stage');
|
||||
await page.evaluate(()=>{data.settings.layout='classic';applyAppearance();});
|
||||
await wait(page,()=>document.documentElement.dataset.layout==='classic'&&Lazy.layout==='classic');
|
||||
// Eager application panels stay usable without executing optional settings modules.
|
||||
await page.click('[data-view="settings"]');await page.click('[data-section="appearance"]');await page.waitForSelector('#setLayout');
|
||||
assert.equal(await page.evaluate(()=>Lazy.loaded('feature:piano')),false);
|
||||
await page.selectOption('#setLayout','glass-stage');await page.waitForFunction(()=>document.documentElement.dataset.layout==='glass-stage');
|
||||
await page.evaluate(()=>{window.__saveData=true;localStorage.setItem('perf-save-data','1');localStorage.setItem('perf-offline','1');});
|
||||
await stop();disconnected=true;await proxy.setOffline(true);
|
||||
console.log(name,'offline reload');await page.reload();await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn'));
|
||||
assert.equal(await page.evaluate(()=>document.documentElement.dataset.layout),'glass-stage');
|
||||
assert.equal(await page.evaluate(()=>window.__firstBodyLayout),'glass-stage','selected layout is established before body/first paint');
|
||||
await checkCssOrder(page);
|
||||
const before=proxy.getStats().requestCount;
|
||||
await page.getByRole('button',{name:'Car mode',exact:true}).click();await page.waitForSelector('.car-mode');
|
||||
assert.equal(await page.locator('.car-controls button').count(),4,'all transport controls survive lazy setup');
|
||||
assert.equal(await page.locator('.car-controls #voiceSearchBtn').count(),0,'voice search stays outside transport');
|
||||
await page.getByRole('button',{name:'Exit Car mode'}).click();
|
||||
assert.equal(proxy.getStats().requestCount,before,'warmed feature first use reaches no network');
|
||||
await page.click('[data-view="settings"]');await page.click('[data-section="appearance"]');await page.selectOption('#setLayout','bento-hub');await page.waitForFunction(()=>document.documentElement.dataset.layout==='bento-hub');
|
||||
await page.selectOption('#setLayout','f7-swipe');await page.waitForFunction(()=>document.documentElement.dataset.layout==='f7-swipe'&&!!window.Framework7);
|
||||
await page.selectOption('#setLayout','classic');await page.waitForFunction(()=>document.documentElement.dataset.layout==='classic');
|
||||
assert.equal(proxy.getStats().requestCount,before,'offline layout changes reach no network');
|
||||
await page.getByRole('searchbox',{name:'Search settings and controls'}).fill('Enable piano transcription');
|
||||
await page.locator('.settings-results button').first().waitFor();
|
||||
await page.getByRole('searchbox',{name:'Search settings and controls'}).fill('');
|
||||
// Every extension panel renders from disk without executing other panels eagerly.
|
||||
for(const id of ['piano','midi','floating-lyrics','vocal-reducer','equalizer','setlist-import','broadcast']) {
|
||||
await page.evaluate(id=>document.querySelector('[data-section="'+id+'"]').click(),id);
|
||||
await wait(page,async id=>{const section=SettingsSections.get(id);return !section.lazyGroup;},id);
|
||||
}
|
||||
await page.evaluate(async()=>{await Lazy.load('feature:export');await Lazy.load('feature:video-edit');});
|
||||
assert.equal(proxy.getStats().requestCount,before,'all warmed extension assets stay offline');
|
||||
await start();disconnected=false;await proxy.setOffline(false);await page.evaluate(()=>localStorage.removeItem('perf-offline'));
|
||||
// Keep an old tab whose core and group contracts remain those of build N.
|
||||
const old=await context.newPage();old.on('pageerror',e=>errors.push({message:e.message,disconnected}));await old.goto(origin);await old.waitForSelector('#carModeBtn');
|
||||
await old.evaluate(async()=>{window.__saveData=true;await Lazy.load('feature:setlist-import');window.__pinned=window.SetlistImport;});
|
||||
await page.evaluate(()=>window.__saveData=true);
|
||||
const previous=await page.evaluate(()=>Lazy.manifest);
|
||||
await stop();
|
||||
for(const file of ['video-edit.js','setlist-import.js','car-mode.js'])writeFileSync(join(cwd,'public',file),readFileSync(join(cwd,'public',file),'utf8')+'\n// phase3 new file\n');
|
||||
const defs=JSON.parse(readFileSync(join(cwd,'public/assets.json')));defs.groups['feature:car-mode'].contract=2;writeFileSync(join(cwd,'public/assets.json'),JSON.stringify(defs));
|
||||
// Production stamping replaces the source-only native map with these updated groups.
|
||||
await start();const target=await(await fetch(origin+'/api/manifest')).json();
|
||||
await page.evaluate(async()=>{window.__saveData=true;const r=await navigator.serviceWorker.getRegistration();await r.update();});
|
||||
await wait(page,async target=>{const r=await navigator.serviceWorker.getRegistration();if(!r?.waiting)return false;return(await SwUpdate.askCacheStatus(r.waiting))?.version===target;},target.buildTag);
|
||||
// No automatic navigation; a real playing flag blocks the real apply flow.
|
||||
let navs=0;page.on('framenavigated',f=>{if(f===page.mainFrame())navs++;});
|
||||
await sleep(1000);assert.equal(navs,0);
|
||||
await page.evaluate(()=>Player._wantsPlaying=true);
|
||||
page.once('dialog',d=>d.dismiss());
|
||||
const guard=await page.evaluate(async()=>{try{await SwUpdate.applyUpdate({getRegistration:()=>navigator.serviceWorker.getRegistration(),askStatus:SwUpdate.askCacheStatus,reload:()=>location.reload()});return false;}catch{return true;}});
|
||||
assert.equal(guard,true);assert.equal(navs,0);
|
||||
await page.evaluate(()=>Player._wantsPlaying=false);
|
||||
// Explicit worker message, then explicit navigation into new stamped core.
|
||||
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.waiting.postMessage({type:'SKIP_WAITING'});});
|
||||
await wait(page,async tag=>{const c=await caches.open('ytplayer-assets');return(await(await c.match('/__ytp_asset_state')).json()).current.buildTag===tag;},target.buildTag);
|
||||
await page.reload();await page.waitForSelector('#carModeBtn');assert.equal(await page.evaluate(()=>Lazy.manifest.buildTag),target.buildTag);
|
||||
// No warm: the new page gets a same-contract N-1 file immediately from disk.
|
||||
const cacheKey='/video-edit.js?v='+target.files['/video-edit.js'].h;
|
||||
assert.equal(await page.evaluate(async key=>!!await(await caches.open('ytplayer-assets')).match(key),cacheKey),false);
|
||||
const stale=await page.evaluate(async key=>{const r=await fetch(key);return {h:r.headers.get('X-Asset-Hash'),body:await r.text()};},cacheKey);
|
||||
assert.equal(stale.h,previous.files['/video-edit.js'].h);assert.ok(!stale.body.includes('// phase3 new file'));
|
||||
await page.evaluate(()=>Lazy.load('feature:video-edit'));const pinned=await page.evaluate(()=>{window.__videoPinned=VideoEdit;return Lazy.loaded('feature:video-edit');});assert.equal(pinned,true);
|
||||
// Different contract is already downloaded before commit, but the old core must keep N-1.
|
||||
const oldRequests=[];old.on('request',r=>oldRequests.push(r.url()));
|
||||
await old.evaluate(()=>Lazy.load('feature:car-mode'));assert.equal(await old.evaluate(()=>Lazy.reloadRequired),true);
|
||||
await old.getByRole('button',{name:'Refresh UI',exact:true}).waitFor();
|
||||
assert.ok(oldRequests.some(u=>u.includes('/car-mode.js?v='+previous.files['/car-mode.js'].h)));
|
||||
await old.evaluate(()=>Lazy.load('feature:setlist-import'));assert.equal(await old.evaluate(()=>window.SetlistImport===window.__pinned),true);
|
||||
// Warm new files explicitly; compatible, unexecuted old-page group selects new cached URL.
|
||||
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.active.postMessage({type:'WARM_ASSETS',saveData:false});});
|
||||
await wait(page,async key=>!!await(await caches.open('ytplayer-assets')).match(key),cacheKey);
|
||||
await old.evaluate(()=>Lazy.load('feature:video-edit'));assert.ok(oldRequests.some(u=>u.includes(cacheKey)));
|
||||
await page.evaluate(()=>Lazy.load('feature:video-edit'));assert.equal(await page.evaluate(()=>window.VideoEdit===window.__videoPinned),true);
|
||||
const offlineTransportErrors=errors.filter(error=>name==='webkit'&&error.disconnected&&(error.message==='TypeError: Load failed'||/\/api\/(?:recommendations\?[^\s]*|version)\.$/.test(error.message)));
|
||||
assert.deepEqual(errors.filter(error=>!offlineTransportErrors.includes(error)),[]);
|
||||
if(offlineTransportErrors.length)assert.ok(failedRequests.some(url=>/\/api\/(?:recommendations|version)/.test(url)));
|
||||
results.push({browser:name,sourceBuildTag:previous.buildTag,classicSharedDefaults:true,cssOrderPreserved:true,settingsSearch:true,carControls:4,offlineFeature:true,firstUseNetwork:0,layoutsOffline:['glass-stage','bento-hub','f7-swipe','classic'],pinned:true,compatibleUnexecutedNewURL:true,incompatibleN1ReloadRequired:true,stagedN1:true,playbackGuard:true,prePaintLayout:true,offlineSettingsPanels:true,scriptErrors:[],offlineTransportErrors});
|
||||
console.log(name,'PASS');
|
||||
} finally {await browser?.close();await proxy?.close();await stop();}
|
||||
}
|
||||
try{for(const name of requested==='all'?['chromium','webkit']:[requested])await run(name);writeFileSync(join(repo,'perf/results/phase3-lazy-2026-10-08.json'),JSON.stringify({results},null,2)+'\n');}finally{rmSync(scratch,{recursive:true,force:true});}
|
||||
@@ -153,6 +153,9 @@ async function run(name) {
|
||||
assert.equal(banners, 1, 'no banner after applying the current build');
|
||||
assert.equal(await oldTab.evaluate(async()=>await (await fetch('/app.js')).text()),oldBytes,'open legacy tab keeps N-1 plain assets');
|
||||
await oldTab.close();
|
||||
// Phase 3 commits core + layout first; idle warming must fill the retained full shell before offline feature checks.
|
||||
await page.evaluate(()=>Lazy.warm());
|
||||
await waitAsync(page,async files=>{const cache=await caches.open('ytplayer-assets');return (await Promise.all(Object.keys(files).map(async path=>{const response=await cache.match(path+'?v='+files[path].h);return !!response&&response.headers.get('X-Asset-Hash')===files[path].h;}))).every(Boolean);},Object.fromEntries(shell.map(path=>[path==='/'?'/index.html':path,manifest.files[path==='/'?'/index.html':path]])));
|
||||
const cacheResult = await page.evaluate(async ({ tag, shell, files }) => {
|
||||
const cache = await caches.open('ytplayer-assets');
|
||||
const missing = [], incorrect = [];
|
||||
@@ -231,5 +234,5 @@ async function run(name) {
|
||||
try {
|
||||
const results = [];
|
||||
for (const name of browserName === 'all' ? ['chromium', 'webkit'] : [browserName]) results.push(await run(name));
|
||||
writeFileSync(join(repo, `perf/results/phase2-migration-${profile}-2026-10-07.json`), JSON.stringify({ commit, results }, null, 2) + '\n');
|
||||
writeFileSync(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 }); }
|
||||
|
||||
2349
perf/results/phase3-final-2026-10-08.json
Normal file
2349
perf/results/phase3-final-2026-10-08.json
Normal file
File diff suppressed because it is too large
Load Diff
132
perf/results/phase3-hashing-rollback-2026-10-08.json
Normal file
132
perf/results/phase3-hashing-rollback-2026-10-08.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"date": "2026-10-07T20:10:25.561Z",
|
||||
"frontendCommit": null,
|
||||
"commit": "46ec9ab2ca0f1b5365b5b05b08052df967d6fcfc",
|
||||
"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": 165,
|
||||
"min": 165,
|
||||
"max": 165,
|
||||
"values": [
|
||||
165
|
||||
]
|
||||
},
|
||||
"bootDone": {
|
||||
"median": 170,
|
||||
"min": 170,
|
||||
"max": 170,
|
||||
"values": [
|
||||
170
|
||||
]
|
||||
},
|
||||
"reloadDuration": {
|
||||
"median": 187,
|
||||
"min": 187,
|
||||
"max": 187,
|
||||
"values": [
|
||||
187
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"webkit": {
|
||||
"lte": {
|
||||
"offline": {
|
||||
"requestCount": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"wireBytes": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"fcp": {
|
||||
"median": 80,
|
||||
"min": 80,
|
||||
"max": 80,
|
||||
"values": [
|
||||
80
|
||||
]
|
||||
},
|
||||
"lcp": {
|
||||
"median": 136,
|
||||
"min": 136,
|
||||
"max": 136,
|
||||
"values": [
|
||||
136
|
||||
]
|
||||
},
|
||||
"domContentLoaded": {
|
||||
"median": 134,
|
||||
"min": 134,
|
||||
"max": 134,
|
||||
"values": [
|
||||
134
|
||||
]
|
||||
},
|
||||
"bootDone": {
|
||||
"median": 140,
|
||||
"min": 140,
|
||||
"max": 140,
|
||||
"values": [
|
||||
140
|
||||
]
|
||||
},
|
||||
"reloadDuration": {
|
||||
"median": 163,
|
||||
"min": 163,
|
||||
"max": 163,
|
||||
"values": [
|
||||
163
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
perf/results/phase3-lazy-2026-10-08.json
Normal file
79
perf/results/phase3-lazy-2026-10-08.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"browser": "chromium",
|
||||
"sourceBuildTag": "bea1f63eba85",
|
||||
"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": "bea1f63eba85",
|
||||
"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:38845/api/version.",
|
||||
"disconnected": true
|
||||
},
|
||||
{
|
||||
"message": "TypeError: Load failed",
|
||||
"disconnected": true
|
||||
},
|
||||
{
|
||||
"message": "/127.0.0.1:38845/api/recommendations?fp=163577c02a53bc40.",
|
||||
"disconnected": true
|
||||
},
|
||||
{
|
||||
"message": "TypeError: Load failed",
|
||||
"disconnected": true
|
||||
},
|
||||
{
|
||||
"message": "/127.0.0.1:38845/api/version.",
|
||||
"disconnected": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
41
perf/results/phase3-migration-lossy-2026-10-08.json
Normal file
41
perf/results/phase3-migration-lossy-2026-10-08.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"commit": "b77938a",
|
||||
"results": [
|
||||
{
|
||||
"browser": "chromium",
|
||||
"oldTag": "1175f1a1d2c1",
|
||||
"newTag": "bea1f63eba85",
|
||||
"banners": 1,
|
||||
"updateReloads": 1,
|
||||
"cachedFiles": 114,
|
||||
"durationMs": 24356,
|
||||
"wireBytes": 607853,
|
||||
"offline": true,
|
||||
"legacyURLs": 21,
|
||||
"profile": "lossy",
|
||||
"userInitiatedOnly": true,
|
||||
"playbackGuard": true,
|
||||
"evictionRepair": true,
|
||||
"incrementalRefresh": true,
|
||||
"offlineTransportErrors": 0
|
||||
},
|
||||
{
|
||||
"browser": "webkit",
|
||||
"oldTag": "1175f1a1d2c1",
|
||||
"newTag": "bea1f63eba85",
|
||||
"banners": 1,
|
||||
"updateReloads": 1,
|
||||
"cachedFiles": 114,
|
||||
"durationMs": 20402,
|
||||
"wireBytes": 729652,
|
||||
"offline": true,
|
||||
"legacyURLs": 21,
|
||||
"profile": "lossy",
|
||||
"userInitiatedOnly": true,
|
||||
"playbackGuard": true,
|
||||
"evictionRepair": true,
|
||||
"incrementalRefresh": true,
|
||||
"offlineTransportErrors": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
41
perf/results/phase3-migration-unthrottled-2026-10-08.json
Normal file
41
perf/results/phase3-migration-unthrottled-2026-10-08.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"commit": "b77938a",
|
||||
"results": [
|
||||
{
|
||||
"browser": "chromium",
|
||||
"oldTag": "1175f1a1d2c1",
|
||||
"newTag": "bea1f63eba85",
|
||||
"banners": 1,
|
||||
"updateReloads": 1,
|
||||
"cachedFiles": 114,
|
||||
"durationMs": 10864,
|
||||
"wireBytes": 625216,
|
||||
"offline": true,
|
||||
"legacyURLs": 21,
|
||||
"profile": "unthrottled",
|
||||
"userInitiatedOnly": true,
|
||||
"playbackGuard": true,
|
||||
"evictionRepair": true,
|
||||
"incrementalRefresh": true,
|
||||
"offlineTransportErrors": 0
|
||||
},
|
||||
{
|
||||
"browser": "webkit",
|
||||
"oldTag": "1175f1a1d2c1",
|
||||
"newTag": "bea1f63eba85",
|
||||
"banners": 1,
|
||||
"updateReloads": 1,
|
||||
"cachedFiles": 114,
|
||||
"durationMs": 9584,
|
||||
"wireBytes": 743322,
|
||||
"offline": true,
|
||||
"legacyURLs": 21,
|
||||
"profile": "unthrottled",
|
||||
"userInitiatedOnly": true,
|
||||
"playbackGuard": true,
|
||||
"evictionRepair": true,
|
||||
"incrementalRefresh": true,
|
||||
"offlineTransportErrors": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
2420
perf/results/phase3-phase0-initial-2026-10-08.json
Normal file
2420
perf/results/phase3-phase0-initial-2026-10-08.json
Normal file
File diff suppressed because it is too large
Load Diff
132
perf/results/phase3-sync-rollback-2026-10-08.json
Normal file
132
perf/results/phase3-sync-rollback-2026-10-08.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"date": "2026-10-07T20:10:14.094Z",
|
||||
"frontendCommit": null,
|
||||
"commit": "46ec9ab2ca0f1b5365b5b05b08052df967d6fcfc",
|
||||
"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": 44,
|
||||
"min": 44,
|
||||
"max": 44,
|
||||
"values": [
|
||||
44
|
||||
]
|
||||
},
|
||||
"lcp": {
|
||||
"median": 44,
|
||||
"min": 44,
|
||||
"max": 44,
|
||||
"values": [
|
||||
44
|
||||
]
|
||||
},
|
||||
"domContentLoaded": {
|
||||
"median": 135,
|
||||
"min": 135,
|
||||
"max": 135,
|
||||
"values": [
|
||||
135
|
||||
]
|
||||
},
|
||||
"bootDone": {
|
||||
"median": 140,
|
||||
"min": 140,
|
||||
"max": 140,
|
||||
"values": [
|
||||
140
|
||||
]
|
||||
},
|
||||
"reloadDuration": {
|
||||
"median": 158,
|
||||
"min": 158,
|
||||
"max": 158,
|
||||
"values": [
|
||||
158
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"webkit": {
|
||||
"lte": {
|
||||
"offline": {
|
||||
"requestCount": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"wireBytes": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"fcp": {
|
||||
"median": 64,
|
||||
"min": 64,
|
||||
"max": 64,
|
||||
"values": [
|
||||
64
|
||||
]
|
||||
},
|
||||
"lcp": {
|
||||
"median": 105,
|
||||
"min": 105,
|
||||
"max": 105,
|
||||
"values": [
|
||||
105
|
||||
]
|
||||
},
|
||||
"domContentLoaded": {
|
||||
"median": 103,
|
||||
"min": 103,
|
||||
"max": 103,
|
||||
"values": [
|
||||
103
|
||||
]
|
||||
},
|
||||
"bootDone": {
|
||||
"median": 107,
|
||||
"min": 107,
|
||||
"max": 107,
|
||||
"values": [
|
||||
107
|
||||
]
|
||||
},
|
||||
"reloadDuration": {
|
||||
"median": 124,
|
||||
"min": 124,
|
||||
"max": 124,
|
||||
"values": [
|
||||
124
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
plans/phase3-plan.md
Normal file
37
plans/phase3-plan.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Phase 3 — lazy groups and staged asset delivery
|
||||
|
||||
Start from Phase 2 tip 4950d3e on codex/phase3. Implement master §3.3,
|
||||
§2c and §6/6b; preserve the production client's explicit Refresh UI exception.
|
||||
|
||||
1. Add tests first for embedded build-local group URLs, ordered/idempotent lazy
|
||||
loading, selected-layout bootstrap under the existing CSP, stale fallback,
|
||||
contract blocking, background warming and playback guards.
|
||||
2. Extend server/asset-manifest.js and index stamping with inert JSON group data
|
||||
(exclude derived index/SW hashes to avoid self-reference). Add frontend/lazy.js
|
||||
as an external head script; read _ytpdata safely and insert selected CSS before
|
||||
paint. Update assets.json and shell-consistency/static-delivery tests deliberately.
|
||||
3. Remove background layout/feature tags from index.html. Adapt app.js feature
|
||||
entry points and Settings layout switching; inspect each module's init and
|
||||
dependencies. Preserve eager playlist/settings/service mode/lyrics editor,
|
||||
saved audio processing and automatic device sharing. Keep pure settings
|
||||
metadata eager where synchronous player defaults require it; load DSP on intent.
|
||||
4. Extend asset-sync-core.js, sw.js and sw-update.js for active-layout blocking,
|
||||
idle/save-data-aware warming, exact hashed N-1 fallback with contract checks,
|
||||
and build-local URLs. Preserve atomic commit, resumability, retention and the
|
||||
explicit-message-only activation/playback protections. Never replace running
|
||||
playback code. Document any seam requiring an owner decision before proceeding.
|
||||
5. Add perf/lazy.mjs real-Bun Chromium/WebKit checks: warmed offline features with
|
||||
zero network on first use, selected offline layout and layout switch, staged
|
||||
update and contract guard, and playback-protected Refresh UI. Recheck migration.
|
||||
Run reduced baseline cold/warm/offline against after-phase0; report compressed
|
||||
initial JS/CSS bytes and boot-done. Target less initial JS/CSS by removed lazy
|
||||
groups; first paint no worse; eager features immediate. Explain measured gaps.
|
||||
6. Review the whole diff against §6/6b, commit a report of at most 350 words and
|
||||
measurements, then overwrite queue-assets/signal with DONE03 and the final hash.
|
||||
|
||||
Each logical implementation commit must pass node --test frontend/*.test.js and
|
||||
server bun install + bun run test. No push/deploy or main-checkout edits. Risks:
|
||||
classic-script initialization/TDZ, lazy settings registrations, remembered DSP,
|
||||
old-tab URL affinity and safe replacement of already executing feature modules.
|
||||
Rollback: revert Phase 3 commits; ASSET_SYNC=0 / ASSET_HASHING=0 keep their tested
|
||||
legacy cache behavior. Retain the full legacy SHELL, adding only new eager helpers.
|
||||
42
plans/phase3-report.md
Normal file
42
plans/phase3-report.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Phase 3 report
|
||||
|
||||
Shipped on `codex/phase3`: plan `895f20a`; contract synchronization `f804a23`;
|
||||
pinned loader `4853c2c`; server embedding `d98d444`; integration `5d6a154`;
|
||||
review fixes `ccfb7d9` through `fab444b`; browser verification/results `b840505`.
|
||||
|
||||
The CSP-safe external head loader reads saved appearance before body creation.
|
||||
Modern clients block core plus their active layout/dependencies; Settings loads before switching
|
||||
and saves immediately. Imported settings also report their layout. Shared markup
|
||||
visibility remains eager. Rare features, stats/export and layout scripts load on
|
||||
intent; playlist/settings/service mode/lyrics editor remain in RAM. Idle warming
|
||||
honours Save-Data and shares one six-download pool across tabs. Legacy migration
|
||||
blocks all layouts because old pages cannot report selection. P2P/direct preserve
|
||||
eight-second startup; EQ metadata/DeviceDB stay eager.
|
||||
|
||||
Accepted decision: executing groups stay pinned until a playback-guarded user
|
||||
reload. Unexecuted groups use newer cached URLs only with matching core/dependency
|
||||
contracts; otherwise retain N-1, mark reload-required and request the existing
|
||||
Refresh UI banner. Changed contracts block activation. Phase 4 must supply
|
||||
state disposal/handoff before live replacement; app.js splitting remains there.
|
||||
|
||||
Verification: 190 frontend/183 server tests, no skips. Chromium/WebKit pass warmed
|
||||
feature first-use with zero network, offline layouts, pre-body selection,
|
||||
Settings search/extensions, Car controls, imported appearance, stale fallback,
|
||||
contract/pinning guards and playback protection. Actual b77938a migration passes
|
||||
both proxy profiles: one user-initiated reload/banner, N-1 tab and offline boot.
|
||||
Both rollback flags pass offline checks.
|
||||
|
||||
Reduced one-run LTE, Chromium/WebKit (`phase3-final-2026-10-08.json`): initial compressed JS/CSS
|
||||
221,307/274,813 bytes versus reconstructed Phase 0 276,414/338,940 (−19.9%/−18.9%).
|
||||
Historical Phase 0 boot 10,480/3,290 ms becomes 7,193/3,293; warm 419/81;
|
||||
offline 166/84. FCP 1,356/2,467 versus 1,572/1,100: WebKit first paint regresses,
|
||||
so the paint target remains unmet despite bootstrap/preload reordering. Core JS
|
||||
updates transfer 159,905/194,026 bytes; inactive CSS readiness 29,724/28,082,
|
||||
with that CSS downloaded after activation. Warm counters include idle downloads.
|
||||
WebKit still duplicates cold app transfer; repeated medians await Phase 5.
|
||||
|
||||
Workers/importScripts/WASM, piano engine and F7 now use hashed URLs; fonts/icons/
|
||||
webmanifest remain plain and cached (exact matrix in perf/README.md). External
|
||||
piano model downloads remain on request. Owner iPhone checks: installed/offline
|
||||
layouts, feature first-use, update guard/override, locked playback 30 minutes,
|
||||
OPFS/quota coexistence. No deployment.
|
||||
29
plans/phase3-staged-apply-decision.md
Normal file
29
plans/phase3-staged-apply-decision.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Phase 3 staged apply — accepted owner decision
|
||||
|
||||
The owner accepted this decision on 2026-10-08. It refines master §2c.5:
|
||||
background downloading never implies that an executing classic script can be
|
||||
safely replaced.
|
||||
|
||||
1. Already executing groups stay pinned until the session ends or the next
|
||||
user-initiated, playback-guarded reload. P2P/direct and every stateful instance
|
||||
are never re-evaluated live. Phase 4 must provide disposal and state handoff
|
||||
before live replacement is allowed.
|
||||
2. A group that has not executed can immediately use newer verified cached URLs
|
||||
only when its contract and dependency contracts equal those expected by the
|
||||
running core's embedded manifest. For a different contract, keep the running
|
||||
build's N-1 URLs, set `Lazy.reloadRequired`, and request the existing Refresh UI
|
||||
banner through the unchanged meta-build versus `/api/version` comparison.
|
||||
3. New core boot may receive a verified N-1 response for an uncached new URL only
|
||||
when group contracts match. The response retains its actual hash; it is never
|
||||
stored under the new hash. Contract changes join the blocking download set.
|
||||
|
||||
Reason: direct-media.js owns private room, pending transfer and waiting maps;
|
||||
WebRTC callbacks retain these closures. Re-evaluating the singleton redirects
|
||||
messages away from those transfers. P2P sockets/timers and piano/MIDI/floating
|
||||
windows have similar state. Contract equality alone does not migrate it.
|
||||
|
||||
Implemented in 4853c2c and 5d6a154. Node loader tests cover pinned execution,
|
||||
compatible unexecuted selection, incompatible N-1 plus reload-required, and
|
||||
incompatible dependencies. Worker tests cover same-contract fallback without
|
||||
cache poisoning and changed-contract readiness before commit. `perf/lazy.mjs`
|
||||
checks these decisions with real workers on Chromium and WebKit.
|
||||
@@ -8,6 +8,15 @@ const tagHash = bytes => createHash('sha256').update(bytes).digest('hex').slice(
|
||||
export const injectBuildTag = (source, tag) => source.replace(
|
||||
/typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'/, JSON.stringify(tag));
|
||||
|
||||
function embedAssets(source, files, groups, buildTag, hashing) {
|
||||
const pageFiles = {};
|
||||
for (const group of Object.values(groups)) for (const path of group.files) {
|
||||
if (path !== '/index.html' && path !== '/sw.js') pageFiles[path] = { ...files[path], h: hashing ? files[path].h : buildTag };
|
||||
}
|
||||
const json = JSON.stringify({ buildTag, groups, files: pageFiles }).replace(/</g, '\\u003c');
|
||||
return source.replace(/(<script type="application\/json" id="ytp-assets">)[\s\S]*?(<\/script>)/, (_, open, close) => open + json + close);
|
||||
}
|
||||
|
||||
export function stampIndex(source, files, { hashing = true, buildTag = '__BUILD_TAG__' } = {}) {
|
||||
if (!hashing) return source.replace('__BUILD_TAG__', buildTag)
|
||||
.replace(/((?:href|src)=")((?![a-z]+:|\/)[^"?]+\.(?:css|js))(")/g, `$1$2?v=${buildTag}$3`);
|
||||
@@ -66,11 +75,11 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu
|
||||
// Derived build metadata cannot be an input to its own hash. Canonicalize
|
||||
// the index meta and SW injected tag, then publish hashes of the final bytes.
|
||||
// All original bytes (including index/SW source) remain inputs via source hashes.
|
||||
let canonicalIndex = source === null ? null : stampIndex(source, files, { hashing });
|
||||
let canonicalIndex = source === null ? null : stampIndex(embedAssets(source, files, groups, '__BUILD_TAG__', hashing), files, { hashing });
|
||||
const canonical = { assetSync, files: { ...files }, groups, contracts };
|
||||
if (canonicalIndex !== null && hashing) canonical.files['/index.html'] = { ...files['/index.html'], h: assetHash(canonicalIndex), s: Buffer.byteLength(canonicalIndex), source: files['/index.html'].h };
|
||||
const buildTag = hashing ? tagHash(JSON.stringify(canonical)) : (override || legacy.digest('hex').slice(0, 12));
|
||||
const index = source === null ? null : stampIndex(source, files, { hashing, buildTag });
|
||||
const index = source === null ? null : embedAssets(stampIndex(source, files, { hashing, buildTag }), files, groups, buildTag, hashing);
|
||||
const sw = swSource === null ? null : injectBuildTag(swSource, buildTag)
|
||||
.replace("typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true", JSON.stringify(assetSync))
|
||||
.replace("importScripts('/asset-sync-core.js')", "importScripts('/asset-sync-core.js?v=" + (files['/asset-sync-core.js']?.h || '') + "')");
|
||||
|
||||
@@ -72,3 +72,19 @@ test('single-tag hashing rollback also selects the legacy worker', () => fixture
|
||||
writeFileSync(join(dir,'sw.js'), "const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'old'; const SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true;");
|
||||
expect(createAssetManifest(dir,{hashing:false}).sw).toContain('const SYNC = false');
|
||||
}));
|
||||
|
||||
test('index embeds build-local group hashes without recursively embedding index/SW hashes', () => fixture(dir => {
|
||||
writeFileSync(join(dir, 'index.html'), '<meta name="ytp-build" content="__BUILD_TAG__"><script type="application/json" id="ytp-assets">{}</script><script src="app.js"></script>');
|
||||
const result=createAssetManifest(dir);
|
||||
const map=JSON.parse(result.index.match(/id="ytp-assets">([^<]+)<\/script>/)[1]);
|
||||
expect(map.buildTag).toBe(result.manifest.buildTag);
|
||||
expect(map.groups).toEqual(result.manifest.groups);
|
||||
expect(map.files['/app.js'].h).toBe(result.manifest.files['/app.js'].h);
|
||||
expect(map.files['/index.html']).toBeUndefined();
|
||||
expect(map.files['/sw.js']).toBeUndefined();
|
||||
expect(createAssetManifest(dir).manifest).toEqual(result.manifest);
|
||||
expect(result.manifest.files['/index.html'].h).toBe(hash(result.index));
|
||||
const off=createAssetManifest(dir,{hashing:false});
|
||||
const legacy=JSON.parse(off.index.match(/id="ytp-assets">([^<]+)<\/script>/)[1]);
|
||||
expect(legacy.files['/app.js'].h).toBe(off.manifest.buildTag);
|
||||
}));
|
||||
|
||||
@@ -13,7 +13,7 @@ mkdirSync(publicDir);
|
||||
mkdirSync(dataDir);
|
||||
mkdirSync(join(publicDir, 'fonts'));
|
||||
mkdirSync(join(publicDir, 'icons'));
|
||||
writeFileSync(join(publicDir, 'index.html'), '<!doctype html><meta name="ytp-build" content="__BUILD_TAG__"><link rel="stylesheet" href="app.css"><script src="app.js"></script><script src="https://cdn.example/x.js"></script><link rel="stylesheet" href="/root.css">');
|
||||
writeFileSync(join(publicDir, 'index.html'), '<!doctype html><script type="application/json" id="ytp-assets">{}</script><meta name="ytp-build" content="__BUILD_TAG__"><link rel="stylesheet" href="app.css"><script src="app.js"></script><script src="https://cdn.example/x.js"></script><link rel="stylesheet" href="/root.css">');
|
||||
writeFileSync(join(publicDir, 'app.css'), 'body{}');
|
||||
writeFileSync(join(publicDir, 'app.js'), 'window.app=true;');
|
||||
writeFileSync(join(publicDir, 'root.css'), 'body{color:red}');
|
||||
@@ -219,6 +219,20 @@ describe('static delivery characterization', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('all index routes carry the identical embedded build-local map', async () => {
|
||||
const manifest=await (await fetch(`${server.base}/api/manifest`)).json();
|
||||
let expected;
|
||||
for(const path of ['/', '/index.html','/search','/playlist/x']) {
|
||||
const html=await (await fetch(`${server.base}${path}`)).text();
|
||||
const map=JSON.parse(html.match(/id="ytp-assets">([^<]+)<\/script>/)[1]);
|
||||
expect(map.buildTag).toBe(manifest.buildTag);
|
||||
expect(map.groups).toEqual(manifest.groups);
|
||||
expect(map.files['/app.js'].h).toBe(manifest.files['/app.js'].h);
|
||||
expect(map.files['/index.html']).toBeUndefined();
|
||||
if(expected)expect(html).toBe(expected);else expected=html;
|
||||
}
|
||||
});
|
||||
|
||||
test('SPA routes fall back to the stamped index', async () => {
|
||||
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
||||
for (const path of ['/search', '/playlist/x']) {
|
||||
|
||||
Reference in New Issue
Block a user