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 @@
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);
+ })());
+ }
});
// ============================================================================