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:
@@ -1,6 +1,6 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
type QueueSource =
|
||||
export type QueueSource =
|
||||
| 'manual'
|
||||
| 'album'
|
||||
| 'playlist'
|
||||
@@ -8,7 +8,7 @@ type QueueSource =
|
||||
| 'search'
|
||||
| 'radio';
|
||||
|
||||
interface QueueEntry {
|
||||
export interface QueueEntry {
|
||||
trackId: string;
|
||||
title: string;
|
||||
artistName: string;
|
||||
@@ -17,7 +17,7 @@ interface QueueEntry {
|
||||
albumArtUrl?: string;
|
||||
}
|
||||
|
||||
interface QueueState {
|
||||
export interface QueueState {
|
||||
entries: QueueEntry[];
|
||||
currentIndex: number;
|
||||
source: QueueSource;
|
||||
@@ -25,52 +25,17 @@ interface QueueState {
|
||||
sourceName: string | null;
|
||||
}
|
||||
|
||||
// STUB demo queue — purely client-side display data so the player bar and
|
||||
// queue drawer render with content before the backend exists. Delete this
|
||||
// block (reset entries/currentIndex/source to the empty values) once real
|
||||
// playback wires tracks into the queue.
|
||||
const DEMO_QUEUE: QueueEntry[] = [
|
||||
{
|
||||
trackId: 'd1',
|
||||
title: 'Quiet Storage',
|
||||
artistName: 'Cyan Atlas',
|
||||
albumTitle: 'Night Index',
|
||||
durationMs: 312_000,
|
||||
},
|
||||
{
|
||||
trackId: 'd2',
|
||||
title: 'Magnetic North',
|
||||
artistName: 'Tidal Bloom',
|
||||
albumTitle: 'Ferric Coast',
|
||||
durationMs: 243_000,
|
||||
},
|
||||
{
|
||||
trackId: 'd3',
|
||||
title: 'Ambergris',
|
||||
artistName: 'Møller',
|
||||
albumTitle: 'Warm Static',
|
||||
durationMs: 201_000,
|
||||
},
|
||||
{
|
||||
trackId: 'd4',
|
||||
title: 'Slow Carrier',
|
||||
artistName: 'Tidal Bloom',
|
||||
albumTitle: 'Ferric Coast',
|
||||
durationMs: 301_000,
|
||||
},
|
||||
];
|
||||
|
||||
const initialState: QueueState = {
|
||||
entries: DEMO_QUEUE,
|
||||
currentIndex: 0,
|
||||
source: 'radio',
|
||||
export const queueInitialState: QueueState = {
|
||||
entries: [],
|
||||
currentIndex: -1,
|
||||
source: 'manual',
|
||||
sourceId: null,
|
||||
sourceName: 'My radio',
|
||||
sourceName: null,
|
||||
};
|
||||
|
||||
export const queueSlice = createSlice({
|
||||
name: 'queue',
|
||||
initialState,
|
||||
initialState: queueInitialState,
|
||||
reducers: {
|
||||
setQueue(
|
||||
state,
|
||||
|
||||
Reference in New Issue
Block a user