feat(offline): make the web UI usable without a reachable backend

Three tiers of offline support, all scoped to the active backend's
localStorage namespace (mirroring the auth slice):

Tier 1 — persist client state. queue + player slices are saved (queue
entries/index/source; player track/position/volume/repeat/shuffle) and
rehydrated on load, so a reload with no backend restores where the user
left off. Playback never auto-resumes (browsers block autoplay). Retires
the DEMO_QUEUE and isQueueOpen:true stubs.

Tier 2 — persist the RTK Query cache. Last-seen library/albums/artists
are snapshotted (fulfilled queries only) and replayed via RTKQ's
extractRehydrationInfo at startup, so the library renders read-only when
the backend is down. ConnectionStatus tooltip flags cached data offline.
No server data is copied into a slice — the cache feeds itself back.

Tier 3 — service worker audio + cover cache (PWA). Audio streams are
cached keyed by content id (token stripped), range-aware (synthetic 206
slicing), with a 500MB LRU cap, so already-played tracks play fully
offline. Cover art uses stale-while-revalidate in its own bounded cache.
Module worker (ESM); pure helpers split into sw-core.js and unit-tested.
Web app manifest enables "Install app". Player source badge now reflects
real cached state.

tsc clean, lint clean, 19 new tests pass, production build verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Senko-san
2026-06-07 19:59:31 +03:00
parent 61dbb1abd2
commit ceee9b9d12
21 changed files with 1054 additions and 53 deletions
+98
View File
@@ -0,0 +1,98 @@
/*
* Service-worker core: pure helpers with NO side effects (no `self`, no
* `caches`, no `fetch`). Split out from `sw.js` so the tricky bits — HTTP Range
* parsing, LRU eviction, cache-key normalization — can be unit-tested in Node.
*
* This is an ES module: `sw.js` imports it (the SW is registered with
* { type: 'module' }) and tests import it natively — one source, no drift.
*/
// Bump the version to invalidate every cached blob on a breaking change.
export const AUDIO_CACHE = 'mcma-audio-v1';
// Synthetic same-origin key holding the LRU index ({ key: {size, lastAccess} }).
export const INDEX_URL = '/__mcma_audio_index__';
// Soft cap on total cached audio. LRU eviction keeps us under this.
export const MAX_BYTES = 500 * 1024 * 1024; // 500 MB
// Cover art (album/artist/playlist images) gets its own cache with a simple
// count cap — covers are small and stable, so no per-byte LRU is needed.
export const COVER_CACHE = 'mcma-covers-v1';
export const MAX_COVERS = 600;
// Backend stream route: /api/v1/streaming/tracks/<trackId>?token=...
const STREAM_RE = /\/streaming\/tracks\/([^/?#]+)/;
/** The track (content) id from a stream URL, or null if it isn't one. */
export function trackIdFromUrl(url) {
const m = STREAM_RE.exec(url);
return m ? m[1] : null;
}
/**
* Canonical cache key for a stream URL: origin + path, query dropped. The auth
* token rides in the query and rotates on refresh, so keying by content path
* (origin keeps two backends from colliding) makes the cache token-stable —
* the same track is one entry regardless of which token fetched it.
*/
export function cacheKeyFor(url) {
try {
const u = new URL(url);
return u.origin + u.pathname;
} catch {
return String(url).split('?')[0];
}
}
/**
* Parse an HTTP `Range` header against a known resource size. Returns inclusive
* { start, end } byte offsets, or null for "no range / serve the whole thing".
* Handles `bytes=a-b`, `bytes=a-` (open-ended) and `bytes=-n` (last n bytes).
*/
export function parseRangeHeader(rangeHeader, size) {
if (!rangeHeader) return null;
const m = /^bytes=(\d*)-(\d*)$/.exec(String(rangeHeader).trim());
if (!m) return null;
let start = m[1] === '' ? null : parseInt(m[1], 10);
let end = m[2] === '' ? null : parseInt(m[2], 10);
if (start === null && end === null) return null;
if (start === null) {
// suffix range: the final `end` bytes
start = Math.max(0, size - end);
end = size - 1;
} else if (end === null) {
end = size - 1;
}
if (Number.isNaN(start) || Number.isNaN(end)) return null;
if (end >= size) end = size - 1;
if (start < 0 || start > end) return null;
return { start, end };
}
/**
* Choose which cached entries to evict (least-recently-used first) so that the
* existing total plus an incoming blob fits under `maxBytes`. Returns the list
* of keys to delete; empty when there's already room.
*
* `index` is { [key]: { size, lastAccess } }.
*/
export function selectEvictions(index, incomingSize, maxBytes) {
let total = incomingSize;
for (const k in index) total += index[k].size || 0;
if (total <= maxBytes) return [];
const entries = Object.keys(index)
.map((k) => ({
key: k,
lastAccess: index[k].lastAccess || 0,
size: index[k].size || 0,
}))
.sort((a, b) => a.lastAccess - b.lastAccess); // oldest first
const evict = [];
for (const e of entries) {
if (total <= maxBytes) break;
evict.push(e.key);
total -= e.size;
}
return evict;
}