Paint saved playlists before the launch sync and swap a background-downloaded update instantly

This commit is contained in:
Jonathan Sykes
2026-09-22 13:27:12 +08:00
parent 691e204d61
commit 987697e759
7 changed files with 300 additions and 18 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -49,6 +49,7 @@
<div class="pl-header">
<span>Playlists</span>
<span id="syncSpinner" class="sync-spinner hidden" title="Syncing with your profile…" aria-live="polite" role="status"></span>
<button id="newPlaylistBtn" class="icon-btn" title="New playlist">+</button>
</div>
<div id="playlistList" class="playlist-list"></div>

View File

@@ -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; }
}

View File

@@ -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<void>}
*/
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;

View File

@@ -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' });
});

View File

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