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
+47 -49
View File
@@ -33,7 +33,7 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
import { usePlayerStore } from './playerStore';
import { onInvoke } from '@/test/mocks/tauri';
import { resetPlayerStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks } from '@/test/helpers/factories';
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
beforeEach(() => {
resetPlayerStore();
@@ -50,56 +50,56 @@ describe('enqueue', () => {
it('appends a single track to an empty queue', () => {
const t1 = makeTrack({ id: 't1' });
usePlayerStore.getState().enqueue([t1], true);
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['t1']);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['t1']);
});
it('appends multiple tracks in order', () => {
const tracks = makeTracks(3);
usePlayerStore.getState().enqueue(tracks, true);
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(tracks.map(t => t.id));
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(tracks.map(t => t.id));
});
it('inserts before the first upcoming auto-added separator', () => {
const head = makeTrack({ id: 'head' });
const auto = makeTrack({ id: 'auto', autoAdded: true });
const tail = makeTrack({ id: 'tail', autoAdded: true });
usePlayerStore.setState({ queue: [head, auto, tail], queueIndex: 0 });
seedQueue([head, auto, tail], { index: 0 });
const incoming = makeTrack({ id: 'new' });
usePlayerStore.getState().enqueue([incoming], true);
// Insert lands between `head` (the currently-playing one) and the first
// auto-added track, so the auto-added group stays at the tail.
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['head', 'new', 'auto', 'tail']);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['head', 'new', 'auto', 'tail']);
});
it('appends at the end when there are no auto-added tracks after the cursor', () => {
const head = makeTrack({ id: 'head' });
const mid = makeTrack({ id: 'mid' });
usePlayerStore.setState({ queue: [head, mid], queueIndex: 0 });
seedQueue([head, mid], { index: 0 });
usePlayerStore.getState().enqueue([makeTrack({ id: 'tail' })], true);
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['head', 'mid', 'tail']);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['head', 'mid', 'tail']);
});
it('ignores auto-added separators that already passed (behind the cursor)', () => {
const past = makeTrack({ id: 'past', autoAdded: true });
const current = makeTrack({ id: 'current' });
usePlayerStore.setState({ queue: [past, current], queueIndex: 1 });
seedQueue([past, current], { index: 1 });
usePlayerStore.getState().enqueue([makeTrack({ id: 'new' })], true);
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['past', 'current', 'new']);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['past', 'current', 'new']);
});
});
describe('enqueueAt', () => {
it('inserts at the given index', () => {
const queue = makeTracks(3);
usePlayerStore.setState({ queue, queueIndex: 0 });
seedQueue(queue, { index: 0 });
const ins = makeTrack({ id: 'ins' });
usePlayerStore.getState().enqueueAt([ins], 2, true);
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([queue[0].id, queue[1].id, 'ins', queue[2].id]);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([queue[0].id, queue[1].id, 'ins', queue[2].id]);
});
it('shifts queueIndex forward when inserting at or before the cursor', () => {
const queue = makeTracks(3);
usePlayerStore.setState({ queue, queueIndex: 2 });
seedQueue(queue, { index: 2 });
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], 1, true);
// Two tracks inserted at idx 1 → cursor (was 2) moves to 4.
expect(usePlayerStore.getState().queueIndex).toBe(4);
@@ -107,59 +107,57 @@ describe('enqueueAt', () => {
it('keeps queueIndex when inserting after the cursor', () => {
const queue = makeTracks(3);
usePlayerStore.setState({ queue, queueIndex: 1 });
seedQueue(queue, { index: 1 });
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' })], 3, true);
expect(usePlayerStore.getState().queueIndex).toBe(1);
});
it('clamps a negative insertIndex to 0', () => {
const queue = makeTracks(2);
usePlayerStore.setState({ queue, queueIndex: 0 });
seedQueue(queue, { index: 0 });
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'front' })], -5, true);
expect(usePlayerStore.getState().queue[0].id).toBe('front');
expect(usePlayerStore.getState().queueItems[0].trackId).toBe('front');
});
it('clamps an over-large insertIndex to the queue length', () => {
const queue = makeTracks(2);
usePlayerStore.setState({ queue, queueIndex: 0 });
seedQueue(queue, { index: 0 });
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'back' })], 99, true);
const q = usePlayerStore.getState().queue;
expect(q[q.length - 1].id).toBe('back');
const q = usePlayerStore.getState().queueItems;
expect(q[q.length - 1].trackId).toBe('back');
});
});
describe('playNext', () => {
it('tags inserted tracks with playNextAdded', () => {
const queue = makeTracks(2);
usePlayerStore.setState({ queue, queueIndex: 0, currentTrack: queue[0] });
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.getState().playNext([makeTrack({ id: 'pn' })]);
const inserted = usePlayerStore.getState().queue.find(t => t.id === 'pn');
const inserted = usePlayerStore.getState().queueItems.find(r => r.trackId === 'pn');
expect(inserted?.playNextAdded).toBe(true);
});
it('inserts immediately after the current track', () => {
const a = makeTrack({ id: 'a' });
const b = makeTrack({ id: 'b' });
usePlayerStore.setState({ queue: [a, b], queueIndex: 0, currentTrack: a });
seedQueue([a, b], { index: 0, currentTrack: a });
usePlayerStore.getState().playNext([makeTrack({ id: 'pn' })]);
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['a', 'pn', 'b']);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['a', 'pn', 'b']);
});
it('returns early on an empty input list', () => {
const queue = makeTracks(2);
usePlayerStore.setState({ queue, queueIndex: 0, currentTrack: queue[0] });
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.getState().playNext([]);
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(queue.map(t => t.id));
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(queue.map(t => t.id));
});
});
describe('clearQueue', () => {
it('empties the queue and resets playback bookkeeping', () => {
const tracks = makeTracks(3);
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
usePlayerStore.setState({
queue: tracks,
queueIndex: 1,
currentTrack: tracks[1],
isPlaying: true,
progress: 0.5,
currentTime: 42,
@@ -167,7 +165,7 @@ describe('clearQueue', () => {
});
usePlayerStore.getState().clearQueue();
const s = usePlayerStore.getState();
expect(s.queue).toEqual([]);
expect(s.queueItems).toEqual([]);
expect(s.queueIndex).toBe(0);
expect(s.currentTrack).toBeNull();
expect(s.isPlaying).toBe(false);
@@ -179,7 +177,7 @@ describe('clearQueue', () => {
it('calls audio_stop on the engine', () => {
const stop = vi.fn(() => undefined);
onInvoke('audio_stop', stop);
usePlayerStore.setState({ queue: makeTracks(2), queueIndex: 0 });
seedQueue(makeTracks(2), { index: 0 });
usePlayerStore.getState().clearQueue();
expect(stop).toHaveBeenCalled();
});
@@ -188,24 +186,24 @@ describe('clearQueue', () => {
describe('reorderQueue', () => {
it('moves a track from startIndex to endIndex', () => {
const [a, b, c, d] = makeTracks(4);
usePlayerStore.setState({ queue: [a, b, c, d], queueIndex: 0 });
seedQueue([a, b, c, d], { index: 0 });
usePlayerStore.getState().reorderQueue(1, 3); // b → after d
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([a.id, c.id, d.id, b.id]);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([a.id, c.id, d.id, b.id]);
});
it('preserves queueIndex by following the current track id, not the slot', () => {
const [a, b, c] = makeTracks(3);
usePlayerStore.setState({ queue: [a, b, c], queueIndex: 1, currentTrack: b });
seedQueue([a, b, c], { index: 1, currentTrack: b });
usePlayerStore.getState().reorderQueue(1, 2); // b moves to the end
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([a.id, c.id, b.id]);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([a.id, c.id, b.id]);
expect(usePlayerStore.getState().queueIndex).toBe(2); // followed `b`
});
it('keeps queueIndex when the current track is unaffected by the move', () => {
const [a, b, c] = makeTracks(3);
usePlayerStore.setState({ queue: [a, b, c], queueIndex: 1, currentTrack: b });
seedQueue([a, b, c], { index: 1, currentTrack: b });
usePlayerStore.getState().reorderQueue(0, 2); // a moves to the end
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([b.id, c.id, a.id]);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([b.id, c.id, a.id]);
expect(usePlayerStore.getState().queueIndex).toBe(0); // followed `b`
});
});
@@ -213,22 +211,22 @@ describe('reorderQueue', () => {
describe('removeTrack', () => {
it('removes the track at the given index', () => {
const tracks = makeTracks(3);
usePlayerStore.setState({ queue: tracks, queueIndex: 0 });
seedQueue(tracks, { index: 0 });
usePlayerStore.getState().removeTrack(1);
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([tracks[0].id, tracks[2].id]);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([tracks[0].id, tracks[2].id]);
});
it('clamps queueIndex when the removal makes the queue shorter than the cursor', () => {
const tracks = makeTracks(3);
usePlayerStore.setState({ queue: tracks, queueIndex: 2 });
seedQueue(tracks, { index: 2 });
usePlayerStore.getState().removeTrack(2);
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([tracks[0].id, tracks[1].id]);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([tracks[0].id, tracks[1].id]);
expect(usePlayerStore.getState().queueIndex).toBe(1);
});
it('keeps queueIndex when removing a track after the cursor', () => {
const tracks = makeTracks(4);
usePlayerStore.setState({ queue: tracks, queueIndex: 1 });
seedQueue(tracks, { index: 1 });
usePlayerStore.getState().removeTrack(3);
expect(usePlayerStore.getState().queueIndex).toBe(1);
});
@@ -245,35 +243,35 @@ describe('undo / redo', () => {
it('rolls back the most recent destructive edit', () => {
const seed = makeTracks(2);
usePlayerStore.setState({ queue: seed, queueIndex: 0, currentTrack: seed[0] });
seedQueue(seed, { index: 0, currentTrack: seed[0] });
usePlayerStore.getState().enqueue([makeTrack({ id: 'add' })], true);
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id, seed[1].id, 'add']);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id, seed[1].id, 'add']);
const undone = usePlayerStore.getState().undoLastQueueEdit();
expect(undone).toBe(true);
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id, seed[1].id]);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id, seed[1].id]);
});
it('replays the undone edit via redo', () => {
const seed = makeTracks(2);
usePlayerStore.setState({ queue: seed, queueIndex: 0, currentTrack: seed[0] });
seedQueue(seed, { index: 0, currentTrack: seed[0] });
usePlayerStore.getState().removeTrack(1);
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id]);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id]);
usePlayerStore.getState().undoLastQueueEdit();
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id, seed[1].id]);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id, seed[1].id]);
usePlayerStore.getState().redoLastQueueEdit();
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id]);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id]);
});
it('a new edit drops any pending redo (Word-style history)', () => {
const seed = makeTracks(3);
usePlayerStore.setState({ queue: seed, queueIndex: 0, currentTrack: seed[0] });
seedQueue(seed, { index: 0, currentTrack: seed[0] });
usePlayerStore.getState().removeTrack(2); // edit A: [s0, s1]
usePlayerStore.getState().undoLastQueueEdit(); // [s0, s1, s2]
expect(usePlayerStore.getState().queue).toHaveLength(3);
expect(usePlayerStore.getState().queueItems).toHaveLength(3);
usePlayerStore.getState().removeTrack(1); // edit B drops the pending redo