fix(player): cap persisted queue window (#756)

Playing/shuffling a big playlist set `queue` to ~10k `Track` objects.
zustand `persist` then `JSON.stringify`'d the whole queue into `localStorage`
on every persisted `set` → `QuotaExceededError` storm: playback never started
and the main thread stalled on each write.

`partialize` now persists only a ±250-track window around `queueIndex`
(remapping the index into the slice) instead of the entire queue.

The authoritative full queue lives server-side — synced via Subsonic
`savePlayQueue` and restored through `getPlayQueue`. localStorage is only a
fast local cache, so bounding it doesn't lose the queue; it just restores a
±250 window instantly while the full queue rehydrates from the server.

Controlled before/after on a 10,509-track playlist:
- before: QuotaExceededError ×9, play/shuffle dead
- after: QuotaExceeded = 0, playback works
This commit is contained in:
‮Artem
2026-05-17 22:06:41 +03:00
committed by GitHub
parent ce10272f01
commit be9722149b
4 changed files with 117 additions and 18 deletions
+76 -1
View File
@@ -1,10 +1,15 @@
/**
* Server play-queue persistence flush characterization (Phase F1 / PR 2c).
* Player store persistence: server play-queue flush (Phase F1 / PR 2c) and
* localStorage partialize windowing (PR #756).
*
* `flushPlayQueuePosition` is the synchronous-from-the-caller's-view path
* that the playback heartbeat / close handler / `pause()` use to push the
* current position to the Subsonic server so cross-device resume works.
*
* `partialize` caps the localStorage queue to a ±250-track window around the
* current index, remapping `queueIndex` into the slice so the persisted
* snapshot stays self-consistent and within the browser storage quota.
*
* Mocks `savePlayQueue` at the module boundary so we can assert the exact
* args passed to the Subsonic API call.
*/
@@ -211,3 +216,73 @@ describe('flushPlayQueuePosition', () => {
expect(posArg).toBe(12999); // Math.floor(12.9999 * 1000)
});
});
// ---------------------------------------------------------------------------
// partialize: localStorage queue window (PR #756)
// ---------------------------------------------------------------------------
function getPartialize() {
// zustand persist middleware exposes config (incl. partialize) via .persist
type PartializeFn = (state: ReturnType<typeof usePlayerStore.getState>) => Record<string, unknown>;
return (usePlayerStore as unknown as { persist: { getOptions(): { partialize: PartializeFn } } })
.persist.getOptions().partialize;
}
describe('partialize: localStorage queue window', () => {
it('caps a large queue to at most 501 tracks and puts the current track at index 250', () => {
const tracks = makeTracks(10509);
usePlayerStore.setState({ queue: tracks, queueIndex: 5000 });
const partial = getPartialize()(usePlayerStore.getState());
expect((partial.queue as unknown[]).length).toBe(501);
expect(partial.queueIndex).toBe(250);
// the track at the remapped index must be the original track[5000]
expect((partial.queue as { id: string }[])[250].id).toBe(tracks[5000].id);
});
it('does not shift the index when the current track is near the start (qi < 250)', () => {
const tracks = makeTracks(1000);
usePlayerStore.setState({ queue: tracks, queueIndex: 50 });
const partial = getPartialize()(usePlayerStore.getState());
// window starts at 0, index is unchanged
expect(partial.queueIndex).toBe(50);
expect((partial.queue as { id: string }[])[50].id).toBe(tracks[50].id);
// window extends 250 ahead: 0..300 = 301 tracks
expect((partial.queue as unknown[]).length).toBe(301);
});
it('handles a current track near the end of the queue correctly', () => {
const tracks = makeTracks(10509);
const lastIdx = 10508;
usePlayerStore.setState({ queue: tracks, queueIndex: lastIdx });
const partial = getPartialize()(usePlayerStore.getState());
// window: [10258, 10509) = 251 tracks; remapped index = 10508 - 10258 = 250
expect((partial.queue as unknown[]).length).toBe(251);
expect(partial.queueIndex).toBe(250);
expect((partial.queue as { id: string }[])[250].id).toBe(tracks[lastIdx].id);
});
it('persists the entire queue unchanged when it is smaller than the window', () => {
const tracks = makeTracks(10);
usePlayerStore.setState({ queue: tracks, queueIndex: 5 });
const partial = getPartialize()(usePlayerStore.getState());
expect((partial.queue as unknown[]).length).toBe(10);
expect(partial.queueIndex).toBe(5);
});
it('handles an empty queue without throwing', () => {
usePlayerStore.setState({ queue: [], queueIndex: 0 });
const partial = getPartialize()(usePlayerStore.getState());
expect((partial.queue as unknown[]).length).toBe(0);
expect(partial.queueIndex).toBe(0);
});
});
+27 -17
View File
@@ -16,6 +16,9 @@ import { createTransportLightActions } from './transportLightActions';
import { createUiStateActions } from './uiStateActions';
import { createUndoRedoActions } from './undoRedoActions';
// Half-width of the localStorage queue window (see partialize below).
const PERSIST_QUEUE_HALF = 250;
export const usePlayerStore = create<PlayerState>()(
persist(
(set, get) => {
@@ -78,23 +81,30 @@ export const usePlayerStore = create<PlayerState>()(
{
name: 'psysonic-player',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
volume: state.volume,
repeatMode: state.repeatMode,
currentTrack: state.currentTrack,
queue: state.queue,
queueServerId: state.queueServerId,
queueIndex: state.queueIndex,
isQueueVisible: state.isQueueVisible,
// currentTime is intentionally NOT persisted here.
// handleAudioProgress fires every 100ms and each setState with a
// persisted field triggers a full JSON serialisation of the queue to
// localStorage. After ~10 minutes of Artist Radio the queue grows to
// 50+ tracks; 6 000+ synchronous SQLite writes cause WKWebView's
// storage process to crash on macOS → black screen + audio stop.
// Resume position is recovered from Subsonic savePlayQueue (5s debounce).
lastfmLovedCache: state.lastfmLovedCache,
}),
partialize: (state) => {
// Persist only a window around the current track: the full queue
// (10k+ on big playlists) overflows the localStorage quota.
const qi = state.queueIndex;
const start = Math.max(0, qi - PERSIST_QUEUE_HALF);
const windowedQueue = state.queue.slice(start, qi + PERSIST_QUEUE_HALF + 1);
return {
volume: state.volume,
repeatMode: state.repeatMode,
currentTrack: state.currentTrack,
queue: windowedQueue,
queueServerId: state.queueServerId,
queueIndex: qi - start, // remap into the windowed slice
isQueueVisible: state.isQueueVisible,
// currentTime is intentionally NOT persisted here.
// handleAudioProgress fires every 100ms and each setState with a
// persisted field triggers a full JSON serialisation of the queue to
// localStorage. After ~10 minutes of Artist Radio the queue grows to
// 50+ tracks; 6 000+ synchronous SQLite writes cause WKWebView's
// storage process to crash on macOS → black screen + audio stop.
// Resume position is recovered from Subsonic savePlayQueue (5s debounce).
lastfmLovedCache: state.lastfmLovedCache,
};
},
}
)
);