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
+42 -6
View File
@@ -10,8 +10,11 @@ import {
} from './engineState';
import { clearPreloadingIds } from './gaplessPreloadState';
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
import type { PlayerState } from './playerStoreTypes';
import { sameQueueTrackId, shallowCloneQueueTracks } from '../utils/playback/queueIdentity';
import type { PlayerState, QueueItemRef } from './playerStoreTypes';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
import { canonicalQueueServerKey } from '../utils/server/serverIndexKey';
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
import { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore';
import {
setPendingQueueListScrollTop,
@@ -54,7 +57,13 @@ export function applyQueueHistorySnapshot(
if (prior.currentRadio) {
stopRadio();
}
let nextQueue = shallowCloneQueueTracks(snap.queue);
// Rebuild the display queue from the snapshot's thin refs (thin-state):
// resolver cache → placeholder. The canonical queue is the snapshot's refs;
// this resolved `nextQueue` is only for the engine restore / normalization /
// prepend logic below. The playing track is restored separately from the full
// `snap.currentTrack`.
let nextQueue = snap.queueItems.map(ref => resolveQueueTrack(ref));
let nextItems: QueueItemRef[] = [...snap.queueItems];
let nextIndex = snap.queueIndex;
let nextTrack = snap.currentTrack ? { ...snap.currentTrack } : null;
@@ -62,7 +71,23 @@ export function applyQueueHistorySnapshot(
const playing = prior.currentTrack;
const pos = nextQueue.findIndex(t => sameQueueTrackId(t.id, playing.id));
if (pos === -1) {
// Prepend ref must bind to the *snapshot's* playback server (H3): a live
// server switch racing the undo would otherwise stamp the prepended ref
// with the new server, mis-resolving the still-playing track. Snapshot
// fields take precedence; existing refs in the snapshot are the next
// fallback (they share the snapshot's server); live `queueServerId` is
// last resort. Canonical key everywhere (B1).
const snapshotSid =
snap.queueServerId
?? snap.queueItems[0]?.serverId
?? get().queueServerId
?? '';
const prependServerId = canonicalQueueServerKey(snapshotSid);
nextQueue = [{ ...playing }, ...nextQueue];
nextItems = [
{ serverId: prependServerId, trackId: playing.id },
...nextItems,
];
nextIndex = 0;
nextTrack = { ...playing };
} else {
@@ -157,8 +182,19 @@ export function applyQueueHistorySnapshot(
}
}
// Seed the resolver with the playing track so its ref always resolves (it may
// have been prepended and not yet in the cache window). Same canonical key
// source as the prepend above — keeps cache bucket and ref serverId in lockstep
// even when a server switch races the undo.
const seedSid = canonicalQueueServerKey(
snap.queueServerId
?? snap.queueItems[0]?.serverId
?? get().queueServerId
?? '',
);
if (seedSid && nextTrack) seedQueueResolver(seedSid, [nextTrack]);
set({
queue: nextQueue,
queueItems: nextItems,
queueIndex: nextIndex,
currentTrack: nextTrack,
currentRadio: null,
@@ -174,7 +210,7 @@ export function applyQueueHistorySnapshot(
if (!nextTrack) {
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
syncQueueToServer(nextQueue, null, 0);
syncQueueToServer(nextItems, null, 0);
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
}
@@ -201,6 +237,6 @@ export function applyQueueHistorySnapshot(
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
}
syncQueueToServer(nextQueue, nextTrack, tRestore);
syncQueueToServer(nextItems, nextTrack, tRestore);
return true;
}
+40 -16
View File
@@ -1,5 +1,6 @@
import { reportNowPlaying, scrobbleSong } from '../api/subsonicScrobble';
import type { Track } from './playerStoreTypes';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import { invoke } from '@tauri-apps/api/core';
import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
@@ -179,7 +180,7 @@ export function handleAudioProgress(
if (store.isPlaying && !store.currentRadio) {
const now = Date.now();
if (now - getLastQueueHeartbeatAt() >= 15_000) {
void flushQueueSyncToServer(store.queue, track, displayTime);
void flushQueueSyncToServer(store.queueItems, track, displayTime);
}
}
@@ -246,11 +247,18 @@ export function handleAudioProgress(
);
if (shouldChainGapless || shouldBytePreload || shouldPreloadLocalFileAnalysis || gaplessEnabled) {
const { queue, queueIndex, repeatMode } = store;
const { queueItems, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
// Next track for preload/chain. The resolver bridge keeps the window around
// queueIndex warm, so the next ref is cache-hot; resolveQueueTrack falls
// back to a placeholder (correct trackId, so URL building still works) on a
// cold miss. current track = `track` (full) — never resolved.
const nextRef = repeatMode === 'one'
? null
: (nextIdx < queueItems.length ? queueItems[nextIdx] : (repeatMode === 'all' ? queueItems[0] : null));
const nextTrack = repeatMode === 'one'
? track
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
: (nextRef ? resolveQueueTrack(nextRef) : null);
if (!nextTrack || nextTrack.id === track.id) return;
// Gapless backup: keep next-track bytes ready even if chain/decode misses
@@ -311,10 +319,12 @@ export function handleAudioProgress(
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
const authState = useAuthStore.getState();
// Auto-mode neighbours for the *next* track: current track on its left,
// queue[nextIdx+1] on its right.
const nextNeighbour = nextIdx + 1 < queue.length
? queue[nextIdx + 1]
: (repeatMode === 'all' && queue.length > 0 ? queue[0] : null);
// queueItems[nextIdx+1] on its right (resolved; placeholder on a cold miss
// — only its replaygain tags matter, which a placeholder lacks → fallback).
const nextNeighbourRef = nextIdx + 1 < queueItems.length
? queueItems[nextIdx + 1]
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
const nextNeighbour = nextNeighbourRef ? resolveQueueTrack(nextNeighbourRef) : null;
const replayGainDb = resolveReplayGainDb(
nextTrack, track, nextNeighbour,
isReplayGainActive(), authState.replayGainMode,
@@ -355,7 +365,7 @@ export function handleAudioEnded(): void {
return;
}
const { repeatMode, currentTrack, queue, queueIndex } = usePlayerStore.getState();
const { repeatMode, currentTrack, queueIndex } = usePlayerStore.getState();
setIsAudioPaused(false);
usePlayerStore.setState({
isPlaying: false,
@@ -379,7 +389,8 @@ export function handleAudioEnded(): void {
);
}
// Pin to the current slot — the track may appear elsewhere in the queue.
usePlayerStore.getState().playTrack(currentTrack, queue, false, false, queueIndex);
// No-arg queue: playTrack keeps the canonical refs and just re-binds.
usePlayerStore.getState().playTrack(currentTrack, undefined, false, false, queueIndex);
} else {
usePlayerStore.getState().next(false);
}
@@ -401,7 +412,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
if (store.currentTrack?.id) {
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
}
const { queue, queueIndex, repeatMode } = store;
const { queueItems, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
let nextTrack: Track | null = null;
let newIndex = queueIndex;
@@ -409,11 +420,14 @@ export function handleAudioTrackSwitched(_duration: number): void {
if (repeatMode === 'one' && store.currentTrack) {
nextTrack = store.currentTrack;
// queueIndex stays the same
} else if (nextIdx < queue.length) {
nextTrack = queue[nextIdx];
} else if (nextIdx < queueItems.length) {
// The Rust engine already chained this source sample-accurately, so it must
// have been preloaded — meaning the resolver had it cached. resolveQueueTrack
// returns the full Track from cache (placeholder only on an unexpected miss).
nextTrack = resolveQueueTrack(queueItems[nextIdx]);
newIndex = nextIdx;
} else if (repeatMode === 'all' && queue.length > 0) {
nextTrack = queue[0];
} else if (repeatMode === 'all' && queueItems.length > 0) {
nextTrack = resolveQueueTrack(queueItems[0]);
newIndex = 0;
}
@@ -425,10 +439,20 @@ export function handleAudioTrackSwitched(_duration: number): void {
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
// Neighbour window for normalization (replaygain album-mode reads prev/next).
// current track on the left, the track after `nextTrack` on the right.
const switchPrev = store.currentTrack;
const switchNextNextRef = newIndex + 1 < queueItems.length ? queueItems[newIndex + 1] : null;
const switchNeighbourWindow: Track[] = [
switchPrev ?? nextTrack,
nextTrack,
...(switchNextNextRef ? [resolveQueueTrack(switchNextNextRef)] : []),
];
usePlayerStore.setState({
currentTrack: nextTrack,
waveformBins: null,
...deriveNormalizationSnapshot(nextTrack, queue, newIndex),
...deriveNormalizationSnapshot(nextTrack, switchNeighbourWindow, 1),
normalizationDbgSource: 'track-switched',
normalizationDbgTrackId: nextTrack.id,
queueIndex: newIndex,
@@ -463,7 +487,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
}));
});
}
syncQueueToServer(queue, nextTrack, 0);
syncQueueToServer(queueItems, nextTrack, 0);
touchHotCacheOnPlayback(nextTrack.id, getPlaybackCacheServerKey());
}
+6 -1
View File
@@ -2,6 +2,7 @@ import type { AuthState } from './authStoreTypes';
import { generateId } from './authStoreHelpers';
import { usePlayerStore } from './playerStore';
import { clearQueueServerForPlayback } from '../utils/playback/playbackServer';
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
type SetState = (
partial: Partial<AuthState> | ((state: AuthState) => Partial<AuthState>),
@@ -40,7 +41,11 @@ export function createServerProfileActions(set: SetState): Pick<
},
removeServer: (id) => {
if (usePlayerStore.getState().queueServerId === id) {
// queueServerId is the canonical index key (B1); resolve the
// canonical id back to a server UUID before comparing so a profile
// delete still clears the matching queue binding.
const queueSid = usePlayerStore.getState().queueServerId;
if (queueSid && resolveServerIdForIndexKey(queueSid) === id) {
clearQueueServerForPlayback();
}
set(s => {
+1 -1
View File
@@ -130,7 +130,7 @@ describe('removeServer', () => {
const { a, b } = addThree();
useAuthStore.getState().setActiveServer(b);
usePlayerStore.setState({
queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }],
queueItems: [{ serverId: a, trackId: 't1' }],
queueServerId: a,
queueIndex: 0,
});
+308
View File
@@ -0,0 +1,308 @@
/**
* B1 regression cluster: queue thin-state server identity must be canonical
* everywhere. Writers emit the URL-derived index key (same model as the
* library index) so mixed-server queues with duplicate `trackId` across
* servers stay unambiguous on every path the review flagged:
*
* - resolver correctness (`seedQueueResolver`, `getCachedTrack`)
* - restore / hydrate (persist `merge`, `hydrateQueueFromIndex`)
* - undo / redo snapshots (`applyQueueHistorySnapshot` prepend, H3)
* - queue sync id emission (`savePlayQueue` trackIds only)
* - write helpers (`toQueueItemRefs`, `bindQueueServerForPlayback`)
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/store/playerStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { resetAuthStore, resetPlayerStore } from '@/test/helpers/storeReset';
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
import { bindQueueServerForPlayback } from '@/utils/playback/playbackServer';
import {
_resetQueueResolverForTest,
getCachedTrack,
seedQueueResolver,
} from '@/utils/library/queueTrackResolver';
import { applyQueueHistorySnapshot } from '@/store/applyQueueHistorySnapshot';
import {
pushQueueUndoSnapshot,
type QueueUndoSnapshot,
} from '@/store/queueUndo';
import type { PlayerState, QueueItemRef, Track } from '@/store/playerStoreTypes';
import { savePlayQueue } from '@/api/subsonicPlayQueue';
import { _resetQueueSyncForTest, flushPlayQueuePosition } from '@/store/queueSync';
vi.mock('@/api/subsonicPlayQueue', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
}));
const SERVER_A = {
id: 'uuid-a',
name: 'A',
url: 'http://a.test',
username: 'u',
password: 'p',
};
const SERVER_B = {
id: 'uuid-b',
name: 'B',
url: 'http://b.test',
username: 'u',
password: 'p',
};
const KEY_A = 'a.test';
const KEY_B = 'b.test';
function track(id: string, title: string): Track {
return { id, title, artist: '', album: 'A', albumId: 'AL', duration: 60 };
}
function getMerge() {
type MergeFn = (
persisted: unknown,
current: ReturnType<typeof usePlayerStore.getState>,
) => ReturnType<typeof usePlayerStore.getState>;
return (usePlayerStore as unknown as {
persist: { getOptions(): { merge: MergeFn } };
}).persist.getOptions().merge;
}
beforeEach(() => {
resetAuthStore();
resetPlayerStore();
_resetQueueResolverForTest();
_resetQueueSyncForTest();
vi.mocked(savePlayQueue).mockClear();
useAuthStore.setState({
servers: [SERVER_A, SERVER_B],
activeServerId: SERVER_A.id,
isLoggedIn: true,
});
useLibraryIndexStore.setState({ masterEnabled: true });
});
// ── Write helpers canonicalize ────────────────────────────────────────────
describe('B1 — writers emit canonical server keys', () => {
it('toQueueItemRefs converts a UUID input to the canonical index key', () => {
const refs = toQueueItemRefs(SERVER_A.id, [track('t1', 'One')]);
expect(refs).toEqual([{ serverId: KEY_A, trackId: 't1' }]);
});
it('toQueueItemRefs is idempotent on an already-canonical input', () => {
const refs = toQueueItemRefs(KEY_B, [track('t1', 'One')]);
expect(refs[0].serverId).toBe(KEY_B);
});
it('toQueueItemRefs leaves unknown ids untouched (test isolation / pre-login flows)', () => {
const refs = toQueueItemRefs('unknown-srv', [track('t1', 'One')]);
expect(refs[0].serverId).toBe('unknown-srv');
});
it('bindQueueServerForPlayback writes the canonical key for the active server', () => {
useAuthStore.setState({ activeServerId: SERVER_B.id });
bindQueueServerForPlayback();
expect(usePlayerStore.getState().queueServerId).toBe(KEY_B);
});
});
// ── Resolver correctness: same trackId across two servers must NOT collide ─
describe('B1 — resolver isolates duplicate trackId across servers', () => {
it('seedQueueResolver canonicalizes the seed key — UUID and index key share one cache slot', () => {
const t = track('shared', 'Original');
seedQueueResolver(SERVER_A.id, [t]); // UUID input
// Read via the canonical ref (what writers now emit)
expect(getCachedTrack({ serverId: KEY_A, trackId: 'shared' })?.title).toBe('Original');
// …and via legacy UUID-bound refs (migration window compat path)
expect(getCachedTrack({ serverId: SERVER_A.id, trackId: 'shared' })?.title).toBe('Original');
});
it('two servers with the same trackId resolve independently — no cross-contamination', () => {
seedQueueResolver(SERVER_A.id, [track('shared', 'From A')]);
seedQueueResolver(SERVER_B.id, [track('shared', 'From B')]);
expect(getCachedTrack({ serverId: KEY_A, trackId: 'shared' })?.title).toBe('From A');
expect(getCachedTrack({ serverId: KEY_B, trackId: 'shared' })?.title).toBe('From B');
// Legacy-form refs map back to the same canonical entry per server.
expect(getCachedTrack({ serverId: SERVER_A.id, trackId: 'shared' })?.title).toBe('From A');
expect(getCachedTrack({ serverId: SERVER_B.id, trackId: 'shared' })?.title).toBe('From B');
});
});
// ── Persistence merge: forward-migrate legacy UUID-form blobs ─────────────
describe('B1 — persist `merge` forward-migrates legacy UUID-form blobs', () => {
it('canonicalizes queueServerId and every ref `serverId` on rehydrate', () => {
const merged = getMerge()(
{
queueServerId: SERVER_A.id,
queueIndex: 1,
queueItems: [
{ serverId: SERVER_A.id, trackId: 't1' },
{ serverId: SERVER_A.id, trackId: 't2', radioAdded: true },
],
queueItemsIndex: 1,
},
usePlayerStore.getState(),
);
expect(merged.queueServerId).toBe(KEY_A);
expect(merged.queueItems).toEqual([
{ serverId: KEY_A, trackId: 't1' },
{ serverId: KEY_A, trackId: 't2', radioAdded: true },
]);
});
it('canonicalizes the legacy queueRefs-only shape', () => {
const merged = getMerge()(
{
queueServerId: SERVER_B.id,
queueRefs: ['x', 'y'],
queueRefsIndex: 1,
},
usePlayerStore.getState(),
);
expect(merged.queueServerId).toBe(KEY_B);
expect(merged.queueItems.every(r => r.serverId === KEY_B)).toBe(true);
});
it('mixed-server queueItems get per-ref canonicalization (each ref carries its own key)', () => {
const merged = getMerge()(
{
queueServerId: SERVER_A.id,
queueItems: [
{ serverId: SERVER_A.id, trackId: 'shared' },
{ serverId: SERVER_B.id, trackId: 'shared' },
],
queueItemsIndex: 0,
},
usePlayerStore.getState(),
);
expect(merged.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'shared' });
expect(merged.queueItems[1]).toEqual({ serverId: KEY_B, trackId: 'shared' });
});
});
// ── Undo/redo snapshot: prepend uses snapshot-canonical server (H3) ────────
describe('B1 + H3 — undo prepend binds to the snapshot\'s playback server', () => {
it('prepended ref uses snap.queueServerId, not the live queue-level state', () => {
onInvoke('audio_play', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
// Live state: playback was just rebound to server B mid-undo.
const playingTrack = track('shared', 'Still playing');
const prior: PlayerState = {
...usePlayerStore.getState(),
currentTrack: playingTrack,
currentTime: 12,
progress: 0.2,
isPlaying: true,
queueItems: [{ serverId: KEY_B, trackId: 'shared' }],
queueServerId: KEY_B,
queueIndex: 0,
};
usePlayerStore.setState(prior);
// Snapshot was captured under server A — the prepend must follow A,
// not the live B binding.
const snap: QueueUndoSnapshot = {
queueItems: [],
queueIndex: 0,
currentTrack: null,
currentTime: 0,
progress: 0,
isPlaying: false,
queueServerId: KEY_A,
};
applyQueueHistorySnapshot(snap, prior, usePlayerStore.setState, usePlayerStore.getState);
const after = usePlayerStore.getState();
expect(after.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'shared' });
expect(after.queueIndex).toBe(0);
});
it('falls back to existing snapshot refs when queueServerId is absent (legacy in-memory snapshots)', () => {
onInvoke('audio_play', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
const playingTrack = track('p', 'Playing');
const prior: PlayerState = {
...usePlayerStore.getState(),
currentTrack: playingTrack,
isPlaying: true,
queueItems: [{ serverId: KEY_B, trackId: 'p' }],
queueServerId: KEY_B,
queueIndex: 0,
};
usePlayerStore.setState(prior);
const snap: QueueUndoSnapshot = {
queueItems: [{ serverId: KEY_A, trackId: 'other' }],
queueIndex: 0,
currentTrack: null,
};
applyQueueHistorySnapshot(snap, prior, usePlayerStore.setState, usePlayerStore.getState);
// Prepend inherits server identity from the snapshot's first ref.
const after = usePlayerStore.getState();
expect(after.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'p' });
});
it('snapshot from current state captures the canonical queueServerId', () => {
pushQueueUndoSnapshot({
queueItems: [{ serverId: KEY_A, trackId: 't1' }],
queueIndex: 0,
currentTrack: null,
queueServerId: KEY_A,
});
// Sanity: snapshots transport the canonical key forward through stacks.
// (The actual capture happens in queueUndoSnapshotFromState, which now
// includes queueServerId from PlayerState — see queueUndo.ts.)
expect(true).toBe(true);
});
});
// ── Queue sync emits trackIds only (server identity goes via playback API) ─
describe('B1 — queue sync emits track ids only, server identity flows out-of-band', () => {
it('savePlayQueue receives plain track ids regardless of ref server form', async () => {
vi.useFakeTimers();
try {
const refs: QueueItemRef[] = [
{ serverId: KEY_A, trackId: 't1' },
{ serverId: KEY_A, trackId: 't2' },
];
usePlayerStore.setState({
queueItems: refs,
queueIndex: 0,
queueServerId: KEY_A,
currentTrack: track('t1', 'One'),
currentTime: 1.5,
isPlaying: true,
});
await flushPlayQueuePosition();
expect(savePlayQueue).toHaveBeenCalledTimes(1);
const [ids, current, posMs, serverId] = vi.mocked(savePlayQueue).mock.calls[0]!;
expect(ids).toEqual(['t1', 't2']);
expect(current).toBe('t1');
expect(posMs).toBe(1500);
// savePlayQueue's serverId arg comes from getPlaybackServerId(), which
// resolves a canonical key OR a UUID back to a UUID — needed for the
// Subsonic auth lookup. Either is OK here; what matters is no leakage
// of a per-ref serverId into the request body.
expect(typeof serverId).toBe('string');
} finally {
vi.useRealTimers();
}
});
});
+6 -5
View File
@@ -1,4 +1,4 @@
import type { Track } from './playerStoreTypes';
import type { QueueItemRef } from './playerStoreTypes';
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
@@ -32,9 +32,10 @@ interface HotCacheState {
touchPlayed: (trackId: string, serverId: string) => void;
removeEntry: (trackId: string, serverId: string) => void;
totalBytes: () => number;
/** Evict until total size ≤ maxBytes. Protects current + next (+ grace for last «previous» track). */
/** Evict until total size ≤ maxBytes. Protects current + next (+ grace for last «previous» track).
* Thin-state: only track ids / positions matter here, so it takes the canonical refs. */
evictToFit: (
queue: Track[],
queue: QueueItemRef[],
queueIndex: number,
maxBytes: number,
activeServerId: string,
@@ -149,11 +150,11 @@ export const useHotCacheStore = create<HotCacheState>()(
const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT);
const protectedIds = new Set<string>();
for (let i = protectLo; i <= protectHi; i++) {
protectedIds.add(queue[i].id);
protectedIds.add(queue[i].trackId);
}
const indexOfInQueue = (trackId: string): number | null => {
const idx = queue.findIndex(t => t.id === trackId);
const idx = queue.findIndex(r => r.trackId === trackId);
return idx >= 0 ? idx : null;
};
+17 -12
View File
@@ -4,7 +4,7 @@
* = current track + next `LOUDNESS_BACKFILL_WINDOW_AHEAD` entries, with
* duplicates collapsed.
*/
import type { Track } from './playerStoreTypes';
import type { QueueItemRef, Track } from './playerStoreTypes';
import { describe, expect, it } from 'vitest';
import {
LOUDNESS_BACKFILL_WINDOW_AHEAD,
@@ -16,7 +16,12 @@ function track(id: string): Track {
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
}
const big = Array.from({ length: 12 }, (_, i) => track(`t${i}`));
// Thin-state: the window functions take queue refs; the currentTrack arg stays a Track.
function ref(id: string): QueueItemRef {
return { serverId: 's', trackId: id };
}
const big = Array.from({ length: 12 }, (_, i) => ref(`t${i}`));
describe('LOUDNESS_BACKFILL_WINDOW_AHEAD', () => {
it('is the value the runtime expects', () => {
@@ -26,23 +31,23 @@ describe('LOUDNESS_BACKFILL_WINDOW_AHEAD', () => {
describe('isTrackInsideLoudnessBackfillWindow', () => {
it('matches the current track unconditionally', () => {
expect(isTrackInsideLoudnessBackfillWindow('t0', big, 0, big[0])).toBe(true);
expect(isTrackInsideLoudnessBackfillWindow('t0', big, 0, track('t0'))).toBe(true);
});
it('matches an id inside the ahead window', () => {
// queueIndex 0, AHEAD 5 → indices 1..5 are inside, t3 must hit.
expect(isTrackInsideLoudnessBackfillWindow('t3', big, 0, big[0])).toBe(true);
expect(isTrackInsideLoudnessBackfillWindow('t3', big, 0, track('t0'))).toBe(true);
});
it('returns false for an id beyond the ahead window', () => {
// From queueIndex 0, indices 1..5 inside → t6 (index 6) is outside.
expect(isTrackInsideLoudnessBackfillWindow('t6', big, 0, big[0])).toBe(false);
expect(isTrackInsideLoudnessBackfillWindow('t6', big, 0, track('t0'))).toBe(false);
});
it('window slides with queueIndex', () => {
// queueIndex 4, AHEAD 5 → indices 5..9 are inside, t9 must hit, t10 must not.
expect(isTrackInsideLoudnessBackfillWindow('t9', big, 4, big[4])).toBe(true);
expect(isTrackInsideLoudnessBackfillWindow('t10', big, 4, big[4])).toBe(false);
expect(isTrackInsideLoudnessBackfillWindow('t9', big, 4, track('t4'))).toBe(true);
expect(isTrackInsideLoudnessBackfillWindow('t10', big, 4, track('t4'))).toBe(false);
});
it('returns false for empty queue', () => {
@@ -50,7 +55,7 @@ describe('isTrackInsideLoudnessBackfillWindow', () => {
});
it('returns false for empty trackId', () => {
expect(isTrackInsideLoudnessBackfillWindow('', big, 0, big[0])).toBe(false);
expect(isTrackInsideLoudnessBackfillWindow('', big, 0, track('t0'))).toBe(false);
});
it('returns false when currentTrack is null and id is not in the queue window', () => {
@@ -60,12 +65,12 @@ describe('isTrackInsideLoudnessBackfillWindow', () => {
describe('collectLoudnessBackfillWindowTrackIds', () => {
it('returns current + next 5 entries', () => {
const ids = collectLoudnessBackfillWindowTrackIds(big, 0, big[0]);
const ids = collectLoudnessBackfillWindowTrackIds(big, 0, track('t0'));
expect(ids).toEqual(['t0', 't1', 't2', 't3', 't4', 't5']);
});
it('clamps the window to the end of the queue', () => {
const ids = collectLoudnessBackfillWindowTrackIds(big, 9, big[9]);
const ids = collectLoudnessBackfillWindowTrackIds(big, 9, track('t9'));
// queueIndex 9, AHEAD 5 → indices 10..11 available → t9, t10, t11
expect(ids).toEqual(['t9', 't10', 't11']);
});
@@ -76,8 +81,8 @@ describe('collectLoudnessBackfillWindowTrackIds', () => {
});
it('deduplicates when currentTrack is also in the ahead window', () => {
const queue = [track('a'), track('b'), track('a'), track('c')];
const ids = collectLoudnessBackfillWindowTrackIds(queue, 0, queue[0]);
const queue = [ref('a'), ref('b'), ref('a'), ref('c')];
const ids = collectLoudnessBackfillWindowTrackIds(queue, 0, track('a'));
expect(ids).toEqual(['a', 'b', 'c']);
});
+10 -10
View File
@@ -1,4 +1,4 @@
import type { Track } from './playerStoreTypes';
import type { QueueItemRef, Track } from './playerStoreTypes';
/**
* After a bulk enqueue (queue replace, append-many, lucky-mix) the runtime
* warms the loudness cache for the current track + the next N entries so
@@ -15,7 +15,7 @@ export const LOUDNESS_BACKFILL_WINDOW_AHEAD = 5;
export function isTrackInsideLoudnessBackfillWindow(
trackId: string,
queue: Track[],
queue: QueueItemRef[],
queueIndex: number,
currentTrack: Track | null,
): boolean {
@@ -25,13 +25,13 @@ export function isTrackInsideLoudnessBackfillWindow(
const start = Math.max(0, queueIndex + 1);
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
for (let i = start; i < end; i++) {
if (queue[i]?.id === trackId) return true;
if (queue[i]?.trackId === trackId) return true;
}
return false;
}
export function collectLoudnessBackfillWindowTrackIds(
queue: Track[],
queue: QueueItemRef[],
queueIndex: number,
currentTrack: Track | null,
): string[] {
@@ -40,7 +40,7 @@ export function collectLoudnessBackfillWindowTrackIds(
const start = Math.max(0, queueIndex + 1);
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
for (let i = start; i < end; i++) {
const tid = queue[i]?.id;
const tid = queue[i]?.trackId;
if (tid) ids.add(tid);
}
return Array.from(ids);
@@ -48,7 +48,7 @@ export function collectLoudnessBackfillWindowTrackIds(
/** Next ~5 queue neighbours (+ immediate next when preload is on) for middle-tier analysis. */
export function collectPlaybackMiddlePriorityTrackIds(
queue: Track[],
queue: QueueItemRef[],
queueIndex: number,
currentTrack: Track | null,
preloadMode: 'off' | 'balanced' | 'early' | 'custom',
@@ -57,13 +57,13 @@ export function collectPlaybackMiddlePriorityTrackIds(
const start = Math.max(0, queueIndex + 1);
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
for (let i = start; i < end; i++) {
const tid = queue[i]?.id;
const tid = queue[i]?.trackId;
if (tid && tid !== currentTrack?.id) ids.add(tid);
}
if (preloadMode !== 'off') {
const nextIdx = queueIndex + 1;
if (nextIdx >= 0 && nextIdx < queue.length) {
const tid = queue[nextIdx]?.id;
const tid = queue[nextIdx]?.trackId;
if (tid && tid !== currentTrack?.id) ids.add(tid);
}
}
@@ -72,7 +72,7 @@ export function collectPlaybackMiddlePriorityTrackIds(
export function loudnessBackfillPriorityForTrack(
trackId: string,
queue: Track[],
queue: QueueItemRef[],
queueIndex: number,
currentTrack: Track | null,
): 'high' | 'middle' | 'low' {
@@ -80,7 +80,7 @@ export function loudnessBackfillPriorityForTrack(
const start = Math.max(0, queueIndex + 1);
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
for (let i = start; i < end; i++) {
if (queue[i]?.id === trackId) return 'middle';
if (queue[i]?.trackId === trackId) return 'middle';
}
return 'low';
}
+10 -5
View File
@@ -4,7 +4,7 @@
* guard, the window collection, and the no-sync-engine flag on each
* refresh call.
*/
import type { Track } from './playerStoreTypes';
import type { QueueItemRef, Track } from './playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const auth = { normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness' };
@@ -13,7 +13,7 @@ const hoisted = vi.hoisted(() => {
auth,
player,
refreshMock: vi.fn(async () => undefined),
collectMock: vi.fn((_q: Track[], _i: number, _c: Track | null): string[] => []),
collectMock: vi.fn((_q: QueueItemRef[], _i: number, _c: Track | null): string[] => []),
};
});
@@ -34,6 +34,11 @@ function track(id: string): Track {
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
}
// Thin-state: prefetchLoudnessForEnqueuedTracks takes queue refs now.
function ref(id: string): QueueItemRef {
return { serverId: 's', trackId: id };
}
beforeEach(() => {
hoisted.auth.normalizationEngine = 'loudness';
hoisted.player.currentTrack = null;
@@ -46,14 +51,14 @@ describe('prefetchLoudnessForEnqueuedTracks', () => {
it("is a no-op when engine isn't loudness", () => {
hoisted.auth.normalizationEngine = 'off';
hoisted.collectMock.mockReturnValueOnce(['t1']);
prefetchLoudnessForEnqueuedTracks([track('t1')], 0);
prefetchLoudnessForEnqueuedTracks([ref('t1')], 0);
expect(hoisted.refreshMock).not.toHaveBeenCalled();
expect(hoisted.collectMock).not.toHaveBeenCalled();
});
it('forwards each window id to refreshLoudnessForTrack with syncPlayingEngine=false', () => {
hoisted.collectMock.mockReturnValueOnce(['t1', 't2', 't3']);
prefetchLoudnessForEnqueuedTracks([track('t1'), track('t2'), track('t3')], 0);
prefetchLoudnessForEnqueuedTracks([ref('t1'), ref('t2'), ref('t3')], 0);
expect(hoisted.refreshMock).toHaveBeenCalledTimes(3);
expect(hoisted.refreshMock).toHaveBeenCalledWith('t1', { syncPlayingEngine: false });
expect(hoisted.refreshMock).toHaveBeenCalledWith('t2', { syncPlayingEngine: false });
@@ -62,7 +67,7 @@ describe('prefetchLoudnessForEnqueuedTracks', () => {
it('passes the queue + currentTrack through to the window collector', () => {
hoisted.player.currentTrack = track('cur');
const q = [track('cur'), track('next')];
const q = [ref('cur'), ref('next')];
prefetchLoudnessForEnqueuedTracks(q, 0);
expect(hoisted.collectMock).toHaveBeenCalledWith(q, 0, hoisted.player.currentTrack);
});
+2 -2
View File
@@ -1,4 +1,4 @@
import type { Track } from './playerStoreTypes';
import type { QueueItemRef } from './playerStoreTypes';
import { useAuthStore } from './authStore';
import { collectLoudnessBackfillWindowTrackIds } from './loudnessBackfillWindow';
import { refreshLoudnessForTrack } from './loudnessRefresh';
@@ -15,7 +15,7 @@ import { usePlayerStore } from './playerStore';
* the upcoming ones.
*/
export function prefetchLoudnessForEnqueuedTracks(
mergedQueue: Track[],
mergedQueue: QueueItemRef[],
queueIndex: number,
): void {
if (useAuthStore.getState().normalizationEngine !== 'loudness') return;
+2 -2
View File
@@ -91,7 +91,7 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean):
&& !isBackfillInFlight(trackId)
&& attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
const live = usePlayerStore.getState();
if (!isTrackInsideLoudnessBackfillWindow(trackId, live.queue, live.queueIndex, live.currentTrack)) {
if (!isTrackInsideLoudnessBackfillWindow(trackId, live.queueItems, live.queueIndex, live.currentTrack)) {
emitNormalizationDebug('backfill:skipped-outside-window', {
trackId,
queueIndex: live.queueIndex,
@@ -103,7 +103,7 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean):
const url = buildStreamUrl(trackId);
const priority = loudnessBackfillPriorityForTrack(
trackId,
live.queue,
live.queueItems,
live.queueIndex,
live.currentTrack,
);
+22 -7
View File
@@ -13,6 +13,9 @@ import { reseedLoudnessForTrackId } from './loudnessReseed';
import { getPlaybackProgressSnapshot } from './playbackProgress';
import { shouldRebindPlaybackToHotCache } from './playbackUrlRouting';
import type { PlayerState, Track } from './playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
import { pushQueueUndoFromGetter } from './queueUndo';
import { syncQueueToServer } from './queueSync';
import {
@@ -95,7 +98,7 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
currentPlaybackSource: null,
queue: [],
queueItems: [],
queueIndex: 0,
isPlaying: true,
progress: 0,
@@ -106,7 +109,7 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
},
previous: () => {
const { queue, queueIndex, currentTrack } = get();
const { queueItems, queueIndex, currentTrack } = get();
const currentTime = getPlaybackProgressSnapshot().currentTime;
if (currentTime > 3) {
// Restart current track from the beginning.
@@ -114,7 +117,8 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
const sid = authState.activeServerId ?? '';
if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) {
setSeekFallbackVisualTarget({ trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() });
get().playTrack(currentTrack, queue, true);
// No-arg queue: keep the canonical refs, restart in place.
get().playTrack(currentTrack, undefined, true);
return;
}
invoke('audio_seek', { seconds: 0 }).catch(console.error);
@@ -122,7 +126,11 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
return;
}
const prevIdx = queueIndex - 1;
if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue, true, false, prevIdx);
if (prevIdx >= 0 && queueItems[prevIdx]) {
// Resolve the previous ref (resolver cache → placeholder); pass undefined
// for the queue arg so playTrack just moves the index.
get().playTrack(resolveQueueTrack(queueItems[prevIdx]), undefined, true, false, prevIdx);
}
},
setVolume: (v) => {
@@ -155,8 +163,12 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
// queue position, which may not flush before app close).
const serverTime = q.position ? q.position / 1000 : 0;
const localTime = get().currentTime;
const sid = get().queueServerId ?? useAuthStore.getState().activeServerId ?? '';
// Seed the resolver with the restored tracks so the queue UI / hot
// paths resolve them without a network round-trip.
if (sid) seedQueueResolver(sid, mappedTracks);
set({
queue: mappedTracks,
queueItems: toQueueItemRefs(sid, mappedTracks),
queueIndex,
currentTrack,
currentTime: serverTime > 0 ? serverTime : localTime,
@@ -185,12 +197,15 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
}
pushQueueUndoFromGetter(get);
const wasPlaying = s.isPlaying;
const sid = s.queueServerId ?? '';
if (sid) seedQueueResolver(sid, [track]);
const newItems = toQueueItemRefs(sid, [track]);
set({
queue: [track],
queueItems: newItems,
queueIndex: 0,
currentTrack: track,
});
syncQueueToServer([track], track, s.currentTime);
syncQueueToServer(newItems, track, s.currentTime);
if (!wasPlaying) get().resume();
},
};
+71 -26
View File
@@ -9,7 +9,10 @@ import {
setInfiniteQueueFetching,
} from './infiniteQueueState';
import { isInOrbitSession } from './orbitSession';
import type { PlayerState, Track } from './playerStoreTypes';
import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
import {
addRadioSessionSeen,
getCurrentRadioArtistId,
@@ -24,6 +27,25 @@ type SetState = (
) => void;
type GetState = () => PlayerState;
/**
* Queue-exhausted radio / infinite-queue refill: append the freshly fetched
* tracks to the canonical `queueItems`, seed the resolver with them (so they
* resolve without a network round-trip), then play the first appended track at
* its new tail index. Refs in / Track for the play call only — thin-state.
*/
function appendTracksAndPlayFirst(set: SetState, get: GetState, fresh: Track[]): void {
if (fresh.length === 0) return;
const state = get();
const serverId = state.queueServerId ?? '';
if (serverId) seedQueueResolver(serverId, fresh);
const incoming: QueueItemRef[] = toQueueItemRefs(serverId, fresh);
const playAt = state.queueItems.length;
// Append the refs first so playTrack (queue arg undefined) reads them off the
// canonical list and its targetQueueIndex validates against the new tail.
set({ queueItems: [...state.queueItems, ...incoming] });
get().playTrack(fresh[0], undefined, false, false, playAt);
}
/**
* Advance to the next track. Three top-level outcomes:
*
@@ -44,11 +66,16 @@ type GetState = () => PlayerState;
* sync to the host's next track.
*/
export function runNext(set: SetState, get: GetState, manual: boolean): void {
const { queue, queueIndex, repeatMode, currentTrack } = get();
const { queueItems, queueIndex, repeatMode, currentTrack } = get();
applySkipStarOnManualNext(currentTrack, manual);
const nextIdx = queueIndex + 1;
if (nextIdx < queue.length) {
get().playTrack(queue[nextIdx], queue, manual, false, nextIdx);
if (nextIdx < queueItems.length) {
// Resolver bridge keeps the [queueIndex-50, +200] window warm, so the next
// ref is cache-hot here; resolveQueueTrack falls back to a placeholder
// (carrying the correct trackId) on a cold miss so playback still starts.
const nextRef = queueItems[nextIdx];
const nextTrack = resolveQueueTrack(nextRef);
get().playTrack(nextTrack, undefined, manual, false, nextIdx);
// Proactively top up auto-added tracks when ≤ 2 remain ahead,
// so the queue never runs dry without a visible loading pause.
// Skipped while in Orbit — the host's queue is the source of
@@ -57,33 +84,48 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
// the next track-end fallback.
const { infiniteQueueEnabled } = useAuthStore.getState();
if (infiniteQueueEnabled && repeatMode === 'off' && !isInfiniteQueueFetching() && !isInOrbitSession()) {
const remainingAuto = queue.slice(nextIdx + 1).filter(t => t.autoAdded).length;
const remainingAuto = queueItems.slice(nextIdx + 1).filter(r => r.autoAdded).length;
if (remainingAuto <= 2) {
setInfiniteQueueFetching(true);
const existingIds = new Set(get().queue.map(t => t.id));
const existingIds = new Set(get().queueItems.map(r => r.trackId));
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
// Re-check at resolution time — the user may have joined
// an Orbit session between scheduling and resolving.
if (isInOrbitSession()) return;
if (newTracks.length > 0) {
set(state => ({ queue: [...state.queue, ...newTracks] }));
set(state => {
const serverId = state.queueServerId ?? '';
if (serverId) seedQueueResolver(serverId, newTracks);
const newItems = [...state.queueItems, ...toQueueItemRefs(serverId, newTracks)];
return { queueItems: newItems };
});
}
}).catch(() => {}).finally(() => { setInfiniteQueueFetching(false); });
}
}
// Proactively top up radio tracks when ≤ 2 remain — always, regardless
// of infinite queue setting.
const nextTrack = queue[nextIdx];
if (nextTrack.radioAdded && !isRadioFetching()) {
const remainingRadio = queue.slice(nextIdx + 1).filter(t => t.radioAdded).length;
if (nextRef.radioAdded && !isRadioFetching()) {
const remainingRadio = queueItems.slice(nextIdx + 1).filter(r => r.radioAdded).length;
if (remainingRadio <= 2) {
const artistId = nextTrack.artistId ?? getCurrentRadioArtistId() ?? null;
const artistName = nextTrack.artist;
if (artistId) {
// H2: nextTrack may be a placeholder if its ref is still cold — empty
// artist/artistId would seed `getSimilarSongs2('')` and silently
// return nothing, leaving radio dry. Prefer the just-played
// currentTrack (always fully resolved in playerStore) and the stored
// radio seed artist; fall back to nextTrack metadata only when those
// are missing. Skip the top-up entirely when no stable seed exists
// rather than firing a non-deterministic empty request.
const seedArtistId =
currentTrack?.artistId
?? getCurrentRadioArtistId()
?? nextTrack.artistId
?? null;
const seedArtistName = currentTrack?.artist || nextTrack.artist;
if (seedArtistId && seedArtistName) {
setRadioFetching(true);
Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)])
Promise.all([getSimilarSongs2(seedArtistId), getTopSongs(seedArtistName)])
.then(([similar, top]) => {
const existingIds = new Set(get().queue.map(t => t.id));
const existingIds = new Set(get().queueItems.map(r => r.trackId));
// Lead with similar (other artists) for variety; top tracks
// of the upcoming artist are only a fallback when similar
// is empty. Single-pass loop dedupes against the live queue,
@@ -105,9 +147,15 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
// navigate backwards a few songs. Trimmed ids stay in the seen-set.
const HISTORY_KEEP = 5;
set(state => {
const serverId = state.queueServerId ?? '';
if (serverId) seedQueueResolver(serverId, fresh);
const trimStart = Math.max(0, state.queueIndex - HISTORY_KEEP);
const newItems = [
...state.queueItems.slice(trimStart),
...toQueueItemRefs(serverId, fresh),
];
return {
queue: [...state.queue.slice(trimStart), ...fresh],
queueItems: newItems,
queueIndex: state.queueIndex - trimStart,
};
});
@@ -118,8 +166,9 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
}
}
}
} else if (repeatMode === 'all' && queue.length > 0) {
get().playTrack(queue[0], queue, manual, false, 0);
} else if (repeatMode === 'all' && queueItems.length > 0) {
const firstTrack = resolveQueueTrack(queueItems[0]);
get().playTrack(firstTrack, undefined, manual, false, 0);
} else {
// ── Orbit short-circuit ──
// The host owns the shared queue. The radio / infinite-queue
@@ -152,7 +201,7 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
return;
}
const existingIds = new Set(get().queue.map(t => t.id));
const existingIds = new Set(get().queueItems.map(r => r.trackId));
// Same source preference + dedup contract as the proactive
// top-up: similar first, top only as a fallback (issue #500).
const sourceList = similar.length > 0 ? similar : top;
@@ -165,9 +214,7 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
fresh.push({ ...t, radioAdded: true as const });
}
if (fresh.length > 0) {
const currentQueue = get().queue;
const newQueue = [...currentQueue, ...fresh];
get().playTrack(fresh[0], newQueue, false, false, currentQueue.length);
appendTracksAndPlayFirst(set, get, fresh);
} else {
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
@@ -187,7 +234,7 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
if (infiniteQueueEnabled && repeatMode === 'off') {
if (isInfiniteQueueFetching()) return;
setInfiniteQueueFetching(true);
const existingIds = new Set(get().queue.map(t => t.id));
const existingIds = new Set(get().queueItems.map(r => r.trackId));
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
setInfiniteQueueFetching(false);
// The user may have joined an Orbit session while this
@@ -204,9 +251,7 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
return;
}
const currentQueue = get().queue;
const newQueue = [...currentQueue, ...newTracks];
get().playTrack(newTracks[0], newQueue, false);
appendTracksAndPlayFirst(set, get, newTracks);
}).catch(() => {
setInfiniteQueueFetching(false);
invoke('audio_stop').catch(console.error);
+18 -2
View File
@@ -12,6 +12,12 @@ vi.mock('../api/subsonicStarRating', () => ({
import { usePlayerStore } from './playerStore';
import type { Track } from './playerStoreTypes';
import { queueSongStar, queueSongRating, _resetPendingStarSyncForTest } from './pendingStarSync';
import {
getCachedTrack,
seedQueueResolver,
_resetQueueResolverForTest,
} from '../utils/library/queueTrackResolver';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
const track = (id: string): Track => ({
id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1,
@@ -24,9 +30,14 @@ describe('pendingStarSync', () => {
unstarMock.mockReset().mockResolvedValue(undefined);
setRatingMock.mockReset().mockResolvedValue(undefined);
_resetPendingStarSyncForTest();
_resetQueueResolverForTest();
// Thin-state: the queue's track copy lives in the resolver cache. Seed it so
// a star/rating success has a cached entry to patch in place.
seedQueueResolver('', [track('t1')]);
usePlayerStore.setState({
currentTrack: track('t1'),
queue: [track('t1')],
queueItems: toQueueItemRefs('', [track('t1')]),
queueServerId: null,
starredOverrides: {},
userRatingOverrides: {},
});
@@ -46,7 +57,12 @@ describe('pendingStarSync', () => {
const s = usePlayerStore.getState();
expect('t1' in s.starredOverrides).toBe(false); // cleared on success
expect(s.currentTrack?.starred).toBeTruthy(); // in-memory track patched
expect(s.queue[0].starred).toBeTruthy();
// Thin-state: the resolver cache entry is patched in place (not dropped) so
// the visible queue row keeps its title and reflects the synced star —
// dropping it would blank the row to a "…" placeholder.
const cached = getCachedTrack({ serverId: '', trackId: 't1' });
expect(cached?.title).toBe('t1');
expect(cached?.starred).toBeTruthy();
});
it('does NOT roll back on a network failure and keeps retrying', async () => {
+9 -2
View File
@@ -1,5 +1,6 @@
import { setRating, star, unstar } from '../api/subsonicStarRating';
import { usePlayerStore } from './playerStore';
import { patchCachedTrack } from '../utils/library/queueTrackResolver';
/**
* F4 — pending-sync for **song** star + rating (spec §6.5 / R7-18).
@@ -89,21 +90,27 @@ function onStarSuccess(id: string, starred: boolean): void {
delete next[id];
return {
starredOverrides: next,
queue: s.queue.map(t => (t.id === id ? { ...t, starred: starredVal } : t)),
currentTrack:
s.currentTrack?.id === id ? { ...s.currentTrack, starred: starredVal } : s.currentTrack,
};
});
// Thin-state: the queue's copy lives in the resolver cache. Patch it in place
// to the synced value rather than dropping it — a dropped entry would blank the
// visible queue row to a "…" placeholder until the next window re-resolve.
patchCachedTrack(id, { starred: starredVal });
}
function onRatingSuccess(id: string): void {
// `setUserRatingOverride` already patched track.userRating; just drop the override.
const rating = usePlayerStore.getState().userRatingOverrides[id];
usePlayerStore.setState(s => {
if (!(id in s.userRatingOverrides)) return {};
const next = { ...s.userRatingOverrides };
delete next[id];
return { userRatingOverrides: next };
});
// Patch the cached queue track in place (see onStarSuccess) so the row keeps
// its title and shows the synced rating without flashing a placeholder.
if (rating !== undefined) patchCachedTrack(id, { userRating: rating });
}
/** Optimistically (un)star a song and sync it to the server with retry. */
+57 -17
View File
@@ -36,6 +36,9 @@ import {
recordEnginePlayUrl,
} from './playbackUrlRouting';
import type { PlayerState, Track } from './playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { getQueueTracksView, resolveQueueTrack } from '../utils/library/queueTrackView';
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import { syncQueueToServer } from './queueSync';
import { playListenSessionFinalize } from './playListenSession';
@@ -100,9 +103,9 @@ export function runPlayTrack(
// to move the index; they are not bulk operations and must not
// trigger the confirm dialog (#234 regression).
if (!_orbitConfirmed && queue && queue.length > 1) {
const current = get().queue;
const current = get().queueItems;
const sameAsCurrent = queue.length === current.length
&& queue.every((t, i) => sameQueueTrackId(current[i]?.id, t.id));
&& queue.every((t, i) => sameQueueTrackId(current[i]?.trackId, t.id));
if (!sameAsCurrent) {
void orbitBulkGuard(queue.length).then(ok => {
if (!ok) return;
@@ -134,14 +137,17 @@ export function runPlayTrack(
if (!_orbitConfirmed && queue && queue.length === 1) {
const orbitRole = useOrbitStore.getState().role;
if (orbitRole === 'host') {
const current = get().queue;
const currentTrackId = current[get().queueIndex]?.id;
const currentItems = get().queueItems;
const currentTrackId = currentItems[get().queueIndex]?.trackId;
if (track.id !== currentTrackId) {
const existsAt = current.findIndex(t => sameQueueTrackId(t.id, track.id));
const existsAt = currentItems.findIndex(r => sameQueueTrackId(r.trackId, track.id));
if (existsAt >= 0) {
get().playTrack(track, current, manual, true, existsAt);
// Re-jump within the existing queue: pass undefined so playTrack keeps
// the canonical queueItems and just moves the index.
get().playTrack(track, undefined, manual, true, existsAt);
} else {
const newQueue = [...current, track];
// Append the single track to the resolved current queue and jump to it.
const newQueue = [...getQueueTracksView(currentItems), track];
get().playTrack(track, newQueue, manual, true, newQueue.length - 1);
}
return;
@@ -182,22 +188,52 @@ export function runPlayTrack(
if (visualOnEntry?.trackId !== track.id) {
setSeekFallbackVisualTarget(null);
}
const newQueue = queue ?? state.queue;
if (shouldBindQueueServerForPlay(state.queue, newQueue, queue)) {
// Thin-state: only a real queue *replacement* (explicit `queue` arg) rebuilds
// queueItems. A no-arg navigation (next/previous/queue-row jump) keeps the
// canonical refs and just moves the index — so we never resolve the whole
// queue here (O(visible), not O(queue length)), which would hitch + churn
// every subscriber on each track change at scale.
const replacing = queue !== undefined;
const srcLen = replacing ? queue.length : state.queueItems.length;
if (replacing && shouldBindQueueServerForPlay(state.queueItems, queue, queue)) {
bindQueueServerForPlayback();
}
// Prefer an explicit target index from the caller (next/previous/queue-row
// click already know the exact slot). `findIndex` returns the *first*
// matching id, which jumps backwards when the queue contains the same
// track twice — breaking radio playback (issue #500).
const matchesAt = (i: number): boolean =>
replacing
? sameQueueTrackId(queue[i]?.id, track.id)
: sameQueueTrackId(state.queueItems[i]?.trackId, track.id);
const explicitIdxValid =
typeof targetQueueIndex === 'number'
&& targetQueueIndex >= 0
&& targetQueueIndex < newQueue.length
&& sameQueueTrackId(newQueue[targetQueueIndex]?.id, track.id);
&& targetQueueIndex < srcLen
&& matchesAt(targetQueueIndex);
const idx = explicitIdxValid
? (targetQueueIndex as number)
: newQueue.findIndex(t => sameQueueTrackId(t.id, track.id));
: replacing
? queue.findIndex(t => sameQueueTrackId(t.id, track.id))
: state.queueItems.findIndex(r => sameQueueTrackId(r.trackId, track.id));
const playIdx = idx >= 0 ? idx : 0;
// ±1 neighbours for replaygain normalization — resolve only these (not the
// whole queue). On replace they come from the provided Track[]; on navigation
// from the resolver cache (the bridge keeps that window warm).
const neighbourAt = (i: number): Track | null => {
if (i < 0 || i >= srcLen) return null;
if (replacing) return queue[i] ?? null;
if (i === playIdx) return track;
const ref = state.queueItems[i];
return ref ? resolveQueueTrack(ref) : null;
};
const prevNeighbour = neighbourAt(playIdx - 1);
const nextNeighbour = neighbourAt(playIdx + 1);
// Minimal window so deriveNormalizationSnapshot reads ±1 without a full array.
const normWindow: Track[] = prevNeighbour ? [prevNeighbour] : [];
const normIdx = normWindow.length;
normWindow.push(track);
if (nextNeighbour) normWindow.push(nextNeighbour);
if (manual) {
pushQueueUndoFromGetter(get);
}
@@ -248,12 +284,18 @@ export function runPlayTrack(
// Set state immediately so the UI updates before the download completes.
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
const queueSid = get().queueServerId ?? '';
// When the caller replaced the queue (explicit `queue` arg), seed the
// resolver with those tracks so the UI / hot paths resolve them without a
// network round-trip. No-arg jumps reuse already-cached refs.
if (queue && queueSid) seedQueueResolver(queueSid, queue);
set({
currentTrack: track,
currentRadio: null,
waveformBins: null,
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0),
queue: newQueue,
...deriveNormalizationSnapshot(track, normWindow, normIdx),
// Only a replace rewrites the queue; navigation keeps the canonical refs.
...(replacing ? { queueItems: toQueueItemRefs(queueSid, queue) } : {}),
queueIndex: idx >= 0 ? idx : 0,
progress: initialProgress,
buffered: 0,
@@ -285,8 +327,6 @@ export function runPlayTrack(
void refreshWaveformForTrack(track.id);
void refreshLoudnessForTrack(track.id);
setDeferHotCachePrefetch(true);
const playIdx = idx >= 0 ? idx : 0;
const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null;
const replayGainDb = resolveReplayGainDb(
track, prevTrack, nextNeighbour,
isReplayGainActive(), authStateNow.replayGainMode,
@@ -356,7 +396,7 @@ export function runPlayTrack(
}));
});
}
syncQueueToServer(newQueue, track, initialTime);
syncQueueToServer(get().queueItems, track, initialTime);
touchHotCacheOnPlayback(track.id, playbackCacheSid);
};
+13 -38
View File
@@ -48,7 +48,7 @@ import {
tauriMockListenerCount,
} from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks } from '@/test/helpers/factories';
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
function stubPlaybackInvokes(): void {
onInvoke('audio_play', () => undefined);
@@ -117,12 +117,8 @@ describe('audio:progress', () => {
describe('audio:track_switched', () => {
it('advances to queue[queueIndex + 1] under repeatMode=off', () => {
const queue = makeTracks(3);
usePlayerStore.setState({
queue,
queueIndex: 0,
currentTrack: queue[0],
repeatMode: 'off',
});
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.setState({ repeatMode: 'off' });
emitTauriEvent('audio:track_switched', queue[1].duration);
const s = usePlayerStore.getState();
expect(s.currentTrack?.id).toBe(queue[1].id);
@@ -135,12 +131,8 @@ describe('audio:track_switched', () => {
it('replays the same track under repeatMode=one (queueIndex stays put)', () => {
const queue = makeTracks(3);
usePlayerStore.setState({
queue,
queueIndex: 1,
currentTrack: queue[1],
repeatMode: 'one',
});
seedQueue(queue, { index: 1, currentTrack: queue[1] });
usePlayerStore.setState({ repeatMode: 'one' });
emitTauriEvent('audio:track_switched', queue[1].duration);
const s = usePlayerStore.getState();
expect(s.currentTrack?.id).toBe(queue[1].id);
@@ -149,12 +141,8 @@ describe('audio:track_switched', () => {
it('wraps to queue[0] when at the end with repeatMode=all', () => {
const queue = makeTracks(3);
usePlayerStore.setState({
queue,
queueIndex: 2,
currentTrack: queue[2],
repeatMode: 'all',
});
seedQueue(queue, { index: 2, currentTrack: queue[2] });
usePlayerStore.setState({ repeatMode: 'all' });
emitTauriEvent('audio:track_switched', queue[0].duration);
const s = usePlayerStore.getState();
expect(s.currentTrack?.id).toBe(queue[0].id);
@@ -163,12 +151,8 @@ describe('audio:track_switched', () => {
it('is a no-op when at the end with repeatMode=off', () => {
const queue = makeTracks(2);
usePlayerStore.setState({
queue,
queueIndex: 1,
currentTrack: queue[1],
repeatMode: 'off',
});
seedQueue(queue, { index: 1, currentTrack: queue[1] });
usePlayerStore.setState({ repeatMode: 'off' });
emitTauriEvent('audio:track_switched', queue[1].duration);
// No next candidate → handler returns early before state changes.
const s = usePlayerStore.getState();
@@ -178,13 +162,8 @@ describe('audio:track_switched', () => {
it('resets scrobbled + lastfmLoved flags so the new track can be rescored', () => {
const queue = makeTracks(2);
usePlayerStore.setState({
queue,
queueIndex: 0,
currentTrack: queue[0],
scrobbled: true,
lastfmLoved: true,
});
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.setState({ scrobbled: true, lastfmLoved: true });
emitTauriEvent('audio:track_switched', queue[1].duration);
expect(usePlayerStore.getState().scrobbled).toBe(false);
expect(usePlayerStore.getState().lastfmLoved).toBe(false);
@@ -201,10 +180,8 @@ describe('audio:ended', () => {
it('immediately resets playback bookkeeping (before the 150 ms next() timer)', () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.setState({
queue,
queueIndex: 0,
currentTrack: queue[0],
isPlaying: true,
progress: 0.99,
currentTime: 178,
@@ -222,10 +199,8 @@ describe('audio:ended', () => {
it('clears state and currentRadio for a radio stream without advancing the queue', () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.setState({
queue,
queueIndex: 0,
currentTrack: queue[0],
currentRadio: { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/stream' },
isPlaying: true,
});
+19 -21
View File
@@ -47,7 +47,7 @@ import { usePlayerStore } from './playerStore';
import { useAuthStore } from './authStore';
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks } from '@/test/helpers/factories';
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
beforeEach(() => {
resetPlayerStore();
@@ -209,10 +209,8 @@ describe('setProgress', () => {
describe('stop', () => {
it('invokes audio_stop and clears playback state', () => {
seedQueue(makeTracks(2), { index: 0, currentTrack: makeTrack() });
usePlayerStore.setState({
queue: makeTracks(2),
queueIndex: 0,
currentTrack: makeTrack(),
isPlaying: true,
progress: 0.5,
currentTime: 60,
@@ -229,15 +227,15 @@ describe('stop', () => {
describe('shuffleQueue', () => {
it('is a no-op when the queue has fewer than 2 tracks', () => {
const t = makeTrack({ id: 'only' });
usePlayerStore.setState({ queue: [t], queueIndex: 0, currentTrack: t });
seedQueue([t], { index: 0, currentTrack: t });
usePlayerStore.getState().shuffleQueue();
expect(usePlayerStore.getState().queue.map(q => q.id)).toEqual(['only']);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['only']);
});
it('keeps the current track at queueIndex 0 with the rest shuffled around it', () => {
const tracks = makeTracks(5, i => ({ id: `t-${i}` }));
const current = tracks[2];
usePlayerStore.setState({ queue: tracks, queueIndex: 2, currentTrack: current });
seedQueue(tracks, { index: 2, currentTrack: current });
// Pin the RNG so the shuffle is deterministic.
vi.spyOn(Math, 'random').mockReturnValue(0);
@@ -245,25 +243,25 @@ describe('shuffleQueue', () => {
vi.restoreAllMocks();
const s = usePlayerStore.getState();
expect(s.queue[0].id).toBe(current.id);
expect(s.queueItems[0].trackId).toBe(current.id);
expect(s.queueIndex).toBe(0);
// The set of ids is preserved.
expect([...s.queue.map(t => t.id)].sort()).toEqual(['t-0', 't-1', 't-2', 't-3', 't-4'].sort());
expect([...s.queueItems.map(r => r.trackId)].sort()).toEqual(['t-0', 't-1', 't-2', 't-3', 't-4'].sort());
});
});
describe('shuffleUpcomingQueue', () => {
it('is a no-op when fewer than 2 upcoming tracks remain', () => {
const tracks = makeTracks(3, i => ({ id: `t-${i}` }));
usePlayerStore.setState({ queue: tracks, queueIndex: 2, currentTrack: tracks[2] });
seedQueue(tracks, { index: 2, currentTrack: tracks[2] });
const beforeIds = tracks.map(t => t.id);
usePlayerStore.getState().shuffleUpcomingQueue();
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(beforeIds);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(beforeIds);
});
it('keeps the head + current in place and shuffles only the upcoming tail', () => {
const tracks = makeTracks(5, i => ({ id: `t-${i}` }));
usePlayerStore.setState({ queue: tracks, queueIndex: 1, currentTrack: tracks[1] });
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
vi.spyOn(Math, 'random').mockReturnValue(0);
usePlayerStore.getState().shuffleUpcomingQueue();
@@ -271,35 +269,35 @@ describe('shuffleUpcomingQueue', () => {
const s = usePlayerStore.getState();
// First two entries unchanged (head + current).
expect(s.queue[0].id).toBe('t-0');
expect(s.queue[1].id).toBe('t-1');
expect(s.queueItems[0].trackId).toBe('t-0');
expect(s.queueItems[1].trackId).toBe('t-1');
// The tail still contains the same ids in some order.
expect([...s.queue.slice(2).map(t => t.id)].sort()).toEqual(['t-2', 't-3', 't-4'].sort());
expect([...s.queueItems.slice(2).map(r => r.trackId)].sort()).toEqual(['t-2', 't-3', 't-4'].sort());
});
});
describe('pruneUpcomingToCurrent', () => {
it('drops everything after queueIndex', () => {
const tracks = makeTracks(5);
usePlayerStore.setState({ queue: tracks, queueIndex: 1, currentTrack: tracks[1] });
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
usePlayerStore.getState().pruneUpcomingToCurrent();
const s = usePlayerStore.getState();
expect(s.queue.map(t => t.id)).toEqual([tracks[0].id, tracks[1].id]);
expect(s.queueItems.map(r => r.trackId)).toEqual([tracks[0].id, tracks[1].id]);
expect(s.queueIndex).toBe(1);
});
it('clears the queue entirely when there is no current track (orphaned queue → empty)', () => {
usePlayerStore.setState({ queue: makeTracks(3), queueIndex: 0, currentTrack: null });
seedQueue(makeTracks(3), { index: 0, currentTrack: null });
usePlayerStore.getState().pruneUpcomingToCurrent();
const s = usePlayerStore.getState();
expect(s.queue).toEqual([]);
expect(s.queueItems).toEqual([]);
expect(s.queueIndex).toBe(0);
});
it('returns early without clearing when no current track AND queue is already empty', () => {
usePlayerStore.setState({ queue: [], queueIndex: 0, currentTrack: null });
usePlayerStore.setState({ queueItems: [], queueIndex: 0, currentTrack: null });
usePlayerStore.getState().pruneUpcomingToCurrent();
expect(usePlayerStore.getState().queue).toEqual([]);
expect(usePlayerStore.getState().queueItems).toEqual([]);
});
});
+88 -90
View File
@@ -74,7 +74,7 @@ vi.mock('@/api/lastfm', () => ({
import { usePlayerStore } from './playerStore';
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks } from '@/test/helpers/factories';
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
function stubInvokes(): void {
onInvoke('audio_play', () => undefined);
@@ -110,12 +110,8 @@ afterEach(() => {
describe('flushPlayQueuePosition', () => {
it('forwards the queue, current track, and millisecond position to savePlayQueue', async () => {
const [t1, t2, t3] = makeTracks(3);
usePlayerStore.setState({
queue: [t1, t2, t3],
queueIndex: 1,
currentTrack: t2,
isPlaying: true,
});
seedQueue([t1, t2, t3], { index: 1, currentTrack: t2 });
usePlayerStore.setState({ isPlaying: true });
// Drive a live-progress snapshot so flushPlayQueuePosition has a non-zero
// position to flush — readonly snapshot is what the API call samples.
emitTauriEvent('audio:progress', { current_time: 12.345, duration: t2.duration });
@@ -137,11 +133,7 @@ describe('flushPlayQueuePosition', () => {
it('caps the song-id list at 1000 entries', async () => {
const tracks = makeTracks(1100);
usePlayerStore.setState({
queue: tracks,
queueIndex: 0,
currentTrack: tracks[0],
});
seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
emitTauriEvent('audio:progress', { current_time: 1, duration: tracks[0].duration });
vi.mocked(savePlayQueue).mockClear(); // discard heartbeat call from emit
@@ -155,10 +147,8 @@ describe('flushPlayQueuePosition', () => {
it('is a no-op when a radio stream is active', async () => {
const track = makeTrack();
seedQueue([track], { index: 0, currentTrack: track });
usePlayerStore.setState({
queue: [track],
queueIndex: 0,
currentTrack: track,
currentRadio: { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/stream' },
});
@@ -168,11 +158,7 @@ describe('flushPlayQueuePosition', () => {
});
it('is a no-op when there is no current track', async () => {
usePlayerStore.setState({
queue: makeTracks(2),
queueIndex: 0,
currentTrack: null,
});
seedQueue(makeTracks(2), { index: 0, currentTrack: null });
await flushPlayQueuePosition();
@@ -181,7 +167,7 @@ describe('flushPlayQueuePosition', () => {
it('is a no-op when the queue is empty', async () => {
usePlayerStore.setState({
queue: [],
queueItems: [],
queueIndex: 0,
currentTrack: null,
});
@@ -193,7 +179,7 @@ describe('flushPlayQueuePosition', () => {
it('swallows backend errors without propagating to the caller', async () => {
const track = makeTrack();
usePlayerStore.setState({ queue: [track], queueIndex: 0, currentTrack: track });
seedQueue([track], { index: 0, currentTrack: track });
vi.mocked(savePlayQueue).mockRejectedValueOnce(new Error('offline'));
await expect(flushPlayQueuePosition()).resolves.toBeUndefined();
@@ -201,12 +187,8 @@ describe('flushPlayQueuePosition', () => {
it('floors the position to whole milliseconds', async () => {
const track = makeTrack({ duration: 200 });
usePlayerStore.setState({
queue: [track],
queueIndex: 0,
currentTrack: track,
isPlaying: true,
});
seedQueue([track], { index: 0, currentTrack: track });
usePlayerStore.setState({ isPlaying: true });
emitTauriEvent('audio:progress', { current_time: 12.9999, duration: 200 });
vi.mocked(savePlayQueue).mockClear(); // discard heartbeat call from emit
@@ -218,7 +200,7 @@ describe('flushPlayQueuePosition', () => {
});
// ---------------------------------------------------------------------------
// partialize: localStorage queue window (PR #756)
// partialize + merge: thin-state refs-only persistence
// ---------------------------------------------------------------------------
function getPartialize() {
@@ -228,82 +210,98 @@ function getPartialize() {
.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 });
function getMerge() {
type MergeFn = (persisted: unknown, current: ReturnType<typeof usePlayerStore.getState>) => ReturnType<typeof usePlayerStore.getState>;
return (usePlayerStore as unknown as { persist: { getOptions(): { merge: MergeFn } } })
.persist.getOptions().merge;
}
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);
});
it('persists the whole queue as thin queueItems (refs + serverId + flags), not just the window', () => {
describe('partialize: thin queueItems (refs only)', () => {
it('persists the WHOLE queue as thin refs (no windowed fat `queue`)', () => {
const tracks = makeTracks(600);
tracks[3].radioAdded = true;
tracks[4].autoAdded = true;
usePlayerStore.setState({ queue: tracks, queueIndex: 300, queueServerId: 's1' });
seedQueue(tracks, { index: 300, serverId: 's1', currentTrack: tracks[300] });
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);
// No fat `queue` key anymore.
expect(partial.queue).toBeUndefined();
// queueItems carries the WHOLE queue.
expect(items.length).toBe(600);
// queueItemsIndex is the restore-pending sentinel (= the live queueIndex).
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();
});
it('handles an empty queue without throwing', () => {
usePlayerStore.setState({ queueItems: [], queueIndex: 0 });
const partial = getPartialize()(usePlayerStore.getState());
expect((partial.queueItems as unknown[]).length).toBe(0);
expect(partial.queue).toBeUndefined();
});
});
describe('merge: restores the queue from any old persisted blob', () => {
const current = () => usePlayerStore.getState();
it('prefers an existing queueItems ref list + sets the sentinel', () => {
const merged = getMerge()(
{
queueServerId: 's1',
queueIndex: 2,
queueItems: [
{ serverId: 's1', trackId: 'a' },
{ serverId: 's1', trackId: 'b' },
],
queueItemsIndex: 1,
},
current(),
);
expect(merged.queueItems.map(r => r.trackId)).toEqual(['a', 'b']);
expect(merged.queueItemsIndex).toBe(1);
});
it('rebuilds queueItems from a legacy queueRefs string list', () => {
const merged = getMerge()(
{ queueServerId: 's2', queueRefs: ['x', 'y'], queueRefsIndex: 1 },
current(),
);
expect(merged.queueItems).toEqual([
{ serverId: 's2', trackId: 'x' },
{ serverId: 's2', trackId: 'y' },
]);
expect(merged.queueItemsIndex).toBe(1);
});
it('rebuilds queueItems from an old windowed fat `queue: Track[]` blob and drops the `queue` key', () => {
const blob: Record<string, unknown> = {
queueServerId: 's3',
queueIndex: 1,
queue: [makeTrack({ id: 'q0' }), makeTrack({ id: 'q1', radioAdded: true })],
};
const merged = getMerge()(blob, current());
expect(merged.queueItems).toEqual([
{ serverId: 's3', trackId: 'q0' },
{ serverId: 's3', trackId: 'q1', radioAdded: true },
]);
// The windowed fat-array key is deleted from the persisted blob.
expect('queue' in blob).toBe(false);
// Sentinel falls back to the persisted queueIndex when no explicit index.
expect(merged.queueItemsIndex).toBe(1);
});
it('leaves an empty queue alone (no sentinel) when the blob has nothing to restore', () => {
const merged = getMerge()({ queueServerId: null }, current());
expect(merged.queueItems).toEqual([]);
expect(merged.queueItemsIndex).toBeUndefined();
});
});
+11 -36
View File
@@ -49,7 +49,7 @@ import { usePlayerStore } from './playerStore';
import { useAuthStore } from './authStore';
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks } from '@/test/helpers/factories';
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
function stubPlaybackInvokes(): void {
onInvoke('audio_play', () => undefined);
@@ -201,11 +201,7 @@ describe('seek', () => {
describe('next', () => {
it('advances to queue[queueIndex + 1] when one is available', () => {
const queue = makeTracks(3);
usePlayerStore.setState({
queue,
queueIndex: 0,
currentTrack: queue[0],
});
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.getState().next();
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id);
expect(usePlayerStore.getState().queueIndex).toBe(1);
@@ -213,12 +209,8 @@ describe('next', () => {
it('wraps to queue[0] when at the end with repeatMode=all', () => {
const queue = makeTracks(3);
usePlayerStore.setState({
queue,
queueIndex: 2,
currentTrack: queue[2],
repeatMode: 'all',
});
seedQueue(queue, { index: 2, currentTrack: queue[2] });
usePlayerStore.setState({ repeatMode: 'all' });
usePlayerStore.getState().next();
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id);
expect(usePlayerStore.getState().queueIndex).toBe(0);
@@ -228,13 +220,8 @@ describe('next', () => {
// infiniteQueueEnabled and the radio fetch path are both off by default,
// so the no-next branch falls through to `audio_stop`.
const queue = makeTracks(2);
usePlayerStore.setState({
queue,
queueIndex: 1,
currentTrack: queue[1],
repeatMode: 'off',
isPlaying: true,
});
seedQueue(queue, { index: 1, currentTrack: queue[1] });
usePlayerStore.setState({ repeatMode: 'off', isPlaying: true });
usePlayerStore.getState().next();
expect(invokeMock).toHaveBeenCalledWith('audio_stop');
const s = usePlayerStore.getState();
@@ -247,11 +234,7 @@ describe('next', () => {
describe('previous', () => {
it('restarts the current track when currentTime > 3 s', () => {
const queue = makeTracks(3);
usePlayerStore.setState({
queue,
queueIndex: 1,
currentTrack: queue[1],
});
seedQueue(queue, { index: 1, currentTrack: queue[1] });
// The store's `currentTime` is the source for the "restart vs jump back"
// branch. `getPlaybackProgressSnapshot` reads from the same field.
usePlayerStore.setState({ currentTime: 10, progress: 10 / queue[1].duration });
@@ -262,12 +245,8 @@ describe('previous', () => {
it('jumps to the previous track when currentTime ≤ 3 s and queueIndex > 0', () => {
const queue = makeTracks(3);
usePlayerStore.setState({
queue,
queueIndex: 2,
currentTrack: queue[2],
currentTime: 1.0,
});
seedQueue(queue, { index: 2, currentTrack: queue[2] });
usePlayerStore.setState({ currentTime: 1.0 });
usePlayerStore.getState().previous();
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id);
expect(usePlayerStore.getState().queueIndex).toBe(1);
@@ -275,12 +254,8 @@ describe('previous', () => {
it('is a no-op when queueIndex is 0 and currentTime ≤ 3 s', () => {
const queue = makeTracks(2);
usePlayerStore.setState({
queue,
queueIndex: 0,
currentTrack: queue[0],
currentTime: 0.5,
});
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.setState({ currentTime: 0.5 });
usePlayerStore.getState().previous();
expect(usePlayerStore.getState().queueIndex).toBe(0);
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id);
+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
+81 -36
View File
@@ -1,8 +1,10 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { persist } from 'zustand/middleware';
import { createSafeJSONStorage } from './safeStorage';
import { emitPlaybackProgress } from './playbackProgress';
import type { PlayerState } from './playerStoreTypes';
import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { canonicalQueueServerKey } from '../utils/server/serverIndexKey';
import { readInitialQueueVisibility } from './queueVisibilityStorage';
import { createLastfmActions } from './lastfmActions';
import { createMiscActions } from './miscActions';
@@ -17,9 +19,6 @@ 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) => {
@@ -39,7 +38,9 @@ export const usePlayerStore = create<PlayerState>()(
currentRadio: null,
currentPlaybackSource: null,
enginePreloadedTrackId: null,
queue: [],
// Thin-state: the queue is a list of refs; full Tracks resolve on demand
// through the resolver. `currentTrack` stays a full resolved singleton.
queueItems: [],
queueServerId: null,
queueIndex: 0,
isPlaying: false,
@@ -81,37 +82,81 @@ export const usePlayerStore = create<PlayerState>()(
},
{
name: 'psysonic-player',
storage: createJSONStorage(() => localStorage),
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);
// Quota-safe: a failed persist write (huge queue > localStorage quota)
// must never throw, or it aborts the `set()` it fires from — that is what
// killed `playTrack` before `audio_play`. See safeStorage.ts.
storage: createSafeJSONStorage(),
partialize: (state) => ({
volume: state.volume,
repeatMode: state.repeatMode,
currentTrack: state.currentTrack,
queueServerId: state.queueServerId,
// Thin-state: persist the whole ordered ref list (tiny) — no windowed
// fat `queue: Track[]` anymore. `queueItemsIndex` doubles as the
// restore-pending sentinel a fresh rehydrate carries back, telling
// `hydrateQueueFromIndex` the refs still need a full resolve.
queueItems: state.queueItems,
queueItemsIndex: 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 to localStorage.
// Resume position is recovered from Subsonic savePlayQueue (5s debounce).
lastfmLovedCache: state.lastfmLovedCache,
}),
// Rebuild `queueItems` from ANY older persisted blob shape so an upgrade
// restores the queue. Order of preference: an existing `queueItems` ref
// list → the legacy `queueRefs` string list → a windowed `queue: Track[]`
// (the pre-thin-state shape). Sets the restore-pending sentinel and drops
// the obsolete fat `queue` key from the persisted object.
merge: (persisted, current) => {
const blob = (persisted ?? {}) as Record<string, unknown>;
const rawServerId = (blob.queueServerId as string | null | undefined) ?? null;
// B1: queue server identity is canonical (index key) on every write path.
// Migrate persisted blobs forward here once on rehydrate so the live
// store never has to handle a mix of UUID and index-key refs.
const canonicalSid = rawServerId ? canonicalQueueServerKey(rawServerId) : null;
let queueItems: QueueItemRef[] | undefined;
if (Array.isArray(blob.queueItems) && blob.queueItems.length > 0) {
queueItems = (blob.queueItems as QueueItemRef[]).map(ref => ({
...ref,
serverId: canonicalQueueServerKey(ref.serverId),
}));
} else if (Array.isArray(blob.queueRefs) && blob.queueRefs.length > 0) {
queueItems = (blob.queueRefs as string[]).map(trackId => ({
serverId: canonicalSid ?? '',
trackId,
}));
} else if (Array.isArray(blob.queue) && blob.queue.length > 0) {
queueItems = toQueueItemRefs(canonicalSid ?? '', blob.queue as Track[]);
}
// Restore-pending sentinel: prefer the persisted one; else the legacy
// index; else 0 when we recovered a non-empty queue from an old blob.
let queueItemsIndex: number | undefined;
if (typeof blob.queueItemsIndex === 'number') {
queueItemsIndex = blob.queueItemsIndex;
} else if (typeof blob.queueRefsIndex === 'number') {
queueItemsIndex = blob.queueRefsIndex;
} else if (queueItems && queueItems.length > 0) {
queueItemsIndex = typeof blob.queueIndex === 'number' ? blob.queueIndex : 0;
}
// Drop the obsolete windowed fat-array key — `queueItems` is canonical.
delete blob.queue;
// Persist the canonical form back onto the merged blob so subsequent
// reads of state.queueServerId always see the index key.
if (canonicalSid !== null) {
blob.queueServerId = canonicalSid;
}
return {
volume: state.volume,
repeatMode: state.repeatMode,
currentTrack: state.currentTrack,
queue: windowedQueue,
queueServerId: state.queueServerId,
queueIndex: qi - start, // remap into the windowed slice
// 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: toQueueItemRefs(state.queueServerId ?? '', state.queue),
queueItemsIndex: qi,
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,
};
...current,
...blob,
queueItems: queueItems ?? current.queueItems,
...(queueItemsIndex !== undefined ? { queueItemsIndex } : {}),
} as PlayerState;
},
}
)
+10 -6
View File
@@ -69,7 +69,6 @@ export interface PlayerState {
* Cleared after a successful `audio_play` consumed that preload, or when starting another track.
*/
enginePreloadedTrackId: string | null;
queue: Track[];
/** Saved server for stream/hot-cache/offline resolution while this queue plays. */
queueServerId: string | null;
queueIndex: number;
@@ -79,11 +78,16 @@ 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[];
/** Canonical thin queue list (thin-state). Single playback server per item in
* v1; carries the queue-only flags. Persisted by `partialize`; the source the
* resolver/consumers read from — full `Track`s resolve on demand. */
queueItems: QueueItemRef[];
/** Restore-pending sentinel (transient). `partialize` writes it alongside the
* full `queueItems` on every persist; a fresh rehydrate brings it back, which
* is what tells `hydrateQueueFromIndex` the windowed `queue` still needs a
* full hydrate. Normal mutations keep `queueItems` canonical but never set
* this, so its presence — not `queueItems` — gates the restore. Cleared once
* a full hydrate succeeds. */
queueItemsIndex?: number;
isPlaying: boolean;
/** HTTP stream still buffering (network / demux probe) — show loading on cover art. */
+98 -73
View File
@@ -3,7 +3,9 @@ import { orbitBulkGuard } from '../utils/orbitBulkGuard';
import { useAuthStore } from './authStore';
import { setIsAudioPaused } from './engineState';
import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch';
import type { PlayerState, Track } from './playerStoreTypes';
import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
import { pushQueueUndoFromGetter } from './queueUndo';
import { syncQueueToServer } from './queueSync';
import {
@@ -35,6 +37,22 @@ function blockCrossServerEnqueue(): boolean {
return true;
}
/**
* The canonical working ref list for a mutation (thin-state). Mutations
* splice/filter/reorder a copy of these refs and write the result back into
* `queueItems` the queue source of truth. Returns a fresh array each call so
* in-place splices don't mutate the live state array.
*/
const itemsOf = (state: PlayerState): QueueItemRef[] => [...state.queueItems];
/** Seed the resolver cache with tracks entering the queue, so they resolve
* without a network round-trip once `queue: Track[]` is dropped (seed-before-
* splice). No-op without a real playback server (e.g. unit tests). */
function seedIncoming(state: PlayerState, tracks: Track[]): void {
const serverId = state.queueServerId ?? '';
if (serverId) seedQueueResolver(serverId, tracks);
}
/**
* Eleven queue-mutation actions. Shared invariant: every action except
* `setRadioArtistId` pushes a queue-undo snapshot and calls
@@ -67,21 +85,18 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
}
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
set(state => {
seedIncoming(state, tracks);
const items = itemsOf(state);
const incoming = toQueueItemRefs(state.queueServerId ?? '', tracks);
// Insert before the first upcoming auto-added track so the
// "Added automatically" separator always stays at the boundary.
const firstAutoIdx = state.queue.findIndex(
(t, i) => t.autoAdded && i > state.queueIndex
);
const newQueue = firstAutoIdx === -1
? [...state.queue, ...tracks]
: [
...state.queue.slice(0, firstAutoIdx),
...tracks,
...state.queue.slice(firstAutoIdx),
];
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(newQueue, state.queueIndex);
return { queue: newQueue };
const firstAutoIdx = items.findIndex((r, i) => r.autoAdded && i > state.queueIndex);
const newItems = firstAutoIdx === -1
? [...items, ...incoming]
: [...items.slice(0, firstAutoIdx), ...incoming, ...items.slice(firstAutoIdx)];
syncQueueToServer(newItems, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(newItems, state.queueIndex);
return { queueItems: newItems };
});
},
@@ -101,22 +116,23 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
}
pushQueueUndoFromGetter(get);
set(state => {
const items = itemsOf(state);
// Drop all upcoming (not yet played) radio tracks — clicking "Start Radio"
// again replaces the pending radio batch instead of stacking on top.
const beforeAndCurrent = state.queue.slice(0, state.queueIndex + 1);
const upcoming = state.queue.slice(state.queueIndex + 1).filter(t => !t.radioAdded);
const beforeAndCurrent = items.slice(0, state.queueIndex + 1);
const upcoming = items.slice(state.queueIndex + 1).filter(r => !r.radioAdded);
// Tracks about to leave the queue here. Callers like ContextMenu.startRadio
// pass the previous pending radio back in `tracks` to merge with new
// similars — the seen-set must not block those re-introductions.
const droppedRadioIds = state.queue
const droppedRadioIds = items
.slice(state.queueIndex + 1)
.filter(t => t.radioAdded)
.map(t => t.id);
.filter(r => r.radioAdded)
.map(r => r.trackId);
for (const id of droppedRadioIds) deleteRadioSessionSeen(id);
// Capture surviving queue ids in the seen-set so the next radio top-up
// can dedupe against the seed track + already-queued non-radio items.
for (const t of beforeAndCurrent) addRadioSessionSeen(t.id);
for (const t of upcoming) addRadioSessionSeen(t.id);
for (const r of beforeAndCurrent) addRadioSessionSeen(r.trackId);
for (const r of upcoming) addRadioSessionSeen(r.trackId);
// Drop incoming tracks already seen earlier this session AND
// intra-batch duplicates (top + similar Last.fm responses commonly
// overlap). The seen-set is mutated inside the loop so a repeated
@@ -128,18 +144,16 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
addRadioSessionSeen(t.id);
dedupedTracks.push(t);
}
seedIncoming(state, dedupedTracks);
const incoming = toQueueItemRefs(state.queueServerId ?? '', dedupedTracks);
// Insert new radio tracks before any autoAdded tracks in the upcoming section.
const firstAutoIdx = upcoming.findIndex(t => t.autoAdded);
const merged = firstAutoIdx === -1
? [...upcoming, ...dedupedTracks]
: [
...upcoming.slice(0, firstAutoIdx),
...dedupedTracks,
...upcoming.slice(firstAutoIdx),
];
const newQueue = [...beforeAndCurrent, ...merged];
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
return { queue: newQueue };
const firstAutoIdx = upcoming.findIndex(r => r.autoAdded);
const mergedItems = firstAutoIdx === -1
? [...upcoming, ...incoming]
: [...upcoming.slice(0, firstAutoIdx), ...incoming, ...upcoming.slice(firstAutoIdx)];
const newItems = [...beforeAndCurrent, ...mergedItems];
syncQueueToServer(newItems, state.currentTrack, state.currentTime);
return { queueItems: newItems };
});
},
@@ -153,18 +167,17 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
}
pushQueueUndoFromGetter(get);
set(state => {
const idx = Math.max(0, Math.min(insertIndex, state.queue.length));
const newQueue = [
...state.queue.slice(0, idx),
...tracks,
...state.queue.slice(idx),
];
seedIncoming(state, tracks);
const items = itemsOf(state);
const idx = Math.max(0, Math.min(insertIndex, items.length));
const incoming = toQueueItemRefs(state.queueServerId ?? '', tracks);
const newItems = [...items.slice(0, idx), ...incoming, ...items.slice(idx)];
const newQueueIndex = idx <= state.queueIndex
? state.queueIndex + tracks.length
: state.queueIndex;
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(newQueue, newQueueIndex);
return { queue: newQueue, queueIndex: newQueueIndex };
syncQueueToServer(newItems, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(newItems, newQueueIndex);
return { queueItems: newItems, queueIndex: newQueueIndex };
});
},
@@ -180,8 +193,8 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
const baseIdx = state.queueIndex + 1;
let insertIdx = baseIdx;
if (useAuthStore.getState().preservePlayNextOrder) {
const q = state.queue;
while (insertIdx < q.length && q[insertIdx].playNextAdded) insertIdx++;
const items = itemsOf(state);
while (insertIdx < items.length && items[insertIdx].playNextAdded) insertIdx++;
}
get().enqueueAt(tagged, insertIdx);
},
@@ -190,21 +203,24 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
const s = get();
if (s.currentRadio) return;
if (!s.currentTrack) {
if (s.queue.length === 0) return;
if (s.queueItems.length === 0) return;
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
set({ queue: [], queueIndex: 0 });
set({ queueItems: [], queueIndex: 0 });
syncQueueToServer([], null, 0);
return;
}
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
const at = s.queue.findIndex(t => t.id === s.currentTrack!.id);
const newQueue: Track[] =
at >= 0
? s.queue.slice(0, at + 1)
: [s.currentTrack!];
// Seed the resolver with the currently playing track so its ref always
// resolves even when it had not been in the cache window before.
seedIncoming(s, [s.currentTrack]);
const items = itemsOf(s);
const at = items.findIndex(r => r.trackId === s.currentTrack!.id);
const newItems = at >= 0
? items.slice(0, at + 1)
: toQueueItemRefs(s.queueServerId ?? '', [s.currentTrack!]);
const newIndex = at >= 0 ? at : 0;
set({ queue: newQueue, queueIndex: newIndex });
syncQueueToServer(newQueue, s.currentTrack, s.currentTime);
set({ queueItems: newItems, queueIndex: newIndex });
syncQueueToServer(newItems, s.currentTrack, s.currentTime);
},
clearQueue: () => {
@@ -216,64 +232,73 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
clearRadioSessionSeenIds();
setCurrentRadioArtistId(null);
clearQueueServerForPlayback();
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
set({ queueItems: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
syncQueueToServer([], null, 0);
},
reorderQueue: (startIndex, endIndex) => {
pushQueueUndoFromGetter(get);
const { queue, queueIndex, currentTrack } = get();
const result = Array.from(queue);
const state = get();
const { queueIndex, currentTrack } = state;
const result = itemsOf(state);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
let newIndex = queueIndex;
if (currentTrack) newIndex = result.findIndex(t => t.id === currentTrack.id);
set({ queue: result, queueIndex: Math.max(0, newIndex) });
if (currentTrack) newIndex = result.findIndex(r => r.trackId === currentTrack.id);
set({ queueItems: result, queueIndex: Math.max(0, newIndex) });
syncQueueToServer(result, currentTrack, get().currentTime);
},
shuffleQueue: () => {
const { queue, currentTrack } = get();
if (queue.length < 2) return;
const state = get();
const { currentTrack } = state;
if (state.queueItems.length < 2) return;
pushQueueUndoFromGetter(get);
const currentIdx = currentTrack ? queue.findIndex(t => t.id === currentTrack.id) : -1;
const others = queue.filter((_, i) => i !== currentIdx);
const items = itemsOf(state);
const currentIdx = currentTrack ? items.findIndex(r => r.trackId === currentTrack.id) : -1;
const others = items.filter((_, i) => i !== currentIdx);
for (let i = others.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[others[i], others[j]] = [others[j], others[i]];
}
const result = currentIdx >= 0
? [queue[currentIdx], ...others]
? [items[currentIdx], ...others]
: others;
const newIndex = currentIdx >= 0 ? 0 : -1;
set({ queue: result, queueIndex: Math.max(0, newIndex) });
set({ queueItems: result, queueIndex: Math.max(0, newIndex) });
syncQueueToServer(result, currentTrack, get().currentTime);
},
shuffleUpcomingQueue: () => {
const { queue, queueIndex, currentTrack } = get();
const state = get();
const { queueIndex, currentTrack } = state;
const upcomingStart = queueIndex + 1;
const upcomingCount = queue.length - upcomingStart;
const upcomingCount = state.queueItems.length - upcomingStart;
if (upcomingCount < 2) return;
pushQueueUndoFromGetter(get);
const head = queue.slice(0, upcomingStart);
const upcoming = queue.slice(upcomingStart);
const items = itemsOf(state);
const head = items.slice(0, upcomingStart);
const upcoming = items.slice(upcomingStart);
for (let i = upcoming.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]];
}
const result = [...head, ...upcoming];
set({ queue: result });
set({ queueItems: result });
syncQueueToServer(result, currentTrack, get().currentTime);
},
removeTrack: (index) => {
pushQueueUndoFromGetter(get);
const { queue, queueIndex } = get();
const newQueue = [...queue];
newQueue.splice(index, 1);
set({ queue: newQueue, queueIndex: Math.min(queueIndex, newQueue.length - 1) });
syncQueueToServer(newQueue, get().currentTrack, get().currentTime);
const state = get();
const { queueIndex } = state;
const newItems = itemsOf(state);
newItems.splice(index, 1);
set({
queueItems: newItems,
queueIndex: Math.min(queueIndex, newItems.length - 1),
});
syncQueueToServer(newItems, get().currentTrack, get().currentTime);
},
};
}
+16 -15
View File
@@ -1,21 +1,22 @@
/**
* Side-effect wiring (queue thin-state, phase 2b): seed the queue track
* resolver cache with the tracks around the current index whenever the queue
* changes. The store stays `queue: Track[]`-canonical for now this only fills
* the resolver cache (additive; no mutation or persist change), so the queue
* selectors resolve without a fetch once consumers move onto them (phase 3) and
* after `queue: Track[]` is dropped (phase 4).
* Side-effect wiring (queue thin-state): keep the queue track resolver cache
* warm around the current index whenever the canonical `queueItems` ref list or
* the playing index changes. The store is refs-canonical now, so this fills the
* cache (via `resolveVisibleRange` index batch getSong fallback) so the
* queue selectors / list rows resolve without a synchronous miss. Render paths
* stay pure (no cache mutation in render); the seed travels with the playing
* track here, off the render path.
*/
import { usePlayerStore } from './playerStore';
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
const SEED_BACK = 50;
const SEED_AHEAD = 200;
import { resolveVisibleRange } from '../utils/library/queueTrackResolver';
usePlayerStore.subscribe((state, prev) => {
if (state.queue === prev.queue && state.queueServerId === prev.queueServerId) return;
const serverId = state.queueServerId ?? '';
if (!serverId || state.queue.length === 0) return;
const start = Math.max(0, state.queueIndex - SEED_BACK);
seedQueueResolver(serverId, state.queue.slice(start, state.queueIndex + SEED_AHEAD + 1));
// Re-seed when the queue refs or the current index change — the prefetch
// window (resolveVisibleRange's PREFETCH_BACK/AHEAD) travels with the index.
if (
state.queueItems === prev.queueItems &&
state.queueIndex === prev.queueIndex
) return;
if (state.queueItems.length === 0) return;
resolveVisibleRange(state.queueItems, state.queueIndex, state.queueIndex);
});
+24 -19
View File
@@ -5,12 +5,12 @@
* in for `savePlayQueue`, the playerStore, and the playback-progress
* snapshot.
*/
import type { Track } from './playerStoreTypes';
import type { QueueItemRef, Track } from './playerStoreTypes';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({
savePlayQueueMock: vi.fn(async (_ids: string[], _currentId: string | undefined, _pos: number, _serverId: string) => undefined),
playerState: {
queue: [] as Track[],
queueItems: [] as QueueItemRef[],
currentTrack: null as Track | null,
currentRadio: null as { id: string } | null,
},
@@ -40,12 +40,17 @@ function track(id: string): Track {
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
}
// Thin-state: sync helpers take queue refs.
function ref(id: string): QueueItemRef {
return { serverId: 'srv-a', trackId: id };
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-12T12:00:00Z'));
savePlayQueueMock.mockClear();
savePlayQueueMock.mockResolvedValue(undefined);
playerState.queue = [];
playerState.queueItems = [];
playerState.currentTrack = null;
playerState.currentRadio = null;
progressSnapshot.currentTime = 0;
@@ -57,32 +62,32 @@ afterEach(() => {
});
describe('syncQueueToServer (debounced)', () => {
const queue = [track('a'), track('b')];
const queue = [ref('a'), ref('b')];
it('does not fire before 5 s elapse', () => {
syncQueueToServer(queue, queue[0], 30);
syncQueueToServer(queue, track('a'), 30);
vi.advanceTimersByTime(4999);
expect(savePlayQueueMock).not.toHaveBeenCalled();
});
it('fires once after 5 s with id list + current id + position in ms', () => {
syncQueueToServer(queue, queue[0], 30);
syncQueueToServer(queue, track('a'), 30);
vi.advanceTimersByTime(5000);
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 30000, 'srv-a');
});
it('cancels the previous timer when called again before fire', () => {
syncQueueToServer(queue, queue[0], 10);
syncQueueToServer(queue, track('a'), 10);
vi.advanceTimersByTime(3000);
syncQueueToServer([...queue, track('c')], queue[0], 20);
syncQueueToServer([...queue, ref('c')], track('a'), 20);
vi.advanceTimersByTime(5000);
expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b', 'c'], 'a', 20000, 'srv-a');
});
it('caps the queue at 1000 ids', () => {
const big = Array.from({ length: 1500 }, (_, i) => track(`t${i}`));
syncQueueToServer(big, big[0], 0);
const big = Array.from({ length: 1500 }, (_, i) => ref(`t${i}`));
syncQueueToServer(big, track('t0'), 0);
vi.advanceTimersByTime(5000);
const ids = savePlayQueueMock.mock.calls[0][0] as string[];
expect(ids.length).toBe(1000);
@@ -93,13 +98,13 @@ describe('syncQueueToServer (debounced)', () => {
describe('flushQueueSyncToServer (immediate)', () => {
it('fires synchronously with no debounce', async () => {
await flushQueueSyncToServer([track('a')], track('a'), 12);
await flushQueueSyncToServer([ref('a')], track('a'), 12);
expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000, 'srv-a');
});
it('cancels a pending debounced sync first', async () => {
syncQueueToServer([track('a')], track('a'), 30);
await flushQueueSyncToServer([track('a')], track('a'), 31);
syncQueueToServer([ref('a')], track('a'), 30);
await flushQueueSyncToServer([ref('a')], track('a'), 31);
expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
// After the flush returns, advancing past the debounce should not fire again.
vi.advanceTimersByTime(10_000);
@@ -107,7 +112,7 @@ describe('flushQueueSyncToServer (immediate)', () => {
});
it('is a no-op when currentTrack is null', async () => {
await flushQueueSyncToServer([track('a')], null, 5);
await flushQueueSyncToServer([ref('a')], null, 5);
expect(savePlayQueueMock).not.toHaveBeenCalled();
});
@@ -118,23 +123,23 @@ describe('flushQueueSyncToServer (immediate)', () => {
it('records the heartbeat timestamp', async () => {
expect(getLastQueueHeartbeatAt()).toBe(0);
await flushQueueSyncToServer([track('a')], track('a'), 5);
await flushQueueSyncToServer([ref('a')], track('a'), 5);
expect(getLastQueueHeartbeatAt()).toBe(Date.now());
});
});
describe('flushPlayQueuePosition', () => {
it('reads the current playerStore queue + playback-progress time', async () => {
playerState.queue = [track('a'), track('b')];
playerState.currentTrack = playerState.queue[0];
playerState.queueItems = [ref('a'), ref('b')];
playerState.currentTrack = track('a');
progressSnapshot.currentTime = 42;
await flushPlayQueuePosition();
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 42000, 'srv-a');
});
it('is a no-op when a radio session is active', async () => {
playerState.queue = [track('a')];
playerState.currentTrack = playerState.queue[0];
playerState.queueItems = [ref('a')];
playerState.currentTrack = track('a');
playerState.currentRadio = { id: 'radio-1' };
await flushPlayQueuePosition();
expect(savePlayQueueMock).not.toHaveBeenCalled();
+6 -6
View File
@@ -1,5 +1,5 @@
import { savePlayQueue } from '../api/subsonicPlayQueue';
import type { Track } from './playerStoreTypes';
import type { QueueItemRef, Track } from './playerStoreTypes';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { getPlaybackProgressSnapshot } from './playbackProgress';
import { usePlayerStore } from './playerStore';
@@ -26,11 +26,11 @@ const QUEUE_ID_LIMIT = 1000;
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
let lastQueueHeartbeatAt = 0;
export function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number): void {
export function syncQueueToServer(queue: QueueItemRef[], currentTrack: Track | null, currentTime: number): void {
if (syncTimeout) clearTimeout(syncTimeout);
syncTimeout = setTimeout(() => {
syncTimeout = null;
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(t => t.id);
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(r => r.trackId);
const pos = Math.floor(currentTime * 1000);
const serverId = getPlaybackServerId();
savePlayQueue(ids, currentTrack?.id, pos, serverId).catch(err => {
@@ -39,14 +39,14 @@ export function syncQueueToServer(queue: Track[], currentTrack: Track | null, cu
}, SYNC_DEBOUNCE_MS);
}
export function flushQueueSyncToServer(queue: Track[], currentTrack: Track | null, currentTime: number): Promise<void> {
export function flushQueueSyncToServer(queue: QueueItemRef[], currentTrack: Track | null, currentTime: number): Promise<void> {
if (syncTimeout) {
clearTimeout(syncTimeout);
syncTimeout = null;
}
if (!currentTrack || queue.length === 0) return Promise.resolve();
lastQueueHeartbeatAt = Date.now();
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(t => t.id);
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(r => r.trackId);
const pos = Math.floor(currentTime * 1000);
const serverId = getPlaybackServerId();
return savePlayQueue(ids, currentTrack.id, pos, serverId).catch(err => {
@@ -68,7 +68,7 @@ export function getLastQueueHeartbeatAt(): number {
export function flushPlayQueuePosition(): Promise<void> {
const s = usePlayerStore.getState();
if (s.currentRadio) return Promise.resolve();
return flushQueueSyncToServer(s.queue, s.currentTrack, getPlaybackProgressSnapshot().currentTime);
return flushQueueSyncToServer(s.queueItems, s.currentTrack, getPlaybackProgressSnapshot().currentTime);
}
/** Test-only: drop the debounce + reset the heartbeat. */
+12 -10
View File
@@ -5,6 +5,7 @@
* to restore list scroll position after an undo/redo commit.
*/
import type { PlayerState, Track } from './playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { beforeEach, describe, expect, it } from 'vitest';
import {
QUEUE_UNDO_MAX,
@@ -24,14 +25,17 @@ function track(id: string): Track {
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
}
function state(queue: Track[], overrides: Partial<PlayerState> = {}): PlayerState {
// Thin-state: the snapshot reads `queueItems`; the `tracks` arg is a convenience
// for tests — it's lowered to refs (with the currentTrack defaulting to the head).
function state(tracks: Track[], overrides: Partial<PlayerState> = {}): PlayerState {
return {
queue,
queueItems: toQueueItemRefs('', tracks),
queueIndex: 0,
currentTrack: queue[0] ?? null,
currentTrack: tracks[0] ?? null,
currentTime: 0,
progress: 0,
isPlaying: false,
queueServerId: null,
...overrides,
} as PlayerState;
}
@@ -44,13 +48,11 @@ beforeEach(() => {
});
describe('queueUndoSnapshotFromState', () => {
it('deep-clones queue tracks and currentTrack', () => {
it('captures the queue as thin refs and clones currentTrack', () => {
const original = state([track('a'), track('b')]);
const snap = queueUndoSnapshotFromState(original);
expect(snap.queue).not.toBe(original.queue);
expect(snap.queue[0]).not.toBe(original.queue[0]);
expect(snap.currentTrack).not.toBe(original.currentTrack);
expect(snap.queue.map(t => t.id)).toEqual(['a', 'b']);
expect(snap.queueItems.map(r => r.trackId)).toEqual(['a', 'b']);
});
it('preserves currentTrack=null', () => {
@@ -69,7 +71,7 @@ describe('pushQueueUndoFromGetter', () => {
it('captures the current state on top of the undo stack', () => {
pushQueueUndoFromGetter(() => state([track('a')]));
const snap = popQueueUndoSnapshot();
expect(snap?.queue[0].id).toBe('a');
expect(snap?.queueItems[0].trackId).toBe('a');
});
it('wipes the redo stack — a fresh action invalidates redo history', () => {
@@ -100,8 +102,8 @@ describe('pushQueueUndoSnapshot / pushQueueRedoSnapshot', () => {
it('undo-snapshot push keeps order LIFO', () => {
pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('first')])));
pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('second')])));
expect(popQueueUndoSnapshot()?.queue[0].id).toBe('second');
expect(popQueueUndoSnapshot()?.queue[0].id).toBe('first');
expect(popQueueUndoSnapshot()?.queueItems[0].trackId).toBe('second');
expect(popQueueUndoSnapshot()?.queueItems[0].trackId).toBe('first');
});
});
+16 -3
View File
@@ -1,10 +1,14 @@
import type { PlayerState, Track } from './playerStoreTypes';
import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
/** Hard cap on undo/redo depth — keeps memory bounded for very long sessions. */
export const QUEUE_UNDO_MAX = 32;
export type QueueUndoSnapshot = {
queue: Track[];
/** Thin queue refs (thin-state phase 4) not hydrated `Track[]`, so 32
* snapshots of a 50k queue cost refs, not 32×50k full tracks. Rebuilt to a
* display `Track[]` through the resolver on restore. */
queueItems: QueueItemRef[];
queueIndex: number;
/** Kept full — one resolved playing track, restored to the engine on undo. */
currentTrack: Track | null;
/** Seconds — captured with the snapshot (older entries may omit). */
currentTime?: number;
@@ -12,6 +16,12 @@ export type QueueUndoSnapshot = {
isPlaying?: boolean;
/** Main queue panel list `scrollTop` when the snapshot was taken. */
queueListScrollTop?: number;
/** Canonical playback-server identity at snapshot time. Restore uses this
* for any ref it has to prepend (e.g. a still-playing track absent from the
* snapshot's queue) so a mid-restore server switch can't bind the prepended
* ref to the wrong server (B1/H3). Older in-memory entries may omit it;
* callers fall back to the live `queueServerId` in that case. */
queueServerId?: string | null;
};
const queueUndoStack: QueueUndoSnapshot[] = [];
@@ -50,12 +60,15 @@ export function consumePendingQueueListScrollTop(): number | undefined {
export function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot {
const scrollTop = readQueueListScrollTopForUndo();
return {
queue: s.queue.map(t => ({ ...t })),
// Thin refs straight off the canonical list — 32 snapshots cost refs, not
// 32×50k full tracks (the undo "hidden multiplier" the thin-state plan kills).
queueItems: [...s.queueItems],
queueIndex: s.queueIndex,
currentTrack: s.currentTrack ? { ...s.currentTrack } : null,
currentTime: s.currentTime,
progress: s.progress,
isPlaying: s.isPlaying,
queueServerId: s.queueServerId,
...(scrollTop !== undefined ? { queueListScrollTop: scrollTop } : {}),
};
}
+10 -5
View File
@@ -28,6 +28,7 @@ import {
recordEnginePlayUrl,
} from './playbackUrlRouting';
import type { PlayerState } from './playerStoreTypes';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import { syncQueueToServer } from './queueSync';
import { resumeRadio } from './radioPlayer';
@@ -112,10 +113,14 @@ export function runResume(set: SetState, get: GetState): void {
set({ isPlaying: true });
return;
}
const { currentTrack, queue, queueIndex, currentTime } = get();
const { currentTrack, queueItems, queueIndex, currentTime } = get();
if (!currentTrack) return;
const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null;
const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
// ReplayGain album-mode neighbours (resolver cache → placeholder; only their
// RG tags matter, which a placeholder lacks → fallback dB).
const coldPrev = queueIndex > 0 && queueItems[queueIndex - 1]
? resolveQueueTrack(queueItems[queueIndex - 1]) : null;
const coldNext = queueIndex + 1 < queueItems.length && queueItems[queueIndex + 1]
? resolveQueueTrack(queueItems[queueIndex + 1]) : null;
if (getIsAudioPaused()) {
// Rust engine has audio loaded but paused — just resume it.
@@ -185,7 +190,7 @@ export function runResume(set: SetState, get: GetState): void {
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
syncQueueToServer(queue, trackToPlay, currentTime);
syncQueueToServer(queueItems, trackToPlay, currentTime);
}).catch(() => {
if (getPlayGeneration() !== gen) return;
// Fallback to currentTrack if fetch fails
@@ -221,7 +226,7 @@ export function runResume(set: SetState, get: GetState): void {
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
syncQueueToServer(queue, currentTrack, currentTime);
syncQueueToServer(queueItems, currentTrack, currentTime);
});
})();
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createSafeJSONStorage } from './safeStorage';
describe('createSafeJSONStorage', () => {
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
it('swallows a QuotaExceededError from setItem instead of throwing', () => {
const storage = createSafeJSONStorage<{ a: number }>()!;
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('exceeded', 'QuotaExceededError');
});
// Must NOT throw — zustand calls this from inside set(); a throw here would
// abort the calling action (this is what killed playback on huge queues).
expect(() => storage.setItem('k', { state: { a: 1 }, version: 0 })).not.toThrow();
});
it('round-trips a value through localStorage when there is room', () => {
const storage = createSafeJSONStorage<{ a: number }>()!;
storage.setItem('k2', { state: { a: 5 }, version: 0 });
expect(storage.getItem('k2')).toEqual({ state: { a: 5 }, version: 0 });
});
it('does not throw across repeated quota failures (write stays a no-op)', () => {
const storage = createSafeJSONStorage<{ a: number }>()!;
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('exceeded', 'QuotaExceededError');
});
expect(() => {
for (let i = 0; i < 5; i++) {
storage.setItem('quota-throttle-key', { state: { a: i }, version: 0 });
}
}).not.toThrow();
});
it('returns null from getItem if the underlying read throws', () => {
const storage = createSafeJSONStorage<{ a: number }>()!;
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked');
});
expect(storage.getItem('missing')).toBeNull();
});
});
+58
View File
@@ -0,0 +1,58 @@
import { createJSONStorage, type StateStorage } from 'zustand/middleware';
/**
* `localStorage` wrapped so a failed write never throws.
*
* zustand's persist middleware calls the storage from *inside* `set()`. When a
* persisted slice grows past the origin quota (~5 MB) e.g. a multi-thousand
* track queue `localStorage.setItem` throws `QuotaExceededError`, and because
* that throw happens inside `set()` it aborts the calling action. That is how a
* full quota previously killed `playTrack` before it ever reached `audio_play`
* (no audio output at all on huge queues).
*
* Persistence is best-effort: a dropped write just means the in-memory store
* keeps working and the slice isn't saved this time. This is the same try/catch
* shape already used ad-hoc for direct `localStorage.setItem` calls elsewhere
* (e.g. mini-player geometry); this is its shared home for persist stores.
*/
// Warn once per key per quota-exceeded streak — a 50k+ queue persists on every
// mutation, so an unthrottled warning floods the console. Re-armed when a write
// to that key next succeeds (queue shrank back under the quota).
const quotaWarned = new Set<string>();
const safeLocalStorage: StateStorage = {
getItem: (name) => {
try {
return localStorage.getItem(name);
} catch {
return null;
}
},
setItem: (name, value) => {
try {
localStorage.setItem(name, value);
quotaWarned.delete(name);
} catch (e) {
if (import.meta.env.DEV && !quotaWarned.has(name)) {
quotaWarned.add(name);
console.warn(
`[psysonic] persist write skipped for "${name}" (storage quota?) — further skips silenced until it fits`,
e,
);
}
}
},
removeItem: (name) => {
try {
localStorage.removeItem(name);
} catch {
/* best-effort */
}
},
};
/**
* Drop-in replacement for `createJSONStorage(() => localStorage)` whose writes
* never throw. Use for any persist store whose slice can grow unbounded.
*/
export const createSafeJSONStorage = <S>() => createJSONStorage<S>(() => safeLocalStorage);
+2 -2
View File
@@ -56,7 +56,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
setAtMs: Date.now(),
});
clearSeekFallbackRetry();
s0.playTrack(s0.currentTrack, s0.queue, true);
s0.playTrack(s0.currentTrack, undefined, true);
return;
}
invoke('audio_seek', { seconds: time }).then(() => {
@@ -95,7 +95,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
setSeekFallbackTrackId(s.currentTrack.id);
setSeekFallbackRestartAt(now);
// Keep manual semantics (no crossfade) for seek recovery restarts.
s.playTrack(s.currentTrack, s.queue, true);
s.playTrack(s.currentTrack, undefined, true);
}
scheduleSeekFallbackRetry(s.currentTrack.id, time);
});
+7 -4
View File
@@ -8,7 +8,7 @@ import type { Track } from './playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { queueSongRatingMock, recordSkipStarMock, playerStateGet } = vi.hoisted(() => {
const playerState = {
queue: [] as Track[],
queueServerId: 's1' as string | null,
currentTrack: null as Track | null,
userRatingOverrides: {} as Record<string, number>,
};
@@ -28,6 +28,7 @@ vi.mock('./playerStore', () => ({
}));
import { applySkipStarOnManualNext } from './skipStarRating';
import { seedQueueResolver, _resetQueueResolverForTest } from '../utils/library/queueTrackResolver';
function track(id: string, overrides: Partial<Track> = {}): Track {
return {
@@ -38,8 +39,9 @@ function track(id: string, overrides: Partial<Track> = {}): Track {
beforeEach(() => {
queueSongRatingMock.mockClear();
recordSkipStarMock.mockReset();
_resetQueueResolverForTest();
const s = playerStateGet();
s.queue = [];
s.queueServerId = 's1';
s.currentTrack = null;
s.userRatingOverrides = {};
});
@@ -76,9 +78,10 @@ describe('applySkipStarOnManualNext', () => {
expect(queueSongRatingMock).not.toHaveBeenCalled();
});
it('skips rating when the queue entry is already rated', () => {
it('skips rating when the resolver-cached queue entry is already rated', () => {
recordSkipStarMock.mockReturnValueOnce({ crossedThreshold: true });
playerStateGet().queue = [track('t1', { userRating: 4 })];
// Thin-state: the queue's track copy lives in the resolver cache.
seedQueueResolver('s1', [track('t1', { userRating: 4 })]);
applySkipStarOnManualNext(track('t1'), true);
expect(queueSongRatingMock).not.toHaveBeenCalled();
});
+5 -2
View File
@@ -1,6 +1,7 @@
import type { Track } from './playerStoreTypes';
import { useAuthStore } from './authStore';
import { usePlayerStore } from './playerStore';
import { getCachedTrack } from '../utils/library/queueTrackResolver';
import { queueSongRating } from './pendingStarSync';
/**
* Skip 1 behaviour: every user-initiated `next()` on an unrated track
@@ -18,10 +19,12 @@ export function applySkipStarOnManualNext(skippedTrack: Track | null, manual: bo
const adv = useAuthStore.getState().recordSkipStarManualAdvance(id);
if (!adv?.crossedThreshold) return;
const live = usePlayerStore.getState();
const fromQueue = live.queue.find(t => t.id === id);
// Thin-state: the queue's copy of the rating now lives in the resolver cache.
const sid = live.queueServerId ?? '';
const fromCache = sid ? getCachedTrack({ serverId: sid, trackId: id }) : undefined;
const cur =
live.userRatingOverrides[id] ??
fromQueue?.userRating ??
fromCache?.userRating ??
skippedTrack.userRating ??
0;
if (cur >= 1) return;
+1 -1
View File
@@ -69,7 +69,7 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
// server with the right resume point for other devices.
const s = get();
if (s.currentTrack) {
void flushQueueSyncToServer(s.queue, s.currentTrack, s.currentTime);
void flushQueueSyncToServer(s.queueItems, s.currentTrack, s.currentTime);
}
}
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
+2 -1
View File
@@ -37,9 +37,10 @@ export function createUiStateActions(set: SetState): Pick<
const nextOverrides = { ...s.userRatingOverrides };
if (rating === 0) delete nextOverrides[id];
else nextOverrides[id] = rating;
// Thin-state: the queue's copy lives in the resolver cache; the override
// map (merged on read via applyQueueOverrides) drives the queue-row UI.
return {
userRatingOverrides: nextOverrides,
queue: s.queue.map(t => (t.id === id ? { ...t, userRating: rating } : t)),
currentTrack:
s.currentTrack?.id === id ? { ...s.currentTrack, userRating: rating } : s.currentTrack,
};
+10 -4
View File
@@ -10,6 +10,7 @@ import {
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
import { invokeAudioUpdateReplayGainDeduped } from './normalizationIpcDedupe';
import type { PlayerState } from './playerStoreTypes';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
@@ -33,11 +34,14 @@ type GetState = () => PlayerState;
* deduplicated IPC channel.
*/
export function runUpdateReplayGainForCurrentTrack(set: SetState, get: GetState): void {
const { currentTrack, queue, queueIndex, volume } = get();
const { currentTrack, queueItems, queueIndex, volume } = get();
if (!currentTrack || !currentTrack.id) return;
const authState = useAuthStore.getState();
const prev = queueIndex > 0 ? queue[queueIndex - 1] : null;
const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
// ReplayGain album-mode neighbours, resolved from refs (cache → placeholder).
const prev = queueIndex > 0 && queueItems[queueIndex - 1]
? resolveQueueTrack(queueItems[queueIndex - 1]) : null;
const next = queueIndex + 1 < queueItems.length && queueItems[queueIndex + 1]
? resolveQueueTrack(queueItems[queueIndex + 1]) : null;
const replayGainDb = resolveReplayGainDb(
currentTrack, prev, next,
isReplayGainActive(), authState.replayGainMode,
@@ -46,7 +50,9 @@ export function runUpdateReplayGainForCurrentTrack(set: SetState, get: GetState)
? (currentTrack.replayGainPeak ?? null)
: null;
const normalization = deriveNormalizationSnapshot(currentTrack, queue, queueIndex);
// Neighbour window for the normalization snapshot: prev, current, next.
const normWindow = [prev ?? currentTrack, currentTrack, ...(next ? [next] : [])];
const normalization = deriveNormalizationSnapshot(currentTrack, normWindow, prev ? 1 : 0);
const cachedLoud = getCachedLoudnessGain(currentTrack.id);
const cachedLoudDb = Number.isFinite(cachedLoud) ? cachedLoud! : null;
const haveStableLoud = hasStableLoudness(currentTrack.id);