Load layouts and optional features from staged asset caches

This commit is contained in:
Jonathan Sykes
2026-10-08 01:29:50 +08:00
parent d98d444552
commit 5d6a1545df
30 changed files with 512 additions and 146 deletions

View File

@@ -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',