refactor(queue): track resolver + selectors (thin-state phase 2) (#859)

* refactor(queue): add queue track resolver (thin-state phase 2a)

Standalone resolver: QueueItemRef → Track via index batch
(library_get_tracks_batch, ≤100/call) → network getSong fallback (P8), into a
bounded LRU cache. Holds raw tracks; session star/rating overrides (F4) merged
on read via applyQueueOverrides. Sync getCachedTrack + subscribeQueueResolver
for selectors; resolveVisibleRange prefetches a [-50, +200] window; carries
queue-only flags from refs; invalidate drops entries after a sync succeeds.

Not wired into the store/UI yet — phase 2b does that.

* refactor(queue): extract toQueueItemRefs helper

Pure helper deriving thin QueueItemRefs from a Track[] queue (per-item
serverId, queue-only flags), shared by the persist partialize and the
upcoming phase-2b resolver bridge. No behaviour change.

* refactor(queue): resolver bridge + queue selectors (thin-state phase 2b)

Seed the resolver cache from the canonical queue (queueResolverBridge, a
windowed [-50, +200] seed around the current index) and add the stable queue
selectors (useQueueTrackAt / useCurrentTrack / useQueueItems).

Additive: the store stays queue: Track[]-canonical; consumers migrate onto the
selectors in phase 3, and the selector impls move to the resolver once
queue: Track[] is dropped in phase 4. No mutation or persist change — the
persisted queueItems keeps its single restore role (no dual-role clash).

* docs(changelog): on-demand queue track loading groundwork (#859)
This commit is contained in:
Frank Stellmacher
2026-05-22 23:30:49 +02:00
committed by GitHub
parent d15e270499
commit 090a31bc82
9 changed files with 478 additions and 8 deletions
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach } from 'vitest';
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 { 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 });
describe('useQueueTracks selectors', () => {
beforeEach(() => {
_resetQueueResolverForTest();
usePlayerStore.setState({ queue: [], queueIndex: 0, queueServerId: 's1', currentTrack: null });
});
it('useQueueTrackAt returns the track at the index, or null', () => {
usePlayerStore.setState({ queue: [track('t1'), track('t2')] });
expect(renderHook(() => useQueueTrackAt(1)).result.current?.id).toBe('t2');
expect(renderHook(() => useQueueTrackAt(9)).result.current).toBeNull();
});
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 })],
});
const { result } = renderHook(() => useQueueItems());
expect(result.current).toEqual([
{ serverId: 's1', trackId: 't1' },
{ serverId: 's1', trackId: 't2', radioAdded: true },
]);
});
});
describe('queueResolverBridge', () => {
beforeEach(() => {
_resetQueueResolverForTest();
usePlayerStore.setState({ queue: [], 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' });
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();
});
});
+28
View File
@@ -0,0 +1,28 @@
import { useMemo } from 'react';
import { usePlayerStore } from '../store/playerStore';
import type { QueueItemRef, Track } from '../store/playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
/**
* 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.
*/
/** The track at a queue index, or null. */
export function useQueueTrackAt(idx: number): Track | null {
return usePlayerStore(s => s.queue[idx] ?? null);
}
/** The currently playing track, or null. */
export function useCurrentTrack(): Track | null {
return usePlayerStore(s => s.currentTrack);
}
/** The whole queue as thin refs (derived; memoized on queue/server identity). */
export function useQueueItems(): QueueItemRef[] {
const queue = usePlayerStore(s => s.queue);
const serverId = usePlayerStore(s => s.queueServerId);
return useMemo(() => toQueueItemRefs(serverId ?? '', queue), [serverId, queue]);
}