mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
committed by
GitHub
parent
a8cfff0b62
commit
45b9229ceb
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
Reference in New Issue
Block a user