Paint saved playlists before the launch sync and swap a background-downloaded update instantly
This commit is contained in:
111
frontend/app.js
111
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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
})());
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user