refactor(queue): persist thin QueueItemRef list (thin-state phase 1) (#858)

* refactor(queue): persist thin QueueItemRef list (thin-state phase 1)

Introduce QueueItemRef ({ serverId, trackId, autoAdded?, radioAdded?,
playNextAdded? }) and persist the whole queue as a thin queueItems list,
superseding the queueRefs id list. Dual-write: the windowed queue: Track[]
stays as the index-off fallback; the in-memory store is unchanged.

hydrateQueueFromIndex now prefers queueItems (per-item serverId) and carries
the queue-only flags onto the hydrated tracks (the index doesn't store them),
with a queueRefs fallback for stores persisted before this change.

Phase 1 of the queue thin-state plan; no store/consumer changes yet.

* test(queue): cover legacy queueRefs-only upgrade in hydrate

* docs(changelog): queue section dividers kept on index restore (#858)
This commit is contained in:
Frank Stellmacher
2026-05-22 22:43:41 +02:00
committed by GitHub
parent 06213cb5d8
commit d15e270499
6 changed files with 164 additions and 22 deletions
+21
View File
@@ -285,4 +285,25 @@ describe('partialize: localStorage queue window', () => {
expect((partial.queue as unknown[]).length).toBe(0);
expect(partial.queueIndex).toBe(0);
});
it('persists the whole queue as thin queueItems (refs + serverId + flags), not just the window', () => {
const tracks = makeTracks(600);
tracks[3].radioAdded = true;
tracks[4].autoAdded = true;
usePlayerStore.setState({ queue: tracks, queueIndex: 300, queueServerId: 's1' });
const partial = getPartialize()(usePlayerStore.getState());
const items = partial.queueItems as {
serverId: string; trackId: string; radioAdded?: boolean; autoAdded?: boolean;
}[];
// windowed `queue` is capped, but queueItems carries the WHOLE queue
expect((partial.queue as unknown[]).length).toBe(501);
expect(items.length).toBe(600);
expect(partial.queueItemsIndex).toBe(300);
expect(items[0].serverId).toBe('s1');
expect(items[3].radioAdded).toBe(true);
expect(items[4].autoAdded).toBe(true);
expect(items[0].radioAdded).toBeUndefined();
});
});
+14 -7
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { emitPlaybackProgress } from './playbackProgress';
import type { PlayerState } from './playerStoreTypes';
import type { PlayerState, QueueItemRef } from './playerStoreTypes';
import { readInitialQueueVisibility } from './queueVisibilityStorage';
import { createLastfmActions } from './lastfmActions';
import { createMiscActions } from './miscActions';
@@ -94,12 +94,19 @@ export const usePlayerStore = create<PlayerState>()(
queue: windowedQueue,
queueServerId: state.queueServerId,
queueIndex: qi - start, // remap into the windowed slice
// F5: full ordered ref list (ids are tiny) so the *whole* queue can be
// rehydrated from the local index on startup. The windowed `queue`
// above stays as the no-index fallback (queue never empty when the
// index is off the P6 default).
queueRefs: state.queue.map(t => t.id),
queueRefsIndex: qi,
// Phase 1: full ordered thin-ref list (tiny) so the *whole* queue can
// be rehydrated from the local index on startup. Dual-write — the
// windowed `queue` above stays as the no-index fallback (queue never
// empty when the index is off, the P6 default). Per-item serverId is
// the playback server (single-server v1); supersedes `queueRefs`.
queueItems: state.queue.map((t): QueueItemRef => {
const ref: QueueItemRef = { serverId: state.queueServerId ?? '', trackId: t.id };
if (t.autoAdded) ref.autoAdded = true;
if (t.radioAdded) ref.radioAdded = true;
if (t.playNextAdded) ref.playNextAdded = true;
return ref;
}),
queueItemsIndex: qi,
isQueueVisible: state.isQueueVisible,
// currentTime is intentionally NOT persisted here.
// handleAudioProgress fires every 100ms and each setState with a
+21
View File
@@ -34,6 +34,21 @@ export interface Track {
playNextAdded?: boolean;
}
/**
* Thin canonical queue item (queue thin-state plan, §5.10). Identity plus the
* queue-only flags; library metadata (title/artist/cover/…) is resolved from
* the local index or network on demand from Phase 2 on. `serverId` is per-item
* (day-1 schema for mixed-server queues); v1 fills it with the single playback
* server.
*/
export interface QueueItemRef {
serverId: string;
trackId: string;
autoAdded?: boolean;
radioAdded?: boolean;
playNextAdded?: boolean;
}
export interface PlayerState {
currentTrack: Track | null;
waveformBins: number[] | null;
@@ -64,6 +79,12 @@ export interface PlayerState {
* are then cleared. Absent / index-off → the windowed `queue` is used as-is. */
queueRefs?: string[];
queueRefsIndex?: number;
/** Phase 1 (transient): thin canonical ref list persisted alongside the
* windowed `queue`, superseding `queueRefs`. Carries per-item `serverId` +
* queue-only flags. Rehydrated like `queueRefs` and cleared after a full
* hydrate from the index. The in-memory queue stays `Track[]` until Phase 2. */
queueItems?: QueueItemRef[];
queueItemsIndex?: number;
isPlaying: boolean;
/** HTTP stream still buffering (network / demux probe) — show loading on cover art. */
isPlaybackBuffering: boolean;