Files
mcma-webui/src/store/slices/queue.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

104 lines
2.7 KiB
TypeScript

import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type QueueSource =
| 'manual'
| 'album'
| 'playlist'
| 'artist'
| 'search'
| 'radio';
export interface QueueEntry {
trackId: string;
title: string;
artistName: string;
albumTitle: string;
durationMs: number;
albumArtUrl?: string;
}
export interface QueueState {
entries: QueueEntry[];
currentIndex: number;
source: QueueSource;
sourceId: string | null;
sourceName: string | null;
}
export const queueInitialState: QueueState = {
entries: [],
currentIndex: -1,
source: 'manual',
sourceId: null,
sourceName: null,
};
export const queueSlice = createSlice({
name: 'queue',
initialState: queueInitialState,
reducers: {
setQueue(
state,
action: PayloadAction<{
entries: QueueEntry[];
startIndex?: number;
source: QueueSource;
sourceId?: string;
sourceName?: string;
}>,
) {
state.entries = action.payload.entries;
state.currentIndex = action.payload.startIndex ?? 0;
state.source = action.payload.source;
state.sourceId = action.payload.sourceId ?? null;
state.sourceName = action.payload.sourceName ?? null;
},
addToQueue(state, action: PayloadAction<QueueEntry>) {
state.entries.push(action.payload);
},
addNextInQueue(state, action: PayloadAction<QueueEntry>) {
state.entries.splice(state.currentIndex + 1, 0, action.payload);
},
removeFromQueue(state, action: PayloadAction<number>) {
state.entries.splice(action.payload, 1);
if (action.payload < state.currentIndex) state.currentIndex--;
},
moveInQueue(state, action: PayloadAction<{ from: number; to: number }>) {
const { from, to } = action.payload;
const [entry] = state.entries.splice(from, 1);
state.entries.splice(to, 0, entry);
if (state.currentIndex === from) state.currentIndex = to;
else if (from < state.currentIndex && to >= state.currentIndex)
state.currentIndex--;
else if (from > state.currentIndex && to <= state.currentIndex)
state.currentIndex++;
},
goToIndex(state, action: PayloadAction<number>) {
state.currentIndex = action.payload;
},
nextTrack(state) {
if (state.currentIndex < state.entries.length - 1) state.currentIndex++;
},
prevTrack(state) {
if (state.currentIndex > 0) state.currentIndex--;
},
clearQueue(state) {
state.entries = [];
state.currentIndex = -1;
},
},
});
export const {
setQueue,
addToQueue,
addNextInQueue,
removeFromQueue,
moveInQueue,
goToIndex,
nextTrack,
prevTrack,
clearQueue,
} = queueSlice.actions;
export default queueSlice.reducer;