124 lines
4.0 KiB
TypeScript
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'
|
|
| 'shuffle'
|
|
| 'loop'
|
|
>;
|
|
type PersistedPlayer = Pick<
|
|
PlayerState,
|
|
'currentTrackId' | 'position' | 'volume' | 'muted'
|
|
>;
|
|
|
|
function pickQueue(state: QueueState): PersistedQueue {
|
|
return {
|
|
entries: state.entries,
|
|
currentIndex: state.currentIndex,
|
|
source: state.source,
|
|
sourceId: state.sourceId,
|
|
sourceName: state.sourceName,
|
|
shuffle: state.shuffle,
|
|
loop: state.loop,
|
|
};
|
|
}
|
|
|
|
function pickPlayer(state: PlayerState): PersistedPlayer {
|
|
return {
|
|
currentTrackId: state.currentTrackId,
|
|
position: state.position,
|
|
volume: state.volume,
|
|
muted: state.muted,
|
|
};
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|