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,5 +1,6 @@
|
||||
import { createApi } from '@reduxjs/toolkit/query/react';
|
||||
import { baseQueryWithReauth } from './baseQuery';
|
||||
import { REHYDRATE_API, type RehydrateApiPayload } from './rehydrate';
|
||||
|
||||
export const api = createApi({
|
||||
reducerPath: 'api',
|
||||
@@ -14,5 +15,16 @@ export const api = createApi({
|
||||
'User',
|
||||
'Storage',
|
||||
],
|
||||
// Tier 2 offline: seed the cache from the persisted snapshot dispatched at
|
||||
// startup (see `store/rtkqPersist.ts`). Returning the saved queries/mutations
|
||||
// lets the last-seen library render before — or instead of — any network call.
|
||||
extractRehydrationInfo(action) {
|
||||
if (action.type === REHYDRATE_API) {
|
||||
// The api reducer reads `queries`/`mutations` off this and restores any
|
||||
// fulfilled entries; pending/rejected ones are ignored automatically.
|
||||
return action.payload as RehydrateApiPayload as never;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
endpoints: () => ({}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
/*
|
||||
* Tier 2 offline support contract. RTK Query can seed its cache from a
|
||||
* persisted snapshot via `extractRehydrationInfo` (see `api/index.ts`). We use
|
||||
* a single action whose payload is the previously-saved api slice state; the
|
||||
* api reducer pulls `queries`/`mutations` out of it on startup so last-seen
|
||||
* library data renders read-only while the backend is unreachable.
|
||||
*
|
||||
* The type string lives here (not in the store) so `api/index.ts` can match it
|
||||
* without importing from the store layer (which would create a cycle).
|
||||
*/
|
||||
export const REHYDRATE_API = 'api/rehydrate';
|
||||
|
||||
export interface RehydrateApiPayload {
|
||||
queries: Record<string, unknown>;
|
||||
mutations: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const rehydrateApi = createAction<RehydrateApiPayload>(REHYDRATE_API);
|
||||
@@ -22,9 +22,15 @@ export function ConnectionStatus() {
|
||||
const status = useConnectionStatus();
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const label = t(STATUS_KEY[status]);
|
||||
// When the backend is unreachable the UI falls back to the persisted RTKQ
|
||||
// cache (Tier 2), so flag that the data on screen is last-seen, not live.
|
||||
const offline = status === 'disconnected' || status === 'error';
|
||||
const tip = offline
|
||||
? `${label} · ${baseUrl} · ${t('conn.cached')}`
|
||||
: `${label} · ${baseUrl}`;
|
||||
|
||||
return (
|
||||
<Tooltip content={`${label} · ${baseUrl}`}>
|
||||
<Tooltip content={tip}>
|
||||
<Badge variant={STATUS_VARIANTS[status]} dot>
|
||||
{label}
|
||||
</Badge>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
toggleQueue,
|
||||
} from '../../store/slices/player';
|
||||
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
||||
import { useStreamCached } from '../../hooks/useStreamCached';
|
||||
import { formatDuration } from '../../lib/format';
|
||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
||||
|
||||
@@ -24,6 +25,8 @@ export function PersistentPlayer() {
|
||||
const player = useAppSelector((s) => s.player);
|
||||
const queue = useAppSelector((s) => s.queue);
|
||||
const currentEntry = queue.entries[queue.currentIndex];
|
||||
// Source indicator: cached → playing locally, otherwise streaming.
|
||||
const cached = useStreamCached(currentEntry?.trackId);
|
||||
|
||||
if (!currentEntry && !player.currentTrackId) {
|
||||
return <div className="player empty">{t('player.nothingPlaying')}</div>;
|
||||
@@ -31,7 +34,7 @@ export function PersistentPlayer() {
|
||||
|
||||
const artUrl = getCoverUrl(currentEntry?.albumArtUrl);
|
||||
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? '';
|
||||
const onStream = true;
|
||||
const onStream = !cached;
|
||||
|
||||
return (
|
||||
<div className="player">
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAppSelector } from './useAppDispatch';
|
||||
import { isStreamCached } from '../lib/sw';
|
||||
import { getStreamUrl } from '../api/endpoints/streaming';
|
||||
|
||||
/**
|
||||
* Whether the given track is available from the offline audio cache (Tier 3).
|
||||
* Drives the player-bar source indicator (local vs streaming). Returns false
|
||||
* until the service worker is controlling and confirms a hit.
|
||||
*/
|
||||
export function useStreamCached(trackId: string | undefined): boolean {
|
||||
const token = useAppSelector((s) => s.auth.accessToken);
|
||||
const [cached, setCached] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!trackId || !token) {
|
||||
setCached(false);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
void isStreamCached(getStreamUrl(trackId, token)).then((hit) => {
|
||||
if (active) setCached(hit);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [trackId, token]);
|
||||
|
||||
return cached;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const en = {
|
||||
disconnected: 'Offline',
|
||||
error: 'Unreachable',
|
||||
manage: 'Connection — manage instances',
|
||||
cached: 'Showing last-seen data',
|
||||
},
|
||||
user: {
|
||||
online: 'online',
|
||||
|
||||
@@ -19,6 +19,7 @@ const ru: Translations = {
|
||||
disconnected: 'Нет связи',
|
||||
error: 'Недоступно',
|
||||
manage: 'Соединение — управление экземплярами',
|
||||
cached: 'Показаны последние данные',
|
||||
},
|
||||
user: {
|
||||
online: 'онлайн',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { BrowserRouter } from 'react-router';
|
||||
import { ThemeProvider, TooltipProvider } from '@olly/modern-sk';
|
||||
import { store } from './store';
|
||||
import { AppRoutes } from './routes';
|
||||
import { registerServiceWorker } from './lib/sw';
|
||||
|
||||
// Import all endpoint injections to ensure they are registered
|
||||
import './api/endpoints/auth';
|
||||
@@ -21,6 +22,10 @@ import './api/endpoints/storage';
|
||||
import './api/endpoints/admin';
|
||||
import './api/endpoints/upload';
|
||||
|
||||
// Tier 3 offline: register the audio-caching service worker (no-op if the
|
||||
// browser/origin doesn't support it).
|
||||
registerServiceWorker();
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (rootEl) {
|
||||
// grained black-ish background + base text color from modern-sk
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Service-worker client: registration + a typed bridge to the audio offline
|
||||
* cache (Tier 3). The SW itself lives in `public/sw.js`; this is the app side.
|
||||
*
|
||||
* Messaging uses a MessageChannel — we hand the SW a port and await its reply —
|
||||
* so each call resolves with that request's result rather than a global event.
|
||||
*/
|
||||
|
||||
export interface AudioCacheStats {
|
||||
count: number;
|
||||
bytes: number;
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
/** Register the service worker. No-op when unsupported (e.g. plain http host). */
|
||||
export function registerServiceWorker(): void {
|
||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
||||
return;
|
||||
}
|
||||
window.addEventListener('load', () => {
|
||||
// Module worker: sw.js uses ES imports (see public/sw.js + sw-core.js).
|
||||
navigator.serviceWorker.register('/sw.js', { type: 'module' }).catch(() => {
|
||||
/* SW unavailable (insecure origin, blocked, …) — app still works online */
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function controller(): ServiceWorker | null {
|
||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
||||
return null;
|
||||
}
|
||||
return navigator.serviceWorker.controller;
|
||||
}
|
||||
|
||||
/** Round-trip a message to the SW; rejects if no controlling SW is present. */
|
||||
function send<T>(message: Record<string, unknown>): Promise<T> {
|
||||
const sw = controller();
|
||||
if (!sw) return Promise.reject(new Error('no-service-worker'));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.onmessage = (event) => resolve(event.data as T);
|
||||
try {
|
||||
sw.postMessage(message, [channel.port2]);
|
||||
} catch (err) {
|
||||
reject(err as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Total size + count of cached audio, or null when the SW isn't controlling. */
|
||||
export async function getAudioCacheStats(): Promise<AudioCacheStats | null> {
|
||||
try {
|
||||
return await send<AudioCacheStats>({ type: 'STATS' });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a given stream URL is already cached for offline playback. */
|
||||
export async function isStreamCached(streamUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const { cached } = await send<{ cached: boolean }>({
|
||||
type: 'HAS',
|
||||
url: streamUrl,
|
||||
});
|
||||
return cached;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the entire audio cache. Resolves false if the SW isn't controlling. */
|
||||
export async function clearAudioCache(): Promise<boolean> {
|
||||
try {
|
||||
const { ok } = await send<{ ok: boolean }>({ type: 'CLEAR' });
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import authReducer from './slices/auth';
|
||||
import playerReducer from './slices/player';
|
||||
import queueReducer from './slices/queue';
|
||||
import uiReducer from './slices/ui';
|
||||
import { loadPlayerState, loadQueueState, startPersistence } from './persist';
|
||||
import { rehydrateApiCache, startApiPersistence } from './rtkqPersist';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
@@ -13,9 +15,23 @@ export const store = configureStore({
|
||||
queue: queueReducer,
|
||||
ui: uiReducer,
|
||||
},
|
||||
// Tier 1 offline: rehydrate queue/player from the active backend's namespace
|
||||
// so a reload (even with no backend) restores exactly where the user left off.
|
||||
preloadedState: {
|
||||
queue: loadQueueState(),
|
||||
player: loadPlayerState(),
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat(api.middleware),
|
||||
});
|
||||
|
||||
// Flush queue/player changes back to localStorage (throttled).
|
||||
startPersistence(store);
|
||||
|
||||
// Tier 2 offline: replay the last-seen RTKQ cache, then keep snapshotting it.
|
||||
// Rehydrate first so cached server data is present before any component mounts.
|
||||
rehydrateApiCache(store.dispatch);
|
||||
startApiPersistence(store);
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
type RepeatMode = 'none' | 'one' | 'all';
|
||||
export type RepeatMode = 'none' | 'one' | 'all';
|
||||
|
||||
interface PlayerState {
|
||||
export interface PlayerState {
|
||||
currentTrackId: string | null;
|
||||
isPlaying: boolean;
|
||||
position: number;
|
||||
@@ -15,7 +15,7 @@ interface PlayerState {
|
||||
isQueueOpen: boolean;
|
||||
}
|
||||
|
||||
const initialState: PlayerState = {
|
||||
export const playerInitialState: PlayerState = {
|
||||
currentTrackId: null,
|
||||
isPlaying: false,
|
||||
position: 0,
|
||||
@@ -25,14 +25,12 @@ const initialState: PlayerState = {
|
||||
repeat: 'none',
|
||||
shuffle: false,
|
||||
isNowPlayingOpen: false,
|
||||
// STUB: open by default so the queue drawer look is visible before a backend
|
||||
// exists (pairs with DEMO_QUEUE). Default to false once real playback lands.
|
||||
isQueueOpen: true,
|
||||
isQueueOpen: false,
|
||||
};
|
||||
|
||||
export const playerSlice = createSlice({
|
||||
name: 'player',
|
||||
initialState,
|
||||
initialState: playerInitialState,
|
||||
reducers: {
|
||||
play(state, action: PayloadAction<string>) {
|
||||
state.currentTrackId = action.payload;
|
||||
|
||||
@@ -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