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:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user