Load layouts and optional features from staged asset caches
This commit is contained in:
@@ -1666,7 +1666,10 @@ 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;
|
||||
}
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B';
|
||||
@@ -2056,7 +2059,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 +3139,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 +3166,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 +3192,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 +5042,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 +5314,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 +5927,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 +5959,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 +6691,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 +6731,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 +6743,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 +6847,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 +6864,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 +6911,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 +6939,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 +7814,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 +7855,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 +9123,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 +9889,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 +11143,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 +12292,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 +12367,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 +12614,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 +12644,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 +12657,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 +12672,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 +12706,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,9 +28,10 @@
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -34,3 +34,14 @@ test('background synchronization fills all groups without publishing a new curre
|
||||
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'));
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ const {test}=require('node:test');const assert=require('node:assert/strict');con
|
||||
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 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=>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}
|
||||
@@ -10,3 +10,28 @@ function environment(){
|
||||
}
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -51,18 +51,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 +78,8 @@
|
||||
"eager": false,
|
||||
"files": [
|
||||
"/theme-classic.css"
|
||||
]
|
||||
],
|
||||
"background": true
|
||||
},
|
||||
"layout:bento-hub": {
|
||||
"contract": 1,
|
||||
@@ -80,6 +89,10 @@
|
||||
"/bento-hub.js",
|
||||
"/fonts/PlusJakartaSans-latin.woff2",
|
||||
"/fonts/PlusJakartaSans-latin-ext.woff2"
|
||||
],
|
||||
"background": true,
|
||||
"dependencies": [
|
||||
"feature:layout-controls"
|
||||
]
|
||||
},
|
||||
"layout:f7-swipe": {
|
||||
@@ -87,9 +100,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 +112,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 +145,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 +178,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 +202,10 @@
|
||||
"/direct-media.js",
|
||||
"/direct-media.css",
|
||||
"/direct-recv-worker.js"
|
||||
],
|
||||
"background": true,
|
||||
"dependencies": [
|
||||
"feature:p2p"
|
||||
]
|
||||
},
|
||||
"feature:video-edit": {
|
||||
@@ -186,21 +213,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 +238,8 @@
|
||||
"files": [],
|
||||
"embeddedIn": [
|
||||
"/app.js"
|
||||
]
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"feature:settings": {
|
||||
"contract": 1,
|
||||
@@ -218,7 +249,8 @@
|
||||
"/app.js",
|
||||
"/settings-sections.js",
|
||||
"/settings-sections.css"
|
||||
]
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"feature:service-mode": {
|
||||
"contract": 1,
|
||||
@@ -227,7 +259,8 @@
|
||||
"embeddedIn": [
|
||||
"/app.js",
|
||||
"/service-ui.css"
|
||||
]
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
"feature:lyrics-editor": {
|
||||
"contract": 1,
|
||||
@@ -237,7 +270,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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
overlay = doc.createElement('section'); overlay.className = 'car-mode'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', 'Car mode');
|
||||
const exit = doc.createElement('button'); exit.type = 'button'; exit.className = 'car-exit'; exit.textContent = 'Exit Car mode'; exit.onclick = close;
|
||||
const title = doc.createElement('h1'); const controls = doc.createElement('div'); controls.className = 'car-controls'; let toggle;
|
||||
for (const [label, action] of [['Previous', 'prev'], ['Play / Pause', 'toggle'], ['15 s back', 'back'], ['Next', 'next']]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = label; b.setAttribute('aria-label', label); b.onclick = () => adapter.action(action); if (action === 'toggle') toggle = b; controls.append(b); }
|
||||
for (const [label, action] of [['Previous', 'prev'], ['Play / Pause', 'toggle'], ['15 s back', 'back'], ['Next', 'next']]) { const b = doc.getElementById('voiceSearchBtn') || doc.createElement('button'); b.id='voiceSearchBtn'; b.type = 'button'; b.textContent = label; b.setAttribute('aria-label', label); b.onclick = () => adapter.action(action); if (action === 'toggle') toggle = b; controls.append(b); }
|
||||
const update = () => { const state = adapter.state(); title.textContent = state.title || 'Choose a song before entering Car mode'; toggle.textContent = state.paused ? 'Play' : 'Pause'; }; update();
|
||||
overlay.append(exit, title, controls); doc.body.append(overlay); timer = setInterval(update, 500); exit.focus();
|
||||
overlay.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); } else if (e.key === 'Tab') { const buttons = [...overlay.querySelectorAll('button')], first = buttons[0], last = buttons.at(-1); if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus(); } } e.stopPropagation(); };
|
||||
@@ -18,12 +18,12 @@
|
||||
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;
|
||||
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 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.getElementById('voiceSearchBtn') || doc.createElement('button'); b.id='voiceSearchBtn'; 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;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -24,22 +24,15 @@
|
||||
<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" />
|
||||
<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","/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="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 +643,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 +658,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>
|
||||
|
||||
@@ -4,15 +4,22 @@
|
||||
const doc = root.document;
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(doc.getElementById('ytp-assets').textContent); }
|
||||
catch { return; } // Native/static shells retain their eager script path.
|
||||
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);
|
||||
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 {
|
||||
@@ -20,15 +27,22 @@
|
||||
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 (newer.contract !== built.contract) { reloadRequired(name); return; }
|
||||
for (const path of built.files) {
|
||||
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) selected.set(path, 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) {
|
||||
@@ -54,8 +68,8 @@
|
||||
if (!group) return Promise.reject(new Error('Unknown asset group: ' + name));
|
||||
const task = (async () => {
|
||||
for (const dependency of group.dependencies || []) await load(dependency);
|
||||
await select(name);
|
||||
for (const path of group.files) if (!/-worker\.js$/.test(path)) await inject(path);
|
||||
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;
|
||||
})();
|
||||
@@ -69,7 +83,31 @@
|
||||
const api = {
|
||||
load, prefetch: load, loaded: name => !!manifest.groups[name]?.executed, url,
|
||||
manifest, layout: 'classic', reloadRequired: false,
|
||||
onLoad(name, fn) { const list = hooks.get(name) || []; list.push(fn); hooks.set(name, list); },
|
||||
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);
|
||||
@@ -84,10 +122,18 @@
|
||||
},
|
||||
};
|
||||
root.Lazy = api;
|
||||
try { api.layout = JSON.parse(root.localStorage.getItem('_ytpdata') || '{}').settings?.layout || 'classic'; } catch {}
|
||||
doc.documentElement.dataset.layout = api.layout;
|
||||
const layout = manifest.groups['layout:' + api.layout] || manifest.groups['layout:classic'];
|
||||
if (doc.readyState === 'loading') for (const path of layout?.files || []) {
|
||||
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);
|
||||
|
||||
@@ -63,3 +63,16 @@ test('unexecuted incompatible group keeps its running-build N-1 URLs and require
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -45,14 +45,23 @@
|
||||
const text = document.createElement('span'), title = document.createElement('b'), summary = document.createElement('small'); title.textContent = section.title; text.append(title, summary); button.append(text);
|
||||
button.setAttribute('aria-controls', 'settings-section-' + section.id); navigation.append(button); rows.set(section.id, { button, summary });
|
||||
const panel = document.createElement('section'); panel.id = 'settings-section-' + section.id; panel.className = 'settings-section'; panel.setAttribute('aria-label', section.title); panels.set(section.id, panel); detail.append(panel);
|
||||
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
|
||||
if (!section.lazyGroup) {
|
||||
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
|
||||
}
|
||||
button.addEventListener('click', () => open(section.id));
|
||||
if(section.lazyGroup)root.Lazy.intent(button,section.lazyGroup);
|
||||
}
|
||||
}
|
||||
grid.append(navigation, detail); host.replaceChildren(toolbar, results, empty, grid);
|
||||
function summaries() { for (const section of sections) { try { rows.get(section.id).summary.textContent = section.summary(context) || ''; } catch {} } }
|
||||
function show(id, inDetail, focusId) {
|
||||
if (registry.get(id)) active = id;
|
||||
const section = sections.find(section => section.id === active);
|
||||
const panel = panels.get(active);
|
||||
if ((inDetail || win.matchMedia?.('(min-width: 861px)').matches) && section?.lazyGroup && !panel.dataset.rendered) {
|
||||
panel.dataset.rendered = '1';
|
||||
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
|
||||
}
|
||||
host.dataset.detail = inDetail ? '1' : '0';
|
||||
for (const [key, panel] of panels) panel.hidden = key !== active;
|
||||
for (const [key, row] of rows) row.button.setAttribute('aria-current', key === active ? 'true' : 'false');
|
||||
|
||||
@@ -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,16 @@ 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.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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -74,6 +74,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 +291,27 @@ self.addEventListener('fetch', (e) => {
|
||||
|
||||
// ---- Message: handle SKIP_WAITING from the client ----
|
||||
const playingClients = new Map(), deferredClients = new Set();
|
||||
let activeLayout = 'classic', layoutReply;
|
||||
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) {
|
||||
e.waitUntil((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)));
|
||||
}
|
||||
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 +330,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 +474,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 +493,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 +533,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 +560,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}); }
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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