Files
mcma-webui/src/store/persist.ts
T
Senko-san ceee9b9d12 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>
2026-06-07 19:59:31 +03:00

124 lines
4.0 KiB
TypeScript

/*
* Tier 1 offline support: persist client state (queue + player) to the active
* backend's localStorage namespace, mirroring the auth slice. This is what lets
* the UI come back exactly as the user left it after a reload with no backend
* reachable — no server data is duplicated (the queue stores track IDs + minimal
* display fields only; full track records still live in the RTKQ cache).
*
* Server data (library/albums/…) is Tier 2 (see `rtkqPersist.ts`).
*/
import { instanceStorage } from '../config/instances';
import {
queueInitialState,
type QueueState,
} from './slices/queue';
import {
playerInitialState,
type PlayerState,
} from './slices/player';
import type { RootState } from './index';
const QUEUE_KEY = 'queue';
const PLAYER_KEY = 'player';
// Only persist fields that make sense to restore. `duration`/`isPlaying` are
// derived from the <audio> element on next load, and the panel toggles are
// transient UI, so they are intentionally left out.
type PersistedQueue = Pick<
QueueState,
'entries' | 'currentIndex' | 'source' | 'sourceId' | 'sourceName'
>;
type PersistedPlayer = Pick<
PlayerState,
'currentTrackId' | 'position' | 'volume' | 'muted' | 'repeat' | 'shuffle'
>;
function pickQueue(state: QueueState): PersistedQueue {
return {
entries: state.entries,
currentIndex: state.currentIndex,
source: state.source,
sourceId: state.sourceId,
sourceName: state.sourceName,
};
}
function pickPlayer(state: PlayerState): PersistedPlayer {
return {
currentTrackId: state.currentTrackId,
position: state.position,
volume: state.volume,
muted: state.muted,
repeat: state.repeat,
shuffle: state.shuffle,
};
}
function read<T>(key: string): Partial<T> | null {
try {
const raw = instanceStorage.get(key);
return raw ? (JSON.parse(raw) as Partial<T>) : null;
} catch {
return null;
}
}
/** Build the queue slice's initial state, restoring any persisted queue. */
export function loadQueueState(): QueueState {
const persisted = read<PersistedQueue>(QUEUE_KEY);
if (!persisted) return queueInitialState;
const merged: QueueState = { ...queueInitialState, ...persisted };
// Guard the index against a corrupted/short entries array.
if (
merged.currentIndex >= merged.entries.length ||
merged.currentIndex < -1
) {
merged.currentIndex = merged.entries.length ? 0 : -1;
}
return merged;
}
/** Build the player slice's initial state, restoring any persisted player. */
export function loadPlayerState(): PlayerState {
const persisted = read<PersistedPlayer>(PLAYER_KEY);
if (!persisted) return playerInitialState;
// Never auto-resume playback on load: browsers block autoplay and the
// <audio> element starts paused regardless. isPlaying stays false.
return { ...playerInitialState, ...persisted, isPlaying: false };
}
/**
* Subscribe a store so queue/player changes are flushed to localStorage. The
* write is throttled because `setPosition` fires several times a second during
* playback — without throttling we'd hammer localStorage on every tick.
*/
export function startPersistence(store: {
getState: () => RootState;
subscribe: (listener: () => void) => () => void;
}): () => void {
const initial = store.getState();
let lastQueue = JSON.stringify(pickQueue(initial.queue));
let lastPlayer = JSON.stringify(pickPlayer(initial.player));
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
timer = null;
const state = store.getState();
const queueSnapshot = JSON.stringify(pickQueue(state.queue));
if (queueSnapshot !== lastQueue) {
instanceStorage.set(QUEUE_KEY, queueSnapshot);
lastQueue = queueSnapshot;
}
const playerSnapshot = JSON.stringify(pickPlayer(state.player));
if (playerSnapshot !== lastPlayer) {
instanceStorage.set(PLAYER_KEY, playerSnapshot);
lastPlayer = playerSnapshot;
}
};
return store.subscribe(() => {
if (timer) return;
timer = setTimeout(flush, 1000);
});
}