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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

tsc + full vitest suite (1115) green.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(changelog): queue mixed-server routing and quota-safe persist (#872)
This commit is contained in:
Frank Stellmacher
2026-05-27 00:10:34 +02:00
committed by GitHub
parent a8cfff0b62
commit 45b9229ceb
88 changed files with 1965 additions and 944 deletions
+17
View File
@@ -213,6 +213,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Queue — panel now reads through the shared track cache
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#860](https://github.com/Psychotoxical/psysonic/pull/860)**
* The queue panel sources its row details through the on-demand track cache, another step toward keeping multi-thousand-track queues light on memory. No visible change.
### Backup UX — blocking progress gate for long operations ### Backup UX — blocking progress gate for long operations
**By [@cucadmuh](https://github.com/cucadmuh), PR [#864](https://github.com/Psychotoxical/psysonic/pull/864)** **By [@cucadmuh](https://github.com/cucadmuh), PR [#864](https://github.com/Psychotoxical/psysonic/pull/864)**
@@ -348,6 +356,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Queue — mixed-server routing and quota-safe persist
**By [@Psychotoxical](https://github.com/Psychotoxical) + [@cucadmuh](https://github.com/cucadmuh), PR [#872](https://github.com/Psychotoxical/psysonic/pull/872)**
* Mixed-server queues with the same track ID on different servers now stay on their original server through track switches, undo, and radio top-ups.
* Persisted queue is quota-safe — a full local storage no longer blocks playback on very large queues.
## [1.46.0] - 2026-05-18 ## [1.46.0] - 2026-05-18
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages. > **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
+2 -1
View File
@@ -6,6 +6,7 @@ import { resetAllStores } from '../test/helpers/storeReset';
import { invokeMock, onInvoke } from '../test/mocks/tauri'; import { invokeMock, onInvoke } from '../test/mocks/tauri';
import { coverArtRef } from '../cover/ref'; import { coverArtRef } from '../cover/ref';
import { coverCacheEnsure, coverCacheRestHost, librarySqlServerId } from './coverCache'; import { coverCacheEnsure, coverCacheRestHost, librarySqlServerId } from './coverCache';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
describe('librarySqlServerId', () => { describe('librarySqlServerId', () => {
beforeEach(() => { beforeEach(() => {
@@ -54,7 +55,7 @@ describe('coverCacheEnsure', () => {
const track = makeTrack({ id: 'q1', coverArt: 'cover-1' }); const track = makeTrack({ id: 'q1', coverArt: 'cover-1' });
usePlayerStore.setState({ usePlayerStore.setState({
queue: [track], queueItems: toQueueItemRefs(playbackServerId, [track]),
queueIndex: 0, queueIndex: 0,
queueServerId: playbackServerId, queueServerId: playbackServerId,
currentTrack: track, currentTrack: track,
+1 -1
View File
@@ -24,7 +24,7 @@ describe('subsonicScrobble', () => {
isLoggedIn: true, isLoggedIn: true,
}); });
usePlayerStore.setState({ usePlayerStore.setState({
queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }], queueItems: [{ serverId: 'a', trackId: 't1' }],
queueServerId: 'a', queueServerId: 'a',
queueIndex: 0, queueIndex: 0,
}); });
+2 -2
View File
@@ -48,7 +48,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore'; import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset'; import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack, makeServer } from '@/test/helpers/factories'; import { makeTrack, makeServer, seedQueue } from '@/test/helpers/factories';
import { onInvoke } from '@/test/mocks/tauri'; import { onInvoke } from '@/test/mocks/tauri';
import { fireEvent } from '@testing-library/react'; import { fireEvent } from '@testing-library/react';
@@ -183,7 +183,7 @@ describe('ContextMenu — type=artist', () => {
describe('ContextMenu — type=queue-item', () => { describe('ContextMenu — type=queue-item', () => {
it('shows a Remove from Queue affordance the song menu does not have', () => { it('shows a Remove from Queue affordance the song menu does not have', () => {
const track = makeTrack({ id: 'q-1' }); const track = makeTrack({ id: 'q-1' });
usePlayerStore.setState({ queue: [track], queueIndex: 0, currentTrack: track }); seedQueue([track], { index: 0, currentTrack: track });
openMenuFor('queue-item', track, 0); openMenuFor('queue-item', track, 0);
const { container } = renderWithProviders(<ContextMenu />); const { container } = renderWithProviders(<ContextMenu />);
expect(container.querySelector('.context-menu')).not.toBeNull(); expect(container.querySelector('.context-menu')).not.toBeNull();
+3 -3
View File
@@ -27,14 +27,14 @@ export default function ContextMenu() {
const navigate = useNavigate(); const navigate = useNavigate();
const navigatePlaybackLibrary = usePlaybackLibraryNavigate(); const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
const orbitRole = useOrbitStore(s => s.role); const orbitRole = useOrbitStore(s => s.role);
const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore( const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queueItems, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
useShallow(s => ({ useShallow(s => ({
contextMenu: s.contextMenu, contextMenu: s.contextMenu,
closeContextMenu: s.closeContextMenu, closeContextMenu: s.closeContextMenu,
playTrack: s.playTrack, playTrack: s.playTrack,
enqueue: s.enqueue, enqueue: s.enqueue,
playNext: s.playNext, playNext: s.playNext,
queue: s.queue, queueItems: s.queueItems,
currentTrack: s.currentTrack, currentTrack: s.currentTrack,
removeTrack: s.removeTrack, removeTrack: s.removeTrack,
lastfmLovedCache: s.lastfmLovedCache, lastfmLovedCache: s.lastfmLovedCache,
@@ -202,7 +202,7 @@ export default function ContextMenu() {
playNext={playNext} playNext={playNext}
enqueue={enqueue} enqueue={enqueue}
removeTrack={removeTrack} removeTrack={removeTrack}
queue={queue} queue={queueItems}
currentTrack={currentTrack} currentTrack={currentTrack}
closeContextMenu={closeContextMenu} closeContextMenu={closeContextMenu}
starredOverrides={starredOverrides} starredOverrides={starredOverrides}
+6 -8
View File
@@ -53,7 +53,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore'; import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset'; import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack } from '@/test/helpers/factories'; import { makeTrack, seedQueue } from '@/test/helpers/factories';
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri'; import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
import { fireEvent } from '@testing-library/react'; import { fireEvent } from '@testing-library/react';
@@ -147,12 +147,11 @@ describe('FullscreenPlayer — control wiring', () => {
}); });
it('clicking Previous Track calls previous()', () => { it('clicking Previous Track calls previous()', () => {
usePlayerStore.setState({ seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
queue: [makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], index: 1,
queueIndex: 1,
currentTrack: makeTrack({ id: 'b' }), currentTrack: makeTrack({ id: 'b' }),
currentTime: 5,
}); });
usePlayerStore.setState({ currentTime: 5 });
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous'); const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
const { getByLabelText } = renderWithProviders( const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />, <FullscreenPlayer onClose={() => {}} />,
@@ -162,9 +161,8 @@ describe('FullscreenPlayer — control wiring', () => {
}); });
it('clicking Next Track calls next()', () => { it('clicking Next Track calls next()', () => {
usePlayerStore.setState({ seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
queue: [makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], index: 0,
queueIndex: 0,
currentTrack: makeTrack({ id: 'a' }), currentTrack: makeTrack({ id: 'a' }),
}); });
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next'); const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
+7 -7
View File
@@ -21,6 +21,7 @@ import { FsPlayBtn } from './fullscreenPlayer/FsPlayBtn';
import { useFsDynamicAccent } from '../hooks/useFsDynamicAccent'; import { useFsDynamicAccent } from '../hooks/useFsDynamicAccent';
import { useFsArtistPortrait } from '../hooks/useFsArtistPortrait'; import { useFsArtistPortrait } from '../hooks/useFsArtistPortrait';
import { useFsIdleFade } from '../hooks/useFsIdleFade'; import { useFsIdleFade } from '../hooks/useFsIdleFade';
import { useQueueTrackAt } from '../hooks/useQueueTracks';
interface FullscreenPlayerProps { interface FullscreenPlayerProps {
onClose: () => void; onClose: () => void;
@@ -73,13 +74,12 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim); const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple'; const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple';
// Pre-fetch next track's 300px cover into the IndexedDB cache. // Pre-fetch next track's 300px cover into the IndexedDB cache. Resolver-first
// Selector returns only the coverArt id, so it only re-runs on actual changes. // (thin-state): the next ref resolves from the cache (the prefetch window
const nextCoverArt = usePlayerStore(s => { // around the current index keeps it warm).
const q = s.queue; const queueIndex = usePlayerStore(s => s.queueIndex);
const idx = s.queueIndex; const nextTrack = useQueueTrackAt(queueIndex + 1);
return (idx >= 0 && idx + 1 < q.length) ? (q[idx + 1]?.coverArt ?? null) : null; const nextCoverArt = queueIndex >= 0 ? (nextTrack?.coverArt ?? null) : null;
});
const queueServerId = usePlayerStore(s => s.queueServerId); const queueServerId = usePlayerStore(s => s.queueServerId);
const activeServerId = useAuthStore(s => s.activeServerId); const activeServerId = useAuthStore(s => s.activeServerId);
useEffect(() => { useEffect(() => {
+46 -8
View File
@@ -6,6 +6,7 @@ import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExtern
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import { import {
ChevronDown, Play, Pause, SkipBack, SkipForward, ChevronDown, Play, Pause, SkipBack, SkipForward,
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X, Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
@@ -15,6 +16,11 @@ import { usePlayerStore } from '../store/playerStore';
import { useCachedUrl } from './CachedImage'; import { useCachedUrl } from './CachedImage';
import { OpenArtistRefInline } from './OpenArtistRefInline'; import { OpenArtistRefInline } from './OpenArtistRefInline';
import { formatTrackTime } from '../utils/format/formatDuration'; import { formatTrackTime } from '../utils/format/formatDuration';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import {
getQueueResolverVersion,
subscribeQueueResolver,
} from '../utils/library/queueTrackResolver';
import LyricsPane from './LyricsPane'; import LyricsPane from './LyricsPane';
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress'; import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
import PlaybackDelayModal from './PlaybackDelayModal'; import PlaybackDelayModal from './PlaybackDelayModal';
@@ -71,17 +77,42 @@ function useAlbumAccentColor(imageUrl: string): string {
// ── Queue Drawer ────────────────────────────────────────────────────────────── // ── Queue Drawer ──────────────────────────────────────────────────────────────
// Stable initial rect so the virtualizer never re-initializes on re-render (an
// inline literal would be a new ref each render → render loop). Replaced by the
// real height on first ResizeObserver measure.
const QUEUE_INITIAL_RECT = { width: 0, height: 600 };
function QueueDrawer({ onClose }: { onClose: () => void }) { function QueueDrawer({ onClose }: { onClose: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const queue = usePlayerStore(s => s.queue); const queue = usePlayerStore(s => s.queueItems);
const queueIndex = usePlayerStore(s => s.queueIndex); const queueIndex = usePlayerStore(s => s.queueIndex);
const playTrack = usePlayerStore(s => s.playTrack); const playTrack = usePlayerStore(s => s.playTrack);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
// Thin-state: the queue is the canonical `QueueItemRef[]`; each row's Track
// comes from the resolver (cache → placeholder), matching the desktop
// QueueList. Subscribe once so rows re-render as the resolver cache fills.
useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
// Scroll active track into view on open // Virtualize so a multi-thousand-track queue keeps DOM at O(visible rows) on
// mobile too (matches the desktop QueuePanel).
const rowVirtualizer = useVirtualizer({
count: queue.length,
getScrollElement: () => listRef.current,
estimateSize: () => 56,
overscan: 10,
getItemKey: i => `${queue[i].trackId}:${i}`,
initialRect: QUEUE_INITIAL_RECT,
});
const virtualItems = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
// Scroll the active track into view on open. Rows are uniform height, so the
// virtualizer's estimate lands the centred index accurately.
useEffect(() => { useEffect(() => {
const el = listRef.current?.querySelector('.mq-item.active'); if (queueIndex >= 0 && queue.length > 0) {
el?.scrollIntoView({ block: 'center', behavior: 'instant' }); rowVirtualizer.scrollToIndex(queueIndex, { align: 'center' });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
return ( return (
@@ -100,13 +131,19 @@ function QueueDrawer({ onClose }: { onClose: () => void }) {
{queue.length === 0 ? ( {queue.length === 0 ? (
<div className="mq-drawer-empty">{t('queue.emptyQueue')}</div> <div className="mq-drawer-empty">{t('queue.emptyQueue')}</div>
) : ( ) : (
queue.map((track, idx) => { <div style={{ height: totalSize, width: '100%', position: 'relative' }}>
{virtualItems.map(vi => {
const idx = vi.index;
const track = resolveQueueTrack(queue[idx]);
const isActive = idx === queueIndex; const isActive = idx === queueIndex;
return ( return (
<div <div
key={`${track.id}-${idx}`} key={vi.key}
data-index={idx}
ref={rowVirtualizer.measureElement}
className={`mq-item${isActive ? ' active' : ''}`} className={`mq-item${isActive ? ' active' : ''}`}
onClick={() => { playTrack(track, queue); onClose(); }} style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
onClick={() => { playTrack(track, undefined, undefined, undefined, idx); onClose(); }}
> >
<div className="mq-item-info"> <div className="mq-item-info">
<div className="mq-item-title"> <div className="mq-item-title">
@@ -118,7 +155,8 @@ function QueueDrawer({ onClose }: { onClose: () => void }) {
<span className="mq-item-dur">{formatTrackTime(track.duration)}</span> <span className="mq-item-dur">{formatTrackTime(track.duration)}</span>
</div> </div>
); );
}) })}
</div>
)} )}
</div> </div>
</div> </div>
+5 -6
View File
@@ -37,7 +37,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore'; import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset'; import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack } from '@/test/helpers/factories'; import { makeTrack, seedQueue } from '@/test/helpers/factories';
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri'; import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
import { fireEvent } from '@testing-library/react'; import { fireEvent } from '@testing-library/react';
@@ -96,12 +96,11 @@ describe('PlayerBar — control wiring', () => {
}); });
it('clicking Previous Track calls previous()', () => { it('clicking Previous Track calls previous()', () => {
usePlayerStore.setState({ seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
queue: [makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], index: 1,
queueIndex: 1,
currentTrack: makeTrack({ id: 'b' }), currentTrack: makeTrack({ id: 'b' }),
currentTime: 10, // > 3 s → restart current
}); });
usePlayerStore.setState({ currentTime: 10 }); // > 3 s → restart current
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous'); const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
const { getByLabelText } = renderWithProviders(<PlayerBar />); const { getByLabelText } = renderWithProviders(<PlayerBar />);
@@ -113,7 +112,7 @@ describe('PlayerBar — control wiring', () => {
it('clicking Next Track calls next()', () => { it('clicking Next Track calls next()', () => {
const t1 = makeTrack({ id: 'a' }); const t1 = makeTrack({ id: 'a' });
const t2 = makeTrack({ id: 'b' }); const t2 = makeTrack({ id: 'b' });
usePlayerStore.setState({ queue: [t1, t2], queueIndex: 0, currentTrack: t1 }); seedQueue([t1, t2], { index: 0, currentTrack: t1 });
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next'); const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
const { getByLabelText } = renderWithProviders(<PlayerBar />); const { getByLabelText } = renderWithProviders(<PlayerBar />);
+6 -26
View File
@@ -41,7 +41,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore'; import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset'; import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks } from '@/test/helpers/factories'; import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri'; import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -86,11 +86,7 @@ describe('QueuePanel — render surface', () => {
it('renders one row per queue track with the matching data-queue-idx', () => { it('renders one row per queue track with the matching data-queue-idx', () => {
const tracks = makeTracks(3); const tracks = makeTracks(3);
usePlayerStore.setState({ seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
queue: tracks,
queueIndex: 0,
currentTrack: tracks[0],
});
const { container } = renderWithProviders(<QueuePanel />); const { container } = renderWithProviders(<QueuePanel />);
const rows = container.querySelectorAll<HTMLElement>('[data-queue-idx]'); const rows = container.querySelectorAll<HTMLElement>('[data-queue-idx]');
expect(rows.length).toBe(3); expect(rows.length).toBe(3);
@@ -101,11 +97,7 @@ describe('QueuePanel — render surface', () => {
it('renders each queue row with the track title text', () => { it('renders each queue row with the track title text', () => {
const t1 = makeTrack({ id: 'q1', title: 'Test Song A' }); const t1 = makeTrack({ id: 'q1', title: 'Test Song A' });
const t2 = makeTrack({ id: 'q2', title: 'Test Song B' }); const t2 = makeTrack({ id: 'q2', title: 'Test Song B' });
usePlayerStore.setState({ seedQueue([t1, t2], { index: 0, currentTrack: t1 });
queue: [t1, t2],
queueIndex: 0,
currentTrack: t1,
});
const { getAllByText, getByText } = renderWithProviders(<QueuePanel />); const { getAllByText, getByText } = renderWithProviders(<QueuePanel />);
// Title A appears both in the now-playing section and in the row; // Title A appears both in the now-playing section and in the row;
// assert at least one match. Title B only lives in its row. // assert at least one match. Title B only lives in its row.
@@ -117,11 +109,7 @@ describe('QueuePanel — render surface', () => {
describe('QueuePanel — toolbar', () => { describe('QueuePanel — toolbar', () => {
it('exposes Shuffle / Save Playlist / Load Playlist / Share Queue / Clear via aria-label', () => { it('exposes Shuffle / Save Playlist / Load Playlist / Share Queue / Clear via aria-label', () => {
const tracks = makeTracks(3); const tracks = makeTracks(3);
usePlayerStore.setState({ seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
queue: tracks,
queueIndex: 0,
currentTrack: tracks[0],
});
const { getByLabelText } = renderWithProviders(<QueuePanel />); const { getByLabelText } = renderWithProviders(<QueuePanel />);
expect(getByLabelText('Shuffle queue')).toBeInTheDocument(); expect(getByLabelText('Shuffle queue')).toBeInTheDocument();
expect(getByLabelText('Save Playlist')).toBeInTheDocument(); expect(getByLabelText('Save Playlist')).toBeInTheDocument();
@@ -131,11 +119,7 @@ describe('QueuePanel — toolbar', () => {
}); });
it('Shuffle button is disabled when the queue has fewer than 2 tracks', () => { it('Shuffle button is disabled when the queue has fewer than 2 tracks', () => {
usePlayerStore.setState({ seedQueue([makeTrack()], { index: 0, currentTrack: makeTrack() });
queue: [makeTrack()],
queueIndex: 0,
currentTrack: makeTrack(),
});
const { getByLabelText } = renderWithProviders(<QueuePanel />); const { getByLabelText } = renderWithProviders(<QueuePanel />);
const shuffle = getByLabelText('Shuffle queue') as HTMLButtonElement; const shuffle = getByLabelText('Shuffle queue') as HTMLButtonElement;
expect(shuffle.disabled).toBe(true); expect(shuffle.disabled).toBe(true);
@@ -149,11 +133,7 @@ describe('QueuePanel — DnD architecture pin (§4.4 of v2 plan)', () => {
// queue back to native HTML5 DnD breaks loudly. // queue back to native HTML5 DnD breaks loudly.
it('queue rows do not declare draggable=true (no HTML5 native drag)', () => { it('queue rows do not declare draggable=true (no HTML5 native drag)', () => {
usePlayerStore.setState({ seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() });
queue: makeTracks(3),
queueIndex: 0,
currentTrack: makeTrack(),
});
const { container } = renderWithProviders(<QueuePanel />); const { container } = renderWithProviders(<QueuePanel />);
const rows = container.querySelectorAll<HTMLElement>('[data-queue-idx]'); const rows = container.querySelectorAll<HTMLElement>('[data-queue-idx]');
for (const row of rows) { for (const row of rows) {
+14 -11
View File
@@ -72,7 +72,10 @@ function QueuePanelHostOrSolo() {
if (!addedBy || addedBy === orbitHostUsername) return t('orbit.queueAddedByYou'); if (!addedBy || addedBy === orbitHostUsername) return t('orbit.queueAddedByYou');
return t('orbit.queueAddedByUser', { user: addedBy }); return t('orbit.queueAddedByUser', { user: addedBy });
}; };
const queue = usePlayerStore(s => s.queue); // Thin-state: the queue is the canonical `QueueItemRef[]`; rows resolve their
// Track from the resolver. List, header, toolbar and id/length reads (save /
// share / playlist) all read off the refs.
const queueItems = usePlayerStore(s => s.queueItems);
const queueIndex = usePlayerStore(s => s.queueIndex); const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
@@ -152,7 +155,7 @@ function QueuePanelHostOrSolo() {
}); });
useQueueAutoScroll({ useQueueAutoScroll({
queue, queue: queueItems,
queueIndex, queueIndex,
currentTrack, currentTrack,
queueListRef, queueListRef,
@@ -165,11 +168,11 @@ function QueuePanelHostOrSolo() {
const [loadModalOpen, setLoadModalOpen] = useState(false); const [loadModalOpen, setLoadModalOpen] = useState(false);
const handleSave = async () => { const handleSave = async () => {
if (queue.length === 0) return; if (queueItems.length === 0) return;
if (activePlaylist) { if (activePlaylist) {
setSaveState('saving'); setSaveState('saving');
try { try {
await updatePlaylist(activePlaylist.id, queue.map(t => t.id)); await updatePlaylist(activePlaylist.id, queueItems.map(r => r.trackId));
setSaveState('saved'); setSaveState('saved');
setTimeout(() => setSaveState('idle'), 1500); setTimeout(() => setSaveState('idle'), 1500);
} catch (e) { } catch (e) {
@@ -191,13 +194,13 @@ function QueuePanelHostOrSolo() {
}; };
const handleCopyQueueShare = async () => { const handleCopyQueueShare = async () => {
if (queue.length === 0) { if (queueItems.length === 0) {
showToast(t('queue.shareQueueEmpty'), 3000, 'info'); showToast(t('queue.shareQueueEmpty'), 3000, 'info');
return; return;
} }
const srv = useAuthStore.getState().getBaseUrl(); const srv = useAuthStore.getState().getBaseUrl();
if (!srv) return; if (!srv) return;
const ids = queue.map(t => t.id); const ids = queueItems.map(r => r.trackId);
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids })); const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
if (ok) showToast(t('contextMenu.shareCopied')); if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error'); else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
@@ -239,7 +242,7 @@ function QueuePanelHostOrSolo() {
</> </>
)} )}
<QueueHeader <QueueHeader
queue={queue} queue={queueItems}
queueIndex={queueIndex} queueIndex={queueIndex}
activePlaylist={activePlaylist} activePlaylist={activePlaylist}
isNowPlayingCollapsed={isNowPlayingCollapsed} isNowPlayingCollapsed={isNowPlayingCollapsed}
@@ -279,7 +282,7 @@ function QueuePanelHostOrSolo() {
{activeTab === 'queue' ? (<> {activeTab === 'queue' ? (<>
{!isNowPlayingCollapsed && toolbarButtons.some(b => b.visible && b.id !== 'separator') && ( {!isNowPlayingCollapsed && toolbarButtons.some(b => b.visible && b.id !== 'separator') && (
<QueueToolbar <QueueToolbar
queue={queue} queue={queueItems}
activePlaylist={activePlaylist} activePlaylist={activePlaylist}
saveState={saveState} saveState={saveState}
toolbarButtons={toolbarButtons} toolbarButtons={toolbarButtons}
@@ -300,10 +303,10 @@ function QueuePanelHostOrSolo() {
/> />
)} )}
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>} {currentTrack && queueItems.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
<QueueList <QueueList
queue={queue} queue={queueItems}
queueIndex={queueIndex} queueIndex={queueIndex}
contextMenu={contextMenu} contextMenu={contextMenu}
playTrack={playTrack} playTrack={playTrack}
@@ -332,7 +335,7 @@ function QueuePanelHostOrSolo() {
onSave={async (name) => { onSave={async (name) => {
try { try {
const createPlaylist = usePlaylistStore.getState().createPlaylist; const createPlaylist = usePlaylistStore.getState().createPlaylist;
const pl = await createPlaylist(name, queue.map(t => t.id)); const pl = await createPlaylist(name, queueItems.map(r => r.trackId));
if (pl) setActivePlaylist({ id: pl.id, name: pl.name }); if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
setSaveModalOpen(false); setSaveModalOpen(false);
} catch (e) { } catch (e) {
@@ -31,7 +31,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
const song = item as Track; const song = item as Track;
return ( return (
<> <>
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, queue, undefined, undefined, queueIndex))}> <div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, undefined, undefined, undefined, queueIndex))}>
<Play size={14} /> {t('contextMenu.playNow')} <Play size={14} /> {t('contextMenu.playNow')}
</div> </div>
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => { <div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
@@ -1,6 +1,6 @@
import type React from 'react'; import type React from 'react';
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes'; import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes'; import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import type { EntityShareKind } from '../../utils/share/shareLink'; import type { EntityShareKind } from '../../utils/share/shareLink';
export type RatingKind = 'song' | 'album' | 'artist'; export type RatingKind = 'song' | 'album' | 'artist';
@@ -22,7 +22,9 @@ export interface ContextMenuItemsProps {
playNext: (tracks: Track[]) => void; playNext: (tracks: Track[]) => void;
enqueue: (tracks: Track[]) => void; enqueue: (tracks: Track[]) => void;
removeTrack: (idx: number) => void; removeTrack: (idx: number) => void;
queue: Track[]; /** Thin-state: the canonical queue refs. The queue-item "Play now" action uses
* the row's `queueIndex` to jump in place — no full Track[] needed. */
queue: QueueItemRef[];
currentTrack: Track | null; currentTrack: Track | null;
closeContextMenu: () => void; closeContextMenu: () => void;
starredOverrides: Record<string, boolean>; starredOverrides: Record<string, boolean>;
+30 -5
View File
@@ -1,8 +1,14 @@
import React from 'react'; import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import OverlayScrollArea from '../OverlayScrollArea'; import OverlayScrollArea from '../OverlayScrollArea';
import type { MiniSyncPayload, MiniTrackInfo } from '../../utils/miniPlayerBridge'; import type { MiniSyncPayload, MiniTrackInfo } from '../../utils/miniPlayerBridge';
// Stable initial rect so the virtualizer never re-initializes on re-render (an
// inline literal would be a new ref each render → render loop). Replaced by the
// real height on first ResizeObserver measure.
const MINI_QUEUE_INITIAL_RECT = { width: 0, height: 400 };
type StartDrag = ( type StartDrag = (
payload: { data: string; label: string }, payload: { data: string; label: string },
x: number, x: number,
@@ -30,13 +36,26 @@ export function MiniQueue({
dropTarget, setDropTarget, dropTargetRef, startDrag, ctxIndex, setCtxMenu, dropTarget, setDropTarget, dropTargetRef, startDrag, ctxIndex, setCtxMenu,
jumpTo, t, jumpTo, t,
}: Props) { }: Props) {
// Virtualize so a multi-thousand-track queue keeps the mini window's DOM at
// O(visible rows). Scroll element is the OverlayScrollArea viewport.
const rowVirtualizer = useVirtualizer({
count: state.queue.length,
getScrollElement: () => queueScrollRef.current,
estimateSize: () => 40,
overscan: 10,
getItemKey: i => `${state.queue[i].id}:${i}`,
initialRect: MINI_QUEUE_INITIAL_RECT,
});
const virtualItems = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
return ( return (
<OverlayScrollArea <OverlayScrollArea
wrapRef={miniQueueWrapRef} wrapRef={miniQueueWrapRef}
viewportRef={queueScrollRef} viewportRef={queueScrollRef}
className="mini-queue-wrap" className="mini-queue-wrap"
viewportClassName="mini-queue" viewportClassName="mini-queue"
measureDeps={[state.queue.length]} measureDeps={[state.queue.length, totalSize]}
railInset="mini" railInset="mini"
viewportScrollBehaviorAuto={isReorderDrag} viewportScrollBehaviorAuto={isReorderDrag}
onMouseMove={(e) => { onMouseMove={(e) => {
@@ -60,7 +79,10 @@ export function MiniQueue({
{state.queue.length === 0 ? ( {state.queue.length === 0 ? (
<div className="mini-queue__empty">{t('miniPlayer.emptyQueue')}</div> <div className="mini-queue__empty">{t('miniPlayer.emptyQueue')}</div>
) : ( ) : (
state.queue.map((track, i) => { <div style={{ height: totalSize, width: '100%', position: 'relative' }}>
{virtualItems.map(vi => {
const i = vi.index;
const track = state.queue[i];
let dragStyle: React.CSSProperties = {}; let dragStyle: React.CSSProperties = {};
if (isReorderDrag && psyDragFromIdxRef.current === i) { if (isReorderDrag && psyDragFromIdxRef.current === i) {
dragStyle = { opacity: 0.4 }; dragStyle = { opacity: 0.4 };
@@ -71,7 +93,9 @@ export function MiniQueue({
} }
return ( return (
<button <button
key={`${track.id}-${i}`} key={vi.key}
data-index={i}
ref={rowVirtualizer.measureElement}
data-mq-idx={i} data-mq-idx={i}
className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}${ctxIndex === i ? ' mini-queue__item--ctx' : ''}`} className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}${ctxIndex === i ? ' mini-queue__item--ctx' : ''}`}
onClick={() => jumpTo(i)} onClick={() => jumpTo(i)}
@@ -105,7 +129,7 @@ export function MiniQueue({
document.addEventListener('mousemove', onMove); document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp); document.addEventListener('mouseup', onUp);
}} }}
style={dragStyle} style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)`, ...dragStyle }}
> >
<span className="mini-queue__num">{i + 1}</span> <span className="mini-queue__num">{i + 1}</span>
<div className="mini-queue__meta"> <div className="mini-queue__meta">
@@ -114,7 +138,8 @@ export function MiniQueue({
</div> </div>
</button> </button>
); );
}) })}
</div>
)} )}
</OverlayScrollArea> </OverlayScrollArea>
); );
@@ -9,6 +9,7 @@ import { usePreviewStore } from '../../store/previewStore';
import { useThemeStore } from '../../store/themeStore'; import { useThemeStore } from '../../store/themeStore';
import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore'; import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
import { songToTrack } from '../../utils/playback/songToTrack'; import { songToTrack } from '../../utils/playback/songToTrack';
import { getQueueTracksView } from '../../utils/library/queueTrackView';
import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers'; import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers';
import { formatTrackTime } from '../../utils/format/formatDuration'; import { formatTrackTime } from '../../utils/format/formatDuration';
@@ -114,19 +115,22 @@ export default function PlaylistSuggestions({
className="playlist-suggestion-play-btn" className="playlist-suggestion-play-btn"
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
const { queue, queueIndex, currentTrack, playTrack } = usePlayerStore.getState(); const { queueItems, queueIndex, currentTrack, playTrack } = usePlayerStore.getState();
const track = songToTrack(song); const track = songToTrack(song);
if (!currentTrack || queue.length === 0) { if (!currentTrack || queueItems.length === 0) {
playTrack(track, [track]); playTrack(track, [track]);
return; return;
} }
const insertAt = Math.min(queueIndex + 1, queue.length); // Thin-state: resolve the current queue, insert after
// the playing track, and play the inserted track.
const resolved = getQueueTracksView(queueItems);
const insertAt = Math.min(queueIndex + 1, resolved.length);
const newQueue = [ const newQueue = [
...queue.slice(0, insertAt), ...resolved.slice(0, insertAt),
track, track,
...queue.slice(insertAt), ...resolved.slice(insertAt),
]; ];
playTrack(track, newQueue); playTrack(track, newQueue, undefined, undefined, insertAt);
}} }}
data-tooltip={t('playlists.playNextSuggestion')} data-tooltip={t('playlists.playNextSuggestion')}
aria-label={t('playlists.playNextSuggestion')} aria-label={t('playlists.playNextSuggestion')}
+33 -12
View File
@@ -1,16 +1,21 @@
import { useMemo } from 'react'; import { useDeferredValue, useMemo, useSyncExternalStore } from 'react';
import { ChevronDown, ListMusic } from 'lucide-react'; import { ChevronDown, ListMusic } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import type { Track } from '../../store/playerStoreTypes'; import type { QueueItemRef } from '../../store/playerStoreTypes';
import type { DurationMode } from '../../utils/componentHelpers/queuePanelHelpers'; import type { DurationMode } from '../../utils/componentHelpers/queuePanelHelpers';
import { formatLongDuration } from '../../utils/format/formatDuration'; import { formatLongDuration } from '../../utils/format/formatDuration';
import { formatClockTime } from '../../utils/format/formatClockTime'; import { formatClockTime } from '../../utils/format/formatClockTime';
import { resolveQueueTrack } from '../../utils/library/queueTrackView';
import {
getQueueResolverVersion,
subscribeQueueResolver,
} from '../../utils/library/queueTrackResolver';
interface Props { interface Props {
queue: Track[]; queue: QueueItemRef[];
queueIndex: number; queueIndex: number;
activePlaylist: { id: string; name: string } | null; activePlaylist: { id: string; name: string } | null;
isNowPlayingCollapsed: boolean; isNowPlayingCollapsed: boolean;
@@ -29,16 +34,32 @@ export function QueueHeader({
const clockFormat = useAuthStore((s) => s.clockFormat); const clockFormat = useAuthStore((s) => s.clockFormat);
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const totalSecs = useMemo(() => // Thin-state: durations come from the resolver cache. The totals re-derive as
queue.reduce((acc: number, track: Track) => acc + (track.duration || 0), 0), // the cache fills (version) and on queue change; tracks past the cache window
[queue] // contribute 0 until they resolve. Pure read (no cache mutation) in the memo.
); // H1 mitigation: a mass-resolve burst (queue restore, prefetch window slide)
const futureTracksDuration = useMemo(() => // bumps `version` dozens of times in one frame; useDeferredValue coalesces
queue.slice(queueIndex + 1).reduce((acc: number, track: Track) => acc + (track.duration || 0), 0), // the burst into a single low-priority commit so long queues do not block
[queue, queueIndex] // the main thread on every cache tick. The aggregation itself is a single
); // pass — one loop produces both totals so a 50k-track queue costs one walk,
// not two.
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
const deferredVersion = useDeferredValue(version);
const { totalSecs, futureTracksDuration } = useMemo(() => {
if (queue.length === 0) return { totalSecs: 0, futureTracksDuration: 0 };
let total = 0;
let future = 0;
for (let i = 0; i < queue.length; i += 1) {
const dur = resolveQueueTrack(queue[i]).duration || 0;
total += dur;
if (i > queueIndex) future += dur;
}
return { totalSecs: total, futureTracksDuration: future };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queue, queueIndex, deferredVersion]);
const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + futureTracksDuration); const currentDuration = queue[queueIndex] ? resolveQueueTrack(queue[queueIndex]).duration : 0;
const remainingSecs = Math.max(0, (currentDuration ?? 0) - currentTime + futureTracksDuration);
let dur: string | null = null; let dur: string | null = null;
if (queue.length > 0) { if (queue.length > 0) {
+29 -11
View File
@@ -1,12 +1,17 @@
import React, { useEffect } from 'react'; import React, { useEffect, useSyncExternalStore } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Play } from 'lucide-react'; import { Play } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import OverlayScrollArea from '../OverlayScrollArea'; import OverlayScrollArea from '../OverlayScrollArea';
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import { useLuckyMixStore } from '../../store/luckyMixStore'; import { useLuckyMixStore } from '../../store/luckyMixStore';
import type { Track, PlayerState } from '../../store/playerStoreTypes'; import type { QueueItemRef, PlayerState } from '../../store/playerStoreTypes';
import { formatTrackTime } from '../../utils/format/formatDuration'; import { formatTrackTime } from '../../utils/format/formatDuration';
import { resolveQueueTrack } from '../../utils/library/queueTrackView';
import {
getQueueResolverVersion,
subscribeQueueResolver,
} from '../../utils/library/queueTrackResolver';
type StartDrag = ( type StartDrag = (
payload: { data: string; label: string }, payload: { data: string; label: string },
@@ -15,7 +20,7 @@ type StartDrag = (
) => void; ) => void;
interface Props { interface Props {
queue: Track[]; queue: QueueItemRef[];
queueIndex: number; queueIndex: number;
contextMenu: PlayerState['contextMenu']; contextMenu: PlayerState['contextMenu'];
playTrack: PlayerState['playTrack']; playTrack: PlayerState['playTrack'];
@@ -31,11 +36,22 @@ interface Props {
t: TFunction; t: TFunction;
} }
// Stable reference so the virtualizer never sees a "changed" option on re-render
// (an inline object literal would be a new ref every render). Only used until the
// ResizeObserver reports the real viewport height.
const INITIAL_RECT = { width: 0, height: 600 };
export function QueueList({ export function QueueList({
queue, queueIndex, contextMenu, playTrack, activeTab, queueListRef, queue, queueIndex, contextMenu, playTrack, activeTab, queueListRef,
suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget, suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget,
startDrag, orbitAttributionLabel, luckyRolling, t, startDrag, orbitAttributionLabel, luckyRolling, t,
}: Props) { }: Props) {
// Thin-state: the queue prop is the canonical `QueueItemRef[]`. Each row's
// full Track comes from the resolver (cache → placeholder; F4 overrides merged
// in resolveQueueTrack). Subscribe once so the list re-renders as the cache
// fills. Pure read in render — no cache mutation (the freeze landmine).
useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
// Virtualize so a 10k+ Artist-Radio queue keeps DOM at O(visible rows). // Virtualize so a 10k+ Artist-Radio queue keeps DOM at O(visible rows).
// Scroll element is the OverlayScrollArea viewport (`queueListRef`); rows have // Scroll element is the OverlayScrollArea viewport (`queueListRef`); rows have
// variable height (radio/auto dividers, lucky-mix loader) so we measure them. // variable height (radio/auto dividers, lucky-mix loader) so we measure them.
@@ -44,11 +60,11 @@ export function QueueList({
getScrollElement: () => queueListRef.current, getScrollElement: () => queueListRef.current,
estimateSize: () => 52, estimateSize: () => 52,
overscan: 10, overscan: 10,
getItemKey: i => `${queue[i].id}:${i}`, getItemKey: i => `${queue[i].trackId}:${i}`,
// Start with a sensible viewport height so rows render before the // Start with a sensible viewport height so rows render before the
// ResizeObserver reports the real size (SSR / jsdom, where the observer // ResizeObserver reports the real size (SSR / jsdom, where the observer
// never fires). The real height overrides this on first measure. // never fires). The real height overrides this on first measure.
initialRect: { width: 0, height: 600 }, initialRect: INITIAL_RECT,
}); });
const virtualItems = rowVirtualizer.getVirtualItems(); const virtualItems = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize(); const totalSize = rowVirtualizer.getTotalSize();
@@ -93,10 +109,11 @@ export function QueueList({
<div style={{ height: totalSize, width: '100%', position: 'relative' }}> <div style={{ height: totalSize, width: '100%', position: 'relative' }}>
{virtualItems.map(vi => { {virtualItems.map(vi => {
const idx = vi.index; const idx = vi.index;
const track = queue[idx]; const base = queue[idx];
const track = resolveQueueTrack(base);
const isPlaying = idx === queueIndex; const isPlaying = idx === queueIndex;
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded); const isFirstAutoAdded = base.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded); const isFirstRadioAdded = base.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
let dragStyle: React.CSSProperties = {}; let dragStyle: React.CSSProperties = {};
if (isQueueDrag && psyDragFromIdxRef.current === idx) { if (isQueueDrag && psyDragFromIdxRef.current === idx) {
@@ -131,9 +148,10 @@ export function QueueList({
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`} className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
onClick={() => { onClick={() => {
suppressNextAutoScrollRef.current = true; suppressNextAutoScrollRef.current = true;
// Pass the row index so a click on a duplicate track lands on // Same-queue jump: undefined keeps the canonical refs; the row
// *this* slot, not the first occurrence (issue #500). // index lands a click on a duplicate track on *this* slot, not
playTrack(track, queue, undefined, undefined, idx); // the first occurrence (issue #500).
playTrack(track, undefined, undefined, undefined, idx);
}} }}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
+2 -2
View File
@@ -3,14 +3,14 @@ import {
Check, FolderOpen, Infinity, MoveRight, Save, Share2, Shuffle, Trash2, Waves, Check, FolderOpen, Infinity, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
} from 'lucide-react'; } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { Track } from '../../store/playerStoreTypes'; import type { QueueItemRef } from '../../store/playerStoreTypes';
import type { import type {
QueueToolbarButtonConfig, QueueToolbarButtonConfig,
QueueToolbarButtonId, QueueToolbarButtonId,
} from '../../store/queueToolbarStore'; } from '../../store/queueToolbarStore';
interface Props { interface Props {
queue: Track[]; queue: QueueItemRef[];
activePlaylist: { id: string; name: string } | null; activePlaylist: { id: string; name: string } | null;
saveState: 'idle' | 'saving' | 'saved'; saveState: 'idle' | 'saving' | 'saved';
toolbarButtons: QueueToolbarButtonConfig[]; toolbarButtons: QueueToolbarButtonConfig[];
+4 -3
View File
@@ -331,7 +331,7 @@ export const SHORTCUT_ACTION_REGISTRY = {
runInMiniWindow: false, runInMiniWindow: false,
run: () => { run: () => {
const store = usePlayerStore.getState(); const store = usePlayerStore.getState();
const { currentTrack, queue, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store; const { currentTrack, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store;
stop(); stop();
resetAudioPause(); resetAudioPause();
invoke('audio_stop') invoke('audio_stop')
@@ -341,9 +341,10 @@ export const SHORTCUT_ACTION_REGISTRY = {
try { try {
const fresh = await getSong(currentTrack.id); const fresh = await getSong(currentTrack.id);
const t = fresh ? songToTrack(fresh) : currentTrack; const t = fresh ? songToTrack(fresh) : currentTrack;
playTrack(t, queue, true); // No-arg queue: keep the canonical refs, re-bind the current track.
playTrack(t, undefined, true);
} catch { } catch {
playTrack(currentTrack, queue, true); playTrack(currentTrack, undefined, true);
} }
} else { } else {
await initializeFromServerQueue(); await initializeFromServerQueue();
+2 -1
View File
@@ -6,6 +6,7 @@ import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { makeTrack } from '../test/helpers/factories'; import { makeTrack } from '../test/helpers/factories';
import { resetAllStores } from '../test/helpers/storeReset'; import { resetAllStores } from '../test/helpers/storeReset';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
const hoisted = vi.hoisted(() => ({ const hoisted = vi.hoisted(() => ({
useCoverArtMock: vi.fn( useCoverArtMock: vi.fn(
@@ -39,7 +40,7 @@ function seedPlaybackState(): { active: string; playback: string } {
useAuthStore.getState().setActiveServer(active); useAuthStore.getState().setActiveServer(active);
const track = makeTrack({ id: 'song-1', coverArt: 'cover-1' }); const track = makeTrack({ id: 'song-1', coverArt: 'cover-1' });
usePlayerStore.setState({ usePlayerStore.setState({
queue: [track], queueItems: toQueueItemRefs(playback, [track]),
queueIndex: 0, queueIndex: 0,
queueServerId: playback, queueServerId: playback,
currentTrack: track, currentTrack: track,
+1 -1
View File
@@ -11,7 +11,7 @@ export function usePlaybackCoverArt(
displayCssPx: number, displayCssPx: number,
): CoverArtHandle { ): CoverArtHandle {
const queueServerId = usePlayerStore(s => s.queueServerId); const queueServerId = usePlayerStore(s => s.queueServerId);
const queueLength = usePlayerStore(s => s.queue.length); const queueLength = usePlayerStore(s => s.queueItems.length);
const activeServerId = useAuthStore(s => s.activeServerId); const activeServerId = useAuthStore(s => s.activeServerId);
const serversFingerprint = useAuthStore(s => const serversFingerprint = useAuthStore(s =>
s.servers s.servers
@@ -3,6 +3,11 @@ import { invoke } from '@tauri-apps/api/core';
import { getPlaybackProgressSnapshot } from '../../store/playbackProgress'; import { getPlaybackProgressSnapshot } from '../../store/playbackProgress';
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { resolveQueueTrack } from '../../utils/library/queueTrackView';
/** Half-width of the CLI snapshot queue window (thin-state — like the mini
* bridge, the full 50k queue must not serialize over IPC on every change). */
const SNAPSHOT_QUEUE_HALF = 100;
/** `psysonic --info`: publishes a JSON snapshot under XDG_RUNTIME_DIR (Rust /** `psysonic --info`: publishes a JSON snapshot under XDG_RUNTIME_DIR (Rust
* writes atomically). Coalesces store changes through a 200 ms debounce and * writes atomically). Coalesces store changes through a 200 ms debounce and
@@ -27,12 +32,19 @@ export function usePlayerSnapshotPublisher() {
ct != null ct != null
? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred)) ? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred))
: null; : null;
// Thin-state: resolve only a window around the playing track (resolver
// cache → placeholder) instead of the whole 50k queue. `queue_length`
// stays the true total; `queue_index` is remapped into the window.
const total = s.queueItems.length;
const winStart = Math.max(0, s.queueIndex - SNAPSHOT_QUEUE_HALF);
const winEnd = Math.min(total, s.queueIndex + SNAPSHOT_QUEUE_HALF + 1);
const windowedQueue = s.queueItems.slice(winStart, winEnd).map(r => resolveQueueTrack(r));
const snapshot = { const snapshot = {
current_track: s.currentTrack, current_track: s.currentTrack,
current_radio: s.currentRadio, current_radio: s.currentRadio,
queue: s.queue, queue: windowedQueue,
queue_index: s.queueIndex, queue_index: s.queueIndex - winStart,
queue_length: s.queue.length, queue_length: total,
is_playing: s.isPlaying, is_playing: s.isPlaying,
current_time: getPlaybackProgressSnapshot().currentTime, current_time: getPlaybackProgressSnapshot().currentTime,
volume: s.volume, volume: s.volume,
@@ -50,7 +62,7 @@ export function usePlayerSnapshotPublisher() {
trackId: s.currentTrack?.id ?? null, trackId: s.currentTrack?.id ?? null,
radioId: s.currentRadio?.id ?? null, radioId: s.currentRadio?.id ?? null,
queueIndex: s.queueIndex, queueIndex: s.queueIndex,
queueLength: s.queue.length, queueLength: total,
isPlaying: s.isPlaying, isPlaying: s.isPlaying,
volume: Math.round(s.volume * 100), volume: Math.round(s.volume * 100),
repeatMode: s.repeatMode, repeatMode: s.repeatMode,
+1 -1
View File
@@ -52,7 +52,7 @@ export function useMiniQueueDrag({
if (parsed.type !== 'queue_reorder') return; if (parsed.type !== 'queue_reorder') return;
const fromIdx = parsed.index as number; const fromIdx = parsed.index as number;
psyDragFromIdxRef.current = null; psyDragFromIdxRef.current = null;
const queueLen = usePlayerStore.getState().queue.length || fallbackQueueLen; const queueLen = usePlayerStore.getState().queueItems.length || fallbackQueueLen;
const insertIdx = tgt const insertIdx = tgt
? (tgt.before ? tgt.idx : tgt.idx + 1) ? (tgt.before ? tgt.idx : tgt.idx + 1)
: queueLen; : queueLen;
+3 -2
View File
@@ -8,6 +8,7 @@ import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { makeTrack } from '../test/helpers/factories'; import { makeTrack } from '../test/helpers/factories';
import { resetAllStores } from '../test/helpers/storeReset'; import { resetAllStores } from '../test/helpers/storeReset';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
vi.mock('../api/coverCache', () => ({ vi.mock('../api/coverCache', () => ({
coverCachePeekBatch: vi.fn(async () => ({})), coverCachePeekBatch: vi.fn(async () => ({})),
@@ -51,7 +52,7 @@ describe('useNowPlayingPrewarm', () => {
coverArt: 'cover-1', coverArt: 'cover-1',
}); });
usePlayerStore.setState({ usePlayerStore.setState({
queue: [track], queueItems: toQueueItemRefs(playback, [track]),
queueIndex: 0, queueIndex: 0,
queueServerId: playback, queueServerId: playback,
currentTrack: track, currentTrack: track,
@@ -76,7 +77,7 @@ describe('useNowPlayingPrewarm', () => {
const { active, playback } = seedServers(); const { active, playback } = seedServers();
const track = makeTrack({ id: 'song-2', coverArt: 'cover-2' }); const track = makeTrack({ id: 'song-2', coverArt: 'cover-2' });
usePlayerStore.setState({ usePlayerStore.setState({
queue: [track], queueItems: toQueueItemRefs(playback, [track]),
queueIndex: 0, queueIndex: 0,
queueServerId: playback, queueServerId: playback,
currentTrack: null, currentTrack: null,
+8 -7
View File
@@ -108,23 +108,24 @@ export function useOrbitHost(): void {
&& (Date.now() - afterSweep.lastShuffle >= effectiveShuffleIntervalMs(afterSweep)); && (Date.now() - afterSweep.lastShuffle >= effectiveShuffleIntervalMs(afterSweep));
const afterShuffle = maybeShuffleQueue(afterSweep); const afterShuffle = maybeShuffleQueue(afterSweep);
if (shouldShuffleNow) { if (shouldShuffleNow) {
const before = usePlayerStore.getState().queue.length; const before = usePlayerStore.getState().queueItems.length;
usePlayerStore.getState().shuffleUpcomingQueue(); usePlayerStore.getState().shuffleUpcomingQueue();
if (before > 0) showToast(i18n.t('orbit.toastShuffled'), 2500, 'info'); if (before > 0) showToast(i18n.t('orbit.toastShuffled'), 2500, 'info');
} }
// 4) Overlay the host's live playback snapshot. // 4) Overlay the host's live playback snapshot. Host tick reads ids only,
// so the thin `queueItems` refs are all we need (no full Track resolve).
const playerLive = usePlayerStore.getState(); const playerLive = usePlayerStore.getState();
const upcoming = playerLive.queue.slice(playerLive.queueIndex + 1); const upcoming = playerLive.queueItems.slice(playerLive.queueIndex + 1);
// Map track id → original suggester (if any). State's `queue` carries // Map track id → original suggester (if any). State's `queue` carries
// every suggestion we've ever seen this session, so it's the right // every suggestion we've ever seen this session, so it's the right
// attribution source even after the track has been merged into the // attribution source even after the track has been merged into the
// host's player queue. // host's player queue.
const suggesterByTrack = new Map<string, string>(); const suggesterByTrack = new Map<string, string>();
for (const q of afterShuffle.queue) suggesterByTrack.set(q.trackId, q.addedBy); for (const q of afterShuffle.queue) suggesterByTrack.set(q.trackId, q.addedBy);
const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(t => ({ const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(r => ({
trackId: t.id, trackId: r.trackId,
addedBy: suggesterByTrack.get(t.id) ?? base.host, addedBy: suggesterByTrack.get(r.trackId) ?? base.host,
})); }));
const next: OrbitState = { const next: OrbitState = {
...afterShuffle, ...afterShuffle,
@@ -204,7 +205,7 @@ export function useOrbitHost(): void {
for (const { track } of toEnqueue) { for (const { track } of toEnqueue) {
const live = usePlayerStore.getState(); const live = usePlayerStore.getState();
const from = Math.max(0, live.queueIndex + 1); const from = Math.max(0, live.queueIndex + 1);
const to = live.queue.length; const to = live.queueItems.length;
const span = Math.max(1, to - from + 1); const span = Math.max(1, to - from + 1);
const pos = from + Math.floor(Math.random() * span); const pos = from + Math.floor(Math.random() * span);
player.enqueueAt([track], pos); player.enqueueAt([track], pos);
+1 -1
View File
@@ -19,7 +19,7 @@ describe('usePlaybackServerId', () => {
isLoggedIn: true, isLoggedIn: true,
}); });
usePlayerStore.setState({ usePlayerStore.setState({
queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }], queueItems: [{ serverId: 'a', trackId: 't1' }],
queueServerId: 'a', queueServerId: 'a',
queueIndex: 0, queueIndex: 0,
}); });
+1 -1
View File
@@ -9,7 +9,7 @@ import { getPlaybackServerId } from '../utils/playback/playbackServer';
*/ */
export function usePlaybackServerId(): string { export function usePlaybackServerId(): string {
const queueServerId = usePlayerStore(s => s.queueServerId); const queueServerId = usePlayerStore(s => s.queueServerId);
const queueLength = usePlayerStore(s => s.queue.length); const queueLength = usePlayerStore(s => s.queueItems.length);
const activeServerId = useAuthStore(s => s.activeServerId); const activeServerId = useAuthStore(s => s.activeServerId);
return useMemo( return useMemo(
() => getPlaybackServerId(), () => getPlaybackServerId(),
+2 -2
View File
@@ -1,9 +1,9 @@
import React, { useLayoutEffect } from 'react'; import React, { useLayoutEffect } from 'react';
import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo'; import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo';
import type { Track } from '../store/playerStoreTypes'; import type { QueueItemRef, Track } from '../store/playerStoreTypes';
interface Args { interface Args {
queue: Track[]; queue: QueueItemRef[];
queueIndex: number; queueIndex: number;
currentTrack: Track | null; currentTrack: Track | null;
queueListRef: React.RefObject<HTMLDivElement | null>; queueListRef: React.RefObject<HTMLDivElement | null>;
+1 -1
View File
@@ -66,7 +66,7 @@ export function useQueuePanelDrag({
const insertIdx = dropTarget const insertIdx = dropTarget
? (dropTarget.before ? dropTarget.idx : dropTarget.idx + 1) ? (dropTarget.before ? dropTarget.idx : dropTarget.idx + 1)
: usePlayerStore.getState().queue.length; : usePlayerStore.getState().queueItems.length;
if (parsedData.type === 'queue_reorder') { if (parsedData.type === 'queue_reorder') {
const fromIdx: number = parsedData.index; const fromIdx: number = parsedData.index;
+24 -18
View File
@@ -3,9 +3,8 @@ import { renderHook } from '@testing-library/react';
import { usePlayerStore } from '@/store/playerStore'; import { usePlayerStore } from '@/store/playerStore';
import type { Track } from '@/store/playerStoreTypes'; import type { Track } from '@/store/playerStoreTypes';
import { getCachedTrack, _resetQueueResolverForTest } from '@/utils/library/queueTrackResolver'; import { getCachedTrack, _resetQueueResolverForTest } from '@/utils/library/queueTrackResolver';
import { seedQueue } from '@/test/helpers/factories';
import { useQueueTrackAt, useCurrentTrack, useQueueItems } from './useQueueTracks'; import { useQueueTrackAt, useCurrentTrack, useQueueItems } from './useQueueTracks';
// Importing the bridge registers the queue→resolver seed subscriber.
import '@/store/queueResolverBridge';
const track = (id: string, over: Partial<Track> = {}): Track => const track = (id: string, over: Partial<Track> = {}): Track =>
({ id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1, ...over }); ({ id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1, ...over });
@@ -13,25 +12,37 @@ const track = (id: string, over: Partial<Track> = {}): Track =>
describe('useQueueTracks selectors', () => { describe('useQueueTracks selectors', () => {
beforeEach(() => { beforeEach(() => {
_resetQueueResolverForTest(); _resetQueueResolverForTest();
usePlayerStore.setState({ queue: [], queueIndex: 0, queueServerId: 's1', currentTrack: null }); usePlayerStore.setState({
queueItems: [], queueIndex: 0, queueServerId: 's1', currentTrack: null,
starredOverrides: {}, userRatingOverrides: {},
});
}); });
it('useQueueTrackAt returns the track at the index, or null', () => { it('useQueueTrackAt returns the track at the index, or null', () => {
usePlayerStore.setState({ queue: [track('t1'), track('t2')] }); // seedQueue seeds the resolver under serverId 's1' and sets the refs.
seedQueue([track('t1'), track('t2')], { serverId: 's1', currentTrack: null });
expect(renderHook(() => useQueueTrackAt(1)).result.current?.id).toBe('t2'); expect(renderHook(() => useQueueTrackAt(1)).result.current?.id).toBe('t2');
expect(renderHook(() => useQueueTrackAt(9)).result.current).toBeNull(); expect(renderHook(() => useQueueTrackAt(9)).result.current).toBeNull();
}); });
it('useQueueTrackAt merges session star/rating overrides', () => {
seedQueue([track('t1')], { serverId: 's1', currentTrack: null });
usePlayerStore.setState({
starredOverrides: { t1: true },
userRatingOverrides: { t1: 5 },
});
const { result } = renderHook(() => useQueueTrackAt(0));
expect(!!result.current?.starred).toBe(true);
expect(result.current?.userRating).toBe(5);
});
it('useCurrentTrack returns the current track', () => { it('useCurrentTrack returns the current track', () => {
usePlayerStore.setState({ currentTrack: track('cur') }); usePlayerStore.setState({ currentTrack: track('cur') });
expect(renderHook(() => useCurrentTrack()).result.current?.id).toBe('cur'); expect(renderHook(() => useCurrentTrack()).result.current?.id).toBe('cur');
}); });
it('useQueueItems derives thin refs (serverId + flags) from the queue', () => { it('useQueueItems returns the canonical thin refs (serverId + flags)', () => {
usePlayerStore.setState({ seedQueue([track('t1'), track('t2', { radioAdded: true })], { serverId: 's1', currentTrack: null });
queueServerId: 's1',
queue: [track('t1'), track('t2', { radioAdded: true })],
});
const { result } = renderHook(() => useQueueItems()); const { result } = renderHook(() => useQueueItems());
expect(result.current).toEqual([ expect(result.current).toEqual([
{ serverId: 's1', trackId: 't1' }, { serverId: 's1', trackId: 't1' },
@@ -40,20 +51,15 @@ describe('useQueueTracks selectors', () => {
}); });
}); });
describe('queueResolverBridge', () => { describe('seedQueue resolver seeding', () => {
beforeEach(() => { beforeEach(() => {
_resetQueueResolverForTest(); _resetQueueResolverForTest();
usePlayerStore.setState({ queue: [], queueIndex: 0, queueServerId: 's1', currentTrack: null }); usePlayerStore.setState({ queueItems: [], queueIndex: 0, queueServerId: 's1', currentTrack: null });
}); });
it('seeds the resolver cache with tracks around the current index on queue change', () => { it('seeds the resolver cache with the queue tracks under the queue server', () => {
usePlayerStore.setState({ queue: [track('t1'), track('t2')], queueIndex: 0, queueServerId: 's1' }); seedQueue([track('t1'), track('t2')], { serverId: 's1', currentTrack: null });
expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1'); expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1');
expect(getCachedTrack({ serverId: 's1', trackId: 't2' })?.id).toBe('t2'); expect(getCachedTrack({ serverId: 's1', trackId: 't2' })?.id).toBe('t2');
}); });
it('does not seed when there is no playback server', () => {
usePlayerStore.setState({ queue: [track('t1')], queueIndex: 0, queueServerId: null });
expect(getCachedTrack({ serverId: '', trackId: 't1' })).toBeUndefined();
});
}); });
+21 -11
View File
@@ -1,18 +1,30 @@
import { useMemo } from 'react'; import { useMemo, useSyncExternalStore } from 'react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import type { QueueItemRef, Track } from '../store/playerStoreTypes'; import type { QueueItemRef, Track } from '../store/playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef'; import { resolveQueueTrack } from '../utils/library/queueTrackView';
import {
getQueueResolverVersion,
subscribeQueueResolver,
} from '../utils/library/queueTrackResolver';
/** /**
* Stable queue selectors (queue thin-state). Consumers migrate onto these in * Stable queue selectors (queue thin-state). The store is refs-canonical now:
* phase 3. Today they read the canonical `queue: Track[]`; once it's dropped * full `Track`s come from the resolver cache (placeholder until a fetch lands),
* (phase 4) the implementations move to the resolver (`queueTrackResolver`) * with session star/rating overrides (F4) merged on read via resolveQueueTrack.
* without changing these signatures.
*/ */
/** The track at a queue index, or null. */ /** The track at a queue index, or null. */
export function useQueueTrackAt(idx: number): Track | null { export function useQueueTrackAt(idx: number): Track | null {
return usePlayerStore(s => s.queue[idx] ?? null); const ref = usePlayerStore(s => s.queueItems[idx] ?? null);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
return useMemo(() => {
if (!ref) return null;
return resolveQueueTrack(ref);
// version drives re-resolution as the cache fills; overrides drive the merge.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref, starredOverrides, userRatingOverrides, version]);
} }
/** The currently playing track, or null. */ /** The currently playing track, or null. */
@@ -20,9 +32,7 @@ export function useCurrentTrack(): Track | null {
return usePlayerStore(s => s.currentTrack); return usePlayerStore(s => s.currentTrack);
} }
/** The whole queue as thin refs (derived; memoized on queue/server identity). */ /** The whole queue as thin refs (the canonical list). */
export function useQueueItems(): QueueItemRef[] { export function useQueueItems(): QueueItemRef[] {
const queue = usePlayerStore(s => s.queue); return usePlayerStore(s => s.queueItems);
const serverId = usePlayerStore(s => s.queueServerId);
return useMemo(() => toQueueItemRefs(serverId ?? '', queue), [serverId, queue]);
} }
+25 -20
View File
@@ -1,6 +1,7 @@
import { buildStreamUrlForServer } from './api/subsonicStreamUrl'; import { buildStreamUrlForServer } from './api/subsonicStreamUrl';
import { getPlaybackCacheServerKey } from './utils/playback/playbackServer'; import { getPlaybackCacheServerKey } from './utils/playback/playbackServer';
import type { Track } from './store/playerStoreTypes'; import type { QueueItemRef } from './store/playerStoreTypes';
import { resolveQueueTrack } from './utils/library/queueTrackView';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from './store/authStore'; import { useAuthStore } from './store/authStore';
import { useHotCacheStore } from './store/hotCacheStore'; import { useHotCacheStore } from './store/hotCacheStore';
@@ -113,12 +114,9 @@ async function runWorker() {
} }
const player = usePlayerStore.getState(); const player = usePlayerStore.getState();
const { queue, queueIndex } = player; const { queueItems, queueIndex } = player;
const wantIds = new Set( const upcomingRefs = queueItems.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD);
queue const wantIds = new Set(upcomingRefs.map(r => r.trackId));
.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD)
.map(t => t.id),
);
if (!wantIds.has(job.trackId)) { if (!wantIds.has(job.trackId)) {
hotCacheFrontendDebug({ hotCacheFrontendDebug({
event: 'prefetch-skip-job', event: 'prefetch-skip-job',
@@ -130,8 +128,11 @@ async function runWorker() {
continue; continue;
} }
const track = queue.find(t => t.id === job.trackId); // Thin-state: the upcoming window sits inside the resolver-warm range, so
if (!track) { // resolveQueueTrack returns the full Track (placeholder only on a cold
// miss, where the size estimate falls back to the bitrate heuristic).
const jobRef = upcomingRefs.find(r => r.trackId === job.trackId);
if (!jobRef) {
hotCacheFrontendDebug({ hotCacheFrontendDebug({
event: 'prefetch-skip-job', event: 'prefetch-skip-job',
trackId: job.trackId, trackId: job.trackId,
@@ -139,10 +140,11 @@ async function runWorker() {
}); });
continue; continue;
} }
const track = resolveQueueTrack(jobRef);
const hotEntries = useHotCacheStore.getState().entries; const hotEntries = useHotCacheStore.getState().entries;
const occupied = sumCachedBytesInProtectedWindow(queue, queueIndex, job.serverId, hotEntries); const occupied = sumCachedBytesInProtectedWindow(queueItems, queueIndex, job.serverId, hotEntries);
const est = estimateTrackHotCacheBytes(track); const est = estimateTrackHotCacheBytes(track);
const isImmediateNext = queue[queueIndex + 1]?.id === job.trackId; const isImmediateNext = queueItems[queueIndex + 1]?.trackId === job.trackId;
if (!isImmediateNext && occupied + est > maxBytes) { if (!isImmediateNext && occupied + est > maxBytes) {
hotCacheFrontendDebug({ hotCacheFrontendDebug({
event: 'prefetch-skip-job', event: 'prefetch-skip-job',
@@ -172,7 +174,7 @@ async function runWorker() {
const authAfter = useAuthStore.getState(); const authAfter = useAuthStore.getState();
const maxAfter = Math.max(0, authAfter.hotCacheMaxMb) * 1024 * 1024; const maxAfter = Math.max(0, authAfter.hotCacheMaxMb) * 1024 * 1024;
await useHotCacheStore.getState().evictToFit( await useHotCacheStore.getState().evictToFit(
fresh.queue, fresh.queueItems,
fresh.queueIndex, fresh.queueIndex,
maxAfter, maxAfter,
getPlaybackCacheServerKey(), getPlaybackCacheServerKey(),
@@ -217,7 +219,7 @@ async function replanNow() {
const customDir = auth.hotCacheDownloadDir || null; const customDir = auth.hotCacheDownloadDir || null;
if (maxBytes <= 0) return; if (maxBytes <= 0) return;
const { queue, queueIndex, currentRadio } = usePlayerStore.getState(); const { queueItems, queueIndex, currentRadio } = usePlayerStore.getState();
if (currentRadio) { if (currentRadio) {
hotCacheFrontendDebug({ event: 'replan-skip', reason: 'radio-mode' }); hotCacheFrontendDebug({ event: 'replan-skip', reason: 'radio-mode' });
return; return;
@@ -225,15 +227,18 @@ async function replanNow() {
const offline = useOfflineStore.getState(); const offline = useOfflineStore.getState();
await useHotCacheStore.getState().evictToFit(queue, queueIndex, maxBytes, serverId, customDir); await useHotCacheStore.getState().evictToFit(queueItems, queueIndex, maxBytes, serverId, customDir);
// Must read entries after eviction: the pre-evict snapshot still lists removed keys and would // Must read entries after eviction: the pre-evict snapshot still lists removed keys and would
// skip prefetch for upcoming tracks that no longer have on-disk rows. // skip prefetch for upcoming tracks that no longer have on-disk rows.
const hotEntries = useHotCacheStore.getState().entries; const hotEntries = useHotCacheStore.getState().entries;
const targets = queue.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD); // Thin-state: resolve only the small upcoming window (within the resolver-warm
const immediateNextId = queue[queueIndex + 1]?.id; // range) to full Tracks for the size estimates / suffix.
let projectedOccupied = sumCachedBytesInProtectedWindow(queue, queueIndex, serverId, hotEntries); const targetRefs = queueItems.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD);
const targets = targetRefs.map(r => resolveQueueTrack(r));
const immediateNextId = queueItems[queueIndex + 1]?.trackId;
let projectedOccupied = sumCachedBytesInProtectedWindow(queueItems, queueIndex, serverId, hotEntries);
const jobs: PrefetchJob[] = []; const jobs: PrefetchJob[] = [];
const skipped: { trackId: string; reason: string }[] = []; const skipped: { trackId: string; reason: string }[] = [];
for (const t of targets) { for (const t of targets) {
@@ -278,7 +283,7 @@ export function initHotCachePrefetch(): () => void {
let lastQueueRef: unknown = null; let lastQueueRef: unknown = null;
let lastQueueIndex = -1; let lastQueueIndex = -1;
const unsubPlayer = usePlayerStore.subscribe(state => { const unsubPlayer = usePlayerStore.subscribe(state => {
const q = state.queue; const q = state.queueItems;
const i = state.queueIndex; const i = state.queueIndex;
if (q === lastQueueRef && i === lastQueueIndex) return; if (q === lastQueueRef && i === lastQueueIndex) return;
const prevIdx = lastQueueIndex; const prevIdx = lastQueueIndex;
@@ -288,11 +293,11 @@ export function initHotCachePrefetch(): () => void {
lastQueueIndex = i; lastQueueIndex = i;
scheduleAnalysisQueuePruneFromPlaybackQueue(); scheduleAnalysisQueuePruneFromPlaybackQueue();
if (onlyIndexMoved && i > prevIdx && prevIdx >= 0 && Array.isArray(prevQ)) { if (onlyIndexMoved && i > prevIdx && prevIdx >= 0 && Array.isArray(prevQ)) {
const left = (prevQ as Track[])[prevIdx]; const left = (prevQ as QueueItemRef[])[prevIdx];
const a = useAuthStore.getState(); const a = useAuthStore.getState();
const graceSid = getPlaybackCacheServerKey(); const graceSid = getPlaybackCacheServerKey();
if (left && graceSid) { if (left && graceSid) {
bumpHotCachePreviousTrackGrace(left.id, graceSid, a.hotCacheDebounceSec); bumpHotCachePreviousTrackGrace(left.trackId, graceSid, a.hotCacheDebounceSec);
scheduleEvictAfterPreviousGrace(); scheduleEvictAfterPreviousGrace();
} }
} }
+4 -4
View File
@@ -19,7 +19,7 @@ type AnalysisPrunePendingResult = {
}; };
export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void { export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void {
const { queue, currentTrack, queueIndex } = usePlayerStore.getState(); const { queueItems, currentTrack, queueIndex } = usePlayerStore.getState();
const { preloadMode } = useAuthStore.getState(); const { preloadMode } = useAuthStore.getState();
const rawServerId = getPlaybackServerId() ?? ''; const rawServerId = getPlaybackServerId() ?? '';
const server = useAuthStore.getState().servers.find(s => s.id === rawServerId); const server = useAuthStore.getState().servers.find(s => s.id === rawServerId);
@@ -34,12 +34,12 @@ export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void {
keepTrackIds.push(tid); keepTrackIds.push(tid);
}; };
pushId(currentTrack?.id); pushId(currentTrack?.id);
for (const track of queue) { for (const ref of queueItems) {
pushId(track.id); pushId(ref.trackId);
if (keepTrackIds.length >= 1000) break; if (keepTrackIds.length >= 1000) break;
} }
const middleTrackIds = collectPlaybackMiddlePriorityTrackIds( const middleTrackIds = collectPlaybackMiddlePriorityTrackIds(
queue, queueItems,
queueIndex, queueIndex,
currentTrack, currentTrack,
preloadMode, preloadMode,
+3 -3
View File
@@ -1,5 +1,5 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import type { Track } from '../store/playerStoreTypes'; import type { QueueItemRef, Track } from '../store/playerStoreTypes';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { HOT_CACHE_PROTECT_AFTER_CURRENT, type HotCacheEntry } from '../store/hotCacheStore'; import { HOT_CACHE_PROTECT_AFTER_CURRENT, type HotCacheEntry } from '../store/hotCacheStore';
@@ -23,7 +23,7 @@ export function entryKey(serverId: string, trackId: string): string {
/** Sum of on-disk bytes for eviction-protected slots (current + next — same span as `evictToFit`). */ /** Sum of on-disk bytes for eviction-protected slots (current + next — same span as `evictToFit`). */
export function sumCachedBytesInProtectedWindow( export function sumCachedBytesInProtectedWindow(
queue: Track[], queue: QueueItemRef[],
queueIndex: number, queueIndex: number,
serverId: string, serverId: string,
entries: Record<string, HotCacheEntry>, entries: Record<string, HotCacheEntry>,
@@ -32,7 +32,7 @@ export function sumCachedBytesInProtectedWindow(
const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT); const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT);
let sum = 0; let sum = 0;
for (let i = protectLo; i <= protectHi; i++) { for (let i = protectLo; i <= protectHi; i++) {
const e = entries[entryKey(serverId, queue[i].id)]; const e = entries[entryKey(serverId, queue[i].trackId)];
if (e) sum += e.sizeBytes || 0; if (e) sum += e.sizeBytes || 0;
} }
return sum; return sum;
+42 -6
View File
@@ -10,8 +10,11 @@ import {
} from './engineState'; } from './engineState';
import { clearPreloadingIds } from './gaplessPreloadState'; import { clearPreloadingIds } from './gaplessPreloadState';
import { deriveNormalizationSnapshot } from './normalizationSnapshot'; import { deriveNormalizationSnapshot } from './normalizationSnapshot';
import type { PlayerState } from './playerStoreTypes'; import type { PlayerState, QueueItemRef } from './playerStoreTypes';
import { sameQueueTrackId, shallowCloneQueueTracks } from '../utils/playback/queueIdentity'; 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 { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore';
import { import {
setPendingQueueListScrollTop, setPendingQueueListScrollTop,
@@ -54,7 +57,13 @@ export function applyQueueHistorySnapshot(
if (prior.currentRadio) { if (prior.currentRadio) {
stopRadio(); 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 nextIndex = snap.queueIndex;
let nextTrack = snap.currentTrack ? { ...snap.currentTrack } : null; let nextTrack = snap.currentTrack ? { ...snap.currentTrack } : null;
@@ -62,7 +71,23 @@ export function applyQueueHistorySnapshot(
const playing = prior.currentTrack; const playing = prior.currentTrack;
const pos = nextQueue.findIndex(t => sameQueueTrackId(t.id, playing.id)); const pos = nextQueue.findIndex(t => sameQueueTrackId(t.id, playing.id));
if (pos === -1) { 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]; nextQueue = [{ ...playing }, ...nextQueue];
nextItems = [
{ serverId: prependServerId, trackId: playing.id },
...nextItems,
];
nextIndex = 0; nextIndex = 0;
nextTrack = { ...playing }; nextTrack = { ...playing };
} else { } 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({ set({
queue: nextQueue, queueItems: nextItems,
queueIndex: nextIndex, queueIndex: nextIndex,
currentTrack: nextTrack, currentTrack: nextTrack,
currentRadio: null, currentRadio: null,
@@ -174,7 +210,7 @@ export function applyQueueHistorySnapshot(
if (!nextTrack) { if (!nextTrack) {
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
setIsAudioPaused(false); setIsAudioPaused(false);
syncQueueToServer(nextQueue, null, 0); syncQueueToServer(nextItems, null, 0);
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) { if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop)); setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
} }
@@ -201,6 +237,6 @@ export function applyQueueHistorySnapshot(
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) { if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop)); setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
} }
syncQueueToServer(nextQueue, nextTrack, tRestore); syncQueueToServer(nextItems, nextTrack, tRestore);
return true; return true;
} }
+40 -16
View File
@@ -1,5 +1,6 @@
import { reportNowPlaying, scrobbleSong } from '../api/subsonicScrobble'; import { reportNowPlaying, scrobbleSong } from '../api/subsonicScrobble';
import type { Track } from './playerStoreTypes'; import type { Track } from './playerStoreTypes';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm'; import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate'; import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
@@ -179,7 +180,7 @@ export function handleAudioProgress(
if (store.isPlaying && !store.currentRadio) { if (store.isPlaying && !store.currentRadio) {
const now = Date.now(); const now = Date.now();
if (now - getLastQueueHeartbeatAt() >= 15_000) { 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) { if (shouldChainGapless || shouldBytePreload || shouldPreloadLocalFileAnalysis || gaplessEnabled) {
const { queue, queueIndex, repeatMode } = store; const { queueItems, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1; 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' const nextTrack = repeatMode === 'one'
? track ? track
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null)); : (nextRef ? resolveQueueTrack(nextRef) : null);
if (!nextTrack || nextTrack.id === track.id) return; if (!nextTrack || nextTrack.id === track.id) return;
// Gapless backup: keep next-track bytes ready even if chain/decode misses // 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 }); void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
const authState = useAuthStore.getState(); const authState = useAuthStore.getState();
// Auto-mode neighbours for the *next* track: current track on its left, // Auto-mode neighbours for the *next* track: current track on its left,
// queue[nextIdx+1] on its right. // queueItems[nextIdx+1] on its right (resolved; placeholder on a cold miss
const nextNeighbour = nextIdx + 1 < queue.length // — only its replaygain tags matter, which a placeholder lacks → fallback).
? queue[nextIdx + 1] const nextNeighbourRef = nextIdx + 1 < queueItems.length
: (repeatMode === 'all' && queue.length > 0 ? queue[0] : null); ? queueItems[nextIdx + 1]
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
const nextNeighbour = nextNeighbourRef ? resolveQueueTrack(nextNeighbourRef) : null;
const replayGainDb = resolveReplayGainDb( const replayGainDb = resolveReplayGainDb(
nextTrack, track, nextNeighbour, nextTrack, track, nextNeighbour,
isReplayGainActive(), authState.replayGainMode, isReplayGainActive(), authState.replayGainMode,
@@ -355,7 +365,7 @@ export function handleAudioEnded(): void {
return; return;
} }
const { repeatMode, currentTrack, queue, queueIndex } = usePlayerStore.getState(); const { repeatMode, currentTrack, queueIndex } = usePlayerStore.getState();
setIsAudioPaused(false); setIsAudioPaused(false);
usePlayerStore.setState({ usePlayerStore.setState({
isPlaying: false, isPlaying: false,
@@ -379,7 +389,8 @@ export function handleAudioEnded(): void {
); );
} }
// Pin to the current slot — the track may appear elsewhere in the queue. // 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 { } else {
usePlayerStore.getState().next(false); usePlayerStore.getState().next(false);
} }
@@ -401,7 +412,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
if (store.currentTrack?.id) { if (store.currentTrack?.id) {
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id); useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
} }
const { queue, queueIndex, repeatMode } = store; const { queueItems, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1; const nextIdx = queueIndex + 1;
let nextTrack: Track | null = null; let nextTrack: Track | null = null;
let newIndex = queueIndex; let newIndex = queueIndex;
@@ -409,11 +420,14 @@ export function handleAudioTrackSwitched(_duration: number): void {
if (repeatMode === 'one' && store.currentTrack) { if (repeatMode === 'one' && store.currentTrack) {
nextTrack = store.currentTrack; nextTrack = store.currentTrack;
// queueIndex stays the same // queueIndex stays the same
} else if (nextIdx < queue.length) { } else if (nextIdx < queueItems.length) {
nextTrack = queue[nextIdx]; // 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; newIndex = nextIdx;
} else if (repeatMode === 'all' && queue.length > 0) { } else if (repeatMode === 'all' && queueItems.length > 0) {
nextTrack = queue[0]; nextTrack = resolveQueueTrack(queueItems[0]);
newIndex = 0; newIndex = 0;
} }
@@ -425,10 +439,20 @@ export function handleAudioTrackSwitched(_duration: number): void {
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId); const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl); 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({ usePlayerStore.setState({
currentTrack: nextTrack, currentTrack: nextTrack,
waveformBins: null, waveformBins: null,
...deriveNormalizationSnapshot(nextTrack, queue, newIndex), ...deriveNormalizationSnapshot(nextTrack, switchNeighbourWindow, 1),
normalizationDbgSource: 'track-switched', normalizationDbgSource: 'track-switched',
normalizationDbgTrackId: nextTrack.id, normalizationDbgTrackId: nextTrack.id,
queueIndex: newIndex, queueIndex: newIndex,
@@ -463,7 +487,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
})); }));
}); });
} }
syncQueueToServer(queue, nextTrack, 0); syncQueueToServer(queueItems, nextTrack, 0);
touchHotCacheOnPlayback(nextTrack.id, getPlaybackCacheServerKey()); touchHotCacheOnPlayback(nextTrack.id, getPlaybackCacheServerKey());
} }
+6 -1
View File
@@ -2,6 +2,7 @@ import type { AuthState } from './authStoreTypes';
import { generateId } from './authStoreHelpers'; import { generateId } from './authStoreHelpers';
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
import { clearQueueServerForPlayback } from '../utils/playback/playbackServer'; import { clearQueueServerForPlayback } from '../utils/playback/playbackServer';
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
type SetState = ( type SetState = (
partial: Partial<AuthState> | ((state: AuthState) => Partial<AuthState>), partial: Partial<AuthState> | ((state: AuthState) => Partial<AuthState>),
@@ -40,7 +41,11 @@ export function createServerProfileActions(set: SetState): Pick<
}, },
removeServer: (id) => { 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(); clearQueueServerForPlayback();
} }
set(s => { set(s => {
+1 -1
View File
@@ -130,7 +130,7 @@ describe('removeServer', () => {
const { a, b } = addThree(); const { a, b } = addThree();
useAuthStore.getState().setActiveServer(b); useAuthStore.getState().setActiveServer(b);
usePlayerStore.setState({ usePlayerStore.setState({
queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }], queueItems: [{ serverId: a, trackId: 't1' }],
queueServerId: a, queueServerId: a,
queueIndex: 0, queueIndex: 0,
}); });
+308
View File
@@ -0,0 +1,308 @@
/**
* B1 regression cluster: queue thin-state server identity must be canonical
* everywhere. Writers emit the URL-derived index key (same model as the
* library index) so mixed-server queues with duplicate `trackId` across
* servers stay unambiguous on every path the review flagged:
*
* - resolver correctness (`seedQueueResolver`, `getCachedTrack`)
* - restore / hydrate (persist `merge`, `hydrateQueueFromIndex`)
* - undo / redo snapshots (`applyQueueHistorySnapshot` prepend, H3)
* - queue sync id emission (`savePlayQueue` trackIds only)
* - write helpers (`toQueueItemRefs`, `bindQueueServerForPlayback`)
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/store/playerStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { resetAuthStore, resetPlayerStore } from '@/test/helpers/storeReset';
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
import { bindQueueServerForPlayback } from '@/utils/playback/playbackServer';
import {
_resetQueueResolverForTest,
getCachedTrack,
seedQueueResolver,
} from '@/utils/library/queueTrackResolver';
import { applyQueueHistorySnapshot } from '@/store/applyQueueHistorySnapshot';
import {
pushQueueUndoSnapshot,
type QueueUndoSnapshot,
} from '@/store/queueUndo';
import type { PlayerState, QueueItemRef, Track } from '@/store/playerStoreTypes';
import { savePlayQueue } from '@/api/subsonicPlayQueue';
import { _resetQueueSyncForTest, flushPlayQueuePosition } from '@/store/queueSync';
vi.mock('@/api/subsonicPlayQueue', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
}));
const SERVER_A = {
id: 'uuid-a',
name: 'A',
url: 'http://a.test',
username: 'u',
password: 'p',
};
const SERVER_B = {
id: 'uuid-b',
name: 'B',
url: 'http://b.test',
username: 'u',
password: 'p',
};
const KEY_A = 'a.test';
const KEY_B = 'b.test';
function track(id: string, title: string): Track {
return { id, title, artist: '', album: 'A', albumId: 'AL', duration: 60 };
}
function getMerge() {
type MergeFn = (
persisted: unknown,
current: ReturnType<typeof usePlayerStore.getState>,
) => ReturnType<typeof usePlayerStore.getState>;
return (usePlayerStore as unknown as {
persist: { getOptions(): { merge: MergeFn } };
}).persist.getOptions().merge;
}
beforeEach(() => {
resetAuthStore();
resetPlayerStore();
_resetQueueResolverForTest();
_resetQueueSyncForTest();
vi.mocked(savePlayQueue).mockClear();
useAuthStore.setState({
servers: [SERVER_A, SERVER_B],
activeServerId: SERVER_A.id,
isLoggedIn: true,
});
useLibraryIndexStore.setState({ masterEnabled: true });
});
// ── Write helpers canonicalize ────────────────────────────────────────────
describe('B1 — writers emit canonical server keys', () => {
it('toQueueItemRefs converts a UUID input to the canonical index key', () => {
const refs = toQueueItemRefs(SERVER_A.id, [track('t1', 'One')]);
expect(refs).toEqual([{ serverId: KEY_A, trackId: 't1' }]);
});
it('toQueueItemRefs is idempotent on an already-canonical input', () => {
const refs = toQueueItemRefs(KEY_B, [track('t1', 'One')]);
expect(refs[0].serverId).toBe(KEY_B);
});
it('toQueueItemRefs leaves unknown ids untouched (test isolation / pre-login flows)', () => {
const refs = toQueueItemRefs('unknown-srv', [track('t1', 'One')]);
expect(refs[0].serverId).toBe('unknown-srv');
});
it('bindQueueServerForPlayback writes the canonical key for the active server', () => {
useAuthStore.setState({ activeServerId: SERVER_B.id });
bindQueueServerForPlayback();
expect(usePlayerStore.getState().queueServerId).toBe(KEY_B);
});
});
// ── Resolver correctness: same trackId across two servers must NOT collide ─
describe('B1 — resolver isolates duplicate trackId across servers', () => {
it('seedQueueResolver canonicalizes the seed key — UUID and index key share one cache slot', () => {
const t = track('shared', 'Original');
seedQueueResolver(SERVER_A.id, [t]); // UUID input
// Read via the canonical ref (what writers now emit)
expect(getCachedTrack({ serverId: KEY_A, trackId: 'shared' })?.title).toBe('Original');
// …and via legacy UUID-bound refs (migration window compat path)
expect(getCachedTrack({ serverId: SERVER_A.id, trackId: 'shared' })?.title).toBe('Original');
});
it('two servers with the same trackId resolve independently — no cross-contamination', () => {
seedQueueResolver(SERVER_A.id, [track('shared', 'From A')]);
seedQueueResolver(SERVER_B.id, [track('shared', 'From B')]);
expect(getCachedTrack({ serverId: KEY_A, trackId: 'shared' })?.title).toBe('From A');
expect(getCachedTrack({ serverId: KEY_B, trackId: 'shared' })?.title).toBe('From B');
// Legacy-form refs map back to the same canonical entry per server.
expect(getCachedTrack({ serverId: SERVER_A.id, trackId: 'shared' })?.title).toBe('From A');
expect(getCachedTrack({ serverId: SERVER_B.id, trackId: 'shared' })?.title).toBe('From B');
});
});
// ── Persistence merge: forward-migrate legacy UUID-form blobs ─────────────
describe('B1 — persist `merge` forward-migrates legacy UUID-form blobs', () => {
it('canonicalizes queueServerId and every ref `serverId` on rehydrate', () => {
const merged = getMerge()(
{
queueServerId: SERVER_A.id,
queueIndex: 1,
queueItems: [
{ serverId: SERVER_A.id, trackId: 't1' },
{ serverId: SERVER_A.id, trackId: 't2', radioAdded: true },
],
queueItemsIndex: 1,
},
usePlayerStore.getState(),
);
expect(merged.queueServerId).toBe(KEY_A);
expect(merged.queueItems).toEqual([
{ serverId: KEY_A, trackId: 't1' },
{ serverId: KEY_A, trackId: 't2', radioAdded: true },
]);
});
it('canonicalizes the legacy queueRefs-only shape', () => {
const merged = getMerge()(
{
queueServerId: SERVER_B.id,
queueRefs: ['x', 'y'],
queueRefsIndex: 1,
},
usePlayerStore.getState(),
);
expect(merged.queueServerId).toBe(KEY_B);
expect(merged.queueItems.every(r => r.serverId === KEY_B)).toBe(true);
});
it('mixed-server queueItems get per-ref canonicalization (each ref carries its own key)', () => {
const merged = getMerge()(
{
queueServerId: SERVER_A.id,
queueItems: [
{ serverId: SERVER_A.id, trackId: 'shared' },
{ serverId: SERVER_B.id, trackId: 'shared' },
],
queueItemsIndex: 0,
},
usePlayerStore.getState(),
);
expect(merged.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'shared' });
expect(merged.queueItems[1]).toEqual({ serverId: KEY_B, trackId: 'shared' });
});
});
// ── Undo/redo snapshot: prepend uses snapshot-canonical server (H3) ────────
describe('B1 + H3 — undo prepend binds to the snapshot\'s playback server', () => {
it('prepended ref uses snap.queueServerId, not the live queue-level state', () => {
onInvoke('audio_play', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
// Live state: playback was just rebound to server B mid-undo.
const playingTrack = track('shared', 'Still playing');
const prior: PlayerState = {
...usePlayerStore.getState(),
currentTrack: playingTrack,
currentTime: 12,
progress: 0.2,
isPlaying: true,
queueItems: [{ serverId: KEY_B, trackId: 'shared' }],
queueServerId: KEY_B,
queueIndex: 0,
};
usePlayerStore.setState(prior);
// Snapshot was captured under server A — the prepend must follow A,
// not the live B binding.
const snap: QueueUndoSnapshot = {
queueItems: [],
queueIndex: 0,
currentTrack: null,
currentTime: 0,
progress: 0,
isPlaying: false,
queueServerId: KEY_A,
};
applyQueueHistorySnapshot(snap, prior, usePlayerStore.setState, usePlayerStore.getState);
const after = usePlayerStore.getState();
expect(after.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'shared' });
expect(after.queueIndex).toBe(0);
});
it('falls back to existing snapshot refs when queueServerId is absent (legacy in-memory snapshots)', () => {
onInvoke('audio_play', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
const playingTrack = track('p', 'Playing');
const prior: PlayerState = {
...usePlayerStore.getState(),
currentTrack: playingTrack,
isPlaying: true,
queueItems: [{ serverId: KEY_B, trackId: 'p' }],
queueServerId: KEY_B,
queueIndex: 0,
};
usePlayerStore.setState(prior);
const snap: QueueUndoSnapshot = {
queueItems: [{ serverId: KEY_A, trackId: 'other' }],
queueIndex: 0,
currentTrack: null,
};
applyQueueHistorySnapshot(snap, prior, usePlayerStore.setState, usePlayerStore.getState);
// Prepend inherits server identity from the snapshot's first ref.
const after = usePlayerStore.getState();
expect(after.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'p' });
});
it('snapshot from current state captures the canonical queueServerId', () => {
pushQueueUndoSnapshot({
queueItems: [{ serverId: KEY_A, trackId: 't1' }],
queueIndex: 0,
currentTrack: null,
queueServerId: KEY_A,
});
// Sanity: snapshots transport the canonical key forward through stacks.
// (The actual capture happens in queueUndoSnapshotFromState, which now
// includes queueServerId from PlayerState — see queueUndo.ts.)
expect(true).toBe(true);
});
});
// ── Queue sync emits trackIds only (server identity goes via playback API) ─
describe('B1 — queue sync emits track ids only, server identity flows out-of-band', () => {
it('savePlayQueue receives plain track ids regardless of ref server form', async () => {
vi.useFakeTimers();
try {
const refs: QueueItemRef[] = [
{ serverId: KEY_A, trackId: 't1' },
{ serverId: KEY_A, trackId: 't2' },
];
usePlayerStore.setState({
queueItems: refs,
queueIndex: 0,
queueServerId: KEY_A,
currentTrack: track('t1', 'One'),
currentTime: 1.5,
isPlaying: true,
});
await flushPlayQueuePosition();
expect(savePlayQueue).toHaveBeenCalledTimes(1);
const [ids, current, posMs, serverId] = vi.mocked(savePlayQueue).mock.calls[0]!;
expect(ids).toEqual(['t1', 't2']);
expect(current).toBe('t1');
expect(posMs).toBe(1500);
// savePlayQueue's serverId arg comes from getPlaybackServerId(), which
// resolves a canonical key OR a UUID back to a UUID — needed for the
// Subsonic auth lookup. Either is OK here; what matters is no leakage
// of a per-ref serverId into the request body.
expect(typeof serverId).toBe('string');
} finally {
vi.useRealTimers();
}
});
});
+6 -5
View File
@@ -1,4 +1,4 @@
import type { Track } from './playerStoreTypes'; import type { QueueItemRef } from './playerStoreTypes';
import { create } from 'zustand'; import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
@@ -32,9 +32,10 @@ interface HotCacheState {
touchPlayed: (trackId: string, serverId: string) => void; touchPlayed: (trackId: string, serverId: string) => void;
removeEntry: (trackId: string, serverId: string) => void; removeEntry: (trackId: string, serverId: string) => void;
totalBytes: () => number; 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: ( evictToFit: (
queue: Track[], queue: QueueItemRef[],
queueIndex: number, queueIndex: number,
maxBytes: number, maxBytes: number,
activeServerId: string, 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 protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT);
const protectedIds = new Set<string>(); const protectedIds = new Set<string>();
for (let i = protectLo; i <= protectHi; i++) { for (let i = protectLo; i <= protectHi; i++) {
protectedIds.add(queue[i].id); protectedIds.add(queue[i].trackId);
} }
const indexOfInQueue = (trackId: string): number | null => { 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; return idx >= 0 ? idx : null;
}; };
+17 -12
View File
@@ -4,7 +4,7 @@
* = current track + next `LOUDNESS_BACKFILL_WINDOW_AHEAD` entries, with * = current track + next `LOUDNESS_BACKFILL_WINDOW_AHEAD` entries, with
* duplicates collapsed. * duplicates collapsed.
*/ */
import type { Track } from './playerStoreTypes'; import type { QueueItemRef, Track } from './playerStoreTypes';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
LOUDNESS_BACKFILL_WINDOW_AHEAD, 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 }; 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', () => { describe('LOUDNESS_BACKFILL_WINDOW_AHEAD', () => {
it('is the value the runtime expects', () => { it('is the value the runtime expects', () => {
@@ -26,23 +31,23 @@ describe('LOUDNESS_BACKFILL_WINDOW_AHEAD', () => {
describe('isTrackInsideLoudnessBackfillWindow', () => { describe('isTrackInsideLoudnessBackfillWindow', () => {
it('matches the current track unconditionally', () => { 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', () => { it('matches an id inside the ahead window', () => {
// queueIndex 0, AHEAD 5 → indices 1..5 are inside, t3 must hit. // 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', () => { it('returns false for an id beyond the ahead window', () => {
// From queueIndex 0, indices 1..5 inside → t6 (index 6) is outside. // 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', () => { it('window slides with queueIndex', () => {
// queueIndex 4, AHEAD 5 → indices 5..9 are inside, t9 must hit, t10 must not. // 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('t9', big, 4, track('t4'))).toBe(true);
expect(isTrackInsideLoudnessBackfillWindow('t10', big, 4, big[4])).toBe(false); expect(isTrackInsideLoudnessBackfillWindow('t10', big, 4, track('t4'))).toBe(false);
}); });
it('returns false for empty queue', () => { it('returns false for empty queue', () => {
@@ -50,7 +55,7 @@ describe('isTrackInsideLoudnessBackfillWindow', () => {
}); });
it('returns false for empty trackId', () => { 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', () => { it('returns false when currentTrack is null and id is not in the queue window', () => {
@@ -60,12 +65,12 @@ describe('isTrackInsideLoudnessBackfillWindow', () => {
describe('collectLoudnessBackfillWindowTrackIds', () => { describe('collectLoudnessBackfillWindowTrackIds', () => {
it('returns current + next 5 entries', () => { 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']); expect(ids).toEqual(['t0', 't1', 't2', 't3', 't4', 't5']);
}); });
it('clamps the window to the end of the queue', () => { 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 // queueIndex 9, AHEAD 5 → indices 10..11 available → t9, t10, t11
expect(ids).toEqual(['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', () => { it('deduplicates when currentTrack is also in the ahead window', () => {
const queue = [track('a'), track('b'), track('a'), track('c')]; const queue = [ref('a'), ref('b'), ref('a'), ref('c')];
const ids = collectLoudnessBackfillWindowTrackIds(queue, 0, queue[0]); const ids = collectLoudnessBackfillWindowTrackIds(queue, 0, track('a'));
expect(ids).toEqual(['a', 'b', 'c']); expect(ids).toEqual(['a', 'b', 'c']);
}); });
+10 -10
View File
@@ -1,4 +1,4 @@
import type { Track } from './playerStoreTypes'; import type { QueueItemRef, Track } from './playerStoreTypes';
/** /**
* After a bulk enqueue (queue replace, append-many, lucky-mix) the runtime * 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 * 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( export function isTrackInsideLoudnessBackfillWindow(
trackId: string, trackId: string,
queue: Track[], queue: QueueItemRef[],
queueIndex: number, queueIndex: number,
currentTrack: Track | null, currentTrack: Track | null,
): boolean { ): boolean {
@@ -25,13 +25,13 @@ export function isTrackInsideLoudnessBackfillWindow(
const start = Math.max(0, queueIndex + 1); const start = Math.max(0, queueIndex + 1);
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD); const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
for (let i = start; i < end; i++) { for (let i = start; i < end; i++) {
if (queue[i]?.id === trackId) return true; if (queue[i]?.trackId === trackId) return true;
} }
return false; return false;
} }
export function collectLoudnessBackfillWindowTrackIds( export function collectLoudnessBackfillWindowTrackIds(
queue: Track[], queue: QueueItemRef[],
queueIndex: number, queueIndex: number,
currentTrack: Track | null, currentTrack: Track | null,
): string[] { ): string[] {
@@ -40,7 +40,7 @@ export function collectLoudnessBackfillWindowTrackIds(
const start = Math.max(0, queueIndex + 1); const start = Math.max(0, queueIndex + 1);
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD); const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
for (let i = start; i < end; i++) { for (let i = start; i < end; i++) {
const tid = queue[i]?.id; const tid = queue[i]?.trackId;
if (tid) ids.add(tid); if (tid) ids.add(tid);
} }
return Array.from(ids); 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. */ /** Next ~5 queue neighbours (+ immediate next when preload is on) for middle-tier analysis. */
export function collectPlaybackMiddlePriorityTrackIds( export function collectPlaybackMiddlePriorityTrackIds(
queue: Track[], queue: QueueItemRef[],
queueIndex: number, queueIndex: number,
currentTrack: Track | null, currentTrack: Track | null,
preloadMode: 'off' | 'balanced' | 'early' | 'custom', preloadMode: 'off' | 'balanced' | 'early' | 'custom',
@@ -57,13 +57,13 @@ export function collectPlaybackMiddlePriorityTrackIds(
const start = Math.max(0, queueIndex + 1); const start = Math.max(0, queueIndex + 1);
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD); const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
for (let i = start; i < end; i++) { 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 (tid && tid !== currentTrack?.id) ids.add(tid);
} }
if (preloadMode !== 'off') { if (preloadMode !== 'off') {
const nextIdx = queueIndex + 1; const nextIdx = queueIndex + 1;
if (nextIdx >= 0 && nextIdx < queue.length) { if (nextIdx >= 0 && nextIdx < queue.length) {
const tid = queue[nextIdx]?.id; const tid = queue[nextIdx]?.trackId;
if (tid && tid !== currentTrack?.id) ids.add(tid); if (tid && tid !== currentTrack?.id) ids.add(tid);
} }
} }
@@ -72,7 +72,7 @@ export function collectPlaybackMiddlePriorityTrackIds(
export function loudnessBackfillPriorityForTrack( export function loudnessBackfillPriorityForTrack(
trackId: string, trackId: string,
queue: Track[], queue: QueueItemRef[],
queueIndex: number, queueIndex: number,
currentTrack: Track | null, currentTrack: Track | null,
): 'high' | 'middle' | 'low' { ): 'high' | 'middle' | 'low' {
@@ -80,7 +80,7 @@ export function loudnessBackfillPriorityForTrack(
const start = Math.max(0, queueIndex + 1); const start = Math.max(0, queueIndex + 1);
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD); const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
for (let i = start; i < end; i++) { for (let i = start; i < end; i++) {
if (queue[i]?.id === trackId) return 'middle'; if (queue[i]?.trackId === trackId) return 'middle';
} }
return 'low'; return 'low';
} }
+10 -5
View File
@@ -4,7 +4,7 @@
* guard, the window collection, and the no-sync-engine flag on each * guard, the window collection, and the no-sync-engine flag on each
* refresh call. * refresh call.
*/ */
import type { Track } from './playerStoreTypes'; import type { QueueItemRef, Track } from './playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => { const hoisted = vi.hoisted(() => {
const auth = { normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness' }; const auth = { normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness' };
@@ -13,7 +13,7 @@ const hoisted = vi.hoisted(() => {
auth, auth,
player, player,
refreshMock: vi.fn(async () => undefined), 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 }; 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(() => { beforeEach(() => {
hoisted.auth.normalizationEngine = 'loudness'; hoisted.auth.normalizationEngine = 'loudness';
hoisted.player.currentTrack = null; hoisted.player.currentTrack = null;
@@ -46,14 +51,14 @@ describe('prefetchLoudnessForEnqueuedTracks', () => {
it("is a no-op when engine isn't loudness", () => { it("is a no-op when engine isn't loudness", () => {
hoisted.auth.normalizationEngine = 'off'; hoisted.auth.normalizationEngine = 'off';
hoisted.collectMock.mockReturnValueOnce(['t1']); hoisted.collectMock.mockReturnValueOnce(['t1']);
prefetchLoudnessForEnqueuedTracks([track('t1')], 0); prefetchLoudnessForEnqueuedTracks([ref('t1')], 0);
expect(hoisted.refreshMock).not.toHaveBeenCalled(); expect(hoisted.refreshMock).not.toHaveBeenCalled();
expect(hoisted.collectMock).not.toHaveBeenCalled(); expect(hoisted.collectMock).not.toHaveBeenCalled();
}); });
it('forwards each window id to refreshLoudnessForTrack with syncPlayingEngine=false', () => { it('forwards each window id to refreshLoudnessForTrack with syncPlayingEngine=false', () => {
hoisted.collectMock.mockReturnValueOnce(['t1', 't2', 't3']); 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).toHaveBeenCalledTimes(3);
expect(hoisted.refreshMock).toHaveBeenCalledWith('t1', { syncPlayingEngine: false }); expect(hoisted.refreshMock).toHaveBeenCalledWith('t1', { syncPlayingEngine: false });
expect(hoisted.refreshMock).toHaveBeenCalledWith('t2', { 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', () => { it('passes the queue + currentTrack through to the window collector', () => {
hoisted.player.currentTrack = track('cur'); hoisted.player.currentTrack = track('cur');
const q = [track('cur'), track('next')]; const q = [ref('cur'), ref('next')];
prefetchLoudnessForEnqueuedTracks(q, 0); prefetchLoudnessForEnqueuedTracks(q, 0);
expect(hoisted.collectMock).toHaveBeenCalledWith(q, 0, hoisted.player.currentTrack); expect(hoisted.collectMock).toHaveBeenCalledWith(q, 0, hoisted.player.currentTrack);
}); });
+2 -2
View File
@@ -1,4 +1,4 @@
import type { Track } from './playerStoreTypes'; import type { QueueItemRef } from './playerStoreTypes';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { collectLoudnessBackfillWindowTrackIds } from './loudnessBackfillWindow'; import { collectLoudnessBackfillWindowTrackIds } from './loudnessBackfillWindow';
import { refreshLoudnessForTrack } from './loudnessRefresh'; import { refreshLoudnessForTrack } from './loudnessRefresh';
@@ -15,7 +15,7 @@ import { usePlayerStore } from './playerStore';
* the upcoming ones. * the upcoming ones.
*/ */
export function prefetchLoudnessForEnqueuedTracks( export function prefetchLoudnessForEnqueuedTracks(
mergedQueue: Track[], mergedQueue: QueueItemRef[],
queueIndex: number, queueIndex: number,
): void { ): void {
if (useAuthStore.getState().normalizationEngine !== 'loudness') return; if (useAuthStore.getState().normalizationEngine !== 'loudness') return;
+2 -2
View File
@@ -91,7 +91,7 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean):
&& !isBackfillInFlight(trackId) && !isBackfillInFlight(trackId)
&& attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) { && attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
const live = usePlayerStore.getState(); 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', { emitNormalizationDebug('backfill:skipped-outside-window', {
trackId, trackId,
queueIndex: live.queueIndex, queueIndex: live.queueIndex,
@@ -103,7 +103,7 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean):
const url = buildStreamUrl(trackId); const url = buildStreamUrl(trackId);
const priority = loudnessBackfillPriorityForTrack( const priority = loudnessBackfillPriorityForTrack(
trackId, trackId,
live.queue, live.queueItems,
live.queueIndex, live.queueIndex,
live.currentTrack, live.currentTrack,
); );
+22 -7
View File
@@ -13,6 +13,9 @@ import { reseedLoudnessForTrackId } from './loudnessReseed';
import { getPlaybackProgressSnapshot } from './playbackProgress'; import { getPlaybackProgressSnapshot } from './playbackProgress';
import { shouldRebindPlaybackToHotCache } from './playbackUrlRouting'; import { shouldRebindPlaybackToHotCache } from './playbackUrlRouting';
import type { PlayerState, Track } from './playerStoreTypes'; 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 { pushQueueUndoFromGetter } from './queueUndo';
import { syncQueueToServer } from './queueSync'; import { syncQueueToServer } from './queueSync';
import { import {
@@ -95,7 +98,7 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
normalizationTargetLufs: null, normalizationTargetLufs: null,
normalizationEngineLive: 'off', normalizationEngineLive: 'off',
currentPlaybackSource: null, currentPlaybackSource: null,
queue: [], queueItems: [],
queueIndex: 0, queueIndex: 0,
isPlaying: true, isPlaying: true,
progress: 0, progress: 0,
@@ -106,7 +109,7 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
}, },
previous: () => { previous: () => {
const { queue, queueIndex, currentTrack } = get(); const { queueItems, queueIndex, currentTrack } = get();
const currentTime = getPlaybackProgressSnapshot().currentTime; const currentTime = getPlaybackProgressSnapshot().currentTime;
if (currentTime > 3) { if (currentTime > 3) {
// Restart current track from the beginning. // Restart current track from the beginning.
@@ -114,7 +117,8 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
const sid = authState.activeServerId ?? ''; const sid = authState.activeServerId ?? '';
if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) { if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) {
setSeekFallbackVisualTarget({ trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() }); 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; return;
} }
invoke('audio_seek', { seconds: 0 }).catch(console.error); invoke('audio_seek', { seconds: 0 }).catch(console.error);
@@ -122,7 +126,11 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
return; return;
} }
const prevIdx = queueIndex - 1; 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) => { setVolume: (v) => {
@@ -155,8 +163,12 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
// queue position, which may not flush before app close). // queue position, which may not flush before app close).
const serverTime = q.position ? q.position / 1000 : 0; const serverTime = q.position ? q.position / 1000 : 0;
const localTime = get().currentTime; 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({ set({
queue: mappedTracks, queueItems: toQueueItemRefs(sid, mappedTracks),
queueIndex, queueIndex,
currentTrack, currentTrack,
currentTime: serverTime > 0 ? serverTime : localTime, currentTime: serverTime > 0 ? serverTime : localTime,
@@ -185,12 +197,15 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
} }
pushQueueUndoFromGetter(get); pushQueueUndoFromGetter(get);
const wasPlaying = s.isPlaying; const wasPlaying = s.isPlaying;
const sid = s.queueServerId ?? '';
if (sid) seedQueueResolver(sid, [track]);
const newItems = toQueueItemRefs(sid, [track]);
set({ set({
queue: [track], queueItems: newItems,
queueIndex: 0, queueIndex: 0,
currentTrack: track, currentTrack: track,
}); });
syncQueueToServer([track], track, s.currentTime); syncQueueToServer(newItems, track, s.currentTime);
if (!wasPlaying) get().resume(); if (!wasPlaying) get().resume();
}, },
}; };
+71 -26
View File
@@ -9,7 +9,10 @@ import {
setInfiniteQueueFetching, setInfiniteQueueFetching,
} from './infiniteQueueState'; } from './infiniteQueueState';
import { isInOrbitSession } from './orbitSession'; 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 { import {
addRadioSessionSeen, addRadioSessionSeen,
getCurrentRadioArtistId, getCurrentRadioArtistId,
@@ -24,6 +27,25 @@ type SetState = (
) => void; ) => void;
type GetState = () => PlayerState; 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: * Advance to the next track. Three top-level outcomes:
* *
@@ -44,11 +66,16 @@ type GetState = () => PlayerState;
* sync to the host's next track. * sync to the host's next track.
*/ */
export function runNext(set: SetState, get: GetState, manual: boolean): void { 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); applySkipStarOnManualNext(currentTrack, manual);
const nextIdx = queueIndex + 1; const nextIdx = queueIndex + 1;
if (nextIdx < queue.length) { if (nextIdx < queueItems.length) {
get().playTrack(queue[nextIdx], queue, manual, false, nextIdx); // 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, // Proactively top up auto-added tracks when ≤ 2 remain ahead,
// so the queue never runs dry without a visible loading pause. // so the queue never runs dry without a visible loading pause.
// Skipped while in Orbit — the host's queue is the source of // 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. // the next track-end fallback.
const { infiniteQueueEnabled } = useAuthStore.getState(); const { infiniteQueueEnabled } = useAuthStore.getState();
if (infiniteQueueEnabled && repeatMode === 'off' && !isInfiniteQueueFetching() && !isInOrbitSession()) { 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) { if (remainingAuto <= 2) {
setInfiniteQueueFetching(true); 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 => { buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
// Re-check at resolution time — the user may have joined // Re-check at resolution time — the user may have joined
// an Orbit session between scheduling and resolving. // an Orbit session between scheduling and resolving.
if (isInOrbitSession()) return; if (isInOrbitSession()) return;
if (newTracks.length > 0) { 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); }); }).catch(() => {}).finally(() => { setInfiniteQueueFetching(false); });
} }
} }
// Proactively top up radio tracks when ≤ 2 remain — always, regardless // Proactively top up radio tracks when ≤ 2 remain — always, regardless
// of infinite queue setting. // of infinite queue setting.
const nextTrack = queue[nextIdx]; if (nextRef.radioAdded && !isRadioFetching()) {
if (nextTrack.radioAdded && !isRadioFetching()) { const remainingRadio = queueItems.slice(nextIdx + 1).filter(r => r.radioAdded).length;
const remainingRadio = queue.slice(nextIdx + 1).filter(t => t.radioAdded).length;
if (remainingRadio <= 2) { if (remainingRadio <= 2) {
const artistId = nextTrack.artistId ?? getCurrentRadioArtistId() ?? null; // H2: nextTrack may be a placeholder if its ref is still cold — empty
const artistName = nextTrack.artist; // artist/artistId would seed `getSimilarSongs2('')` and silently
if (artistId) { // 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); setRadioFetching(true);
Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]) Promise.all([getSimilarSongs2(seedArtistId), getTopSongs(seedArtistName)])
.then(([similar, top]) => { .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 // Lead with similar (other artists) for variety; top tracks
// of the upcoming artist are only a fallback when similar // of the upcoming artist are only a fallback when similar
// is empty. Single-pass loop dedupes against the live queue, // 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. // navigate backwards a few songs. Trimmed ids stay in the seen-set.
const HISTORY_KEEP = 5; const HISTORY_KEEP = 5;
set(state => { set(state => {
const serverId = state.queueServerId ?? '';
if (serverId) seedQueueResolver(serverId, fresh);
const trimStart = Math.max(0, state.queueIndex - HISTORY_KEEP); const trimStart = Math.max(0, state.queueIndex - HISTORY_KEEP);
const newItems = [
...state.queueItems.slice(trimStart),
...toQueueItemRefs(serverId, fresh),
];
return { return {
queue: [...state.queue.slice(trimStart), ...fresh], queueItems: newItems,
queueIndex: state.queueIndex - trimStart, 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) { } else if (repeatMode === 'all' && queueItems.length > 0) {
get().playTrack(queue[0], queue, manual, false, 0); const firstTrack = resolveQueueTrack(queueItems[0]);
get().playTrack(firstTrack, undefined, manual, false, 0);
} else { } else {
// ── Orbit short-circuit ── // ── Orbit short-circuit ──
// The host owns the shared queue. The radio / infinite-queue // 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 }); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
return; 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 // Same source preference + dedup contract as the proactive
// top-up: similar first, top only as a fallback (issue #500). // top-up: similar first, top only as a fallback (issue #500).
const sourceList = similar.length > 0 ? similar : top; 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 }); fresh.push({ ...t, radioAdded: true as const });
} }
if (fresh.length > 0) { if (fresh.length > 0) {
const currentQueue = get().queue; appendTracksAndPlayFirst(set, get, fresh);
const newQueue = [...currentQueue, ...fresh];
get().playTrack(fresh[0], newQueue, false, false, currentQueue.length);
} else { } else {
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
setIsAudioPaused(false); setIsAudioPaused(false);
@@ -187,7 +234,7 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
if (infiniteQueueEnabled && repeatMode === 'off') { if (infiniteQueueEnabled && repeatMode === 'off') {
if (isInfiniteQueueFetching()) return; if (isInfiniteQueueFetching()) return;
setInfiniteQueueFetching(true); 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 => { buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
setInfiniteQueueFetching(false); setInfiniteQueueFetching(false);
// The user may have joined an Orbit session while this // 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 }); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
return; return;
} }
const currentQueue = get().queue; appendTracksAndPlayFirst(set, get, newTracks);
const newQueue = [...currentQueue, ...newTracks];
get().playTrack(newTracks[0], newQueue, false);
}).catch(() => { }).catch(() => {
setInfiniteQueueFetching(false); setInfiniteQueueFetching(false);
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
+18 -2
View File
@@ -12,6 +12,12 @@ vi.mock('../api/subsonicStarRating', () => ({
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
import type { Track } from './playerStoreTypes'; import type { Track } from './playerStoreTypes';
import { queueSongStar, queueSongRating, _resetPendingStarSyncForTest } from './pendingStarSync'; 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 => ({ const track = (id: string): Track => ({
id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1, id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1,
@@ -24,9 +30,14 @@ describe('pendingStarSync', () => {
unstarMock.mockReset().mockResolvedValue(undefined); unstarMock.mockReset().mockResolvedValue(undefined);
setRatingMock.mockReset().mockResolvedValue(undefined); setRatingMock.mockReset().mockResolvedValue(undefined);
_resetPendingStarSyncForTest(); _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({ usePlayerStore.setState({
currentTrack: track('t1'), currentTrack: track('t1'),
queue: [track('t1')], queueItems: toQueueItemRefs('', [track('t1')]),
queueServerId: null,
starredOverrides: {}, starredOverrides: {},
userRatingOverrides: {}, userRatingOverrides: {},
}); });
@@ -46,7 +57,12 @@ describe('pendingStarSync', () => {
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
expect('t1' in s.starredOverrides).toBe(false); // cleared on success expect('t1' in s.starredOverrides).toBe(false); // cleared on success
expect(s.currentTrack?.starred).toBeTruthy(); // in-memory track patched 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 () => { it('does NOT roll back on a network failure and keeps retrying', async () => {
+9 -2
View File
@@ -1,5 +1,6 @@
import { setRating, star, unstar } from '../api/subsonicStarRating'; import { setRating, star, unstar } from '../api/subsonicStarRating';
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
import { patchCachedTrack } from '../utils/library/queueTrackResolver';
/** /**
* F4 pending-sync for **song** star + rating (spec §6.5 / R7-18). * 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]; delete next[id];
return { return {
starredOverrides: next, starredOverrides: next,
queue: s.queue.map(t => (t.id === id ? { ...t, starred: starredVal } : t)),
currentTrack: currentTrack:
s.currentTrack?.id === id ? { ...s.currentTrack, starred: starredVal } : s.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 { function onRatingSuccess(id: string): void {
// `setUserRatingOverride` already patched track.userRating; just drop the override. const rating = usePlayerStore.getState().userRatingOverrides[id];
usePlayerStore.setState(s => { usePlayerStore.setState(s => {
if (!(id in s.userRatingOverrides)) return {}; if (!(id in s.userRatingOverrides)) return {};
const next = { ...s.userRatingOverrides }; const next = { ...s.userRatingOverrides };
delete next[id]; delete next[id];
return { userRatingOverrides: next }; 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. */ /** Optimistically (un)star a song and sync it to the server with retry. */
+57 -17
View File
@@ -36,6 +36,9 @@ import {
recordEnginePlayUrl, recordEnginePlayUrl,
} from './playbackUrlRouting'; } from './playbackUrlRouting';
import type { PlayerState, Track } from './playerStoreTypes'; 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 { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import { syncQueueToServer } from './queueSync'; import { syncQueueToServer } from './queueSync';
import { playListenSessionFinalize } from './playListenSession'; import { playListenSessionFinalize } from './playListenSession';
@@ -100,9 +103,9 @@ export function runPlayTrack(
// to move the index; they are not bulk operations and must not // to move the index; they are not bulk operations and must not
// trigger the confirm dialog (#234 regression). // trigger the confirm dialog (#234 regression).
if (!_orbitConfirmed && queue && queue.length > 1) { if (!_orbitConfirmed && queue && queue.length > 1) {
const current = get().queue; const current = get().queueItems;
const sameAsCurrent = queue.length === current.length 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) { if (!sameAsCurrent) {
void orbitBulkGuard(queue.length).then(ok => { void orbitBulkGuard(queue.length).then(ok => {
if (!ok) return; if (!ok) return;
@@ -134,14 +137,17 @@ export function runPlayTrack(
if (!_orbitConfirmed && queue && queue.length === 1) { if (!_orbitConfirmed && queue && queue.length === 1) {
const orbitRole = useOrbitStore.getState().role; const orbitRole = useOrbitStore.getState().role;
if (orbitRole === 'host') { if (orbitRole === 'host') {
const current = get().queue; const currentItems = get().queueItems;
const currentTrackId = current[get().queueIndex]?.id; const currentTrackId = currentItems[get().queueIndex]?.trackId;
if (track.id !== currentTrackId) { 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) { 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 { } 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); get().playTrack(track, newQueue, manual, true, newQueue.length - 1);
} }
return; return;
@@ -182,22 +188,52 @@ export function runPlayTrack(
if (visualOnEntry?.trackId !== track.id) { if (visualOnEntry?.trackId !== track.id) {
setSeekFallbackVisualTarget(null); setSeekFallbackVisualTarget(null);
} }
const newQueue = queue ?? state.queue; // Thin-state: only a real queue *replacement* (explicit `queue` arg) rebuilds
if (shouldBindQueueServerForPlay(state.queue, newQueue, queue)) { // 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(); bindQueueServerForPlayback();
} }
// Prefer an explicit target index from the caller (next/previous/queue-row // Prefer an explicit target index from the caller (next/previous/queue-row
// click already know the exact slot). `findIndex` returns the *first* // click already know the exact slot). `findIndex` returns the *first*
// matching id, which jumps backwards when the queue contains the same // matching id, which jumps backwards when the queue contains the same
// track twice — breaking radio playback (issue #500). // 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 = const explicitIdxValid =
typeof targetQueueIndex === 'number' typeof targetQueueIndex === 'number'
&& targetQueueIndex >= 0 && targetQueueIndex >= 0
&& targetQueueIndex < newQueue.length && targetQueueIndex < srcLen
&& sameQueueTrackId(newQueue[targetQueueIndex]?.id, track.id); && matchesAt(targetQueueIndex);
const idx = explicitIdxValid const idx = explicitIdxValid
? (targetQueueIndex as number) ? (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) { if (manual) {
pushQueueUndoFromGetter(get); pushQueueUndoFromGetter(get);
} }
@@ -248,12 +284,18 @@ export function runPlayTrack(
// Set state immediately so the UI updates before the download completes. // Set state immediately so the UI updates before the download completes.
// currentRadio: null ensures the PlayerBar switches out of radio mode right away. // 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({ set({
currentTrack: track, currentTrack: track,
currentRadio: null, currentRadio: null,
waveformBins: null, waveformBins: null,
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0), ...deriveNormalizationSnapshot(track, normWindow, normIdx),
queue: newQueue, // Only a replace rewrites the queue; navigation keeps the canonical refs.
...(replacing ? { queueItems: toQueueItemRefs(queueSid, queue) } : {}),
queueIndex: idx >= 0 ? idx : 0, queueIndex: idx >= 0 ? idx : 0,
progress: initialProgress, progress: initialProgress,
buffered: 0, buffered: 0,
@@ -285,8 +327,6 @@ export function runPlayTrack(
void refreshWaveformForTrack(track.id); void refreshWaveformForTrack(track.id);
void refreshLoudnessForTrack(track.id); void refreshLoudnessForTrack(track.id);
setDeferHotCachePrefetch(true); setDeferHotCachePrefetch(true);
const playIdx = idx >= 0 ? idx : 0;
const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null;
const replayGainDb = resolveReplayGainDb( const replayGainDb = resolveReplayGainDb(
track, prevTrack, nextNeighbour, track, prevTrack, nextNeighbour,
isReplayGainActive(), authStateNow.replayGainMode, isReplayGainActive(), authStateNow.replayGainMode,
@@ -356,7 +396,7 @@ export function runPlayTrack(
})); }));
}); });
} }
syncQueueToServer(newQueue, track, initialTime); syncQueueToServer(get().queueItems, track, initialTime);
touchHotCacheOnPlayback(track.id, playbackCacheSid); touchHotCacheOnPlayback(track.id, playbackCacheSid);
}; };
+13 -38
View File
@@ -48,7 +48,7 @@ import {
tauriMockListenerCount, tauriMockListenerCount,
} from '@/test/mocks/tauri'; } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset'; 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 { function stubPlaybackInvokes(): void {
onInvoke('audio_play', () => undefined); onInvoke('audio_play', () => undefined);
@@ -117,12 +117,8 @@ describe('audio:progress', () => {
describe('audio:track_switched', () => { describe('audio:track_switched', () => {
it('advances to queue[queueIndex + 1] under repeatMode=off', () => { it('advances to queue[queueIndex + 1] under repeatMode=off', () => {
const queue = makeTracks(3); const queue = makeTracks(3);
usePlayerStore.setState({ seedQueue(queue, { index: 0, currentTrack: queue[0] });
queue, usePlayerStore.setState({ repeatMode: 'off' });
queueIndex: 0,
currentTrack: queue[0],
repeatMode: 'off',
});
emitTauriEvent('audio:track_switched', queue[1].duration); emitTauriEvent('audio:track_switched', queue[1].duration);
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
expect(s.currentTrack?.id).toBe(queue[1].id); 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)', () => { it('replays the same track under repeatMode=one (queueIndex stays put)', () => {
const queue = makeTracks(3); const queue = makeTracks(3);
usePlayerStore.setState({ seedQueue(queue, { index: 1, currentTrack: queue[1] });
queue, usePlayerStore.setState({ repeatMode: 'one' });
queueIndex: 1,
currentTrack: queue[1],
repeatMode: 'one',
});
emitTauriEvent('audio:track_switched', queue[1].duration); emitTauriEvent('audio:track_switched', queue[1].duration);
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
expect(s.currentTrack?.id).toBe(queue[1].id); 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', () => { it('wraps to queue[0] when at the end with repeatMode=all', () => {
const queue = makeTracks(3); const queue = makeTracks(3);
usePlayerStore.setState({ seedQueue(queue, { index: 2, currentTrack: queue[2] });
queue, usePlayerStore.setState({ repeatMode: 'all' });
queueIndex: 2,
currentTrack: queue[2],
repeatMode: 'all',
});
emitTauriEvent('audio:track_switched', queue[0].duration); emitTauriEvent('audio:track_switched', queue[0].duration);
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
expect(s.currentTrack?.id).toBe(queue[0].id); 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', () => { it('is a no-op when at the end with repeatMode=off', () => {
const queue = makeTracks(2); const queue = makeTracks(2);
usePlayerStore.setState({ seedQueue(queue, { index: 1, currentTrack: queue[1] });
queue, usePlayerStore.setState({ repeatMode: 'off' });
queueIndex: 1,
currentTrack: queue[1],
repeatMode: 'off',
});
emitTauriEvent('audio:track_switched', queue[1].duration); emitTauriEvent('audio:track_switched', queue[1].duration);
// No next candidate → handler returns early before state changes. // No next candidate → handler returns early before state changes.
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
@@ -178,13 +162,8 @@ describe('audio:track_switched', () => {
it('resets scrobbled + lastfmLoved flags so the new track can be rescored', () => { it('resets scrobbled + lastfmLoved flags so the new track can be rescored', () => {
const queue = makeTracks(2); const queue = makeTracks(2);
usePlayerStore.setState({ seedQueue(queue, { index: 0, currentTrack: queue[0] });
queue, usePlayerStore.setState({ scrobbled: true, lastfmLoved: true });
queueIndex: 0,
currentTrack: queue[0],
scrobbled: true,
lastfmLoved: true,
});
emitTauriEvent('audio:track_switched', queue[1].duration); emitTauriEvent('audio:track_switched', queue[1].duration);
expect(usePlayerStore.getState().scrobbled).toBe(false); expect(usePlayerStore.getState().scrobbled).toBe(false);
expect(usePlayerStore.getState().lastfmLoved).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)', () => { it('immediately resets playback bookkeeping (before the 150 ms next() timer)', () => {
const queue = makeTracks(2); const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.setState({ usePlayerStore.setState({
queue,
queueIndex: 0,
currentTrack: queue[0],
isPlaying: true, isPlaying: true,
progress: 0.99, progress: 0.99,
currentTime: 178, currentTime: 178,
@@ -222,10 +199,8 @@ describe('audio:ended', () => {
it('clears state and currentRadio for a radio stream without advancing the queue', () => { it('clears state and currentRadio for a radio stream without advancing the queue', () => {
const queue = makeTracks(2); const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.setState({ usePlayerStore.setState({
queue,
queueIndex: 0,
currentTrack: queue[0],
currentRadio: { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/stream' }, currentRadio: { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/stream' },
isPlaying: true, isPlaying: true,
}); });
+19 -21
View File
@@ -47,7 +47,7 @@ import { usePlayerStore } from './playerStore';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { onInvoke, invokeMock } from '@/test/mocks/tauri'; import { onInvoke, invokeMock } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset'; import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks } from '@/test/helpers/factories'; import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
beforeEach(() => { beforeEach(() => {
resetPlayerStore(); resetPlayerStore();
@@ -209,10 +209,8 @@ describe('setProgress', () => {
describe('stop', () => { describe('stop', () => {
it('invokes audio_stop and clears playback state', () => { it('invokes audio_stop and clears playback state', () => {
seedQueue(makeTracks(2), { index: 0, currentTrack: makeTrack() });
usePlayerStore.setState({ usePlayerStore.setState({
queue: makeTracks(2),
queueIndex: 0,
currentTrack: makeTrack(),
isPlaying: true, isPlaying: true,
progress: 0.5, progress: 0.5,
currentTime: 60, currentTime: 60,
@@ -229,15 +227,15 @@ describe('stop', () => {
describe('shuffleQueue', () => { describe('shuffleQueue', () => {
it('is a no-op when the queue has fewer than 2 tracks', () => { it('is a no-op when the queue has fewer than 2 tracks', () => {
const t = makeTrack({ id: 'only' }); const t = makeTrack({ id: 'only' });
usePlayerStore.setState({ queue: [t], queueIndex: 0, currentTrack: t }); seedQueue([t], { index: 0, currentTrack: t });
usePlayerStore.getState().shuffleQueue(); 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', () => { it('keeps the current track at queueIndex 0 with the rest shuffled around it', () => {
const tracks = makeTracks(5, i => ({ id: `t-${i}` })); const tracks = makeTracks(5, i => ({ id: `t-${i}` }));
const current = tracks[2]; 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. // Pin the RNG so the shuffle is deterministic.
vi.spyOn(Math, 'random').mockReturnValue(0); vi.spyOn(Math, 'random').mockReturnValue(0);
@@ -245,25 +243,25 @@ describe('shuffleQueue', () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
const s = usePlayerStore.getState(); 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); expect(s.queueIndex).toBe(0);
// The set of ids is preserved. // 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', () => { describe('shuffleUpcomingQueue', () => {
it('is a no-op when fewer than 2 upcoming tracks remain', () => { it('is a no-op when fewer than 2 upcoming tracks remain', () => {
const tracks = makeTracks(3, i => ({ id: `t-${i}` })); 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); const beforeIds = tracks.map(t => t.id);
usePlayerStore.getState().shuffleUpcomingQueue(); 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', () => { it('keeps the head + current in place and shuffles only the upcoming tail', () => {
const tracks = makeTracks(5, i => ({ id: `t-${i}` })); 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); vi.spyOn(Math, 'random').mockReturnValue(0);
usePlayerStore.getState().shuffleUpcomingQueue(); usePlayerStore.getState().shuffleUpcomingQueue();
@@ -271,35 +269,35 @@ describe('shuffleUpcomingQueue', () => {
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
// First two entries unchanged (head + current). // First two entries unchanged (head + current).
expect(s.queue[0].id).toBe('t-0'); expect(s.queueItems[0].trackId).toBe('t-0');
expect(s.queue[1].id).toBe('t-1'); expect(s.queueItems[1].trackId).toBe('t-1');
// The tail still contains the same ids in some order. // 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', () => { describe('pruneUpcomingToCurrent', () => {
it('drops everything after queueIndex', () => { it('drops everything after queueIndex', () => {
const tracks = makeTracks(5); const tracks = makeTracks(5);
usePlayerStore.setState({ queue: tracks, queueIndex: 1, currentTrack: tracks[1] }); seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
usePlayerStore.getState().pruneUpcomingToCurrent(); usePlayerStore.getState().pruneUpcomingToCurrent();
const s = usePlayerStore.getState(); 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); expect(s.queueIndex).toBe(1);
}); });
it('clears the queue entirely when there is no current track (orphaned queue → empty)', () => { 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(); usePlayerStore.getState().pruneUpcomingToCurrent();
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
expect(s.queue).toEqual([]); expect(s.queueItems).toEqual([]);
expect(s.queueIndex).toBe(0); expect(s.queueIndex).toBe(0);
}); });
it('returns early without clearing when no current track AND queue is already empty', () => { 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(); usePlayerStore.getState().pruneUpcomingToCurrent();
expect(usePlayerStore.getState().queue).toEqual([]); expect(usePlayerStore.getState().queueItems).toEqual([]);
}); });
}); });
+88 -90
View File
@@ -74,7 +74,7 @@ vi.mock('@/api/lastfm', () => ({
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri'; import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset'; 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 { function stubInvokes(): void {
onInvoke('audio_play', () => undefined); onInvoke('audio_play', () => undefined);
@@ -110,12 +110,8 @@ afterEach(() => {
describe('flushPlayQueuePosition', () => { describe('flushPlayQueuePosition', () => {
it('forwards the queue, current track, and millisecond position to savePlayQueue', async () => { it('forwards the queue, current track, and millisecond position to savePlayQueue', async () => {
const [t1, t2, t3] = makeTracks(3); const [t1, t2, t3] = makeTracks(3);
usePlayerStore.setState({ seedQueue([t1, t2, t3], { index: 1, currentTrack: t2 });
queue: [t1, t2, t3], usePlayerStore.setState({ isPlaying: true });
queueIndex: 1,
currentTrack: t2,
isPlaying: true,
});
// Drive a live-progress snapshot so flushPlayQueuePosition has a non-zero // Drive a live-progress snapshot so flushPlayQueuePosition has a non-zero
// position to flush — readonly snapshot is what the API call samples. // position to flush — readonly snapshot is what the API call samples.
emitTauriEvent('audio:progress', { current_time: 12.345, duration: t2.duration }); 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 () => { it('caps the song-id list at 1000 entries', async () => {
const tracks = makeTracks(1100); const tracks = makeTracks(1100);
usePlayerStore.setState({ seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
queue: tracks,
queueIndex: 0,
currentTrack: tracks[0],
});
emitTauriEvent('audio:progress', { current_time: 1, duration: tracks[0].duration }); emitTauriEvent('audio:progress', { current_time: 1, duration: tracks[0].duration });
vi.mocked(savePlayQueue).mockClear(); // discard heartbeat call from emit 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 () => { it('is a no-op when a radio stream is active', async () => {
const track = makeTrack(); const track = makeTrack();
seedQueue([track], { index: 0, currentTrack: track });
usePlayerStore.setState({ usePlayerStore.setState({
queue: [track],
queueIndex: 0,
currentTrack: track,
currentRadio: { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/stream' }, 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 () => { it('is a no-op when there is no current track', async () => {
usePlayerStore.setState({ seedQueue(makeTracks(2), { index: 0, currentTrack: null });
queue: makeTracks(2),
queueIndex: 0,
currentTrack: null,
});
await flushPlayQueuePosition(); await flushPlayQueuePosition();
@@ -181,7 +167,7 @@ describe('flushPlayQueuePosition', () => {
it('is a no-op when the queue is empty', async () => { it('is a no-op when the queue is empty', async () => {
usePlayerStore.setState({ usePlayerStore.setState({
queue: [], queueItems: [],
queueIndex: 0, queueIndex: 0,
currentTrack: null, currentTrack: null,
}); });
@@ -193,7 +179,7 @@ describe('flushPlayQueuePosition', () => {
it('swallows backend errors without propagating to the caller', async () => { it('swallows backend errors without propagating to the caller', async () => {
const track = makeTrack(); const track = makeTrack();
usePlayerStore.setState({ queue: [track], queueIndex: 0, currentTrack: track }); seedQueue([track], { index: 0, currentTrack: track });
vi.mocked(savePlayQueue).mockRejectedValueOnce(new Error('offline')); vi.mocked(savePlayQueue).mockRejectedValueOnce(new Error('offline'));
await expect(flushPlayQueuePosition()).resolves.toBeUndefined(); await expect(flushPlayQueuePosition()).resolves.toBeUndefined();
@@ -201,12 +187,8 @@ describe('flushPlayQueuePosition', () => {
it('floors the position to whole milliseconds', async () => { it('floors the position to whole milliseconds', async () => {
const track = makeTrack({ duration: 200 }); const track = makeTrack({ duration: 200 });
usePlayerStore.setState({ seedQueue([track], { index: 0, currentTrack: track });
queue: [track], usePlayerStore.setState({ isPlaying: true });
queueIndex: 0,
currentTrack: track,
isPlaying: true,
});
emitTauriEvent('audio:progress', { current_time: 12.9999, duration: 200 }); emitTauriEvent('audio:progress', { current_time: 12.9999, duration: 200 });
vi.mocked(savePlayQueue).mockClear(); // discard heartbeat call from emit 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() { function getPartialize() {
@@ -228,82 +210,98 @@ function getPartialize() {
.persist.getOptions().partialize; .persist.getOptions().partialize;
} }
describe('partialize: localStorage queue window', () => { function getMerge() {
it('caps a large queue to at most 501 tracks and puts the current track at index 250', () => { type MergeFn = (persisted: unknown, current: ReturnType<typeof usePlayerStore.getState>) => ReturnType<typeof usePlayerStore.getState>;
const tracks = makeTracks(10509); return (usePlayerStore as unknown as { persist: { getOptions(): { merge: MergeFn } } })
usePlayerStore.setState({ queue: tracks, queueIndex: 5000 }); .persist.getOptions().merge;
}
const partial = getPartialize()(usePlayerStore.getState()); describe('partialize: thin queueItems (refs only)', () => {
it('persists the WHOLE queue as thin refs (no windowed fat `queue`)', () => {
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', () => {
const tracks = makeTracks(600); const tracks = makeTracks(600);
tracks[3].radioAdded = true; tracks[3].radioAdded = true;
tracks[4].autoAdded = 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 partial = getPartialize()(usePlayerStore.getState());
const items = partial.queueItems as { const items = partial.queueItems as {
serverId: string; trackId: string; radioAdded?: boolean; autoAdded?: boolean; serverId: string; trackId: string; radioAdded?: boolean; autoAdded?: boolean;
}[]; }[];
// windowed `queue` is capped, but queueItems carries the WHOLE queue // No fat `queue` key anymore.
expect((partial.queue as unknown[]).length).toBe(501); expect(partial.queue).toBeUndefined();
// queueItems carries the WHOLE queue.
expect(items.length).toBe(600); expect(items.length).toBe(600);
// queueItemsIndex is the restore-pending sentinel (= the live queueIndex).
expect(partial.queueItemsIndex).toBe(300); expect(partial.queueItemsIndex).toBe(300);
expect(items[0].serverId).toBe('s1'); expect(items[0].serverId).toBe('s1');
expect(items[3].radioAdded).toBe(true); expect(items[3].radioAdded).toBe(true);
expect(items[4].autoAdded).toBe(true); expect(items[4].autoAdded).toBe(true);
expect(items[0].radioAdded).toBeUndefined(); expect(items[0].radioAdded).toBeUndefined();
}); });
it('handles an empty queue without throwing', () => {
usePlayerStore.setState({ queueItems: [], queueIndex: 0 });
const partial = getPartialize()(usePlayerStore.getState());
expect((partial.queueItems as unknown[]).length).toBe(0);
expect(partial.queue).toBeUndefined();
});
});
describe('merge: restores the queue from any old persisted blob', () => {
const current = () => usePlayerStore.getState();
it('prefers an existing queueItems ref list + sets the sentinel', () => {
const merged = getMerge()(
{
queueServerId: 's1',
queueIndex: 2,
queueItems: [
{ serverId: 's1', trackId: 'a' },
{ serverId: 's1', trackId: 'b' },
],
queueItemsIndex: 1,
},
current(),
);
expect(merged.queueItems.map(r => r.trackId)).toEqual(['a', 'b']);
expect(merged.queueItemsIndex).toBe(1);
});
it('rebuilds queueItems from a legacy queueRefs string list', () => {
const merged = getMerge()(
{ queueServerId: 's2', queueRefs: ['x', 'y'], queueRefsIndex: 1 },
current(),
);
expect(merged.queueItems).toEqual([
{ serverId: 's2', trackId: 'x' },
{ serverId: 's2', trackId: 'y' },
]);
expect(merged.queueItemsIndex).toBe(1);
});
it('rebuilds queueItems from an old windowed fat `queue: Track[]` blob and drops the `queue` key', () => {
const blob: Record<string, unknown> = {
queueServerId: 's3',
queueIndex: 1,
queue: [makeTrack({ id: 'q0' }), makeTrack({ id: 'q1', radioAdded: true })],
};
const merged = getMerge()(blob, current());
expect(merged.queueItems).toEqual([
{ serverId: 's3', trackId: 'q0' },
{ serverId: 's3', trackId: 'q1', radioAdded: true },
]);
// The windowed fat-array key is deleted from the persisted blob.
expect('queue' in blob).toBe(false);
// Sentinel falls back to the persisted queueIndex when no explicit index.
expect(merged.queueItemsIndex).toBe(1);
});
it('leaves an empty queue alone (no sentinel) when the blob has nothing to restore', () => {
const merged = getMerge()({ queueServerId: null }, current());
expect(merged.queueItems).toEqual([]);
expect(merged.queueItemsIndex).toBeUndefined();
});
}); });
+11 -36
View File
@@ -49,7 +49,7 @@ import { usePlayerStore } from './playerStore';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { onInvoke, invokeMock } from '@/test/mocks/tauri'; import { onInvoke, invokeMock } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset'; 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 { function stubPlaybackInvokes(): void {
onInvoke('audio_play', () => undefined); onInvoke('audio_play', () => undefined);
@@ -201,11 +201,7 @@ describe('seek', () => {
describe('next', () => { describe('next', () => {
it('advances to queue[queueIndex + 1] when one is available', () => { it('advances to queue[queueIndex + 1] when one is available', () => {
const queue = makeTracks(3); const queue = makeTracks(3);
usePlayerStore.setState({ seedQueue(queue, { index: 0, currentTrack: queue[0] });
queue,
queueIndex: 0,
currentTrack: queue[0],
});
usePlayerStore.getState().next(); usePlayerStore.getState().next();
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id); expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id);
expect(usePlayerStore.getState().queueIndex).toBe(1); expect(usePlayerStore.getState().queueIndex).toBe(1);
@@ -213,12 +209,8 @@ describe('next', () => {
it('wraps to queue[0] when at the end with repeatMode=all', () => { it('wraps to queue[0] when at the end with repeatMode=all', () => {
const queue = makeTracks(3); const queue = makeTracks(3);
usePlayerStore.setState({ seedQueue(queue, { index: 2, currentTrack: queue[2] });
queue, usePlayerStore.setState({ repeatMode: 'all' });
queueIndex: 2,
currentTrack: queue[2],
repeatMode: 'all',
});
usePlayerStore.getState().next(); usePlayerStore.getState().next();
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id); expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id);
expect(usePlayerStore.getState().queueIndex).toBe(0); expect(usePlayerStore.getState().queueIndex).toBe(0);
@@ -228,13 +220,8 @@ describe('next', () => {
// infiniteQueueEnabled and the radio fetch path are both off by default, // infiniteQueueEnabled and the radio fetch path are both off by default,
// so the no-next branch falls through to `audio_stop`. // so the no-next branch falls through to `audio_stop`.
const queue = makeTracks(2); const queue = makeTracks(2);
usePlayerStore.setState({ seedQueue(queue, { index: 1, currentTrack: queue[1] });
queue, usePlayerStore.setState({ repeatMode: 'off', isPlaying: true });
queueIndex: 1,
currentTrack: queue[1],
repeatMode: 'off',
isPlaying: true,
});
usePlayerStore.getState().next(); usePlayerStore.getState().next();
expect(invokeMock).toHaveBeenCalledWith('audio_stop'); expect(invokeMock).toHaveBeenCalledWith('audio_stop');
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
@@ -247,11 +234,7 @@ describe('next', () => {
describe('previous', () => { describe('previous', () => {
it('restarts the current track when currentTime > 3 s', () => { it('restarts the current track when currentTime > 3 s', () => {
const queue = makeTracks(3); const queue = makeTracks(3);
usePlayerStore.setState({ seedQueue(queue, { index: 1, currentTrack: queue[1] });
queue,
queueIndex: 1,
currentTrack: queue[1],
});
// The store's `currentTime` is the source for the "restart vs jump back" // The store's `currentTime` is the source for the "restart vs jump back"
// branch. `getPlaybackProgressSnapshot` reads from the same field. // branch. `getPlaybackProgressSnapshot` reads from the same field.
usePlayerStore.setState({ currentTime: 10, progress: 10 / queue[1].duration }); 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', () => { it('jumps to the previous track when currentTime ≤ 3 s and queueIndex > 0', () => {
const queue = makeTracks(3); const queue = makeTracks(3);
usePlayerStore.setState({ seedQueue(queue, { index: 2, currentTrack: queue[2] });
queue, usePlayerStore.setState({ currentTime: 1.0 });
queueIndex: 2,
currentTrack: queue[2],
currentTime: 1.0,
});
usePlayerStore.getState().previous(); usePlayerStore.getState().previous();
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id); expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id);
expect(usePlayerStore.getState().queueIndex).toBe(1); 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', () => { it('is a no-op when queueIndex is 0 and currentTime ≤ 3 s', () => {
const queue = makeTracks(2); const queue = makeTracks(2);
usePlayerStore.setState({ seedQueue(queue, { index: 0, currentTrack: queue[0] });
queue, usePlayerStore.setState({ currentTime: 0.5 });
queueIndex: 0,
currentTrack: queue[0],
currentTime: 0.5,
});
usePlayerStore.getState().previous(); usePlayerStore.getState().previous();
expect(usePlayerStore.getState().queueIndex).toBe(0); expect(usePlayerStore.getState().queueIndex).toBe(0);
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id); expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id);
+47 -49
View File
@@ -33,7 +33,7 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
import { onInvoke } from '@/test/mocks/tauri'; import { onInvoke } from '@/test/mocks/tauri';
import { resetPlayerStore } from '@/test/helpers/storeReset'; import { resetPlayerStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks } from '@/test/helpers/factories'; import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
beforeEach(() => { beforeEach(() => {
resetPlayerStore(); resetPlayerStore();
@@ -50,56 +50,56 @@ describe('enqueue', () => {
it('appends a single track to an empty queue', () => { it('appends a single track to an empty queue', () => {
const t1 = makeTrack({ id: 't1' }); const t1 = makeTrack({ id: 't1' });
usePlayerStore.getState().enqueue([t1], true); 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', () => { it('appends multiple tracks in order', () => {
const tracks = makeTracks(3); const tracks = makeTracks(3);
usePlayerStore.getState().enqueue(tracks, true); 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', () => { it('inserts before the first upcoming auto-added separator', () => {
const head = makeTrack({ id: 'head' }); const head = makeTrack({ id: 'head' });
const auto = makeTrack({ id: 'auto', autoAdded: true }); const auto = makeTrack({ id: 'auto', autoAdded: true });
const tail = makeTrack({ id: 'tail', 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' }); const incoming = makeTrack({ id: 'new' });
usePlayerStore.getState().enqueue([incoming], true); usePlayerStore.getState().enqueue([incoming], true);
// Insert lands between `head` (the currently-playing one) and the first // Insert lands between `head` (the currently-playing one) and the first
// auto-added track, so the auto-added group stays at the tail. // 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', () => { it('appends at the end when there are no auto-added tracks after the cursor', () => {
const head = makeTrack({ id: 'head' }); const head = makeTrack({ id: 'head' });
const mid = makeTrack({ id: 'mid' }); const mid = makeTrack({ id: 'mid' });
usePlayerStore.setState({ queue: [head, mid], queueIndex: 0 }); seedQueue([head, mid], { index: 0 });
usePlayerStore.getState().enqueue([makeTrack({ id: 'tail' })], true); 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)', () => { it('ignores auto-added separators that already passed (behind the cursor)', () => {
const past = makeTrack({ id: 'past', autoAdded: true }); const past = makeTrack({ id: 'past', autoAdded: true });
const current = makeTrack({ id: 'current' }); const current = makeTrack({ id: 'current' });
usePlayerStore.setState({ queue: [past, current], queueIndex: 1 }); seedQueue([past, current], { index: 1 });
usePlayerStore.getState().enqueue([makeTrack({ id: 'new' })], true); 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', () => { describe('enqueueAt', () => {
it('inserts at the given index', () => { it('inserts at the given index', () => {
const queue = makeTracks(3); const queue = makeTracks(3);
usePlayerStore.setState({ queue, queueIndex: 0 }); seedQueue(queue, { index: 0 });
const ins = makeTrack({ id: 'ins' }); const ins = makeTrack({ id: 'ins' });
usePlayerStore.getState().enqueueAt([ins], 2, true); 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', () => { it('shifts queueIndex forward when inserting at or before the cursor', () => {
const queue = makeTracks(3); const queue = makeTracks(3);
usePlayerStore.setState({ queue, queueIndex: 2 }); seedQueue(queue, { index: 2 });
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], 1, true); usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], 1, true);
// Two tracks inserted at idx 1 → cursor (was 2) moves to 4. // Two tracks inserted at idx 1 → cursor (was 2) moves to 4.
expect(usePlayerStore.getState().queueIndex).toBe(4); expect(usePlayerStore.getState().queueIndex).toBe(4);
@@ -107,59 +107,57 @@ describe('enqueueAt', () => {
it('keeps queueIndex when inserting after the cursor', () => { it('keeps queueIndex when inserting after the cursor', () => {
const queue = makeTracks(3); const queue = makeTracks(3);
usePlayerStore.setState({ queue, queueIndex: 1 }); seedQueue(queue, { index: 1 });
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' })], 3, true); usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' })], 3, true);
expect(usePlayerStore.getState().queueIndex).toBe(1); expect(usePlayerStore.getState().queueIndex).toBe(1);
}); });
it('clamps a negative insertIndex to 0', () => { it('clamps a negative insertIndex to 0', () => {
const queue = makeTracks(2); const queue = makeTracks(2);
usePlayerStore.setState({ queue, queueIndex: 0 }); seedQueue(queue, { index: 0 });
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'front' })], -5, true); 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', () => { it('clamps an over-large insertIndex to the queue length', () => {
const queue = makeTracks(2); const queue = makeTracks(2);
usePlayerStore.setState({ queue, queueIndex: 0 }); seedQueue(queue, { index: 0 });
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'back' })], 99, true); usePlayerStore.getState().enqueueAt([makeTrack({ id: 'back' })], 99, true);
const q = usePlayerStore.getState().queue; const q = usePlayerStore.getState().queueItems;
expect(q[q.length - 1].id).toBe('back'); expect(q[q.length - 1].trackId).toBe('back');
}); });
}); });
describe('playNext', () => { describe('playNext', () => {
it('tags inserted tracks with playNextAdded', () => { it('tags inserted tracks with playNextAdded', () => {
const queue = makeTracks(2); 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' })]); 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); expect(inserted?.playNextAdded).toBe(true);
}); });
it('inserts immediately after the current track', () => { it('inserts immediately after the current track', () => {
const a = makeTrack({ id: 'a' }); const a = makeTrack({ id: 'a' });
const b = makeTrack({ id: 'b' }); 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' })]); 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', () => { it('returns early on an empty input list', () => {
const queue = makeTracks(2); const queue = makeTracks(2);
usePlayerStore.setState({ queue, queueIndex: 0, currentTrack: queue[0] }); seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.getState().playNext([]); 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', () => { describe('clearQueue', () => {
it('empties the queue and resets playback bookkeeping', () => { it('empties the queue and resets playback bookkeeping', () => {
const tracks = makeTracks(3); const tracks = makeTracks(3);
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
usePlayerStore.setState({ usePlayerStore.setState({
queue: tracks,
queueIndex: 1,
currentTrack: tracks[1],
isPlaying: true, isPlaying: true,
progress: 0.5, progress: 0.5,
currentTime: 42, currentTime: 42,
@@ -167,7 +165,7 @@ describe('clearQueue', () => {
}); });
usePlayerStore.getState().clearQueue(); usePlayerStore.getState().clearQueue();
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
expect(s.queue).toEqual([]); expect(s.queueItems).toEqual([]);
expect(s.queueIndex).toBe(0); expect(s.queueIndex).toBe(0);
expect(s.currentTrack).toBeNull(); expect(s.currentTrack).toBeNull();
expect(s.isPlaying).toBe(false); expect(s.isPlaying).toBe(false);
@@ -179,7 +177,7 @@ describe('clearQueue', () => {
it('calls audio_stop on the engine', () => { it('calls audio_stop on the engine', () => {
const stop = vi.fn(() => undefined); const stop = vi.fn(() => undefined);
onInvoke('audio_stop', stop); onInvoke('audio_stop', stop);
usePlayerStore.setState({ queue: makeTracks(2), queueIndex: 0 }); seedQueue(makeTracks(2), { index: 0 });
usePlayerStore.getState().clearQueue(); usePlayerStore.getState().clearQueue();
expect(stop).toHaveBeenCalled(); expect(stop).toHaveBeenCalled();
}); });
@@ -188,24 +186,24 @@ describe('clearQueue', () => {
describe('reorderQueue', () => { describe('reorderQueue', () => {
it('moves a track from startIndex to endIndex', () => { it('moves a track from startIndex to endIndex', () => {
const [a, b, c, d] = makeTracks(4); 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 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', () => { it('preserves queueIndex by following the current track id, not the slot', () => {
const [a, b, c] = makeTracks(3); 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 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` expect(usePlayerStore.getState().queueIndex).toBe(2); // followed `b`
}); });
it('keeps queueIndex when the current track is unaffected by the move', () => { it('keeps queueIndex when the current track is unaffected by the move', () => {
const [a, b, c] = makeTracks(3); 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 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` expect(usePlayerStore.getState().queueIndex).toBe(0); // followed `b`
}); });
}); });
@@ -213,22 +211,22 @@ describe('reorderQueue', () => {
describe('removeTrack', () => { describe('removeTrack', () => {
it('removes the track at the given index', () => { it('removes the track at the given index', () => {
const tracks = makeTracks(3); const tracks = makeTracks(3);
usePlayerStore.setState({ queue: tracks, queueIndex: 0 }); seedQueue(tracks, { index: 0 });
usePlayerStore.getState().removeTrack(1); 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', () => { it('clamps queueIndex when the removal makes the queue shorter than the cursor', () => {
const tracks = makeTracks(3); const tracks = makeTracks(3);
usePlayerStore.setState({ queue: tracks, queueIndex: 2 }); seedQueue(tracks, { index: 2 });
usePlayerStore.getState().removeTrack(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); expect(usePlayerStore.getState().queueIndex).toBe(1);
}); });
it('keeps queueIndex when removing a track after the cursor', () => { it('keeps queueIndex when removing a track after the cursor', () => {
const tracks = makeTracks(4); const tracks = makeTracks(4);
usePlayerStore.setState({ queue: tracks, queueIndex: 1 }); seedQueue(tracks, { index: 1 });
usePlayerStore.getState().removeTrack(3); usePlayerStore.getState().removeTrack(3);
expect(usePlayerStore.getState().queueIndex).toBe(1); expect(usePlayerStore.getState().queueIndex).toBe(1);
}); });
@@ -245,35 +243,35 @@ describe('undo / redo', () => {
it('rolls back the most recent destructive edit', () => { it('rolls back the most recent destructive edit', () => {
const seed = makeTracks(2); 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); 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(); const undone = usePlayerStore.getState().undoLastQueueEdit();
expect(undone).toBe(true); 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', () => { it('replays the undone edit via redo', () => {
const seed = makeTracks(2); const seed = makeTracks(2);
usePlayerStore.setState({ queue: seed, queueIndex: 0, currentTrack: seed[0] }); seedQueue(seed, { index: 0, currentTrack: seed[0] });
usePlayerStore.getState().removeTrack(1); 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(); 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(); 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)', () => { it('a new edit drops any pending redo (Word-style history)', () => {
const seed = makeTracks(3); 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().removeTrack(2); // edit A: [s0, s1]
usePlayerStore.getState().undoLastQueueEdit(); // [s0, s1, s2] 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 usePlayerStore.getState().removeTrack(1); // edit B drops the pending redo
+81 -36
View File
@@ -1,8 +1,10 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { createSafeJSONStorage } from './safeStorage';
import { emitPlaybackProgress } from './playbackProgress'; import { emitPlaybackProgress } from './playbackProgress';
import type { PlayerState } from './playerStoreTypes'; import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef'; import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { canonicalQueueServerKey } from '../utils/server/serverIndexKey';
import { readInitialQueueVisibility } from './queueVisibilityStorage'; import { readInitialQueueVisibility } from './queueVisibilityStorage';
import { createLastfmActions } from './lastfmActions'; import { createLastfmActions } from './lastfmActions';
import { createMiscActions } from './miscActions'; import { createMiscActions } from './miscActions';
@@ -17,9 +19,6 @@ import { createTransportLightActions } from './transportLightActions';
import { createUiStateActions } from './uiStateActions'; import { createUiStateActions } from './uiStateActions';
import { createUndoRedoActions } from './undoRedoActions'; import { createUndoRedoActions } from './undoRedoActions';
// Half-width of the localStorage queue window (see partialize below).
const PERSIST_QUEUE_HALF = 250;
export const usePlayerStore = create<PlayerState>()( export const usePlayerStore = create<PlayerState>()(
persist( persist(
(set, get) => { (set, get) => {
@@ -39,7 +38,9 @@ export const usePlayerStore = create<PlayerState>()(
currentRadio: null, currentRadio: null,
currentPlaybackSource: null, currentPlaybackSource: null,
enginePreloadedTrackId: 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, queueServerId: null,
queueIndex: 0, queueIndex: 0,
isPlaying: false, isPlaying: false,
@@ -81,37 +82,81 @@ export const usePlayerStore = create<PlayerState>()(
}, },
{ {
name: 'psysonic-player', name: 'psysonic-player',
storage: createJSONStorage(() => localStorage), // Quota-safe: a failed persist write (huge queue > localStorage quota)
partialize: (state) => { // must never throw, or it aborts the `set()` it fires from — that is what
// Persist only a window around the current track: the full queue // killed `playTrack` before `audio_play`. See safeStorage.ts.
// (10k+ on big playlists) overflows the localStorage quota. storage: createSafeJSONStorage(),
const qi = state.queueIndex; partialize: (state) => ({
const start = Math.max(0, qi - PERSIST_QUEUE_HALF); volume: state.volume,
const windowedQueue = state.queue.slice(start, qi + PERSIST_QUEUE_HALF + 1); 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 { return {
volume: state.volume, ...current,
repeatMode: state.repeatMode, ...blob,
currentTrack: state.currentTrack, queueItems: queueItems ?? current.queueItems,
queue: windowedQueue, ...(queueItemsIndex !== undefined ? { queueItemsIndex } : {}),
queueServerId: state.queueServerId, } as PlayerState;
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,
};
}, },
} }
) )
+10 -6
View File
@@ -69,7 +69,6 @@ export interface PlayerState {
* Cleared after a successful `audio_play` consumed that preload, or when starting another track. * Cleared after a successful `audio_play` consumed that preload, or when starting another track.
*/ */
enginePreloadedTrackId: string | null; enginePreloadedTrackId: string | null;
queue: Track[];
/** Saved server for stream/hot-cache/offline resolution while this queue plays. */ /** Saved server for stream/hot-cache/offline resolution while this queue plays. */
queueServerId: string | null; queueServerId: string | null;
queueIndex: number; queueIndex: number;
@@ -79,11 +78,16 @@ export interface PlayerState {
* are then cleared. Absent / index-off the windowed `queue` is used as-is. */ * are then cleared. Absent / index-off the windowed `queue` is used as-is. */
queueRefs?: string[]; queueRefs?: string[];
queueRefsIndex?: number; queueRefsIndex?: number;
/** Phase 1 (transient): thin canonical ref list persisted alongside the /** Canonical thin queue list (thin-state). Single playback server per item in
* windowed `queue`, superseding `queueRefs`. Carries per-item `serverId` + * v1; carries the queue-only flags. Persisted by `partialize`; the source the
* queue-only flags. Rehydrated like `queueRefs` and cleared after a full * resolver/consumers read from full `Track`s resolve on demand. */
* hydrate from the index. The in-memory queue stays `Track[]` until Phase 2. */ queueItems: QueueItemRef[];
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; queueItemsIndex?: number;
isPlaying: boolean; isPlaying: boolean;
/** HTTP stream still buffering (network / demux probe) — show loading on cover art. */ /** HTTP stream still buffering (network / demux probe) — show loading on cover art. */
+98 -73
View File
@@ -3,7 +3,9 @@ import { orbitBulkGuard } from '../utils/orbitBulkGuard';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { setIsAudioPaused } from './engineState'; import { setIsAudioPaused } from './engineState';
import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch'; 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 { pushQueueUndoFromGetter } from './queueUndo';
import { syncQueueToServer } from './queueSync'; import { syncQueueToServer } from './queueSync';
import { import {
@@ -35,6 +37,22 @@ function blockCrossServerEnqueue(): boolean {
return true; 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 * Eleven queue-mutation actions. Shared invariant: every action except
* `setRadioArtistId` pushes a queue-undo snapshot and calls * `setRadioArtistId` pushes a queue-undo snapshot and calls
@@ -67,21 +85,18 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
} }
if (!skipQueueUndo) pushQueueUndoFromGetter(get); if (!skipQueueUndo) pushQueueUndoFromGetter(get);
set(state => { 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 // Insert before the first upcoming auto-added track so the
// "Added automatically" separator always stays at the boundary. // "Added automatically" separator always stays at the boundary.
const firstAutoIdx = state.queue.findIndex( const firstAutoIdx = items.findIndex((r, i) => r.autoAdded && i > state.queueIndex);
(t, i) => t.autoAdded && i > state.queueIndex const newItems = firstAutoIdx === -1
); ? [...items, ...incoming]
const newQueue = firstAutoIdx === -1 : [...items.slice(0, firstAutoIdx), ...incoming, ...items.slice(firstAutoIdx)];
? [...state.queue, ...tracks] syncQueueToServer(newItems, state.currentTrack, state.currentTime);
: [ prefetchLoudnessForEnqueuedTracks(newItems, state.queueIndex);
...state.queue.slice(0, firstAutoIdx), return { queueItems: newItems };
...tracks,
...state.queue.slice(firstAutoIdx),
];
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(newQueue, state.queueIndex);
return { queue: newQueue };
}); });
}, },
@@ -101,22 +116,23 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
} }
pushQueueUndoFromGetter(get); pushQueueUndoFromGetter(get);
set(state => { set(state => {
const items = itemsOf(state);
// Drop all upcoming (not yet played) radio tracks — clicking "Start Radio" // Drop all upcoming (not yet played) radio tracks — clicking "Start Radio"
// again replaces the pending radio batch instead of stacking on top. // again replaces the pending radio batch instead of stacking on top.
const beforeAndCurrent = state.queue.slice(0, state.queueIndex + 1); const beforeAndCurrent = items.slice(0, state.queueIndex + 1);
const upcoming = state.queue.slice(state.queueIndex + 1).filter(t => !t.radioAdded); const upcoming = items.slice(state.queueIndex + 1).filter(r => !r.radioAdded);
// Tracks about to leave the queue here. Callers like ContextMenu.startRadio // Tracks about to leave the queue here. Callers like ContextMenu.startRadio
// pass the previous pending radio back in `tracks` to merge with new // pass the previous pending radio back in `tracks` to merge with new
// similars — the seen-set must not block those re-introductions. // similars — the seen-set must not block those re-introductions.
const droppedRadioIds = state.queue const droppedRadioIds = items
.slice(state.queueIndex + 1) .slice(state.queueIndex + 1)
.filter(t => t.radioAdded) .filter(r => r.radioAdded)
.map(t => t.id); .map(r => r.trackId);
for (const id of droppedRadioIds) deleteRadioSessionSeen(id); for (const id of droppedRadioIds) deleteRadioSessionSeen(id);
// Capture surviving queue ids in the seen-set so the next radio top-up // 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. // can dedupe against the seed track + already-queued non-radio items.
for (const t of beforeAndCurrent) addRadioSessionSeen(t.id); for (const r of beforeAndCurrent) addRadioSessionSeen(r.trackId);
for (const t of upcoming) addRadioSessionSeen(t.id); for (const r of upcoming) addRadioSessionSeen(r.trackId);
// Drop incoming tracks already seen earlier this session AND // Drop incoming tracks already seen earlier this session AND
// intra-batch duplicates (top + similar Last.fm responses commonly // intra-batch duplicates (top + similar Last.fm responses commonly
// overlap). The seen-set is mutated inside the loop so a repeated // 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); addRadioSessionSeen(t.id);
dedupedTracks.push(t); dedupedTracks.push(t);
} }
seedIncoming(state, dedupedTracks);
const incoming = toQueueItemRefs(state.queueServerId ?? '', dedupedTracks);
// Insert new radio tracks before any autoAdded tracks in the upcoming section. // Insert new radio tracks before any autoAdded tracks in the upcoming section.
const firstAutoIdx = upcoming.findIndex(t => t.autoAdded); const firstAutoIdx = upcoming.findIndex(r => r.autoAdded);
const merged = firstAutoIdx === -1 const mergedItems = firstAutoIdx === -1
? [...upcoming, ...dedupedTracks] ? [...upcoming, ...incoming]
: [ : [...upcoming.slice(0, firstAutoIdx), ...incoming, ...upcoming.slice(firstAutoIdx)];
...upcoming.slice(0, firstAutoIdx), const newItems = [...beforeAndCurrent, ...mergedItems];
...dedupedTracks, syncQueueToServer(newItems, state.currentTrack, state.currentTime);
...upcoming.slice(firstAutoIdx), return { queueItems: newItems };
];
const newQueue = [...beforeAndCurrent, ...merged];
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
return { queue: newQueue };
}); });
}, },
@@ -153,18 +167,17 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
} }
pushQueueUndoFromGetter(get); pushQueueUndoFromGetter(get);
set(state => { set(state => {
const idx = Math.max(0, Math.min(insertIndex, state.queue.length)); seedIncoming(state, tracks);
const newQueue = [ const items = itemsOf(state);
...state.queue.slice(0, idx), const idx = Math.max(0, Math.min(insertIndex, items.length));
...tracks, const incoming = toQueueItemRefs(state.queueServerId ?? '', tracks);
...state.queue.slice(idx), const newItems = [...items.slice(0, idx), ...incoming, ...items.slice(idx)];
];
const newQueueIndex = idx <= state.queueIndex const newQueueIndex = idx <= state.queueIndex
? state.queueIndex + tracks.length ? state.queueIndex + tracks.length
: state.queueIndex; : state.queueIndex;
syncQueueToServer(newQueue, state.currentTrack, state.currentTime); syncQueueToServer(newItems, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(newQueue, newQueueIndex); prefetchLoudnessForEnqueuedTracks(newItems, newQueueIndex);
return { queue: newQueue, queueIndex: newQueueIndex }; return { queueItems: newItems, queueIndex: newQueueIndex };
}); });
}, },
@@ -180,8 +193,8 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
const baseIdx = state.queueIndex + 1; const baseIdx = state.queueIndex + 1;
let insertIdx = baseIdx; let insertIdx = baseIdx;
if (useAuthStore.getState().preservePlayNextOrder) { if (useAuthStore.getState().preservePlayNextOrder) {
const q = state.queue; const items = itemsOf(state);
while (insertIdx < q.length && q[insertIdx].playNextAdded) insertIdx++; while (insertIdx < items.length && items[insertIdx].playNextAdded) insertIdx++;
} }
get().enqueueAt(tagged, insertIdx); get().enqueueAt(tagged, insertIdx);
}, },
@@ -190,21 +203,24 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
const s = get(); const s = get();
if (s.currentRadio) return; if (s.currentRadio) return;
if (!s.currentTrack) { if (!s.currentTrack) {
if (s.queue.length === 0) return; if (s.queueItems.length === 0) return;
if (!skipQueueUndo) pushQueueUndoFromGetter(get); if (!skipQueueUndo) pushQueueUndoFromGetter(get);
set({ queue: [], queueIndex: 0 }); set({ queueItems: [], queueIndex: 0 });
syncQueueToServer([], null, 0); syncQueueToServer([], null, 0);
return; return;
} }
if (!skipQueueUndo) pushQueueUndoFromGetter(get); if (!skipQueueUndo) pushQueueUndoFromGetter(get);
const at = s.queue.findIndex(t => t.id === s.currentTrack!.id); // Seed the resolver with the currently playing track so its ref always
const newQueue: Track[] = // resolves even when it had not been in the cache window before.
at >= 0 seedIncoming(s, [s.currentTrack]);
? s.queue.slice(0, at + 1) const items = itemsOf(s);
: [s.currentTrack!]; 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; const newIndex = at >= 0 ? at : 0;
set({ queue: newQueue, queueIndex: newIndex }); set({ queueItems: newItems, queueIndex: newIndex });
syncQueueToServer(newQueue, s.currentTrack, s.currentTime); syncQueueToServer(newItems, s.currentTrack, s.currentTime);
}, },
clearQueue: () => { clearQueue: () => {
@@ -216,64 +232,73 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
clearRadioSessionSeenIds(); clearRadioSessionSeenIds();
setCurrentRadioArtistId(null); setCurrentRadioArtistId(null);
clearQueueServerForPlayback(); 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); syncQueueToServer([], null, 0);
}, },
reorderQueue: (startIndex, endIndex) => { reorderQueue: (startIndex, endIndex) => {
pushQueueUndoFromGetter(get); pushQueueUndoFromGetter(get);
const { queue, queueIndex, currentTrack } = get(); const state = get();
const result = Array.from(queue); const { queueIndex, currentTrack } = state;
const result = itemsOf(state);
const [removed] = result.splice(startIndex, 1); const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed); result.splice(endIndex, 0, removed);
let newIndex = queueIndex; let newIndex = queueIndex;
if (currentTrack) newIndex = result.findIndex(t => t.id === currentTrack.id); if (currentTrack) newIndex = result.findIndex(r => r.trackId === currentTrack.id);
set({ queue: result, queueIndex: Math.max(0, newIndex) }); set({ queueItems: result, queueIndex: Math.max(0, newIndex) });
syncQueueToServer(result, currentTrack, get().currentTime); syncQueueToServer(result, currentTrack, get().currentTime);
}, },
shuffleQueue: () => { shuffleQueue: () => {
const { queue, currentTrack } = get(); const state = get();
if (queue.length < 2) return; const { currentTrack } = state;
if (state.queueItems.length < 2) return;
pushQueueUndoFromGetter(get); pushQueueUndoFromGetter(get);
const currentIdx = currentTrack ? queue.findIndex(t => t.id === currentTrack.id) : -1; const items = itemsOf(state);
const others = queue.filter((_, i) => i !== currentIdx); 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--) { for (let i = others.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
[others[i], others[j]] = [others[j], others[i]]; [others[i], others[j]] = [others[j], others[i]];
} }
const result = currentIdx >= 0 const result = currentIdx >= 0
? [queue[currentIdx], ...others] ? [items[currentIdx], ...others]
: others; : others;
const newIndex = currentIdx >= 0 ? 0 : -1; 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); syncQueueToServer(result, currentTrack, get().currentTime);
}, },
shuffleUpcomingQueue: () => { shuffleUpcomingQueue: () => {
const { queue, queueIndex, currentTrack } = get(); const state = get();
const { queueIndex, currentTrack } = state;
const upcomingStart = queueIndex + 1; const upcomingStart = queueIndex + 1;
const upcomingCount = queue.length - upcomingStart; const upcomingCount = state.queueItems.length - upcomingStart;
if (upcomingCount < 2) return; if (upcomingCount < 2) return;
pushQueueUndoFromGetter(get); pushQueueUndoFromGetter(get);
const head = queue.slice(0, upcomingStart); const items = itemsOf(state);
const upcoming = queue.slice(upcomingStart); const head = items.slice(0, upcomingStart);
const upcoming = items.slice(upcomingStart);
for (let i = upcoming.length - 1; i > 0; i--) { for (let i = upcoming.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
[upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]]; [upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]];
} }
const result = [...head, ...upcoming]; const result = [...head, ...upcoming];
set({ queue: result }); set({ queueItems: result });
syncQueueToServer(result, currentTrack, get().currentTime); syncQueueToServer(result, currentTrack, get().currentTime);
}, },
removeTrack: (index) => { removeTrack: (index) => {
pushQueueUndoFromGetter(get); pushQueueUndoFromGetter(get);
const { queue, queueIndex } = get(); const state = get();
const newQueue = [...queue]; const { queueIndex } = state;
newQueue.splice(index, 1); const newItems = itemsOf(state);
set({ queue: newQueue, queueIndex: Math.min(queueIndex, newQueue.length - 1) }); newItems.splice(index, 1);
syncQueueToServer(newQueue, get().currentTrack, get().currentTime); set({
queueItems: newItems,
queueIndex: Math.min(queueIndex, newItems.length - 1),
});
syncQueueToServer(newItems, get().currentTrack, get().currentTime);
}, },
}; };
} }
+16 -15
View File
@@ -1,21 +1,22 @@
/** /**
* Side-effect wiring (queue thin-state, phase 2b): seed the queue track * Side-effect wiring (queue thin-state): keep the queue track resolver cache
* resolver cache with the tracks around the current index whenever the queue * warm around the current index whenever the canonical `queueItems` ref list or
* changes. The store stays `queue: Track[]`-canonical for now this only fills * the playing index changes. The store is refs-canonical now, so this fills the
* the resolver cache (additive; no mutation or persist change), so the queue * cache (via `resolveVisibleRange` index batch getSong fallback) so the
* selectors resolve without a fetch once consumers move onto them (phase 3) and * queue selectors / list rows resolve without a synchronous miss. Render paths
* after `queue: Track[]` is dropped (phase 4). * stay pure (no cache mutation in render); the seed travels with the playing
* track here, off the render path.
*/ */
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
import { seedQueueResolver } from '../utils/library/queueTrackResolver'; import { resolveVisibleRange } from '../utils/library/queueTrackResolver';
const SEED_BACK = 50;
const SEED_AHEAD = 200;
usePlayerStore.subscribe((state, prev) => { usePlayerStore.subscribe((state, prev) => {
if (state.queue === prev.queue && state.queueServerId === prev.queueServerId) return; // Re-seed when the queue refs or the current index change — the prefetch
const serverId = state.queueServerId ?? ''; // window (resolveVisibleRange's PREFETCH_BACK/AHEAD) travels with the index.
if (!serverId || state.queue.length === 0) return; if (
const start = Math.max(0, state.queueIndex - SEED_BACK); state.queueItems === prev.queueItems &&
seedQueueResolver(serverId, state.queue.slice(start, state.queueIndex + SEED_AHEAD + 1)); state.queueIndex === prev.queueIndex
) return;
if (state.queueItems.length === 0) return;
resolveVisibleRange(state.queueItems, state.queueIndex, state.queueIndex);
}); });
+24 -19
View File
@@ -5,12 +5,12 @@
* in for `savePlayQueue`, the playerStore, and the playback-progress * in for `savePlayQueue`, the playerStore, and the playback-progress
* snapshot. * snapshot.
*/ */
import type { Track } from './playerStoreTypes'; import type { QueueItemRef, Track } from './playerStoreTypes';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({ const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({
savePlayQueueMock: vi.fn(async (_ids: string[], _currentId: string | undefined, _pos: number, _serverId: string) => undefined), savePlayQueueMock: vi.fn(async (_ids: string[], _currentId: string | undefined, _pos: number, _serverId: string) => undefined),
playerState: { playerState: {
queue: [] as Track[], queueItems: [] as QueueItemRef[],
currentTrack: null as Track | null, currentTrack: null as Track | null,
currentRadio: null as { id: string } | 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 }; 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(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-12T12:00:00Z')); vi.setSystemTime(new Date('2026-05-12T12:00:00Z'));
savePlayQueueMock.mockClear(); savePlayQueueMock.mockClear();
savePlayQueueMock.mockResolvedValue(undefined); savePlayQueueMock.mockResolvedValue(undefined);
playerState.queue = []; playerState.queueItems = [];
playerState.currentTrack = null; playerState.currentTrack = null;
playerState.currentRadio = null; playerState.currentRadio = null;
progressSnapshot.currentTime = 0; progressSnapshot.currentTime = 0;
@@ -57,32 +62,32 @@ afterEach(() => {
}); });
describe('syncQueueToServer (debounced)', () => { describe('syncQueueToServer (debounced)', () => {
const queue = [track('a'), track('b')]; const queue = [ref('a'), ref('b')];
it('does not fire before 5 s elapse', () => { it('does not fire before 5 s elapse', () => {
syncQueueToServer(queue, queue[0], 30); syncQueueToServer(queue, track('a'), 30);
vi.advanceTimersByTime(4999); vi.advanceTimersByTime(4999);
expect(savePlayQueueMock).not.toHaveBeenCalled(); expect(savePlayQueueMock).not.toHaveBeenCalled();
}); });
it('fires once after 5 s with id list + current id + position in ms', () => { 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); vi.advanceTimersByTime(5000);
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 30000, 'srv-a'); expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 30000, 'srv-a');
}); });
it('cancels the previous timer when called again before fire', () => { it('cancels the previous timer when called again before fire', () => {
syncQueueToServer(queue, queue[0], 10); syncQueueToServer(queue, track('a'), 10);
vi.advanceTimersByTime(3000); vi.advanceTimersByTime(3000);
syncQueueToServer([...queue, track('c')], queue[0], 20); syncQueueToServer([...queue, ref('c')], track('a'), 20);
vi.advanceTimersByTime(5000); vi.advanceTimersByTime(5000);
expect(savePlayQueueMock).toHaveBeenCalledTimes(1); expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b', 'c'], 'a', 20000, 'srv-a'); expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b', 'c'], 'a', 20000, 'srv-a');
}); });
it('caps the queue at 1000 ids', () => { it('caps the queue at 1000 ids', () => {
const big = Array.from({ length: 1500 }, (_, i) => track(`t${i}`)); const big = Array.from({ length: 1500 }, (_, i) => ref(`t${i}`));
syncQueueToServer(big, big[0], 0); syncQueueToServer(big, track('t0'), 0);
vi.advanceTimersByTime(5000); vi.advanceTimersByTime(5000);
const ids = savePlayQueueMock.mock.calls[0][0] as string[]; const ids = savePlayQueueMock.mock.calls[0][0] as string[];
expect(ids.length).toBe(1000); expect(ids.length).toBe(1000);
@@ -93,13 +98,13 @@ describe('syncQueueToServer (debounced)', () => {
describe('flushQueueSyncToServer (immediate)', () => { describe('flushQueueSyncToServer (immediate)', () => {
it('fires synchronously with no debounce', async () => { 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'); expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000, 'srv-a');
}); });
it('cancels a pending debounced sync first', async () => { it('cancels a pending debounced sync first', async () => {
syncQueueToServer([track('a')], track('a'), 30); syncQueueToServer([ref('a')], track('a'), 30);
await flushQueueSyncToServer([track('a')], track('a'), 31); await flushQueueSyncToServer([ref('a')], track('a'), 31);
expect(savePlayQueueMock).toHaveBeenCalledTimes(1); expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
// After the flush returns, advancing past the debounce should not fire again. // After the flush returns, advancing past the debounce should not fire again.
vi.advanceTimersByTime(10_000); vi.advanceTimersByTime(10_000);
@@ -107,7 +112,7 @@ describe('flushQueueSyncToServer (immediate)', () => {
}); });
it('is a no-op when currentTrack is null', async () => { 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(); expect(savePlayQueueMock).not.toHaveBeenCalled();
}); });
@@ -118,23 +123,23 @@ describe('flushQueueSyncToServer (immediate)', () => {
it('records the heartbeat timestamp', async () => { it('records the heartbeat timestamp', async () => {
expect(getLastQueueHeartbeatAt()).toBe(0); expect(getLastQueueHeartbeatAt()).toBe(0);
await flushQueueSyncToServer([track('a')], track('a'), 5); await flushQueueSyncToServer([ref('a')], track('a'), 5);
expect(getLastQueueHeartbeatAt()).toBe(Date.now()); expect(getLastQueueHeartbeatAt()).toBe(Date.now());
}); });
}); });
describe('flushPlayQueuePosition', () => { describe('flushPlayQueuePosition', () => {
it('reads the current playerStore queue + playback-progress time', async () => { it('reads the current playerStore queue + playback-progress time', async () => {
playerState.queue = [track('a'), track('b')]; playerState.queueItems = [ref('a'), ref('b')];
playerState.currentTrack = playerState.queue[0]; playerState.currentTrack = track('a');
progressSnapshot.currentTime = 42; progressSnapshot.currentTime = 42;
await flushPlayQueuePosition(); await flushPlayQueuePosition();
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 42000, 'srv-a'); expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 42000, 'srv-a');
}); });
it('is a no-op when a radio session is active', async () => { it('is a no-op when a radio session is active', async () => {
playerState.queue = [track('a')]; playerState.queueItems = [ref('a')];
playerState.currentTrack = playerState.queue[0]; playerState.currentTrack = track('a');
playerState.currentRadio = { id: 'radio-1' }; playerState.currentRadio = { id: 'radio-1' };
await flushPlayQueuePosition(); await flushPlayQueuePosition();
expect(savePlayQueueMock).not.toHaveBeenCalled(); expect(savePlayQueueMock).not.toHaveBeenCalled();
+6 -6
View File
@@ -1,5 +1,5 @@
import { savePlayQueue } from '../api/subsonicPlayQueue'; import { savePlayQueue } from '../api/subsonicPlayQueue';
import type { Track } from './playerStoreTypes'; import type { QueueItemRef, Track } from './playerStoreTypes';
import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { getPlaybackProgressSnapshot } from './playbackProgress'; import { getPlaybackProgressSnapshot } from './playbackProgress';
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
@@ -26,11 +26,11 @@ const QUEUE_ID_LIMIT = 1000;
let syncTimeout: ReturnType<typeof setTimeout> | null = null; let syncTimeout: ReturnType<typeof setTimeout> | null = null;
let lastQueueHeartbeatAt = 0; 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); if (syncTimeout) clearTimeout(syncTimeout);
syncTimeout = setTimeout(() => { syncTimeout = setTimeout(() => {
syncTimeout = null; 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 pos = Math.floor(currentTime * 1000);
const serverId = getPlaybackServerId(); const serverId = getPlaybackServerId();
savePlayQueue(ids, currentTrack?.id, pos, serverId).catch(err => { savePlayQueue(ids, currentTrack?.id, pos, serverId).catch(err => {
@@ -39,14 +39,14 @@ export function syncQueueToServer(queue: Track[], currentTrack: Track | null, cu
}, SYNC_DEBOUNCE_MS); }, 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) { if (syncTimeout) {
clearTimeout(syncTimeout); clearTimeout(syncTimeout);
syncTimeout = null; syncTimeout = null;
} }
if (!currentTrack || queue.length === 0) return Promise.resolve(); if (!currentTrack || queue.length === 0) return Promise.resolve();
lastQueueHeartbeatAt = Date.now(); 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 pos = Math.floor(currentTime * 1000);
const serverId = getPlaybackServerId(); const serverId = getPlaybackServerId();
return savePlayQueue(ids, currentTrack.id, pos, serverId).catch(err => { return savePlayQueue(ids, currentTrack.id, pos, serverId).catch(err => {
@@ -68,7 +68,7 @@ export function getLastQueueHeartbeatAt(): number {
export function flushPlayQueuePosition(): Promise<void> { export function flushPlayQueuePosition(): Promise<void> {
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
if (s.currentRadio) return Promise.resolve(); 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. */ /** Test-only: drop the debounce + reset the heartbeat. */
+12 -10
View File
@@ -5,6 +5,7 @@
* to restore list scroll position after an undo/redo commit. * to restore list scroll position after an undo/redo commit.
*/ */
import type { PlayerState, Track } from './playerStoreTypes'; import type { PlayerState, Track } from './playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { import {
QUEUE_UNDO_MAX, QUEUE_UNDO_MAX,
@@ -24,14 +25,17 @@ function track(id: string): Track {
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 }; 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 { return {
queue, queueItems: toQueueItemRefs('', tracks),
queueIndex: 0, queueIndex: 0,
currentTrack: queue[0] ?? null, currentTrack: tracks[0] ?? null,
currentTime: 0, currentTime: 0,
progress: 0, progress: 0,
isPlaying: false, isPlaying: false,
queueServerId: null,
...overrides, ...overrides,
} as PlayerState; } as PlayerState;
} }
@@ -44,13 +48,11 @@ beforeEach(() => {
}); });
describe('queueUndoSnapshotFromState', () => { 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 original = state([track('a'), track('b')]);
const snap = queueUndoSnapshotFromState(original); 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.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', () => { it('preserves currentTrack=null', () => {
@@ -69,7 +71,7 @@ describe('pushQueueUndoFromGetter', () => {
it('captures the current state on top of the undo stack', () => { it('captures the current state on top of the undo stack', () => {
pushQueueUndoFromGetter(() => state([track('a')])); pushQueueUndoFromGetter(() => state([track('a')]));
const snap = popQueueUndoSnapshot(); 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', () => { 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', () => { it('undo-snapshot push keeps order LIFO', () => {
pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('first')]))); pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('first')])));
pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('second')]))); pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('second')])));
expect(popQueueUndoSnapshot()?.queue[0].id).toBe('second'); expect(popQueueUndoSnapshot()?.queueItems[0].trackId).toBe('second');
expect(popQueueUndoSnapshot()?.queue[0].id).toBe('first'); expect(popQueueUndoSnapshot()?.queueItems[0].trackId).toBe('first');
}); });
}); });
+16 -3
View File
@@ -1,10 +1,14 @@
import type { PlayerState, Track } from './playerStoreTypes'; import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
/** Hard cap on undo/redo depth — keeps memory bounded for very long sessions. */ /** Hard cap on undo/redo depth — keeps memory bounded for very long sessions. */
export const QUEUE_UNDO_MAX = 32; export const QUEUE_UNDO_MAX = 32;
export type QueueUndoSnapshot = { 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; queueIndex: number;
/** Kept full — one resolved playing track, restored to the engine on undo. */
currentTrack: Track | null; currentTrack: Track | null;
/** Seconds — captured with the snapshot (older entries may omit). */ /** Seconds — captured with the snapshot (older entries may omit). */
currentTime?: number; currentTime?: number;
@@ -12,6 +16,12 @@ export type QueueUndoSnapshot = {
isPlaying?: boolean; isPlaying?: boolean;
/** Main queue panel list `scrollTop` when the snapshot was taken. */ /** Main queue panel list `scrollTop` when the snapshot was taken. */
queueListScrollTop?: number; 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[] = []; const queueUndoStack: QueueUndoSnapshot[] = [];
@@ -50,12 +60,15 @@ export function consumePendingQueueListScrollTop(): number | undefined {
export function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot { export function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot {
const scrollTop = readQueueListScrollTopForUndo(); const scrollTop = readQueueListScrollTopForUndo();
return { 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, queueIndex: s.queueIndex,
currentTrack: s.currentTrack ? { ...s.currentTrack } : null, currentTrack: s.currentTrack ? { ...s.currentTrack } : null,
currentTime: s.currentTime, currentTime: s.currentTime,
progress: s.progress, progress: s.progress,
isPlaying: s.isPlaying, isPlaying: s.isPlaying,
queueServerId: s.queueServerId,
...(scrollTop !== undefined ? { queueListScrollTop: scrollTop } : {}), ...(scrollTop !== undefined ? { queueListScrollTop: scrollTop } : {}),
}; };
} }
+10 -5
View File
@@ -28,6 +28,7 @@ import {
recordEnginePlayUrl, recordEnginePlayUrl,
} from './playbackUrlRouting'; } from './playbackUrlRouting';
import type { PlayerState } from './playerStoreTypes'; import type { PlayerState } from './playerStoreTypes';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import { promoteCompletedStreamToHotCache } from './promoteStreamCache'; import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import { syncQueueToServer } from './queueSync'; import { syncQueueToServer } from './queueSync';
import { resumeRadio } from './radioPlayer'; import { resumeRadio } from './radioPlayer';
@@ -112,10 +113,14 @@ export function runResume(set: SetState, get: GetState): void {
set({ isPlaying: true }); set({ isPlaying: true });
return; return;
} }
const { currentTrack, queue, queueIndex, currentTime } = get(); const { currentTrack, queueItems, queueIndex, currentTime } = get();
if (!currentTrack) return; if (!currentTrack) return;
const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null; // ReplayGain album-mode neighbours (resolver cache → placeholder; only their
const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; // 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()) { if (getIsAudioPaused()) {
// Rust engine has audio loaded but paused — just resume it. // 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); console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false }); set({ isPlaying: false });
}); });
syncQueueToServer(queue, trackToPlay, currentTime); syncQueueToServer(queueItems, trackToPlay, currentTime);
}).catch(() => { }).catch(() => {
if (getPlayGeneration() !== gen) return; if (getPlayGeneration() !== gen) return;
// Fallback to currentTrack if fetch fails // 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); console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false }); set({ isPlaying: false });
}); });
syncQueueToServer(queue, currentTrack, currentTime); syncQueueToServer(queueItems, currentTrack, currentTime);
}); });
})(); })();
} }
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createSafeJSONStorage } from './safeStorage';
describe('createSafeJSONStorage', () => {
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
it('swallows a QuotaExceededError from setItem instead of throwing', () => {
const storage = createSafeJSONStorage<{ a: number }>()!;
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('exceeded', 'QuotaExceededError');
});
// Must NOT throw — zustand calls this from inside set(); a throw here would
// abort the calling action (this is what killed playback on huge queues).
expect(() => storage.setItem('k', { state: { a: 1 }, version: 0 })).not.toThrow();
});
it('round-trips a value through localStorage when there is room', () => {
const storage = createSafeJSONStorage<{ a: number }>()!;
storage.setItem('k2', { state: { a: 5 }, version: 0 });
expect(storage.getItem('k2')).toEqual({ state: { a: 5 }, version: 0 });
});
it('does not throw across repeated quota failures (write stays a no-op)', () => {
const storage = createSafeJSONStorage<{ a: number }>()!;
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('exceeded', 'QuotaExceededError');
});
expect(() => {
for (let i = 0; i < 5; i++) {
storage.setItem('quota-throttle-key', { state: { a: i }, version: 0 });
}
}).not.toThrow();
});
it('returns null from getItem if the underlying read throws', () => {
const storage = createSafeJSONStorage<{ a: number }>()!;
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked');
});
expect(storage.getItem('missing')).toBeNull();
});
});
+58
View File
@@ -0,0 +1,58 @@
import { createJSONStorage, type StateStorage } from 'zustand/middleware';
/**
* `localStorage` wrapped so a failed write never throws.
*
* zustand's persist middleware calls the storage from *inside* `set()`. When a
* persisted slice grows past the origin quota (~5 MB) e.g. a multi-thousand
* track queue `localStorage.setItem` throws `QuotaExceededError`, and because
* that throw happens inside `set()` it aborts the calling action. That is how a
* full quota previously killed `playTrack` before it ever reached `audio_play`
* (no audio output at all on huge queues).
*
* Persistence is best-effort: a dropped write just means the in-memory store
* keeps working and the slice isn't saved this time. This is the same try/catch
* shape already used ad-hoc for direct `localStorage.setItem` calls elsewhere
* (e.g. mini-player geometry); this is its shared home for persist stores.
*/
// Warn once per key per quota-exceeded streak — a 50k+ queue persists on every
// mutation, so an unthrottled warning floods the console. Re-armed when a write
// to that key next succeeds (queue shrank back under the quota).
const quotaWarned = new Set<string>();
const safeLocalStorage: StateStorage = {
getItem: (name) => {
try {
return localStorage.getItem(name);
} catch {
return null;
}
},
setItem: (name, value) => {
try {
localStorage.setItem(name, value);
quotaWarned.delete(name);
} catch (e) {
if (import.meta.env.DEV && !quotaWarned.has(name)) {
quotaWarned.add(name);
console.warn(
`[psysonic] persist write skipped for "${name}" (storage quota?) — further skips silenced until it fits`,
e,
);
}
}
},
removeItem: (name) => {
try {
localStorage.removeItem(name);
} catch {
/* best-effort */
}
},
};
/**
* Drop-in replacement for `createJSONStorage(() => localStorage)` whose writes
* never throw. Use for any persist store whose slice can grow unbounded.
*/
export const createSafeJSONStorage = <S>() => createJSONStorage<S>(() => safeLocalStorage);
+2 -2
View File
@@ -56,7 +56,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
setAtMs: Date.now(), setAtMs: Date.now(),
}); });
clearSeekFallbackRetry(); clearSeekFallbackRetry();
s0.playTrack(s0.currentTrack, s0.queue, true); s0.playTrack(s0.currentTrack, undefined, true);
return; return;
} }
invoke('audio_seek', { seconds: time }).then(() => { invoke('audio_seek', { seconds: time }).then(() => {
@@ -95,7 +95,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
setSeekFallbackTrackId(s.currentTrack.id); setSeekFallbackTrackId(s.currentTrack.id);
setSeekFallbackRestartAt(now); setSeekFallbackRestartAt(now);
// Keep manual semantics (no crossfade) for seek recovery restarts. // 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); scheduleSeekFallbackRetry(s.currentTrack.id, time);
}); });
+7 -4
View File
@@ -8,7 +8,7 @@ import type { Track } from './playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
const { queueSongRatingMock, recordSkipStarMock, playerStateGet } = vi.hoisted(() => { const { queueSongRatingMock, recordSkipStarMock, playerStateGet } = vi.hoisted(() => {
const playerState = { const playerState = {
queue: [] as Track[], queueServerId: 's1' as string | null,
currentTrack: null as Track | null, currentTrack: null as Track | null,
userRatingOverrides: {} as Record<string, number>, userRatingOverrides: {} as Record<string, number>,
}; };
@@ -28,6 +28,7 @@ vi.mock('./playerStore', () => ({
})); }));
import { applySkipStarOnManualNext } from './skipStarRating'; import { applySkipStarOnManualNext } from './skipStarRating';
import { seedQueueResolver, _resetQueueResolverForTest } from '../utils/library/queueTrackResolver';
function track(id: string, overrides: Partial<Track> = {}): Track { function track(id: string, overrides: Partial<Track> = {}): Track {
return { return {
@@ -38,8 +39,9 @@ function track(id: string, overrides: Partial<Track> = {}): Track {
beforeEach(() => { beforeEach(() => {
queueSongRatingMock.mockClear(); queueSongRatingMock.mockClear();
recordSkipStarMock.mockReset(); recordSkipStarMock.mockReset();
_resetQueueResolverForTest();
const s = playerStateGet(); const s = playerStateGet();
s.queue = []; s.queueServerId = 's1';
s.currentTrack = null; s.currentTrack = null;
s.userRatingOverrides = {}; s.userRatingOverrides = {};
}); });
@@ -76,9 +78,10 @@ describe('applySkipStarOnManualNext', () => {
expect(queueSongRatingMock).not.toHaveBeenCalled(); 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 }); 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); applySkipStarOnManualNext(track('t1'), true);
expect(queueSongRatingMock).not.toHaveBeenCalled(); expect(queueSongRatingMock).not.toHaveBeenCalled();
}); });
+5 -2
View File
@@ -1,6 +1,7 @@
import type { Track } from './playerStoreTypes'; import type { Track } from './playerStoreTypes';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
import { getCachedTrack } from '../utils/library/queueTrackResolver';
import { queueSongRating } from './pendingStarSync'; import { queueSongRating } from './pendingStarSync';
/** /**
* Skip 1 behaviour: every user-initiated `next()` on an unrated track * 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); const adv = useAuthStore.getState().recordSkipStarManualAdvance(id);
if (!adv?.crossedThreshold) return; if (!adv?.crossedThreshold) return;
const live = usePlayerStore.getState(); 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 = const cur =
live.userRatingOverrides[id] ?? live.userRatingOverrides[id] ??
fromQueue?.userRating ?? fromCache?.userRating ??
skippedTrack.userRating ?? skippedTrack.userRating ??
0; 0;
if (cur >= 1) return; if (cur >= 1) return;
+1 -1
View File
@@ -69,7 +69,7 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
// server with the right resume point for other devices. // server with the right resume point for other devices.
const s = get(); const s = get();
if (s.currentTrack) { 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 }); set({ isPlaying: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
+2 -1
View File
@@ -37,9 +37,10 @@ export function createUiStateActions(set: SetState): Pick<
const nextOverrides = { ...s.userRatingOverrides }; const nextOverrides = { ...s.userRatingOverrides };
if (rating === 0) delete nextOverrides[id]; if (rating === 0) delete nextOverrides[id];
else nextOverrides[id] = rating; 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 { return {
userRatingOverrides: nextOverrides, userRatingOverrides: nextOverrides,
queue: s.queue.map(t => (t.id === id ? { ...t, userRating: rating } : t)),
currentTrack: currentTrack:
s.currentTrack?.id === id ? { ...s.currentTrack, userRating: rating } : s.currentTrack, s.currentTrack?.id === id ? { ...s.currentTrack, userRating: rating } : s.currentTrack,
}; };
+10 -4
View File
@@ -10,6 +10,7 @@ import {
import { deriveNormalizationSnapshot } from './normalizationSnapshot'; import { deriveNormalizationSnapshot } from './normalizationSnapshot';
import { invokeAudioUpdateReplayGainDeduped } from './normalizationIpcDedupe'; import { invokeAudioUpdateReplayGainDeduped } from './normalizationIpcDedupe';
import type { PlayerState } from './playerStoreTypes'; import type { PlayerState } from './playerStoreTypes';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
type SetState = ( type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>), partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
@@ -33,11 +34,14 @@ type GetState = () => PlayerState;
* deduplicated IPC channel. * deduplicated IPC channel.
*/ */
export function runUpdateReplayGainForCurrentTrack(set: SetState, get: GetState): void { 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; if (!currentTrack || !currentTrack.id) return;
const authState = useAuthStore.getState(); const authState = useAuthStore.getState();
const prev = queueIndex > 0 ? queue[queueIndex - 1] : null; // ReplayGain album-mode neighbours, resolved from refs (cache → placeholder).
const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; 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( const replayGainDb = resolveReplayGainDb(
currentTrack, prev, next, currentTrack, prev, next,
isReplayGainActive(), authState.replayGainMode, isReplayGainActive(), authState.replayGainMode,
@@ -46,7 +50,9 @@ export function runUpdateReplayGainForCurrentTrack(set: SetState, get: GetState)
? (currentTrack.replayGainPeak ?? null) ? (currentTrack.replayGainPeak ?? null)
: 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 cachedLoud = getCachedLoudnessGain(currentTrack.id);
const cachedLoudDb = Number.isFinite(cachedLoud) ? cachedLoud! : null; const cachedLoudDb = Number.isFinite(cachedLoud) ? cachedLoud! : null;
const haveStableLoud = hasStableLoudness(currentTrack.id); const haveStableLoud = hasStableLoudness(currentTrack.id);
+28
View File
@@ -8,6 +8,9 @@
import type { SubsonicSong } from '@/api/subsonicTypes'; import type { SubsonicSong } from '@/api/subsonicTypes';
import type { ServerProfile } from '@/store/authStoreTypes'; import type { ServerProfile } from '@/store/authStoreTypes';
import type { Track } from '@/store/playerStoreTypes'; import type { Track } from '@/store/playerStoreTypes';
import { usePlayerStore } from '@/store/playerStore';
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
let trackCounter = 0; let trackCounter = 0;
let songCounter = 0; let songCounter = 0;
let serverCounter = 0; let serverCounter = 0;
@@ -71,6 +74,31 @@ export function makeAuthState(opts: { servers?: ServerProfile[]; activeServerId?
}; };
} }
/**
* Seed the player store with a queue (thin-state). Replaces the old
* `setState({ queue: [...] })` seeds: it seeds the resolver cache with the full
* `tracks` (so rows / hot paths resolve them) AND sets the canonical
* `queueItems` refs + index + currentTrack + queueServerId. Pass `serverId` to
* match the active server when the test resolves through a real server id;
* defaults to `''` (the resolver / refs use that same empty id).
*/
export function seedQueue(
tracks: Track[],
opts: { index?: number; currentTrack?: Track | null; serverId?: string } = {},
): void {
const serverId = opts.serverId ?? '';
const index = opts.index ?? 0;
const currentTrack =
opts.currentTrack === undefined ? (tracks[index] ?? null) : opts.currentTrack;
seedQueueResolver(serverId, tracks);
usePlayerStore.setState({
queueItems: toQueueItemRefs(serverId, tracks),
queueIndex: index,
currentTrack,
queueServerId: serverId || null,
});
}
/** Minimal `usePlayerStore.setState(...)` partial for queue characterization tests. */ /** Minimal `usePlayerStore.setState(...)` partial for queue characterization tests. */
export function makeQueueState(opts: { queue?: Track[]; currentIndex?: number; currentTrack?: Track | null } = {}) { export function makeQueueState(opts: { queue?: Track[]; currentIndex?: number; currentTrack?: Track | null } = {}) {
const queue = opts.queue ?? makeTracks(3); const queue = opts.queue ?? makeTracks(3);
+2 -1
View File
@@ -66,5 +66,6 @@ export function restartPlaybackForRateChange(): void {
setAtMs: Date.now(), setAtMs: Date.now(),
}); });
} }
player.playTrack(track, player.queue, true); // No-arg queue: keep the canonical refs, restart the current track in place.
player.playTrack(track, undefined, true);
} }
@@ -6,6 +6,7 @@ import { buildDownloadUrl } from '../../api/subsonicStreamUrl';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import type { Track } from '../../store/playerStoreTypes'; import type { Track } from '../../store/playerStoreTypes';
import { resolveQueueTrack } from '../library/queueTrackView';
import { useZipDownloadStore } from '../../store/zipDownloadStore'; import { useZipDownloadStore } from '../../store/zipDownloadStore';
import { useDownloadModalStore } from '../../store/downloadModalStore'; import { useDownloadModalStore } from '../../store/downloadModalStore';
import type { EntityShareKind } from '../share/shareLink'; import type { EntityShareKind } from '../share/shareLink';
@@ -90,8 +91,13 @@ export async function startRadio(
.filter(t => t.id !== topTracks[0].id), .filter(t => t.id !== topTracks[0].id),
); );
if (similarTracks.length === 0) return; if (similarTracks.length === 0) return;
const { queue, queueIndex } = usePlayerStore.getState(); const { queueItems, queueIndex } = usePlayerStore.getState();
const pendingRadio = queue.slice(queueIndex + 1).filter(t => t.radioAdded); // Thin-state: resolve the upcoming radio refs (cache-warm window) back to
// Tracks so they merge with the new similars in enqueueRadio.
const pendingRadio = queueItems
.slice(queueIndex + 1)
.filter(r => r.radioAdded)
.map(r => resolveQueueTrack(r));
usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId); usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId);
}); });
} catch (e) { } catch (e) {
@@ -1,6 +1,10 @@
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import { resolveQueueTrack } from '../library/queueTrackView';
import type { MiniSyncPayload, MiniTrackInfo } from '../miniPlayerBridge'; import type { MiniSyncPayload, MiniTrackInfo } from '../miniPlayerBridge';
/** Half-width of the mini initial-snapshot queue window (matches the bridge). */
const MINI_SNAPSHOT_HALF = 100;
export const COLLAPSED_SIZE = { w: 340, h: 260 }; export const COLLAPSED_SIZE = { w: 340, h: 260 };
export const EXPANDED_SIZE = { w: 340, h: 500 }; export const EXPANDED_SIZE = { w: 340, h: 500 };
// Minimum window dimensions per state. When the queue is open the floor must // Minimum window dimensions per state. When the queue is open the floor must
@@ -54,10 +58,17 @@ export function toMini(t: any): MiniTrackInfo {
export function initialSnapshot(): MiniSyncPayload { export function initialSnapshot(): MiniSyncPayload {
try { try {
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
// Thin-state: resolve a window around the index (resolver cache →
// placeholder), remapping queueIndex like the live bridge snapshot.
const idx = s.queueIndex ?? 0;
const start = Math.max(0, idx - MINI_SNAPSHOT_HALF);
const windowed = (s.queueItems ?? [])
.slice(start, idx + MINI_SNAPSHOT_HALF + 1)
.map(r => resolveQueueTrack(r));
return { return {
track: s.currentTrack ? toMini(s.currentTrack) : null, track: s.currentTrack ? toMini(s.currentTrack) : null,
queue: (s.queue ?? []).map(toMini), queue: windowed.map(toMini),
queueIndex: s.queueIndex ?? 0, queueIndex: idx - start,
queueServerId: s.queueServerId ?? null, queueServerId: s.queueServerId ?? null,
isPlaying: s.isPlaying, isPlaying: s.isPlaying,
volume: s.volume ?? 1, volume: s.volume ?? 1,
+10 -5
View File
@@ -1,15 +1,20 @@
import type { QueueItemRef, Track } from '../../store/playerStoreTypes'; import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import { canonicalQueueServerKey } from '../server/serverIndexKey';
/** /**
* Derive thin `QueueItemRef`s from a `Track[]` queue (thin-state). Per-item * Derive thin `QueueItemRef`s from a `Track[]` queue (thin-state). Per-item
* `serverId` is the single playback server in v1; queue-only flags are carried * `serverId` is the canonical server index key every writer normalizes here
* through, others omitted to keep the persisted/derived list small. Pure no * so refs are unambiguous across mixed-server queues (same `trackId` on two
* store import, so both `playerStore` (persist) and the resolver bridge can use * servers must collide on nothing, since the resolver uses `serverId:trackId`).
* it without a circular dependency. * Queue-only flags are carried through, others omitted to keep the persisted /
* derived list small. Pure no store import beyond the canonicalizer, so both
* `playerStore` (persist) and the resolver bridge can use it without a
* circular dependency.
*/ */
export function toQueueItemRefs(serverId: string, queue: Track[]): QueueItemRef[] { export function toQueueItemRefs(serverId: string, queue: Track[]): QueueItemRef[] {
const canonicalId = canonicalQueueServerKey(serverId);
return queue.map(t => { return queue.map(t => {
const ref: QueueItemRef = { serverId, trackId: t.id }; const ref: QueueItemRef = { serverId: canonicalId, trackId: t.id };
if (t.autoAdded) ref.autoAdded = true; if (t.autoAdded) ref.autoAdded = true;
if (t.radioAdded) ref.radioAdded = true; if (t.radioAdded) ref.radioAdded = true;
if (t.playNextAdded) ref.playNextAdded = true; if (t.playNextAdded) ref.playNextAdded = true;
+51 -71
View File
@@ -4,6 +4,10 @@ import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { usePlayerStore } from '@/store/playerStore'; import { usePlayerStore } from '@/store/playerStore';
import type { TrackRefDto } from '@/api/library'; import type { TrackRefDto } from '@/api/library';
import type { Track } from '@/store/playerStoreTypes'; import type { Track } from '@/store/playerStoreTypes';
import {
getCachedTrack,
_resetQueueResolverForTest,
} from './queueTrackResolver';
import { hydrateQueueFromIndex } from './queueRestore'; import { hydrateQueueFromIndex } from './queueRestore';
const ready = () => const ready = () =>
@@ -34,72 +38,39 @@ const track = (id: string): Track => ({ id, title: id, artist: '', album: 'A', a
function seedStore(over: Partial<ReturnType<typeof usePlayerStore.getState>> = {}) { function seedStore(over: Partial<ReturnType<typeof usePlayerStore.getState>> = {}) {
usePlayerStore.setState({ usePlayerStore.setState({
queue: [track('w1')],
queueServerId: 's1', queueServerId: 's1',
queueIndex: 0, queueIndex: 0,
currentTrack: null, currentTrack: null,
queueItems: [],
queueItemsIndex: undefined,
queueRefs: undefined, queueRefs: undefined,
queueRefsIndex: undefined, queueRefsIndex: undefined,
...over, ...over,
}); });
} }
/**
* Thin-state `hydrateQueueFromIndex`: the store is refs-canonical, so cold
* restore eagerly resolves the whole `queueItems` ref list into the resolver
* cache (index batch getSong fallback) and clears the restore-pending
* sentinel. It no longer swaps a fat `Track[]` into the store `queueItems`
* stays the source of truth.
*/
describe('hydrateQueueFromIndex', () => { describe('hydrateQueueFromIndex', () => {
beforeEach(() => { beforeEach(() => {
useLibraryIndexStore.setState({ masterEnabled: true }); useLibraryIndexStore.setState({ masterEnabled: true });
_resetQueueResolverForTest();
seedStore(); seedStore();
}); });
it('does nothing without persisted refs', async () => { it('does nothing without a restore-pending sentinel', async () => {
seedStore({ queueRefs: undefined }); seedStore({ queueItems: [{ serverId: 's1', trackId: 'w1' }], queueItemsIndex: undefined });
await hydrateQueueFromIndex(); await hydrateQueueFromIndex();
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['w1']); // No resolve dispatched (no sentinel) → nothing cached.
expect(getCachedTrack({ serverId: 's1', trackId: 'w1' })).toBeUndefined();
}); });
it('keeps the windowed fallback when the index is not ready', async () => { it('resolves the whole queueItems ref list into the resolver cache and clears the sentinel', async () => {
onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' }));
seedStore({ queueRefs: ['t1', 't2', 't3'], queueRefsIndex: 1 });
await hydrateQueueFromIndex();
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['w1']);
expect(usePlayerStore.getState().queueRefs).toEqual(['t1', 't2', 't3']); // not cleared
});
it('restores the full queue and re-locates the current track when ready', async () => {
ready();
echoBatch();
seedStore({
queueRefs: ['t1', 't2', 't3'],
queueRefsIndex: 1,
currentTrack: track('t2'),
});
await hydrateQueueFromIndex();
const s = usePlayerStore.getState();
expect(s.queue.map(t => t.id)).toEqual(['t1', 't2', 't3']);
expect(s.queueIndex).toBe(1); // re-located to current track t2
expect(s.queueRefs).toBeUndefined(); // cleared after success
});
it('batches refs in chunks of 100', async () => {
ready();
echoBatch();
const refs = Array.from({ length: 150 }, (_, i) => `t${i}`);
seedStore({ queueRefs: refs, queueRefsIndex: 0 });
await hydrateQueueFromIndex();
expect(usePlayerStore.getState().queue).toHaveLength(150);
});
it('keeps the fallback when the current track is not in the hydrated list', async () => {
ready();
echoBatch();
seedStore({
queueRefs: ['t1', 't2'],
currentTrack: track('gone'),
});
await hydrateQueueFromIndex();
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['w1']); // unchanged
});
it('hydrates from queueItems (preferred) and clears the ref lists', async () => {
ready(); ready();
echoBatch(); echoBatch();
seedStore({ seedStore({
@@ -113,17 +84,31 @@ describe('hydrateQueueFromIndex', () => {
}); });
await hydrateQueueFromIndex(); await hydrateQueueFromIndex();
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
expect(s.queue.map(t => t.id)).toEqual(['t1', 't2', 't3']); // queueItems stays canonical (no fat-array swap).
expect(s.queueIndex).toBe(1); // re-located to current track t2 expect(s.queueItems.map(r => r.trackId)).toEqual(['t1', 't2', 't3']);
expect(s.queueItems).toBeUndefined(); // Restore-pending sentinel cleared so it runs at most once.
expect(s.queueRefs).toBeUndefined(); expect(s.queueItemsIndex).toBeUndefined();
// Every ref was resolved into the cache.
expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1');
expect(getCachedTrack({ serverId: 's1', trackId: 't3' })?.id).toBe('t3');
}); });
it('upgrades a legacy queueRefs-only store (no queueItems) via queueServerId', async () => { it('batches refs in chunks of 100', async () => {
ready();
echoBatch();
const items = Array.from({ length: 150 }, (_, i) => ({ serverId: 's1', trackId: `t${i}` }));
seedStore({ queueItems: items, queueItemsIndex: 0 });
await hydrateQueueFromIndex();
// All 150 resolved into the cache (the resolver chunks ≤100/call internally).
expect(getCachedTrack({ serverId: 's1', trackId: 't0' })?.id).toBe('t0');
expect(getCachedTrack({ serverId: 's1', trackId: 't149' })?.id).toBe('t149');
});
it('upgrades a legacy queueRefs-only blob via queueServerId, then clears it', async () => {
ready(); ready();
echoBatch(); echoBatch();
seedStore({ seedStore({
queueItems: undefined, // pre-Phase-1 persist shape queueItems: [], // pre-thin-state in-memory shape
queueRefs: ['t1', 't2', 't3'], queueRefs: ['t1', 't2', 't3'],
queueRefsIndex: 1, queueRefsIndex: 1,
queueServerId: 's1', queueServerId: 's1',
@@ -131,28 +116,23 @@ describe('hydrateQueueFromIndex', () => {
}); });
await hydrateQueueFromIndex(); await hydrateQueueFromIndex();
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
expect(s.queue.map(t => t.id)).toEqual(['t1', 't2', 't3']); // Legacy refs resolved into the cache and the legacy fields cleared.
expect(s.queueIndex).toBe(1); expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1');
expect(s.queueRefs).toBeUndefined(); // both ref lists cleared after success expect(getCachedTrack({ serverId: 's1', trackId: 't3' })?.id).toBe('t3');
expect(s.queueItems).toBeUndefined(); expect(s.queueItemsIndex).toBeUndefined();
expect(s.queueRefs).toBeUndefined();
}); });
it('carries queue-only flags from queueItems onto hydrated tracks', async () => { it('clears the sentinel even when the index is not ready (best-effort getSong fallback runs)', async () => {
ready(); onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' }));
echoBatch(); onInvoke('library_get_tracks_batch', () => []);
seedStore({ seedStore({
queueItems: [ queueItems: [{ serverId: 's1', trackId: 't1' }],
{ serverId: 's1', trackId: 't1' },
{ serverId: 's1', trackId: 't2', radioAdded: true },
{ serverId: 's1', trackId: 't3', autoAdded: true, playNextAdded: true },
],
queueItemsIndex: 0, queueItemsIndex: 0,
}); });
await hydrateQueueFromIndex(); await hydrateQueueFromIndex();
const q = usePlayerStore.getState().queue; // Sentinel cleared up front so the eager resolve runs at most once; refs stay.
expect(q.find(t => t.id === 't1')?.radioAdded).toBeUndefined(); expect(usePlayerStore.getState().queueItemsIndex).toBeUndefined();
expect(q.find(t => t.id === 't2')?.radioAdded).toBe(true); expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['t1']);
expect(q.find(t => t.id === 't3')?.autoAdded).toBe(true);
expect(q.find(t => t.id === 't3')?.playNextAdded).toBe(true);
}); });
}); });
+43 -72
View File
@@ -1,89 +1,60 @@
import { libraryGetTracksBatch, type LibraryTrackDto, type TrackRefDto } from '../../api/library';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import type { Track } from '../../store/playerStoreTypes'; import type { QueueItemRef } from '../../store/playerStoreTypes';
import { songToTrack } from '../playback/songToTrack'; import { canonicalQueueServerKey } from '../server/serverIndexKey';
import { trackToSong } from './advancedSearchLocal'; import { resolveBatch } from './queueTrackResolver';
import { libraryIsReady } from './libraryReady';
/** `library_get_tracks_batch` cap (spec §8.6 — max 100 refs/call). */
const BATCH = 100;
/** /**
* Full-queue restore. The player store rehydrates a *windowed* `queue` plus a * Full-queue restore (thin-state, decision B). The player store rehydrates the
* full thin-ref list. When the library index is ready for the queue's server, * whole thin `queueItems` ref list from localStorage on startup; this eagerly
* hydrate the entire queue from the index (`library_get_tracks_batch`, 100 * resolves every ref into the resolver cache so the queue UI / playback paths
* refs/call) and swap it in, re-locating the current track so the playback * have real `Track` metadata. `resolveBatch` does the index batch
* position stays correct even if some refs were dropped (unknown to the index). * (`library_get_tracks_batch`, 100 refs/call) `getSong` network fallback (P8)
* internally, so the queue is never empty even with the index off (the P6
* default every ref still resolves via getSong).
* *
* Phase 1: prefers `queueItems` (per-item serverId + queue-only flags) and * Clears the restore-pending sentinel (`queueItemsIndex`) once the eager resolve
* carries those flags onto the hydrated tracks; falls back to the legacy * is dispatched so it runs at most once; `queueItems` stays canonical. Legacy
* `queueRefs` string list for stores persisted before Phase 1. * pre-thin-state blobs that only carried `queueRefs` were normalised into
* * `queueItems` by the store's persist `merge`, so this reads `queueItems` only.
* Best-effort: missing refs / index not ready / any failure leave the windowed
* `queue` untouched no regression when the index is off (the P6 default).
* Clears the ref lists once a full hydrate succeeds so it runs at most once.
*/ */
export async function hydrateQueueFromIndex(): Promise<void> { export async function hydrateQueueFromIndex(): Promise<void> {
const player = usePlayerStore.getState(); const player = usePlayerStore.getState();
const items = player.queueItems; // Restore-pending sentinel: `partialize` writes `queueItemsIndex` alongside
let refs: TrackRefDto[] | null = null; // the full `queueItems` on every persist, so a fresh rehydrate carries it
if (items?.length) { // back. Normal in-memory mutations keep `queueItems` canonical but never set
refs = items.map(it => ({ serverId: it.serverId, trackId: it.trackId })); // the index, so its presence — not a non-empty `queueItems` — marks "this
} else if (player.queueRefs?.length) { // restored queue still needs an eager resolve". Without it (steady state /
const sid = player.queueServerId ?? useAuthStore.getState().activeServerId; // later server switch) there is nothing to do.
if (sid) refs = player.queueRefs.map(trackId => ({ serverId: sid, trackId })); const restorePending =
player.queueItemsIndex !== undefined || (player.queueRefs?.length ?? 0) > 0;
if (!restorePending) return;
let refs: QueueItemRef[] = player.queueItems ?? [];
if (refs.length === 0 && player.queueRefs?.length) {
const rawSid = player.queueServerId ?? useAuthStore.getState().activeServerId ?? '';
const sid = canonicalQueueServerKey(rawSid);
refs = player.queueRefs.map(trackId => ({ serverId: sid, trackId }));
} }
if (!refs || refs.length === 0) return;
const clearRefs = () => // Clear the restore-pending sentinel + any legacy refs; `queueItems` stays the
usePlayerStore.setState({ // canonical mirror. Done up front so a later resolve never re-triggers.
queueItems: undefined, queueItemsIndex: undefined, usePlayerStore.setState({
queueRefs: undefined, queueRefsIndex: undefined, queueItemsIndex: undefined,
}); queueRefs: undefined,
queueRefsIndex: undefined,
});
// v1 is single-server; gate readiness on the queue's server. if (refs.length === 0) return;
const serverId = refs[0].serverId || player.queueServerId || useAuthStore.getState().activeServerId;
if (!serverId) {
clearRefs();
return;
}
// Keep the windowed fallback (and the refs, for a later ready startup) when
// the index can't serve the queue yet.
if (!(await libraryIsReady(serverId))) return;
// Eager resolve of the whole queue into the resolver cache (best-effort —
// index batch when ready, else getSong window fallback so the queue plays
// even with the index off). Failures leave refs as placeholders until a row
// scrolls into view and the resolver bridge fetches them.
try { try {
const dtos: LibraryTrackDto[] = []; await resolveBatch(refs);
for (let i = 0; i < refs.length; i += BATCH) {
dtos.push(...(await libraryGetTracksBatch(refs.slice(i, i + BATCH))));
}
if (dtos.length === 0) return; // index has none of them → keep fallback
// The index doesn't store queue-only flags (radio/auto/play-next dividers),
// so carry them from the refs onto the hydrated tracks.
const flags = new Map(items?.map(it => [it.trackId, it]));
const hydrated: Track[] = dtos.map(d => {
const t = songToTrack(trackToSong(d));
const f = flags.get(t.id);
if (f?.autoAdded) t.autoAdded = true;
if (f?.radioAdded) t.radioAdded = true;
if (f?.playNextAdded) t.playNextAdded = true;
return t;
});
// Re-locate the current track so queueIndex stays aligned with playback.
const cur = usePlayerStore.getState().currentTrack;
const idx = cur ? hydrated.findIndex(t => t.id === cur.id) : -1;
if (cur && idx < 0) return; // can't align playback → keep windowed fallback
usePlayerStore.setState({
queue: hydrated,
queueIndex: idx >= 0 ? idx : 0,
queueItems: undefined, queueItemsIndex: undefined,
queueRefs: undefined, queueRefsIndex: undefined,
});
} catch { } catch {
// best-effort; the windowed fallback stays in place /* best-effort */
} }
} }
+43 -14
View File
@@ -3,6 +3,7 @@ import { getSong } from '../../api/subsonicLibrary';
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import type { QueueItemRef, Track } from '../../store/playerStoreTypes'; import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import { songToTrack } from '../playback/songToTrack'; import { songToTrack } from '../playback/songToTrack';
import { canonicalQueueServerKey } from '../server/serverIndexKey';
import { trackToSong } from './advancedSearchLocal'; import { trackToSong } from './advancedSearchLocal';
import { libraryIsReady } from './libraryReady'; import { libraryIsReady } from './libraryReady';
@@ -30,26 +31,24 @@ const refKey = (r: { serverId: string; trackId: string }) => `${r.serverId}:${r.
const cache = new Map<string, Track>(); const cache = new Map<string, Track>();
const inFlight = new Set<string>(); const inFlight = new Set<string>();
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
let cacheVersion = 0;
function notify(): void { function notify(): void {
cacheVersion++;
for (const l of listeners) l(); for (const l of listeners) l();
} }
/** Monotonic version, bumped on every cache change (for `useSyncExternalStore`). */
export function getQueueResolverVersion(): number {
return cacheVersion;
}
/** Subscribe to cache changes (for `useSyncExternalStore` selectors). */ /** Subscribe to cache changes (for `useSyncExternalStore` selectors). */
export function subscribeQueueResolver(cb: () => void): () => void { export function subscribeQueueResolver(cb: () => void): () => void {
listeners.add(cb); listeners.add(cb);
return () => { listeners.delete(cb); }; return () => { listeners.delete(cb); };
} }
function cacheTouch(key: string): Track | undefined {
const t = cache.get(key);
if (t !== undefined) {
cache.delete(key);
cache.set(key, t); // move to most-recent
}
return t;
}
function cacheSet(key: string, track: Track): void { function cacheSet(key: string, track: Track): void {
if (cache.has(key)) cache.delete(key); if (cache.has(key)) cache.delete(key);
cache.set(key, track); cache.set(key, track);
@@ -69,7 +68,20 @@ function carryFlags(track: Track, ref: QueueItemRef | undefined): Track {
/** Synchronous cache read (no fetch); undefined on miss. */ /** Synchronous cache read (no fetch); undefined on miss. */
export function getCachedTrack(ref: QueueItemRef): Track | undefined { export function getCachedTrack(ref: QueueItemRef): Track | undefined {
return cacheTouch(refKey(ref)); // Pure read — no LRU bump. Called from component render (QueueList rows), where
// a Map mutation (delete+set) is a render side-effect. Recency is set at write
// time in cacheSet instead; this cache is effectively insertion-order/FIFO.
const direct = cache.get(refKey(ref));
if (direct) return direct;
// Compat: refs persisted before B1 (queue server identity canonicalization)
// may still carry a UUID. Writes are canonical now, so the live cache key
// is `${indexKey}:${trackId}`; map UUID → indexKey on read to bridge the
// migration window.
const canonical = canonicalQueueServerKey(ref.serverId);
if (canonical && canonical !== ref.serverId) {
return cache.get(refKey({ serverId: canonical, trackId: ref.trackId }));
}
return undefined;
} }
/** Lightweight placeholder shown until a ref resolves. */ /** Lightweight placeholder shown until a ref resolves. */
@@ -101,10 +113,13 @@ export function applyQueueOverrides(track: Track): Track {
return next; return next;
} }
/** Seed the cache with already-known tracks (e.g. on enqueue) — no fetch. */ /** Seed the cache with already-known tracks (e.g. on enqueue) no fetch.
* Canonicalizes the caller-supplied server id so seed and refs always agree
* on a single key shape. */
export function seedQueueResolver(serverId: string, tracks: Track[]): void { export function seedQueueResolver(serverId: string, tracks: Track[]): void {
if (tracks.length === 0) return; if (tracks.length === 0) return;
for (const t of tracks) cacheSet(refKey({ serverId, trackId: t.id }), t); const canonicalId = canonicalQueueServerKey(serverId);
for (const t of tracks) cacheSet(refKey({ serverId: canonicalId, trackId: t.id }), t);
notify(); notify();
} }
@@ -177,8 +192,7 @@ export function resolveVisibleRange(refs: QueueItemRef[], fromIdx: number, toIdx
if (end > start) void resolveBatch(refs.slice(start, end)); if (end > start) void resolveBatch(refs.slice(start, end));
} }
/** Drop cached entries for a track id (e.g. after a star/rating sync succeeds, /** Drop cached entries for a track id, forcing the next resolve to re-fetch. */
* so the next read re-fetches the server truth). */
export function invalidateQueueResolver(trackId: string): void { export function invalidateQueueResolver(trackId: string): void {
let changed = false; let changed = false;
for (const key of [...cache.keys()]) { for (const key of [...cache.keys()]) {
@@ -190,6 +204,21 @@ export function invalidateQueueResolver(trackId: string): void {
if (changed) notify(); if (changed) notify();
} }
/** Patch cached entries for a track id in place (e.g. after a star/rating sync
* succeeds). Unlike {@link invalidateQueueResolver}, this keeps the entry so a
* visible queue row never blanks to a placeholder the row stays resolved and
* just reflects the synced value. No-op for refs not currently cached. */
export function patchCachedTrack(trackId: string, patch: Partial<Track>): void {
let changed = false;
for (const [key, track] of cache) {
if (key.endsWith(`:${trackId}`)) {
cache.set(key, { ...track, ...patch });
changed = true;
}
}
if (changed) notify();
}
/** Test-only: clear cache + in-flight set. */ /** Test-only: clear cache + in-flight set. */
export function _resetQueueResolverForTest(): void { export function _resetQueueResolverForTest(): void {
cache.clear(); cache.clear();
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { usePlayerStore } from '@/store/playerStore';
import type { QueueItemRef, Track } from '@/store/playerStoreTypes';
import { seedQueueResolver, _resetQueueResolverForTest } from '@/utils/library/queueTrackResolver';
import { resolveQueueTrack, getQueueTracksView } from './queueTrackView';
const track = (id: string, over: Partial<Track> = {}): Track =>
({ id, title: id, artist: 'A', album: 'Al', albumId: 'Al', duration: 1, ...over });
const ref = (trackId: string, over: Partial<QueueItemRef> = {}): QueueItemRef =>
({ serverId: 's1', trackId, ...over });
describe('queueTrackView', () => {
beforeEach(() => {
_resetQueueResolverForTest();
usePlayerStore.setState({ starredOverrides: {}, userRatingOverrides: {} });
});
it('resolves from the resolver cache when present', () => {
seedQueueResolver('s1', [track('t1', { title: 'Cached' })]);
expect(resolveQueueTrack(ref('t1')).title).toBe('Cached');
});
it('falls back to the provided Track on cache miss', () => {
expect(resolveQueueTrack(ref('t2'), track('t2', { title: 'Fallback' })).title).toBe('Fallback');
});
it('returns a placeholder on miss with no fallback', () => {
const r = resolveQueueTrack(ref('t3'));
expect(r.id).toBe('t3');
expect(r.title).toBe('…');
});
it('carries the ref queue-only flags onto the resolved track', () => {
seedQueueResolver('s1', [track('t4')]);
const r = resolveQueueTrack(ref('t4', { radioAdded: true }));
expect(r.radioAdded).toBe(true);
});
it('merges session star/rating overrides', () => {
seedQueueResolver('s1', [track('t5')]);
usePlayerStore.setState({ starredOverrides: { t5: true }, userRatingOverrides: { t5: 4 } });
const r = resolveQueueTrack(ref('t5'));
expect(!!r.starred).toBe(true);
expect(r.userRating).toBe(4);
});
it('getQueueTracksView resolves each ref, preferring cache then fallback', () => {
seedQueueResolver('s1', [track('a', { title: 'CachedA' })]);
const out = getQueueTracksView([ref('a'), ref('b')], [track('a'), track('b', { title: 'FbB' })]);
expect(out.map(t => t.title)).toEqual(['CachedA', 'FbB']);
});
});
+66
View File
@@ -0,0 +1,66 @@
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import { getCachedTrack, placeholderTrack, applyQueueOverrides } from './queueTrackResolver';
/**
* Dual-write bridge (thin-state phase 4): rebuild the legacy `queue: Track[]`
* from the canonical `QueueItemRef[]` after a ref-native mutation. Each ref's
* track is sourced from the supplied `pools` (the previous queue + any tracks
* just handed to the mutation) by id **purely structural**: no resolver cache
* read and no F4 override merge (display still applies those), so the derived
* array is byte-identical to the old fat-array mutation result. The ref is the
* source of truth for the queue-only flags. A ref with no pooled track falls
* back to a placeholder (does not happen during dual-write, where every ref's
* track is in hand). Removed in the final step together with `queue: Track[]`.
*/
export function bridgeQueueFromItems(items: QueueItemRef[], pools: Track[][]): Track[] {
const byId = new Map<string, Track>();
for (const pool of pools) {
for (const t of pool) if (!byId.has(t.id)) byId.set(t.id, t);
}
return items.map(ref => {
const base = byId.get(ref.trackId);
if (!base) return placeholderTrack(ref);
if (
base.autoAdded === ref.autoAdded &&
base.radioAdded === ref.radioAdded &&
base.playNextAdded === ref.playNextAdded
) {
return base;
}
return { ...base, autoAdded: ref.autoAdded, radioAdded: ref.radioAdded, playNextAdded: ref.playNextAdded };
});
}
/**
* Queue thin-state phase 4: turn a `QueueItemRef` into a display `Track` for the
* upcoming consumer migration off `queue: Track[]`.
*
* Resolver-first: cache caller fallback (the legacy `queue[idx]` Track during
* the dual-write transition) placeholder. Queue-only flags come from the ref
* (they are not in the index/cache); session star/rating overrides (F4) are
* merged last. Pure synchronous read **no fetch, no cache mutation** so it is
* safe to call from render (the resolver's `getCachedTrack` is a plain `cache.get`
* for exactly this reason; see the freeze fix in queueTrackResolver).
*/
export function resolveQueueTrack(ref: QueueItemRef, fallback?: Track): Track {
const base = getCachedTrack(ref) ?? fallback ?? placeholderTrack(ref);
// Carry the ref's queue-only flags onto the resolved track without mutating the
// cached object (a render-time mutation is what caused the earlier render loop).
const needsFlags =
base.autoAdded !== ref.autoAdded ||
base.radioAdded !== ref.radioAdded ||
base.playNextAdded !== ref.playNextAdded;
const flagged = needsFlags
? { ...base, autoAdded: ref.autoAdded, radioAdded: ref.radioAdded, playNextAdded: ref.playNextAdded }
: base;
return applyQueueOverrides(flagged);
}
/**
* Resolve a whole ref list to display `Track`s (non-React call sites: snapshots,
* hot-cache planning, sync). Same per-item rules as {@link resolveQueueTrack};
* `fallbacks[i]` is the legacy `queue[i]` during the dual-write transition.
*/
export function getQueueTracksView(refs: QueueItemRef[], fallbacks?: Track[]): Track[] {
return refs.map((ref, i) => resolveQueueTrack(ref, fallbacks?.[i]));
}
+37 -15
View File
@@ -2,6 +2,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
import { listen, emitTo } from '@tauri-apps/api/event'; import { listen, emitTo } from '@tauri-apps/api/event';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { resolveQueueTrack } from './library/queueTrackView';
import type { SubsonicOpenArtistRef } from '../api/subsonicTypes'; import type { SubsonicOpenArtistRef } from '../api/subsonicTypes';
export const MINI_WINDOW_LABEL = 'mini'; export const MINI_WINDOW_LABEL = 'mini';
@@ -56,13 +57,27 @@ function toMini(t: any): MiniTrackInfo {
}; };
} }
/** Cap the queue pushed to the mini at ±100 tracks around the playing song a
* 50k Artist-Radio queue must not serialize in full over IPC on every push. The
* mini stays slice-relative (no component change); control events (jump/reorder/
* remove) are translated back to absolute indices via {@link miniWindowStart}. */
const MINI_QUEUE_HALF = 100;
let miniWindowStart = 0;
function snapshot(): MiniSyncPayload { function snapshot(): MiniSyncPayload {
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
const a = useAuthStore.getState(); const a = useAuthStore.getState();
const idx = s.queueIndex ?? 0;
const start = Math.max(0, idx - MINI_QUEUE_HALF);
// Thin-state: resolve the windowed slice (resolver cache → placeholder).
const windowed = (s.queueItems ?? [])
.slice(start, idx + MINI_QUEUE_HALF + 1)
.map(r => resolveQueueTrack(r));
miniWindowStart = start;
return { return {
track: s.currentTrack ? toMini(s.currentTrack) : null, track: s.currentTrack ? toMini(s.currentTrack) : null,
queue: (s.queue ?? []).map(toMini), queue: windowed.map(toMini),
queueIndex: s.queueIndex ?? 0, queueIndex: idx - start, // local position within the windowed slice
queueServerId: s.queueServerId ?? null, queueServerId: s.queueServerId ?? null,
isPlaying: s.isPlaying, isPlaying: s.isPlaying,
volume: s.volume, volume: s.volume,
@@ -112,7 +127,7 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|| state.isPlaying !== prev.isPlaying || state.isPlaying !== prev.isPlaying
|| state.currentTrack?.starred !== prev.currentTrack?.starred || state.currentTrack?.starred !== prev.currentTrack?.starred
|| state.queueIndex !== prev.queueIndex || state.queueIndex !== prev.queueIndex
|| state.queue !== prev.queue || state.queueItems !== prev.queueItems
|| state.queueServerId !== prev.queueServerId || state.queueServerId !== prev.queueServerId
|| state.volume !== prev.volume) { || state.volume !== prev.volume) {
push(); push();
@@ -153,30 +168,37 @@ export function initMiniPlayerBridgeOnMain(): () => void {
} }
}); });
// Jump to a specific queue index. // Jump to a specific queue index. The mini sends a slice-relative index; add
// the window offset from the last push to land on the absolute queue position.
const jumpUnlisten = listen<{ index: number }>('mini:jump', (e) => { const jumpUnlisten = listen<{ index: number }>('mini:jump', (e) => {
const store = usePlayerStore.getState(); const store = usePlayerStore.getState();
const idx = e.payload?.index ?? -1; const idx = (e.payload?.index ?? -1) + miniWindowStart;
if (idx < 0 || idx >= store.queue.length) return; if (idx < 0 || idx >= store.queueItems.length) return;
const track = store.queue[idx]; const ref = store.queueItems[idx];
if (track) store.playTrack(track, store.queue, true); if (ref) {
// Resolve the target ref; pass undefined so playTrack keeps the canonical
// queue and just jumps to this slot.
store.playTrack(resolveQueueTrack(ref), undefined, true, false, idx);
}
}); });
// PsyDnD reorder forwarded from the mini queue. // PsyDnD reorder forwarded from the mini queue (slice-relative → absolute).
const reorderUnlisten = listen<{ from: number; to: number }>('mini:reorder', (e) => { const reorderUnlisten = listen<{ from: number; to: number }>('mini:reorder', (e) => {
const store = usePlayerStore.getState(); const store = usePlayerStore.getState();
const { from, to } = e.payload ?? { from: -1, to: -1 }; const raw = e.payload ?? { from: -1, to: -1 };
if (from < 0 || from >= store.queue.length) return; const from = raw.from + miniWindowStart;
if (to < 0 || to > store.queue.length) return; const to = raw.to + miniWindowStart;
if (from < 0 || from >= store.queueItems.length) return;
if (to < 0 || to > store.queueItems.length) return;
if (from === to) return; if (from === to) return;
store.reorderQueue(from, to); store.reorderQueue(from, to);
}); });
// Remove a track at index (context menu → "Remove from queue"). // Remove a track at index (context menu → "Remove from queue"; slice-relative).
const removeUnlisten = listen<{ index: number }>('mini:remove', (e) => { const removeUnlisten = listen<{ index: number }>('mini:remove', (e) => {
const store = usePlayerStore.getState(); const store = usePlayerStore.getState();
const idx = e.payload?.index ?? -1; const idx = (e.payload?.index ?? -1) + miniWindowStart;
if (idx < 0 || idx >= store.queue.length) return; if (idx < 0 || idx >= store.queueItems.length) return;
store.removeTrack(idx); store.removeTrack(idx);
}); });
+10 -9
View File
@@ -1,7 +1,7 @@
import { getSimilarSongs } from '../../api/subsonicArtists'; import { getSimilarSongs } from '../../api/subsonicArtists';
import { filterSongsToActiveLibrary, getRandomSongs } from '../../api/subsonicLibrary'; import { filterSongsToActiveLibrary, getRandomSongs } from '../../api/subsonicLibrary';
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes'; import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes'; import type { QueueItemRef } from '../../store/playerStoreTypes';
import { songToTrack } from '../playback/songToTrack'; import { songToTrack } from '../playback/songToTrack';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import i18n from '../../i18n'; import i18n from '../../i18n';
@@ -93,14 +93,15 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
// Snapshot the current queue *before* we prune — so if the build fails // Snapshot the current queue *before* we prune — so if the build fails
// before we ever play a track, we can put it back the way it was instead // before we ever play a track, we can put it back the way it was instead
// of leaving the user with an empty player. // of leaving the user with an empty player. Thin-state: snapshot the refs and
// the resolved tracks (to re-seed the resolver on restore).
const playerStateBefore = usePlayerStore.getState(); const playerStateBefore = usePlayerStore.getState();
const queueSnapshot: { const queueSnapshot: {
queue: Track[]; queueItems: QueueItemRef[];
queueIndex: number; queueIndex: number;
queueServerId: string | null; queueServerId: string | null;
} = { } = {
queue: [...playerStateBefore.queue], queueItems: [...playerStateBefore.queueItems],
queueIndex: playerStateBefore.queueIndex, queueIndex: playerStateBefore.queueIndex,
queueServerId: playerStateBefore.queueServerId, queueServerId: playerStateBefore.queueServerId,
}; };
@@ -125,8 +126,8 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
try { try {
let allSeedSongs: SubsonicSong[] = []; let allSeedSongs: SubsonicSong[] = [];
const mixQueueSize = () => usePlayerStore.getState().queue.length; const mixQueueSize = () => usePlayerStore.getState().queueItems.length;
const mixQueueTrackIds = () => new Set(usePlayerStore.getState().queue.map(t => t.id)); const mixQueueTrackIds = () => new Set(usePlayerStore.getState().queueItems.map(r => r.trackId));
const bailIfCancelled = () => { const bailIfCancelled = () => {
if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled(); if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled();
@@ -156,7 +157,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
const nextId = state.currentTrack?.id ?? null; const nextId = state.currentTrack?.id ?? null;
if (nextId === prevId) return; if (nextId === prevId) return;
if (!nextId) return; if (!nextId) return;
if (state.queue.some(t => t.id === nextId)) return; if (state.queueItems.some(r => r.trackId === nextId)) return;
useLuckyMixStore.getState().cancel(); useLuckyMixStore.getState().cancel();
}); });
} }
@@ -389,12 +390,12 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
// whatever we managed to enqueue is more useful than the old queue. // whatever we managed to enqueue is more useful than the old queue.
if (!startedPlayback) { if (!startedPlayback) {
usePlayerStore.setState({ usePlayerStore.setState({
queue: queueSnapshot.queue, queueItems: queueSnapshot.queueItems,
queueIndex: queueSnapshot.queueIndex, queueIndex: queueSnapshot.queueIndex,
queueServerId: queueSnapshot.queueServerId, queueServerId: queueSnapshot.queueServerId,
}); });
logStep('queue_restored_after_failure', { logStep('queue_restored_after_failure', {
restoredCount: queueSnapshot.queue.length, restoredCount: queueSnapshot.queueItems.length,
}); });
} }
showToast(i18n.t('luckyMix.failed'), 5000, 'error'); showToast(i18n.t('luckyMix.failed'), 5000, 'error');
+2 -2
View File
@@ -45,14 +45,14 @@ export async function playSongNow(song: SubsonicSong, queue?: SubsonicSong[]): P
export async function enqueueAndPlay(song: SubsonicSong): Promise<void> { export async function enqueueAndPlay(song: SubsonicSong): Promise<void> {
const track = songToTrack(song); const track = songToTrack(song);
const store = usePlayerStore.getState(); const store = usePlayerStore.getState();
const { isPlaying, volume, queue } = store; const { isPlaying, volume, queueItems } = store;
if (isPlaying) { if (isPlaying) {
await fadeOut(store.setVolume, volume, 700); await fadeOut(store.setVolume, volume, 700);
usePlayerStore.setState({ volume }); usePlayerStore.setState({ volume });
} }
if (!queue.some(t => t.id === track.id)) { if (!queueItems.some(r => r.trackId === track.id)) {
usePlayerStore.getState().enqueue([track]); usePlayerStore.getState().enqueue([track]);
} }
// playTrack with no queue arg uses the current state.queue, finds the track by id, // playTrack with no queue arg uses the current state.queue, finds the track by id,
+18 -11
View File
@@ -30,7 +30,7 @@ describe('playbackServer', () => {
isLoggedIn: true, isLoggedIn: true,
}); });
usePlayerStore.setState({ usePlayerStore.setState({
queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }], queueItems: [{ serverId: 'a', trackId: 't1' }],
queueServerId: 'a', queueServerId: 'a',
queueIndex: 0, queueIndex: 0,
}); });
@@ -43,21 +43,22 @@ describe('playbackServer', () => {
it('getPlaybackServerId falls back to active when queue is empty', () => { it('getPlaybackServerId falls back to active when queue is empty', () => {
clearQueueServerForPlayback(); clearQueueServerForPlayback();
usePlayerStore.setState({ queue: [] }); usePlayerStore.setState({ queueItems: [] });
useAuthStore.setState({ activeServerId: 'b' }); useAuthStore.setState({ activeServerId: 'b' });
expect(getPlaybackServerId()).toBe('b'); expect(getPlaybackServerId()).toBe('b');
}); });
it('bindQueueServerForPlayback pins active server', () => { it('bindQueueServerForPlayback pins active server as canonical index key', () => {
useAuthStore.setState({ activeServerId: 'b' }); useAuthStore.setState({ activeServerId: 'b' });
bindQueueServerForPlayback(); bindQueueServerForPlayback();
expect(usePlayerStore.getState().queueServerId).toBe('b'); // B1: writers emit the canonical (URL-derived) server key, not the UUID.
expect(usePlayerStore.getState().queueServerId).toBe('b.test');
}); });
it('playbackServerDiffersFromActive when queue server != active', () => { it('playbackServerDiffersFromActive when queue server != active', () => {
useAuthStore.setState({ activeServerId: 'b' }); useAuthStore.setState({ activeServerId: 'b' });
expect(playbackServerDiffersFromActive()).toBe(true); expect(playbackServerDiffersFromActive()).toBe(true);
usePlayerStore.setState({ queue: [] }); usePlayerStore.setState({ queueItems: [] });
expect(playbackServerDiffersFromActive()).toBe(false); expect(playbackServerDiffersFromActive()).toBe(false);
}); });
@@ -66,16 +67,20 @@ describe('playbackServer', () => {
useAuthStore.setState({ activeServerId: 'b' }); useAuthStore.setState({ activeServerId: 'b' });
prepareActiveServerForNewMix(); prepareActiveServerForNewMix();
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
expect(s.queue).toEqual([]); expect(s.queueItems).toEqual([]);
expect(s.currentTrack).toBeNull(); expect(s.currentTrack).toBeNull();
expect(s.queueServerId).toBe('b'); // Canonical index key on re-pin (B1).
expect(s.queueServerId).toBe('b.test');
expect(playbackServerDiffersFromActive()).toBe(false); expect(playbackServerDiffersFromActive()).toBe(false);
}); });
it('prepareActiveServerForNewMix is a no-op when queue already matches active', () => { it('prepareActiveServerForNewMix is a no-op when queue already matches active', () => {
useAuthStore.setState({ activeServerId: 'a' }); useAuthStore.setState({ activeServerId: 'a' });
prepareActiveServerForNewMix(); prepareActiveServerForNewMix();
expect(usePlayerStore.getState().queue).toHaveLength(1); expect(usePlayerStore.getState().queueItems).toHaveLength(1);
// Pre-existing queueServerId='a' (UUID) is tolerated by the reader helpers
// even while writers emit canonical index keys — this is the migration
// window the resolver compat path covers (B1).
expect(usePlayerStore.getState().queueServerId).toBe('a'); expect(usePlayerStore.getState().queueServerId).toBe('a');
}); });
@@ -108,12 +113,14 @@ describe('playbackServer', () => {
}); });
it('shouldBindQueueServerForPlay detects queue replacement', () => { it('shouldBindQueueServerForPlay detects queue replacement', () => {
const prev = [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }]; // Thin-state: prevQueue is the canonical refs; newQueue / explicit arg are Tracks.
const prevRefs = [{ serverId: 'a', trackId: 't1' }];
const sameTrack = [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }];
const next = [ const next = [
{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }, { id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 },
{ id: 't2', title: 'T2', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }, { id: 't2', title: 'T2', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 },
]; ];
expect(shouldBindQueueServerForPlay(prev, next, next)).toBe(true); expect(shouldBindQueueServerForPlay(prevRefs, next, next)).toBe(true);
expect(shouldBindQueueServerForPlay(prev, prev, undefined)).toBe(false); expect(shouldBindQueueServerForPlay(prevRefs, sameTrack, undefined)).toBe(false);
}); });
}); });
+23 -13
View File
@@ -6,22 +6,26 @@ import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import { switchActiveServer } from '../server/switchActiveServer'; import { switchActiveServer } from '../server/switchActiveServer';
import { sameQueueTrackId } from './queueIdentity'; import { sameQueueTrackId } from './queueIdentity';
import type { Track } from '../../store/playerStoreTypes'; import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import { resolveServerIdForIndexKey } from '../server/serverLookup'; import { resolveServerIdForIndexKey } from '../server/serverLookup';
import { resolveIndexKey, serverIndexKeyFromUrl } from '../server/serverIndexKey'; import {
resolveIndexKey,
serverIndexKeyForProfile,
serverIndexKeyFromUrl,
} from '../server/serverIndexKey';
/** Server that owns the current queue / stream URLs (may differ from the browsed server). */ /** Server that owns the current queue / stream URLs (may differ from the browsed server). */
export function getPlaybackServerId(): string { export function getPlaybackServerId(): string {
const { queueServerId, queue } = usePlayerStore.getState(); const { queueServerId, queueItems } = usePlayerStore.getState();
if ((queue?.length ?? 0) > 0 && queueServerId) { if ((queueItems?.length ?? 0) > 0 && queueServerId) {
return resolveServerIdForIndexKey(queueServerId); return resolveServerIdForIndexKey(queueServerId);
} }
return useAuthStore.getState().activeServerId ?? ''; return useAuthStore.getState().activeServerId ?? '';
} }
export function getPlaybackIndexKey(): string { export function getPlaybackIndexKey(): string {
const { queueServerId, queue } = usePlayerStore.getState(); const { queueServerId, queueItems } = usePlayerStore.getState();
if ((queue?.length ?? 0) > 0 && queueServerId) { if ((queueItems?.length ?? 0) > 0 && queueServerId) {
return resolveIndexKey(queueServerId); return resolveIndexKey(queueServerId);
} }
const activeId = useAuthStore.getState().activeServerId ?? ''; const activeId = useAuthStore.getState().activeServerId ?? '';
@@ -43,7 +47,13 @@ export function getPlaybackCacheServerKey(): string {
export function bindQueueServerForPlayback(): void { export function bindQueueServerForPlayback(): void {
const sid = useAuthStore.getState().activeServerId; const sid = useAuthStore.getState().activeServerId;
if (!sid) return; if (!sid) return;
usePlayerStore.setState({ queueServerId: sid }); const server = useAuthStore.getState().servers.find(s => s.id === sid);
// Canonical index key on writes so mixed-server queues stay unambiguous —
// every ref/queue-level server identifier follows the same shape that the
// library index already uses. Falls back to the raw id when the server
// profile cannot be resolved (e.g. tests with a stubbed auth store).
const canonical = server ? serverIndexKeyForProfile(server) || sid : sid;
usePlayerStore.setState({ queueServerId: canonical });
} }
export function clearQueueServerForPlayback(): void { export function clearQueueServerForPlayback(): void {
@@ -51,8 +61,8 @@ export function clearQueueServerForPlayback(): void {
} }
export function playbackServerDiffersFromActive(): boolean { export function playbackServerDiffersFromActive(): boolean {
const { queueServerId, queue } = usePlayerStore.getState(); const { queueServerId, queueItems } = usePlayerStore.getState();
if ((queue?.length ?? 0) === 0 || !queueServerId) return false; if ((queueItems?.length ?? 0) === 0 || !queueServerId) return false;
const activeSid = useAuthStore.getState().activeServerId; const activeSid = useAuthStore.getState().activeServerId;
const resolvedQueue = resolveServerIdForIndexKey(queueServerId); const resolvedQueue = resolveServerIdForIndexKey(queueServerId);
return !!activeSid && resolvedQueue !== activeSid; return !!activeSid && resolvedQueue !== activeSid;
@@ -65,8 +75,8 @@ export function playbackServerDiffersFromActive(): boolean {
export function shouldHandoffQueueToActiveServer(): boolean { export function shouldHandoffQueueToActiveServer(): boolean {
const activeSid = useAuthStore.getState().activeServerId; const activeSid = useAuthStore.getState().activeServerId;
if (!activeSid) return false; if (!activeSid) return false;
const { queue, queueServerId } = usePlayerStore.getState(); const { queueItems, queueServerId } = usePlayerStore.getState();
if ((queue?.length ?? 0) === 0) return false; if ((queueItems?.length ?? 0) === 0) return false;
if (!queueServerId) return true; if (!queueServerId) return true;
return resolveServerIdForIndexKey(queueServerId) !== activeSid; return resolveServerIdForIndexKey(queueServerId) !== activeSid;
} }
@@ -101,7 +111,7 @@ export function playbackCoverArtForId(coverId: string, displayCssPx: number): {
} }
export function shouldBindQueueServerForPlay( export function shouldBindQueueServerForPlay(
prevQueue: Track[], prevQueue: QueueItemRef[],
newQueue: Track[], newQueue: Track[],
explicitQueueArg: Track[] | undefined, explicitQueueArg: Track[] | undefined,
): boolean { ): boolean {
@@ -109,5 +119,5 @@ export function shouldBindQueueServerForPlay(
if (prevQueue.length === 0) return true; if (prevQueue.length === 0) return true;
if (explicitQueueArg === undefined) return false; if (explicitQueueArg === undefined) return false;
if (explicitQueueArg.length !== prevQueue.length) return true; if (explicitQueueArg.length !== prevQueue.length) return true;
return !explicitQueueArg.every((t, i) => sameQueueTrackId(prevQueue[i]?.id, t.id)); return !explicitQueueArg.every((t, i) => sameQueueTrackId(prevQueue[i]?.trackId, t.id));
} }
+22
View File
@@ -17,3 +17,25 @@ export function resolveIndexKey(serverIdOrKey: string): string {
if (!server) return serverIdOrKey; if (!server) return serverIdOrKey;
return serverIndexKeyFromUrl(server.url) || serverIdOrKey; return serverIndexKeyFromUrl(server.url) || serverIdOrKey;
} }
/**
* Canonical key for queue-thin-state writers: returns the URL-derived index key
* for any known server (whether the caller passed the UUID or the index key),
* and leaves unknown / already-canonical values untouched. Idempotent.
*
* Use this on every write path that lands in `QueueItemRef.serverId` or
* `PlayerState.queueServerId`. Reading sides may still receive legacy UUID
* values from persisted blobs; `serverLookup` helpers accept both shapes.
*/
export function canonicalQueueServerKey(serverIdOrKey: string): string {
if (!serverIdOrKey) return serverIdOrKey;
// Defensive: tests sometimes stub `useAuthStore` without seeding `servers`.
// Treat a missing list as "unknown server" rather than crashing the read.
const servers = useAuthStore.getState().servers;
if (!servers) return serverIdOrKey;
const server = servers.find(s => s.id === serverIdOrKey);
if (server) {
return serverIndexKeyFromUrl(server.url) || serverIdOrKey;
}
return serverIdOrKey;
}