From 45b9229ceb2b16a6ae65609f41ee957f2ed40725 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 27 May 2026 00:10:34 +0200 Subject: [PATCH] refactor(queue): thin-state refs as canonical, full Track via resolver (#872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- CHANGELOG.md | 17 + src/api/coverCache.test.ts | 3 +- src/api/subsonicScrobble.test.ts | 2 +- src/components/ContextMenu.test.tsx | 4 +- src/components/ContextMenu.tsx | 6 +- src/components/FullscreenPlayer.test.tsx | 14 +- src/components/FullscreenPlayer.tsx | 14 +- src/components/MobilePlayerView.tsx | 54 ++- src/components/PlayerBar.test.tsx | 11 +- src/components/QueuePanel.test.tsx | 32 +- src/components/QueuePanel.tsx | 25 +- .../contextMenu/QueueItemContextItems.tsx | 2 +- .../contextMenu/contextMenuItemTypes.ts | 6 +- src/components/miniPlayer/MiniQueue.tsx | 35 +- .../playlist/PlaylistSuggestions.tsx | 16 +- src/components/queuePanel/QueueHeader.tsx | 45 ++- src/components/queuePanel/QueueList.tsx | 40 ++- src/components/queuePanel/QueueToolbar.tsx | 4 +- src/config/shortcutActionRegistry.ts | 7 +- src/cover/usePlaybackCoverArt.test.ts | 3 +- src/cover/usePlaybackCoverArt.ts | 2 +- .../tauriBridge/usePlayerSnapshotPublisher.ts | 20 +- src/hooks/useMiniQueueDrag.ts | 2 +- src/hooks/useNowPlayingPrewarm.test.ts | 5 +- src/hooks/useOrbitHost.ts | 15 +- src/hooks/usePlaybackServerId.test.ts | 2 +- src/hooks/usePlaybackServerId.ts | 2 +- src/hooks/useQueueAutoScroll.ts | 4 +- src/hooks/useQueuePanelDrag.ts | 2 +- src/hooks/useQueueTracks.test.ts | 42 ++- src/hooks/useQueueTracks.ts | 32 +- src/hotCachePrefetch.ts | 45 +-- src/hotCachePrefetch/analysisPrune.ts | 8 +- src/hotCachePrefetch/helpers.ts | 6 +- src/store/applyQueueHistorySnapshot.ts | 48 ++- src/store/audioEventHandlers.ts | 56 +++- src/store/authServerProfileActions.ts | 7 +- src/store/authStore.servers.test.ts | 2 +- src/store/b1QueueServerIdentity.test.ts | 308 ++++++++++++++++++ src/store/hotCacheStore.ts | 11 +- src/store/loudnessBackfillWindow.test.ts | 29 +- src/store/loudnessBackfillWindow.ts | 20 +- src/store/loudnessPrefetch.test.ts | 15 +- src/store/loudnessPrefetch.ts | 4 +- src/store/loudnessRefresh.ts | 4 +- src/store/miscActions.ts | 29 +- src/store/nextAction.ts | 97 ++++-- src/store/pendingStarSync.test.ts | 20 +- src/store/pendingStarSync.ts | 11 +- src/store/playTrackAction.ts | 74 ++++- src/store/playerStore.events.test.ts | 51 +-- src/store/playerStore.misc.test.ts | 40 ++- src/store/playerStore.persistence.test.ts | 178 +++++----- src/store/playerStore.playbackActions.test.ts | 47 +-- src/store/playerStore.queue.test.ts | 96 +++--- src/store/playerStore.ts | 117 +++++-- src/store/playerStoreTypes.ts | 16 +- src/store/queueMutationActions.ts | 171 +++++----- src/store/queueResolverBridge.ts | 31 +- src/store/queueSync.test.ts | 43 +-- src/store/queueSync.ts | 12 +- src/store/queueUndo.test.ts | 22 +- src/store/queueUndo.ts | 19 +- src/store/resumeAction.ts | 15 +- src/store/safeStorage.test.ts | 45 +++ src/store/safeStorage.ts | 58 ++++ src/store/seekAction.ts | 4 +- src/store/skipStarRating.test.ts | 11 +- src/store/skipStarRating.ts | 7 +- src/store/transportLightActions.ts | 2 +- src/store/uiStateActions.ts | 3 +- src/store/updateReplayGainAction.ts | 14 +- src/test/helpers/factories.ts | 28 ++ src/utils/audio/playbackRateRestart.ts | 3 +- .../componentHelpers/contextMenuActions.ts | 10 +- .../componentHelpers/miniPlayerHelpers.ts | 15 +- src/utils/library/queueItemRef.ts | 15 +- src/utils/library/queueRestore.test.ts | 122 +++---- src/utils/library/queueRestore.ts | 115 +++---- src/utils/library/queueTrackResolver.ts | 57 +++- src/utils/library/queueTrackView.test.ts | 52 +++ src/utils/library/queueTrackView.ts | 66 ++++ src/utils/miniPlayerBridge.ts | 52 ++- src/utils/mix/luckyMix.ts | 19 +- src/utils/playback/playSong.ts | 4 +- src/utils/playback/playbackServer.test.ts | 29 +- src/utils/playback/playbackServer.ts | 36 +- src/utils/server/serverIndexKey.ts | 22 ++ 88 files changed, 1965 insertions(+), 944 deletions(-) create mode 100644 src/store/b1QueueServerIdentity.test.ts create mode 100644 src/store/safeStorage.test.ts create mode 100644 src/store/safeStorage.ts create mode 100644 src/utils/library/queueTrackView.test.ts create mode 100644 src/utils/library/queueTrackView.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d6f9b048..97b0d260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 **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 > **🙏 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. diff --git a/src/api/coverCache.test.ts b/src/api/coverCache.test.ts index 7686a55d..9126bab6 100644 --- a/src/api/coverCache.test.ts +++ b/src/api/coverCache.test.ts @@ -6,6 +6,7 @@ import { resetAllStores } from '../test/helpers/storeReset'; import { invokeMock, onInvoke } from '../test/mocks/tauri'; import { coverArtRef } from '../cover/ref'; import { coverCacheEnsure, coverCacheRestHost, librarySqlServerId } from './coverCache'; +import { toQueueItemRefs } from '../utils/library/queueItemRef'; describe('librarySqlServerId', () => { beforeEach(() => { @@ -54,7 +55,7 @@ describe('coverCacheEnsure', () => { const track = makeTrack({ id: 'q1', coverArt: 'cover-1' }); usePlayerStore.setState({ - queue: [track], + queueItems: toQueueItemRefs(playbackServerId, [track]), queueIndex: 0, queueServerId: playbackServerId, currentTrack: track, diff --git a/src/api/subsonicScrobble.test.ts b/src/api/subsonicScrobble.test.ts index e28841e1..8f6cf35a 100644 --- a/src/api/subsonicScrobble.test.ts +++ b/src/api/subsonicScrobble.test.ts @@ -24,7 +24,7 @@ describe('subsonicScrobble', () => { isLoggedIn: true, }); usePlayerStore.setState({ - queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }], + queueItems: [{ serverId: 'a', trackId: 't1' }], queueServerId: 'a', queueIndex: 0, }); diff --git a/src/components/ContextMenu.test.tsx b/src/components/ContextMenu.test.tsx index a691714a..a09db856 100644 --- a/src/components/ContextMenu.test.tsx +++ b/src/components/ContextMenu.test.tsx @@ -48,7 +48,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders'; import { usePlayerStore } from '@/store/playerStore'; import { useAuthStore } from '@/store/authStore'; 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 { fireEvent } from '@testing-library/react'; @@ -183,7 +183,7 @@ describe('ContextMenu — type=artist', () => { describe('ContextMenu — type=queue-item', () => { it('shows a Remove from Queue affordance the song menu does not have', () => { 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); const { container } = renderWithProviders(); expect(container.querySelector('.context-menu')).not.toBeNull(); diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 98de99e7..2d2b06ab 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -27,14 +27,14 @@ export default function ContextMenu() { const navigate = useNavigate(); const navigatePlaybackLibrary = usePlaybackLibraryNavigate(); 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 => ({ contextMenu: s.contextMenu, closeContextMenu: s.closeContextMenu, playTrack: s.playTrack, enqueue: s.enqueue, playNext: s.playNext, - queue: s.queue, + queueItems: s.queueItems, currentTrack: s.currentTrack, removeTrack: s.removeTrack, lastfmLovedCache: s.lastfmLovedCache, @@ -202,7 +202,7 @@ export default function ContextMenu() { playNext={playNext} enqueue={enqueue} removeTrack={removeTrack} - queue={queue} + queue={queueItems} currentTrack={currentTrack} closeContextMenu={closeContextMenu} starredOverrides={starredOverrides} diff --git a/src/components/FullscreenPlayer.test.tsx b/src/components/FullscreenPlayer.test.tsx index ac089d91..89d92ba0 100644 --- a/src/components/FullscreenPlayer.test.tsx +++ b/src/components/FullscreenPlayer.test.tsx @@ -53,7 +53,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders'; import { usePlayerStore } from '@/store/playerStore'; import { useAuthStore } from '@/store/authStore'; 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 { fireEvent } from '@testing-library/react'; @@ -147,12 +147,11 @@ describe('FullscreenPlayer — control wiring', () => { }); it('clicking Previous Track calls previous()', () => { - usePlayerStore.setState({ - queue: [makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], - queueIndex: 1, + seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], { + index: 1, currentTrack: makeTrack({ id: 'b' }), - currentTime: 5, }); + usePlayerStore.setState({ currentTime: 5 }); const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous'); const { getByLabelText } = renderWithProviders( {}} />, @@ -162,9 +161,8 @@ describe('FullscreenPlayer — control wiring', () => { }); it('clicking Next Track calls next()', () => { - usePlayerStore.setState({ - queue: [makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], - queueIndex: 0, + seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], { + index: 0, currentTrack: makeTrack({ id: 'a' }), }); const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next'); diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 87ed97d8..f0e2a348 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -21,6 +21,7 @@ import { FsPlayBtn } from './fullscreenPlayer/FsPlayBtn'; import { useFsDynamicAccent } from '../hooks/useFsDynamicAccent'; import { useFsArtistPortrait } from '../hooks/useFsArtistPortrait'; import { useFsIdleFade } from '../hooks/useFsIdleFade'; +import { useQueueTrackAt } from '../hooks/useQueueTracks'; interface FullscreenPlayerProps { onClose: () => void; @@ -73,13 +74,12 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { const fsPortraitDim = useAuthStore(s => s.fsPortraitDim); const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple'; - // Pre-fetch next track's 300px cover into the IndexedDB cache. - // Selector returns only the coverArt id, so it only re-runs on actual changes. - const nextCoverArt = usePlayerStore(s => { - const q = s.queue; - const idx = s.queueIndex; - return (idx >= 0 && idx + 1 < q.length) ? (q[idx + 1]?.coverArt ?? null) : null; - }); + // Pre-fetch next track's 300px cover into the IndexedDB cache. Resolver-first + // (thin-state): the next ref resolves from the cache (the prefetch window + // around the current index keeps it warm). + const queueIndex = usePlayerStore(s => s.queueIndex); + const nextTrack = useQueueTrackAt(queueIndex + 1); + const nextCoverArt = queueIndex >= 0 ? (nextTrack?.coverArt ?? null) : null; const queueServerId = usePlayerStore(s => s.queueServerId); const activeServerId = useAuthStore(s => s.activeServerId); useEffect(() => { diff --git a/src/components/MobilePlayerView.tsx b/src/components/MobilePlayerView.tsx index c0aa63ac..bdeb4066 100644 --- a/src/components/MobilePlayerView.tsx +++ b/src/components/MobilePlayerView.tsx @@ -6,6 +6,7 @@ import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExtern import { useNavigate } from 'react-router-dom'; import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; import { useTranslation } from 'react-i18next'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { ChevronDown, Play, Pause, SkipBack, SkipForward, Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X, @@ -15,6 +16,11 @@ import { usePlayerStore } from '../store/playerStore'; import { useCachedUrl } from './CachedImage'; import { OpenArtistRefInline } from './OpenArtistRefInline'; import { formatTrackTime } from '../utils/format/formatDuration'; +import { resolveQueueTrack } from '../utils/library/queueTrackView'; +import { + getQueueResolverVersion, + subscribeQueueResolver, +} from '../utils/library/queueTrackResolver'; import LyricsPane from './LyricsPane'; import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress'; import PlaybackDelayModal from './PlaybackDelayModal'; @@ -71,17 +77,42 @@ function useAlbumAccentColor(imageUrl: string): string { // ── 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 }) { const { t } = useTranslation(); - const queue = usePlayerStore(s => s.queue); + const queue = usePlayerStore(s => s.queueItems); const queueIndex = usePlayerStore(s => s.queueIndex); const playTrack = usePlayerStore(s => s.playTrack); const listRef = useRef(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(() => { - const el = listRef.current?.querySelector('.mq-item.active'); - el?.scrollIntoView({ block: 'center', behavior: 'instant' }); + if (queueIndex >= 0 && queue.length > 0) { + rowVirtualizer.scrollToIndex(queueIndex, { align: 'center' }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( @@ -100,13 +131,19 @@ function QueueDrawer({ onClose }: { onClose: () => void }) { {queue.length === 0 ? (
{t('queue.emptyQueue')}
) : ( - queue.map((track, idx) => { +
+ {virtualItems.map(vi => { + const idx = vi.index; + const track = resolveQueueTrack(queue[idx]); const isActive = idx === queueIndex; return (
{ 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(); }} >
@@ -118,7 +155,8 @@ function QueueDrawer({ onClose }: { onClose: () => void }) { {formatTrackTime(track.duration)}
); - }) + })} +
)}
diff --git a/src/components/PlayerBar.test.tsx b/src/components/PlayerBar.test.tsx index dc3e1c94..70f9e75d 100644 --- a/src/components/PlayerBar.test.tsx +++ b/src/components/PlayerBar.test.tsx @@ -37,7 +37,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders'; import { usePlayerStore } from '@/store/playerStore'; import { useAuthStore } from '@/store/authStore'; 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 { fireEvent } from '@testing-library/react'; @@ -96,12 +96,11 @@ describe('PlayerBar — control wiring', () => { }); it('clicking Previous Track calls previous()', () => { - usePlayerStore.setState({ - queue: [makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], - queueIndex: 1, + seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], { + index: 1, 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 { getByLabelText } = renderWithProviders(); @@ -113,7 +112,7 @@ describe('PlayerBar — control wiring', () => { it('clicking Next Track calls next()', () => { const t1 = makeTrack({ id: 'a' }); 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 { getByLabelText } = renderWithProviders(); diff --git a/src/components/QueuePanel.test.tsx b/src/components/QueuePanel.test.tsx index 5cd72c54..428e86a4 100644 --- a/src/components/QueuePanel.test.tsx +++ b/src/components/QueuePanel.test.tsx @@ -41,7 +41,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders'; import { usePlayerStore } from '@/store/playerStore'; import { useAuthStore } from '@/store/authStore'; 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 { readFileSync } from 'node:fs'; 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', () => { const tracks = makeTracks(3); - usePlayerStore.setState({ - queue: tracks, - queueIndex: 0, - currentTrack: tracks[0], - }); + seedQueue(tracks, { index: 0, currentTrack: tracks[0] }); const { container } = renderWithProviders(); const rows = container.querySelectorAll('[data-queue-idx]'); expect(rows.length).toBe(3); @@ -101,11 +97,7 @@ describe('QueuePanel — render surface', () => { it('renders each queue row with the track title text', () => { const t1 = makeTrack({ id: 'q1', title: 'Test Song A' }); const t2 = makeTrack({ id: 'q2', title: 'Test Song B' }); - usePlayerStore.setState({ - queue: [t1, t2], - queueIndex: 0, - currentTrack: t1, - }); + seedQueue([t1, t2], { index: 0, currentTrack: t1 }); const { getAllByText, getByText } = renderWithProviders(); // 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. @@ -117,11 +109,7 @@ describe('QueuePanel — render surface', () => { describe('QueuePanel — toolbar', () => { it('exposes Shuffle / Save Playlist / Load Playlist / Share Queue / Clear via aria-label', () => { const tracks = makeTracks(3); - usePlayerStore.setState({ - queue: tracks, - queueIndex: 0, - currentTrack: tracks[0], - }); + seedQueue(tracks, { index: 0, currentTrack: tracks[0] }); const { getByLabelText } = renderWithProviders(); expect(getByLabelText('Shuffle queue')).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', () => { - usePlayerStore.setState({ - queue: [makeTrack()], - queueIndex: 0, - currentTrack: makeTrack(), - }); + seedQueue([makeTrack()], { index: 0, currentTrack: makeTrack() }); const { getByLabelText } = renderWithProviders(); const shuffle = getByLabelText('Shuffle queue') as HTMLButtonElement; 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. it('queue rows do not declare draggable=true (no HTML5 native drag)', () => { - usePlayerStore.setState({ - queue: makeTracks(3), - queueIndex: 0, - currentTrack: makeTrack(), - }); + seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() }); const { container } = renderWithProviders(); const rows = container.querySelectorAll('[data-queue-idx]'); for (const row of rows) { diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 903a2e40..38d53240 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -72,7 +72,10 @@ function QueuePanelHostOrSolo() { if (!addedBy || addedBy === orbitHostUsername) return t('orbit.queueAddedByYou'); 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 currentTrack = usePlayerStore(s => s.currentTrack); const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); @@ -152,7 +155,7 @@ function QueuePanelHostOrSolo() { }); useQueueAutoScroll({ - queue, + queue: queueItems, queueIndex, currentTrack, queueListRef, @@ -165,11 +168,11 @@ function QueuePanelHostOrSolo() { const [loadModalOpen, setLoadModalOpen] = useState(false); const handleSave = async () => { - if (queue.length === 0) return; + if (queueItems.length === 0) return; if (activePlaylist) { setSaveState('saving'); try { - await updatePlaylist(activePlaylist.id, queue.map(t => t.id)); + await updatePlaylist(activePlaylist.id, queueItems.map(r => r.trackId)); setSaveState('saved'); setTimeout(() => setSaveState('idle'), 1500); } catch (e) { @@ -191,13 +194,13 @@ function QueuePanelHostOrSolo() { }; const handleCopyQueueShare = async () => { - if (queue.length === 0) { + if (queueItems.length === 0) { showToast(t('queue.shareQueueEmpty'), 3000, 'info'); return; } const srv = useAuthStore.getState().getBaseUrl(); 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 })); if (ok) showToast(t('contextMenu.shareCopied')); else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error'); @@ -239,7 +242,7 @@ function QueuePanelHostOrSolo() { )} {!isNowPlayingCollapsed && toolbarButtons.some(b => b.visible && b.id !== 'separator') && ( )} - {currentTrack && queue.length > 0 &&
{t('queue.nextTracks')}
} + {currentTrack && queueItems.length > 0 &&
{t('queue.nextTracks')}
} { try { 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 }); setSaveModalOpen(false); } catch (e) { diff --git a/src/components/contextMenu/QueueItemContextItems.tsx b/src/components/contextMenu/QueueItemContextItems.tsx index 4d91ccdd..1215ef8d 100644 --- a/src/components/contextMenu/QueueItemContextItems.tsx +++ b/src/components/contextMenu/QueueItemContextItems.tsx @@ -31,7 +31,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) { const song = item as Track; return ( <> -
handleAction(() => playTrack(song, queue, undefined, undefined, queueIndex))}> +
handleAction(() => playTrack(song, undefined, undefined, undefined, queueIndex))}> {t('contextMenu.playNow')}
handleAction(() => { diff --git a/src/components/contextMenu/contextMenuItemTypes.ts b/src/components/contextMenu/contextMenuItemTypes.ts index 6c229772..3b2a7cc2 100644 --- a/src/components/contextMenu/contextMenuItemTypes.ts +++ b/src/components/contextMenu/contextMenuItemTypes.ts @@ -1,6 +1,6 @@ import type React from 'react'; 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'; export type RatingKind = 'song' | 'album' | 'artist'; @@ -22,7 +22,9 @@ export interface ContextMenuItemsProps { playNext: (tracks: Track[]) => void; enqueue: (tracks: Track[]) => 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; closeContextMenu: () => void; starredOverrides: Record; diff --git a/src/components/miniPlayer/MiniQueue.tsx b/src/components/miniPlayer/MiniQueue.tsx index 6913b12d..1d133ff2 100644 --- a/src/components/miniPlayer/MiniQueue.tsx +++ b/src/components/miniPlayer/MiniQueue.tsx @@ -1,8 +1,14 @@ import React from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; import type { TFunction } from 'i18next'; import OverlayScrollArea from '../OverlayScrollArea'; 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 = ( payload: { data: string; label: string }, x: number, @@ -30,13 +36,26 @@ export function MiniQueue({ dropTarget, setDropTarget, dropTargetRef, startDrag, ctxIndex, setCtxMenu, jumpTo, t, }: 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 ( { @@ -60,7 +79,10 @@ export function MiniQueue({ {state.queue.length === 0 ? (
{t('miniPlayer.emptyQueue')}
) : ( - state.queue.map((track, i) => { +
+ {virtualItems.map(vi => { + const i = vi.index; + const track = state.queue[i]; let dragStyle: React.CSSProperties = {}; if (isReorderDrag && psyDragFromIdxRef.current === i) { dragStyle = { opacity: 0.4 }; @@ -71,7 +93,9 @@ export function MiniQueue({ } return ( ); - }) + })} +
)}
); diff --git a/src/components/playlist/PlaylistSuggestions.tsx b/src/components/playlist/PlaylistSuggestions.tsx index 890118f1..c37690a4 100644 --- a/src/components/playlist/PlaylistSuggestions.tsx +++ b/src/components/playlist/PlaylistSuggestions.tsx @@ -9,6 +9,7 @@ import { usePreviewStore } from '../../store/previewStore'; import { useThemeStore } from '../../store/themeStore'; import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore'; import { songToTrack } from '../../utils/playback/songToTrack'; +import { getQueueTracksView } from '../../utils/library/queueTrackView'; import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers'; import { formatTrackTime } from '../../utils/format/formatDuration'; @@ -114,19 +115,22 @@ export default function PlaylistSuggestions({ className="playlist-suggestion-play-btn" onClick={e => { e.stopPropagation(); - const { queue, queueIndex, currentTrack, playTrack } = usePlayerStore.getState(); + const { queueItems, queueIndex, currentTrack, playTrack } = usePlayerStore.getState(); const track = songToTrack(song); - if (!currentTrack || queue.length === 0) { + if (!currentTrack || queueItems.length === 0) { playTrack(track, [track]); 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 = [ - ...queue.slice(0, insertAt), + ...resolved.slice(0, insertAt), track, - ...queue.slice(insertAt), + ...resolved.slice(insertAt), ]; - playTrack(track, newQueue); + playTrack(track, newQueue, undefined, undefined, insertAt); }} data-tooltip={t('playlists.playNextSuggestion')} aria-label={t('playlists.playNextSuggestion')} diff --git a/src/components/queuePanel/QueueHeader.tsx b/src/components/queuePanel/QueueHeader.tsx index 101789f6..5df6654b 100644 --- a/src/components/queuePanel/QueueHeader.tsx +++ b/src/components/queuePanel/QueueHeader.tsx @@ -1,16 +1,21 @@ -import { useMemo } from 'react'; +import { useDeferredValue, useMemo, useSyncExternalStore } from 'react'; import { ChevronDown, ListMusic } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { usePlayerStore } from '../../store/playerStore'; 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 { formatLongDuration } from '../../utils/format/formatDuration'; import { formatClockTime } from '../../utils/format/formatClockTime'; +import { resolveQueueTrack } from '../../utils/library/queueTrackView'; +import { + getQueueResolverVersion, + subscribeQueueResolver, +} from '../../utils/library/queueTrackResolver'; interface Props { - queue: Track[]; + queue: QueueItemRef[]; queueIndex: number; activePlaylist: { id: string; name: string } | null; isNowPlayingCollapsed: boolean; @@ -29,16 +34,32 @@ export function QueueHeader({ const clockFormat = useAuthStore((s) => s.clockFormat); const { i18n } = useTranslation(); - const totalSecs = useMemo(() => - queue.reduce((acc: number, track: Track) => acc + (track.duration || 0), 0), - [queue] - ); - const futureTracksDuration = useMemo(() => - queue.slice(queueIndex + 1).reduce((acc: number, track: Track) => acc + (track.duration || 0), 0), - [queue, queueIndex] - ); + // Thin-state: durations come from the resolver cache. The totals re-derive as + // the cache fills (version) and on queue change; tracks past the cache window + // contribute 0 until they resolve. Pure read (no cache mutation) in the memo. + // H1 mitigation: a mass-resolve burst (queue restore, prefetch window slide) + // bumps `version` dozens of times in one frame; useDeferredValue coalesces + // the burst into a single low-priority commit so long queues do not block + // 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; if (queue.length > 0) { diff --git a/src/components/queuePanel/QueueList.tsx b/src/components/queuePanel/QueueList.tsx index 365bd907..dff6ffe0 100644 --- a/src/components/queuePanel/QueueList.tsx +++ b/src/components/queuePanel/QueueList.tsx @@ -1,12 +1,17 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useSyncExternalStore } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Play } from 'lucide-react'; import type { TFunction } from 'i18next'; import OverlayScrollArea from '../OverlayScrollArea'; import { usePlayerStore } from '../../store/playerStore'; 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 { resolveQueueTrack } from '../../utils/library/queueTrackView'; +import { + getQueueResolverVersion, + subscribeQueueResolver, +} from '../../utils/library/queueTrackResolver'; type StartDrag = ( payload: { data: string; label: string }, @@ -15,7 +20,7 @@ type StartDrag = ( ) => void; interface Props { - queue: Track[]; + queue: QueueItemRef[]; queueIndex: number; contextMenu: PlayerState['contextMenu']; playTrack: PlayerState['playTrack']; @@ -31,11 +36,22 @@ interface Props { 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({ queue, queueIndex, contextMenu, playTrack, activeTab, queueListRef, suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget, startDrag, orbitAttributionLabel, luckyRolling, t, }: 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). // Scroll element is the OverlayScrollArea viewport (`queueListRef`); rows have // variable height (radio/auto dividers, lucky-mix loader) so we measure them. @@ -44,11 +60,11 @@ export function QueueList({ getScrollElement: () => queueListRef.current, estimateSize: () => 52, 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 // ResizeObserver reports the real size (SSR / jsdom, where the observer // never fires). The real height overrides this on first measure. - initialRect: { width: 0, height: 600 }, + initialRect: INITIAL_RECT, }); const virtualItems = rowVirtualizer.getVirtualItems(); const totalSize = rowVirtualizer.getTotalSize(); @@ -93,10 +109,11 @@ export function QueueList({
{virtualItems.map(vi => { const idx = vi.index; - const track = queue[idx]; + const base = queue[idx]; + const track = resolveQueueTrack(base); const isPlaying = idx === queueIndex; - const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded); - const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded); + const isFirstAutoAdded = base.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded); + const isFirstRadioAdded = base.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded); let dragStyle: React.CSSProperties = {}; 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' : ''}`} onClick={() => { suppressNextAutoScrollRef.current = true; - // Pass the row index so a click on a duplicate track lands on - // *this* slot, not the first occurrence (issue #500). - playTrack(track, queue, undefined, undefined, idx); + // Same-queue jump: undefined keeps the canonical refs; the row + // index lands a click on a duplicate track on *this* slot, not + // the first occurrence (issue #500). + playTrack(track, undefined, undefined, undefined, idx); }} onContextMenu={(e) => { e.preventDefault(); diff --git a/src/components/queuePanel/QueueToolbar.tsx b/src/components/queuePanel/QueueToolbar.tsx index 16e30834..6d04cef1 100644 --- a/src/components/queuePanel/QueueToolbar.tsx +++ b/src/components/queuePanel/QueueToolbar.tsx @@ -3,14 +3,14 @@ import { Check, FolderOpen, Infinity, MoveRight, Save, Share2, Shuffle, Trash2, Waves, } from 'lucide-react'; import type { TFunction } from 'i18next'; -import type { Track } from '../../store/playerStoreTypes'; +import type { QueueItemRef } from '../../store/playerStoreTypes'; import type { QueueToolbarButtonConfig, QueueToolbarButtonId, } from '../../store/queueToolbarStore'; interface Props { - queue: Track[]; + queue: QueueItemRef[]; activePlaylist: { id: string; name: string } | null; saveState: 'idle' | 'saving' | 'saved'; toolbarButtons: QueueToolbarButtonConfig[]; diff --git a/src/config/shortcutActionRegistry.ts b/src/config/shortcutActionRegistry.ts index 239afead..e00ad55f 100644 --- a/src/config/shortcutActionRegistry.ts +++ b/src/config/shortcutActionRegistry.ts @@ -331,7 +331,7 @@ export const SHORTCUT_ACTION_REGISTRY = { runInMiniWindow: false, run: () => { const store = usePlayerStore.getState(); - const { currentTrack, queue, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store; + const { currentTrack, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store; stop(); resetAudioPause(); invoke('audio_stop') @@ -341,9 +341,10 @@ export const SHORTCUT_ACTION_REGISTRY = { try { const fresh = await getSong(currentTrack.id); 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 { - playTrack(currentTrack, queue, true); + playTrack(currentTrack, undefined, true); } } else { await initializeFromServerQueue(); diff --git a/src/cover/usePlaybackCoverArt.test.ts b/src/cover/usePlaybackCoverArt.test.ts index c508637f..6688cf04 100644 --- a/src/cover/usePlaybackCoverArt.test.ts +++ b/src/cover/usePlaybackCoverArt.test.ts @@ -6,6 +6,7 @@ import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; import { makeTrack } from '../test/helpers/factories'; import { resetAllStores } from '../test/helpers/storeReset'; +import { toQueueItemRefs } from '../utils/library/queueItemRef'; const hoisted = vi.hoisted(() => ({ useCoverArtMock: vi.fn( @@ -39,7 +40,7 @@ function seedPlaybackState(): { active: string; playback: string } { useAuthStore.getState().setActiveServer(active); const track = makeTrack({ id: 'song-1', coverArt: 'cover-1' }); usePlayerStore.setState({ - queue: [track], + queueItems: toQueueItemRefs(playback, [track]), queueIndex: 0, queueServerId: playback, currentTrack: track, diff --git a/src/cover/usePlaybackCoverArt.ts b/src/cover/usePlaybackCoverArt.ts index 8b0aa786..70abe118 100644 --- a/src/cover/usePlaybackCoverArt.ts +++ b/src/cover/usePlaybackCoverArt.ts @@ -11,7 +11,7 @@ export function usePlaybackCoverArt( displayCssPx: number, ): CoverArtHandle { 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 serversFingerprint = useAuthStore(s => s.servers diff --git a/src/hooks/tauriBridge/usePlayerSnapshotPublisher.ts b/src/hooks/tauriBridge/usePlayerSnapshotPublisher.ts index 2f894df5..5505a69c 100644 --- a/src/hooks/tauriBridge/usePlayerSnapshotPublisher.ts +++ b/src/hooks/tauriBridge/usePlayerSnapshotPublisher.ts @@ -3,6 +3,11 @@ import { invoke } from '@tauri-apps/api/core'; import { getPlaybackProgressSnapshot } from '../../store/playbackProgress'; import { usePlayerStore } from '../../store/playerStore'; 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 * writes atomically). Coalesces store changes through a 200 ms debounce and @@ -27,12 +32,19 @@ export function usePlayerSnapshotPublisher() { ct != null ? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred)) : 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 = { current_track: s.currentTrack, current_radio: s.currentRadio, - queue: s.queue, - queue_index: s.queueIndex, - queue_length: s.queue.length, + queue: windowedQueue, + queue_index: s.queueIndex - winStart, + queue_length: total, is_playing: s.isPlaying, current_time: getPlaybackProgressSnapshot().currentTime, volume: s.volume, @@ -50,7 +62,7 @@ export function usePlayerSnapshotPublisher() { trackId: s.currentTrack?.id ?? null, radioId: s.currentRadio?.id ?? null, queueIndex: s.queueIndex, - queueLength: s.queue.length, + queueLength: total, isPlaying: s.isPlaying, volume: Math.round(s.volume * 100), repeatMode: s.repeatMode, diff --git a/src/hooks/useMiniQueueDrag.ts b/src/hooks/useMiniQueueDrag.ts index ed0c99f7..f4a53145 100644 --- a/src/hooks/useMiniQueueDrag.ts +++ b/src/hooks/useMiniQueueDrag.ts @@ -52,7 +52,7 @@ export function useMiniQueueDrag({ if (parsed.type !== 'queue_reorder') return; const fromIdx = parsed.index as number; psyDragFromIdxRef.current = null; - const queueLen = usePlayerStore.getState().queue.length || fallbackQueueLen; + const queueLen = usePlayerStore.getState().queueItems.length || fallbackQueueLen; const insertIdx = tgt ? (tgt.before ? tgt.idx : tgt.idx + 1) : queueLen; diff --git a/src/hooks/useNowPlayingPrewarm.test.ts b/src/hooks/useNowPlayingPrewarm.test.ts index fef1bf3c..a78c1478 100644 --- a/src/hooks/useNowPlayingPrewarm.test.ts +++ b/src/hooks/useNowPlayingPrewarm.test.ts @@ -8,6 +8,7 @@ import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; import { makeTrack } from '../test/helpers/factories'; import { resetAllStores } from '../test/helpers/storeReset'; +import { toQueueItemRefs } from '../utils/library/queueItemRef'; vi.mock('../api/coverCache', () => ({ coverCachePeekBatch: vi.fn(async () => ({})), @@ -51,7 +52,7 @@ describe('useNowPlayingPrewarm', () => { coverArt: 'cover-1', }); usePlayerStore.setState({ - queue: [track], + queueItems: toQueueItemRefs(playback, [track]), queueIndex: 0, queueServerId: playback, currentTrack: track, @@ -76,7 +77,7 @@ describe('useNowPlayingPrewarm', () => { const { active, playback } = seedServers(); const track = makeTrack({ id: 'song-2', coverArt: 'cover-2' }); usePlayerStore.setState({ - queue: [track], + queueItems: toQueueItemRefs(playback, [track]), queueIndex: 0, queueServerId: playback, currentTrack: null, diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts index 24e4faf0..4b4aa46e 100644 --- a/src/hooks/useOrbitHost.ts +++ b/src/hooks/useOrbitHost.ts @@ -108,23 +108,24 @@ export function useOrbitHost(): void { && (Date.now() - afterSweep.lastShuffle >= effectiveShuffleIntervalMs(afterSweep)); const afterShuffle = maybeShuffleQueue(afterSweep); if (shouldShuffleNow) { - const before = usePlayerStore.getState().queue.length; + const before = usePlayerStore.getState().queueItems.length; usePlayerStore.getState().shuffleUpcomingQueue(); 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 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 // every suggestion we've ever seen this session, so it's the right // attribution source even after the track has been merged into the // host's player queue. const suggesterByTrack = new Map(); for (const q of afterShuffle.queue) suggesterByTrack.set(q.trackId, q.addedBy); - const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(t => ({ - trackId: t.id, - addedBy: suggesterByTrack.get(t.id) ?? base.host, + const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(r => ({ + trackId: r.trackId, + addedBy: suggesterByTrack.get(r.trackId) ?? base.host, })); const next: OrbitState = { ...afterShuffle, @@ -204,7 +205,7 @@ export function useOrbitHost(): void { for (const { track } of toEnqueue) { const live = usePlayerStore.getState(); 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 pos = from + Math.floor(Math.random() * span); player.enqueueAt([track], pos); diff --git a/src/hooks/usePlaybackServerId.test.ts b/src/hooks/usePlaybackServerId.test.ts index 73d45a16..54afb57a 100644 --- a/src/hooks/usePlaybackServerId.test.ts +++ b/src/hooks/usePlaybackServerId.test.ts @@ -19,7 +19,7 @@ describe('usePlaybackServerId', () => { isLoggedIn: true, }); usePlayerStore.setState({ - queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }], + queueItems: [{ serverId: 'a', trackId: 't1' }], queueServerId: 'a', queueIndex: 0, }); diff --git a/src/hooks/usePlaybackServerId.ts b/src/hooks/usePlaybackServerId.ts index ebf4fa19..d4e0f968 100644 --- a/src/hooks/usePlaybackServerId.ts +++ b/src/hooks/usePlaybackServerId.ts @@ -9,7 +9,7 @@ import { getPlaybackServerId } from '../utils/playback/playbackServer'; */ export function usePlaybackServerId(): string { 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); return useMemo( () => getPlaybackServerId(), diff --git a/src/hooks/useQueueAutoScroll.ts b/src/hooks/useQueueAutoScroll.ts index 6a6931e7..1bf844f4 100644 --- a/src/hooks/useQueueAutoScroll.ts +++ b/src/hooks/useQueueAutoScroll.ts @@ -1,9 +1,9 @@ import React, { useLayoutEffect } from 'react'; import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo'; -import type { Track } from '../store/playerStoreTypes'; +import type { QueueItemRef, Track } from '../store/playerStoreTypes'; interface Args { - queue: Track[]; + queue: QueueItemRef[]; queueIndex: number; currentTrack: Track | null; queueListRef: React.RefObject; diff --git a/src/hooks/useQueuePanelDrag.ts b/src/hooks/useQueuePanelDrag.ts index 281f8785..d8e9950b 100644 --- a/src/hooks/useQueuePanelDrag.ts +++ b/src/hooks/useQueuePanelDrag.ts @@ -66,7 +66,7 @@ export function useQueuePanelDrag({ const insertIdx = dropTarget ? (dropTarget.before ? dropTarget.idx : dropTarget.idx + 1) - : usePlayerStore.getState().queue.length; + : usePlayerStore.getState().queueItems.length; if (parsedData.type === 'queue_reorder') { const fromIdx: number = parsedData.index; diff --git a/src/hooks/useQueueTracks.test.ts b/src/hooks/useQueueTracks.test.ts index f4e09702..f84ee224 100644 --- a/src/hooks/useQueueTracks.test.ts +++ b/src/hooks/useQueueTracks.test.ts @@ -3,9 +3,8 @@ import { renderHook } from '@testing-library/react'; import { usePlayerStore } from '@/store/playerStore'; import type { Track } from '@/store/playerStoreTypes'; import { getCachedTrack, _resetQueueResolverForTest } from '@/utils/library/queueTrackResolver'; +import { seedQueue } from '@/test/helpers/factories'; 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 => ({ id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1, ...over }); @@ -13,25 +12,37 @@ const track = (id: string, over: Partial = {}): Track => describe('useQueueTracks selectors', () => { beforeEach(() => { _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', () => { - 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(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', () => { usePlayerStore.setState({ currentTrack: track('cur') }); expect(renderHook(() => useCurrentTrack()).result.current?.id).toBe('cur'); }); - it('useQueueItems derives thin refs (serverId + flags) from the queue', () => { - usePlayerStore.setState({ - queueServerId: 's1', - queue: [track('t1'), track('t2', { radioAdded: true })], - }); + it('useQueueItems returns the canonical thin refs (serverId + flags)', () => { + seedQueue([track('t1'), track('t2', { radioAdded: true })], { serverId: 's1', currentTrack: null }); const { result } = renderHook(() => useQueueItems()); expect(result.current).toEqual([ { serverId: 's1', trackId: 't1' }, @@ -40,20 +51,15 @@ describe('useQueueTracks selectors', () => { }); }); -describe('queueResolverBridge', () => { +describe('seedQueue resolver seeding', () => { beforeEach(() => { _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', () => { - usePlayerStore.setState({ queue: [track('t1'), track('t2')], queueIndex: 0, queueServerId: 's1' }); + it('seeds the resolver cache with the queue tracks under the queue server', () => { + seedQueue([track('t1'), track('t2')], { serverId: 's1', currentTrack: null }); expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1'); expect(getCachedTrack({ serverId: 's1', trackId: 't2' })?.id).toBe('t2'); }); - - it('does not seed when there is no playback server', () => { - usePlayerStore.setState({ queue: [track('t1')], queueIndex: 0, queueServerId: null }); - expect(getCachedTrack({ serverId: '', trackId: 't1' })).toBeUndefined(); - }); }); diff --git a/src/hooks/useQueueTracks.ts b/src/hooks/useQueueTracks.ts index 5b2bc5f9..92f9ee49 100644 --- a/src/hooks/useQueueTracks.ts +++ b/src/hooks/useQueueTracks.ts @@ -1,18 +1,30 @@ -import { useMemo } from 'react'; +import { useMemo, useSyncExternalStore } from 'react'; import { usePlayerStore } from '../store/playerStore'; 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 - * phase 3. Today they read the canonical `queue: Track[]`; once it's dropped - * (phase 4) the implementations move to the resolver (`queueTrackResolver`) - * without changing these signatures. + * Stable queue selectors (queue thin-state). The store is refs-canonical now: + * full `Track`s come from the resolver cache (placeholder until a fetch lands), + * with session star/rating overrides (F4) merged on read via resolveQueueTrack. */ /** The track at a queue index, or 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. */ @@ -20,9 +32,7 @@ export function useCurrentTrack(): Track | null { 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[] { - const queue = usePlayerStore(s => s.queue); - const serverId = usePlayerStore(s => s.queueServerId); - return useMemo(() => toQueueItemRefs(serverId ?? '', queue), [serverId, queue]); + return usePlayerStore(s => s.queueItems); } diff --git a/src/hotCachePrefetch.ts b/src/hotCachePrefetch.ts index 4d47383a..767c625b 100644 --- a/src/hotCachePrefetch.ts +++ b/src/hotCachePrefetch.ts @@ -1,6 +1,7 @@ import { buildStreamUrlForServer } from './api/subsonicStreamUrl'; 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 { useAuthStore } from './store/authStore'; import { useHotCacheStore } from './store/hotCacheStore'; @@ -113,12 +114,9 @@ async function runWorker() { } const player = usePlayerStore.getState(); - const { queue, queueIndex } = player; - const wantIds = new Set( - queue - .slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD) - .map(t => t.id), - ); + const { queueItems, queueIndex } = player; + const upcomingRefs = queueItems.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD); + const wantIds = new Set(upcomingRefs.map(r => r.trackId)); if (!wantIds.has(job.trackId)) { hotCacheFrontendDebug({ event: 'prefetch-skip-job', @@ -130,8 +128,11 @@ async function runWorker() { continue; } - const track = queue.find(t => t.id === job.trackId); - if (!track) { + // Thin-state: the upcoming window sits inside the resolver-warm range, so + // 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({ event: 'prefetch-skip-job', trackId: job.trackId, @@ -139,10 +140,11 @@ async function runWorker() { }); continue; } + const track = resolveQueueTrack(jobRef); 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 isImmediateNext = queue[queueIndex + 1]?.id === job.trackId; + const isImmediateNext = queueItems[queueIndex + 1]?.trackId === job.trackId; if (!isImmediateNext && occupied + est > maxBytes) { hotCacheFrontendDebug({ event: 'prefetch-skip-job', @@ -172,7 +174,7 @@ async function runWorker() { const authAfter = useAuthStore.getState(); const maxAfter = Math.max(0, authAfter.hotCacheMaxMb) * 1024 * 1024; await useHotCacheStore.getState().evictToFit( - fresh.queue, + fresh.queueItems, fresh.queueIndex, maxAfter, getPlaybackCacheServerKey(), @@ -217,7 +219,7 @@ async function replanNow() { const customDir = auth.hotCacheDownloadDir || null; if (maxBytes <= 0) return; - const { queue, queueIndex, currentRadio } = usePlayerStore.getState(); + const { queueItems, queueIndex, currentRadio } = usePlayerStore.getState(); if (currentRadio) { hotCacheFrontendDebug({ event: 'replan-skip', reason: 'radio-mode' }); return; @@ -225,15 +227,18 @@ async function replanNow() { 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 // skip prefetch for upcoming tracks that no longer have on-disk rows. const hotEntries = useHotCacheStore.getState().entries; - const targets = queue.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD); - const immediateNextId = queue[queueIndex + 1]?.id; - let projectedOccupied = sumCachedBytesInProtectedWindow(queue, queueIndex, serverId, hotEntries); + // Thin-state: resolve only the small upcoming window (within the resolver-warm + // range) to full Tracks for the size estimates / suffix. + 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 skipped: { trackId: string; reason: string }[] = []; for (const t of targets) { @@ -278,7 +283,7 @@ export function initHotCachePrefetch(): () => void { let lastQueueRef: unknown = null; let lastQueueIndex = -1; const unsubPlayer = usePlayerStore.subscribe(state => { - const q = state.queue; + const q = state.queueItems; const i = state.queueIndex; if (q === lastQueueRef && i === lastQueueIndex) return; const prevIdx = lastQueueIndex; @@ -288,11 +293,11 @@ export function initHotCachePrefetch(): () => void { lastQueueIndex = i; scheduleAnalysisQueuePruneFromPlaybackQueue(); 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 graceSid = getPlaybackCacheServerKey(); if (left && graceSid) { - bumpHotCachePreviousTrackGrace(left.id, graceSid, a.hotCacheDebounceSec); + bumpHotCachePreviousTrackGrace(left.trackId, graceSid, a.hotCacheDebounceSec); scheduleEvictAfterPreviousGrace(); } } diff --git a/src/hotCachePrefetch/analysisPrune.ts b/src/hotCachePrefetch/analysisPrune.ts index 025c1ed7..194fb404 100644 --- a/src/hotCachePrefetch/analysisPrune.ts +++ b/src/hotCachePrefetch/analysisPrune.ts @@ -19,7 +19,7 @@ type AnalysisPrunePendingResult = { }; export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void { - const { queue, currentTrack, queueIndex } = usePlayerStore.getState(); + const { queueItems, currentTrack, queueIndex } = usePlayerStore.getState(); const { preloadMode } = useAuthStore.getState(); const rawServerId = getPlaybackServerId() ?? ''; const server = useAuthStore.getState().servers.find(s => s.id === rawServerId); @@ -34,12 +34,12 @@ export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void { keepTrackIds.push(tid); }; pushId(currentTrack?.id); - for (const track of queue) { - pushId(track.id); + for (const ref of queueItems) { + pushId(ref.trackId); if (keepTrackIds.length >= 1000) break; } const middleTrackIds = collectPlaybackMiddlePriorityTrackIds( - queue, + queueItems, queueIndex, currentTrack, preloadMode, diff --git a/src/hotCachePrefetch/helpers.ts b/src/hotCachePrefetch/helpers.ts index 36ace8c7..c2512847 100644 --- a/src/hotCachePrefetch/helpers.ts +++ b/src/hotCachePrefetch/helpers.ts @@ -1,5 +1,5 @@ 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 { 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`). */ export function sumCachedBytesInProtectedWindow( - queue: Track[], + queue: QueueItemRef[], queueIndex: number, serverId: string, entries: Record, @@ -32,7 +32,7 @@ export function sumCachedBytesInProtectedWindow( const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT); let sum = 0; 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; } return sum; diff --git a/src/store/applyQueueHistorySnapshot.ts b/src/store/applyQueueHistorySnapshot.ts index 3eeb8c65..b750c274 100644 --- a/src/store/applyQueueHistorySnapshot.ts +++ b/src/store/applyQueueHistorySnapshot.ts @@ -10,8 +10,11 @@ import { } from './engineState'; import { clearPreloadingIds } from './gaplessPreloadState'; import { deriveNormalizationSnapshot } from './normalizationSnapshot'; -import type { PlayerState } from './playerStoreTypes'; -import { sameQueueTrackId, shallowCloneQueueTracks } from '../utils/playback/queueIdentity'; +import type { PlayerState, QueueItemRef } from './playerStoreTypes'; +import { resolveQueueTrack } from '../utils/library/queueTrackView'; +import { seedQueueResolver } from '../utils/library/queueTrackResolver'; +import { canonicalQueueServerKey } from '../utils/server/serverIndexKey'; +import { sameQueueTrackId } from '../utils/playback/queueIdentity'; import { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore'; import { setPendingQueueListScrollTop, @@ -54,7 +57,13 @@ export function applyQueueHistorySnapshot( if (prior.currentRadio) { stopRadio(); } - let nextQueue = shallowCloneQueueTracks(snap.queue); + // Rebuild the display queue from the snapshot's thin refs (thin-state): + // resolver cache → placeholder. The canonical queue is the snapshot's refs; + // this resolved `nextQueue` is only for the engine restore / normalization / + // prepend logic below. The playing track is restored separately from the full + // `snap.currentTrack`. + let nextQueue = snap.queueItems.map(ref => resolveQueueTrack(ref)); + let nextItems: QueueItemRef[] = [...snap.queueItems]; let nextIndex = snap.queueIndex; let nextTrack = snap.currentTrack ? { ...snap.currentTrack } : null; @@ -62,7 +71,23 @@ export function applyQueueHistorySnapshot( const playing = prior.currentTrack; const pos = nextQueue.findIndex(t => sameQueueTrackId(t.id, playing.id)); if (pos === -1) { + // Prepend ref must bind to the *snapshot's* playback server (H3): a live + // server switch racing the undo would otherwise stamp the prepended ref + // with the new server, mis-resolving the still-playing track. Snapshot + // fields take precedence; existing refs in the snapshot are the next + // fallback (they share the snapshot's server); live `queueServerId` is + // last resort. Canonical key everywhere (B1). + const snapshotSid = + snap.queueServerId + ?? snap.queueItems[0]?.serverId + ?? get().queueServerId + ?? ''; + const prependServerId = canonicalQueueServerKey(snapshotSid); nextQueue = [{ ...playing }, ...nextQueue]; + nextItems = [ + { serverId: prependServerId, trackId: playing.id }, + ...nextItems, + ]; nextIndex = 0; nextTrack = { ...playing }; } else { @@ -157,8 +182,19 @@ export function applyQueueHistorySnapshot( } } + // Seed the resolver with the playing track so its ref always resolves (it may + // have been prepended and not yet in the cache window). Same canonical key + // source as the prepend above — keeps cache bucket and ref serverId in lockstep + // even when a server switch races the undo. + const seedSid = canonicalQueueServerKey( + snap.queueServerId + ?? snap.queueItems[0]?.serverId + ?? get().queueServerId + ?? '', + ); + if (seedSid && nextTrack) seedQueueResolver(seedSid, [nextTrack]); set({ - queue: nextQueue, + queueItems: nextItems, queueIndex: nextIndex, currentTrack: nextTrack, currentRadio: null, @@ -174,7 +210,7 @@ export function applyQueueHistorySnapshot( if (!nextTrack) { invoke('audio_stop').catch(console.error); setIsAudioPaused(false); - syncQueueToServer(nextQueue, null, 0); + syncQueueToServer(nextItems, null, 0); if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) { setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop)); } @@ -201,6 +237,6 @@ export function applyQueueHistorySnapshot( if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) { setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop)); } - syncQueueToServer(nextQueue, nextTrack, tRestore); + syncQueueToServer(nextItems, nextTrack, tRestore); return true; } diff --git a/src/store/audioEventHandlers.ts b/src/store/audioEventHandlers.ts index 23b582bc..ed4d1134 100644 --- a/src/store/audioEventHandlers.ts +++ b/src/store/audioEventHandlers.ts @@ -1,5 +1,6 @@ import { reportNowPlaying, scrobbleSong } from '../api/subsonicScrobble'; import type { Track } from './playerStoreTypes'; +import { resolveQueueTrack } from '../utils/library/queueTrackView'; import { invoke } from '@tauri-apps/api/core'; import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm'; import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate'; @@ -179,7 +180,7 @@ export function handleAudioProgress( if (store.isPlaying && !store.currentRadio) { const now = Date.now(); if (now - getLastQueueHeartbeatAt() >= 15_000) { - void flushQueueSyncToServer(store.queue, track, displayTime); + void flushQueueSyncToServer(store.queueItems, track, displayTime); } } @@ -246,11 +247,18 @@ export function handleAudioProgress( ); if (shouldChainGapless || shouldBytePreload || shouldPreloadLocalFileAnalysis || gaplessEnabled) { - const { queue, queueIndex, repeatMode } = store; + const { queueItems, queueIndex, repeatMode } = store; const nextIdx = queueIndex + 1; + // Next track for preload/chain. The resolver bridge keeps the window around + // queueIndex warm, so the next ref is cache-hot; resolveQueueTrack falls + // back to a placeholder (correct trackId, so URL building still works) on a + // cold miss. current track = `track` (full) — never resolved. + const nextRef = repeatMode === 'one' + ? null + : (nextIdx < queueItems.length ? queueItems[nextIdx] : (repeatMode === 'all' ? queueItems[0] : null)); const nextTrack = repeatMode === 'one' ? track - : (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null)); + : (nextRef ? resolveQueueTrack(nextRef) : null); if (!nextTrack || nextTrack.id === track.id) return; // Gapless backup: keep next-track bytes ready even if chain/decode misses @@ -311,10 +319,12 @@ export function handleAudioProgress( void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false }); const authState = useAuthStore.getState(); // Auto-mode neighbours for the *next* track: current track on its left, - // queue[nextIdx+1] on its right. - const nextNeighbour = nextIdx + 1 < queue.length - ? queue[nextIdx + 1] - : (repeatMode === 'all' && queue.length > 0 ? queue[0] : null); + // queueItems[nextIdx+1] on its right (resolved; placeholder on a cold miss + // — only its replaygain tags matter, which a placeholder lacks → fallback). + const nextNeighbourRef = nextIdx + 1 < queueItems.length + ? queueItems[nextIdx + 1] + : (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null); + const nextNeighbour = nextNeighbourRef ? resolveQueueTrack(nextNeighbourRef) : null; const replayGainDb = resolveReplayGainDb( nextTrack, track, nextNeighbour, isReplayGainActive(), authState.replayGainMode, @@ -355,7 +365,7 @@ export function handleAudioEnded(): void { return; } - const { repeatMode, currentTrack, queue, queueIndex } = usePlayerStore.getState(); + const { repeatMode, currentTrack, queueIndex } = usePlayerStore.getState(); setIsAudioPaused(false); usePlayerStore.setState({ isPlaying: false, @@ -379,7 +389,8 @@ export function handleAudioEnded(): void { ); } // Pin to the current slot — the track may appear elsewhere in the queue. - usePlayerStore.getState().playTrack(currentTrack, queue, false, false, queueIndex); + // No-arg queue: playTrack keeps the canonical refs and just re-binds. + usePlayerStore.getState().playTrack(currentTrack, undefined, false, false, queueIndex); } else { usePlayerStore.getState().next(false); } @@ -401,7 +412,7 @@ export function handleAudioTrackSwitched(_duration: number): void { if (store.currentTrack?.id) { useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id); } - const { queue, queueIndex, repeatMode } = store; + const { queueItems, queueIndex, repeatMode } = store; const nextIdx = queueIndex + 1; let nextTrack: Track | null = null; let newIndex = queueIndex; @@ -409,11 +420,14 @@ export function handleAudioTrackSwitched(_duration: number): void { if (repeatMode === 'one' && store.currentTrack) { nextTrack = store.currentTrack; // queueIndex stays the same - } else if (nextIdx < queue.length) { - nextTrack = queue[nextIdx]; + } else if (nextIdx < queueItems.length) { + // The Rust engine already chained this source sample-accurately, so it must + // have been preloaded — meaning the resolver had it cached. resolveQueueTrack + // returns the full Track from cache (placeholder only on an unexpected miss). + nextTrack = resolveQueueTrack(queueItems[nextIdx]); newIndex = nextIdx; - } else if (repeatMode === 'all' && queue.length > 0) { - nextTrack = queue[0]; + } else if (repeatMode === 'all' && queueItems.length > 0) { + nextTrack = resolveQueueTrack(queueItems[0]); newIndex = 0; } @@ -425,10 +439,20 @@ export function handleAudioTrackSwitched(_duration: number): void { const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId); const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl); + // Neighbour window for normalization (replaygain album-mode reads prev/next). + // current track on the left, the track after `nextTrack` on the right. + const switchPrev = store.currentTrack; + const switchNextNextRef = newIndex + 1 < queueItems.length ? queueItems[newIndex + 1] : null; + const switchNeighbourWindow: Track[] = [ + switchPrev ?? nextTrack, + nextTrack, + ...(switchNextNextRef ? [resolveQueueTrack(switchNextNextRef)] : []), + ]; + usePlayerStore.setState({ currentTrack: nextTrack, waveformBins: null, - ...deriveNormalizationSnapshot(nextTrack, queue, newIndex), + ...deriveNormalizationSnapshot(nextTrack, switchNeighbourWindow, 1), normalizationDbgSource: 'track-switched', normalizationDbgTrackId: nextTrack.id, queueIndex: newIndex, @@ -463,7 +487,7 @@ export function handleAudioTrackSwitched(_duration: number): void { })); }); } - syncQueueToServer(queue, nextTrack, 0); + syncQueueToServer(queueItems, nextTrack, 0); touchHotCacheOnPlayback(nextTrack.id, getPlaybackCacheServerKey()); } diff --git a/src/store/authServerProfileActions.ts b/src/store/authServerProfileActions.ts index 9f4467ca..8e449947 100644 --- a/src/store/authServerProfileActions.ts +++ b/src/store/authServerProfileActions.ts @@ -2,6 +2,7 @@ import type { AuthState } from './authStoreTypes'; import { generateId } from './authStoreHelpers'; import { usePlayerStore } from './playerStore'; import { clearQueueServerForPlayback } from '../utils/playback/playbackServer'; +import { resolveServerIdForIndexKey } from '../utils/server/serverLookup'; type SetState = ( partial: Partial | ((state: AuthState) => Partial), @@ -40,7 +41,11 @@ export function createServerProfileActions(set: SetState): Pick< }, removeServer: (id) => { - if (usePlayerStore.getState().queueServerId === id) { + // queueServerId is the canonical index key (B1); resolve the + // canonical id back to a server UUID before comparing so a profile + // delete still clears the matching queue binding. + const queueSid = usePlayerStore.getState().queueServerId; + if (queueSid && resolveServerIdForIndexKey(queueSid) === id) { clearQueueServerForPlayback(); } set(s => { diff --git a/src/store/authStore.servers.test.ts b/src/store/authStore.servers.test.ts index 2a592c05..5e9ef973 100644 --- a/src/store/authStore.servers.test.ts +++ b/src/store/authStore.servers.test.ts @@ -130,7 +130,7 @@ describe('removeServer', () => { const { a, b } = addThree(); useAuthStore.getState().setActiveServer(b); usePlayerStore.setState({ - queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }], + queueItems: [{ serverId: a, trackId: 't1' }], queueServerId: a, queueIndex: 0, }); diff --git a/src/store/b1QueueServerIdentity.test.ts b/src/store/b1QueueServerIdentity.test.ts new file mode 100644 index 00000000..0cda7718 --- /dev/null +++ b/src/store/b1QueueServerIdentity.test.ts @@ -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, + ) => ReturnType; + 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(); + } + }); +}); + diff --git a/src/store/hotCacheStore.ts b/src/store/hotCacheStore.ts index 1021babe..35e7977d 100644 --- a/src/store/hotCacheStore.ts +++ b/src/store/hotCacheStore.ts @@ -1,4 +1,4 @@ -import type { Track } from './playerStoreTypes'; +import type { QueueItemRef } from './playerStoreTypes'; import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; @@ -32,9 +32,10 @@ interface HotCacheState { touchPlayed: (trackId: string, serverId: string) => void; removeEntry: (trackId: string, serverId: string) => void; totalBytes: () => number; - /** Evict until total size ≤ maxBytes. Protects current + next (+ grace for last «previous» track). */ + /** Evict until total size ≤ maxBytes. Protects current + next (+ grace for last «previous» track). + * Thin-state: only track ids / positions matter here, so it takes the canonical refs. */ evictToFit: ( - queue: Track[], + queue: QueueItemRef[], queueIndex: number, maxBytes: number, activeServerId: string, @@ -149,11 +150,11 @@ export const useHotCacheStore = create()( const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT); const protectedIds = new Set(); for (let i = protectLo; i <= protectHi; i++) { - protectedIds.add(queue[i].id); + protectedIds.add(queue[i].trackId); } const indexOfInQueue = (trackId: string): number | null => { - const idx = queue.findIndex(t => t.id === trackId); + const idx = queue.findIndex(r => r.trackId === trackId); return idx >= 0 ? idx : null; }; diff --git a/src/store/loudnessBackfillWindow.test.ts b/src/store/loudnessBackfillWindow.test.ts index a69cdef5..39b7c5fa 100644 --- a/src/store/loudnessBackfillWindow.test.ts +++ b/src/store/loudnessBackfillWindow.test.ts @@ -4,7 +4,7 @@ * = current track + next `LOUDNESS_BACKFILL_WINDOW_AHEAD` entries, with * duplicates collapsed. */ -import type { Track } from './playerStoreTypes'; +import type { QueueItemRef, Track } from './playerStoreTypes'; import { describe, expect, it } from 'vitest'; import { LOUDNESS_BACKFILL_WINDOW_AHEAD, @@ -16,7 +16,12 @@ function track(id: string): Track { return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 }; } -const big = Array.from({ length: 12 }, (_, i) => track(`t${i}`)); +// Thin-state: the window functions take queue refs; the currentTrack arg stays a Track. +function ref(id: string): QueueItemRef { + return { serverId: 's', trackId: id }; +} + +const big = Array.from({ length: 12 }, (_, i) => ref(`t${i}`)); describe('LOUDNESS_BACKFILL_WINDOW_AHEAD', () => { it('is the value the runtime expects', () => { @@ -26,23 +31,23 @@ describe('LOUDNESS_BACKFILL_WINDOW_AHEAD', () => { describe('isTrackInsideLoudnessBackfillWindow', () => { it('matches the current track unconditionally', () => { - expect(isTrackInsideLoudnessBackfillWindow('t0', big, 0, big[0])).toBe(true); + expect(isTrackInsideLoudnessBackfillWindow('t0', big, 0, track('t0'))).toBe(true); }); it('matches an id inside the ahead window', () => { // queueIndex 0, AHEAD 5 → indices 1..5 are inside, t3 must hit. - expect(isTrackInsideLoudnessBackfillWindow('t3', big, 0, big[0])).toBe(true); + expect(isTrackInsideLoudnessBackfillWindow('t3', big, 0, track('t0'))).toBe(true); }); it('returns false for an id beyond the ahead window', () => { // From queueIndex 0, indices 1..5 inside → t6 (index 6) is outside. - expect(isTrackInsideLoudnessBackfillWindow('t6', big, 0, big[0])).toBe(false); + expect(isTrackInsideLoudnessBackfillWindow('t6', big, 0, track('t0'))).toBe(false); }); it('window slides with queueIndex', () => { // queueIndex 4, AHEAD 5 → indices 5..9 are inside, t9 must hit, t10 must not. - expect(isTrackInsideLoudnessBackfillWindow('t9', big, 4, big[4])).toBe(true); - expect(isTrackInsideLoudnessBackfillWindow('t10', big, 4, big[4])).toBe(false); + expect(isTrackInsideLoudnessBackfillWindow('t9', big, 4, track('t4'))).toBe(true); + expect(isTrackInsideLoudnessBackfillWindow('t10', big, 4, track('t4'))).toBe(false); }); it('returns false for empty queue', () => { @@ -50,7 +55,7 @@ describe('isTrackInsideLoudnessBackfillWindow', () => { }); it('returns false for empty trackId', () => { - expect(isTrackInsideLoudnessBackfillWindow('', big, 0, big[0])).toBe(false); + expect(isTrackInsideLoudnessBackfillWindow('', big, 0, track('t0'))).toBe(false); }); it('returns false when currentTrack is null and id is not in the queue window', () => { @@ -60,12 +65,12 @@ describe('isTrackInsideLoudnessBackfillWindow', () => { describe('collectLoudnessBackfillWindowTrackIds', () => { it('returns current + next 5 entries', () => { - const ids = collectLoudnessBackfillWindowTrackIds(big, 0, big[0]); + const ids = collectLoudnessBackfillWindowTrackIds(big, 0, track('t0')); expect(ids).toEqual(['t0', 't1', 't2', 't3', 't4', 't5']); }); it('clamps the window to the end of the queue', () => { - const ids = collectLoudnessBackfillWindowTrackIds(big, 9, big[9]); + const ids = collectLoudnessBackfillWindowTrackIds(big, 9, track('t9')); // queueIndex 9, AHEAD 5 → indices 10..11 available → t9, t10, t11 expect(ids).toEqual(['t9', 't10', 't11']); }); @@ -76,8 +81,8 @@ describe('collectLoudnessBackfillWindowTrackIds', () => { }); it('deduplicates when currentTrack is also in the ahead window', () => { - const queue = [track('a'), track('b'), track('a'), track('c')]; - const ids = collectLoudnessBackfillWindowTrackIds(queue, 0, queue[0]); + const queue = [ref('a'), ref('b'), ref('a'), ref('c')]; + const ids = collectLoudnessBackfillWindowTrackIds(queue, 0, track('a')); expect(ids).toEqual(['a', 'b', 'c']); }); diff --git a/src/store/loudnessBackfillWindow.ts b/src/store/loudnessBackfillWindow.ts index 393a5be2..a72f77b2 100644 --- a/src/store/loudnessBackfillWindow.ts +++ b/src/store/loudnessBackfillWindow.ts @@ -1,4 +1,4 @@ -import type { Track } from './playerStoreTypes'; +import type { QueueItemRef, Track } from './playerStoreTypes'; /** * After a bulk enqueue (queue replace, append-many, lucky-mix) the runtime * warms the loudness cache for the current track + the next N entries so @@ -15,7 +15,7 @@ export const LOUDNESS_BACKFILL_WINDOW_AHEAD = 5; export function isTrackInsideLoudnessBackfillWindow( trackId: string, - queue: Track[], + queue: QueueItemRef[], queueIndex: number, currentTrack: Track | null, ): boolean { @@ -25,13 +25,13 @@ export function isTrackInsideLoudnessBackfillWindow( const start = Math.max(0, queueIndex + 1); const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD); for (let i = start; i < end; i++) { - if (queue[i]?.id === trackId) return true; + if (queue[i]?.trackId === trackId) return true; } return false; } export function collectLoudnessBackfillWindowTrackIds( - queue: Track[], + queue: QueueItemRef[], queueIndex: number, currentTrack: Track | null, ): string[] { @@ -40,7 +40,7 @@ export function collectLoudnessBackfillWindowTrackIds( const start = Math.max(0, queueIndex + 1); const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD); for (let i = start; i < end; i++) { - const tid = queue[i]?.id; + const tid = queue[i]?.trackId; if (tid) ids.add(tid); } return Array.from(ids); @@ -48,7 +48,7 @@ export function collectLoudnessBackfillWindowTrackIds( /** Next ~5 queue neighbours (+ immediate next when preload is on) for middle-tier analysis. */ export function collectPlaybackMiddlePriorityTrackIds( - queue: Track[], + queue: QueueItemRef[], queueIndex: number, currentTrack: Track | null, preloadMode: 'off' | 'balanced' | 'early' | 'custom', @@ -57,13 +57,13 @@ export function collectPlaybackMiddlePriorityTrackIds( const start = Math.max(0, queueIndex + 1); const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD); for (let i = start; i < end; i++) { - const tid = queue[i]?.id; + const tid = queue[i]?.trackId; if (tid && tid !== currentTrack?.id) ids.add(tid); } if (preloadMode !== 'off') { const nextIdx = queueIndex + 1; if (nextIdx >= 0 && nextIdx < queue.length) { - const tid = queue[nextIdx]?.id; + const tid = queue[nextIdx]?.trackId; if (tid && tid !== currentTrack?.id) ids.add(tid); } } @@ -72,7 +72,7 @@ export function collectPlaybackMiddlePriorityTrackIds( export function loudnessBackfillPriorityForTrack( trackId: string, - queue: Track[], + queue: QueueItemRef[], queueIndex: number, currentTrack: Track | null, ): 'high' | 'middle' | 'low' { @@ -80,7 +80,7 @@ export function loudnessBackfillPriorityForTrack( const start = Math.max(0, queueIndex + 1); const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD); for (let i = start; i < end; i++) { - if (queue[i]?.id === trackId) return 'middle'; + if (queue[i]?.trackId === trackId) return 'middle'; } return 'low'; } diff --git a/src/store/loudnessPrefetch.test.ts b/src/store/loudnessPrefetch.test.ts index 0bb49546..5a305070 100644 --- a/src/store/loudnessPrefetch.test.ts +++ b/src/store/loudnessPrefetch.test.ts @@ -4,7 +4,7 @@ * guard, the window collection, and the no-sync-engine flag on each * refresh call. */ -import type { Track } from './playerStoreTypes'; +import type { QueueItemRef, Track } from './playerStoreTypes'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const hoisted = vi.hoisted(() => { const auth = { normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness' }; @@ -13,7 +13,7 @@ const hoisted = vi.hoisted(() => { auth, player, refreshMock: vi.fn(async () => undefined), - collectMock: vi.fn((_q: Track[], _i: number, _c: Track | null): string[] => []), + collectMock: vi.fn((_q: QueueItemRef[], _i: number, _c: Track | null): string[] => []), }; }); @@ -34,6 +34,11 @@ function track(id: string): Track { return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 }; } +// Thin-state: prefetchLoudnessForEnqueuedTracks takes queue refs now. +function ref(id: string): QueueItemRef { + return { serverId: 's', trackId: id }; +} + beforeEach(() => { hoisted.auth.normalizationEngine = 'loudness'; hoisted.player.currentTrack = null; @@ -46,14 +51,14 @@ describe('prefetchLoudnessForEnqueuedTracks', () => { it("is a no-op when engine isn't loudness", () => { hoisted.auth.normalizationEngine = 'off'; hoisted.collectMock.mockReturnValueOnce(['t1']); - prefetchLoudnessForEnqueuedTracks([track('t1')], 0); + prefetchLoudnessForEnqueuedTracks([ref('t1')], 0); expect(hoisted.refreshMock).not.toHaveBeenCalled(); expect(hoisted.collectMock).not.toHaveBeenCalled(); }); it('forwards each window id to refreshLoudnessForTrack with syncPlayingEngine=false', () => { hoisted.collectMock.mockReturnValueOnce(['t1', 't2', 't3']); - prefetchLoudnessForEnqueuedTracks([track('t1'), track('t2'), track('t3')], 0); + prefetchLoudnessForEnqueuedTracks([ref('t1'), ref('t2'), ref('t3')], 0); expect(hoisted.refreshMock).toHaveBeenCalledTimes(3); expect(hoisted.refreshMock).toHaveBeenCalledWith('t1', { syncPlayingEngine: false }); expect(hoisted.refreshMock).toHaveBeenCalledWith('t2', { syncPlayingEngine: false }); @@ -62,7 +67,7 @@ describe('prefetchLoudnessForEnqueuedTracks', () => { it('passes the queue + currentTrack through to the window collector', () => { hoisted.player.currentTrack = track('cur'); - const q = [track('cur'), track('next')]; + const q = [ref('cur'), ref('next')]; prefetchLoudnessForEnqueuedTracks(q, 0); expect(hoisted.collectMock).toHaveBeenCalledWith(q, 0, hoisted.player.currentTrack); }); diff --git a/src/store/loudnessPrefetch.ts b/src/store/loudnessPrefetch.ts index dd084e1f..c0958f6f 100644 --- a/src/store/loudnessPrefetch.ts +++ b/src/store/loudnessPrefetch.ts @@ -1,4 +1,4 @@ -import type { Track } from './playerStoreTypes'; +import type { QueueItemRef } from './playerStoreTypes'; import { useAuthStore } from './authStore'; import { collectLoudnessBackfillWindowTrackIds } from './loudnessBackfillWindow'; import { refreshLoudnessForTrack } from './loudnessRefresh'; @@ -15,7 +15,7 @@ import { usePlayerStore } from './playerStore'; * the upcoming ones. */ export function prefetchLoudnessForEnqueuedTracks( - mergedQueue: Track[], + mergedQueue: QueueItemRef[], queueIndex: number, ): void { if (useAuthStore.getState().normalizationEngine !== 'loudness') return; diff --git a/src/store/loudnessRefresh.ts b/src/store/loudnessRefresh.ts index 694097d0..31bd49a1 100644 --- a/src/store/loudnessRefresh.ts +++ b/src/store/loudnessRefresh.ts @@ -91,7 +91,7 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): && !isBackfillInFlight(trackId) && attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) { const live = usePlayerStore.getState(); - if (!isTrackInsideLoudnessBackfillWindow(trackId, live.queue, live.queueIndex, live.currentTrack)) { + if (!isTrackInsideLoudnessBackfillWindow(trackId, live.queueItems, live.queueIndex, live.currentTrack)) { emitNormalizationDebug('backfill:skipped-outside-window', { trackId, queueIndex: live.queueIndex, @@ -103,7 +103,7 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): const url = buildStreamUrl(trackId); const priority = loudnessBackfillPriorityForTrack( trackId, - live.queue, + live.queueItems, live.queueIndex, live.currentTrack, ); diff --git a/src/store/miscActions.ts b/src/store/miscActions.ts index 06ae791f..f16a6f8e 100644 --- a/src/store/miscActions.ts +++ b/src/store/miscActions.ts @@ -13,6 +13,9 @@ import { reseedLoudnessForTrackId } from './loudnessReseed'; import { getPlaybackProgressSnapshot } from './playbackProgress'; import { shouldRebindPlaybackToHotCache } from './playbackUrlRouting'; import type { PlayerState, Track } from './playerStoreTypes'; +import { toQueueItemRefs } from '../utils/library/queueItemRef'; +import { resolveQueueTrack } from '../utils/library/queueTrackView'; +import { seedQueueResolver } from '../utils/library/queueTrackResolver'; import { pushQueueUndoFromGetter } from './queueUndo'; import { syncQueueToServer } from './queueSync'; import { @@ -95,7 +98,7 @@ export function createMiscActions(set: SetState, get: GetState): Pick< normalizationTargetLufs: null, normalizationEngineLive: 'off', currentPlaybackSource: null, - queue: [], + queueItems: [], queueIndex: 0, isPlaying: true, progress: 0, @@ -106,7 +109,7 @@ export function createMiscActions(set: SetState, get: GetState): Pick< }, previous: () => { - const { queue, queueIndex, currentTrack } = get(); + const { queueItems, queueIndex, currentTrack } = get(); const currentTime = getPlaybackProgressSnapshot().currentTime; if (currentTime > 3) { // Restart current track from the beginning. @@ -114,7 +117,8 @@ export function createMiscActions(set: SetState, get: GetState): Pick< const sid = authState.activeServerId ?? ''; if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) { setSeekFallbackVisualTarget({ trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() }); - get().playTrack(currentTrack, queue, true); + // No-arg queue: keep the canonical refs, restart in place. + get().playTrack(currentTrack, undefined, true); return; } invoke('audio_seek', { seconds: 0 }).catch(console.error); @@ -122,7 +126,11 @@ export function createMiscActions(set: SetState, get: GetState): Pick< return; } const prevIdx = queueIndex - 1; - if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue, true, false, prevIdx); + if (prevIdx >= 0 && queueItems[prevIdx]) { + // Resolve the previous ref (resolver cache → placeholder); pass undefined + // for the queue arg so playTrack just moves the index. + get().playTrack(resolveQueueTrack(queueItems[prevIdx]), undefined, true, false, prevIdx); + } }, setVolume: (v) => { @@ -155,8 +163,12 @@ export function createMiscActions(set: SetState, get: GetState): Pick< // queue position, which may not flush before app close). const serverTime = q.position ? q.position / 1000 : 0; const localTime = get().currentTime; + const sid = get().queueServerId ?? useAuthStore.getState().activeServerId ?? ''; + // Seed the resolver with the restored tracks so the queue UI / hot + // paths resolve them without a network round-trip. + if (sid) seedQueueResolver(sid, mappedTracks); set({ - queue: mappedTracks, + queueItems: toQueueItemRefs(sid, mappedTracks), queueIndex, currentTrack, currentTime: serverTime > 0 ? serverTime : localTime, @@ -185,12 +197,15 @@ export function createMiscActions(set: SetState, get: GetState): Pick< } pushQueueUndoFromGetter(get); const wasPlaying = s.isPlaying; + const sid = s.queueServerId ?? ''; + if (sid) seedQueueResolver(sid, [track]); + const newItems = toQueueItemRefs(sid, [track]); set({ - queue: [track], + queueItems: newItems, queueIndex: 0, currentTrack: track, }); - syncQueueToServer([track], track, s.currentTime); + syncQueueToServer(newItems, track, s.currentTime); if (!wasPlaying) get().resume(); }, }; diff --git a/src/store/nextAction.ts b/src/store/nextAction.ts index 8823d58b..15d29fad 100644 --- a/src/store/nextAction.ts +++ b/src/store/nextAction.ts @@ -9,7 +9,10 @@ import { setInfiniteQueueFetching, } from './infiniteQueueState'; import { isInOrbitSession } from './orbitSession'; -import type { PlayerState, Track } from './playerStoreTypes'; +import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes'; +import { toQueueItemRefs } from '../utils/library/queueItemRef'; +import { resolveQueueTrack } from '../utils/library/queueTrackView'; +import { seedQueueResolver } from '../utils/library/queueTrackResolver'; import { addRadioSessionSeen, getCurrentRadioArtistId, @@ -24,6 +27,25 @@ type SetState = ( ) => void; type GetState = () => PlayerState; +/** + * Queue-exhausted radio / infinite-queue refill: append the freshly fetched + * tracks to the canonical `queueItems`, seed the resolver with them (so they + * resolve without a network round-trip), then play the first appended track at + * its new tail index. Refs in / Track for the play call only — thin-state. + */ +function appendTracksAndPlayFirst(set: SetState, get: GetState, fresh: Track[]): void { + if (fresh.length === 0) return; + const state = get(); + const serverId = state.queueServerId ?? ''; + if (serverId) seedQueueResolver(serverId, fresh); + const incoming: QueueItemRef[] = toQueueItemRefs(serverId, fresh); + const playAt = state.queueItems.length; + // Append the refs first so playTrack (queue arg undefined) reads them off the + // canonical list and its targetQueueIndex validates against the new tail. + set({ queueItems: [...state.queueItems, ...incoming] }); + get().playTrack(fresh[0], undefined, false, false, playAt); +} + /** * Advance to the next track. Three top-level outcomes: * @@ -44,11 +66,16 @@ type GetState = () => PlayerState; * sync to the host's next track. */ export function runNext(set: SetState, get: GetState, manual: boolean): void { - const { queue, queueIndex, repeatMode, currentTrack } = get(); + const { queueItems, queueIndex, repeatMode, currentTrack } = get(); applySkipStarOnManualNext(currentTrack, manual); const nextIdx = queueIndex + 1; - if (nextIdx < queue.length) { - get().playTrack(queue[nextIdx], queue, manual, false, nextIdx); + if (nextIdx < queueItems.length) { + // Resolver bridge keeps the [queueIndex-50, +200] window warm, so the next + // ref is cache-hot here; resolveQueueTrack falls back to a placeholder + // (carrying the correct trackId) on a cold miss so playback still starts. + const nextRef = queueItems[nextIdx]; + const nextTrack = resolveQueueTrack(nextRef); + get().playTrack(nextTrack, undefined, manual, false, nextIdx); // Proactively top up auto-added tracks when ≤ 2 remain ahead, // so the queue never runs dry without a visible loading pause. // Skipped while in Orbit — the host's queue is the source of @@ -57,33 +84,48 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void { // the next track-end fallback. const { infiniteQueueEnabled } = useAuthStore.getState(); if (infiniteQueueEnabled && repeatMode === 'off' && !isInfiniteQueueFetching() && !isInOrbitSession()) { - const remainingAuto = queue.slice(nextIdx + 1).filter(t => t.autoAdded).length; + const remainingAuto = queueItems.slice(nextIdx + 1).filter(r => r.autoAdded).length; if (remainingAuto <= 2) { setInfiniteQueueFetching(true); - const existingIds = new Set(get().queue.map(t => t.id)); + const existingIds = new Set(get().queueItems.map(r => r.trackId)); buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => { // Re-check at resolution time — the user may have joined // an Orbit session between scheduling and resolving. if (isInOrbitSession()) return; if (newTracks.length > 0) { - set(state => ({ queue: [...state.queue, ...newTracks] })); + set(state => { + const serverId = state.queueServerId ?? ''; + if (serverId) seedQueueResolver(serverId, newTracks); + const newItems = [...state.queueItems, ...toQueueItemRefs(serverId, newTracks)]; + return { queueItems: newItems }; + }); } }).catch(() => {}).finally(() => { setInfiniteQueueFetching(false); }); } } // Proactively top up radio tracks when ≤ 2 remain — always, regardless // of infinite queue setting. - const nextTrack = queue[nextIdx]; - if (nextTrack.radioAdded && !isRadioFetching()) { - const remainingRadio = queue.slice(nextIdx + 1).filter(t => t.radioAdded).length; + if (nextRef.radioAdded && !isRadioFetching()) { + const remainingRadio = queueItems.slice(nextIdx + 1).filter(r => r.radioAdded).length; if (remainingRadio <= 2) { - const artistId = nextTrack.artistId ?? getCurrentRadioArtistId() ?? null; - const artistName = nextTrack.artist; - if (artistId) { + // H2: nextTrack may be a placeholder if its ref is still cold — empty + // artist/artistId would seed `getSimilarSongs2('')` and silently + // return nothing, leaving radio dry. Prefer the just-played + // currentTrack (always fully resolved in playerStore) and the stored + // radio seed artist; fall back to nextTrack metadata only when those + // are missing. Skip the top-up entirely when no stable seed exists + // rather than firing a non-deterministic empty request. + const seedArtistId = + currentTrack?.artistId + ?? getCurrentRadioArtistId() + ?? nextTrack.artistId + ?? null; + const seedArtistName = currentTrack?.artist || nextTrack.artist; + if (seedArtistId && seedArtistName) { setRadioFetching(true); - Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]) + Promise.all([getSimilarSongs2(seedArtistId), getTopSongs(seedArtistName)]) .then(([similar, top]) => { - const existingIds = new Set(get().queue.map(t => t.id)); + const existingIds = new Set(get().queueItems.map(r => r.trackId)); // Lead with similar (other artists) for variety; top tracks // of the upcoming artist are only a fallback when similar // is empty. Single-pass loop dedupes against the live queue, @@ -105,9 +147,15 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void { // navigate backwards a few songs. Trimmed ids stay in the seen-set. const HISTORY_KEEP = 5; set(state => { + const serverId = state.queueServerId ?? ''; + if (serverId) seedQueueResolver(serverId, fresh); const trimStart = Math.max(0, state.queueIndex - HISTORY_KEEP); + const newItems = [ + ...state.queueItems.slice(trimStart), + ...toQueueItemRefs(serverId, fresh), + ]; return { - queue: [...state.queue.slice(trimStart), ...fresh], + queueItems: newItems, queueIndex: state.queueIndex - trimStart, }; }); @@ -118,8 +166,9 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void { } } } - } else if (repeatMode === 'all' && queue.length > 0) { - get().playTrack(queue[0], queue, manual, false, 0); + } else if (repeatMode === 'all' && queueItems.length > 0) { + const firstTrack = resolveQueueTrack(queueItems[0]); + get().playTrack(firstTrack, undefined, manual, false, 0); } else { // ── Orbit short-circuit ── // The host owns the shared queue. The radio / infinite-queue @@ -152,7 +201,7 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void { set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); return; } - const existingIds = new Set(get().queue.map(t => t.id)); + const existingIds = new Set(get().queueItems.map(r => r.trackId)); // Same source preference + dedup contract as the proactive // top-up: similar first, top only as a fallback (issue #500). const sourceList = similar.length > 0 ? similar : top; @@ -165,9 +214,7 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void { fresh.push({ ...t, radioAdded: true as const }); } if (fresh.length > 0) { - const currentQueue = get().queue; - const newQueue = [...currentQueue, ...fresh]; - get().playTrack(fresh[0], newQueue, false, false, currentQueue.length); + appendTracksAndPlayFirst(set, get, fresh); } else { invoke('audio_stop').catch(console.error); setIsAudioPaused(false); @@ -187,7 +234,7 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void { if (infiniteQueueEnabled && repeatMode === 'off') { if (isInfiniteQueueFetching()) return; setInfiniteQueueFetching(true); - const existingIds = new Set(get().queue.map(t => t.id)); + const existingIds = new Set(get().queueItems.map(r => r.trackId)); buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => { setInfiniteQueueFetching(false); // The user may have joined an Orbit session while this @@ -204,9 +251,7 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void { set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); return; } - const currentQueue = get().queue; - const newQueue = [...currentQueue, ...newTracks]; - get().playTrack(newTracks[0], newQueue, false); + appendTracksAndPlayFirst(set, get, newTracks); }).catch(() => { setInfiniteQueueFetching(false); invoke('audio_stop').catch(console.error); diff --git a/src/store/pendingStarSync.test.ts b/src/store/pendingStarSync.test.ts index a5083aec..6cac14a3 100644 --- a/src/store/pendingStarSync.test.ts +++ b/src/store/pendingStarSync.test.ts @@ -12,6 +12,12 @@ vi.mock('../api/subsonicStarRating', () => ({ import { usePlayerStore } from './playerStore'; import type { Track } from './playerStoreTypes'; import { queueSongStar, queueSongRating, _resetPendingStarSyncForTest } from './pendingStarSync'; +import { + getCachedTrack, + seedQueueResolver, + _resetQueueResolverForTest, +} from '../utils/library/queueTrackResolver'; +import { toQueueItemRefs } from '../utils/library/queueItemRef'; const track = (id: string): Track => ({ id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1, @@ -24,9 +30,14 @@ describe('pendingStarSync', () => { unstarMock.mockReset().mockResolvedValue(undefined); setRatingMock.mockReset().mockResolvedValue(undefined); _resetPendingStarSyncForTest(); + _resetQueueResolverForTest(); + // Thin-state: the queue's track copy lives in the resolver cache. Seed it so + // a star/rating success has a cached entry to patch in place. + seedQueueResolver('', [track('t1')]); usePlayerStore.setState({ currentTrack: track('t1'), - queue: [track('t1')], + queueItems: toQueueItemRefs('', [track('t1')]), + queueServerId: null, starredOverrides: {}, userRatingOverrides: {}, }); @@ -46,7 +57,12 @@ describe('pendingStarSync', () => { const s = usePlayerStore.getState(); expect('t1' in s.starredOverrides).toBe(false); // cleared on success expect(s.currentTrack?.starred).toBeTruthy(); // in-memory track patched - expect(s.queue[0].starred).toBeTruthy(); + // Thin-state: the resolver cache entry is patched in place (not dropped) so + // the visible queue row keeps its title and reflects the synced star — + // dropping it would blank the row to a "…" placeholder. + const cached = getCachedTrack({ serverId: '', trackId: 't1' }); + expect(cached?.title).toBe('t1'); + expect(cached?.starred).toBeTruthy(); }); it('does NOT roll back on a network failure and keeps retrying', async () => { diff --git a/src/store/pendingStarSync.ts b/src/store/pendingStarSync.ts index 800eded9..5362ddcb 100644 --- a/src/store/pendingStarSync.ts +++ b/src/store/pendingStarSync.ts @@ -1,5 +1,6 @@ import { setRating, star, unstar } from '../api/subsonicStarRating'; import { usePlayerStore } from './playerStore'; +import { patchCachedTrack } from '../utils/library/queueTrackResolver'; /** * F4 — pending-sync for **song** star + rating (spec §6.5 / R7-18). @@ -89,21 +90,27 @@ function onStarSuccess(id: string, starred: boolean): void { delete next[id]; return { starredOverrides: next, - queue: s.queue.map(t => (t.id === id ? { ...t, starred: starredVal } : t)), currentTrack: s.currentTrack?.id === id ? { ...s.currentTrack, starred: starredVal } : s.currentTrack, }; }); + // Thin-state: the queue's copy lives in the resolver cache. Patch it in place + // to the synced value rather than dropping it — a dropped entry would blank the + // visible queue row to a "…" placeholder until the next window re-resolve. + patchCachedTrack(id, { starred: starredVal }); } function onRatingSuccess(id: string): void { - // `setUserRatingOverride` already patched track.userRating; just drop the override. + const rating = usePlayerStore.getState().userRatingOverrides[id]; usePlayerStore.setState(s => { if (!(id in s.userRatingOverrides)) return {}; const next = { ...s.userRatingOverrides }; delete next[id]; return { userRatingOverrides: next }; }); + // Patch the cached queue track in place (see onStarSuccess) so the row keeps + // its title and shows the synced rating without flashing a placeholder. + if (rating !== undefined) patchCachedTrack(id, { userRating: rating }); } /** Optimistically (un)star a song and sync it to the server with retry. */ diff --git a/src/store/playTrackAction.ts b/src/store/playTrackAction.ts index b02621b6..9a6932c3 100644 --- a/src/store/playTrackAction.ts +++ b/src/store/playTrackAction.ts @@ -36,6 +36,9 @@ import { recordEnginePlayUrl, } from './playbackUrlRouting'; import type { PlayerState, Track } from './playerStoreTypes'; +import { toQueueItemRefs } from '../utils/library/queueItemRef'; +import { getQueueTracksView, resolveQueueTrack } from '../utils/library/queueTrackView'; +import { seedQueueResolver } from '../utils/library/queueTrackResolver'; import { promoteCompletedStreamToHotCache } from './promoteStreamCache'; import { syncQueueToServer } from './queueSync'; import { playListenSessionFinalize } from './playListenSession'; @@ -100,9 +103,9 @@ export function runPlayTrack( // to move the index; they are not bulk operations and must not // trigger the confirm dialog (#234 regression). if (!_orbitConfirmed && queue && queue.length > 1) { - const current = get().queue; + const current = get().queueItems; const sameAsCurrent = queue.length === current.length - && queue.every((t, i) => sameQueueTrackId(current[i]?.id, t.id)); + && queue.every((t, i) => sameQueueTrackId(current[i]?.trackId, t.id)); if (!sameAsCurrent) { void orbitBulkGuard(queue.length).then(ok => { if (!ok) return; @@ -134,14 +137,17 @@ export function runPlayTrack( if (!_orbitConfirmed && queue && queue.length === 1) { const orbitRole = useOrbitStore.getState().role; if (orbitRole === 'host') { - const current = get().queue; - const currentTrackId = current[get().queueIndex]?.id; + const currentItems = get().queueItems; + const currentTrackId = currentItems[get().queueIndex]?.trackId; if (track.id !== currentTrackId) { - const existsAt = current.findIndex(t => sameQueueTrackId(t.id, track.id)); + const existsAt = currentItems.findIndex(r => sameQueueTrackId(r.trackId, track.id)); if (existsAt >= 0) { - get().playTrack(track, current, manual, true, existsAt); + // Re-jump within the existing queue: pass undefined so playTrack keeps + // the canonical queueItems and just moves the index. + get().playTrack(track, undefined, manual, true, existsAt); } else { - const newQueue = [...current, track]; + // Append the single track to the resolved current queue and jump to it. + const newQueue = [...getQueueTracksView(currentItems), track]; get().playTrack(track, newQueue, manual, true, newQueue.length - 1); } return; @@ -182,22 +188,52 @@ export function runPlayTrack( if (visualOnEntry?.trackId !== track.id) { setSeekFallbackVisualTarget(null); } - const newQueue = queue ?? state.queue; - if (shouldBindQueueServerForPlay(state.queue, newQueue, queue)) { + // Thin-state: only a real queue *replacement* (explicit `queue` arg) rebuilds + // queueItems. A no-arg navigation (next/previous/queue-row jump) keeps the + // canonical refs and just moves the index — so we never resolve the whole + // queue here (O(visible), not O(queue length)), which would hitch + churn + // every subscriber on each track change at scale. + const replacing = queue !== undefined; + const srcLen = replacing ? queue.length : state.queueItems.length; + if (replacing && shouldBindQueueServerForPlay(state.queueItems, queue, queue)) { bindQueueServerForPlayback(); } // Prefer an explicit target index from the caller (next/previous/queue-row // click already know the exact slot). `findIndex` returns the *first* // matching id, which jumps backwards when the queue contains the same // track twice — breaking radio playback (issue #500). + const matchesAt = (i: number): boolean => + replacing + ? sameQueueTrackId(queue[i]?.id, track.id) + : sameQueueTrackId(state.queueItems[i]?.trackId, track.id); const explicitIdxValid = typeof targetQueueIndex === 'number' && targetQueueIndex >= 0 - && targetQueueIndex < newQueue.length - && sameQueueTrackId(newQueue[targetQueueIndex]?.id, track.id); + && targetQueueIndex < srcLen + && matchesAt(targetQueueIndex); const idx = explicitIdxValid ? (targetQueueIndex as number) - : newQueue.findIndex(t => sameQueueTrackId(t.id, track.id)); + : replacing + ? queue.findIndex(t => sameQueueTrackId(t.id, track.id)) + : state.queueItems.findIndex(r => sameQueueTrackId(r.trackId, track.id)); + const playIdx = idx >= 0 ? idx : 0; + // ±1 neighbours for replaygain normalization — resolve only these (not the + // whole queue). On replace they come from the provided Track[]; on navigation + // from the resolver cache (the bridge keeps that window warm). + const neighbourAt = (i: number): Track | null => { + if (i < 0 || i >= srcLen) return null; + if (replacing) return queue[i] ?? null; + if (i === playIdx) return track; + const ref = state.queueItems[i]; + return ref ? resolveQueueTrack(ref) : null; + }; + const prevNeighbour = neighbourAt(playIdx - 1); + const nextNeighbour = neighbourAt(playIdx + 1); + // Minimal window so deriveNormalizationSnapshot reads ±1 without a full array. + const normWindow: Track[] = prevNeighbour ? [prevNeighbour] : []; + const normIdx = normWindow.length; + normWindow.push(track); + if (nextNeighbour) normWindow.push(nextNeighbour); if (manual) { pushQueueUndoFromGetter(get); } @@ -248,12 +284,18 @@ export function runPlayTrack( // Set state immediately so the UI updates before the download completes. // currentRadio: null ensures the PlayerBar switches out of radio mode right away. + const queueSid = get().queueServerId ?? ''; + // When the caller replaced the queue (explicit `queue` arg), seed the + // resolver with those tracks so the UI / hot paths resolve them without a + // network round-trip. No-arg jumps reuse already-cached refs. + if (queue && queueSid) seedQueueResolver(queueSid, queue); set({ currentTrack: track, currentRadio: null, waveformBins: null, - ...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0), - queue: newQueue, + ...deriveNormalizationSnapshot(track, normWindow, normIdx), + // Only a replace rewrites the queue; navigation keeps the canonical refs. + ...(replacing ? { queueItems: toQueueItemRefs(queueSid, queue) } : {}), queueIndex: idx >= 0 ? idx : 0, progress: initialProgress, buffered: 0, @@ -285,8 +327,6 @@ export function runPlayTrack( void refreshWaveformForTrack(track.id); void refreshLoudnessForTrack(track.id); setDeferHotCachePrefetch(true); - const playIdx = idx >= 0 ? idx : 0; - const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null; const replayGainDb = resolveReplayGainDb( track, prevTrack, nextNeighbour, isReplayGainActive(), authStateNow.replayGainMode, @@ -356,7 +396,7 @@ export function runPlayTrack( })); }); } - syncQueueToServer(newQueue, track, initialTime); + syncQueueToServer(get().queueItems, track, initialTime); touchHotCacheOnPlayback(track.id, playbackCacheSid); }; diff --git a/src/store/playerStore.events.test.ts b/src/store/playerStore.events.test.ts index c11da2b6..0bfa29bf 100644 --- a/src/store/playerStore.events.test.ts +++ b/src/store/playerStore.events.test.ts @@ -48,7 +48,7 @@ import { tauriMockListenerCount, } from '@/test/mocks/tauri'; import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset'; -import { makeTrack, makeTracks } from '@/test/helpers/factories'; +import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories'; function stubPlaybackInvokes(): void { onInvoke('audio_play', () => undefined); @@ -117,12 +117,8 @@ describe('audio:progress', () => { describe('audio:track_switched', () => { it('advances to queue[queueIndex + 1] under repeatMode=off', () => { const queue = makeTracks(3); - usePlayerStore.setState({ - queue, - queueIndex: 0, - currentTrack: queue[0], - repeatMode: 'off', - }); + seedQueue(queue, { index: 0, currentTrack: queue[0] }); + usePlayerStore.setState({ repeatMode: 'off' }); emitTauriEvent('audio:track_switched', queue[1].duration); const s = usePlayerStore.getState(); expect(s.currentTrack?.id).toBe(queue[1].id); @@ -135,12 +131,8 @@ describe('audio:track_switched', () => { it('replays the same track under repeatMode=one (queueIndex stays put)', () => { const queue = makeTracks(3); - usePlayerStore.setState({ - queue, - queueIndex: 1, - currentTrack: queue[1], - repeatMode: 'one', - }); + seedQueue(queue, { index: 1, currentTrack: queue[1] }); + usePlayerStore.setState({ repeatMode: 'one' }); emitTauriEvent('audio:track_switched', queue[1].duration); const s = usePlayerStore.getState(); expect(s.currentTrack?.id).toBe(queue[1].id); @@ -149,12 +141,8 @@ describe('audio:track_switched', () => { it('wraps to queue[0] when at the end with repeatMode=all', () => { const queue = makeTracks(3); - usePlayerStore.setState({ - queue, - queueIndex: 2, - currentTrack: queue[2], - repeatMode: 'all', - }); + seedQueue(queue, { index: 2, currentTrack: queue[2] }); + usePlayerStore.setState({ repeatMode: 'all' }); emitTauriEvent('audio:track_switched', queue[0].duration); const s = usePlayerStore.getState(); expect(s.currentTrack?.id).toBe(queue[0].id); @@ -163,12 +151,8 @@ describe('audio:track_switched', () => { it('is a no-op when at the end with repeatMode=off', () => { const queue = makeTracks(2); - usePlayerStore.setState({ - queue, - queueIndex: 1, - currentTrack: queue[1], - repeatMode: 'off', - }); + seedQueue(queue, { index: 1, currentTrack: queue[1] }); + usePlayerStore.setState({ repeatMode: 'off' }); emitTauriEvent('audio:track_switched', queue[1].duration); // No next candidate → handler returns early before state changes. const s = usePlayerStore.getState(); @@ -178,13 +162,8 @@ describe('audio:track_switched', () => { it('resets scrobbled + lastfmLoved flags so the new track can be rescored', () => { const queue = makeTracks(2); - usePlayerStore.setState({ - queue, - queueIndex: 0, - currentTrack: queue[0], - scrobbled: true, - lastfmLoved: true, - }); + seedQueue(queue, { index: 0, currentTrack: queue[0] }); + usePlayerStore.setState({ scrobbled: true, lastfmLoved: true }); emitTauriEvent('audio:track_switched', queue[1].duration); expect(usePlayerStore.getState().scrobbled).toBe(false); expect(usePlayerStore.getState().lastfmLoved).toBe(false); @@ -201,10 +180,8 @@ describe('audio:ended', () => { it('immediately resets playback bookkeeping (before the 150 ms next() timer)', () => { const queue = makeTracks(2); + seedQueue(queue, { index: 0, currentTrack: queue[0] }); usePlayerStore.setState({ - queue, - queueIndex: 0, - currentTrack: queue[0], isPlaying: true, progress: 0.99, currentTime: 178, @@ -222,10 +199,8 @@ describe('audio:ended', () => { it('clears state and currentRadio for a radio stream without advancing the queue', () => { const queue = makeTracks(2); + seedQueue(queue, { index: 0, currentTrack: queue[0] }); usePlayerStore.setState({ - queue, - queueIndex: 0, - currentTrack: queue[0], currentRadio: { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/stream' }, isPlaying: true, }); diff --git a/src/store/playerStore.misc.test.ts b/src/store/playerStore.misc.test.ts index 70830871..3076efc2 100644 --- a/src/store/playerStore.misc.test.ts +++ b/src/store/playerStore.misc.test.ts @@ -47,7 +47,7 @@ import { usePlayerStore } from './playerStore'; import { useAuthStore } from './authStore'; import { onInvoke, invokeMock } from '@/test/mocks/tauri'; import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset'; -import { makeTrack, makeTracks } from '@/test/helpers/factories'; +import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories'; beforeEach(() => { resetPlayerStore(); @@ -209,10 +209,8 @@ describe('setProgress', () => { describe('stop', () => { it('invokes audio_stop and clears playback state', () => { + seedQueue(makeTracks(2), { index: 0, currentTrack: makeTrack() }); usePlayerStore.setState({ - queue: makeTracks(2), - queueIndex: 0, - currentTrack: makeTrack(), isPlaying: true, progress: 0.5, currentTime: 60, @@ -229,15 +227,15 @@ describe('stop', () => { describe('shuffleQueue', () => { it('is a no-op when the queue has fewer than 2 tracks', () => { const t = makeTrack({ id: 'only' }); - usePlayerStore.setState({ queue: [t], queueIndex: 0, currentTrack: t }); + seedQueue([t], { index: 0, currentTrack: t }); usePlayerStore.getState().shuffleQueue(); - expect(usePlayerStore.getState().queue.map(q => q.id)).toEqual(['only']); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['only']); }); it('keeps the current track at queueIndex 0 with the rest shuffled around it', () => { const tracks = makeTracks(5, i => ({ id: `t-${i}` })); const current = tracks[2]; - usePlayerStore.setState({ queue: tracks, queueIndex: 2, currentTrack: current }); + seedQueue(tracks, { index: 2, currentTrack: current }); // Pin the RNG so the shuffle is deterministic. vi.spyOn(Math, 'random').mockReturnValue(0); @@ -245,25 +243,25 @@ describe('shuffleQueue', () => { vi.restoreAllMocks(); const s = usePlayerStore.getState(); - expect(s.queue[0].id).toBe(current.id); + expect(s.queueItems[0].trackId).toBe(current.id); expect(s.queueIndex).toBe(0); // The set of ids is preserved. - expect([...s.queue.map(t => t.id)].sort()).toEqual(['t-0', 't-1', 't-2', 't-3', 't-4'].sort()); + expect([...s.queueItems.map(r => r.trackId)].sort()).toEqual(['t-0', 't-1', 't-2', 't-3', 't-4'].sort()); }); }); describe('shuffleUpcomingQueue', () => { it('is a no-op when fewer than 2 upcoming tracks remain', () => { const tracks = makeTracks(3, i => ({ id: `t-${i}` })); - usePlayerStore.setState({ queue: tracks, queueIndex: 2, currentTrack: tracks[2] }); + seedQueue(tracks, { index: 2, currentTrack: tracks[2] }); const beforeIds = tracks.map(t => t.id); usePlayerStore.getState().shuffleUpcomingQueue(); - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(beforeIds); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(beforeIds); }); it('keeps the head + current in place and shuffles only the upcoming tail', () => { const tracks = makeTracks(5, i => ({ id: `t-${i}` })); - usePlayerStore.setState({ queue: tracks, queueIndex: 1, currentTrack: tracks[1] }); + seedQueue(tracks, { index: 1, currentTrack: tracks[1] }); vi.spyOn(Math, 'random').mockReturnValue(0); usePlayerStore.getState().shuffleUpcomingQueue(); @@ -271,35 +269,35 @@ describe('shuffleUpcomingQueue', () => { const s = usePlayerStore.getState(); // First two entries unchanged (head + current). - expect(s.queue[0].id).toBe('t-0'); - expect(s.queue[1].id).toBe('t-1'); + expect(s.queueItems[0].trackId).toBe('t-0'); + expect(s.queueItems[1].trackId).toBe('t-1'); // The tail still contains the same ids in some order. - expect([...s.queue.slice(2).map(t => t.id)].sort()).toEqual(['t-2', 't-3', 't-4'].sort()); + expect([...s.queueItems.slice(2).map(r => r.trackId)].sort()).toEqual(['t-2', 't-3', 't-4'].sort()); }); }); describe('pruneUpcomingToCurrent', () => { it('drops everything after queueIndex', () => { const tracks = makeTracks(5); - usePlayerStore.setState({ queue: tracks, queueIndex: 1, currentTrack: tracks[1] }); + seedQueue(tracks, { index: 1, currentTrack: tracks[1] }); usePlayerStore.getState().pruneUpcomingToCurrent(); const s = usePlayerStore.getState(); - expect(s.queue.map(t => t.id)).toEqual([tracks[0].id, tracks[1].id]); + expect(s.queueItems.map(r => r.trackId)).toEqual([tracks[0].id, tracks[1].id]); expect(s.queueIndex).toBe(1); }); it('clears the queue entirely when there is no current track (orphaned queue → empty)', () => { - usePlayerStore.setState({ queue: makeTracks(3), queueIndex: 0, currentTrack: null }); + seedQueue(makeTracks(3), { index: 0, currentTrack: null }); usePlayerStore.getState().pruneUpcomingToCurrent(); const s = usePlayerStore.getState(); - expect(s.queue).toEqual([]); + expect(s.queueItems).toEqual([]); expect(s.queueIndex).toBe(0); }); it('returns early without clearing when no current track AND queue is already empty', () => { - usePlayerStore.setState({ queue: [], queueIndex: 0, currentTrack: null }); + usePlayerStore.setState({ queueItems: [], queueIndex: 0, currentTrack: null }); usePlayerStore.getState().pruneUpcomingToCurrent(); - expect(usePlayerStore.getState().queue).toEqual([]); + expect(usePlayerStore.getState().queueItems).toEqual([]); }); }); diff --git a/src/store/playerStore.persistence.test.ts b/src/store/playerStore.persistence.test.ts index 4146e446..28e8172c 100644 --- a/src/store/playerStore.persistence.test.ts +++ b/src/store/playerStore.persistence.test.ts @@ -74,7 +74,7 @@ vi.mock('@/api/lastfm', () => ({ import { usePlayerStore } from './playerStore'; import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri'; import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset'; -import { makeTrack, makeTracks } from '@/test/helpers/factories'; +import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories'; function stubInvokes(): void { onInvoke('audio_play', () => undefined); @@ -110,12 +110,8 @@ afterEach(() => { describe('flushPlayQueuePosition', () => { it('forwards the queue, current track, and millisecond position to savePlayQueue', async () => { const [t1, t2, t3] = makeTracks(3); - usePlayerStore.setState({ - queue: [t1, t2, t3], - queueIndex: 1, - currentTrack: t2, - isPlaying: true, - }); + seedQueue([t1, t2, t3], { index: 1, currentTrack: t2 }); + usePlayerStore.setState({ isPlaying: true }); // Drive a live-progress snapshot so flushPlayQueuePosition has a non-zero // position to flush — readonly snapshot is what the API call samples. emitTauriEvent('audio:progress', { current_time: 12.345, duration: t2.duration }); @@ -137,11 +133,7 @@ describe('flushPlayQueuePosition', () => { it('caps the song-id list at 1000 entries', async () => { const tracks = makeTracks(1100); - usePlayerStore.setState({ - queue: tracks, - queueIndex: 0, - currentTrack: tracks[0], - }); + seedQueue(tracks, { index: 0, currentTrack: tracks[0] }); emitTauriEvent('audio:progress', { current_time: 1, duration: tracks[0].duration }); vi.mocked(savePlayQueue).mockClear(); // discard heartbeat call from emit @@ -155,10 +147,8 @@ describe('flushPlayQueuePosition', () => { it('is a no-op when a radio stream is active', async () => { const track = makeTrack(); + seedQueue([track], { index: 0, currentTrack: track }); usePlayerStore.setState({ - queue: [track], - queueIndex: 0, - currentTrack: track, currentRadio: { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/stream' }, }); @@ -168,11 +158,7 @@ describe('flushPlayQueuePosition', () => { }); it('is a no-op when there is no current track', async () => { - usePlayerStore.setState({ - queue: makeTracks(2), - queueIndex: 0, - currentTrack: null, - }); + seedQueue(makeTracks(2), { index: 0, currentTrack: null }); await flushPlayQueuePosition(); @@ -181,7 +167,7 @@ describe('flushPlayQueuePosition', () => { it('is a no-op when the queue is empty', async () => { usePlayerStore.setState({ - queue: [], + queueItems: [], queueIndex: 0, currentTrack: null, }); @@ -193,7 +179,7 @@ describe('flushPlayQueuePosition', () => { it('swallows backend errors without propagating to the caller', async () => { const track = makeTrack(); - usePlayerStore.setState({ queue: [track], queueIndex: 0, currentTrack: track }); + seedQueue([track], { index: 0, currentTrack: track }); vi.mocked(savePlayQueue).mockRejectedValueOnce(new Error('offline')); await expect(flushPlayQueuePosition()).resolves.toBeUndefined(); @@ -201,12 +187,8 @@ describe('flushPlayQueuePosition', () => { it('floors the position to whole milliseconds', async () => { const track = makeTrack({ duration: 200 }); - usePlayerStore.setState({ - queue: [track], - queueIndex: 0, - currentTrack: track, - isPlaying: true, - }); + seedQueue([track], { index: 0, currentTrack: track }); + usePlayerStore.setState({ isPlaying: true }); emitTauriEvent('audio:progress', { current_time: 12.9999, duration: 200 }); vi.mocked(savePlayQueue).mockClear(); // discard heartbeat call from emit @@ -218,7 +200,7 @@ describe('flushPlayQueuePosition', () => { }); // --------------------------------------------------------------------------- -// partialize: localStorage queue window (PR #756) +// partialize + merge: thin-state refs-only persistence // --------------------------------------------------------------------------- function getPartialize() { @@ -228,82 +210,98 @@ function getPartialize() { .persist.getOptions().partialize; } -describe('partialize: localStorage queue window', () => { - it('caps a large queue to at most 501 tracks and puts the current track at index 250', () => { - const tracks = makeTracks(10509); - usePlayerStore.setState({ queue: tracks, queueIndex: 5000 }); +function getMerge() { + type MergeFn = (persisted: unknown, current: ReturnType) => ReturnType; + return (usePlayerStore as unknown as { persist: { getOptions(): { merge: MergeFn } } }) + .persist.getOptions().merge; +} - const partial = getPartialize()(usePlayerStore.getState()); - - expect((partial.queue as unknown[]).length).toBe(501); - expect(partial.queueIndex).toBe(250); - // the track at the remapped index must be the original track[5000] - expect((partial.queue as { id: string }[])[250].id).toBe(tracks[5000].id); - }); - - it('does not shift the index when the current track is near the start (qi < 250)', () => { - const tracks = makeTracks(1000); - usePlayerStore.setState({ queue: tracks, queueIndex: 50 }); - - const partial = getPartialize()(usePlayerStore.getState()); - - // window starts at 0, index is unchanged - expect(partial.queueIndex).toBe(50); - expect((partial.queue as { id: string }[])[50].id).toBe(tracks[50].id); - // window extends 250 ahead: 0..300 = 301 tracks - expect((partial.queue as unknown[]).length).toBe(301); - }); - - it('handles a current track near the end of the queue correctly', () => { - const tracks = makeTracks(10509); - const lastIdx = 10508; - usePlayerStore.setState({ queue: tracks, queueIndex: lastIdx }); - - const partial = getPartialize()(usePlayerStore.getState()); - - // window: [10258, 10509) = 251 tracks; remapped index = 10508 - 10258 = 250 - expect((partial.queue as unknown[]).length).toBe(251); - expect(partial.queueIndex).toBe(250); - expect((partial.queue as { id: string }[])[250].id).toBe(tracks[lastIdx].id); - }); - - it('persists the entire queue unchanged when it is smaller than the window', () => { - const tracks = makeTracks(10); - usePlayerStore.setState({ queue: tracks, queueIndex: 5 }); - - const partial = getPartialize()(usePlayerStore.getState()); - - expect((partial.queue as unknown[]).length).toBe(10); - expect(partial.queueIndex).toBe(5); - }); - - it('handles an empty queue without throwing', () => { - usePlayerStore.setState({ queue: [], queueIndex: 0 }); - - const partial = getPartialize()(usePlayerStore.getState()); - - expect((partial.queue as unknown[]).length).toBe(0); - expect(partial.queueIndex).toBe(0); - }); - - it('persists the whole queue as thin queueItems (refs + serverId + flags), not just the window', () => { +describe('partialize: thin queueItems (refs only)', () => { + it('persists the WHOLE queue as thin refs (no windowed fat `queue`)', () => { const tracks = makeTracks(600); tracks[3].radioAdded = true; tracks[4].autoAdded = true; - usePlayerStore.setState({ queue: tracks, queueIndex: 300, queueServerId: 's1' }); + seedQueue(tracks, { index: 300, serverId: 's1', currentTrack: tracks[300] }); const partial = getPartialize()(usePlayerStore.getState()); const items = partial.queueItems as { serverId: string; trackId: string; radioAdded?: boolean; autoAdded?: boolean; }[]; - // windowed `queue` is capped, but queueItems carries the WHOLE queue - expect((partial.queue as unknown[]).length).toBe(501); + // No fat `queue` key anymore. + expect(partial.queue).toBeUndefined(); + // queueItems carries the WHOLE queue. expect(items.length).toBe(600); + // queueItemsIndex is the restore-pending sentinel (= the live queueIndex). expect(partial.queueItemsIndex).toBe(300); expect(items[0].serverId).toBe('s1'); expect(items[3].radioAdded).toBe(true); expect(items[4].autoAdded).toBe(true); expect(items[0].radioAdded).toBeUndefined(); }); + + it('handles an empty queue without throwing', () => { + usePlayerStore.setState({ queueItems: [], queueIndex: 0 }); + + const partial = getPartialize()(usePlayerStore.getState()); + + expect((partial.queueItems as unknown[]).length).toBe(0); + expect(partial.queue).toBeUndefined(); + }); +}); + +describe('merge: restores the queue from any old persisted blob', () => { + const current = () => usePlayerStore.getState(); + + it('prefers an existing queueItems ref list + sets the sentinel', () => { + const merged = getMerge()( + { + queueServerId: 's1', + queueIndex: 2, + queueItems: [ + { serverId: 's1', trackId: 'a' }, + { serverId: 's1', trackId: 'b' }, + ], + queueItemsIndex: 1, + }, + current(), + ); + expect(merged.queueItems.map(r => r.trackId)).toEqual(['a', 'b']); + expect(merged.queueItemsIndex).toBe(1); + }); + + it('rebuilds queueItems from a legacy queueRefs string list', () => { + const merged = getMerge()( + { queueServerId: 's2', queueRefs: ['x', 'y'], queueRefsIndex: 1 }, + current(), + ); + expect(merged.queueItems).toEqual([ + { serverId: 's2', trackId: 'x' }, + { serverId: 's2', trackId: 'y' }, + ]); + expect(merged.queueItemsIndex).toBe(1); + }); + + it('rebuilds queueItems from an old windowed fat `queue: Track[]` blob and drops the `queue` key', () => { + const blob: Record = { + 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(); + }); }); diff --git a/src/store/playerStore.playbackActions.test.ts b/src/store/playerStore.playbackActions.test.ts index 9dfeac07..b383070e 100644 --- a/src/store/playerStore.playbackActions.test.ts +++ b/src/store/playerStore.playbackActions.test.ts @@ -49,7 +49,7 @@ import { usePlayerStore } from './playerStore'; import { useAuthStore } from './authStore'; import { onInvoke, invokeMock } from '@/test/mocks/tauri'; import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset'; -import { makeTrack, makeTracks } from '@/test/helpers/factories'; +import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories'; function stubPlaybackInvokes(): void { onInvoke('audio_play', () => undefined); @@ -201,11 +201,7 @@ describe('seek', () => { describe('next', () => { it('advances to queue[queueIndex + 1] when one is available', () => { const queue = makeTracks(3); - usePlayerStore.setState({ - queue, - queueIndex: 0, - currentTrack: queue[0], - }); + seedQueue(queue, { index: 0, currentTrack: queue[0] }); usePlayerStore.getState().next(); expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id); expect(usePlayerStore.getState().queueIndex).toBe(1); @@ -213,12 +209,8 @@ describe('next', () => { it('wraps to queue[0] when at the end with repeatMode=all', () => { const queue = makeTracks(3); - usePlayerStore.setState({ - queue, - queueIndex: 2, - currentTrack: queue[2], - repeatMode: 'all', - }); + seedQueue(queue, { index: 2, currentTrack: queue[2] }); + usePlayerStore.setState({ repeatMode: 'all' }); usePlayerStore.getState().next(); expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id); expect(usePlayerStore.getState().queueIndex).toBe(0); @@ -228,13 +220,8 @@ describe('next', () => { // infiniteQueueEnabled and the radio fetch path are both off by default, // so the no-next branch falls through to `audio_stop`. const queue = makeTracks(2); - usePlayerStore.setState({ - queue, - queueIndex: 1, - currentTrack: queue[1], - repeatMode: 'off', - isPlaying: true, - }); + seedQueue(queue, { index: 1, currentTrack: queue[1] }); + usePlayerStore.setState({ repeatMode: 'off', isPlaying: true }); usePlayerStore.getState().next(); expect(invokeMock).toHaveBeenCalledWith('audio_stop'); const s = usePlayerStore.getState(); @@ -247,11 +234,7 @@ describe('next', () => { describe('previous', () => { it('restarts the current track when currentTime > 3 s', () => { const queue = makeTracks(3); - usePlayerStore.setState({ - queue, - queueIndex: 1, - currentTrack: queue[1], - }); + seedQueue(queue, { index: 1, currentTrack: queue[1] }); // The store's `currentTime` is the source for the "restart vs jump back" // branch. `getPlaybackProgressSnapshot` reads from the same field. usePlayerStore.setState({ currentTime: 10, progress: 10 / queue[1].duration }); @@ -262,12 +245,8 @@ describe('previous', () => { it('jumps to the previous track when currentTime ≤ 3 s and queueIndex > 0', () => { const queue = makeTracks(3); - usePlayerStore.setState({ - queue, - queueIndex: 2, - currentTrack: queue[2], - currentTime: 1.0, - }); + seedQueue(queue, { index: 2, currentTrack: queue[2] }); + usePlayerStore.setState({ currentTime: 1.0 }); usePlayerStore.getState().previous(); expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id); expect(usePlayerStore.getState().queueIndex).toBe(1); @@ -275,12 +254,8 @@ describe('previous', () => { it('is a no-op when queueIndex is 0 and currentTime ≤ 3 s', () => { const queue = makeTracks(2); - usePlayerStore.setState({ - queue, - queueIndex: 0, - currentTrack: queue[0], - currentTime: 0.5, - }); + seedQueue(queue, { index: 0, currentTrack: queue[0] }); + usePlayerStore.setState({ currentTime: 0.5 }); usePlayerStore.getState().previous(); expect(usePlayerStore.getState().queueIndex).toBe(0); expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id); diff --git a/src/store/playerStore.queue.test.ts b/src/store/playerStore.queue.test.ts index 765c7c11..476a5959 100644 --- a/src/store/playerStore.queue.test.ts +++ b/src/store/playerStore.queue.test.ts @@ -33,7 +33,7 @@ vi.mock('@/utils/orbitBulkGuard', () => ({ import { usePlayerStore } from './playerStore'; import { onInvoke } from '@/test/mocks/tauri'; import { resetPlayerStore } from '@/test/helpers/storeReset'; -import { makeTrack, makeTracks } from '@/test/helpers/factories'; +import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories'; beforeEach(() => { resetPlayerStore(); @@ -50,56 +50,56 @@ describe('enqueue', () => { it('appends a single track to an empty queue', () => { const t1 = makeTrack({ id: 't1' }); usePlayerStore.getState().enqueue([t1], true); - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['t1']); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['t1']); }); it('appends multiple tracks in order', () => { const tracks = makeTracks(3); usePlayerStore.getState().enqueue(tracks, true); - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(tracks.map(t => t.id)); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(tracks.map(t => t.id)); }); it('inserts before the first upcoming auto-added separator', () => { const head = makeTrack({ id: 'head' }); const auto = makeTrack({ id: 'auto', autoAdded: true }); const tail = makeTrack({ id: 'tail', autoAdded: true }); - usePlayerStore.setState({ queue: [head, auto, tail], queueIndex: 0 }); + seedQueue([head, auto, tail], { index: 0 }); const incoming = makeTrack({ id: 'new' }); usePlayerStore.getState().enqueue([incoming], true); // Insert lands between `head` (the currently-playing one) and the first // auto-added track, so the auto-added group stays at the tail. - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['head', 'new', 'auto', 'tail']); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['head', 'new', 'auto', 'tail']); }); it('appends at the end when there are no auto-added tracks after the cursor', () => { const head = makeTrack({ id: 'head' }); const mid = makeTrack({ id: 'mid' }); - usePlayerStore.setState({ queue: [head, mid], queueIndex: 0 }); + seedQueue([head, mid], { index: 0 }); usePlayerStore.getState().enqueue([makeTrack({ id: 'tail' })], true); - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['head', 'mid', 'tail']); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['head', 'mid', 'tail']); }); it('ignores auto-added separators that already passed (behind the cursor)', () => { const past = makeTrack({ id: 'past', autoAdded: true }); const current = makeTrack({ id: 'current' }); - usePlayerStore.setState({ queue: [past, current], queueIndex: 1 }); + seedQueue([past, current], { index: 1 }); usePlayerStore.getState().enqueue([makeTrack({ id: 'new' })], true); - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['past', 'current', 'new']); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['past', 'current', 'new']); }); }); describe('enqueueAt', () => { it('inserts at the given index', () => { const queue = makeTracks(3); - usePlayerStore.setState({ queue, queueIndex: 0 }); + seedQueue(queue, { index: 0 }); const ins = makeTrack({ id: 'ins' }); usePlayerStore.getState().enqueueAt([ins], 2, true); - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([queue[0].id, queue[1].id, 'ins', queue[2].id]); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([queue[0].id, queue[1].id, 'ins', queue[2].id]); }); it('shifts queueIndex forward when inserting at or before the cursor', () => { const queue = makeTracks(3); - usePlayerStore.setState({ queue, queueIndex: 2 }); + seedQueue(queue, { index: 2 }); usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], 1, true); // Two tracks inserted at idx 1 → cursor (was 2) moves to 4. expect(usePlayerStore.getState().queueIndex).toBe(4); @@ -107,59 +107,57 @@ describe('enqueueAt', () => { it('keeps queueIndex when inserting after the cursor', () => { const queue = makeTracks(3); - usePlayerStore.setState({ queue, queueIndex: 1 }); + seedQueue(queue, { index: 1 }); usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' })], 3, true); expect(usePlayerStore.getState().queueIndex).toBe(1); }); it('clamps a negative insertIndex to 0', () => { const queue = makeTracks(2); - usePlayerStore.setState({ queue, queueIndex: 0 }); + seedQueue(queue, { index: 0 }); usePlayerStore.getState().enqueueAt([makeTrack({ id: 'front' })], -5, true); - expect(usePlayerStore.getState().queue[0].id).toBe('front'); + expect(usePlayerStore.getState().queueItems[0].trackId).toBe('front'); }); it('clamps an over-large insertIndex to the queue length', () => { const queue = makeTracks(2); - usePlayerStore.setState({ queue, queueIndex: 0 }); + seedQueue(queue, { index: 0 }); usePlayerStore.getState().enqueueAt([makeTrack({ id: 'back' })], 99, true); - const q = usePlayerStore.getState().queue; - expect(q[q.length - 1].id).toBe('back'); + const q = usePlayerStore.getState().queueItems; + expect(q[q.length - 1].trackId).toBe('back'); }); }); describe('playNext', () => { it('tags inserted tracks with playNextAdded', () => { const queue = makeTracks(2); - usePlayerStore.setState({ queue, queueIndex: 0, currentTrack: queue[0] }); + seedQueue(queue, { index: 0, currentTrack: queue[0] }); usePlayerStore.getState().playNext([makeTrack({ id: 'pn' })]); - const inserted = usePlayerStore.getState().queue.find(t => t.id === 'pn'); + const inserted = usePlayerStore.getState().queueItems.find(r => r.trackId === 'pn'); expect(inserted?.playNextAdded).toBe(true); }); it('inserts immediately after the current track', () => { const a = makeTrack({ id: 'a' }); const b = makeTrack({ id: 'b' }); - usePlayerStore.setState({ queue: [a, b], queueIndex: 0, currentTrack: a }); + seedQueue([a, b], { index: 0, currentTrack: a }); usePlayerStore.getState().playNext([makeTrack({ id: 'pn' })]); - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['a', 'pn', 'b']); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['a', 'pn', 'b']); }); it('returns early on an empty input list', () => { const queue = makeTracks(2); - usePlayerStore.setState({ queue, queueIndex: 0, currentTrack: queue[0] }); + seedQueue(queue, { index: 0, currentTrack: queue[0] }); usePlayerStore.getState().playNext([]); - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(queue.map(t => t.id)); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(queue.map(t => t.id)); }); }); describe('clearQueue', () => { it('empties the queue and resets playback bookkeeping', () => { const tracks = makeTracks(3); + seedQueue(tracks, { index: 1, currentTrack: tracks[1] }); usePlayerStore.setState({ - queue: tracks, - queueIndex: 1, - currentTrack: tracks[1], isPlaying: true, progress: 0.5, currentTime: 42, @@ -167,7 +165,7 @@ describe('clearQueue', () => { }); usePlayerStore.getState().clearQueue(); const s = usePlayerStore.getState(); - expect(s.queue).toEqual([]); + expect(s.queueItems).toEqual([]); expect(s.queueIndex).toBe(0); expect(s.currentTrack).toBeNull(); expect(s.isPlaying).toBe(false); @@ -179,7 +177,7 @@ describe('clearQueue', () => { it('calls audio_stop on the engine', () => { const stop = vi.fn(() => undefined); onInvoke('audio_stop', stop); - usePlayerStore.setState({ queue: makeTracks(2), queueIndex: 0 }); + seedQueue(makeTracks(2), { index: 0 }); usePlayerStore.getState().clearQueue(); expect(stop).toHaveBeenCalled(); }); @@ -188,24 +186,24 @@ describe('clearQueue', () => { describe('reorderQueue', () => { it('moves a track from startIndex to endIndex', () => { const [a, b, c, d] = makeTracks(4); - usePlayerStore.setState({ queue: [a, b, c, d], queueIndex: 0 }); + seedQueue([a, b, c, d], { index: 0 }); usePlayerStore.getState().reorderQueue(1, 3); // b → after d - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([a.id, c.id, d.id, b.id]); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([a.id, c.id, d.id, b.id]); }); it('preserves queueIndex by following the current track id, not the slot', () => { const [a, b, c] = makeTracks(3); - usePlayerStore.setState({ queue: [a, b, c], queueIndex: 1, currentTrack: b }); + seedQueue([a, b, c], { index: 1, currentTrack: b }); usePlayerStore.getState().reorderQueue(1, 2); // b moves to the end - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([a.id, c.id, b.id]); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([a.id, c.id, b.id]); expect(usePlayerStore.getState().queueIndex).toBe(2); // followed `b` }); it('keeps queueIndex when the current track is unaffected by the move', () => { const [a, b, c] = makeTracks(3); - usePlayerStore.setState({ queue: [a, b, c], queueIndex: 1, currentTrack: b }); + seedQueue([a, b, c], { index: 1, currentTrack: b }); usePlayerStore.getState().reorderQueue(0, 2); // a moves to the end - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([b.id, c.id, a.id]); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([b.id, c.id, a.id]); expect(usePlayerStore.getState().queueIndex).toBe(0); // followed `b` }); }); @@ -213,22 +211,22 @@ describe('reorderQueue', () => { describe('removeTrack', () => { it('removes the track at the given index', () => { const tracks = makeTracks(3); - usePlayerStore.setState({ queue: tracks, queueIndex: 0 }); + seedQueue(tracks, { index: 0 }); usePlayerStore.getState().removeTrack(1); - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([tracks[0].id, tracks[2].id]); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([tracks[0].id, tracks[2].id]); }); it('clamps queueIndex when the removal makes the queue shorter than the cursor', () => { const tracks = makeTracks(3); - usePlayerStore.setState({ queue: tracks, queueIndex: 2 }); + seedQueue(tracks, { index: 2 }); usePlayerStore.getState().removeTrack(2); - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([tracks[0].id, tracks[1].id]); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([tracks[0].id, tracks[1].id]); expect(usePlayerStore.getState().queueIndex).toBe(1); }); it('keeps queueIndex when removing a track after the cursor', () => { const tracks = makeTracks(4); - usePlayerStore.setState({ queue: tracks, queueIndex: 1 }); + seedQueue(tracks, { index: 1 }); usePlayerStore.getState().removeTrack(3); expect(usePlayerStore.getState().queueIndex).toBe(1); }); @@ -245,35 +243,35 @@ describe('undo / redo', () => { it('rolls back the most recent destructive edit', () => { const seed = makeTracks(2); - usePlayerStore.setState({ queue: seed, queueIndex: 0, currentTrack: seed[0] }); + seedQueue(seed, { index: 0, currentTrack: seed[0] }); usePlayerStore.getState().enqueue([makeTrack({ id: 'add' })], true); - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id, seed[1].id, 'add']); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id, seed[1].id, 'add']); const undone = usePlayerStore.getState().undoLastQueueEdit(); expect(undone).toBe(true); - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id, seed[1].id]); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id, seed[1].id]); }); it('replays the undone edit via redo', () => { const seed = makeTracks(2); - usePlayerStore.setState({ queue: seed, queueIndex: 0, currentTrack: seed[0] }); + seedQueue(seed, { index: 0, currentTrack: seed[0] }); usePlayerStore.getState().removeTrack(1); - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id]); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id]); usePlayerStore.getState().undoLastQueueEdit(); - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id, seed[1].id]); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id, seed[1].id]); usePlayerStore.getState().redoLastQueueEdit(); - expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id]); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id]); }); it('a new edit drops any pending redo (Word-style history)', () => { const seed = makeTracks(3); - usePlayerStore.setState({ queue: seed, queueIndex: 0, currentTrack: seed[0] }); + seedQueue(seed, { index: 0, currentTrack: seed[0] }); usePlayerStore.getState().removeTrack(2); // edit A: [s0, s1] usePlayerStore.getState().undoLastQueueEdit(); // [s0, s1, s2] - expect(usePlayerStore.getState().queue).toHaveLength(3); + expect(usePlayerStore.getState().queueItems).toHaveLength(3); usePlayerStore.getState().removeTrack(1); // edit B drops the pending redo diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index a0858fdc..258fb057 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -1,8 +1,10 @@ import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; +import { persist } from 'zustand/middleware'; +import { createSafeJSONStorage } from './safeStorage'; import { emitPlaybackProgress } from './playbackProgress'; -import type { PlayerState } from './playerStoreTypes'; +import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes'; import { toQueueItemRefs } from '../utils/library/queueItemRef'; +import { canonicalQueueServerKey } from '../utils/server/serverIndexKey'; import { readInitialQueueVisibility } from './queueVisibilityStorage'; import { createLastfmActions } from './lastfmActions'; import { createMiscActions } from './miscActions'; @@ -17,9 +19,6 @@ import { createTransportLightActions } from './transportLightActions'; import { createUiStateActions } from './uiStateActions'; import { createUndoRedoActions } from './undoRedoActions'; -// Half-width of the localStorage queue window (see partialize below). -const PERSIST_QUEUE_HALF = 250; - export const usePlayerStore = create()( persist( (set, get) => { @@ -39,7 +38,9 @@ export const usePlayerStore = create()( currentRadio: null, currentPlaybackSource: null, enginePreloadedTrackId: null, - queue: [], + // Thin-state: the queue is a list of refs; full Tracks resolve on demand + // through the resolver. `currentTrack` stays a full resolved singleton. + queueItems: [], queueServerId: null, queueIndex: 0, isPlaying: false, @@ -81,37 +82,81 @@ export const usePlayerStore = create()( }, { name: 'psysonic-player', - storage: createJSONStorage(() => localStorage), - partialize: (state) => { - // Persist only a window around the current track: the full queue - // (10k+ on big playlists) overflows the localStorage quota. - const qi = state.queueIndex; - const start = Math.max(0, qi - PERSIST_QUEUE_HALF); - const windowedQueue = state.queue.slice(start, qi + PERSIST_QUEUE_HALF + 1); + // Quota-safe: a failed persist write (huge queue > localStorage quota) + // must never throw, or it aborts the `set()` it fires from — that is what + // killed `playTrack` before `audio_play`. See safeStorage.ts. + storage: createSafeJSONStorage(), + partialize: (state) => ({ + volume: state.volume, + repeatMode: state.repeatMode, + currentTrack: state.currentTrack, + queueServerId: state.queueServerId, + // Thin-state: persist the whole ordered ref list (tiny) — no windowed + // fat `queue: Track[]` anymore. `queueItemsIndex` doubles as the + // restore-pending sentinel a fresh rehydrate carries back, telling + // `hydrateQueueFromIndex` the refs still need a full resolve. + queueItems: state.queueItems, + queueItemsIndex: state.queueIndex, + isQueueVisible: state.isQueueVisible, + // currentTime is intentionally NOT persisted here. + // handleAudioProgress fires every 100ms and each setState with a + // persisted field triggers a full JSON serialisation to localStorage. + // Resume position is recovered from Subsonic savePlayQueue (5s debounce). + lastfmLovedCache: state.lastfmLovedCache, + }), + // Rebuild `queueItems` from ANY older persisted blob shape so an upgrade + // restores the queue. Order of preference: an existing `queueItems` ref + // list → the legacy `queueRefs` string list → a windowed `queue: Track[]` + // (the pre-thin-state shape). Sets the restore-pending sentinel and drops + // the obsolete fat `queue` key from the persisted object. + merge: (persisted, current) => { + const blob = (persisted ?? {}) as Record; + const rawServerId = (blob.queueServerId as string | null | undefined) ?? null; + // B1: queue server identity is canonical (index key) on every write path. + // Migrate persisted blobs forward here once on rehydrate so the live + // store never has to handle a mix of UUID and index-key refs. + const canonicalSid = rawServerId ? canonicalQueueServerKey(rawServerId) : null; + + let queueItems: QueueItemRef[] | undefined; + if (Array.isArray(blob.queueItems) && blob.queueItems.length > 0) { + queueItems = (blob.queueItems as QueueItemRef[]).map(ref => ({ + ...ref, + serverId: canonicalQueueServerKey(ref.serverId), + })); + } else if (Array.isArray(blob.queueRefs) && blob.queueRefs.length > 0) { + queueItems = (blob.queueRefs as string[]).map(trackId => ({ + serverId: canonicalSid ?? '', + trackId, + })); + } else if (Array.isArray(blob.queue) && blob.queue.length > 0) { + queueItems = toQueueItemRefs(canonicalSid ?? '', blob.queue as Track[]); + } + + // Restore-pending sentinel: prefer the persisted one; else the legacy + // index; else 0 when we recovered a non-empty queue from an old blob. + let queueItemsIndex: number | undefined; + if (typeof blob.queueItemsIndex === 'number') { + queueItemsIndex = blob.queueItemsIndex; + } else if (typeof blob.queueRefsIndex === 'number') { + queueItemsIndex = blob.queueRefsIndex; + } else if (queueItems && queueItems.length > 0) { + queueItemsIndex = typeof blob.queueIndex === 'number' ? blob.queueIndex : 0; + } + + // Drop the obsolete windowed fat-array key — `queueItems` is canonical. + delete blob.queue; + // Persist the canonical form back onto the merged blob so subsequent + // reads of state.queueServerId always see the index key. + if (canonicalSid !== null) { + blob.queueServerId = canonicalSid; + } + return { - volume: state.volume, - repeatMode: state.repeatMode, - currentTrack: state.currentTrack, - queue: windowedQueue, - queueServerId: state.queueServerId, - queueIndex: qi - start, // remap into the windowed slice - // Phase 1: full ordered thin-ref list (tiny) so the *whole* queue can - // be rehydrated from the local index on startup. Dual-write — the - // windowed `queue` above stays as the no-index fallback (queue never - // empty when the index is off, the P6 default). Per-item serverId is - // the playback server (single-server v1); supersedes `queueRefs`. - queueItems: toQueueItemRefs(state.queueServerId ?? '', state.queue), - queueItemsIndex: qi, - isQueueVisible: state.isQueueVisible, - // currentTime is intentionally NOT persisted here. - // handleAudioProgress fires every 100ms and each setState with a - // persisted field triggers a full JSON serialisation of the queue to - // localStorage. After ~10 minutes of Artist Radio the queue grows to - // 50+ tracks; 6 000+ synchronous SQLite writes cause WKWebView's - // storage process to crash on macOS → black screen + audio stop. - // Resume position is recovered from Subsonic savePlayQueue (5s debounce). - lastfmLovedCache: state.lastfmLovedCache, - }; + ...current, + ...blob, + queueItems: queueItems ?? current.queueItems, + ...(queueItemsIndex !== undefined ? { queueItemsIndex } : {}), + } as PlayerState; }, } ) diff --git a/src/store/playerStoreTypes.ts b/src/store/playerStoreTypes.ts index 606512a8..85a0f648 100644 --- a/src/store/playerStoreTypes.ts +++ b/src/store/playerStoreTypes.ts @@ -69,7 +69,6 @@ export interface PlayerState { * Cleared after a successful `audio_play` consumed that preload, or when starting another track. */ enginePreloadedTrackId: string | null; - queue: Track[]; /** Saved server for stream/hot-cache/offline resolution while this queue plays. */ queueServerId: string | null; queueIndex: number; @@ -79,11 +78,16 @@ export interface PlayerState { * are then cleared. Absent / index-off → the windowed `queue` is used as-is. */ queueRefs?: string[]; queueRefsIndex?: number; - /** Phase 1 (transient): thin canonical ref list persisted alongside the - * windowed `queue`, superseding `queueRefs`. Carries per-item `serverId` + - * queue-only flags. Rehydrated like `queueRefs` and cleared after a full - * hydrate from the index. The in-memory queue stays `Track[]` until Phase 2. */ - queueItems?: QueueItemRef[]; + /** Canonical thin queue list (thin-state). Single playback server per item in + * v1; carries the queue-only flags. Persisted by `partialize`; the source the + * resolver/consumers read from — full `Track`s resolve on demand. */ + queueItems: QueueItemRef[]; + /** Restore-pending sentinel (transient). `partialize` writes it alongside the + * full `queueItems` on every persist; a fresh rehydrate brings it back, which + * is what tells `hydrateQueueFromIndex` the windowed `queue` still needs a + * full hydrate. Normal mutations keep `queueItems` canonical but never set + * this, so its presence — not `queueItems` — gates the restore. Cleared once + * a full hydrate succeeds. */ queueItemsIndex?: number; isPlaying: boolean; /** HTTP stream still buffering (network / demux probe) — show loading on cover art. */ diff --git a/src/store/queueMutationActions.ts b/src/store/queueMutationActions.ts index 7f91c6f5..9fd9f990 100644 --- a/src/store/queueMutationActions.ts +++ b/src/store/queueMutationActions.ts @@ -3,7 +3,9 @@ import { orbitBulkGuard } from '../utils/orbitBulkGuard'; import { useAuthStore } from './authStore'; import { setIsAudioPaused } from './engineState'; import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch'; -import type { PlayerState, Track } from './playerStoreTypes'; +import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes'; +import { toQueueItemRefs } from '../utils/library/queueItemRef'; +import { seedQueueResolver } from '../utils/library/queueTrackResolver'; import { pushQueueUndoFromGetter } from './queueUndo'; import { syncQueueToServer } from './queueSync'; import { @@ -35,6 +37,22 @@ function blockCrossServerEnqueue(): boolean { return true; } +/** + * The canonical working ref list for a mutation (thin-state). Mutations + * splice/filter/reorder a copy of these refs and write the result back into + * `queueItems` — the queue source of truth. Returns a fresh array each call so + * in-place splices don't mutate the live state array. + */ +const itemsOf = (state: PlayerState): QueueItemRef[] => [...state.queueItems]; + +/** Seed the resolver cache with tracks entering the queue, so they resolve + * without a network round-trip once `queue: Track[]` is dropped (seed-before- + * splice). No-op without a real playback server (e.g. unit tests). */ +function seedIncoming(state: PlayerState, tracks: Track[]): void { + const serverId = state.queueServerId ?? ''; + if (serverId) seedQueueResolver(serverId, tracks); +} + /** * Eleven queue-mutation actions. Shared invariant: every action except * `setRadioArtistId` pushes a queue-undo snapshot and calls @@ -67,21 +85,18 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< } if (!skipQueueUndo) pushQueueUndoFromGetter(get); set(state => { + seedIncoming(state, tracks); + const items = itemsOf(state); + const incoming = toQueueItemRefs(state.queueServerId ?? '', tracks); // Insert before the first upcoming auto-added track so the // "Added automatically" separator always stays at the boundary. - const firstAutoIdx = state.queue.findIndex( - (t, i) => t.autoAdded && i > state.queueIndex - ); - const newQueue = firstAutoIdx === -1 - ? [...state.queue, ...tracks] - : [ - ...state.queue.slice(0, firstAutoIdx), - ...tracks, - ...state.queue.slice(firstAutoIdx), - ]; - syncQueueToServer(newQueue, state.currentTrack, state.currentTime); - prefetchLoudnessForEnqueuedTracks(newQueue, state.queueIndex); - return { queue: newQueue }; + const firstAutoIdx = items.findIndex((r, i) => r.autoAdded && i > state.queueIndex); + const newItems = firstAutoIdx === -1 + ? [...items, ...incoming] + : [...items.slice(0, firstAutoIdx), ...incoming, ...items.slice(firstAutoIdx)]; + syncQueueToServer(newItems, state.currentTrack, state.currentTime); + prefetchLoudnessForEnqueuedTracks(newItems, state.queueIndex); + return { queueItems: newItems }; }); }, @@ -101,22 +116,23 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< } pushQueueUndoFromGetter(get); set(state => { + const items = itemsOf(state); // Drop all upcoming (not yet played) radio tracks — clicking "Start Radio" // again replaces the pending radio batch instead of stacking on top. - const beforeAndCurrent = state.queue.slice(0, state.queueIndex + 1); - const upcoming = state.queue.slice(state.queueIndex + 1).filter(t => !t.radioAdded); + const beforeAndCurrent = items.slice(0, state.queueIndex + 1); + const upcoming = items.slice(state.queueIndex + 1).filter(r => !r.radioAdded); // Tracks about to leave the queue here. Callers like ContextMenu.startRadio // pass the previous pending radio back in `tracks` to merge with new // similars — the seen-set must not block those re-introductions. - const droppedRadioIds = state.queue + const droppedRadioIds = items .slice(state.queueIndex + 1) - .filter(t => t.radioAdded) - .map(t => t.id); + .filter(r => r.radioAdded) + .map(r => r.trackId); for (const id of droppedRadioIds) deleteRadioSessionSeen(id); // Capture surviving queue ids in the seen-set so the next radio top-up // can dedupe against the seed track + already-queued non-radio items. - for (const t of beforeAndCurrent) addRadioSessionSeen(t.id); - for (const t of upcoming) addRadioSessionSeen(t.id); + for (const r of beforeAndCurrent) addRadioSessionSeen(r.trackId); + for (const r of upcoming) addRadioSessionSeen(r.trackId); // Drop incoming tracks already seen earlier this session AND // intra-batch duplicates (top + similar Last.fm responses commonly // overlap). The seen-set is mutated inside the loop so a repeated @@ -128,18 +144,16 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< addRadioSessionSeen(t.id); dedupedTracks.push(t); } + seedIncoming(state, dedupedTracks); + const incoming = toQueueItemRefs(state.queueServerId ?? '', dedupedTracks); // Insert new radio tracks before any autoAdded tracks in the upcoming section. - const firstAutoIdx = upcoming.findIndex(t => t.autoAdded); - const merged = firstAutoIdx === -1 - ? [...upcoming, ...dedupedTracks] - : [ - ...upcoming.slice(0, firstAutoIdx), - ...dedupedTracks, - ...upcoming.slice(firstAutoIdx), - ]; - const newQueue = [...beforeAndCurrent, ...merged]; - syncQueueToServer(newQueue, state.currentTrack, state.currentTime); - return { queue: newQueue }; + const firstAutoIdx = upcoming.findIndex(r => r.autoAdded); + const mergedItems = firstAutoIdx === -1 + ? [...upcoming, ...incoming] + : [...upcoming.slice(0, firstAutoIdx), ...incoming, ...upcoming.slice(firstAutoIdx)]; + const newItems = [...beforeAndCurrent, ...mergedItems]; + syncQueueToServer(newItems, state.currentTrack, state.currentTime); + return { queueItems: newItems }; }); }, @@ -153,18 +167,17 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< } pushQueueUndoFromGetter(get); set(state => { - const idx = Math.max(0, Math.min(insertIndex, state.queue.length)); - const newQueue = [ - ...state.queue.slice(0, idx), - ...tracks, - ...state.queue.slice(idx), - ]; + seedIncoming(state, tracks); + const items = itemsOf(state); + const idx = Math.max(0, Math.min(insertIndex, items.length)); + const incoming = toQueueItemRefs(state.queueServerId ?? '', tracks); + const newItems = [...items.slice(0, idx), ...incoming, ...items.slice(idx)]; const newQueueIndex = idx <= state.queueIndex ? state.queueIndex + tracks.length : state.queueIndex; - syncQueueToServer(newQueue, state.currentTrack, state.currentTime); - prefetchLoudnessForEnqueuedTracks(newQueue, newQueueIndex); - return { queue: newQueue, queueIndex: newQueueIndex }; + syncQueueToServer(newItems, state.currentTrack, state.currentTime); + prefetchLoudnessForEnqueuedTracks(newItems, newQueueIndex); + return { queueItems: newItems, queueIndex: newQueueIndex }; }); }, @@ -180,8 +193,8 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< const baseIdx = state.queueIndex + 1; let insertIdx = baseIdx; if (useAuthStore.getState().preservePlayNextOrder) { - const q = state.queue; - while (insertIdx < q.length && q[insertIdx].playNextAdded) insertIdx++; + const items = itemsOf(state); + while (insertIdx < items.length && items[insertIdx].playNextAdded) insertIdx++; } get().enqueueAt(tagged, insertIdx); }, @@ -190,21 +203,24 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< const s = get(); if (s.currentRadio) return; if (!s.currentTrack) { - if (s.queue.length === 0) return; + if (s.queueItems.length === 0) return; if (!skipQueueUndo) pushQueueUndoFromGetter(get); - set({ queue: [], queueIndex: 0 }); + set({ queueItems: [], queueIndex: 0 }); syncQueueToServer([], null, 0); return; } if (!skipQueueUndo) pushQueueUndoFromGetter(get); - const at = s.queue.findIndex(t => t.id === s.currentTrack!.id); - const newQueue: Track[] = - at >= 0 - ? s.queue.slice(0, at + 1) - : [s.currentTrack!]; + // Seed the resolver with the currently playing track so its ref always + // resolves even when it had not been in the cache window before. + seedIncoming(s, [s.currentTrack]); + const items = itemsOf(s); + const at = items.findIndex(r => r.trackId === s.currentTrack!.id); + const newItems = at >= 0 + ? items.slice(0, at + 1) + : toQueueItemRefs(s.queueServerId ?? '', [s.currentTrack!]); const newIndex = at >= 0 ? at : 0; - set({ queue: newQueue, queueIndex: newIndex }); - syncQueueToServer(newQueue, s.currentTrack, s.currentTime); + set({ queueItems: newItems, queueIndex: newIndex }); + syncQueueToServer(newItems, s.currentTrack, s.currentTime); }, clearQueue: () => { @@ -216,64 +232,73 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< clearRadioSessionSeenIds(); setCurrentRadioArtistId(null); clearQueueServerForPlayback(); - set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); + set({ queueItems: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); syncQueueToServer([], null, 0); }, reorderQueue: (startIndex, endIndex) => { pushQueueUndoFromGetter(get); - const { queue, queueIndex, currentTrack } = get(); - const result = Array.from(queue); + const state = get(); + const { queueIndex, currentTrack } = state; + const result = itemsOf(state); const [removed] = result.splice(startIndex, 1); result.splice(endIndex, 0, removed); let newIndex = queueIndex; - if (currentTrack) newIndex = result.findIndex(t => t.id === currentTrack.id); - set({ queue: result, queueIndex: Math.max(0, newIndex) }); + if (currentTrack) newIndex = result.findIndex(r => r.trackId === currentTrack.id); + set({ queueItems: result, queueIndex: Math.max(0, newIndex) }); syncQueueToServer(result, currentTrack, get().currentTime); }, shuffleQueue: () => { - const { queue, currentTrack } = get(); - if (queue.length < 2) return; + const state = get(); + const { currentTrack } = state; + if (state.queueItems.length < 2) return; pushQueueUndoFromGetter(get); - const currentIdx = currentTrack ? queue.findIndex(t => t.id === currentTrack.id) : -1; - const others = queue.filter((_, i) => i !== currentIdx); + const items = itemsOf(state); + const currentIdx = currentTrack ? items.findIndex(r => r.trackId === currentTrack.id) : -1; + const others = items.filter((_, i) => i !== currentIdx); for (let i = others.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [others[i], others[j]] = [others[j], others[i]]; } const result = currentIdx >= 0 - ? [queue[currentIdx], ...others] + ? [items[currentIdx], ...others] : others; const newIndex = currentIdx >= 0 ? 0 : -1; - set({ queue: result, queueIndex: Math.max(0, newIndex) }); + set({ queueItems: result, queueIndex: Math.max(0, newIndex) }); syncQueueToServer(result, currentTrack, get().currentTime); }, shuffleUpcomingQueue: () => { - const { queue, queueIndex, currentTrack } = get(); + const state = get(); + const { queueIndex, currentTrack } = state; const upcomingStart = queueIndex + 1; - const upcomingCount = queue.length - upcomingStart; + const upcomingCount = state.queueItems.length - upcomingStart; if (upcomingCount < 2) return; pushQueueUndoFromGetter(get); - const head = queue.slice(0, upcomingStart); - const upcoming = queue.slice(upcomingStart); + const items = itemsOf(state); + const head = items.slice(0, upcomingStart); + const upcoming = items.slice(upcomingStart); for (let i = upcoming.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]]; } const result = [...head, ...upcoming]; - set({ queue: result }); + set({ queueItems: result }); syncQueueToServer(result, currentTrack, get().currentTime); }, removeTrack: (index) => { pushQueueUndoFromGetter(get); - const { queue, queueIndex } = get(); - const newQueue = [...queue]; - newQueue.splice(index, 1); - set({ queue: newQueue, queueIndex: Math.min(queueIndex, newQueue.length - 1) }); - syncQueueToServer(newQueue, get().currentTrack, get().currentTime); + const state = get(); + const { queueIndex } = state; + const newItems = itemsOf(state); + newItems.splice(index, 1); + set({ + queueItems: newItems, + queueIndex: Math.min(queueIndex, newItems.length - 1), + }); + syncQueueToServer(newItems, get().currentTrack, get().currentTime); }, }; } diff --git a/src/store/queueResolverBridge.ts b/src/store/queueResolverBridge.ts index dfb2387a..3725a0e1 100644 --- a/src/store/queueResolverBridge.ts +++ b/src/store/queueResolverBridge.ts @@ -1,21 +1,22 @@ /** - * Side-effect wiring (queue thin-state, phase 2b): seed the queue track - * resolver cache with the tracks around the current index whenever the queue - * changes. The store stays `queue: Track[]`-canonical for now — this only fills - * the resolver cache (additive; no mutation or persist change), so the queue - * selectors resolve without a fetch once consumers move onto them (phase 3) and - * after `queue: Track[]` is dropped (phase 4). + * Side-effect wiring (queue thin-state): keep the queue track resolver cache + * warm around the current index whenever the canonical `queueItems` ref list or + * the playing index changes. The store is refs-canonical now, so this fills the + * cache (via `resolveVisibleRange` — index batch → getSong fallback) so the + * queue selectors / list rows resolve without a synchronous miss. Render paths + * stay pure (no cache mutation in render); the seed travels with the playing + * track here, off the render path. */ import { usePlayerStore } from './playerStore'; -import { seedQueueResolver } from '../utils/library/queueTrackResolver'; - -const SEED_BACK = 50; -const SEED_AHEAD = 200; +import { resolveVisibleRange } from '../utils/library/queueTrackResolver'; usePlayerStore.subscribe((state, prev) => { - if (state.queue === prev.queue && state.queueServerId === prev.queueServerId) return; - const serverId = state.queueServerId ?? ''; - if (!serverId || state.queue.length === 0) return; - const start = Math.max(0, state.queueIndex - SEED_BACK); - seedQueueResolver(serverId, state.queue.slice(start, state.queueIndex + SEED_AHEAD + 1)); + // Re-seed when the queue refs or the current index change — the prefetch + // window (resolveVisibleRange's PREFETCH_BACK/AHEAD) travels with the index. + if ( + state.queueItems === prev.queueItems && + state.queueIndex === prev.queueIndex + ) return; + if (state.queueItems.length === 0) return; + resolveVisibleRange(state.queueItems, state.queueIndex, state.queueIndex); }); diff --git a/src/store/queueSync.test.ts b/src/store/queueSync.test.ts index 77b9c72c..0237c189 100644 --- a/src/store/queueSync.test.ts +++ b/src/store/queueSync.test.ts @@ -5,12 +5,12 @@ * in for `savePlayQueue`, the playerStore, and the playback-progress * snapshot. */ -import type { Track } from './playerStoreTypes'; +import type { QueueItemRef, Track } from './playerStoreTypes'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({ savePlayQueueMock: vi.fn(async (_ids: string[], _currentId: string | undefined, _pos: number, _serverId: string) => undefined), playerState: { - queue: [] as Track[], + queueItems: [] as QueueItemRef[], currentTrack: null as Track | null, currentRadio: null as { id: string } | null, }, @@ -40,12 +40,17 @@ function track(id: string): Track { return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 }; } +// Thin-state: sync helpers take queue refs. +function ref(id: string): QueueItemRef { + return { serverId: 'srv-a', trackId: id }; +} + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-05-12T12:00:00Z')); savePlayQueueMock.mockClear(); savePlayQueueMock.mockResolvedValue(undefined); - playerState.queue = []; + playerState.queueItems = []; playerState.currentTrack = null; playerState.currentRadio = null; progressSnapshot.currentTime = 0; @@ -57,32 +62,32 @@ afterEach(() => { }); describe('syncQueueToServer (debounced)', () => { - const queue = [track('a'), track('b')]; + const queue = [ref('a'), ref('b')]; it('does not fire before 5 s elapse', () => { - syncQueueToServer(queue, queue[0], 30); + syncQueueToServer(queue, track('a'), 30); vi.advanceTimersByTime(4999); expect(savePlayQueueMock).not.toHaveBeenCalled(); }); it('fires once after 5 s with id list + current id + position in ms', () => { - syncQueueToServer(queue, queue[0], 30); + syncQueueToServer(queue, track('a'), 30); vi.advanceTimersByTime(5000); expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 30000, 'srv-a'); }); it('cancels the previous timer when called again before fire', () => { - syncQueueToServer(queue, queue[0], 10); + syncQueueToServer(queue, track('a'), 10); vi.advanceTimersByTime(3000); - syncQueueToServer([...queue, track('c')], queue[0], 20); + syncQueueToServer([...queue, ref('c')], track('a'), 20); vi.advanceTimersByTime(5000); expect(savePlayQueueMock).toHaveBeenCalledTimes(1); expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b', 'c'], 'a', 20000, 'srv-a'); }); it('caps the queue at 1000 ids', () => { - const big = Array.from({ length: 1500 }, (_, i) => track(`t${i}`)); - syncQueueToServer(big, big[0], 0); + const big = Array.from({ length: 1500 }, (_, i) => ref(`t${i}`)); + syncQueueToServer(big, track('t0'), 0); vi.advanceTimersByTime(5000); const ids = savePlayQueueMock.mock.calls[0][0] as string[]; expect(ids.length).toBe(1000); @@ -93,13 +98,13 @@ describe('syncQueueToServer (debounced)', () => { describe('flushQueueSyncToServer (immediate)', () => { it('fires synchronously with no debounce', async () => { - await flushQueueSyncToServer([track('a')], track('a'), 12); + await flushQueueSyncToServer([ref('a')], track('a'), 12); expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000, 'srv-a'); }); it('cancels a pending debounced sync first', async () => { - syncQueueToServer([track('a')], track('a'), 30); - await flushQueueSyncToServer([track('a')], track('a'), 31); + syncQueueToServer([ref('a')], track('a'), 30); + await flushQueueSyncToServer([ref('a')], track('a'), 31); expect(savePlayQueueMock).toHaveBeenCalledTimes(1); // After the flush returns, advancing past the debounce should not fire again. vi.advanceTimersByTime(10_000); @@ -107,7 +112,7 @@ describe('flushQueueSyncToServer (immediate)', () => { }); it('is a no-op when currentTrack is null', async () => { - await flushQueueSyncToServer([track('a')], null, 5); + await flushQueueSyncToServer([ref('a')], null, 5); expect(savePlayQueueMock).not.toHaveBeenCalled(); }); @@ -118,23 +123,23 @@ describe('flushQueueSyncToServer (immediate)', () => { it('records the heartbeat timestamp', async () => { expect(getLastQueueHeartbeatAt()).toBe(0); - await flushQueueSyncToServer([track('a')], track('a'), 5); + await flushQueueSyncToServer([ref('a')], track('a'), 5); expect(getLastQueueHeartbeatAt()).toBe(Date.now()); }); }); describe('flushPlayQueuePosition', () => { it('reads the current playerStore queue + playback-progress time', async () => { - playerState.queue = [track('a'), track('b')]; - playerState.currentTrack = playerState.queue[0]; + playerState.queueItems = [ref('a'), ref('b')]; + playerState.currentTrack = track('a'); progressSnapshot.currentTime = 42; await flushPlayQueuePosition(); expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 42000, 'srv-a'); }); it('is a no-op when a radio session is active', async () => { - playerState.queue = [track('a')]; - playerState.currentTrack = playerState.queue[0]; + playerState.queueItems = [ref('a')]; + playerState.currentTrack = track('a'); playerState.currentRadio = { id: 'radio-1' }; await flushPlayQueuePosition(); expect(savePlayQueueMock).not.toHaveBeenCalled(); diff --git a/src/store/queueSync.ts b/src/store/queueSync.ts index 2d53ce65..a239d27f 100644 --- a/src/store/queueSync.ts +++ b/src/store/queueSync.ts @@ -1,5 +1,5 @@ import { savePlayQueue } from '../api/subsonicPlayQueue'; -import type { Track } from './playerStoreTypes'; +import type { QueueItemRef, Track } from './playerStoreTypes'; import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { getPlaybackProgressSnapshot } from './playbackProgress'; import { usePlayerStore } from './playerStore'; @@ -26,11 +26,11 @@ const QUEUE_ID_LIMIT = 1000; let syncTimeout: ReturnType | null = null; let lastQueueHeartbeatAt = 0; -export function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number): void { +export function syncQueueToServer(queue: QueueItemRef[], currentTrack: Track | null, currentTime: number): void { if (syncTimeout) clearTimeout(syncTimeout); syncTimeout = setTimeout(() => { syncTimeout = null; - const ids = queue.slice(0, QUEUE_ID_LIMIT).map(t => t.id); + const ids = queue.slice(0, QUEUE_ID_LIMIT).map(r => r.trackId); const pos = Math.floor(currentTime * 1000); const serverId = getPlaybackServerId(); savePlayQueue(ids, currentTrack?.id, pos, serverId).catch(err => { @@ -39,14 +39,14 @@ export function syncQueueToServer(queue: Track[], currentTrack: Track | null, cu }, SYNC_DEBOUNCE_MS); } -export function flushQueueSyncToServer(queue: Track[], currentTrack: Track | null, currentTime: number): Promise { +export function flushQueueSyncToServer(queue: QueueItemRef[], currentTrack: Track | null, currentTime: number): Promise { if (syncTimeout) { clearTimeout(syncTimeout); syncTimeout = null; } if (!currentTrack || queue.length === 0) return Promise.resolve(); lastQueueHeartbeatAt = Date.now(); - const ids = queue.slice(0, QUEUE_ID_LIMIT).map(t => t.id); + const ids = queue.slice(0, QUEUE_ID_LIMIT).map(r => r.trackId); const pos = Math.floor(currentTime * 1000); const serverId = getPlaybackServerId(); return savePlayQueue(ids, currentTrack.id, pos, serverId).catch(err => { @@ -68,7 +68,7 @@ export function getLastQueueHeartbeatAt(): number { export function flushPlayQueuePosition(): Promise { const s = usePlayerStore.getState(); if (s.currentRadio) return Promise.resolve(); - return flushQueueSyncToServer(s.queue, s.currentTrack, getPlaybackProgressSnapshot().currentTime); + return flushQueueSyncToServer(s.queueItems, s.currentTrack, getPlaybackProgressSnapshot().currentTime); } /** Test-only: drop the debounce + reset the heartbeat. */ diff --git a/src/store/queueUndo.test.ts b/src/store/queueUndo.test.ts index 1fe9c4b5..4d58b9be 100644 --- a/src/store/queueUndo.test.ts +++ b/src/store/queueUndo.test.ts @@ -5,6 +5,7 @@ * to restore list scroll position after an undo/redo commit. */ import type { PlayerState, Track } from './playerStoreTypes'; +import { toQueueItemRefs } from '../utils/library/queueItemRef'; import { beforeEach, describe, expect, it } from 'vitest'; import { QUEUE_UNDO_MAX, @@ -24,14 +25,17 @@ function track(id: string): Track { return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 }; } -function state(queue: Track[], overrides: Partial = {}): PlayerState { +// 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 { return { - queue, + queueItems: toQueueItemRefs('', tracks), queueIndex: 0, - currentTrack: queue[0] ?? null, + currentTrack: tracks[0] ?? null, currentTime: 0, progress: 0, isPlaying: false, + queueServerId: null, ...overrides, } as PlayerState; } @@ -44,13 +48,11 @@ beforeEach(() => { }); describe('queueUndoSnapshotFromState', () => { - it('deep-clones queue tracks and currentTrack', () => { + it('captures the queue as thin refs and clones currentTrack', () => { const original = state([track('a'), track('b')]); const snap = queueUndoSnapshotFromState(original); - expect(snap.queue).not.toBe(original.queue); - expect(snap.queue[0]).not.toBe(original.queue[0]); expect(snap.currentTrack).not.toBe(original.currentTrack); - expect(snap.queue.map(t => t.id)).toEqual(['a', 'b']); + expect(snap.queueItems.map(r => r.trackId)).toEqual(['a', 'b']); }); it('preserves currentTrack=null', () => { @@ -69,7 +71,7 @@ describe('pushQueueUndoFromGetter', () => { it('captures the current state on top of the undo stack', () => { pushQueueUndoFromGetter(() => state([track('a')])); const snap = popQueueUndoSnapshot(); - expect(snap?.queue[0].id).toBe('a'); + expect(snap?.queueItems[0].trackId).toBe('a'); }); it('wipes the redo stack — a fresh action invalidates redo history', () => { @@ -100,8 +102,8 @@ describe('pushQueueUndoSnapshot / pushQueueRedoSnapshot', () => { it('undo-snapshot push keeps order LIFO', () => { pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('first')]))); pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('second')]))); - expect(popQueueUndoSnapshot()?.queue[0].id).toBe('second'); - expect(popQueueUndoSnapshot()?.queue[0].id).toBe('first'); + expect(popQueueUndoSnapshot()?.queueItems[0].trackId).toBe('second'); + expect(popQueueUndoSnapshot()?.queueItems[0].trackId).toBe('first'); }); }); diff --git a/src/store/queueUndo.ts b/src/store/queueUndo.ts index 2f872647..ee43b52c 100644 --- a/src/store/queueUndo.ts +++ b/src/store/queueUndo.ts @@ -1,10 +1,14 @@ -import type { PlayerState, Track } from './playerStoreTypes'; +import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes'; /** Hard cap on undo/redo depth — keeps memory bounded for very long sessions. */ export const QUEUE_UNDO_MAX = 32; export type QueueUndoSnapshot = { - queue: Track[]; + /** Thin queue refs (thin-state phase 4) — not hydrated `Track[]`, so 32 + * snapshots of a 50k queue cost refs, not 32×50k full tracks. Rebuilt to a + * display `Track[]` through the resolver on restore. */ + queueItems: QueueItemRef[]; queueIndex: number; + /** Kept full — one resolved playing track, restored to the engine on undo. */ currentTrack: Track | null; /** Seconds — captured with the snapshot (older entries may omit). */ currentTime?: number; @@ -12,6 +16,12 @@ export type QueueUndoSnapshot = { isPlaying?: boolean; /** Main queue panel list `scrollTop` when the snapshot was taken. */ queueListScrollTop?: number; + /** Canonical playback-server identity at snapshot time. Restore uses this + * for any ref it has to prepend (e.g. a still-playing track absent from the + * snapshot's queue) so a mid-restore server switch can't bind the prepended + * ref to the wrong server (B1/H3). Older in-memory entries may omit it; + * callers fall back to the live `queueServerId` in that case. */ + queueServerId?: string | null; }; const queueUndoStack: QueueUndoSnapshot[] = []; @@ -50,12 +60,15 @@ export function consumePendingQueueListScrollTop(): number | undefined { export function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot { const scrollTop = readQueueListScrollTopForUndo(); return { - queue: s.queue.map(t => ({ ...t })), + // Thin refs straight off the canonical list — 32 snapshots cost refs, not + // 32×50k full tracks (the undo "hidden multiplier" the thin-state plan kills). + queueItems: [...s.queueItems], queueIndex: s.queueIndex, currentTrack: s.currentTrack ? { ...s.currentTrack } : null, currentTime: s.currentTime, progress: s.progress, isPlaying: s.isPlaying, + queueServerId: s.queueServerId, ...(scrollTop !== undefined ? { queueListScrollTop: scrollTop } : {}), }; } diff --git a/src/store/resumeAction.ts b/src/store/resumeAction.ts index 317c36f2..905f5f39 100644 --- a/src/store/resumeAction.ts +++ b/src/store/resumeAction.ts @@ -28,6 +28,7 @@ import { recordEnginePlayUrl, } from './playbackUrlRouting'; import type { PlayerState } from './playerStoreTypes'; +import { resolveQueueTrack } from '../utils/library/queueTrackView'; import { promoteCompletedStreamToHotCache } from './promoteStreamCache'; import { syncQueueToServer } from './queueSync'; import { resumeRadio } from './radioPlayer'; @@ -112,10 +113,14 @@ export function runResume(set: SetState, get: GetState): void { set({ isPlaying: true }); return; } - const { currentTrack, queue, queueIndex, currentTime } = get(); + const { currentTrack, queueItems, queueIndex, currentTime } = get(); if (!currentTrack) return; - const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null; - const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; + // ReplayGain album-mode neighbours (resolver cache → placeholder; only their + // RG tags matter, which a placeholder lacks → fallback dB). + const coldPrev = queueIndex > 0 && queueItems[queueIndex - 1] + ? resolveQueueTrack(queueItems[queueIndex - 1]) : null; + const coldNext = queueIndex + 1 < queueItems.length && queueItems[queueIndex + 1] + ? resolveQueueTrack(queueItems[queueIndex + 1]) : null; if (getIsAudioPaused()) { // Rust engine has audio loaded but paused — just resume it. @@ -185,7 +190,7 @@ export function runResume(set: SetState, get: GetState): void { console.error('[psysonic] audio_play (cold resume) failed:', err); set({ isPlaying: false }); }); - syncQueueToServer(queue, trackToPlay, currentTime); + syncQueueToServer(queueItems, trackToPlay, currentTime); }).catch(() => { if (getPlayGeneration() !== gen) return; // Fallback to currentTrack if fetch fails @@ -221,7 +226,7 @@ export function runResume(set: SetState, get: GetState): void { console.error('[psysonic] audio_play (cold resume) failed:', err); set({ isPlaying: false }); }); - syncQueueToServer(queue, currentTrack, currentTime); + syncQueueToServer(queueItems, currentTrack, currentTime); }); })(); } diff --git a/src/store/safeStorage.test.ts b/src/store/safeStorage.test.ts new file mode 100644 index 00000000..19ac5443 --- /dev/null +++ b/src/store/safeStorage.test.ts @@ -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(); + }); +}); diff --git a/src/store/safeStorage.ts b/src/store/safeStorage.ts new file mode 100644 index 00000000..d2855509 --- /dev/null +++ b/src/store/safeStorage.ts @@ -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(); + +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 = () => createJSONStorage(() => safeLocalStorage); diff --git a/src/store/seekAction.ts b/src/store/seekAction.ts index fa120471..db71b649 100644 --- a/src/store/seekAction.ts +++ b/src/store/seekAction.ts @@ -56,7 +56,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void { setAtMs: Date.now(), }); clearSeekFallbackRetry(); - s0.playTrack(s0.currentTrack, s0.queue, true); + s0.playTrack(s0.currentTrack, undefined, true); return; } invoke('audio_seek', { seconds: time }).then(() => { @@ -95,7 +95,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void { setSeekFallbackTrackId(s.currentTrack.id); setSeekFallbackRestartAt(now); // Keep manual semantics (no crossfade) for seek recovery restarts. - s.playTrack(s.currentTrack, s.queue, true); + s.playTrack(s.currentTrack, undefined, true); } scheduleSeekFallbackRetry(s.currentTrack.id, time); }); diff --git a/src/store/skipStarRating.test.ts b/src/store/skipStarRating.test.ts index 11d95640..d4866e65 100644 --- a/src/store/skipStarRating.test.ts +++ b/src/store/skipStarRating.test.ts @@ -8,7 +8,7 @@ import type { Track } from './playerStoreTypes'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const { queueSongRatingMock, recordSkipStarMock, playerStateGet } = vi.hoisted(() => { const playerState = { - queue: [] as Track[], + queueServerId: 's1' as string | null, currentTrack: null as Track | null, userRatingOverrides: {} as Record, }; @@ -28,6 +28,7 @@ vi.mock('./playerStore', () => ({ })); import { applySkipStarOnManualNext } from './skipStarRating'; +import { seedQueueResolver, _resetQueueResolverForTest } from '../utils/library/queueTrackResolver'; function track(id: string, overrides: Partial = {}): Track { return { @@ -38,8 +39,9 @@ function track(id: string, overrides: Partial = {}): Track { beforeEach(() => { queueSongRatingMock.mockClear(); recordSkipStarMock.mockReset(); + _resetQueueResolverForTest(); const s = playerStateGet(); - s.queue = []; + s.queueServerId = 's1'; s.currentTrack = null; s.userRatingOverrides = {}; }); @@ -76,9 +78,10 @@ describe('applySkipStarOnManualNext', () => { expect(queueSongRatingMock).not.toHaveBeenCalled(); }); - it('skips rating when the queue entry is already rated', () => { + it('skips rating when the resolver-cached queue entry is already rated', () => { recordSkipStarMock.mockReturnValueOnce({ crossedThreshold: true }); - playerStateGet().queue = [track('t1', { userRating: 4 })]; + // Thin-state: the queue's track copy lives in the resolver cache. + seedQueueResolver('s1', [track('t1', { userRating: 4 })]); applySkipStarOnManualNext(track('t1'), true); expect(queueSongRatingMock).not.toHaveBeenCalled(); }); diff --git a/src/store/skipStarRating.ts b/src/store/skipStarRating.ts index 8985603c..04aa7c7b 100644 --- a/src/store/skipStarRating.ts +++ b/src/store/skipStarRating.ts @@ -1,6 +1,7 @@ import type { Track } from './playerStoreTypes'; import { useAuthStore } from './authStore'; import { usePlayerStore } from './playerStore'; +import { getCachedTrack } from '../utils/library/queueTrackResolver'; import { queueSongRating } from './pendingStarSync'; /** * Skip → 1★ behaviour: every user-initiated `next()` on an unrated track @@ -18,10 +19,12 @@ export function applySkipStarOnManualNext(skippedTrack: Track | null, manual: bo const adv = useAuthStore.getState().recordSkipStarManualAdvance(id); if (!adv?.crossedThreshold) return; const live = usePlayerStore.getState(); - const fromQueue = live.queue.find(t => t.id === id); + // Thin-state: the queue's copy of the rating now lives in the resolver cache. + const sid = live.queueServerId ?? ''; + const fromCache = sid ? getCachedTrack({ serverId: sid, trackId: id }) : undefined; const cur = live.userRatingOverrides[id] ?? - fromQueue?.userRating ?? + fromCache?.userRating ?? skippedTrack.userRating ?? 0; if (cur >= 1) return; diff --git a/src/store/transportLightActions.ts b/src/store/transportLightActions.ts index c45e4d07..3324b81f 100644 --- a/src/store/transportLightActions.ts +++ b/src/store/transportLightActions.ts @@ -69,7 +69,7 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick< // server with the right resume point for other devices. const s = get(); if (s.currentTrack) { - void flushQueueSyncToServer(s.queue, s.currentTrack, s.currentTime); + void flushQueueSyncToServer(s.queueItems, s.currentTrack, s.currentTime); } } set({ isPlaying: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null }); diff --git a/src/store/uiStateActions.ts b/src/store/uiStateActions.ts index e0b63499..f743a573 100644 --- a/src/store/uiStateActions.ts +++ b/src/store/uiStateActions.ts @@ -37,9 +37,10 @@ export function createUiStateActions(set: SetState): Pick< const nextOverrides = { ...s.userRatingOverrides }; if (rating === 0) delete nextOverrides[id]; else nextOverrides[id] = rating; + // Thin-state: the queue's copy lives in the resolver cache; the override + // map (merged on read via applyQueueOverrides) drives the queue-row UI. return { userRatingOverrides: nextOverrides, - queue: s.queue.map(t => (t.id === id ? { ...t, userRating: rating } : t)), currentTrack: s.currentTrack?.id === id ? { ...s.currentTrack, userRating: rating } : s.currentTrack, }; diff --git a/src/store/updateReplayGainAction.ts b/src/store/updateReplayGainAction.ts index 05c63009..ecc07c6a 100644 --- a/src/store/updateReplayGainAction.ts +++ b/src/store/updateReplayGainAction.ts @@ -10,6 +10,7 @@ import { import { deriveNormalizationSnapshot } from './normalizationSnapshot'; import { invokeAudioUpdateReplayGainDeduped } from './normalizationIpcDedupe'; import type { PlayerState } from './playerStoreTypes'; +import { resolveQueueTrack } from '../utils/library/queueTrackView'; type SetState = ( partial: Partial | ((state: PlayerState) => Partial), @@ -33,11 +34,14 @@ type GetState = () => PlayerState; * deduplicated IPC channel. */ export function runUpdateReplayGainForCurrentTrack(set: SetState, get: GetState): void { - const { currentTrack, queue, queueIndex, volume } = get(); + const { currentTrack, queueItems, queueIndex, volume } = get(); if (!currentTrack || !currentTrack.id) return; const authState = useAuthStore.getState(); - const prev = queueIndex > 0 ? queue[queueIndex - 1] : null; - const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; + // ReplayGain album-mode neighbours, resolved from refs (cache → placeholder). + const prev = queueIndex > 0 && queueItems[queueIndex - 1] + ? resolveQueueTrack(queueItems[queueIndex - 1]) : null; + const next = queueIndex + 1 < queueItems.length && queueItems[queueIndex + 1] + ? resolveQueueTrack(queueItems[queueIndex + 1]) : null; const replayGainDb = resolveReplayGainDb( currentTrack, prev, next, isReplayGainActive(), authState.replayGainMode, @@ -46,7 +50,9 @@ export function runUpdateReplayGainForCurrentTrack(set: SetState, get: GetState) ? (currentTrack.replayGainPeak ?? null) : null; - const normalization = deriveNormalizationSnapshot(currentTrack, queue, queueIndex); + // Neighbour window for the normalization snapshot: prev, current, next. + const normWindow = [prev ?? currentTrack, currentTrack, ...(next ? [next] : [])]; + const normalization = deriveNormalizationSnapshot(currentTrack, normWindow, prev ? 1 : 0); const cachedLoud = getCachedLoudnessGain(currentTrack.id); const cachedLoudDb = Number.isFinite(cachedLoud) ? cachedLoud! : null; const haveStableLoud = hasStableLoudness(currentTrack.id); diff --git a/src/test/helpers/factories.ts b/src/test/helpers/factories.ts index 446018cd..d0deb4b7 100644 --- a/src/test/helpers/factories.ts +++ b/src/test/helpers/factories.ts @@ -8,6 +8,9 @@ import type { SubsonicSong } from '@/api/subsonicTypes'; import type { ServerProfile } from '@/store/authStoreTypes'; 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 songCounter = 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. */ export function makeQueueState(opts: { queue?: Track[]; currentIndex?: number; currentTrack?: Track | null } = {}) { const queue = opts.queue ?? makeTracks(3); diff --git a/src/utils/audio/playbackRateRestart.ts b/src/utils/audio/playbackRateRestart.ts index c91fe8d9..0e312942 100644 --- a/src/utils/audio/playbackRateRestart.ts +++ b/src/utils/audio/playbackRateRestart.ts @@ -66,5 +66,6 @@ export function restartPlaybackForRateChange(): void { 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); } diff --git a/src/utils/componentHelpers/contextMenuActions.ts b/src/utils/componentHelpers/contextMenuActions.ts index 678e8536..2fb4d3d0 100644 --- a/src/utils/componentHelpers/contextMenuActions.ts +++ b/src/utils/componentHelpers/contextMenuActions.ts @@ -6,6 +6,7 @@ import { buildDownloadUrl } from '../../api/subsonicStreamUrl'; import { useAuthStore } from '../../store/authStore'; import { usePlayerStore } from '../../store/playerStore'; import type { Track } from '../../store/playerStoreTypes'; +import { resolveQueueTrack } from '../library/queueTrackView'; import { useZipDownloadStore } from '../../store/zipDownloadStore'; import { useDownloadModalStore } from '../../store/downloadModalStore'; import type { EntityShareKind } from '../share/shareLink'; @@ -90,8 +91,13 @@ export async function startRadio( .filter(t => t.id !== topTracks[0].id), ); if (similarTracks.length === 0) return; - const { queue, queueIndex } = usePlayerStore.getState(); - const pendingRadio = queue.slice(queueIndex + 1).filter(t => t.radioAdded); + const { queueItems, queueIndex } = usePlayerStore.getState(); + // 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); }); } catch (e) { diff --git a/src/utils/componentHelpers/miniPlayerHelpers.ts b/src/utils/componentHelpers/miniPlayerHelpers.ts index 227943a1..d653e140 100644 --- a/src/utils/componentHelpers/miniPlayerHelpers.ts +++ b/src/utils/componentHelpers/miniPlayerHelpers.ts @@ -1,6 +1,10 @@ import { usePlayerStore } from '../../store/playerStore'; +import { resolveQueueTrack } from '../library/queueTrackView'; 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 EXPANDED_SIZE = { w: 340, h: 500 }; // 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 { try { 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 { track: s.currentTrack ? toMini(s.currentTrack) : null, - queue: (s.queue ?? []).map(toMini), - queueIndex: s.queueIndex ?? 0, + queue: windowed.map(toMini), + queueIndex: idx - start, queueServerId: s.queueServerId ?? null, isPlaying: s.isPlaying, volume: s.volume ?? 1, diff --git a/src/utils/library/queueItemRef.ts b/src/utils/library/queueItemRef.ts index f7d86f1a..9dd48f66 100644 --- a/src/utils/library/queueItemRef.ts +++ b/src/utils/library/queueItemRef.ts @@ -1,15 +1,20 @@ import type { QueueItemRef, Track } from '../../store/playerStoreTypes'; +import { canonicalQueueServerKey } from '../server/serverIndexKey'; /** * 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 - * through, others omitted to keep the persisted/derived list small. Pure — no - * store import, so both `playerStore` (persist) and the resolver bridge can use - * it without a circular dependency. + * `serverId` is the canonical server index key — every writer normalizes here + * so refs are unambiguous across mixed-server queues (same `trackId` on two + * servers must collide on nothing, since the resolver uses `serverId:trackId`). + * 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[] { + const canonicalId = canonicalQueueServerKey(serverId); 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.radioAdded) ref.radioAdded = true; if (t.playNextAdded) ref.playNextAdded = true; diff --git a/src/utils/library/queueRestore.test.ts b/src/utils/library/queueRestore.test.ts index ac0cba5c..1dba1275 100644 --- a/src/utils/library/queueRestore.test.ts +++ b/src/utils/library/queueRestore.test.ts @@ -4,6 +4,10 @@ import { useLibraryIndexStore } from '@/store/libraryIndexStore'; import { usePlayerStore } from '@/store/playerStore'; import type { TrackRefDto } from '@/api/library'; import type { Track } from '@/store/playerStoreTypes'; +import { + getCachedTrack, + _resetQueueResolverForTest, +} from './queueTrackResolver'; import { hydrateQueueFromIndex } from './queueRestore'; const ready = () => @@ -34,72 +38,39 @@ const track = (id: string): Track => ({ id, title: id, artist: '', album: 'A', a function seedStore(over: Partial> = {}) { usePlayerStore.setState({ - queue: [track('w1')], queueServerId: 's1', queueIndex: 0, currentTrack: null, + queueItems: [], + queueItemsIndex: undefined, queueRefs: undefined, queueRefsIndex: undefined, ...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', () => { beforeEach(() => { useLibraryIndexStore.setState({ masterEnabled: true }); + _resetQueueResolverForTest(); seedStore(); }); - it('does nothing without persisted refs', async () => { - seedStore({ queueRefs: undefined }); + it('does nothing without a restore-pending sentinel', async () => { + seedStore({ queueItems: [{ serverId: 's1', trackId: 'w1' }], queueItemsIndex: undefined }); 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 () => { - 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 () => { + it('resolves the whole queueItems ref list into the resolver cache and clears the sentinel', async () => { ready(); echoBatch(); seedStore({ @@ -113,17 +84,31 @@ describe('hydrateQueueFromIndex', () => { }); 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.queueItems).toBeUndefined(); - expect(s.queueRefs).toBeUndefined(); + // queueItems stays canonical (no fat-array swap). + expect(s.queueItems.map(r => r.trackId)).toEqual(['t1', 't2', 't3']); + // Restore-pending sentinel cleared so it runs at most once. + 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(); echoBatch(); seedStore({ - queueItems: undefined, // pre-Phase-1 persist shape + queueItems: [], // pre-thin-state in-memory shape queueRefs: ['t1', 't2', 't3'], queueRefsIndex: 1, queueServerId: 's1', @@ -131,28 +116,23 @@ describe('hydrateQueueFromIndex', () => { }); await hydrateQueueFromIndex(); const s = usePlayerStore.getState(); - expect(s.queue.map(t => t.id)).toEqual(['t1', 't2', 't3']); - expect(s.queueIndex).toBe(1); - expect(s.queueRefs).toBeUndefined(); // both ref lists cleared after success - expect(s.queueItems).toBeUndefined(); + // Legacy refs resolved into the cache and the legacy fields cleared. + expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1'); + expect(getCachedTrack({ serverId: 's1', trackId: 't3' })?.id).toBe('t3'); + expect(s.queueItemsIndex).toBeUndefined(); + expect(s.queueRefs).toBeUndefined(); }); - it('carries queue-only flags from queueItems onto hydrated tracks', async () => { - ready(); - echoBatch(); + it('clears the sentinel even when the index is not ready (best-effort getSong fallback runs)', async () => { + onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' })); + onInvoke('library_get_tracks_batch', () => []); seedStore({ - queueItems: [ - { serverId: 's1', trackId: 't1' }, - { serverId: 's1', trackId: 't2', radioAdded: true }, - { serverId: 's1', trackId: 't3', autoAdded: true, playNextAdded: true }, - ], + queueItems: [{ serverId: 's1', trackId: 't1' }], queueItemsIndex: 0, }); await hydrateQueueFromIndex(); - const q = usePlayerStore.getState().queue; - expect(q.find(t => t.id === 't1')?.radioAdded).toBeUndefined(); - expect(q.find(t => t.id === 't2')?.radioAdded).toBe(true); - expect(q.find(t => t.id === 't3')?.autoAdded).toBe(true); - expect(q.find(t => t.id === 't3')?.playNextAdded).toBe(true); + // Sentinel cleared up front so the eager resolve runs at most once; refs stay. + expect(usePlayerStore.getState().queueItemsIndex).toBeUndefined(); + expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['t1']); }); }); diff --git a/src/utils/library/queueRestore.ts b/src/utils/library/queueRestore.ts index 326aa277..162b38a8 100644 --- a/src/utils/library/queueRestore.ts +++ b/src/utils/library/queueRestore.ts @@ -1,89 +1,60 @@ -import { libraryGetTracksBatch, type LibraryTrackDto, type TrackRefDto } from '../../api/library'; import { useAuthStore } from '../../store/authStore'; import { usePlayerStore } from '../../store/playerStore'; -import type { Track } from '../../store/playerStoreTypes'; -import { songToTrack } from '../playback/songToTrack'; -import { trackToSong } from './advancedSearchLocal'; -import { libraryIsReady } from './libraryReady'; - -/** `library_get_tracks_batch` cap (spec §8.6 — max 100 refs/call). */ -const BATCH = 100; +import type { QueueItemRef } from '../../store/playerStoreTypes'; +import { canonicalQueueServerKey } from '../server/serverIndexKey'; +import { resolveBatch } from './queueTrackResolver'; /** - * Full-queue restore. The player store rehydrates a *windowed* `queue` plus a - * full thin-ref list. When the library index is ready for the queue's server, - * hydrate the entire queue from the index (`library_get_tracks_batch`, ≤100 - * refs/call) and swap it in, re-locating the current track so the playback - * position stays correct even if some refs were dropped (unknown to the index). + * Full-queue restore (thin-state, decision B). The player store rehydrates the + * whole thin `queueItems` ref list from localStorage on startup; this eagerly + * resolves every ref into the resolver cache so the queue UI / playback paths + * have real `Track` metadata. `resolveBatch` does the index batch + * (`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 - * carries those flags onto the hydrated tracks; falls back to the legacy - * `queueRefs` string list for stores persisted before Phase 1. - * - * 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. + * Clears the restore-pending sentinel (`queueItemsIndex`) once the eager resolve + * is dispatched so it runs at most once; `queueItems` stays canonical. Legacy + * pre-thin-state blobs that only carried `queueRefs` were normalised into + * `queueItems` by the store's persist `merge`, so this reads `queueItems` only. */ export async function hydrateQueueFromIndex(): Promise { const player = usePlayerStore.getState(); - const items = player.queueItems; - let refs: TrackRefDto[] | null = null; - if (items?.length) { - refs = items.map(it => ({ serverId: it.serverId, trackId: it.trackId })); - } else if (player.queueRefs?.length) { - const sid = player.queueServerId ?? useAuthStore.getState().activeServerId; - if (sid) refs = player.queueRefs.map(trackId => ({ serverId: sid, trackId })); + // Restore-pending sentinel: `partialize` writes `queueItemsIndex` alongside + // the full `queueItems` on every persist, so a fresh rehydrate carries it + // back. Normal in-memory mutations keep `queueItems` canonical but never set + // the index, so its presence — not a non-empty `queueItems` — marks "this + // restored queue still needs an eager resolve". Without it (steady state / + // later server switch) there is nothing to do. + 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 = () => - usePlayerStore.setState({ - queueItems: undefined, queueItemsIndex: undefined, - queueRefs: undefined, queueRefsIndex: undefined, - }); + // Clear the restore-pending sentinel + any legacy refs; `queueItems` stays the + // canonical mirror. Done up front so a later resolve never re-triggers. + usePlayerStore.setState({ + queueItemsIndex: undefined, + queueRefs: undefined, + queueRefsIndex: undefined, + }); - // v1 is single-server; gate readiness on the queue's server. - 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; + if (refs.length === 0) 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 { - const dtos: LibraryTrackDto[] = []; - 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, - }); + await resolveBatch(refs); } catch { - // best-effort; the windowed fallback stays in place + /* best-effort */ } } diff --git a/src/utils/library/queueTrackResolver.ts b/src/utils/library/queueTrackResolver.ts index 5e449643..9b73fea7 100644 --- a/src/utils/library/queueTrackResolver.ts +++ b/src/utils/library/queueTrackResolver.ts @@ -3,6 +3,7 @@ import { getSong } from '../../api/subsonicLibrary'; import { usePlayerStore } from '../../store/playerStore'; import type { QueueItemRef, Track } from '../../store/playerStoreTypes'; import { songToTrack } from '../playback/songToTrack'; +import { canonicalQueueServerKey } from '../server/serverIndexKey'; import { trackToSong } from './advancedSearchLocal'; import { libraryIsReady } from './libraryReady'; @@ -30,26 +31,24 @@ const refKey = (r: { serverId: string; trackId: string }) => `${r.serverId}:${r. const cache = new Map(); const inFlight = new Set(); const listeners = new Set<() => void>(); +let cacheVersion = 0; function notify(): void { + cacheVersion++; 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). */ export function subscribeQueueResolver(cb: () => void): () => void { listeners.add(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 { if (cache.has(key)) cache.delete(key); cache.set(key, track); @@ -69,7 +68,20 @@ function carryFlags(track: Track, ref: QueueItemRef | undefined): Track { /** Synchronous cache read (no fetch); undefined on miss. */ 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. */ @@ -101,10 +113,13 @@ export function applyQueueOverrides(track: Track): Track { 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 { 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(); } @@ -177,8 +192,7 @@ export function resolveVisibleRange(refs: QueueItemRef[], fromIdx: number, toIdx if (end > start) void resolveBatch(refs.slice(start, end)); } -/** Drop cached entries for a track id (e.g. after a star/rating sync succeeds, - * so the next read re-fetches the server truth). */ +/** Drop cached entries for a track id, forcing the next resolve to re-fetch. */ export function invalidateQueueResolver(trackId: string): void { let changed = false; for (const key of [...cache.keys()]) { @@ -190,6 +204,21 @@ export function invalidateQueueResolver(trackId: string): void { 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): 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. */ export function _resetQueueResolverForTest(): void { cache.clear(); diff --git a/src/utils/library/queueTrackView.test.ts b/src/utils/library/queueTrackView.test.ts new file mode 100644 index 00000000..45f39611 --- /dev/null +++ b/src/utils/library/queueTrackView.test.ts @@ -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 => + ({ id, title: id, artist: 'A', album: 'Al', albumId: 'Al', duration: 1, ...over }); +const ref = (trackId: string, over: Partial = {}): 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']); + }); +}); diff --git a/src/utils/library/queueTrackView.ts b/src/utils/library/queueTrackView.ts new file mode 100644 index 00000000..4f11900a --- /dev/null +++ b/src/utils/library/queueTrackView.ts @@ -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(); + 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])); +} diff --git a/src/utils/miniPlayerBridge.ts b/src/utils/miniPlayerBridge.ts index 228cf4c3..61e53607 100644 --- a/src/utils/miniPlayerBridge.ts +++ b/src/utils/miniPlayerBridge.ts @@ -2,6 +2,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window'; import { listen, emitTo } from '@tauri-apps/api/event'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; +import { resolveQueueTrack } from './library/queueTrackView'; import type { SubsonicOpenArtistRef } from '../api/subsonicTypes'; 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 { const s = usePlayerStore.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 { track: s.currentTrack ? toMini(s.currentTrack) : null, - queue: (s.queue ?? []).map(toMini), - queueIndex: s.queueIndex ?? 0, + queue: windowed.map(toMini), + queueIndex: idx - start, // local position within the windowed slice queueServerId: s.queueServerId ?? null, isPlaying: s.isPlaying, volume: s.volume, @@ -112,7 +127,7 @@ export function initMiniPlayerBridgeOnMain(): () => void { || state.isPlaying !== prev.isPlaying || state.currentTrack?.starred !== prev.currentTrack?.starred || state.queueIndex !== prev.queueIndex - || state.queue !== prev.queue + || state.queueItems !== prev.queueItems || state.queueServerId !== prev.queueServerId || state.volume !== prev.volume) { 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 store = usePlayerStore.getState(); - const idx = e.payload?.index ?? -1; - if (idx < 0 || idx >= store.queue.length) return; - const track = store.queue[idx]; - if (track) store.playTrack(track, store.queue, true); + const idx = (e.payload?.index ?? -1) + miniWindowStart; + if (idx < 0 || idx >= store.queueItems.length) return; + const ref = store.queueItems[idx]; + 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 store = usePlayerStore.getState(); - const { from, to } = e.payload ?? { from: -1, to: -1 }; - if (from < 0 || from >= store.queue.length) return; - if (to < 0 || to > store.queue.length) return; + const raw = e.payload ?? { from: -1, to: -1 }; + const from = raw.from + miniWindowStart; + 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; 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 store = usePlayerStore.getState(); - const idx = e.payload?.index ?? -1; - if (idx < 0 || idx >= store.queue.length) return; + const idx = (e.payload?.index ?? -1) + miniWindowStart; + if (idx < 0 || idx >= store.queueItems.length) return; store.removeTrack(idx); }); diff --git a/src/utils/mix/luckyMix.ts b/src/utils/mix/luckyMix.ts index 2fe33fdc..a55456e3 100644 --- a/src/utils/mix/luckyMix.ts +++ b/src/utils/mix/luckyMix.ts @@ -1,7 +1,7 @@ import { getSimilarSongs } from '../../api/subsonicArtists'; import { filterSongsToActiveLibrary, getRandomSongs } from '../../api/subsonicLibrary'; 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 { invoke } from '@tauri-apps/api/core'; import i18n from '../../i18n'; @@ -93,14 +93,15 @@ export async function buildAndPlayLuckyMix(): Promise { // 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 - // 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 queueSnapshot: { - queue: Track[]; + queueItems: QueueItemRef[]; queueIndex: number; queueServerId: string | null; } = { - queue: [...playerStateBefore.queue], + queueItems: [...playerStateBefore.queueItems], queueIndex: playerStateBefore.queueIndex, queueServerId: playerStateBefore.queueServerId, }; @@ -125,8 +126,8 @@ export async function buildAndPlayLuckyMix(): Promise { try { let allSeedSongs: SubsonicSong[] = []; - const mixQueueSize = () => usePlayerStore.getState().queue.length; - const mixQueueTrackIds = () => new Set(usePlayerStore.getState().queue.map(t => t.id)); + const mixQueueSize = () => usePlayerStore.getState().queueItems.length; + const mixQueueTrackIds = () => new Set(usePlayerStore.getState().queueItems.map(r => r.trackId)); const bailIfCancelled = () => { if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled(); @@ -156,7 +157,7 @@ export async function buildAndPlayLuckyMix(): Promise { const nextId = state.currentTrack?.id ?? null; if (nextId === prevId) 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(); }); } @@ -389,12 +390,12 @@ export async function buildAndPlayLuckyMix(): Promise { // whatever we managed to enqueue is more useful than the old queue. if (!startedPlayback) { usePlayerStore.setState({ - queue: queueSnapshot.queue, + queueItems: queueSnapshot.queueItems, queueIndex: queueSnapshot.queueIndex, queueServerId: queueSnapshot.queueServerId, }); logStep('queue_restored_after_failure', { - restoredCount: queueSnapshot.queue.length, + restoredCount: queueSnapshot.queueItems.length, }); } showToast(i18n.t('luckyMix.failed'), 5000, 'error'); diff --git a/src/utils/playback/playSong.ts b/src/utils/playback/playSong.ts index 8447931b..e60efee6 100644 --- a/src/utils/playback/playSong.ts +++ b/src/utils/playback/playSong.ts @@ -45,14 +45,14 @@ export async function playSongNow(song: SubsonicSong, queue?: SubsonicSong[]): P export async function enqueueAndPlay(song: SubsonicSong): Promise { const track = songToTrack(song); const store = usePlayerStore.getState(); - const { isPlaying, volume, queue } = store; + const { isPlaying, volume, queueItems } = store; if (isPlaying) { await fadeOut(store.setVolume, volume, 700); usePlayerStore.setState({ volume }); } - if (!queue.some(t => t.id === track.id)) { + if (!queueItems.some(r => r.trackId === track.id)) { usePlayerStore.getState().enqueue([track]); } // playTrack with no queue arg uses the current state.queue, finds the track by id, diff --git a/src/utils/playback/playbackServer.test.ts b/src/utils/playback/playbackServer.test.ts index 416be9af..1071b06a 100644 --- a/src/utils/playback/playbackServer.test.ts +++ b/src/utils/playback/playbackServer.test.ts @@ -30,7 +30,7 @@ describe('playbackServer', () => { isLoggedIn: true, }); usePlayerStore.setState({ - queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }], + queueItems: [{ serverId: 'a', trackId: 't1' }], queueServerId: 'a', queueIndex: 0, }); @@ -43,21 +43,22 @@ describe('playbackServer', () => { it('getPlaybackServerId falls back to active when queue is empty', () => { clearQueueServerForPlayback(); - usePlayerStore.setState({ queue: [] }); + usePlayerStore.setState({ queueItems: [] }); useAuthStore.setState({ activeServerId: 'b' }); expect(getPlaybackServerId()).toBe('b'); }); - it('bindQueueServerForPlayback pins active server', () => { + it('bindQueueServerForPlayback pins active server as canonical index key', () => { useAuthStore.setState({ activeServerId: 'b' }); 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', () => { useAuthStore.setState({ activeServerId: 'b' }); expect(playbackServerDiffersFromActive()).toBe(true); - usePlayerStore.setState({ queue: [] }); + usePlayerStore.setState({ queueItems: [] }); expect(playbackServerDiffersFromActive()).toBe(false); }); @@ -66,16 +67,20 @@ describe('playbackServer', () => { useAuthStore.setState({ activeServerId: 'b' }); prepareActiveServerForNewMix(); const s = usePlayerStore.getState(); - expect(s.queue).toEqual([]); + expect(s.queueItems).toEqual([]); 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); }); it('prepareActiveServerForNewMix is a no-op when queue already matches active', () => { useAuthStore.setState({ activeServerId: 'a' }); 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'); }); @@ -108,12 +113,14 @@ describe('playbackServer', () => { }); 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 = [ { id: 't1', title: 'T', 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(prev, prev, undefined)).toBe(false); + expect(shouldBindQueueServerForPlay(prevRefs, next, next)).toBe(true); + expect(shouldBindQueueServerForPlay(prevRefs, sameTrack, undefined)).toBe(false); }); }); diff --git a/src/utils/playback/playbackServer.ts b/src/utils/playback/playbackServer.ts index 25eb5d09..1a94a5a8 100644 --- a/src/utils/playback/playbackServer.ts +++ b/src/utils/playback/playbackServer.ts @@ -6,22 +6,26 @@ import { useAuthStore } from '../../store/authStore'; import { usePlayerStore } from '../../store/playerStore'; import { switchActiveServer } from '../server/switchActiveServer'; import { sameQueueTrackId } from './queueIdentity'; -import type { Track } from '../../store/playerStoreTypes'; +import type { QueueItemRef, Track } from '../../store/playerStoreTypes'; 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). */ export function getPlaybackServerId(): string { - const { queueServerId, queue } = usePlayerStore.getState(); - if ((queue?.length ?? 0) > 0 && queueServerId) { + const { queueServerId, queueItems } = usePlayerStore.getState(); + if ((queueItems?.length ?? 0) > 0 && queueServerId) { return resolveServerIdForIndexKey(queueServerId); } return useAuthStore.getState().activeServerId ?? ''; } export function getPlaybackIndexKey(): string { - const { queueServerId, queue } = usePlayerStore.getState(); - if ((queue?.length ?? 0) > 0 && queueServerId) { + const { queueServerId, queueItems } = usePlayerStore.getState(); + if ((queueItems?.length ?? 0) > 0 && queueServerId) { return resolveIndexKey(queueServerId); } const activeId = useAuthStore.getState().activeServerId ?? ''; @@ -43,7 +47,13 @@ export function getPlaybackCacheServerKey(): string { export function bindQueueServerForPlayback(): void { const sid = useAuthStore.getState().activeServerId; 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 { @@ -51,8 +61,8 @@ export function clearQueueServerForPlayback(): void { } export function playbackServerDiffersFromActive(): boolean { - const { queueServerId, queue } = usePlayerStore.getState(); - if ((queue?.length ?? 0) === 0 || !queueServerId) return false; + const { queueServerId, queueItems } = usePlayerStore.getState(); + if ((queueItems?.length ?? 0) === 0 || !queueServerId) return false; const activeSid = useAuthStore.getState().activeServerId; const resolvedQueue = resolveServerIdForIndexKey(queueServerId); return !!activeSid && resolvedQueue !== activeSid; @@ -65,8 +75,8 @@ export function playbackServerDiffersFromActive(): boolean { export function shouldHandoffQueueToActiveServer(): boolean { const activeSid = useAuthStore.getState().activeServerId; if (!activeSid) return false; - const { queue, queueServerId } = usePlayerStore.getState(); - if ((queue?.length ?? 0) === 0) return false; + const { queueItems, queueServerId } = usePlayerStore.getState(); + if ((queueItems?.length ?? 0) === 0) return false; if (!queueServerId) return true; return resolveServerIdForIndexKey(queueServerId) !== activeSid; } @@ -101,7 +111,7 @@ export function playbackCoverArtForId(coverId: string, displayCssPx: number): { } export function shouldBindQueueServerForPlay( - prevQueue: Track[], + prevQueue: QueueItemRef[], newQueue: Track[], explicitQueueArg: Track[] | undefined, ): boolean { @@ -109,5 +119,5 @@ export function shouldBindQueueServerForPlay( if (prevQueue.length === 0) return true; if (explicitQueueArg === undefined) return false; 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)); } diff --git a/src/utils/server/serverIndexKey.ts b/src/utils/server/serverIndexKey.ts index 05655423..a4d48175 100644 --- a/src/utils/server/serverIndexKey.ts +++ b/src/utils/server/serverIndexKey.ts @@ -17,3 +17,25 @@ export function resolveIndexKey(serverIdOrKey: string): string { if (!server) return 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; +}