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
+76
View File
@@ -0,0 +1,76 @@
/*
* Tier 2 offline support: persist the RTK Query cache (last-seen server data —
* library/albums/artists/…) to the active backend's localStorage namespace and
* replay it into the cache on startup. With the backend down, components keep
* rendering the last-known data read-only instead of an error state.
*
* Per the architecture invariant, server data is NOT copied into a slice: this
* snapshots the RTKQ cache itself and feeds it back through RTKQ's own
* `extractRehydrationInfo` mechanism (see `api/index.ts`).
*/
import { instanceStorage } from '../config/instances';
import { rehydrateApi, type RehydrateApiPayload } from '../api/rehydrate';
import type { RootState } from './index';
const CACHE_KEY = 'rtkq';
type ApiState = RootState['api'];
type QueryEntry = ApiState['queries'][string];
/**
* Keep only successfully-fulfilled query results — pending/rejected entries
* carry no usable data and subscriptions are rebuilt by components on mount.
* Mutation results are never restored.
*/
function snapshot(apiState: ApiState): RehydrateApiPayload {
const queries: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(apiState.queries)) {
const q = entry as QueryEntry | undefined;
if (q && q.status === 'fulfilled') queries[key] = q;
}
return { queries, mutations: {} };
}
function load(): RehydrateApiPayload | null {
try {
const raw = instanceStorage.get(CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<RehydrateApiPayload>;
if (!parsed.queries) return null;
return { queries: parsed.queries, mutations: {} };
} catch {
return null;
}
}
/** Replay the persisted cache into RTKQ. Call once after the store is created. */
export function rehydrateApiCache(dispatch: (action: unknown) => void): void {
const cached = load();
if (cached) dispatch(rehydrateApi(cached));
}
/**
* Subscribe a store so the RTKQ cache is flushed to localStorage. Throttled,
* since cache state churns on every in-flight query transition.
*/
export function startApiPersistence(store: {
getState: () => RootState;
subscribe: (listener: () => void) => () => void;
}): () => void {
let last = '';
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
timer = null;
const snap = JSON.stringify(snapshot(store.getState().api));
if (snap !== last) {
instanceStorage.set(CACHE_KEY, snap);
last = snap;
}
};
return store.subscribe(() => {
if (timer) return;
timer = setTimeout(flush, 2000);
});
}