refactor(queue): thin-state refs as canonical, full Track via resolver (#872)

* refactor(queue): wire queue UI to the track resolver (thin-state phase 3)

cucadmuh's phase-3 steps:
- Selectors (useQueueTracks) read resolver-first: getCachedTrack → queue: Track[]
  fallback (until phase 4), F4 star/rating overrides merged on read.
- QueueList rows source their track from the resolver (queue fallback); rows show
  title/artist/duration only, so no override merge there.
- pendingStarSync star/rating success → invalidateQueueResolver so the cache
  reflects the synced value.
- queueResolverBridge re-seeds on queueIndex change too — the prefetch window
  travels with the playing track.

Additive: queue: Track[] stays canonical and behaviour is unchanged (rows
resolve to the same data). Phase 4 drops queue: Track[] and the fallbacks.

* docs(changelog): queue panel reads through track cache (#860)

* fix(queue): stop a render loop that froze the UI on long queues

A long virtualized queue + a track change could lock the WebView for ~2 min:
- useVirtualizer was handed a fresh `initialRect` object literal every render, so
  it kept re-initializing in a loop. Hoisted it to a stable module constant.
- getCachedTrack did an LRU bump (Map delete+set) during render — a render-time
  side effect. Made it a pure read; recency is set at write time in cacheSet.

* perf(mobile): virtualize the mobile player queue drawer

The mobile now-playing queue drawer rendered the full queue with .map; a
multi-thousand-track queue meant thousands of DOM nodes. Virtualize it with
@tanstack/react-virtual (uniform rows, stable initialRect) so the DOM stays at
O(visible rows), matching the desktop QueuePanel. Active track is centred on open
via scrollToIndex.

* perf(mini): virtualize the mini-player queue list

The mini-player queue rendered the full MiniSyncPayload queue with .map.
Virtualize it against the OverlayScrollArea viewport (stable initialRect) so the
mini window's DOM stays at O(visible rows). Drag-reorder is preserved: rows keep
data-mq-idx alongside the virtualizer's measureElement.

* refactor(queue): add resolveQueueTrack/getQueueTracksView helper (thin-state phase 4)

Render-safe ref→Track view for the phase-4 consumer migration off queue: Track[].
Resolver cache → caller fallback (legacy queue[idx] during dual-write) →
placeholder; ref queue-only flags carried, F4 overrides merged. Pure synchronous
read, no cache mutation (the freeze landmine), so it is safe in render.

* refactor(queue): keep queueItems as the canonical in-memory mirror (thin-state phase 4)

Step 1b: dual-write the thin queueItems ref list at every queue write site
(the 11 mutations, next/radio top-up, playTrack, undo/redo restore, instant-mix,
radio, server-queue init, lucky-mix rollback, and hydrate) so it tracks
queue: Track[] in memory, not only at persist time. Identity-preserving maps
(star/rating overrides) keep the same refs and are intentionally left untouched.

Resolves the restore double-role flagged for 1b: queueItemsIndex is now the
restore-pending sentinel that gates hydrateQueueFromIndex, while queueItems
stays canonical -- rebuilt from the whole queue after a full hydrate instead of
cleared. Normal mutations never set the sentinel, so it only fires on a fresh
cold-start restore, not on later server switches.

No behaviour change; queue: Track[] stays the source consumers read until
phase 3. tsc + full vitest suite (1119 tests) green.

* refactor(queue): mobile queue drawer reads through the track resolver (thin-state phase 4)

Step 2: the mobile now-playing queue drawer resolves each row's track from the
resolver cache (→ queue: Track[] fallback until phase 4), matching the desktop
QueueList wired in the phase-3 commit. Subscribes to the resolver version so
rows re-render as the cache fills. Structure (count, order, keys, the playTrack
arg) still comes from queue: Track[] until it is dropped in the final step.

The mobile drawer was the last queue display surface still reading track
metadata straight off the fat queue. tsc + full vitest suite green.

* refactor(queue): ref-native queue mutations + dual-write bridge (thin-state phase 4)

Step 3a: the 11 queueMutationActions now splice/filter/reorder QueueItemRef[]
(matching by trackId + the ref's queue-only flags) instead of Track[].
`bridgeQueueFromItems` rebuilds the dual-written queue: Track[] from the new
refs by id — purely structural (no resolver/override merge), so behaviour is
byte-identical and playerStore.queue.test.ts stays unchanged green. The working
ref list comes from `itemsOf(state)` (derived from queue: Track[] for now); the
final step swaps that one line to state.queueItems once the fat queue is gone.

enqueue / enqueueAt / enqueueRadio seed the resolver cache with incoming tracks
(seed-before-splice) so they resolve without a network round-trip after the fat
queue is dropped. Adds a DEV-only id-parity guardrail (queue vs queueItems);
dev-runtime only, silent in vitest and prod.

tsc + full vitest suite (1119) green; contract test unchanged.

* refactor(queue): ref-native radio/infinite top-ups (thin-state phase 4)

Step 3b: nextAction's proactive infinite-queue and radio top-ups build the new
queue as QueueItemRef[] and bridge back to queue: Track[] (same as the queue
mutations), and seed the resolver cache with the freshly fetched tracks so they
resolve without a network round-trip after the fat queue is dropped. The radio
top-up keeps its HISTORY_KEEP front-trim, now expressed on refs.

The exhausted-queue refill paths hand their new queue to playTrack, which keeps
its fat-queue handling until the final step (its no-arg case needs the resolver-
derived queue that lands with the queue: Track[] removal). tsc + full vitest
(1119) green; contract test unchanged.

* refactor(queue): undo snapshots store thin refs, not Track[] (thin-state phase 4)

Step 4: QueueUndoSnapshot.queue: Track[] becomes queueItems: QueueItemRef[],
killing the undo "hidden multiplier" — 32 snapshots of a 50k queue now cost
refs, not 32×50k full tracks. applyQueueHistorySnapshot rebuilds the display
queue from the refs via resolveQueueTrack: resolver cache → the live queue by id
(covers tracks the edit didn't remove) → placeholder. currentTrack stays a full
track in the snapshot and is restored to the engine unchanged.

The snapshot refs derive from queue: Track[] for now (so the undo/redo contract
cases, which seed only `queue`, stay green); the final step swaps that to
[...s.queueItems]. tsc + full vitest suite (1119) green.

* perf(mini): cap the mini-player queue snapshot to ±100 around the current track (thin-state phase 4)

Step 5: the mini bridge no longer serializes the full queue over IPC on every
push — a 50k Artist-Radio queue would otherwise re-encode in full on every track
advance. snapshot() sends a window of 100 tracks before/after the playing song;
queueIndex is made slice-relative. The mini component stays unchanged (slice-
relative); jump/reorder/remove control events are translated back to absolute
queue indices via the window offset captured on the last push.

tsc + full vitest suite (1119) green. Mini bridge has no unit tests — needs a
quick mini-player smoke (queue shows ±100, jump/reorder/remove land correctly).

* refactor(queue): make queueItems a required PlayerState field (thin-state phase 4)

Foundation for the final consumer migration off queue: Track[]: queueItems has
been written at every queue write site since phase 1b, so promoting it from
optional to required is a no-op at runtime (tsc confirms zero new errors) and
lets the upcoming reader migrations read state.queueItems without `?? []` noise.

* refactor(queue): migrate structural queue readers off queue: Track[] (thin-state phase 4)

First reader batch toward dropping queue: Track[]: the queue-length selectors
(usePlaybackServerId, usePlaybackCoverArt, useQueuePanelDrag, useMiniQueueDrag)
now read state.queueItems.length, and FullscreenPlayer's next-track cover prefetch
resolves through useQueueTrackAt instead of indexing the fat queue. All behaviour-
identical during dual-write (queueItems is in lockstep with queue). tsc + full
vitest suite (1119) green.

Note: getPlaybackServerId() (playbackServer.ts) deliberately stays on queue for
now — it is called from many partially-mocked test stores, so it migrates with
the final field removal where the seedQueue helper covers those tests.

* refactor(queue): QueuePanel save/share/playlist read queueItems (thin-state phase 4)

The id/length reads (save to playlist, share link, create playlist, empty-queue
guards, next-tracks divider) now read state.queueItems instead of the fat queue.
Behaviour-identical during dual-write; queue: Track[] stays for the rendered
QueueList + auto-scroll until the field is dropped. tsc + full suite (1119) green.

* refactor(queue): drop queue: Track[] — thin queueItems is the only queue (thin-state phase 4)

The store no longer holds the fat queue. `queueItems: QueueItemRef[]` is the sole
canonical queue; full `Track`s resolve on demand via the resolver (index batch →
getSong fallback, bounded LRU cache); only `currentTrack` stays a full Track. At
50k tracks the store holds ~hundreds of resolved tracks + the refs, not 50k Track
objects.

- **Persist:** partialize is refs-only (no windowed slice / PERSIST_QUEUE_HALF).
  A `merge` migrates every historical blob shape → `queueItems` (existing
  `queueItems` → legacy `queueRefs` → pre-ref windowed `queue: Track[]`) and drops
  the obsolete `queue` key, so saved queues survive the upgrade.
- **Restore (decision B):** `hydrateQueueFromIndex` eager-resolves the whole
  ref list into the cache on cold start (index → getSong, so an index-off queue
  still plays), clears the restore sentinel.
- **Resolver bridge:** keeps `[idx-50, idx+200]` warm via `resolveVisibleRange`.
- **Mutations / actions / playback:** operate on refs; the playing track is
  `currentTrack`, the next/neighbour tracks resolve from the cache. Navigation
  (next/previous/row-jump) keeps `queueItems` and only moves the index — no full
  resolve or queue rebuild per track change.
- **Persist tests** cover the three old-blob migrations; `seedQueue` test helper
  replaces the `setState({ queue })` seeds.

tsc + full vitest suite (1115) green. Behaviour-preserving by the test contract;
the gapless track change + cold-start restore + mini cap still want a live smoke
before merge.

* fix(queue): star/rating keeps the queue row resolved instead of blanking to "…" (thin-state)

Rating/starring a queue song flashed the row's title to the "…" placeholder
until the next track change. Root cause: on sync success pendingStarSync called
invalidateQueueResolver, which DROPPED the cached track — and with queue: Track[]
gone there's no fat fallback, so the row resolved to a placeholder until the
resolver bridge re-fetched the window.

Fix: add patchCachedTrack(trackId, patch) and use it on star/rating success to
update the cached entry in place (title kept, synced starred/userRating applied)
instead of dropping it. No placeholder flash, no re-fetch.

tsc + full vitest suite (1115) green.

* fix(player): quota-safe persist so a full localStorage can't kill playback

A very large queue (~50k refs) overflows the localStorage quota; the persist
write then threw QuotaExceededError from inside set(), which aborted playTrack
before audio_play — no audio output at all. Back the player persist with a
quota-safe storage wrapper so a failed write degrades to a no-op instead of
throwing. Restoring the full ref list at that ceiling (vs a windowed cap) is
left as a follow-up.

* polish(player): throttle the quota-skip persist warning to once per key

The quota-safe persist logs a skip on every failed write; on a huge queue that
floods the dev console once per mutation. Warn once per key per quota-exceeded
streak, re-armed when a write to that key next succeeds.

* fix(queue): port new cover-pipeline readers to thin-state

Main's cover pipeline (#870) reads s.queue.length and seeds the player
store with queue: [track] in its tests. Under thin-state, queue: Track[]
no longer exists — the canonical queue is queueItems: QueueItemRef[].
These four files were brought across in the merge but still spoke the
old shape; this commit aligns them with the thin-state contract.

- src/cover/usePlaybackCoverArt: queueLength = queueItems.length
- src/cover/usePlaybackCoverArt.test: seed via toQueueItemRefs
- src/api/coverCache.test: same
- src/hooks/useNowPlayingPrewarm.test: same (two test cases)

* fix(queue): canonicalize thin-state server identity for mixed-server queues

`QueueItemRef.serverId` and `PlayerState.queueServerId` are now written as
the URL-derived index key on every writer path, matching the library index
direction. Mixed-server queues with duplicate `trackId` across servers stay
unambiguous because the resolver cache, persistence, and playback bindings
all share one key shape.

- new `canonicalQueueServerKey()` helper (idempotent UUID-or-key normalizer)
- `toQueueItemRefs`, `bindQueueServerForPlayback`, `seedQueueResolver`, and
  `hydrateQueueFromIndex` emit canonical keys
- `getCachedTrack` falls back to the canonical lookup so refs persisted in
  the legacy UUID shape still resolve through the migration window
- persist `merge` rewrites `queueServerId` and every ref `serverId` on
  rehydrate, so the live store never holds mixed shapes
- `removeServer` compares against the resolved id so a profile delete still
  clears the matching queue binding
- the two `playbackServer.test.ts` asserts that hard-coded the UUID shape
  are updated to the canonical key (existing reader-tolerance is unchanged)

* fix(queue-undo): bind snapshot prepend to snapshot-canonical server identity

When `applyQueueHistorySnapshot` has to prepend the still-playing track
(the snapshot's queue does not contain it), the new ref must follow the
snapshot's playback server, not the live `queueServerId`. A server switch
racing the undo would otherwise stamp the prepended ref with the new
server, mis-resolving the playing track on the very next render.

- `QueueUndoSnapshot` now carries `queueServerId` (captured by
  `queueUndoSnapshotFromState`); older in-memory entries fall back through
  the snapshot's own refs and finally the live store value
- the prepend in `applyQueueHistorySnapshot` plus the post-restore
  `seedQueueResolver` both source the server identity from this snapshot
  context, run through `canonicalQueueServerKey` so cache bucket and ref
  shape stay in lockstep

* test(queue): regression cluster for mixed-server queues with duplicate trackId

Covers the four invariants the thin-state review called out:

- resolver correctness: same `trackId` on two servers maps to two distinct
  cache entries via canonical keys, and legacy UUID-shaped refs still read
  the same entries through the compat lookup path
- restore/hydrate: persist `merge` forward-migrates UUID-form blobs in
  three shapes (canonical `queueItems`, legacy `queueRefs`, mixed-server
  `queueItems`) to canonical keys
- undo snapshot application: prepended ref follows the snapshot's playback
  server even when the live queue has been rebound to a different one,
  with fallback to snapshot refs and live state for legacy entries
- queue sync id emission: `flushPlayQueuePosition` -> `savePlayQueue`
  passes plain track ids and the playback server out of band, no per-ref
  `serverId` ever leaks into the request body

Also asserts the write helpers (`toQueueItemRefs`,
`bindQueueServerForPlayback`) emit canonical keys directly.

* perf(queue-header): coalesce resolver burst updates and aggregate in one pass

`QueueHeader` recomputed total and remaining queue durations on every
resolver cache version bump via two separate full-queue reduces. A mass
resolve burst (queue restore, prefetch window slide) bumps the version
dozens of times in one frame, and very long queues turned that into
visible main-thread stutter.

- one pass: a single for-loop produces both total and future-tracks
  duration; a 50k-track queue costs one walk per recompute, not two
- `useDeferredValue(version)` coalesces the burst into a single
  low-priority commit so the cache version is only sampled once per
  React frame instead of once per cache write

* fix(queue): use stable artist seed for radio top-up

The proactive radio top-up in `runNext` seeded `getSimilarSongs2` and
`getTopSongs` from `resolveQueueTrack(nextRef)` metadata. When the next
ref is still cold in the resolver cache, the placeholder track has empty
artist fields, and the top-up would fire `getSimilarSongs2('')` -- silently
returning nothing and leaving the queue dry just before the radio rail
would have refilled.

- prefer the just-played `currentTrack` (always fully resolved in the
  player store) and the stored radio seed artist id
- fall back to the next-track metadata only when those are missing
- skip the top-up entirely when no stable seed is available, instead of
  emitting a non-deterministic empty request

* docs(changelog): queue mixed-server routing and quota-safe persist (#872)
This commit is contained in:
Frank Stellmacher
2026-05-27 00:10:34 +02:00
committed by GitHub
parent a8cfff0b62
commit 45b9229ceb
88 changed files with 1965 additions and 944 deletions
@@ -3,6 +3,11 @@ import { invoke } from '@tauri-apps/api/core';
import { getPlaybackProgressSnapshot } from '../../store/playbackProgress';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
import { resolveQueueTrack } from '../../utils/library/queueTrackView';
/** Half-width of the CLI snapshot queue window (thin-state — like the mini
* bridge, the full 50k queue must not serialize over IPC on every change). */
const SNAPSHOT_QUEUE_HALF = 100;
/** `psysonic --info`: publishes a JSON snapshot under XDG_RUNTIME_DIR (Rust
* writes atomically). Coalesces store changes through a 200 ms debounce and
@@ -27,12 +32,19 @@ export function usePlayerSnapshotPublisher() {
ct != null
? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred))
: null;
// Thin-state: resolve only a window around the playing track (resolver
// cache → placeholder) instead of the whole 50k queue. `queue_length`
// stays the true total; `queue_index` is remapped into the window.
const total = s.queueItems.length;
const winStart = Math.max(0, s.queueIndex - SNAPSHOT_QUEUE_HALF);
const winEnd = Math.min(total, s.queueIndex + SNAPSHOT_QUEUE_HALF + 1);
const windowedQueue = s.queueItems.slice(winStart, winEnd).map(r => resolveQueueTrack(r));
const snapshot = {
current_track: s.currentTrack,
current_radio: s.currentRadio,
queue: s.queue,
queue_index: s.queueIndex,
queue_length: s.queue.length,
queue: windowedQueue,
queue_index: s.queueIndex - winStart,
queue_length: total,
is_playing: s.isPlaying,
current_time: getPlaybackProgressSnapshot().currentTime,
volume: s.volume,
@@ -50,7 +62,7 @@ export function usePlayerSnapshotPublisher() {
trackId: s.currentTrack?.id ?? null,
radioId: s.currentRadio?.id ?? null,
queueIndex: s.queueIndex,
queueLength: s.queue.length,
queueLength: total,
isPlaying: s.isPlaying,
volume: Math.round(s.volume * 100),
repeatMode: s.repeatMode,
+1 -1
View File
@@ -52,7 +52,7 @@ export function useMiniQueueDrag({
if (parsed.type !== 'queue_reorder') return;
const fromIdx = parsed.index as number;
psyDragFromIdxRef.current = null;
const queueLen = usePlayerStore.getState().queue.length || fallbackQueueLen;
const queueLen = usePlayerStore.getState().queueItems.length || fallbackQueueLen;
const insertIdx = tgt
? (tgt.before ? tgt.idx : tgt.idx + 1)
: queueLen;
+3 -2
View File
@@ -8,6 +8,7 @@ import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { makeTrack } from '../test/helpers/factories';
import { resetAllStores } from '../test/helpers/storeReset';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
vi.mock('../api/coverCache', () => ({
coverCachePeekBatch: vi.fn(async () => ({})),
@@ -51,7 +52,7 @@ describe('useNowPlayingPrewarm', () => {
coverArt: 'cover-1',
});
usePlayerStore.setState({
queue: [track],
queueItems: toQueueItemRefs(playback, [track]),
queueIndex: 0,
queueServerId: playback,
currentTrack: track,
@@ -76,7 +77,7 @@ describe('useNowPlayingPrewarm', () => {
const { active, playback } = seedServers();
const track = makeTrack({ id: 'song-2', coverArt: 'cover-2' });
usePlayerStore.setState({
queue: [track],
queueItems: toQueueItemRefs(playback, [track]),
queueIndex: 0,
queueServerId: playback,
currentTrack: null,
+8 -7
View File
@@ -108,23 +108,24 @@ export function useOrbitHost(): void {
&& (Date.now() - afterSweep.lastShuffle >= effectiveShuffleIntervalMs(afterSweep));
const afterShuffle = maybeShuffleQueue(afterSweep);
if (shouldShuffleNow) {
const before = usePlayerStore.getState().queue.length;
const before = usePlayerStore.getState().queueItems.length;
usePlayerStore.getState().shuffleUpcomingQueue();
if (before > 0) showToast(i18n.t('orbit.toastShuffled'), 2500, 'info');
}
// 4) Overlay the host's live playback snapshot.
// 4) Overlay the host's live playback snapshot. Host tick reads ids only,
// so the thin `queueItems` refs are all we need (no full Track resolve).
const playerLive = usePlayerStore.getState();
const upcoming = playerLive.queue.slice(playerLive.queueIndex + 1);
const upcoming = playerLive.queueItems.slice(playerLive.queueIndex + 1);
// Map track id → original suggester (if any). State's `queue` carries
// every suggestion we've ever seen this session, so it's the right
// attribution source even after the track has been merged into the
// host's player queue.
const suggesterByTrack = new Map<string, string>();
for (const q of afterShuffle.queue) suggesterByTrack.set(q.trackId, q.addedBy);
const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(t => ({
trackId: t.id,
addedBy: suggesterByTrack.get(t.id) ?? base.host,
const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(r => ({
trackId: r.trackId,
addedBy: suggesterByTrack.get(r.trackId) ?? base.host,
}));
const next: OrbitState = {
...afterShuffle,
@@ -204,7 +205,7 @@ export function useOrbitHost(): void {
for (const { track } of toEnqueue) {
const live = usePlayerStore.getState();
const from = Math.max(0, live.queueIndex + 1);
const to = live.queue.length;
const to = live.queueItems.length;
const span = Math.max(1, to - from + 1);
const pos = from + Math.floor(Math.random() * span);
player.enqueueAt([track], pos);
+1 -1
View File
@@ -19,7 +19,7 @@ describe('usePlaybackServerId', () => {
isLoggedIn: true,
});
usePlayerStore.setState({
queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }],
queueItems: [{ serverId: 'a', trackId: 't1' }],
queueServerId: 'a',
queueIndex: 0,
});
+1 -1
View File
@@ -9,7 +9,7 @@ import { getPlaybackServerId } from '../utils/playback/playbackServer';
*/
export function usePlaybackServerId(): string {
const queueServerId = usePlayerStore(s => s.queueServerId);
const queueLength = usePlayerStore(s => s.queue.length);
const queueLength = usePlayerStore(s => s.queueItems.length);
const activeServerId = useAuthStore(s => s.activeServerId);
return useMemo(
() => getPlaybackServerId(),
+2 -2
View File
@@ -1,9 +1,9 @@
import React, { useLayoutEffect } from 'react';
import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo';
import type { Track } from '../store/playerStoreTypes';
import type { QueueItemRef, Track } from '../store/playerStoreTypes';
interface Args {
queue: Track[];
queue: QueueItemRef[];
queueIndex: number;
currentTrack: Track | null;
queueListRef: React.RefObject<HTMLDivElement | null>;
+1 -1
View File
@@ -66,7 +66,7 @@ export function useQueuePanelDrag({
const insertIdx = dropTarget
? (dropTarget.before ? dropTarget.idx : dropTarget.idx + 1)
: usePlayerStore.getState().queue.length;
: usePlayerStore.getState().queueItems.length;
if (parsedData.type === 'queue_reorder') {
const fromIdx: number = parsedData.index;
+24 -18
View File
@@ -3,9 +3,8 @@ import { renderHook } from '@testing-library/react';
import { usePlayerStore } from '@/store/playerStore';
import type { Track } from '@/store/playerStoreTypes';
import { getCachedTrack, _resetQueueResolverForTest } from '@/utils/library/queueTrackResolver';
import { seedQueue } from '@/test/helpers/factories';
import { useQueueTrackAt, useCurrentTrack, useQueueItems } from './useQueueTracks';
// Importing the bridge registers the queue→resolver seed subscriber.
import '@/store/queueResolverBridge';
const track = (id: string, over: Partial<Track> = {}): Track =>
({ id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1, ...over });
@@ -13,25 +12,37 @@ const track = (id: string, over: Partial<Track> = {}): Track =>
describe('useQueueTracks selectors', () => {
beforeEach(() => {
_resetQueueResolverForTest();
usePlayerStore.setState({ queue: [], queueIndex: 0, queueServerId: 's1', currentTrack: null });
usePlayerStore.setState({
queueItems: [], queueIndex: 0, queueServerId: 's1', currentTrack: null,
starredOverrides: {}, userRatingOverrides: {},
});
});
it('useQueueTrackAt returns the track at the index, or null', () => {
usePlayerStore.setState({ queue: [track('t1'), track('t2')] });
// seedQueue seeds the resolver under serverId 's1' and sets the refs.
seedQueue([track('t1'), track('t2')], { serverId: 's1', currentTrack: null });
expect(renderHook(() => useQueueTrackAt(1)).result.current?.id).toBe('t2');
expect(renderHook(() => useQueueTrackAt(9)).result.current).toBeNull();
});
it('useQueueTrackAt merges session star/rating overrides', () => {
seedQueue([track('t1')], { serverId: 's1', currentTrack: null });
usePlayerStore.setState({
starredOverrides: { t1: true },
userRatingOverrides: { t1: 5 },
});
const { result } = renderHook(() => useQueueTrackAt(0));
expect(!!result.current?.starred).toBe(true);
expect(result.current?.userRating).toBe(5);
});
it('useCurrentTrack returns the current track', () => {
usePlayerStore.setState({ currentTrack: track('cur') });
expect(renderHook(() => useCurrentTrack()).result.current?.id).toBe('cur');
});
it('useQueueItems derives thin refs (serverId + flags) from the queue', () => {
usePlayerStore.setState({
queueServerId: 's1',
queue: [track('t1'), track('t2', { radioAdded: true })],
});
it('useQueueItems returns the canonical thin refs (serverId + flags)', () => {
seedQueue([track('t1'), track('t2', { radioAdded: true })], { serverId: 's1', currentTrack: null });
const { result } = renderHook(() => useQueueItems());
expect(result.current).toEqual([
{ serverId: 's1', trackId: 't1' },
@@ -40,20 +51,15 @@ describe('useQueueTracks selectors', () => {
});
});
describe('queueResolverBridge', () => {
describe('seedQueue resolver seeding', () => {
beforeEach(() => {
_resetQueueResolverForTest();
usePlayerStore.setState({ queue: [], queueIndex: 0, queueServerId: 's1', currentTrack: null });
usePlayerStore.setState({ queueItems: [], queueIndex: 0, queueServerId: 's1', currentTrack: null });
});
it('seeds the resolver cache with tracks around the current index on queue change', () => {
usePlayerStore.setState({ queue: [track('t1'), track('t2')], queueIndex: 0, queueServerId: 's1' });
it('seeds the resolver cache with the queue tracks under the queue server', () => {
seedQueue([track('t1'), track('t2')], { serverId: 's1', currentTrack: null });
expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1');
expect(getCachedTrack({ serverId: 's1', trackId: 't2' })?.id).toBe('t2');
});
it('does not seed when there is no playback server', () => {
usePlayerStore.setState({ queue: [track('t1')], queueIndex: 0, queueServerId: null });
expect(getCachedTrack({ serverId: '', trackId: 't1' })).toBeUndefined();
});
});
+21 -11
View File
@@ -1,18 +1,30 @@
import { useMemo } from 'react';
import { useMemo, useSyncExternalStore } from 'react';
import { usePlayerStore } from '../store/playerStore';
import type { QueueItemRef, Track } from '../store/playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import {
getQueueResolverVersion,
subscribeQueueResolver,
} from '../utils/library/queueTrackResolver';
/**
* Stable queue selectors (queue thin-state). Consumers migrate onto these in
* phase 3. Today they read the canonical `queue: Track[]`; once it's dropped
* (phase 4) the implementations move to the resolver (`queueTrackResolver`)
* without changing these signatures.
* Stable queue selectors (queue thin-state). The store is refs-canonical now:
* full `Track`s come from the resolver cache (placeholder until a fetch lands),
* with session star/rating overrides (F4) merged on read via resolveQueueTrack.
*/
/** The track at a queue index, or null. */
export function useQueueTrackAt(idx: number): Track | null {
return usePlayerStore(s => s.queue[idx] ?? null);
const ref = usePlayerStore(s => s.queueItems[idx] ?? null);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
return useMemo(() => {
if (!ref) return null;
return resolveQueueTrack(ref);
// version drives re-resolution as the cache fills; overrides drive the merge.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref, starredOverrides, userRatingOverrides, version]);
}
/** The currently playing track, or null. */
@@ -20,9 +32,7 @@ export function useCurrentTrack(): Track | null {
return usePlayerStore(s => s.currentTrack);
}
/** The whole queue as thin refs (derived; memoized on queue/server identity). */
/** The whole queue as thin refs (the canonical list). */
export function useQueueItems(): QueueItemRef[] {
const queue = usePlayerStore(s => s.queue);
const serverId = usePlayerStore(s => s.queueServerId);
return useMemo(() => toQueueItemRefs(serverId ?? '', queue), [serverId, queue]);
return usePlayerStore(s => s.queueItems);
}