diff --git a/CLAUDE.md b/CLAUDE.md index 7e3c27e..73aae28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -309,6 +309,20 @@ Local DB file: `server/data/ytplayer.db` (gitignored). `BUILD_TAG` is computed f worker and the SW_UPDATE_AVAILABLE broadcast are only *prompts to re-check*. This loop was "fixed" three times by chasing individual triggers — don't add a trigger that calls `showUpdateBanner()` directly. +- **Background download + instant swap (added 2026-09-22).** A new worker + precaches the whole shell during `install`, so by the time the user presses + "Refresh UI" the build is usually already on disk. `applyUpdate` asks the + **waiting** worker `CACHE_STATUS` over a `MessageChannel`; when it answers + `ready: true` (it re-checks every SHELL url, since a cache can be evicted) + the download is skipped entirely and the swap is immediate. + Two rules keep this safe: the probe is **opt-in** (`askStatus`) — never + implicit, because a probe awaiting a reply hangs forever if the caller + injects a `setTimeout` that never fires (the existing tests do exactly + that) — and *any* doubt (no reply, timeout, thrown error, `ready: false`) + falls back to the all-or-nothing download below, which is still what + guarantees correctness. `maybeShowUpdateBanner` also calls `prefetchUpdate()` + → `registration.update()` so the download starts the moment a new build is + seen rather than when the user clicks. - **Refresh UI** (`frontend/sw-update.js`): `refreshShellInPlace()` re-downloads every file the shell caches hold — cache-busted (`?__ytpfresh=`) so even an OLD worker's cache-first handler can't answer from its cache, 3 tries per file, @@ -361,6 +375,19 @@ Local DB file: `server/data/ytplayer.db` (gitignored). `BUILD_TAG` is computed f the fetch resolves to `null` offline and `respondWith(null)` throws. It ends with `|| Response.error()`. +## First paint vs. the network (launch) +`boot()` used to `await` the profile pull and the shared-playlist inbox before +the first `render()`, so on a slow link the sidebar stayed empty for as long as +the network took. Everything the device knows is already in localStorage, so it +now paints immediately and reconciles afterwards: +- `?list=` / `?profile=` share links still run **before** the first paint — they + *replace* the synced slice, so painting first would flash the old playlists + and swap them out. They are skipped entirely when the parameter is absent. +- `syncOnLaunch()` then runs the network pass with a spinner (`#syncSpinner`, + beside the Playlists header, `setSyncing()` is counted so the last finisher + clears it) and re-renders **only if** `playlistFingerprint()` changed — a + needless render would drop the sidebar's scroll position. + ## Data model quirks - Client state persists in localStorage key **`_ytpdata`** and syncs (debounced 400 ms) to `POST /api/user/sync`, keyed by a browser fingerprint. diff --git a/frontend/app.js b/frontend/app.js index 0a71dbd..9c5d319 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -427,6 +427,44 @@ function applyProfileData(name, payload, updatedAt) { API.saveData(data).catch(() => {}); } +// ---- "syncing" indicator ------------------------------------------------- +// Playlists paint from localStorage on the first frame, so the network pass +// needs to say it is still working. Counted, because several things sync at +// once and the last one to finish should be the one that hides it. +let _syncBusy = 0; +function setSyncing(on) { + _syncBusy = Math.max(0, _syncBusy + (on ? 1 : -1)); + const el = document.getElementById('syncSpinner'); + if (el) el.classList.toggle('hidden', _syncBusy === 0); +} + +/** Runs the launch network pass without blocking the first paint. */ +async function syncOnLaunch() { + setSyncing(true); + const before = playlistFingerprint(); + try { + await pullProfileIfNewer(); + // A delivery is addressed to a profile, so this runs once we know which + // one (if any) this device is linked to. + await checkPlaylistInbox(); + } catch { /* offline — the local copy is already on screen */ } + finally { setSyncing(false); } + // Only repaint when the server actually gave us something different; + // a needless render would drop the sidebar's scroll position. + if (playlistFingerprint() !== before) { + renderSmartSidebar(); + render(); + } + updateProfileStatus(); +} + +// Cheap "did the synced slice change?" check — names, ids and lengths. +function playlistFingerprint() { + try { + return JSON.stringify((data.playlists || []).map((p) => [p.id, p.name, (p.videos || []).length])); + } catch { return String(Math.random()); } +} + // On launch: adopt the server copy when it's newer than this device's last // sync (another device pushed since); otherwise push local state up. async function pullProfileIfNewer() { @@ -9041,7 +9079,32 @@ async function maybeShowUpdateBanner() { if (!server) return; const running = RUNNING_BUILD || _knownBuildTag; if (!running) { _knownBuildTag = server; return; } - if (server !== running) showUpdateBanner(server); + if (server !== running) { + // Start fetching the new build NOW, in the background: registration.update() + // installs the new worker, whose install precaches the whole shell. By the + // time the user presses "Refresh UI" it is usually already on disk, and the + // swap is instant. Purely an optimisation — if it never finishes, applyUpdate + // falls back to downloading. + prefetchUpdate(); + showUpdateBanner(server); + } +} + +// Kicks the service worker into installing the new build in the background. +// Safe to call repeatedly: update() is a no-op when a worker is already +// installing or waiting. +let _prefetchedFor = null; +function prefetchUpdate() { + if (!('serviceWorker' in navigator)) return; + const tag = _knownBuildTag || 'new'; + if (_prefetchedFor === tag) return; + _prefetchedFor = tag; + (async () => { + try { + const reg = _swReg || await navigator.serviceWorker.getRegistration(); + if (reg) await reg.update(); + } catch { /* offline or blocked — "Refresh UI" still downloads */ } + })(); } function checkBuildTag() { @@ -9127,7 +9190,8 @@ async function applyUpdate(target, { quiet = false } = {}) { _updating = true; const prev = readUpdateState(); writeUpdateState({ target, attempt: (prev ? prev.attempt : 0) + 1 }); - if (!quiet) _updateToast = toast('Downloading the update…', { duration: 125000 }); + // The toast is raised by onPhase below, once we know whether this is an + // instant swap or a download. try { const reg = _swReg || (('serviceWorker' in navigator) ? await navigator.serviceWorker.getRegistration() : null); const SU = window.SwUpdate; @@ -9137,6 +9201,16 @@ async function applyUpdate(target, { quiet = false } = {}) { reg, container: navigator.serviceWorker, reload, + // When the waiting worker already downloaded this build in the + // background, the toast says so and nothing is re-downloaded. + askStatus: (worker) => SU.askCacheStatus(worker, { timeoutMs: 4000, setTimeout: (fn, ms) => setTimeout(fn, ms) }), + onPhase: (phase) => { + if (quiet) return; + if (_updateToast) _updateToast.remove(); + _updateToast = phase === 'ready' + ? toast('Applying the update…', { duration: 20000 }) + : toast('Downloading the update…', { duration: 125000 }); + }, refreshShell: ('caches' in window) ? () => SU.refreshShellInPlace({ cachesApi: caches, fetchFn: (url, init) => fetch(url, init), @@ -9260,15 +9334,29 @@ async function boot() { } catch { // first run / bridge not ready — start with defaults } - // A ?list=… or ?profile=… share link; then adopt the server copy if another - // device pushed a newer one. Both run before any rendering so no re-render - // pass is needed. - await adoptSharedPlaylistFromUrl(); - await adoptProfileFromUrl(); - await pullProfileIfNewer(); - // After the profile is settled — a delivery is addressed to the profile, so - // this must run once we know which one (if any) this device is linked to. - await checkPlaylistInbox(); + // A ?list=… or ?profile=… share link REPLACES the synced slice, so those two + // still run before the first paint — otherwise the old playlists would flash + // and be swapped out. They are no-ops without the URL parameter. + const hasShareParam = (() => { + try { + const q = new URLSearchParams(location.search); + return q.has('list') || q.has('profile'); + } catch { return false; } + })(); + if (hasShareParam) { + await adoptSharedPlaylistFromUrl(); + await adoptProfileFromUrl(); + } + + // Everything this device already knows is in localStorage, so paint it NOW. + // On a slow link the profile pull and the playlist inbox used to be awaited + // first, which left the sidebar empty for as long as the network took. + renderSmartSidebar(); + render(); + + // …then reconcile with the server in the background, with the spinner up, + // and re-render only if something actually changed. + syncOnLaunch(); startPlaylistInboxWatch(); Presenter.boot(); Share.bootFromUrl(); @@ -9294,7 +9382,6 @@ async function boot() { // Not awaited — artwork backfill must never delay first paint. warmOfflineThumbs(); - renderSmartSidebar(); checkAutoBackup(); render(); els.searchInput.focus(); diff --git a/frontend/index.html b/frontend/index.html index 206d3fb..45ef538 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -49,6 +49,7 @@
Playlists +
diff --git a/frontend/styles.css b/frontend/styles.css index 144fe56..df8e3fb 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -4116,3 +4116,22 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t @media (hover: none) { .stage-lyrics-bar { opacity: 1; } } + +/* --- "syncing" spinner beside the Playlists header ----------------------- + Playlists paint from localStorage on the first frame; this says the + network pass (profile pull, shared-playlist inbox) is still running. */ +.sync-spinner { + width: 12px; height: 12px; + /* .pl-header is space-between; auto margin keeps the spinner beside the + label instead of floating in the middle of the row. */ + margin: 0 auto 0 8px; + flex: none; + border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--accent) 35%, transparent); + border-top-color: var(--accent); + animation: sync-spin 0.7s linear infinite; +} +@keyframes sync-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { + .sync-spinner { animation-duration: 2.4s; } +} diff --git a/frontend/sw-update.js b/frontend/sw-update.js index c3dfd4b..d1688b5 100644 --- a/frontend/sw-update.js +++ b/frontend/sw-update.js @@ -109,11 +109,44 @@ return { refreshed: fetched.length, caches: names.length }; } + /** + * Asks a worker whether it has already precached its whole shell. + * Resolves to null on any problem (no reply, no MessageChannel, timeout) — + * the caller then takes the download path, which is always correct. + * + * @param {ServiceWorker} worker + * @param {object} [opts] + * @param {number} [opts.timeoutMs] + * @param {Function} [opts.setTimeout] + * @param {Function} [opts.Channel] MessageChannel constructor (injectable) + * @returns {Promise<{ready: boolean, version: string}|null>} + */ + function askCacheStatus(worker, { timeoutMs = 3000, setTimeout: setTimeoutFn, Channel } = {}) { + const Ctor = Channel || (typeof MessageChannel !== 'undefined' ? MessageChannel : null); + const schedule = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null); + if (!worker || !Ctor) return Promise.resolve(null); + return new Promise((resolve) => { + let done = false; + const finish = (v) => { if (!done) { done = true; resolve(v); } }; + try { + const ch = new Ctor(); + ch.port1.onmessage = (ev) => finish(ev && ev.data ? ev.data : null); + worker.postMessage({ type: 'CACHE_STATUS' }, [ch.port2]); + } catch { finish(null); return; } + if (schedule) schedule(() => finish(null), timeoutMs); + }); + } + /** * Applies an update: refresh the shell in place (throws on failure — the * caller reports it and nothing reloads), then activate a waiting worker if * there is one, then reload exactly once. * + * Fast path: a waiting worker precached the entire new shell while it + * installed, so when it confirms that, the download is skipped and the swap + * is immediate. If it cannot confirm, the all-or-nothing download runs as + * before — the slow path is still what guarantees correctness. + * * @param {object} opts * @param {ServiceWorkerRegistration|null} opts.reg * @param {ServiceWorkerContainer} opts.container @@ -122,12 +155,24 @@ * @param {Function} [opts.setTimeout] injectable for tests * @returns {Promise} */ - async function applyUpdate({ reg, container, reload, refreshShell, setTimeout: setTimeoutFn }) { + async function applyUpdate({ reg, container, reload, refreshShell, askStatus, onPhase, setTimeout: setTimeoutFn }) { const scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null); - - if (refreshShell) await refreshShell(); - const waiting = reg && reg.waiting; + + // Did the waiting worker already download this build in the background? + // Opt-in: without an askStatus the download path runs exactly as before. + // (It must never be implicit — a probe that waits on a reply would hang + // forever if the caller injected a setTimeout that never fires.) + let precached = false; + if (waiting && askStatus) { + try { + const status = await askStatus(waiting); + precached = !!(status && status.ready); + } catch { precached = false; } + } + if (onPhase) onPhase(precached ? 'ready' : 'downloading'); + if (!precached && refreshShell) await refreshShell(); + if (!waiting) { reload(); return; @@ -146,7 +191,7 @@ waiting.postMessage({ type: 'SKIP_WAITING' }); } - const SwUpdate = { applyUpdate, refreshShellInPlace, BUST_PARAM }; + const SwUpdate = { applyUpdate, refreshShellInPlace, askCacheStatus, BUST_PARAM }; if (typeof module !== 'undefined' && module.exports) { module.exports = SwUpdate; diff --git a/frontend/sw-update.test.js b/frontend/sw-update.test.js index 0400beb..f815fae 100644 --- a/frontend/sw-update.test.js +++ b/frontend/sw-update.test.js @@ -16,7 +16,16 @@ const { test } = require('node:test'); const assert = require('node:assert'); -const { applyUpdate, refreshShellInPlace, BUST_PARAM } = require('./sw-update'); +const { applyUpdate, refreshShellInPlace, askCacheStatus, BUST_PARAM } = require('./sw-update'); + +// Minimal MessageChannel stand-in: port2 is handed to the "worker", which +// delivers a reply back through port1.onmessage. +function FakeChannel() { + const p1 = { onmessage: null }; + const p2 = { __deliver: (data) => { if (p1.onmessage) p1.onmessage({ data }); } }; + this.port1 = p1; + this.port2 = p2; +} const ORIGIN = 'https://worship.example'; @@ -211,3 +220,79 @@ test('applyUpdate with no registration at all still reloads', async () => { await applyUpdate({ reg: null, container: fakeContainer(), reload: () => { reloads++; } }); assert.strictEqual(reloads, 1); }); + +// ---- background download + instant swap ------------------------------------ + +test('applyUpdate skips the download when the waiting worker already precached the build', async () => { + const order = []; + const container = fakeContainer(); + await applyUpdate({ + reg: { waiting: { postMessage: (m) => order.push('msg:' + m.type) } }, + container, + reload: () => order.push('reload'), + refreshShell: async () => { order.push('refresh'); }, + askStatus: async () => ({ ready: true, version: 'new' }), + onPhase: (p) => order.push('phase:' + p), + setTimeout: () => {}, + }); + assert.deepStrictEqual(order, ['phase:ready', 'msg:SKIP_WAITING'], + 'the whole point: no re-download when the shell is already on disk'); + container.fireControllerChange(); + assert.ok(order.includes('reload')); +}); + +test('applyUpdate still downloads when the waiting worker reports an incomplete cache', async () => { + const order = []; + await applyUpdate({ + reg: { waiting: { postMessage: (m) => order.push('msg:' + m.type) } }, + container: fakeContainer(), + reload: () => order.push('reload'), + refreshShell: async () => { order.push('refresh'); }, + askStatus: async () => ({ ready: false, missing: 3 }), + onPhase: (p) => order.push('phase:' + p), + setTimeout: () => {}, + }); + assert.deepStrictEqual(order, ['phase:downloading', 'refresh', 'msg:SKIP_WAITING']); +}); + +test('a worker that never answers falls back to downloading, it does not hang', async () => { + const order = []; + await applyUpdate({ + reg: { waiting: { postMessage: () => {} } }, + container: fakeContainer(), + reload: () => order.push('reload'), + refreshShell: async () => { order.push('refresh'); }, + askStatus: async () => null, // no reply / timed out + setTimeout: () => {}, + }); + assert.deepStrictEqual(order, ['refresh']); +}); + +test('a failing status probe is not fatal — the download path still runs', async () => { + const order = []; + await applyUpdate({ + reg: { waiting: { postMessage: () => {} } }, + container: fakeContainer(), + reload: () => order.push('reload'), + refreshShell: async () => { order.push('refresh'); }, + askStatus: async () => { throw new Error('port closed'); }, + setTimeout: () => {}, + }); + assert.deepStrictEqual(order, ['refresh']); +}); + +test('askCacheStatus resolves null when the worker never replies', async () => { + let fire = null; + const p = askCacheStatus( + { postMessage() {} }, + { timeoutMs: 10, setTimeout: (fn) => { fire = fn; }, Channel: FakeChannel }, + ); + fire(); + assert.strictEqual(await p, null); +}); + +test('askCacheStatus returns the worker reply', async () => { + const worker = { postMessage(_msg, ports) { ports[0].__deliver({ ready: true, version: 'abc' }); } }; + const got = await askCacheStatus(worker, { setTimeout: () => {}, Channel: FakeChannel }); + assert.deepStrictEqual(got, { ready: true, version: 'abc' }); +}); diff --git a/frontend/sw.js b/frontend/sw.js index 63a1a30..684f754 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -176,6 +176,24 @@ self.addEventListener('message', (e) => { if (e.data && e.data.type === 'SKIP_WAITING') { self.skipWaiting(); } + // "Is your shell already downloaded?" — a waiting worker has precached the + // whole new build during install, so "Refresh UI" can swap to it instantly + // instead of downloading everything a second time. Answer honestly: install + // deletes its cache on failure, but a cache can also be evicted under + // storage pressure, so the files are actually checked. + if (e.data && e.data.type === 'CACHE_STATUS') { + e.waitUntil((async () => { + let missing = SHELL.length; + try { + const cache = await caches.open(CACHE); + const found = await Promise.all(SHELL.map((url) => cache.match(url))); + missing = found.filter((r) => !r).length; + } catch { /* storage blocked — report not ready */ } + const reply = { type: 'CACHE_STATUS', version: VERSION, ready: missing === 0, missing }; + if (e.ports && e.ports[0]) e.ports[0].postMessage(reply); + else if (e.source) e.source.postMessage(reply); + })()); + } }); // ============================================================================