mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(queue): thin-state refs as canonical, full Track via resolver (#872)
* refactor(queue): wire queue UI to the track resolver (thin-state phase 3) cucadmuh's phase-3 steps: - Selectors (useQueueTracks) read resolver-first: getCachedTrack → queue: Track[] fallback (until phase 4), F4 star/rating overrides merged on read. - QueueList rows source their track from the resolver (queue fallback); rows show title/artist/duration only, so no override merge there. - pendingStarSync star/rating success → invalidateQueueResolver so the cache reflects the synced value. - queueResolverBridge re-seeds on queueIndex change too — the prefetch window travels with the playing track. Additive: queue: Track[] stays canonical and behaviour is unchanged (rows resolve to the same data). Phase 4 drops queue: Track[] and the fallbacks. * docs(changelog): queue panel reads through track cache (#860) * fix(queue): stop a render loop that froze the UI on long queues A long virtualized queue + a track change could lock the WebView for ~2 min: - useVirtualizer was handed a fresh `initialRect` object literal every render, so it kept re-initializing in a loop. Hoisted it to a stable module constant. - getCachedTrack did an LRU bump (Map delete+set) during render — a render-time side effect. Made it a pure read; recency is set at write time in cacheSet. * perf(mobile): virtualize the mobile player queue drawer The mobile now-playing queue drawer rendered the full queue with .map; a multi-thousand-track queue meant thousands of DOM nodes. Virtualize it with @tanstack/react-virtual (uniform rows, stable initialRect) so the DOM stays at O(visible rows), matching the desktop QueuePanel. Active track is centred on open via scrollToIndex. * perf(mini): virtualize the mini-player queue list The mini-player queue rendered the full MiniSyncPayload queue with .map. Virtualize it against the OverlayScrollArea viewport (stable initialRect) so the mini window's DOM stays at O(visible rows). Drag-reorder is preserved: rows keep data-mq-idx alongside the virtualizer's measureElement. * refactor(queue): add resolveQueueTrack/getQueueTracksView helper (thin-state phase 4) Render-safe ref→Track view for the phase-4 consumer migration off queue: Track[]. Resolver cache → caller fallback (legacy queue[idx] during dual-write) → placeholder; ref queue-only flags carried, F4 overrides merged. Pure synchronous read, no cache mutation (the freeze landmine), so it is safe in render. * refactor(queue): keep queueItems as the canonical in-memory mirror (thin-state phase 4) Step 1b: dual-write the thin queueItems ref list at every queue write site (the 11 mutations, next/radio top-up, playTrack, undo/redo restore, instant-mix, radio, server-queue init, lucky-mix rollback, and hydrate) so it tracks queue: Track[] in memory, not only at persist time. Identity-preserving maps (star/rating overrides) keep the same refs and are intentionally left untouched. Resolves the restore double-role flagged for 1b: queueItemsIndex is now the restore-pending sentinel that gates hydrateQueueFromIndex, while queueItems stays canonical -- rebuilt from the whole queue after a full hydrate instead of cleared. Normal mutations never set the sentinel, so it only fires on a fresh cold-start restore, not on later server switches. No behaviour change; queue: Track[] stays the source consumers read until phase 3. tsc + full vitest suite (1119 tests) green. * refactor(queue): mobile queue drawer reads through the track resolver (thin-state phase 4) Step 2: the mobile now-playing queue drawer resolves each row's track from the resolver cache (→ queue: Track[] fallback until phase 4), matching the desktop QueueList wired in the phase-3 commit. Subscribes to the resolver version so rows re-render as the cache fills. Structure (count, order, keys, the playTrack arg) still comes from queue: Track[] until it is dropped in the final step. The mobile drawer was the last queue display surface still reading track metadata straight off the fat queue. tsc + full vitest suite green. * refactor(queue): ref-native queue mutations + dual-write bridge (thin-state phase 4) Step 3a: the 11 queueMutationActions now splice/filter/reorder QueueItemRef[] (matching by trackId + the ref's queue-only flags) instead of Track[]. `bridgeQueueFromItems` rebuilds the dual-written queue: Track[] from the new refs by id — purely structural (no resolver/override merge), so behaviour is byte-identical and playerStore.queue.test.ts stays unchanged green. The working ref list comes from `itemsOf(state)` (derived from queue: Track[] for now); the final step swaps that one line to state.queueItems once the fat queue is gone. enqueue / enqueueAt / enqueueRadio seed the resolver cache with incoming tracks (seed-before-splice) so they resolve without a network round-trip after the fat queue is dropped. Adds a DEV-only id-parity guardrail (queue vs queueItems); dev-runtime only, silent in vitest and prod. tsc + full vitest suite (1119) green; contract test unchanged. * refactor(queue): ref-native radio/infinite top-ups (thin-state phase 4) Step 3b: nextAction's proactive infinite-queue and radio top-ups build the new queue as QueueItemRef[] and bridge back to queue: Track[] (same as the queue mutations), and seed the resolver cache with the freshly fetched tracks so they resolve without a network round-trip after the fat queue is dropped. The radio top-up keeps its HISTORY_KEEP front-trim, now expressed on refs. The exhausted-queue refill paths hand their new queue to playTrack, which keeps its fat-queue handling until the final step (its no-arg case needs the resolver- derived queue that lands with the queue: Track[] removal). tsc + full vitest (1119) green; contract test unchanged. * refactor(queue): undo snapshots store thin refs, not Track[] (thin-state phase 4) Step 4: QueueUndoSnapshot.queue: Track[] becomes queueItems: QueueItemRef[], killing the undo "hidden multiplier" — 32 snapshots of a 50k queue now cost refs, not 32×50k full tracks. applyQueueHistorySnapshot rebuilds the display queue from the refs via resolveQueueTrack: resolver cache → the live queue by id (covers tracks the edit didn't remove) → placeholder. currentTrack stays a full track in the snapshot and is restored to the engine unchanged. The snapshot refs derive from queue: Track[] for now (so the undo/redo contract cases, which seed only `queue`, stay green); the final step swaps that to [...s.queueItems]. tsc + full vitest suite (1119) green. * perf(mini): cap the mini-player queue snapshot to ±100 around the current track (thin-state phase 4) Step 5: the mini bridge no longer serializes the full queue over IPC on every push — a 50k Artist-Radio queue would otherwise re-encode in full on every track advance. snapshot() sends a window of 100 tracks before/after the playing song; queueIndex is made slice-relative. The mini component stays unchanged (slice- relative); jump/reorder/remove control events are translated back to absolute queue indices via the window offset captured on the last push. tsc + full vitest suite (1119) green. Mini bridge has no unit tests — needs a quick mini-player smoke (queue shows ±100, jump/reorder/remove land correctly). * refactor(queue): make queueItems a required PlayerState field (thin-state phase 4) Foundation for the final consumer migration off queue: Track[]: queueItems has been written at every queue write site since phase 1b, so promoting it from optional to required is a no-op at runtime (tsc confirms zero new errors) and lets the upcoming reader migrations read state.queueItems without `?? []` noise. * refactor(queue): migrate structural queue readers off queue: Track[] (thin-state phase 4) First reader batch toward dropping queue: Track[]: the queue-length selectors (usePlaybackServerId, usePlaybackCoverArt, useQueuePanelDrag, useMiniQueueDrag) now read state.queueItems.length, and FullscreenPlayer's next-track cover prefetch resolves through useQueueTrackAt instead of indexing the fat queue. All behaviour- identical during dual-write (queueItems is in lockstep with queue). tsc + full vitest suite (1119) green. Note: getPlaybackServerId() (playbackServer.ts) deliberately stays on queue for now — it is called from many partially-mocked test stores, so it migrates with the final field removal where the seedQueue helper covers those tests. * refactor(queue): QueuePanel save/share/playlist read queueItems (thin-state phase 4) The id/length reads (save to playlist, share link, create playlist, empty-queue guards, next-tracks divider) now read state.queueItems instead of the fat queue. Behaviour-identical during dual-write; queue: Track[] stays for the rendered QueueList + auto-scroll until the field is dropped. tsc + full suite (1119) green. * refactor(queue): drop queue: Track[] — thin queueItems is the only queue (thin-state phase 4) The store no longer holds the fat queue. `queueItems: QueueItemRef[]` is the sole canonical queue; full `Track`s resolve on demand via the resolver (index batch → getSong fallback, bounded LRU cache); only `currentTrack` stays a full Track. At 50k tracks the store holds ~hundreds of resolved tracks + the refs, not 50k Track objects. - **Persist:** partialize is refs-only (no windowed slice / PERSIST_QUEUE_HALF). A `merge` migrates every historical blob shape → `queueItems` (existing `queueItems` → legacy `queueRefs` → pre-ref windowed `queue: Track[]`) and drops the obsolete `queue` key, so saved queues survive the upgrade. - **Restore (decision B):** `hydrateQueueFromIndex` eager-resolves the whole ref list into the cache on cold start (index → getSong, so an index-off queue still plays), clears the restore sentinel. - **Resolver bridge:** keeps `[idx-50, idx+200]` warm via `resolveVisibleRange`. - **Mutations / actions / playback:** operate on refs; the playing track is `currentTrack`, the next/neighbour tracks resolve from the cache. Navigation (next/previous/row-jump) keeps `queueItems` and only moves the index — no full resolve or queue rebuild per track change. - **Persist tests** cover the three old-blob migrations; `seedQueue` test helper replaces the `setState({ queue })` seeds. tsc + full vitest suite (1115) green. Behaviour-preserving by the test contract; the gapless track change + cold-start restore + mini cap still want a live smoke before merge. * fix(queue): star/rating keeps the queue row resolved instead of blanking to "…" (thin-state) Rating/starring a queue song flashed the row's title to the "…" placeholder until the next track change. Root cause: on sync success pendingStarSync called invalidateQueueResolver, which DROPPED the cached track — and with queue: Track[] gone there's no fat fallback, so the row resolved to a placeholder until the resolver bridge re-fetched the window. Fix: add patchCachedTrack(trackId, patch) and use it on star/rating success to update the cached entry in place (title kept, synced starred/userRating applied) instead of dropping it. No placeholder flash, no re-fetch. tsc + full vitest suite (1115) green. * fix(player): quota-safe persist so a full localStorage can't kill playback A very large queue (~50k refs) overflows the localStorage quota; the persist write then threw QuotaExceededError from inside set(), which aborted playTrack before audio_play — no audio output at all. Back the player persist with a quota-safe storage wrapper so a failed write degrades to a no-op instead of throwing. Restoring the full ref list at that ceiling (vs a windowed cap) is left as a follow-up. * polish(player): throttle the quota-skip persist warning to once per key The quota-safe persist logs a skip on every failed write; on a huge queue that floods the dev console once per mutation. Warn once per key per quota-exceeded streak, re-armed when a write to that key next succeeds. * fix(queue): port new cover-pipeline readers to thin-state Main's cover pipeline (#870) reads s.queue.length and seeds the player store with queue: [track] in its tests. Under thin-state, queue: Track[] no longer exists — the canonical queue is queueItems: QueueItemRef[]. These four files were brought across in the merge but still spoke the old shape; this commit aligns them with the thin-state contract. - src/cover/usePlaybackCoverArt: queueLength = queueItems.length - src/cover/usePlaybackCoverArt.test: seed via toQueueItemRefs - src/api/coverCache.test: same - src/hooks/useNowPlayingPrewarm.test: same (two test cases) * fix(queue): canonicalize thin-state server identity for mixed-server queues `QueueItemRef.serverId` and `PlayerState.queueServerId` are now written as the URL-derived index key on every writer path, matching the library index direction. Mixed-server queues with duplicate `trackId` across servers stay unambiguous because the resolver cache, persistence, and playback bindings all share one key shape. - new `canonicalQueueServerKey()` helper (idempotent UUID-or-key normalizer) - `toQueueItemRefs`, `bindQueueServerForPlayback`, `seedQueueResolver`, and `hydrateQueueFromIndex` emit canonical keys - `getCachedTrack` falls back to the canonical lookup so refs persisted in the legacy UUID shape still resolve through the migration window - persist `merge` rewrites `queueServerId` and every ref `serverId` on rehydrate, so the live store never holds mixed shapes - `removeServer` compares against the resolved id so a profile delete still clears the matching queue binding - the two `playbackServer.test.ts` asserts that hard-coded the UUID shape are updated to the canonical key (existing reader-tolerance is unchanged) * fix(queue-undo): bind snapshot prepend to snapshot-canonical server identity When `applyQueueHistorySnapshot` has to prepend the still-playing track (the snapshot's queue does not contain it), the new ref must follow the snapshot's playback server, not the live `queueServerId`. A server switch racing the undo would otherwise stamp the prepended ref with the new server, mis-resolving the playing track on the very next render. - `QueueUndoSnapshot` now carries `queueServerId` (captured by `queueUndoSnapshotFromState`); older in-memory entries fall back through the snapshot's own refs and finally the live store value - the prepend in `applyQueueHistorySnapshot` plus the post-restore `seedQueueResolver` both source the server identity from this snapshot context, run through `canonicalQueueServerKey` so cache bucket and ref shape stay in lockstep * test(queue): regression cluster for mixed-server queues with duplicate trackId Covers the four invariants the thin-state review called out: - resolver correctness: same `trackId` on two servers maps to two distinct cache entries via canonical keys, and legacy UUID-shaped refs still read the same entries through the compat lookup path - restore/hydrate: persist `merge` forward-migrates UUID-form blobs in three shapes (canonical `queueItems`, legacy `queueRefs`, mixed-server `queueItems`) to canonical keys - undo snapshot application: prepended ref follows the snapshot's playback server even when the live queue has been rebound to a different one, with fallback to snapshot refs and live state for legacy entries - queue sync id emission: `flushPlayQueuePosition` -> `savePlayQueue` passes plain track ids and the playback server out of band, no per-ref `serverId` ever leaks into the request body Also asserts the write helpers (`toQueueItemRefs`, `bindQueueServerForPlayback`) emit canonical keys directly. * perf(queue-header): coalesce resolver burst updates and aggregate in one pass `QueueHeader` recomputed total and remaining queue durations on every resolver cache version bump via two separate full-queue reduces. A mass resolve burst (queue restore, prefetch window slide) bumps the version dozens of times in one frame, and very long queues turned that into visible main-thread stutter. - one pass: a single for-loop produces both total and future-tracks duration; a 50k-track queue costs one walk per recompute, not two - `useDeferredValue(version)` coalesces the burst into a single low-priority commit so the cache version is only sampled once per React frame instead of once per cache write * fix(queue): use stable artist seed for radio top-up The proactive radio top-up in `runNext` seeded `getSimilarSongs2` and `getTopSongs` from `resolveQueueTrack(nextRef)` metadata. When the next ref is still cold in the resolver cache, the placeholder track has empty artist fields, and the top-up would fire `getSimilarSongs2('')` -- silently returning nothing and leaving the queue dry just before the radio rail would have refilled. - prefer the just-played `currentTrack` (always fully resolved in the player store) and the stored radio seed artist id - fall back to the next-track metadata only when those are missing - skip the top-up entirely when no stable seed is available, instead of emitting a non-deterministic empty request * docs(changelog): queue mixed-server routing and quota-safe persist (#872)
This commit is contained in:
committed by
GitHub
parent
a8cfff0b62
commit
45b9229ceb
@@ -213,6 +213,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Queue — panel now reads through the shared track cache
|
||||||
|
|
||||||
|
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#860](https://github.com/Psychotoxical/psysonic/pull/860)**
|
||||||
|
|
||||||
|
* The queue panel sources its row details through the on-demand track cache, another step toward keeping multi-thousand-track queues light on memory. No visible change.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Backup UX — blocking progress gate for long operations
|
### Backup UX — blocking progress gate for long operations
|
||||||
|
|
||||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#864](https://github.com/Psychotoxical/psysonic/pull/864)**
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#864](https://github.com/Psychotoxical/psysonic/pull/864)**
|
||||||
@@ -348,6 +356,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Queue — mixed-server routing and quota-safe persist
|
||||||
|
|
||||||
|
**By [@Psychotoxical](https://github.com/Psychotoxical) + [@cucadmuh](https://github.com/cucadmuh), PR [#872](https://github.com/Psychotoxical/psysonic/pull/872)**
|
||||||
|
|
||||||
|
* Mixed-server queues with the same track ID on different servers now stay on their original server through track switches, undo, and radio top-ups.
|
||||||
|
* Persisted queue is quota-safe — a full local storage no longer blocks playback on very large queues.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [1.46.0] - 2026-05-18
|
## [1.46.0] - 2026-05-18
|
||||||
|
|
||||||
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
|
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { resetAllStores } from '../test/helpers/storeReset';
|
|||||||
import { invokeMock, onInvoke } from '../test/mocks/tauri';
|
import { invokeMock, onInvoke } from '../test/mocks/tauri';
|
||||||
import { coverArtRef } from '../cover/ref';
|
import { coverArtRef } from '../cover/ref';
|
||||||
import { coverCacheEnsure, coverCacheRestHost, librarySqlServerId } from './coverCache';
|
import { coverCacheEnsure, coverCacheRestHost, librarySqlServerId } from './coverCache';
|
||||||
|
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
||||||
|
|
||||||
describe('librarySqlServerId', () => {
|
describe('librarySqlServerId', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -54,7 +55,7 @@ describe('coverCacheEnsure', () => {
|
|||||||
|
|
||||||
const track = makeTrack({ id: 'q1', coverArt: 'cover-1' });
|
const track = makeTrack({ id: 'q1', coverArt: 'cover-1' });
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue: [track],
|
queueItems: toQueueItemRefs(playbackServerId, [track]),
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
queueServerId: playbackServerId,
|
queueServerId: playbackServerId,
|
||||||
currentTrack: track,
|
currentTrack: track,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe('subsonicScrobble', () => {
|
|||||||
isLoggedIn: true,
|
isLoggedIn: true,
|
||||||
});
|
});
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }],
|
queueItems: [{ serverId: 'a', trackId: 't1' }],
|
||||||
queueServerId: 'a',
|
queueServerId: 'a',
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
|||||||
import { usePlayerStore } from '@/store/playerStore';
|
import { usePlayerStore } from '@/store/playerStore';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||||
import { makeTrack, makeServer } from '@/test/helpers/factories';
|
import { makeTrack, makeServer, seedQueue } from '@/test/helpers/factories';
|
||||||
import { onInvoke } from '@/test/mocks/tauri';
|
import { onInvoke } from '@/test/mocks/tauri';
|
||||||
import { fireEvent } from '@testing-library/react';
|
import { fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ describe('ContextMenu — type=artist', () => {
|
|||||||
describe('ContextMenu — type=queue-item', () => {
|
describe('ContextMenu — type=queue-item', () => {
|
||||||
it('shows a Remove from Queue affordance the song menu does not have', () => {
|
it('shows a Remove from Queue affordance the song menu does not have', () => {
|
||||||
const track = makeTrack({ id: 'q-1' });
|
const track = makeTrack({ id: 'q-1' });
|
||||||
usePlayerStore.setState({ queue: [track], queueIndex: 0, currentTrack: track });
|
seedQueue([track], { index: 0, currentTrack: track });
|
||||||
openMenuFor('queue-item', track, 0);
|
openMenuFor('queue-item', track, 0);
|
||||||
const { container } = renderWithProviders(<ContextMenu />);
|
const { container } = renderWithProviders(<ContextMenu />);
|
||||||
expect(container.querySelector('.context-menu')).not.toBeNull();
|
expect(container.querySelector('.context-menu')).not.toBeNull();
|
||||||
|
|||||||
@@ -27,14 +27,14 @@ export default function ContextMenu() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
|
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
|
||||||
const orbitRole = useOrbitStore(s => s.role);
|
const orbitRole = useOrbitStore(s => s.role);
|
||||||
const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
|
const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queueItems, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
|
||||||
useShallow(s => ({
|
useShallow(s => ({
|
||||||
contextMenu: s.contextMenu,
|
contextMenu: s.contextMenu,
|
||||||
closeContextMenu: s.closeContextMenu,
|
closeContextMenu: s.closeContextMenu,
|
||||||
playTrack: s.playTrack,
|
playTrack: s.playTrack,
|
||||||
enqueue: s.enqueue,
|
enqueue: s.enqueue,
|
||||||
playNext: s.playNext,
|
playNext: s.playNext,
|
||||||
queue: s.queue,
|
queueItems: s.queueItems,
|
||||||
currentTrack: s.currentTrack,
|
currentTrack: s.currentTrack,
|
||||||
removeTrack: s.removeTrack,
|
removeTrack: s.removeTrack,
|
||||||
lastfmLovedCache: s.lastfmLovedCache,
|
lastfmLovedCache: s.lastfmLovedCache,
|
||||||
@@ -202,7 +202,7 @@ export default function ContextMenu() {
|
|||||||
playNext={playNext}
|
playNext={playNext}
|
||||||
enqueue={enqueue}
|
enqueue={enqueue}
|
||||||
removeTrack={removeTrack}
|
removeTrack={removeTrack}
|
||||||
queue={queue}
|
queue={queueItems}
|
||||||
currentTrack={currentTrack}
|
currentTrack={currentTrack}
|
||||||
closeContextMenu={closeContextMenu}
|
closeContextMenu={closeContextMenu}
|
||||||
starredOverrides={starredOverrides}
|
starredOverrides={starredOverrides}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
|||||||
import { usePlayerStore } from '@/store/playerStore';
|
import { usePlayerStore } from '@/store/playerStore';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||||
import { makeTrack } from '@/test/helpers/factories';
|
import { makeTrack, seedQueue } from '@/test/helpers/factories';
|
||||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||||
import { fireEvent } from '@testing-library/react';
|
import { fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
@@ -147,12 +147,11 @@ describe('FullscreenPlayer — control wiring', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clicking Previous Track calls previous()', () => {
|
it('clicking Previous Track calls previous()', () => {
|
||||||
usePlayerStore.setState({
|
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
|
||||||
queue: [makeTrack({ id: 'a' }), makeTrack({ id: 'b' })],
|
index: 1,
|
||||||
queueIndex: 1,
|
|
||||||
currentTrack: makeTrack({ id: 'b' }),
|
currentTrack: makeTrack({ id: 'b' }),
|
||||||
currentTime: 5,
|
|
||||||
});
|
});
|
||||||
|
usePlayerStore.setState({ currentTime: 5 });
|
||||||
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
|
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
|
||||||
const { getByLabelText } = renderWithProviders(
|
const { getByLabelText } = renderWithProviders(
|
||||||
<FullscreenPlayer onClose={() => {}} />,
|
<FullscreenPlayer onClose={() => {}} />,
|
||||||
@@ -162,9 +161,8 @@ describe('FullscreenPlayer — control wiring', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clicking Next Track calls next()', () => {
|
it('clicking Next Track calls next()', () => {
|
||||||
usePlayerStore.setState({
|
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
|
||||||
queue: [makeTrack({ id: 'a' }), makeTrack({ id: 'b' })],
|
index: 0,
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: makeTrack({ id: 'a' }),
|
currentTrack: makeTrack({ id: 'a' }),
|
||||||
});
|
});
|
||||||
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
|
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { FsPlayBtn } from './fullscreenPlayer/FsPlayBtn';
|
|||||||
import { useFsDynamicAccent } from '../hooks/useFsDynamicAccent';
|
import { useFsDynamicAccent } from '../hooks/useFsDynamicAccent';
|
||||||
import { useFsArtistPortrait } from '../hooks/useFsArtistPortrait';
|
import { useFsArtistPortrait } from '../hooks/useFsArtistPortrait';
|
||||||
import { useFsIdleFade } from '../hooks/useFsIdleFade';
|
import { useFsIdleFade } from '../hooks/useFsIdleFade';
|
||||||
|
import { useQueueTrackAt } from '../hooks/useQueueTracks';
|
||||||
|
|
||||||
interface FullscreenPlayerProps {
|
interface FullscreenPlayerProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -73,13 +74,12 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
|||||||
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
|
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
|
||||||
const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple';
|
const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple';
|
||||||
|
|
||||||
// Pre-fetch next track's 300px cover into the IndexedDB cache.
|
// Pre-fetch next track's 300px cover into the IndexedDB cache. Resolver-first
|
||||||
// Selector returns only the coverArt id, so it only re-runs on actual changes.
|
// (thin-state): the next ref resolves from the cache (the prefetch window
|
||||||
const nextCoverArt = usePlayerStore(s => {
|
// around the current index keeps it warm).
|
||||||
const q = s.queue;
|
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||||
const idx = s.queueIndex;
|
const nextTrack = useQueueTrackAt(queueIndex + 1);
|
||||||
return (idx >= 0 && idx + 1 < q.length) ? (q[idx + 1]?.coverArt ?? null) : null;
|
const nextCoverArt = queueIndex >= 0 ? (nextTrack?.coverArt ?? null) : null;
|
||||||
});
|
|
||||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExtern
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import {
|
import {
|
||||||
ChevronDown, Play, Pause, SkipBack, SkipForward,
|
ChevronDown, Play, Pause, SkipBack, SkipForward,
|
||||||
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
|
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
|
||||||
@@ -15,6 +16,11 @@ import { usePlayerStore } from '../store/playerStore';
|
|||||||
import { useCachedUrl } from './CachedImage';
|
import { useCachedUrl } from './CachedImage';
|
||||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||||
import { formatTrackTime } from '../utils/format/formatDuration';
|
import { formatTrackTime } from '../utils/format/formatDuration';
|
||||||
|
import { resolveQueueTrack } from '../utils/library/queueTrackView';
|
||||||
|
import {
|
||||||
|
getQueueResolverVersion,
|
||||||
|
subscribeQueueResolver,
|
||||||
|
} from '../utils/library/queueTrackResolver';
|
||||||
import LyricsPane from './LyricsPane';
|
import LyricsPane from './LyricsPane';
|
||||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||||||
import PlaybackDelayModal from './PlaybackDelayModal';
|
import PlaybackDelayModal from './PlaybackDelayModal';
|
||||||
@@ -71,17 +77,42 @@ function useAlbumAccentColor(imageUrl: string): string {
|
|||||||
|
|
||||||
// ── Queue Drawer ──────────────────────────────────────────────────────────────
|
// ── Queue Drawer ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Stable initial rect so the virtualizer never re-initializes on re-render (an
|
||||||
|
// inline literal would be a new ref each render → render loop). Replaced by the
|
||||||
|
// real height on first ResizeObserver measure.
|
||||||
|
const QUEUE_INITIAL_RECT = { width: 0, height: 600 };
|
||||||
|
|
||||||
function QueueDrawer({ onClose }: { onClose: () => void }) {
|
function QueueDrawer({ onClose }: { onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queue = usePlayerStore(s => s.queue);
|
const queue = usePlayerStore(s => s.queueItems);
|
||||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||||
const playTrack = usePlayerStore(s => s.playTrack);
|
const playTrack = usePlayerStore(s => s.playTrack);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Thin-state: the queue is the canonical `QueueItemRef[]`; each row's Track
|
||||||
|
// comes from the resolver (cache → placeholder), matching the desktop
|
||||||
|
// QueueList. Subscribe once so rows re-render as the resolver cache fills.
|
||||||
|
useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||||||
|
|
||||||
// Scroll active track into view on open
|
// Virtualize so a multi-thousand-track queue keeps DOM at O(visible rows) on
|
||||||
|
// mobile too (matches the desktop QueuePanel).
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: queue.length,
|
||||||
|
getScrollElement: () => listRef.current,
|
||||||
|
estimateSize: () => 56,
|
||||||
|
overscan: 10,
|
||||||
|
getItemKey: i => `${queue[i].trackId}:${i}`,
|
||||||
|
initialRect: QUEUE_INITIAL_RECT,
|
||||||
|
});
|
||||||
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||||
|
const totalSize = rowVirtualizer.getTotalSize();
|
||||||
|
|
||||||
|
// Scroll the active track into view on open. Rows are uniform height, so the
|
||||||
|
// virtualizer's estimate lands the centred index accurately.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = listRef.current?.querySelector('.mq-item.active');
|
if (queueIndex >= 0 && queue.length > 0) {
|
||||||
el?.scrollIntoView({ block: 'center', behavior: 'instant' });
|
rowVirtualizer.scrollToIndex(queueIndex, { align: 'center' });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,13 +131,19 @@ function QueueDrawer({ onClose }: { onClose: () => void }) {
|
|||||||
{queue.length === 0 ? (
|
{queue.length === 0 ? (
|
||||||
<div className="mq-drawer-empty">{t('queue.emptyQueue')}</div>
|
<div className="mq-drawer-empty">{t('queue.emptyQueue')}</div>
|
||||||
) : (
|
) : (
|
||||||
queue.map((track, idx) => {
|
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
||||||
|
{virtualItems.map(vi => {
|
||||||
|
const idx = vi.index;
|
||||||
|
const track = resolveQueueTrack(queue[idx]);
|
||||||
const isActive = idx === queueIndex;
|
const isActive = idx === queueIndex;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${track.id}-${idx}`}
|
key={vi.key}
|
||||||
|
data-index={idx}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
className={`mq-item${isActive ? ' active' : ''}`}
|
className={`mq-item${isActive ? ' active' : ''}`}
|
||||||
onClick={() => { playTrack(track, queue); onClose(); }}
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
||||||
|
onClick={() => { playTrack(track, undefined, undefined, undefined, idx); onClose(); }}
|
||||||
>
|
>
|
||||||
<div className="mq-item-info">
|
<div className="mq-item-info">
|
||||||
<div className="mq-item-title">
|
<div className="mq-item-title">
|
||||||
@@ -118,7 +155,8 @@ function QueueDrawer({ onClose }: { onClose: () => void }) {
|
|||||||
<span className="mq-item-dur">{formatTrackTime(track.duration)}</span>
|
<span className="mq-item-dur">{formatTrackTime(track.duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
|||||||
import { usePlayerStore } from '@/store/playerStore';
|
import { usePlayerStore } from '@/store/playerStore';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||||
import { makeTrack } from '@/test/helpers/factories';
|
import { makeTrack, seedQueue } from '@/test/helpers/factories';
|
||||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||||
import { fireEvent } from '@testing-library/react';
|
import { fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
@@ -96,12 +96,11 @@ describe('PlayerBar — control wiring', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clicking Previous Track calls previous()', () => {
|
it('clicking Previous Track calls previous()', () => {
|
||||||
usePlayerStore.setState({
|
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
|
||||||
queue: [makeTrack({ id: 'a' }), makeTrack({ id: 'b' })],
|
index: 1,
|
||||||
queueIndex: 1,
|
|
||||||
currentTrack: makeTrack({ id: 'b' }),
|
currentTrack: makeTrack({ id: 'b' }),
|
||||||
currentTime: 10, // > 3 s → restart current
|
|
||||||
});
|
});
|
||||||
|
usePlayerStore.setState({ currentTime: 10 }); // > 3 s → restart current
|
||||||
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
|
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
|
||||||
|
|
||||||
const { getByLabelText } = renderWithProviders(<PlayerBar />);
|
const { getByLabelText } = renderWithProviders(<PlayerBar />);
|
||||||
@@ -113,7 +112,7 @@ describe('PlayerBar — control wiring', () => {
|
|||||||
it('clicking Next Track calls next()', () => {
|
it('clicking Next Track calls next()', () => {
|
||||||
const t1 = makeTrack({ id: 'a' });
|
const t1 = makeTrack({ id: 'a' });
|
||||||
const t2 = makeTrack({ id: 'b' });
|
const t2 = makeTrack({ id: 'b' });
|
||||||
usePlayerStore.setState({ queue: [t1, t2], queueIndex: 0, currentTrack: t1 });
|
seedQueue([t1, t2], { index: 0, currentTrack: t1 });
|
||||||
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
|
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
|
||||||
|
|
||||||
const { getByLabelText } = renderWithProviders(<PlayerBar />);
|
const { getByLabelText } = renderWithProviders(<PlayerBar />);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
|||||||
import { usePlayerStore } from '@/store/playerStore';
|
import { usePlayerStore } from '@/store/playerStore';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||||
import { makeTrack, makeTracks } from '@/test/helpers/factories';
|
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
|
||||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
@@ -86,11 +86,7 @@ describe('QueuePanel — render surface', () => {
|
|||||||
|
|
||||||
it('renders one row per queue track with the matching data-queue-idx', () => {
|
it('renders one row per queue track with the matching data-queue-idx', () => {
|
||||||
const tracks = makeTracks(3);
|
const tracks = makeTracks(3);
|
||||||
usePlayerStore.setState({
|
seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
|
||||||
queue: tracks,
|
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: tracks[0],
|
|
||||||
});
|
|
||||||
const { container } = renderWithProviders(<QueuePanel />);
|
const { container } = renderWithProviders(<QueuePanel />);
|
||||||
const rows = container.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
const rows = container.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||||
expect(rows.length).toBe(3);
|
expect(rows.length).toBe(3);
|
||||||
@@ -101,11 +97,7 @@ describe('QueuePanel — render surface', () => {
|
|||||||
it('renders each queue row with the track title text', () => {
|
it('renders each queue row with the track title text', () => {
|
||||||
const t1 = makeTrack({ id: 'q1', title: 'Test Song A' });
|
const t1 = makeTrack({ id: 'q1', title: 'Test Song A' });
|
||||||
const t2 = makeTrack({ id: 'q2', title: 'Test Song B' });
|
const t2 = makeTrack({ id: 'q2', title: 'Test Song B' });
|
||||||
usePlayerStore.setState({
|
seedQueue([t1, t2], { index: 0, currentTrack: t1 });
|
||||||
queue: [t1, t2],
|
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: t1,
|
|
||||||
});
|
|
||||||
const { getAllByText, getByText } = renderWithProviders(<QueuePanel />);
|
const { getAllByText, getByText } = renderWithProviders(<QueuePanel />);
|
||||||
// Title A appears both in the now-playing section and in the row;
|
// Title A appears both in the now-playing section and in the row;
|
||||||
// assert at least one match. Title B only lives in its row.
|
// assert at least one match. Title B only lives in its row.
|
||||||
@@ -117,11 +109,7 @@ describe('QueuePanel — render surface', () => {
|
|||||||
describe('QueuePanel — toolbar', () => {
|
describe('QueuePanel — toolbar', () => {
|
||||||
it('exposes Shuffle / Save Playlist / Load Playlist / Share Queue / Clear via aria-label', () => {
|
it('exposes Shuffle / Save Playlist / Load Playlist / Share Queue / Clear via aria-label', () => {
|
||||||
const tracks = makeTracks(3);
|
const tracks = makeTracks(3);
|
||||||
usePlayerStore.setState({
|
seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
|
||||||
queue: tracks,
|
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: tracks[0],
|
|
||||||
});
|
|
||||||
const { getByLabelText } = renderWithProviders(<QueuePanel />);
|
const { getByLabelText } = renderWithProviders(<QueuePanel />);
|
||||||
expect(getByLabelText('Shuffle queue')).toBeInTheDocument();
|
expect(getByLabelText('Shuffle queue')).toBeInTheDocument();
|
||||||
expect(getByLabelText('Save Playlist')).toBeInTheDocument();
|
expect(getByLabelText('Save Playlist')).toBeInTheDocument();
|
||||||
@@ -131,11 +119,7 @@ describe('QueuePanel — toolbar', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Shuffle button is disabled when the queue has fewer than 2 tracks', () => {
|
it('Shuffle button is disabled when the queue has fewer than 2 tracks', () => {
|
||||||
usePlayerStore.setState({
|
seedQueue([makeTrack()], { index: 0, currentTrack: makeTrack() });
|
||||||
queue: [makeTrack()],
|
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: makeTrack(),
|
|
||||||
});
|
|
||||||
const { getByLabelText } = renderWithProviders(<QueuePanel />);
|
const { getByLabelText } = renderWithProviders(<QueuePanel />);
|
||||||
const shuffle = getByLabelText('Shuffle queue') as HTMLButtonElement;
|
const shuffle = getByLabelText('Shuffle queue') as HTMLButtonElement;
|
||||||
expect(shuffle.disabled).toBe(true);
|
expect(shuffle.disabled).toBe(true);
|
||||||
@@ -149,11 +133,7 @@ describe('QueuePanel — DnD architecture pin (§4.4 of v2 plan)', () => {
|
|||||||
// queue back to native HTML5 DnD breaks loudly.
|
// queue back to native HTML5 DnD breaks loudly.
|
||||||
|
|
||||||
it('queue rows do not declare draggable=true (no HTML5 native drag)', () => {
|
it('queue rows do not declare draggable=true (no HTML5 native drag)', () => {
|
||||||
usePlayerStore.setState({
|
seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() });
|
||||||
queue: makeTracks(3),
|
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: makeTrack(),
|
|
||||||
});
|
|
||||||
const { container } = renderWithProviders(<QueuePanel />);
|
const { container } = renderWithProviders(<QueuePanel />);
|
||||||
const rows = container.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
const rows = container.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|||||||
@@ -72,7 +72,10 @@ function QueuePanelHostOrSolo() {
|
|||||||
if (!addedBy || addedBy === orbitHostUsername) return t('orbit.queueAddedByYou');
|
if (!addedBy || addedBy === orbitHostUsername) return t('orbit.queueAddedByYou');
|
||||||
return t('orbit.queueAddedByUser', { user: addedBy });
|
return t('orbit.queueAddedByUser', { user: addedBy });
|
||||||
};
|
};
|
||||||
const queue = usePlayerStore(s => s.queue);
|
// Thin-state: the queue is the canonical `QueueItemRef[]`; rows resolve their
|
||||||
|
// Track from the resolver. List, header, toolbar and id/length reads (save /
|
||||||
|
// share / playlist) all read off the refs.
|
||||||
|
const queueItems = usePlayerStore(s => s.queueItems);
|
||||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||||
@@ -152,7 +155,7 @@ function QueuePanelHostOrSolo() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useQueueAutoScroll({
|
useQueueAutoScroll({
|
||||||
queue,
|
queue: queueItems,
|
||||||
queueIndex,
|
queueIndex,
|
||||||
currentTrack,
|
currentTrack,
|
||||||
queueListRef,
|
queueListRef,
|
||||||
@@ -165,11 +168,11 @@ function QueuePanelHostOrSolo() {
|
|||||||
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (queue.length === 0) return;
|
if (queueItems.length === 0) return;
|
||||||
if (activePlaylist) {
|
if (activePlaylist) {
|
||||||
setSaveState('saving');
|
setSaveState('saving');
|
||||||
try {
|
try {
|
||||||
await updatePlaylist(activePlaylist.id, queue.map(t => t.id));
|
await updatePlaylist(activePlaylist.id, queueItems.map(r => r.trackId));
|
||||||
setSaveState('saved');
|
setSaveState('saved');
|
||||||
setTimeout(() => setSaveState('idle'), 1500);
|
setTimeout(() => setSaveState('idle'), 1500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -191,13 +194,13 @@ function QueuePanelHostOrSolo() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyQueueShare = async () => {
|
const handleCopyQueueShare = async () => {
|
||||||
if (queue.length === 0) {
|
if (queueItems.length === 0) {
|
||||||
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
|
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const srv = useAuthStore.getState().getBaseUrl();
|
const srv = useAuthStore.getState().getBaseUrl();
|
||||||
if (!srv) return;
|
if (!srv) return;
|
||||||
const ids = queue.map(t => t.id);
|
const ids = queueItems.map(r => r.trackId);
|
||||||
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
|
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
|
||||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||||
@@ -239,7 +242,7 @@ function QueuePanelHostOrSolo() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<QueueHeader
|
<QueueHeader
|
||||||
queue={queue}
|
queue={queueItems}
|
||||||
queueIndex={queueIndex}
|
queueIndex={queueIndex}
|
||||||
activePlaylist={activePlaylist}
|
activePlaylist={activePlaylist}
|
||||||
isNowPlayingCollapsed={isNowPlayingCollapsed}
|
isNowPlayingCollapsed={isNowPlayingCollapsed}
|
||||||
@@ -279,7 +282,7 @@ function QueuePanelHostOrSolo() {
|
|||||||
{activeTab === 'queue' ? (<>
|
{activeTab === 'queue' ? (<>
|
||||||
{!isNowPlayingCollapsed && toolbarButtons.some(b => b.visible && b.id !== 'separator') && (
|
{!isNowPlayingCollapsed && toolbarButtons.some(b => b.visible && b.id !== 'separator') && (
|
||||||
<QueueToolbar
|
<QueueToolbar
|
||||||
queue={queue}
|
queue={queueItems}
|
||||||
activePlaylist={activePlaylist}
|
activePlaylist={activePlaylist}
|
||||||
saveState={saveState}
|
saveState={saveState}
|
||||||
toolbarButtons={toolbarButtons}
|
toolbarButtons={toolbarButtons}
|
||||||
@@ -300,10 +303,10 @@ function QueuePanelHostOrSolo() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
{currentTrack && queueItems.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
||||||
|
|
||||||
<QueueList
|
<QueueList
|
||||||
queue={queue}
|
queue={queueItems}
|
||||||
queueIndex={queueIndex}
|
queueIndex={queueIndex}
|
||||||
contextMenu={contextMenu}
|
contextMenu={contextMenu}
|
||||||
playTrack={playTrack}
|
playTrack={playTrack}
|
||||||
@@ -332,7 +335,7 @@ function QueuePanelHostOrSolo() {
|
|||||||
onSave={async (name) => {
|
onSave={async (name) => {
|
||||||
try {
|
try {
|
||||||
const createPlaylist = usePlaylistStore.getState().createPlaylist;
|
const createPlaylist = usePlaylistStore.getState().createPlaylist;
|
||||||
const pl = await createPlaylist(name, queue.map(t => t.id));
|
const pl = await createPlaylist(name, queueItems.map(r => r.trackId));
|
||||||
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
|
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
|
||||||
setSaveModalOpen(false);
|
setSaveModalOpen(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
|
|||||||
const song = item as Track;
|
const song = item as Track;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, queue, undefined, undefined, queueIndex))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, undefined, undefined, undefined, queueIndex))}>
|
||||||
<Play size={14} /> {t('contextMenu.playNow')}
|
<Play size={14} /> {t('contextMenu.playNow')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
|
||||||
import type { EntityShareKind } from '../../utils/share/shareLink';
|
import type { EntityShareKind } from '../../utils/share/shareLink';
|
||||||
|
|
||||||
export type RatingKind = 'song' | 'album' | 'artist';
|
export type RatingKind = 'song' | 'album' | 'artist';
|
||||||
@@ -22,7 +22,9 @@ export interface ContextMenuItemsProps {
|
|||||||
playNext: (tracks: Track[]) => void;
|
playNext: (tracks: Track[]) => void;
|
||||||
enqueue: (tracks: Track[]) => void;
|
enqueue: (tracks: Track[]) => void;
|
||||||
removeTrack: (idx: number) => void;
|
removeTrack: (idx: number) => void;
|
||||||
queue: Track[];
|
/** Thin-state: the canonical queue refs. The queue-item "Play now" action uses
|
||||||
|
* the row's `queueIndex` to jump in place — no full Track[] needed. */
|
||||||
|
queue: QueueItemRef[];
|
||||||
currentTrack: Track | null;
|
currentTrack: Track | null;
|
||||||
closeContextMenu: () => void;
|
closeContextMenu: () => void;
|
||||||
starredOverrides: Record<string, boolean>;
|
starredOverrides: Record<string, boolean>;
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import OverlayScrollArea from '../OverlayScrollArea';
|
import OverlayScrollArea from '../OverlayScrollArea';
|
||||||
import type { MiniSyncPayload, MiniTrackInfo } from '../../utils/miniPlayerBridge';
|
import type { MiniSyncPayload, MiniTrackInfo } from '../../utils/miniPlayerBridge';
|
||||||
|
|
||||||
|
// Stable initial rect so the virtualizer never re-initializes on re-render (an
|
||||||
|
// inline literal would be a new ref each render → render loop). Replaced by the
|
||||||
|
// real height on first ResizeObserver measure.
|
||||||
|
const MINI_QUEUE_INITIAL_RECT = { width: 0, height: 400 };
|
||||||
|
|
||||||
type StartDrag = (
|
type StartDrag = (
|
||||||
payload: { data: string; label: string },
|
payload: { data: string; label: string },
|
||||||
x: number,
|
x: number,
|
||||||
@@ -30,13 +36,26 @@ export function MiniQueue({
|
|||||||
dropTarget, setDropTarget, dropTargetRef, startDrag, ctxIndex, setCtxMenu,
|
dropTarget, setDropTarget, dropTargetRef, startDrag, ctxIndex, setCtxMenu,
|
||||||
jumpTo, t,
|
jumpTo, t,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
// Virtualize so a multi-thousand-track queue keeps the mini window's DOM at
|
||||||
|
// O(visible rows). Scroll element is the OverlayScrollArea viewport.
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: state.queue.length,
|
||||||
|
getScrollElement: () => queueScrollRef.current,
|
||||||
|
estimateSize: () => 40,
|
||||||
|
overscan: 10,
|
||||||
|
getItemKey: i => `${state.queue[i].id}:${i}`,
|
||||||
|
initialRect: MINI_QUEUE_INITIAL_RECT,
|
||||||
|
});
|
||||||
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||||
|
const totalSize = rowVirtualizer.getTotalSize();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OverlayScrollArea
|
<OverlayScrollArea
|
||||||
wrapRef={miniQueueWrapRef}
|
wrapRef={miniQueueWrapRef}
|
||||||
viewportRef={queueScrollRef}
|
viewportRef={queueScrollRef}
|
||||||
className="mini-queue-wrap"
|
className="mini-queue-wrap"
|
||||||
viewportClassName="mini-queue"
|
viewportClassName="mini-queue"
|
||||||
measureDeps={[state.queue.length]}
|
measureDeps={[state.queue.length, totalSize]}
|
||||||
railInset="mini"
|
railInset="mini"
|
||||||
viewportScrollBehaviorAuto={isReorderDrag}
|
viewportScrollBehaviorAuto={isReorderDrag}
|
||||||
onMouseMove={(e) => {
|
onMouseMove={(e) => {
|
||||||
@@ -60,7 +79,10 @@ export function MiniQueue({
|
|||||||
{state.queue.length === 0 ? (
|
{state.queue.length === 0 ? (
|
||||||
<div className="mini-queue__empty">{t('miniPlayer.emptyQueue')}</div>
|
<div className="mini-queue__empty">{t('miniPlayer.emptyQueue')}</div>
|
||||||
) : (
|
) : (
|
||||||
state.queue.map((track, i) => {
|
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
||||||
|
{virtualItems.map(vi => {
|
||||||
|
const i = vi.index;
|
||||||
|
const track = state.queue[i];
|
||||||
let dragStyle: React.CSSProperties = {};
|
let dragStyle: React.CSSProperties = {};
|
||||||
if (isReorderDrag && psyDragFromIdxRef.current === i) {
|
if (isReorderDrag && psyDragFromIdxRef.current === i) {
|
||||||
dragStyle = { opacity: 0.4 };
|
dragStyle = { opacity: 0.4 };
|
||||||
@@ -71,7 +93,9 @@ export function MiniQueue({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${track.id}-${i}`}
|
key={vi.key}
|
||||||
|
data-index={i}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
data-mq-idx={i}
|
data-mq-idx={i}
|
||||||
className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}${ctxIndex === i ? ' mini-queue__item--ctx' : ''}`}
|
className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}${ctxIndex === i ? ' mini-queue__item--ctx' : ''}`}
|
||||||
onClick={() => jumpTo(i)}
|
onClick={() => jumpTo(i)}
|
||||||
@@ -105,7 +129,7 @@ export function MiniQueue({
|
|||||||
document.addEventListener('mousemove', onMove);
|
document.addEventListener('mousemove', onMove);
|
||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
}}
|
}}
|
||||||
style={dragStyle}
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)`, ...dragStyle }}
|
||||||
>
|
>
|
||||||
<span className="mini-queue__num">{i + 1}</span>
|
<span className="mini-queue__num">{i + 1}</span>
|
||||||
<div className="mini-queue__meta">
|
<div className="mini-queue__meta">
|
||||||
@@ -114,7 +138,8 @@ export function MiniQueue({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</OverlayScrollArea>
|
</OverlayScrollArea>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { usePreviewStore } from '../../store/previewStore';
|
|||||||
import { useThemeStore } from '../../store/themeStore';
|
import { useThemeStore } from '../../store/themeStore';
|
||||||
import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
|
import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
|
||||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||||
|
import { getQueueTracksView } from '../../utils/library/queueTrackView';
|
||||||
import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers';
|
import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers';
|
||||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||||
|
|
||||||
@@ -114,19 +115,22 @@ export default function PlaylistSuggestions({
|
|||||||
className="playlist-suggestion-play-btn"
|
className="playlist-suggestion-play-btn"
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const { queue, queueIndex, currentTrack, playTrack } = usePlayerStore.getState();
|
const { queueItems, queueIndex, currentTrack, playTrack } = usePlayerStore.getState();
|
||||||
const track = songToTrack(song);
|
const track = songToTrack(song);
|
||||||
if (!currentTrack || queue.length === 0) {
|
if (!currentTrack || queueItems.length === 0) {
|
||||||
playTrack(track, [track]);
|
playTrack(track, [track]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const insertAt = Math.min(queueIndex + 1, queue.length);
|
// Thin-state: resolve the current queue, insert after
|
||||||
|
// the playing track, and play the inserted track.
|
||||||
|
const resolved = getQueueTracksView(queueItems);
|
||||||
|
const insertAt = Math.min(queueIndex + 1, resolved.length);
|
||||||
const newQueue = [
|
const newQueue = [
|
||||||
...queue.slice(0, insertAt),
|
...resolved.slice(0, insertAt),
|
||||||
track,
|
track,
|
||||||
...queue.slice(insertAt),
|
...resolved.slice(insertAt),
|
||||||
];
|
];
|
||||||
playTrack(track, newQueue);
|
playTrack(track, newQueue, undefined, undefined, insertAt);
|
||||||
}}
|
}}
|
||||||
data-tooltip={t('playlists.playNextSuggestion')}
|
data-tooltip={t('playlists.playNextSuggestion')}
|
||||||
aria-label={t('playlists.playNextSuggestion')}
|
aria-label={t('playlists.playNextSuggestion')}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { useMemo } from 'react';
|
import { useDeferredValue, useMemo, useSyncExternalStore } from 'react';
|
||||||
import { ChevronDown, ListMusic } from 'lucide-react';
|
import { ChevronDown, ListMusic } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { QueueItemRef } from '../../store/playerStoreTypes';
|
||||||
import type { DurationMode } from '../../utils/componentHelpers/queuePanelHelpers';
|
import type { DurationMode } from '../../utils/componentHelpers/queuePanelHelpers';
|
||||||
import { formatLongDuration } from '../../utils/format/formatDuration';
|
import { formatLongDuration } from '../../utils/format/formatDuration';
|
||||||
import { formatClockTime } from '../../utils/format/formatClockTime';
|
import { formatClockTime } from '../../utils/format/formatClockTime';
|
||||||
|
import { resolveQueueTrack } from '../../utils/library/queueTrackView';
|
||||||
|
import {
|
||||||
|
getQueueResolverVersion,
|
||||||
|
subscribeQueueResolver,
|
||||||
|
} from '../../utils/library/queueTrackResolver';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
queue: Track[];
|
queue: QueueItemRef[];
|
||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
activePlaylist: { id: string; name: string } | null;
|
activePlaylist: { id: string; name: string } | null;
|
||||||
isNowPlayingCollapsed: boolean;
|
isNowPlayingCollapsed: boolean;
|
||||||
@@ -29,16 +34,32 @@ export function QueueHeader({
|
|||||||
const clockFormat = useAuthStore((s) => s.clockFormat);
|
const clockFormat = useAuthStore((s) => s.clockFormat);
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
const totalSecs = useMemo(() =>
|
// Thin-state: durations come from the resolver cache. The totals re-derive as
|
||||||
queue.reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
|
// the cache fills (version) and on queue change; tracks past the cache window
|
||||||
[queue]
|
// contribute 0 until they resolve. Pure read (no cache mutation) in the memo.
|
||||||
);
|
// H1 mitigation: a mass-resolve burst (queue restore, prefetch window slide)
|
||||||
const futureTracksDuration = useMemo(() =>
|
// bumps `version` dozens of times in one frame; useDeferredValue coalesces
|
||||||
queue.slice(queueIndex + 1).reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
|
// the burst into a single low-priority commit so long queues do not block
|
||||||
[queue, queueIndex]
|
// the main thread on every cache tick. The aggregation itself is a single
|
||||||
);
|
// pass — one loop produces both totals so a 50k-track queue costs one walk,
|
||||||
|
// not two.
|
||||||
|
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||||||
|
const deferredVersion = useDeferredValue(version);
|
||||||
|
const { totalSecs, futureTracksDuration } = useMemo(() => {
|
||||||
|
if (queue.length === 0) return { totalSecs: 0, futureTracksDuration: 0 };
|
||||||
|
let total = 0;
|
||||||
|
let future = 0;
|
||||||
|
for (let i = 0; i < queue.length; i += 1) {
|
||||||
|
const dur = resolveQueueTrack(queue[i]).duration || 0;
|
||||||
|
total += dur;
|
||||||
|
if (i > queueIndex) future += dur;
|
||||||
|
}
|
||||||
|
return { totalSecs: total, futureTracksDuration: future };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [queue, queueIndex, deferredVersion]);
|
||||||
|
|
||||||
const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + futureTracksDuration);
|
const currentDuration = queue[queueIndex] ? resolveQueueTrack(queue[queueIndex]).duration : 0;
|
||||||
|
const remainingSecs = Math.max(0, (currentDuration ?? 0) - currentTime + futureTracksDuration);
|
||||||
|
|
||||||
let dur: string | null = null;
|
let dur: string | null = null;
|
||||||
if (queue.length > 0) {
|
if (queue.length > 0) {
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { Play } from 'lucide-react';
|
import { Play } from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import OverlayScrollArea from '../OverlayScrollArea';
|
import OverlayScrollArea from '../OverlayScrollArea';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { useLuckyMixStore } from '../../store/luckyMixStore';
|
import { useLuckyMixStore } from '../../store/luckyMixStore';
|
||||||
import type { Track, PlayerState } from '../../store/playerStoreTypes';
|
import type { QueueItemRef, PlayerState } from '../../store/playerStoreTypes';
|
||||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||||
|
import { resolveQueueTrack } from '../../utils/library/queueTrackView';
|
||||||
|
import {
|
||||||
|
getQueueResolverVersion,
|
||||||
|
subscribeQueueResolver,
|
||||||
|
} from '../../utils/library/queueTrackResolver';
|
||||||
|
|
||||||
type StartDrag = (
|
type StartDrag = (
|
||||||
payload: { data: string; label: string },
|
payload: { data: string; label: string },
|
||||||
@@ -15,7 +20,7 @@ type StartDrag = (
|
|||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
queue: Track[];
|
queue: QueueItemRef[];
|
||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
contextMenu: PlayerState['contextMenu'];
|
contextMenu: PlayerState['contextMenu'];
|
||||||
playTrack: PlayerState['playTrack'];
|
playTrack: PlayerState['playTrack'];
|
||||||
@@ -31,11 +36,22 @@ interface Props {
|
|||||||
t: TFunction;
|
t: TFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stable reference so the virtualizer never sees a "changed" option on re-render
|
||||||
|
// (an inline object literal would be a new ref every render). Only used until the
|
||||||
|
// ResizeObserver reports the real viewport height.
|
||||||
|
const INITIAL_RECT = { width: 0, height: 600 };
|
||||||
|
|
||||||
export function QueueList({
|
export function QueueList({
|
||||||
queue, queueIndex, contextMenu, playTrack, activeTab, queueListRef,
|
queue, queueIndex, contextMenu, playTrack, activeTab, queueListRef,
|
||||||
suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget,
|
suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget,
|
||||||
startDrag, orbitAttributionLabel, luckyRolling, t,
|
startDrag, orbitAttributionLabel, luckyRolling, t,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
// Thin-state: the queue prop is the canonical `QueueItemRef[]`. Each row's
|
||||||
|
// full Track comes from the resolver (cache → placeholder; F4 overrides merged
|
||||||
|
// in resolveQueueTrack). Subscribe once so the list re-renders as the cache
|
||||||
|
// fills. Pure read in render — no cache mutation (the freeze landmine).
|
||||||
|
useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||||||
|
|
||||||
// Virtualize so a 10k+ Artist-Radio queue keeps DOM at O(visible rows).
|
// Virtualize so a 10k+ Artist-Radio queue keeps DOM at O(visible rows).
|
||||||
// Scroll element is the OverlayScrollArea viewport (`queueListRef`); rows have
|
// Scroll element is the OverlayScrollArea viewport (`queueListRef`); rows have
|
||||||
// variable height (radio/auto dividers, lucky-mix loader) so we measure them.
|
// variable height (radio/auto dividers, lucky-mix loader) so we measure them.
|
||||||
@@ -44,11 +60,11 @@ export function QueueList({
|
|||||||
getScrollElement: () => queueListRef.current,
|
getScrollElement: () => queueListRef.current,
|
||||||
estimateSize: () => 52,
|
estimateSize: () => 52,
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
getItemKey: i => `${queue[i].id}:${i}`,
|
getItemKey: i => `${queue[i].trackId}:${i}`,
|
||||||
// Start with a sensible viewport height so rows render before the
|
// Start with a sensible viewport height so rows render before the
|
||||||
// ResizeObserver reports the real size (SSR / jsdom, where the observer
|
// ResizeObserver reports the real size (SSR / jsdom, where the observer
|
||||||
// never fires). The real height overrides this on first measure.
|
// never fires). The real height overrides this on first measure.
|
||||||
initialRect: { width: 0, height: 600 },
|
initialRect: INITIAL_RECT,
|
||||||
});
|
});
|
||||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||||
const totalSize = rowVirtualizer.getTotalSize();
|
const totalSize = rowVirtualizer.getTotalSize();
|
||||||
@@ -93,10 +109,11 @@ export function QueueList({
|
|||||||
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
||||||
{virtualItems.map(vi => {
|
{virtualItems.map(vi => {
|
||||||
const idx = vi.index;
|
const idx = vi.index;
|
||||||
const track = queue[idx];
|
const base = queue[idx];
|
||||||
|
const track = resolveQueueTrack(base);
|
||||||
const isPlaying = idx === queueIndex;
|
const isPlaying = idx === queueIndex;
|
||||||
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
const isFirstAutoAdded = base.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
||||||
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
const isFirstRadioAdded = base.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
||||||
|
|
||||||
let dragStyle: React.CSSProperties = {};
|
let dragStyle: React.CSSProperties = {};
|
||||||
if (isQueueDrag && psyDragFromIdxRef.current === idx) {
|
if (isQueueDrag && psyDragFromIdxRef.current === idx) {
|
||||||
@@ -131,9 +148,10 @@ export function QueueList({
|
|||||||
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
suppressNextAutoScrollRef.current = true;
|
suppressNextAutoScrollRef.current = true;
|
||||||
// Pass the row index so a click on a duplicate track lands on
|
// Same-queue jump: undefined keeps the canonical refs; the row
|
||||||
// *this* slot, not the first occurrence (issue #500).
|
// index lands a click on a duplicate track on *this* slot, not
|
||||||
playTrack(track, queue, undefined, undefined, idx);
|
// the first occurrence (issue #500).
|
||||||
|
playTrack(track, undefined, undefined, undefined, idx);
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import {
|
|||||||
Check, FolderOpen, Infinity, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
|
Check, FolderOpen, Infinity, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { QueueItemRef } from '../../store/playerStoreTypes';
|
||||||
import type {
|
import type {
|
||||||
QueueToolbarButtonConfig,
|
QueueToolbarButtonConfig,
|
||||||
QueueToolbarButtonId,
|
QueueToolbarButtonId,
|
||||||
} from '../../store/queueToolbarStore';
|
} from '../../store/queueToolbarStore';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
queue: Track[];
|
queue: QueueItemRef[];
|
||||||
activePlaylist: { id: string; name: string } | null;
|
activePlaylist: { id: string; name: string } | null;
|
||||||
saveState: 'idle' | 'saving' | 'saved';
|
saveState: 'idle' | 'saving' | 'saved';
|
||||||
toolbarButtons: QueueToolbarButtonConfig[];
|
toolbarButtons: QueueToolbarButtonConfig[];
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ export const SHORTCUT_ACTION_REGISTRY = {
|
|||||||
runInMiniWindow: false,
|
runInMiniWindow: false,
|
||||||
run: () => {
|
run: () => {
|
||||||
const store = usePlayerStore.getState();
|
const store = usePlayerStore.getState();
|
||||||
const { currentTrack, queue, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store;
|
const { currentTrack, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store;
|
||||||
stop();
|
stop();
|
||||||
resetAudioPause();
|
resetAudioPause();
|
||||||
invoke('audio_stop')
|
invoke('audio_stop')
|
||||||
@@ -341,9 +341,10 @@ export const SHORTCUT_ACTION_REGISTRY = {
|
|||||||
try {
|
try {
|
||||||
const fresh = await getSong(currentTrack.id);
|
const fresh = await getSong(currentTrack.id);
|
||||||
const t = fresh ? songToTrack(fresh) : currentTrack;
|
const t = fresh ? songToTrack(fresh) : currentTrack;
|
||||||
playTrack(t, queue, true);
|
// No-arg queue: keep the canonical refs, re-bind the current track.
|
||||||
|
playTrack(t, undefined, true);
|
||||||
} catch {
|
} catch {
|
||||||
playTrack(currentTrack, queue, true);
|
playTrack(currentTrack, undefined, true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await initializeFromServerQueue();
|
await initializeFromServerQueue();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { makeTrack } from '../test/helpers/factories';
|
import { makeTrack } from '../test/helpers/factories';
|
||||||
import { resetAllStores } from '../test/helpers/storeReset';
|
import { resetAllStores } from '../test/helpers/storeReset';
|
||||||
|
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
||||||
|
|
||||||
const hoisted = vi.hoisted(() => ({
|
const hoisted = vi.hoisted(() => ({
|
||||||
useCoverArtMock: vi.fn(
|
useCoverArtMock: vi.fn(
|
||||||
@@ -39,7 +40,7 @@ function seedPlaybackState(): { active: string; playback: string } {
|
|||||||
useAuthStore.getState().setActiveServer(active);
|
useAuthStore.getState().setActiveServer(active);
|
||||||
const track = makeTrack({ id: 'song-1', coverArt: 'cover-1' });
|
const track = makeTrack({ id: 'song-1', coverArt: 'cover-1' });
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue: [track],
|
queueItems: toQueueItemRefs(playback, [track]),
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
queueServerId: playback,
|
queueServerId: playback,
|
||||||
currentTrack: track,
|
currentTrack: track,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function usePlaybackCoverArt(
|
|||||||
displayCssPx: number,
|
displayCssPx: number,
|
||||||
): CoverArtHandle {
|
): CoverArtHandle {
|
||||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||||
const queueLength = usePlayerStore(s => s.queue.length);
|
const queueLength = usePlayerStore(s => s.queueItems.length);
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
const serversFingerprint = useAuthStore(s =>
|
const serversFingerprint = useAuthStore(s =>
|
||||||
s.servers
|
s.servers
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import { getPlaybackProgressSnapshot } from '../../store/playbackProgress';
|
import { getPlaybackProgressSnapshot } from '../../store/playbackProgress';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { resolveQueueTrack } from '../../utils/library/queueTrackView';
|
||||||
|
|
||||||
|
/** Half-width of the CLI snapshot queue window (thin-state — like the mini
|
||||||
|
* bridge, the full 50k queue must not serialize over IPC on every change). */
|
||||||
|
const SNAPSHOT_QUEUE_HALF = 100;
|
||||||
|
|
||||||
/** `psysonic --info`: publishes a JSON snapshot under XDG_RUNTIME_DIR (Rust
|
/** `psysonic --info`: publishes a JSON snapshot under XDG_RUNTIME_DIR (Rust
|
||||||
* writes atomically). Coalesces store changes through a 200 ms debounce and
|
* writes atomically). Coalesces store changes through a 200 ms debounce and
|
||||||
@@ -27,12 +32,19 @@ export function usePlayerSnapshotPublisher() {
|
|||||||
ct != null
|
ct != null
|
||||||
? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred))
|
? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred))
|
||||||
: null;
|
: null;
|
||||||
|
// Thin-state: resolve only a window around the playing track (resolver
|
||||||
|
// cache → placeholder) instead of the whole 50k queue. `queue_length`
|
||||||
|
// stays the true total; `queue_index` is remapped into the window.
|
||||||
|
const total = s.queueItems.length;
|
||||||
|
const winStart = Math.max(0, s.queueIndex - SNAPSHOT_QUEUE_HALF);
|
||||||
|
const winEnd = Math.min(total, s.queueIndex + SNAPSHOT_QUEUE_HALF + 1);
|
||||||
|
const windowedQueue = s.queueItems.slice(winStart, winEnd).map(r => resolveQueueTrack(r));
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
current_track: s.currentTrack,
|
current_track: s.currentTrack,
|
||||||
current_radio: s.currentRadio,
|
current_radio: s.currentRadio,
|
||||||
queue: s.queue,
|
queue: windowedQueue,
|
||||||
queue_index: s.queueIndex,
|
queue_index: s.queueIndex - winStart,
|
||||||
queue_length: s.queue.length,
|
queue_length: total,
|
||||||
is_playing: s.isPlaying,
|
is_playing: s.isPlaying,
|
||||||
current_time: getPlaybackProgressSnapshot().currentTime,
|
current_time: getPlaybackProgressSnapshot().currentTime,
|
||||||
volume: s.volume,
|
volume: s.volume,
|
||||||
@@ -50,7 +62,7 @@ export function usePlayerSnapshotPublisher() {
|
|||||||
trackId: s.currentTrack?.id ?? null,
|
trackId: s.currentTrack?.id ?? null,
|
||||||
radioId: s.currentRadio?.id ?? null,
|
radioId: s.currentRadio?.id ?? null,
|
||||||
queueIndex: s.queueIndex,
|
queueIndex: s.queueIndex,
|
||||||
queueLength: s.queue.length,
|
queueLength: total,
|
||||||
isPlaying: s.isPlaying,
|
isPlaying: s.isPlaying,
|
||||||
volume: Math.round(s.volume * 100),
|
volume: Math.round(s.volume * 100),
|
||||||
repeatMode: s.repeatMode,
|
repeatMode: s.repeatMode,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function useMiniQueueDrag({
|
|||||||
if (parsed.type !== 'queue_reorder') return;
|
if (parsed.type !== 'queue_reorder') return;
|
||||||
const fromIdx = parsed.index as number;
|
const fromIdx = parsed.index as number;
|
||||||
psyDragFromIdxRef.current = null;
|
psyDragFromIdxRef.current = null;
|
||||||
const queueLen = usePlayerStore.getState().queue.length || fallbackQueueLen;
|
const queueLen = usePlayerStore.getState().queueItems.length || fallbackQueueLen;
|
||||||
const insertIdx = tgt
|
const insertIdx = tgt
|
||||||
? (tgt.before ? tgt.idx : tgt.idx + 1)
|
? (tgt.before ? tgt.idx : tgt.idx + 1)
|
||||||
: queueLen;
|
: queueLen;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { makeTrack } from '../test/helpers/factories';
|
import { makeTrack } from '../test/helpers/factories';
|
||||||
import { resetAllStores } from '../test/helpers/storeReset';
|
import { resetAllStores } from '../test/helpers/storeReset';
|
||||||
|
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
||||||
|
|
||||||
vi.mock('../api/coverCache', () => ({
|
vi.mock('../api/coverCache', () => ({
|
||||||
coverCachePeekBatch: vi.fn(async () => ({})),
|
coverCachePeekBatch: vi.fn(async () => ({})),
|
||||||
@@ -51,7 +52,7 @@ describe('useNowPlayingPrewarm', () => {
|
|||||||
coverArt: 'cover-1',
|
coverArt: 'cover-1',
|
||||||
});
|
});
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue: [track],
|
queueItems: toQueueItemRefs(playback, [track]),
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
queueServerId: playback,
|
queueServerId: playback,
|
||||||
currentTrack: track,
|
currentTrack: track,
|
||||||
@@ -76,7 +77,7 @@ describe('useNowPlayingPrewarm', () => {
|
|||||||
const { active, playback } = seedServers();
|
const { active, playback } = seedServers();
|
||||||
const track = makeTrack({ id: 'song-2', coverArt: 'cover-2' });
|
const track = makeTrack({ id: 'song-2', coverArt: 'cover-2' });
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue: [track],
|
queueItems: toQueueItemRefs(playback, [track]),
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
queueServerId: playback,
|
queueServerId: playback,
|
||||||
currentTrack: null,
|
currentTrack: null,
|
||||||
|
|||||||
@@ -108,23 +108,24 @@ export function useOrbitHost(): void {
|
|||||||
&& (Date.now() - afterSweep.lastShuffle >= effectiveShuffleIntervalMs(afterSweep));
|
&& (Date.now() - afterSweep.lastShuffle >= effectiveShuffleIntervalMs(afterSweep));
|
||||||
const afterShuffle = maybeShuffleQueue(afterSweep);
|
const afterShuffle = maybeShuffleQueue(afterSweep);
|
||||||
if (shouldShuffleNow) {
|
if (shouldShuffleNow) {
|
||||||
const before = usePlayerStore.getState().queue.length;
|
const before = usePlayerStore.getState().queueItems.length;
|
||||||
usePlayerStore.getState().shuffleUpcomingQueue();
|
usePlayerStore.getState().shuffleUpcomingQueue();
|
||||||
if (before > 0) showToast(i18n.t('orbit.toastShuffled'), 2500, 'info');
|
if (before > 0) showToast(i18n.t('orbit.toastShuffled'), 2500, 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Overlay the host's live playback snapshot.
|
// 4) Overlay the host's live playback snapshot. Host tick reads ids only,
|
||||||
|
// so the thin `queueItems` refs are all we need (no full Track resolve).
|
||||||
const playerLive = usePlayerStore.getState();
|
const playerLive = usePlayerStore.getState();
|
||||||
const upcoming = playerLive.queue.slice(playerLive.queueIndex + 1);
|
const upcoming = playerLive.queueItems.slice(playerLive.queueIndex + 1);
|
||||||
// Map track id → original suggester (if any). State's `queue` carries
|
// Map track id → original suggester (if any). State's `queue` carries
|
||||||
// every suggestion we've ever seen this session, so it's the right
|
// every suggestion we've ever seen this session, so it's the right
|
||||||
// attribution source even after the track has been merged into the
|
// attribution source even after the track has been merged into the
|
||||||
// host's player queue.
|
// host's player queue.
|
||||||
const suggesterByTrack = new Map<string, string>();
|
const suggesterByTrack = new Map<string, string>();
|
||||||
for (const q of afterShuffle.queue) suggesterByTrack.set(q.trackId, q.addedBy);
|
for (const q of afterShuffle.queue) suggesterByTrack.set(q.trackId, q.addedBy);
|
||||||
const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(t => ({
|
const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(r => ({
|
||||||
trackId: t.id,
|
trackId: r.trackId,
|
||||||
addedBy: suggesterByTrack.get(t.id) ?? base.host,
|
addedBy: suggesterByTrack.get(r.trackId) ?? base.host,
|
||||||
}));
|
}));
|
||||||
const next: OrbitState = {
|
const next: OrbitState = {
|
||||||
...afterShuffle,
|
...afterShuffle,
|
||||||
@@ -204,7 +205,7 @@ export function useOrbitHost(): void {
|
|||||||
for (const { track } of toEnqueue) {
|
for (const { track } of toEnqueue) {
|
||||||
const live = usePlayerStore.getState();
|
const live = usePlayerStore.getState();
|
||||||
const from = Math.max(0, live.queueIndex + 1);
|
const from = Math.max(0, live.queueIndex + 1);
|
||||||
const to = live.queue.length;
|
const to = live.queueItems.length;
|
||||||
const span = Math.max(1, to - from + 1);
|
const span = Math.max(1, to - from + 1);
|
||||||
const pos = from + Math.floor(Math.random() * span);
|
const pos = from + Math.floor(Math.random() * span);
|
||||||
player.enqueueAt([track], pos);
|
player.enqueueAt([track], pos);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe('usePlaybackServerId', () => {
|
|||||||
isLoggedIn: true,
|
isLoggedIn: true,
|
||||||
});
|
});
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }],
|
queueItems: [{ serverId: 'a', trackId: 't1' }],
|
||||||
queueServerId: 'a',
|
queueServerId: 'a',
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
|||||||
*/
|
*/
|
||||||
export function usePlaybackServerId(): string {
|
export function usePlaybackServerId(): string {
|
||||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||||
const queueLength = usePlayerStore(s => s.queue.length);
|
const queueLength = usePlayerStore(s => s.queueItems.length);
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => getPlaybackServerId(),
|
() => getPlaybackServerId(),
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useLayoutEffect } from 'react';
|
import React, { useLayoutEffect } from 'react';
|
||||||
import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo';
|
import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { QueueItemRef, Track } from '../store/playerStoreTypes';
|
||||||
|
|
||||||
interface Args {
|
interface Args {
|
||||||
queue: Track[];
|
queue: QueueItemRef[];
|
||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
currentTrack: Track | null;
|
currentTrack: Track | null;
|
||||||
queueListRef: React.RefObject<HTMLDivElement | null>;
|
queueListRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function useQueuePanelDrag({
|
|||||||
|
|
||||||
const insertIdx = dropTarget
|
const insertIdx = dropTarget
|
||||||
? (dropTarget.before ? dropTarget.idx : dropTarget.idx + 1)
|
? (dropTarget.before ? dropTarget.idx : dropTarget.idx + 1)
|
||||||
: usePlayerStore.getState().queue.length;
|
: usePlayerStore.getState().queueItems.length;
|
||||||
|
|
||||||
if (parsedData.type === 'queue_reorder') {
|
if (parsedData.type === 'queue_reorder') {
|
||||||
const fromIdx: number = parsedData.index;
|
const fromIdx: number = parsedData.index;
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { renderHook } from '@testing-library/react';
|
|||||||
import { usePlayerStore } from '@/store/playerStore';
|
import { usePlayerStore } from '@/store/playerStore';
|
||||||
import type { Track } from '@/store/playerStoreTypes';
|
import type { Track } from '@/store/playerStoreTypes';
|
||||||
import { getCachedTrack, _resetQueueResolverForTest } from '@/utils/library/queueTrackResolver';
|
import { getCachedTrack, _resetQueueResolverForTest } from '@/utils/library/queueTrackResolver';
|
||||||
|
import { seedQueue } from '@/test/helpers/factories';
|
||||||
import { useQueueTrackAt, useCurrentTrack, useQueueItems } from './useQueueTracks';
|
import { useQueueTrackAt, useCurrentTrack, useQueueItems } from './useQueueTracks';
|
||||||
// Importing the bridge registers the queue→resolver seed subscriber.
|
|
||||||
import '@/store/queueResolverBridge';
|
|
||||||
|
|
||||||
const track = (id: string, over: Partial<Track> = {}): Track =>
|
const track = (id: string, over: Partial<Track> = {}): Track =>
|
||||||
({ id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1, ...over });
|
({ id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1, ...over });
|
||||||
@@ -13,25 +12,37 @@ const track = (id: string, over: Partial<Track> = {}): Track =>
|
|||||||
describe('useQueueTracks selectors', () => {
|
describe('useQueueTracks selectors', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetQueueResolverForTest();
|
_resetQueueResolverForTest();
|
||||||
usePlayerStore.setState({ queue: [], queueIndex: 0, queueServerId: 's1', currentTrack: null });
|
usePlayerStore.setState({
|
||||||
|
queueItems: [], queueIndex: 0, queueServerId: 's1', currentTrack: null,
|
||||||
|
starredOverrides: {}, userRatingOverrides: {},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('useQueueTrackAt returns the track at the index, or null', () => {
|
it('useQueueTrackAt returns the track at the index, or null', () => {
|
||||||
usePlayerStore.setState({ queue: [track('t1'), track('t2')] });
|
// seedQueue seeds the resolver under serverId 's1' and sets the refs.
|
||||||
|
seedQueue([track('t1'), track('t2')], { serverId: 's1', currentTrack: null });
|
||||||
expect(renderHook(() => useQueueTrackAt(1)).result.current?.id).toBe('t2');
|
expect(renderHook(() => useQueueTrackAt(1)).result.current?.id).toBe('t2');
|
||||||
expect(renderHook(() => useQueueTrackAt(9)).result.current).toBeNull();
|
expect(renderHook(() => useQueueTrackAt(9)).result.current).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('useQueueTrackAt merges session star/rating overrides', () => {
|
||||||
|
seedQueue([track('t1')], { serverId: 's1', currentTrack: null });
|
||||||
|
usePlayerStore.setState({
|
||||||
|
starredOverrides: { t1: true },
|
||||||
|
userRatingOverrides: { t1: 5 },
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useQueueTrackAt(0));
|
||||||
|
expect(!!result.current?.starred).toBe(true);
|
||||||
|
expect(result.current?.userRating).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
it('useCurrentTrack returns the current track', () => {
|
it('useCurrentTrack returns the current track', () => {
|
||||||
usePlayerStore.setState({ currentTrack: track('cur') });
|
usePlayerStore.setState({ currentTrack: track('cur') });
|
||||||
expect(renderHook(() => useCurrentTrack()).result.current?.id).toBe('cur');
|
expect(renderHook(() => useCurrentTrack()).result.current?.id).toBe('cur');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('useQueueItems derives thin refs (serverId + flags) from the queue', () => {
|
it('useQueueItems returns the canonical thin refs (serverId + flags)', () => {
|
||||||
usePlayerStore.setState({
|
seedQueue([track('t1'), track('t2', { radioAdded: true })], { serverId: 's1', currentTrack: null });
|
||||||
queueServerId: 's1',
|
|
||||||
queue: [track('t1'), track('t2', { radioAdded: true })],
|
|
||||||
});
|
|
||||||
const { result } = renderHook(() => useQueueItems());
|
const { result } = renderHook(() => useQueueItems());
|
||||||
expect(result.current).toEqual([
|
expect(result.current).toEqual([
|
||||||
{ serverId: 's1', trackId: 't1' },
|
{ serverId: 's1', trackId: 't1' },
|
||||||
@@ -40,20 +51,15 @@ describe('useQueueTracks selectors', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('queueResolverBridge', () => {
|
describe('seedQueue resolver seeding', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetQueueResolverForTest();
|
_resetQueueResolverForTest();
|
||||||
usePlayerStore.setState({ queue: [], queueIndex: 0, queueServerId: 's1', currentTrack: null });
|
usePlayerStore.setState({ queueItems: [], queueIndex: 0, queueServerId: 's1', currentTrack: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('seeds the resolver cache with tracks around the current index on queue change', () => {
|
it('seeds the resolver cache with the queue tracks under the queue server', () => {
|
||||||
usePlayerStore.setState({ queue: [track('t1'), track('t2')], queueIndex: 0, queueServerId: 's1' });
|
seedQueue([track('t1'), track('t2')], { serverId: 's1', currentTrack: null });
|
||||||
expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1');
|
expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1');
|
||||||
expect(getCachedTrack({ serverId: 's1', trackId: 't2' })?.id).toBe('t2');
|
expect(getCachedTrack({ serverId: 's1', trackId: 't2' })?.id).toBe('t2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not seed when there is no playback server', () => {
|
|
||||||
usePlayerStore.setState({ queue: [track('t1')], queueIndex: 0, queueServerId: null });
|
|
||||||
expect(getCachedTrack({ serverId: '', trackId: 't1' })).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+21
-11
@@ -1,18 +1,30 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useSyncExternalStore } from 'react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import type { QueueItemRef, Track } from '../store/playerStoreTypes';
|
import type { QueueItemRef, Track } from '../store/playerStoreTypes';
|
||||||
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
import { resolveQueueTrack } from '../utils/library/queueTrackView';
|
||||||
|
import {
|
||||||
|
getQueueResolverVersion,
|
||||||
|
subscribeQueueResolver,
|
||||||
|
} from '../utils/library/queueTrackResolver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stable queue selectors (queue thin-state). Consumers migrate onto these in
|
* Stable queue selectors (queue thin-state). The store is refs-canonical now:
|
||||||
* phase 3. Today they read the canonical `queue: Track[]`; once it's dropped
|
* full `Track`s come from the resolver cache (placeholder until a fetch lands),
|
||||||
* (phase 4) the implementations move to the resolver (`queueTrackResolver`)
|
* with session star/rating overrides (F4) merged on read via resolveQueueTrack.
|
||||||
* without changing these signatures.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** The track at a queue index, or null. */
|
/** The track at a queue index, or null. */
|
||||||
export function useQueueTrackAt(idx: number): Track | null {
|
export function useQueueTrackAt(idx: number): Track | null {
|
||||||
return usePlayerStore(s => s.queue[idx] ?? null);
|
const ref = usePlayerStore(s => s.queueItems[idx] ?? null);
|
||||||
|
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||||
|
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||||
|
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!ref) return null;
|
||||||
|
return resolveQueueTrack(ref);
|
||||||
|
// version drives re-resolution as the cache fills; overrides drive the merge.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [ref, starredOverrides, userRatingOverrides, version]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The currently playing track, or null. */
|
/** The currently playing track, or null. */
|
||||||
@@ -20,9 +32,7 @@ export function useCurrentTrack(): Track | null {
|
|||||||
return usePlayerStore(s => s.currentTrack);
|
return usePlayerStore(s => s.currentTrack);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The whole queue as thin refs (derived; memoized on queue/server identity). */
|
/** The whole queue as thin refs (the canonical list). */
|
||||||
export function useQueueItems(): QueueItemRef[] {
|
export function useQueueItems(): QueueItemRef[] {
|
||||||
const queue = usePlayerStore(s => s.queue);
|
return usePlayerStore(s => s.queueItems);
|
||||||
const serverId = usePlayerStore(s => s.queueServerId);
|
|
||||||
return useMemo(() => toQueueItemRefs(serverId ?? '', queue), [serverId, queue]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-20
@@ -1,6 +1,7 @@
|
|||||||
import { buildStreamUrlForServer } from './api/subsonicStreamUrl';
|
import { buildStreamUrlForServer } from './api/subsonicStreamUrl';
|
||||||
import { getPlaybackCacheServerKey } from './utils/playback/playbackServer';
|
import { getPlaybackCacheServerKey } from './utils/playback/playbackServer';
|
||||||
import type { Track } from './store/playerStoreTypes';
|
import type { QueueItemRef } from './store/playerStoreTypes';
|
||||||
|
import { resolveQueueTrack } from './utils/library/queueTrackView';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useAuthStore } from './store/authStore';
|
import { useAuthStore } from './store/authStore';
|
||||||
import { useHotCacheStore } from './store/hotCacheStore';
|
import { useHotCacheStore } from './store/hotCacheStore';
|
||||||
@@ -113,12 +114,9 @@ async function runWorker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const player = usePlayerStore.getState();
|
const player = usePlayerStore.getState();
|
||||||
const { queue, queueIndex } = player;
|
const { queueItems, queueIndex } = player;
|
||||||
const wantIds = new Set(
|
const upcomingRefs = queueItems.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD);
|
||||||
queue
|
const wantIds = new Set(upcomingRefs.map(r => r.trackId));
|
||||||
.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD)
|
|
||||||
.map(t => t.id),
|
|
||||||
);
|
|
||||||
if (!wantIds.has(job.trackId)) {
|
if (!wantIds.has(job.trackId)) {
|
||||||
hotCacheFrontendDebug({
|
hotCacheFrontendDebug({
|
||||||
event: 'prefetch-skip-job',
|
event: 'prefetch-skip-job',
|
||||||
@@ -130,8 +128,11 @@ async function runWorker() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const track = queue.find(t => t.id === job.trackId);
|
// Thin-state: the upcoming window sits inside the resolver-warm range, so
|
||||||
if (!track) {
|
// resolveQueueTrack returns the full Track (placeholder only on a cold
|
||||||
|
// miss, where the size estimate falls back to the bitrate heuristic).
|
||||||
|
const jobRef = upcomingRefs.find(r => r.trackId === job.trackId);
|
||||||
|
if (!jobRef) {
|
||||||
hotCacheFrontendDebug({
|
hotCacheFrontendDebug({
|
||||||
event: 'prefetch-skip-job',
|
event: 'prefetch-skip-job',
|
||||||
trackId: job.trackId,
|
trackId: job.trackId,
|
||||||
@@ -139,10 +140,11 @@ async function runWorker() {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const track = resolveQueueTrack(jobRef);
|
||||||
const hotEntries = useHotCacheStore.getState().entries;
|
const hotEntries = useHotCacheStore.getState().entries;
|
||||||
const occupied = sumCachedBytesInProtectedWindow(queue, queueIndex, job.serverId, hotEntries);
|
const occupied = sumCachedBytesInProtectedWindow(queueItems, queueIndex, job.serverId, hotEntries);
|
||||||
const est = estimateTrackHotCacheBytes(track);
|
const est = estimateTrackHotCacheBytes(track);
|
||||||
const isImmediateNext = queue[queueIndex + 1]?.id === job.trackId;
|
const isImmediateNext = queueItems[queueIndex + 1]?.trackId === job.trackId;
|
||||||
if (!isImmediateNext && occupied + est > maxBytes) {
|
if (!isImmediateNext && occupied + est > maxBytes) {
|
||||||
hotCacheFrontendDebug({
|
hotCacheFrontendDebug({
|
||||||
event: 'prefetch-skip-job',
|
event: 'prefetch-skip-job',
|
||||||
@@ -172,7 +174,7 @@ async function runWorker() {
|
|||||||
const authAfter = useAuthStore.getState();
|
const authAfter = useAuthStore.getState();
|
||||||
const maxAfter = Math.max(0, authAfter.hotCacheMaxMb) * 1024 * 1024;
|
const maxAfter = Math.max(0, authAfter.hotCacheMaxMb) * 1024 * 1024;
|
||||||
await useHotCacheStore.getState().evictToFit(
|
await useHotCacheStore.getState().evictToFit(
|
||||||
fresh.queue,
|
fresh.queueItems,
|
||||||
fresh.queueIndex,
|
fresh.queueIndex,
|
||||||
maxAfter,
|
maxAfter,
|
||||||
getPlaybackCacheServerKey(),
|
getPlaybackCacheServerKey(),
|
||||||
@@ -217,7 +219,7 @@ async function replanNow() {
|
|||||||
const customDir = auth.hotCacheDownloadDir || null;
|
const customDir = auth.hotCacheDownloadDir || null;
|
||||||
if (maxBytes <= 0) return;
|
if (maxBytes <= 0) return;
|
||||||
|
|
||||||
const { queue, queueIndex, currentRadio } = usePlayerStore.getState();
|
const { queueItems, queueIndex, currentRadio } = usePlayerStore.getState();
|
||||||
if (currentRadio) {
|
if (currentRadio) {
|
||||||
hotCacheFrontendDebug({ event: 'replan-skip', reason: 'radio-mode' });
|
hotCacheFrontendDebug({ event: 'replan-skip', reason: 'radio-mode' });
|
||||||
return;
|
return;
|
||||||
@@ -225,15 +227,18 @@ async function replanNow() {
|
|||||||
|
|
||||||
const offline = useOfflineStore.getState();
|
const offline = useOfflineStore.getState();
|
||||||
|
|
||||||
await useHotCacheStore.getState().evictToFit(queue, queueIndex, maxBytes, serverId, customDir);
|
await useHotCacheStore.getState().evictToFit(queueItems, queueIndex, maxBytes, serverId, customDir);
|
||||||
|
|
||||||
// Must read entries after eviction: the pre-evict snapshot still lists removed keys and would
|
// Must read entries after eviction: the pre-evict snapshot still lists removed keys and would
|
||||||
// skip prefetch for upcoming tracks that no longer have on-disk rows.
|
// skip prefetch for upcoming tracks that no longer have on-disk rows.
|
||||||
const hotEntries = useHotCacheStore.getState().entries;
|
const hotEntries = useHotCacheStore.getState().entries;
|
||||||
|
|
||||||
const targets = queue.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD);
|
// Thin-state: resolve only the small upcoming window (within the resolver-warm
|
||||||
const immediateNextId = queue[queueIndex + 1]?.id;
|
// range) to full Tracks for the size estimates / suffix.
|
||||||
let projectedOccupied = sumCachedBytesInProtectedWindow(queue, queueIndex, serverId, hotEntries);
|
const targetRefs = queueItems.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD);
|
||||||
|
const targets = targetRefs.map(r => resolveQueueTrack(r));
|
||||||
|
const immediateNextId = queueItems[queueIndex + 1]?.trackId;
|
||||||
|
let projectedOccupied = sumCachedBytesInProtectedWindow(queueItems, queueIndex, serverId, hotEntries);
|
||||||
const jobs: PrefetchJob[] = [];
|
const jobs: PrefetchJob[] = [];
|
||||||
const skipped: { trackId: string; reason: string }[] = [];
|
const skipped: { trackId: string; reason: string }[] = [];
|
||||||
for (const t of targets) {
|
for (const t of targets) {
|
||||||
@@ -278,7 +283,7 @@ export function initHotCachePrefetch(): () => void {
|
|||||||
let lastQueueRef: unknown = null;
|
let lastQueueRef: unknown = null;
|
||||||
let lastQueueIndex = -1;
|
let lastQueueIndex = -1;
|
||||||
const unsubPlayer = usePlayerStore.subscribe(state => {
|
const unsubPlayer = usePlayerStore.subscribe(state => {
|
||||||
const q = state.queue;
|
const q = state.queueItems;
|
||||||
const i = state.queueIndex;
|
const i = state.queueIndex;
|
||||||
if (q === lastQueueRef && i === lastQueueIndex) return;
|
if (q === lastQueueRef && i === lastQueueIndex) return;
|
||||||
const prevIdx = lastQueueIndex;
|
const prevIdx = lastQueueIndex;
|
||||||
@@ -288,11 +293,11 @@ export function initHotCachePrefetch(): () => void {
|
|||||||
lastQueueIndex = i;
|
lastQueueIndex = i;
|
||||||
scheduleAnalysisQueuePruneFromPlaybackQueue();
|
scheduleAnalysisQueuePruneFromPlaybackQueue();
|
||||||
if (onlyIndexMoved && i > prevIdx && prevIdx >= 0 && Array.isArray(prevQ)) {
|
if (onlyIndexMoved && i > prevIdx && prevIdx >= 0 && Array.isArray(prevQ)) {
|
||||||
const left = (prevQ as Track[])[prevIdx];
|
const left = (prevQ as QueueItemRef[])[prevIdx];
|
||||||
const a = useAuthStore.getState();
|
const a = useAuthStore.getState();
|
||||||
const graceSid = getPlaybackCacheServerKey();
|
const graceSid = getPlaybackCacheServerKey();
|
||||||
if (left && graceSid) {
|
if (left && graceSid) {
|
||||||
bumpHotCachePreviousTrackGrace(left.id, graceSid, a.hotCacheDebounceSec);
|
bumpHotCachePreviousTrackGrace(left.trackId, graceSid, a.hotCacheDebounceSec);
|
||||||
scheduleEvictAfterPreviousGrace();
|
scheduleEvictAfterPreviousGrace();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type AnalysisPrunePendingResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void {
|
export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void {
|
||||||
const { queue, currentTrack, queueIndex } = usePlayerStore.getState();
|
const { queueItems, currentTrack, queueIndex } = usePlayerStore.getState();
|
||||||
const { preloadMode } = useAuthStore.getState();
|
const { preloadMode } = useAuthStore.getState();
|
||||||
const rawServerId = getPlaybackServerId() ?? '';
|
const rawServerId = getPlaybackServerId() ?? '';
|
||||||
const server = useAuthStore.getState().servers.find(s => s.id === rawServerId);
|
const server = useAuthStore.getState().servers.find(s => s.id === rawServerId);
|
||||||
@@ -34,12 +34,12 @@ export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void {
|
|||||||
keepTrackIds.push(tid);
|
keepTrackIds.push(tid);
|
||||||
};
|
};
|
||||||
pushId(currentTrack?.id);
|
pushId(currentTrack?.id);
|
||||||
for (const track of queue) {
|
for (const ref of queueItems) {
|
||||||
pushId(track.id);
|
pushId(ref.trackId);
|
||||||
if (keepTrackIds.length >= 1000) break;
|
if (keepTrackIds.length >= 1000) break;
|
||||||
}
|
}
|
||||||
const middleTrackIds = collectPlaybackMiddlePriorityTrackIds(
|
const middleTrackIds = collectPlaybackMiddlePriorityTrackIds(
|
||||||
queue,
|
queueItems,
|
||||||
queueIndex,
|
queueIndex,
|
||||||
currentTrack,
|
currentTrack,
|
||||||
preloadMode,
|
preloadMode,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { QueueItemRef, Track } from '../store/playerStoreTypes';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { HOT_CACHE_PROTECT_AFTER_CURRENT, type HotCacheEntry } from '../store/hotCacheStore';
|
import { HOT_CACHE_PROTECT_AFTER_CURRENT, type HotCacheEntry } from '../store/hotCacheStore';
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export function entryKey(serverId: string, trackId: string): string {
|
|||||||
|
|
||||||
/** Sum of on-disk bytes for eviction-protected slots (current + next — same span as `evictToFit`). */
|
/** Sum of on-disk bytes for eviction-protected slots (current + next — same span as `evictToFit`). */
|
||||||
export function sumCachedBytesInProtectedWindow(
|
export function sumCachedBytesInProtectedWindow(
|
||||||
queue: Track[],
|
queue: QueueItemRef[],
|
||||||
queueIndex: number,
|
queueIndex: number,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
entries: Record<string, HotCacheEntry>,
|
entries: Record<string, HotCacheEntry>,
|
||||||
@@ -32,7 +32,7 @@ export function sumCachedBytesInProtectedWindow(
|
|||||||
const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT);
|
const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT);
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (let i = protectLo; i <= protectHi; i++) {
|
for (let i = protectLo; i <= protectHi; i++) {
|
||||||
const e = entries[entryKey(serverId, queue[i].id)];
|
const e = entries[entryKey(serverId, queue[i].trackId)];
|
||||||
if (e) sum += e.sizeBytes || 0;
|
if (e) sum += e.sizeBytes || 0;
|
||||||
}
|
}
|
||||||
return sum;
|
return sum;
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import {
|
|||||||
} from './engineState';
|
} from './engineState';
|
||||||
import { clearPreloadingIds } from './gaplessPreloadState';
|
import { clearPreloadingIds } from './gaplessPreloadState';
|
||||||
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
|
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
|
||||||
import type { PlayerState } from './playerStoreTypes';
|
import type { PlayerState, QueueItemRef } from './playerStoreTypes';
|
||||||
import { sameQueueTrackId, shallowCloneQueueTracks } from '../utils/playback/queueIdentity';
|
import { resolveQueueTrack } from '../utils/library/queueTrackView';
|
||||||
|
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
|
||||||
|
import { canonicalQueueServerKey } from '../utils/server/serverIndexKey';
|
||||||
|
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
|
||||||
import { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore';
|
import { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore';
|
||||||
import {
|
import {
|
||||||
setPendingQueueListScrollTop,
|
setPendingQueueListScrollTop,
|
||||||
@@ -54,7 +57,13 @@ export function applyQueueHistorySnapshot(
|
|||||||
if (prior.currentRadio) {
|
if (prior.currentRadio) {
|
||||||
stopRadio();
|
stopRadio();
|
||||||
}
|
}
|
||||||
let nextQueue = shallowCloneQueueTracks(snap.queue);
|
// Rebuild the display queue from the snapshot's thin refs (thin-state):
|
||||||
|
// resolver cache → placeholder. The canonical queue is the snapshot's refs;
|
||||||
|
// this resolved `nextQueue` is only for the engine restore / normalization /
|
||||||
|
// prepend logic below. The playing track is restored separately from the full
|
||||||
|
// `snap.currentTrack`.
|
||||||
|
let nextQueue = snap.queueItems.map(ref => resolveQueueTrack(ref));
|
||||||
|
let nextItems: QueueItemRef[] = [...snap.queueItems];
|
||||||
let nextIndex = snap.queueIndex;
|
let nextIndex = snap.queueIndex;
|
||||||
let nextTrack = snap.currentTrack ? { ...snap.currentTrack } : null;
|
let nextTrack = snap.currentTrack ? { ...snap.currentTrack } : null;
|
||||||
|
|
||||||
@@ -62,7 +71,23 @@ export function applyQueueHistorySnapshot(
|
|||||||
const playing = prior.currentTrack;
|
const playing = prior.currentTrack;
|
||||||
const pos = nextQueue.findIndex(t => sameQueueTrackId(t.id, playing.id));
|
const pos = nextQueue.findIndex(t => sameQueueTrackId(t.id, playing.id));
|
||||||
if (pos === -1) {
|
if (pos === -1) {
|
||||||
|
// Prepend ref must bind to the *snapshot's* playback server (H3): a live
|
||||||
|
// server switch racing the undo would otherwise stamp the prepended ref
|
||||||
|
// with the new server, mis-resolving the still-playing track. Snapshot
|
||||||
|
// fields take precedence; existing refs in the snapshot are the next
|
||||||
|
// fallback (they share the snapshot's server); live `queueServerId` is
|
||||||
|
// last resort. Canonical key everywhere (B1).
|
||||||
|
const snapshotSid =
|
||||||
|
snap.queueServerId
|
||||||
|
?? snap.queueItems[0]?.serverId
|
||||||
|
?? get().queueServerId
|
||||||
|
?? '';
|
||||||
|
const prependServerId = canonicalQueueServerKey(snapshotSid);
|
||||||
nextQueue = [{ ...playing }, ...nextQueue];
|
nextQueue = [{ ...playing }, ...nextQueue];
|
||||||
|
nextItems = [
|
||||||
|
{ serverId: prependServerId, trackId: playing.id },
|
||||||
|
...nextItems,
|
||||||
|
];
|
||||||
nextIndex = 0;
|
nextIndex = 0;
|
||||||
nextTrack = { ...playing };
|
nextTrack = { ...playing };
|
||||||
} else {
|
} else {
|
||||||
@@ -157,8 +182,19 @@ export function applyQueueHistorySnapshot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed the resolver with the playing track so its ref always resolves (it may
|
||||||
|
// have been prepended and not yet in the cache window). Same canonical key
|
||||||
|
// source as the prepend above — keeps cache bucket and ref serverId in lockstep
|
||||||
|
// even when a server switch races the undo.
|
||||||
|
const seedSid = canonicalQueueServerKey(
|
||||||
|
snap.queueServerId
|
||||||
|
?? snap.queueItems[0]?.serverId
|
||||||
|
?? get().queueServerId
|
||||||
|
?? '',
|
||||||
|
);
|
||||||
|
if (seedSid && nextTrack) seedQueueResolver(seedSid, [nextTrack]);
|
||||||
set({
|
set({
|
||||||
queue: nextQueue,
|
queueItems: nextItems,
|
||||||
queueIndex: nextIndex,
|
queueIndex: nextIndex,
|
||||||
currentTrack: nextTrack,
|
currentTrack: nextTrack,
|
||||||
currentRadio: null,
|
currentRadio: null,
|
||||||
@@ -174,7 +210,7 @@ export function applyQueueHistorySnapshot(
|
|||||||
if (!nextTrack) {
|
if (!nextTrack) {
|
||||||
invoke('audio_stop').catch(console.error);
|
invoke('audio_stop').catch(console.error);
|
||||||
setIsAudioPaused(false);
|
setIsAudioPaused(false);
|
||||||
syncQueueToServer(nextQueue, null, 0);
|
syncQueueToServer(nextItems, null, 0);
|
||||||
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
|
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
|
||||||
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
|
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
|
||||||
}
|
}
|
||||||
@@ -201,6 +237,6 @@ export function applyQueueHistorySnapshot(
|
|||||||
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
|
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
|
||||||
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
|
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
|
||||||
}
|
}
|
||||||
syncQueueToServer(nextQueue, nextTrack, tRestore);
|
syncQueueToServer(nextItems, nextTrack, tRestore);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { reportNowPlaying, scrobbleSong } from '../api/subsonicScrobble';
|
import { reportNowPlaying, scrobbleSong } from '../api/subsonicScrobble';
|
||||||
import type { Track } from './playerStoreTypes';
|
import type { Track } from './playerStoreTypes';
|
||||||
|
import { resolveQueueTrack } from '../utils/library/queueTrackView';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm';
|
import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm';
|
||||||
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
||||||
@@ -179,7 +180,7 @@ export function handleAudioProgress(
|
|||||||
if (store.isPlaying && !store.currentRadio) {
|
if (store.isPlaying && !store.currentRadio) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - getLastQueueHeartbeatAt() >= 15_000) {
|
if (now - getLastQueueHeartbeatAt() >= 15_000) {
|
||||||
void flushQueueSyncToServer(store.queue, track, displayTime);
|
void flushQueueSyncToServer(store.queueItems, track, displayTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,11 +247,18 @@ export function handleAudioProgress(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (shouldChainGapless || shouldBytePreload || shouldPreloadLocalFileAnalysis || gaplessEnabled) {
|
if (shouldChainGapless || shouldBytePreload || shouldPreloadLocalFileAnalysis || gaplessEnabled) {
|
||||||
const { queue, queueIndex, repeatMode } = store;
|
const { queueItems, queueIndex, repeatMode } = store;
|
||||||
const nextIdx = queueIndex + 1;
|
const nextIdx = queueIndex + 1;
|
||||||
|
// Next track for preload/chain. The resolver bridge keeps the window around
|
||||||
|
// queueIndex warm, so the next ref is cache-hot; resolveQueueTrack falls
|
||||||
|
// back to a placeholder (correct trackId, so URL building still works) on a
|
||||||
|
// cold miss. current track = `track` (full) — never resolved.
|
||||||
|
const nextRef = repeatMode === 'one'
|
||||||
|
? null
|
||||||
|
: (nextIdx < queueItems.length ? queueItems[nextIdx] : (repeatMode === 'all' ? queueItems[0] : null));
|
||||||
const nextTrack = repeatMode === 'one'
|
const nextTrack = repeatMode === 'one'
|
||||||
? track
|
? track
|
||||||
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
|
: (nextRef ? resolveQueueTrack(nextRef) : null);
|
||||||
if (!nextTrack || nextTrack.id === track.id) return;
|
if (!nextTrack || nextTrack.id === track.id) return;
|
||||||
|
|
||||||
// Gapless backup: keep next-track bytes ready even if chain/decode misses
|
// Gapless backup: keep next-track bytes ready even if chain/decode misses
|
||||||
@@ -311,10 +319,12 @@ export function handleAudioProgress(
|
|||||||
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
||||||
const authState = useAuthStore.getState();
|
const authState = useAuthStore.getState();
|
||||||
// Auto-mode neighbours for the *next* track: current track on its left,
|
// Auto-mode neighbours for the *next* track: current track on its left,
|
||||||
// queue[nextIdx+1] on its right.
|
// queueItems[nextIdx+1] on its right (resolved; placeholder on a cold miss
|
||||||
const nextNeighbour = nextIdx + 1 < queue.length
|
// — only its replaygain tags matter, which a placeholder lacks → fallback).
|
||||||
? queue[nextIdx + 1]
|
const nextNeighbourRef = nextIdx + 1 < queueItems.length
|
||||||
: (repeatMode === 'all' && queue.length > 0 ? queue[0] : null);
|
? queueItems[nextIdx + 1]
|
||||||
|
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
|
||||||
|
const nextNeighbour = nextNeighbourRef ? resolveQueueTrack(nextNeighbourRef) : null;
|
||||||
const replayGainDb = resolveReplayGainDb(
|
const replayGainDb = resolveReplayGainDb(
|
||||||
nextTrack, track, nextNeighbour,
|
nextTrack, track, nextNeighbour,
|
||||||
isReplayGainActive(), authState.replayGainMode,
|
isReplayGainActive(), authState.replayGainMode,
|
||||||
@@ -355,7 +365,7 @@ export function handleAudioEnded(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { repeatMode, currentTrack, queue, queueIndex } = usePlayerStore.getState();
|
const { repeatMode, currentTrack, queueIndex } = usePlayerStore.getState();
|
||||||
setIsAudioPaused(false);
|
setIsAudioPaused(false);
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
@@ -379,7 +389,8 @@ export function handleAudioEnded(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Pin to the current slot — the track may appear elsewhere in the queue.
|
// Pin to the current slot — the track may appear elsewhere in the queue.
|
||||||
usePlayerStore.getState().playTrack(currentTrack, queue, false, false, queueIndex);
|
// No-arg queue: playTrack keeps the canonical refs and just re-binds.
|
||||||
|
usePlayerStore.getState().playTrack(currentTrack, undefined, false, false, queueIndex);
|
||||||
} else {
|
} else {
|
||||||
usePlayerStore.getState().next(false);
|
usePlayerStore.getState().next(false);
|
||||||
}
|
}
|
||||||
@@ -401,7 +412,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
|
|||||||
if (store.currentTrack?.id) {
|
if (store.currentTrack?.id) {
|
||||||
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
|
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
|
||||||
}
|
}
|
||||||
const { queue, queueIndex, repeatMode } = store;
|
const { queueItems, queueIndex, repeatMode } = store;
|
||||||
const nextIdx = queueIndex + 1;
|
const nextIdx = queueIndex + 1;
|
||||||
let nextTrack: Track | null = null;
|
let nextTrack: Track | null = null;
|
||||||
let newIndex = queueIndex;
|
let newIndex = queueIndex;
|
||||||
@@ -409,11 +420,14 @@ export function handleAudioTrackSwitched(_duration: number): void {
|
|||||||
if (repeatMode === 'one' && store.currentTrack) {
|
if (repeatMode === 'one' && store.currentTrack) {
|
||||||
nextTrack = store.currentTrack;
|
nextTrack = store.currentTrack;
|
||||||
// queueIndex stays the same
|
// queueIndex stays the same
|
||||||
} else if (nextIdx < queue.length) {
|
} else if (nextIdx < queueItems.length) {
|
||||||
nextTrack = queue[nextIdx];
|
// The Rust engine already chained this source sample-accurately, so it must
|
||||||
|
// have been preloaded — meaning the resolver had it cached. resolveQueueTrack
|
||||||
|
// returns the full Track from cache (placeholder only on an unexpected miss).
|
||||||
|
nextTrack = resolveQueueTrack(queueItems[nextIdx]);
|
||||||
newIndex = nextIdx;
|
newIndex = nextIdx;
|
||||||
} else if (repeatMode === 'all' && queue.length > 0) {
|
} else if (repeatMode === 'all' && queueItems.length > 0) {
|
||||||
nextTrack = queue[0];
|
nextTrack = resolveQueueTrack(queueItems[0]);
|
||||||
newIndex = 0;
|
newIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,10 +439,20 @@ export function handleAudioTrackSwitched(_duration: number): void {
|
|||||||
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
||||||
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
|
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
|
||||||
|
|
||||||
|
// Neighbour window for normalization (replaygain album-mode reads prev/next).
|
||||||
|
// current track on the left, the track after `nextTrack` on the right.
|
||||||
|
const switchPrev = store.currentTrack;
|
||||||
|
const switchNextNextRef = newIndex + 1 < queueItems.length ? queueItems[newIndex + 1] : null;
|
||||||
|
const switchNeighbourWindow: Track[] = [
|
||||||
|
switchPrev ?? nextTrack,
|
||||||
|
nextTrack,
|
||||||
|
...(switchNextNextRef ? [resolveQueueTrack(switchNextNextRef)] : []),
|
||||||
|
];
|
||||||
|
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
currentTrack: nextTrack,
|
currentTrack: nextTrack,
|
||||||
waveformBins: null,
|
waveformBins: null,
|
||||||
...deriveNormalizationSnapshot(nextTrack, queue, newIndex),
|
...deriveNormalizationSnapshot(nextTrack, switchNeighbourWindow, 1),
|
||||||
normalizationDbgSource: 'track-switched',
|
normalizationDbgSource: 'track-switched',
|
||||||
normalizationDbgTrackId: nextTrack.id,
|
normalizationDbgTrackId: nextTrack.id,
|
||||||
queueIndex: newIndex,
|
queueIndex: newIndex,
|
||||||
@@ -463,7 +487,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
syncQueueToServer(queue, nextTrack, 0);
|
syncQueueToServer(queueItems, nextTrack, 0);
|
||||||
touchHotCacheOnPlayback(nextTrack.id, getPlaybackCacheServerKey());
|
touchHotCacheOnPlayback(nextTrack.id, getPlaybackCacheServerKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AuthState } from './authStoreTypes';
|
|||||||
import { generateId } from './authStoreHelpers';
|
import { generateId } from './authStoreHelpers';
|
||||||
import { usePlayerStore } from './playerStore';
|
import { usePlayerStore } from './playerStore';
|
||||||
import { clearQueueServerForPlayback } from '../utils/playback/playbackServer';
|
import { clearQueueServerForPlayback } from '../utils/playback/playbackServer';
|
||||||
|
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
|
||||||
|
|
||||||
type SetState = (
|
type SetState = (
|
||||||
partial: Partial<AuthState> | ((state: AuthState) => Partial<AuthState>),
|
partial: Partial<AuthState> | ((state: AuthState) => Partial<AuthState>),
|
||||||
@@ -40,7 +41,11 @@ export function createServerProfileActions(set: SetState): Pick<
|
|||||||
},
|
},
|
||||||
|
|
||||||
removeServer: (id) => {
|
removeServer: (id) => {
|
||||||
if (usePlayerStore.getState().queueServerId === id) {
|
// queueServerId is the canonical index key (B1); resolve the
|
||||||
|
// canonical id back to a server UUID before comparing so a profile
|
||||||
|
// delete still clears the matching queue binding.
|
||||||
|
const queueSid = usePlayerStore.getState().queueServerId;
|
||||||
|
if (queueSid && resolveServerIdForIndexKey(queueSid) === id) {
|
||||||
clearQueueServerForPlayback();
|
clearQueueServerForPlayback();
|
||||||
}
|
}
|
||||||
set(s => {
|
set(s => {
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ describe('removeServer', () => {
|
|||||||
const { a, b } = addThree();
|
const { a, b } = addThree();
|
||||||
useAuthStore.getState().setActiveServer(b);
|
useAuthStore.getState().setActiveServer(b);
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }],
|
queueItems: [{ serverId: a, trackId: 't1' }],
|
||||||
queueServerId: a,
|
queueServerId: a,
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* B1 regression cluster: queue thin-state server identity must be canonical
|
||||||
|
* everywhere. Writers emit the URL-derived index key (same model as the
|
||||||
|
* library index) so mixed-server queues with duplicate `trackId` across
|
||||||
|
* servers stay unambiguous on every path the review flagged:
|
||||||
|
*
|
||||||
|
* - resolver correctness (`seedQueueResolver`, `getCachedTrack`)
|
||||||
|
* - restore / hydrate (persist `merge`, `hydrateQueueFromIndex`)
|
||||||
|
* - undo / redo snapshots (`applyQueueHistorySnapshot` prepend, H3)
|
||||||
|
* - queue sync id emission (`savePlayQueue` trackIds only)
|
||||||
|
* - write helpers (`toQueueItemRefs`, `bindQueueServerForPlayback`)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { onInvoke } from '@/test/mocks/tauri';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { usePlayerStore } from '@/store/playerStore';
|
||||||
|
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||||
|
import { resetAuthStore, resetPlayerStore } from '@/test/helpers/storeReset';
|
||||||
|
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||||
|
import { bindQueueServerForPlayback } from '@/utils/playback/playbackServer';
|
||||||
|
import {
|
||||||
|
_resetQueueResolverForTest,
|
||||||
|
getCachedTrack,
|
||||||
|
seedQueueResolver,
|
||||||
|
} from '@/utils/library/queueTrackResolver';
|
||||||
|
import { applyQueueHistorySnapshot } from '@/store/applyQueueHistorySnapshot';
|
||||||
|
import {
|
||||||
|
pushQueueUndoSnapshot,
|
||||||
|
type QueueUndoSnapshot,
|
||||||
|
} from '@/store/queueUndo';
|
||||||
|
import type { PlayerState, QueueItemRef, Track } from '@/store/playerStoreTypes';
|
||||||
|
import { savePlayQueue } from '@/api/subsonicPlayQueue';
|
||||||
|
import { _resetQueueSyncForTest, flushPlayQueuePosition } from '@/store/queueSync';
|
||||||
|
|
||||||
|
vi.mock('@/api/subsonicPlayQueue', () => ({
|
||||||
|
savePlayQueue: vi.fn(async () => undefined),
|
||||||
|
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SERVER_A = {
|
||||||
|
id: 'uuid-a',
|
||||||
|
name: 'A',
|
||||||
|
url: 'http://a.test',
|
||||||
|
username: 'u',
|
||||||
|
password: 'p',
|
||||||
|
};
|
||||||
|
const SERVER_B = {
|
||||||
|
id: 'uuid-b',
|
||||||
|
name: 'B',
|
||||||
|
url: 'http://b.test',
|
||||||
|
username: 'u',
|
||||||
|
password: 'p',
|
||||||
|
};
|
||||||
|
const KEY_A = 'a.test';
|
||||||
|
const KEY_B = 'b.test';
|
||||||
|
|
||||||
|
function track(id: string, title: string): Track {
|
||||||
|
return { id, title, artist: '', album: 'A', albumId: 'AL', duration: 60 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMerge() {
|
||||||
|
type MergeFn = (
|
||||||
|
persisted: unknown,
|
||||||
|
current: ReturnType<typeof usePlayerStore.getState>,
|
||||||
|
) => ReturnType<typeof usePlayerStore.getState>;
|
||||||
|
return (usePlayerStore as unknown as {
|
||||||
|
persist: { getOptions(): { merge: MergeFn } };
|
||||||
|
}).persist.getOptions().merge;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAuthStore();
|
||||||
|
resetPlayerStore();
|
||||||
|
_resetQueueResolverForTest();
|
||||||
|
_resetQueueSyncForTest();
|
||||||
|
vi.mocked(savePlayQueue).mockClear();
|
||||||
|
useAuthStore.setState({
|
||||||
|
servers: [SERVER_A, SERVER_B],
|
||||||
|
activeServerId: SERVER_A.id,
|
||||||
|
isLoggedIn: true,
|
||||||
|
});
|
||||||
|
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Write helpers canonicalize ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('B1 — writers emit canonical server keys', () => {
|
||||||
|
it('toQueueItemRefs converts a UUID input to the canonical index key', () => {
|
||||||
|
const refs = toQueueItemRefs(SERVER_A.id, [track('t1', 'One')]);
|
||||||
|
expect(refs).toEqual([{ serverId: KEY_A, trackId: 't1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toQueueItemRefs is idempotent on an already-canonical input', () => {
|
||||||
|
const refs = toQueueItemRefs(KEY_B, [track('t1', 'One')]);
|
||||||
|
expect(refs[0].serverId).toBe(KEY_B);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toQueueItemRefs leaves unknown ids untouched (test isolation / pre-login flows)', () => {
|
||||||
|
const refs = toQueueItemRefs('unknown-srv', [track('t1', 'One')]);
|
||||||
|
expect(refs[0].serverId).toBe('unknown-srv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bindQueueServerForPlayback writes the canonical key for the active server', () => {
|
||||||
|
useAuthStore.setState({ activeServerId: SERVER_B.id });
|
||||||
|
bindQueueServerForPlayback();
|
||||||
|
expect(usePlayerStore.getState().queueServerId).toBe(KEY_B);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Resolver correctness: same trackId across two servers must NOT collide ─
|
||||||
|
|
||||||
|
describe('B1 — resolver isolates duplicate trackId across servers', () => {
|
||||||
|
it('seedQueueResolver canonicalizes the seed key — UUID and index key share one cache slot', () => {
|
||||||
|
const t = track('shared', 'Original');
|
||||||
|
seedQueueResolver(SERVER_A.id, [t]); // UUID input
|
||||||
|
// Read via the canonical ref (what writers now emit)
|
||||||
|
expect(getCachedTrack({ serverId: KEY_A, trackId: 'shared' })?.title).toBe('Original');
|
||||||
|
// …and via legacy UUID-bound refs (migration window compat path)
|
||||||
|
expect(getCachedTrack({ serverId: SERVER_A.id, trackId: 'shared' })?.title).toBe('Original');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two servers with the same trackId resolve independently — no cross-contamination', () => {
|
||||||
|
seedQueueResolver(SERVER_A.id, [track('shared', 'From A')]);
|
||||||
|
seedQueueResolver(SERVER_B.id, [track('shared', 'From B')]);
|
||||||
|
expect(getCachedTrack({ serverId: KEY_A, trackId: 'shared' })?.title).toBe('From A');
|
||||||
|
expect(getCachedTrack({ serverId: KEY_B, trackId: 'shared' })?.title).toBe('From B');
|
||||||
|
// Legacy-form refs map back to the same canonical entry per server.
|
||||||
|
expect(getCachedTrack({ serverId: SERVER_A.id, trackId: 'shared' })?.title).toBe('From A');
|
||||||
|
expect(getCachedTrack({ serverId: SERVER_B.id, trackId: 'shared' })?.title).toBe('From B');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Persistence merge: forward-migrate legacy UUID-form blobs ─────────────
|
||||||
|
|
||||||
|
describe('B1 — persist `merge` forward-migrates legacy UUID-form blobs', () => {
|
||||||
|
it('canonicalizes queueServerId and every ref `serverId` on rehydrate', () => {
|
||||||
|
const merged = getMerge()(
|
||||||
|
{
|
||||||
|
queueServerId: SERVER_A.id,
|
||||||
|
queueIndex: 1,
|
||||||
|
queueItems: [
|
||||||
|
{ serverId: SERVER_A.id, trackId: 't1' },
|
||||||
|
{ serverId: SERVER_A.id, trackId: 't2', radioAdded: true },
|
||||||
|
],
|
||||||
|
queueItemsIndex: 1,
|
||||||
|
},
|
||||||
|
usePlayerStore.getState(),
|
||||||
|
);
|
||||||
|
expect(merged.queueServerId).toBe(KEY_A);
|
||||||
|
expect(merged.queueItems).toEqual([
|
||||||
|
{ serverId: KEY_A, trackId: 't1' },
|
||||||
|
{ serverId: KEY_A, trackId: 't2', radioAdded: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonicalizes the legacy queueRefs-only shape', () => {
|
||||||
|
const merged = getMerge()(
|
||||||
|
{
|
||||||
|
queueServerId: SERVER_B.id,
|
||||||
|
queueRefs: ['x', 'y'],
|
||||||
|
queueRefsIndex: 1,
|
||||||
|
},
|
||||||
|
usePlayerStore.getState(),
|
||||||
|
);
|
||||||
|
expect(merged.queueServerId).toBe(KEY_B);
|
||||||
|
expect(merged.queueItems.every(r => r.serverId === KEY_B)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed-server queueItems get per-ref canonicalization (each ref carries its own key)', () => {
|
||||||
|
const merged = getMerge()(
|
||||||
|
{
|
||||||
|
queueServerId: SERVER_A.id,
|
||||||
|
queueItems: [
|
||||||
|
{ serverId: SERVER_A.id, trackId: 'shared' },
|
||||||
|
{ serverId: SERVER_B.id, trackId: 'shared' },
|
||||||
|
],
|
||||||
|
queueItemsIndex: 0,
|
||||||
|
},
|
||||||
|
usePlayerStore.getState(),
|
||||||
|
);
|
||||||
|
expect(merged.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'shared' });
|
||||||
|
expect(merged.queueItems[1]).toEqual({ serverId: KEY_B, trackId: 'shared' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Undo/redo snapshot: prepend uses snapshot-canonical server (H3) ────────
|
||||||
|
|
||||||
|
describe('B1 + H3 — undo prepend binds to the snapshot\'s playback server', () => {
|
||||||
|
it('prepended ref uses snap.queueServerId, not the live queue-level state', () => {
|
||||||
|
onInvoke('audio_play', () => undefined);
|
||||||
|
onInvoke('audio_seek', () => undefined);
|
||||||
|
onInvoke('audio_stop', () => undefined);
|
||||||
|
onInvoke('audio_get_state', () => ({ playing: false }));
|
||||||
|
|
||||||
|
// Live state: playback was just rebound to server B mid-undo.
|
||||||
|
const playingTrack = track('shared', 'Still playing');
|
||||||
|
const prior: PlayerState = {
|
||||||
|
...usePlayerStore.getState(),
|
||||||
|
currentTrack: playingTrack,
|
||||||
|
currentTime: 12,
|
||||||
|
progress: 0.2,
|
||||||
|
isPlaying: true,
|
||||||
|
queueItems: [{ serverId: KEY_B, trackId: 'shared' }],
|
||||||
|
queueServerId: KEY_B,
|
||||||
|
queueIndex: 0,
|
||||||
|
};
|
||||||
|
usePlayerStore.setState(prior);
|
||||||
|
|
||||||
|
// Snapshot was captured under server A — the prepend must follow A,
|
||||||
|
// not the live B binding.
|
||||||
|
const snap: QueueUndoSnapshot = {
|
||||||
|
queueItems: [],
|
||||||
|
queueIndex: 0,
|
||||||
|
currentTrack: null,
|
||||||
|
currentTime: 0,
|
||||||
|
progress: 0,
|
||||||
|
isPlaying: false,
|
||||||
|
queueServerId: KEY_A,
|
||||||
|
};
|
||||||
|
|
||||||
|
applyQueueHistorySnapshot(snap, prior, usePlayerStore.setState, usePlayerStore.getState);
|
||||||
|
|
||||||
|
const after = usePlayerStore.getState();
|
||||||
|
expect(after.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'shared' });
|
||||||
|
expect(after.queueIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to existing snapshot refs when queueServerId is absent (legacy in-memory snapshots)', () => {
|
||||||
|
onInvoke('audio_play', () => undefined);
|
||||||
|
onInvoke('audio_seek', () => undefined);
|
||||||
|
onInvoke('audio_stop', () => undefined);
|
||||||
|
onInvoke('audio_get_state', () => ({ playing: false }));
|
||||||
|
|
||||||
|
const playingTrack = track('p', 'Playing');
|
||||||
|
const prior: PlayerState = {
|
||||||
|
...usePlayerStore.getState(),
|
||||||
|
currentTrack: playingTrack,
|
||||||
|
isPlaying: true,
|
||||||
|
queueItems: [{ serverId: KEY_B, trackId: 'p' }],
|
||||||
|
queueServerId: KEY_B,
|
||||||
|
queueIndex: 0,
|
||||||
|
};
|
||||||
|
usePlayerStore.setState(prior);
|
||||||
|
|
||||||
|
const snap: QueueUndoSnapshot = {
|
||||||
|
queueItems: [{ serverId: KEY_A, trackId: 'other' }],
|
||||||
|
queueIndex: 0,
|
||||||
|
currentTrack: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
applyQueueHistorySnapshot(snap, prior, usePlayerStore.setState, usePlayerStore.getState);
|
||||||
|
|
||||||
|
// Prepend inherits server identity from the snapshot's first ref.
|
||||||
|
const after = usePlayerStore.getState();
|
||||||
|
expect(after.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'p' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snapshot from current state captures the canonical queueServerId', () => {
|
||||||
|
pushQueueUndoSnapshot({
|
||||||
|
queueItems: [{ serverId: KEY_A, trackId: 't1' }],
|
||||||
|
queueIndex: 0,
|
||||||
|
currentTrack: null,
|
||||||
|
queueServerId: KEY_A,
|
||||||
|
});
|
||||||
|
// Sanity: snapshots transport the canonical key forward through stacks.
|
||||||
|
// (The actual capture happens in queueUndoSnapshotFromState, which now
|
||||||
|
// includes queueServerId from PlayerState — see queueUndo.ts.)
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Queue sync emits trackIds only (server identity goes via playback API) ─
|
||||||
|
|
||||||
|
describe('B1 — queue sync emits track ids only, server identity flows out-of-band', () => {
|
||||||
|
it('savePlayQueue receives plain track ids regardless of ref server form', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const refs: QueueItemRef[] = [
|
||||||
|
{ serverId: KEY_A, trackId: 't1' },
|
||||||
|
{ serverId: KEY_A, trackId: 't2' },
|
||||||
|
];
|
||||||
|
usePlayerStore.setState({
|
||||||
|
queueItems: refs,
|
||||||
|
queueIndex: 0,
|
||||||
|
queueServerId: KEY_A,
|
||||||
|
currentTrack: track('t1', 'One'),
|
||||||
|
currentTime: 1.5,
|
||||||
|
isPlaying: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPlayQueuePosition();
|
||||||
|
|
||||||
|
expect(savePlayQueue).toHaveBeenCalledTimes(1);
|
||||||
|
const [ids, current, posMs, serverId] = vi.mocked(savePlayQueue).mock.calls[0]!;
|
||||||
|
expect(ids).toEqual(['t1', 't2']);
|
||||||
|
expect(current).toBe('t1');
|
||||||
|
expect(posMs).toBe(1500);
|
||||||
|
// savePlayQueue's serverId arg comes from getPlaybackServerId(), which
|
||||||
|
// resolves a canonical key OR a UUID back to a UUID — needed for the
|
||||||
|
// Subsonic auth lookup. Either is OK here; what matters is no leakage
|
||||||
|
// of a per-ref serverId into the request body.
|
||||||
|
expect(typeof serverId).toBe('string');
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Track } from './playerStoreTypes';
|
import type { QueueItemRef } from './playerStoreTypes';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
@@ -32,9 +32,10 @@ interface HotCacheState {
|
|||||||
touchPlayed: (trackId: string, serverId: string) => void;
|
touchPlayed: (trackId: string, serverId: string) => void;
|
||||||
removeEntry: (trackId: string, serverId: string) => void;
|
removeEntry: (trackId: string, serverId: string) => void;
|
||||||
totalBytes: () => number;
|
totalBytes: () => number;
|
||||||
/** Evict until total size ≤ maxBytes. Protects current + next (+ grace for last «previous» track). */
|
/** Evict until total size ≤ maxBytes. Protects current + next (+ grace for last «previous» track).
|
||||||
|
* Thin-state: only track ids / positions matter here, so it takes the canonical refs. */
|
||||||
evictToFit: (
|
evictToFit: (
|
||||||
queue: Track[],
|
queue: QueueItemRef[],
|
||||||
queueIndex: number,
|
queueIndex: number,
|
||||||
maxBytes: number,
|
maxBytes: number,
|
||||||
activeServerId: string,
|
activeServerId: string,
|
||||||
@@ -149,11 +150,11 @@ export const useHotCacheStore = create<HotCacheState>()(
|
|||||||
const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT);
|
const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT);
|
||||||
const protectedIds = new Set<string>();
|
const protectedIds = new Set<string>();
|
||||||
for (let i = protectLo; i <= protectHi; i++) {
|
for (let i = protectLo; i <= protectHi; i++) {
|
||||||
protectedIds.add(queue[i].id);
|
protectedIds.add(queue[i].trackId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const indexOfInQueue = (trackId: string): number | null => {
|
const indexOfInQueue = (trackId: string): number | null => {
|
||||||
const idx = queue.findIndex(t => t.id === trackId);
|
const idx = queue.findIndex(r => r.trackId === trackId);
|
||||||
return idx >= 0 ? idx : null;
|
return idx >= 0 ? idx : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* = current track + next `LOUDNESS_BACKFILL_WINDOW_AHEAD` entries, with
|
* = current track + next `LOUDNESS_BACKFILL_WINDOW_AHEAD` entries, with
|
||||||
* duplicates collapsed.
|
* duplicates collapsed.
|
||||||
*/
|
*/
|
||||||
import type { Track } from './playerStoreTypes';
|
import type { QueueItemRef, Track } from './playerStoreTypes';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
LOUDNESS_BACKFILL_WINDOW_AHEAD,
|
LOUDNESS_BACKFILL_WINDOW_AHEAD,
|
||||||
@@ -16,7 +16,12 @@ function track(id: string): Track {
|
|||||||
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
|
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const big = Array.from({ length: 12 }, (_, i) => track(`t${i}`));
|
// Thin-state: the window functions take queue refs; the currentTrack arg stays a Track.
|
||||||
|
function ref(id: string): QueueItemRef {
|
||||||
|
return { serverId: 's', trackId: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const big = Array.from({ length: 12 }, (_, i) => ref(`t${i}`));
|
||||||
|
|
||||||
describe('LOUDNESS_BACKFILL_WINDOW_AHEAD', () => {
|
describe('LOUDNESS_BACKFILL_WINDOW_AHEAD', () => {
|
||||||
it('is the value the runtime expects', () => {
|
it('is the value the runtime expects', () => {
|
||||||
@@ -26,23 +31,23 @@ describe('LOUDNESS_BACKFILL_WINDOW_AHEAD', () => {
|
|||||||
|
|
||||||
describe('isTrackInsideLoudnessBackfillWindow', () => {
|
describe('isTrackInsideLoudnessBackfillWindow', () => {
|
||||||
it('matches the current track unconditionally', () => {
|
it('matches the current track unconditionally', () => {
|
||||||
expect(isTrackInsideLoudnessBackfillWindow('t0', big, 0, big[0])).toBe(true);
|
expect(isTrackInsideLoudnessBackfillWindow('t0', big, 0, track('t0'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches an id inside the ahead window', () => {
|
it('matches an id inside the ahead window', () => {
|
||||||
// queueIndex 0, AHEAD 5 → indices 1..5 are inside, t3 must hit.
|
// queueIndex 0, AHEAD 5 → indices 1..5 are inside, t3 must hit.
|
||||||
expect(isTrackInsideLoudnessBackfillWindow('t3', big, 0, big[0])).toBe(true);
|
expect(isTrackInsideLoudnessBackfillWindow('t3', big, 0, track('t0'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false for an id beyond the ahead window', () => {
|
it('returns false for an id beyond the ahead window', () => {
|
||||||
// From queueIndex 0, indices 1..5 inside → t6 (index 6) is outside.
|
// From queueIndex 0, indices 1..5 inside → t6 (index 6) is outside.
|
||||||
expect(isTrackInsideLoudnessBackfillWindow('t6', big, 0, big[0])).toBe(false);
|
expect(isTrackInsideLoudnessBackfillWindow('t6', big, 0, track('t0'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('window slides with queueIndex', () => {
|
it('window slides with queueIndex', () => {
|
||||||
// queueIndex 4, AHEAD 5 → indices 5..9 are inside, t9 must hit, t10 must not.
|
// queueIndex 4, AHEAD 5 → indices 5..9 are inside, t9 must hit, t10 must not.
|
||||||
expect(isTrackInsideLoudnessBackfillWindow('t9', big, 4, big[4])).toBe(true);
|
expect(isTrackInsideLoudnessBackfillWindow('t9', big, 4, track('t4'))).toBe(true);
|
||||||
expect(isTrackInsideLoudnessBackfillWindow('t10', big, 4, big[4])).toBe(false);
|
expect(isTrackInsideLoudnessBackfillWindow('t10', big, 4, track('t4'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false for empty queue', () => {
|
it('returns false for empty queue', () => {
|
||||||
@@ -50,7 +55,7 @@ describe('isTrackInsideLoudnessBackfillWindow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns false for empty trackId', () => {
|
it('returns false for empty trackId', () => {
|
||||||
expect(isTrackInsideLoudnessBackfillWindow('', big, 0, big[0])).toBe(false);
|
expect(isTrackInsideLoudnessBackfillWindow('', big, 0, track('t0'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false when currentTrack is null and id is not in the queue window', () => {
|
it('returns false when currentTrack is null and id is not in the queue window', () => {
|
||||||
@@ -60,12 +65,12 @@ describe('isTrackInsideLoudnessBackfillWindow', () => {
|
|||||||
|
|
||||||
describe('collectLoudnessBackfillWindowTrackIds', () => {
|
describe('collectLoudnessBackfillWindowTrackIds', () => {
|
||||||
it('returns current + next 5 entries', () => {
|
it('returns current + next 5 entries', () => {
|
||||||
const ids = collectLoudnessBackfillWindowTrackIds(big, 0, big[0]);
|
const ids = collectLoudnessBackfillWindowTrackIds(big, 0, track('t0'));
|
||||||
expect(ids).toEqual(['t0', 't1', 't2', 't3', 't4', 't5']);
|
expect(ids).toEqual(['t0', 't1', 't2', 't3', 't4', 't5']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clamps the window to the end of the queue', () => {
|
it('clamps the window to the end of the queue', () => {
|
||||||
const ids = collectLoudnessBackfillWindowTrackIds(big, 9, big[9]);
|
const ids = collectLoudnessBackfillWindowTrackIds(big, 9, track('t9'));
|
||||||
// queueIndex 9, AHEAD 5 → indices 10..11 available → t9, t10, t11
|
// queueIndex 9, AHEAD 5 → indices 10..11 available → t9, t10, t11
|
||||||
expect(ids).toEqual(['t9', 't10', 't11']);
|
expect(ids).toEqual(['t9', 't10', 't11']);
|
||||||
});
|
});
|
||||||
@@ -76,8 +81,8 @@ describe('collectLoudnessBackfillWindowTrackIds', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('deduplicates when currentTrack is also in the ahead window', () => {
|
it('deduplicates when currentTrack is also in the ahead window', () => {
|
||||||
const queue = [track('a'), track('b'), track('a'), track('c')];
|
const queue = [ref('a'), ref('b'), ref('a'), ref('c')];
|
||||||
const ids = collectLoudnessBackfillWindowTrackIds(queue, 0, queue[0]);
|
const ids = collectLoudnessBackfillWindowTrackIds(queue, 0, track('a'));
|
||||||
expect(ids).toEqual(['a', 'b', 'c']);
|
expect(ids).toEqual(['a', 'b', 'c']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Track } from './playerStoreTypes';
|
import type { QueueItemRef, Track } from './playerStoreTypes';
|
||||||
/**
|
/**
|
||||||
* After a bulk enqueue (queue replace, append-many, lucky-mix) the runtime
|
* After a bulk enqueue (queue replace, append-many, lucky-mix) the runtime
|
||||||
* warms the loudness cache for the current track + the next N entries so
|
* warms the loudness cache for the current track + the next N entries so
|
||||||
@@ -15,7 +15,7 @@ export const LOUDNESS_BACKFILL_WINDOW_AHEAD = 5;
|
|||||||
|
|
||||||
export function isTrackInsideLoudnessBackfillWindow(
|
export function isTrackInsideLoudnessBackfillWindow(
|
||||||
trackId: string,
|
trackId: string,
|
||||||
queue: Track[],
|
queue: QueueItemRef[],
|
||||||
queueIndex: number,
|
queueIndex: number,
|
||||||
currentTrack: Track | null,
|
currentTrack: Track | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -25,13 +25,13 @@ export function isTrackInsideLoudnessBackfillWindow(
|
|||||||
const start = Math.max(0, queueIndex + 1);
|
const start = Math.max(0, queueIndex + 1);
|
||||||
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
|
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
|
||||||
for (let i = start; i < end; i++) {
|
for (let i = start; i < end; i++) {
|
||||||
if (queue[i]?.id === trackId) return true;
|
if (queue[i]?.trackId === trackId) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectLoudnessBackfillWindowTrackIds(
|
export function collectLoudnessBackfillWindowTrackIds(
|
||||||
queue: Track[],
|
queue: QueueItemRef[],
|
||||||
queueIndex: number,
|
queueIndex: number,
|
||||||
currentTrack: Track | null,
|
currentTrack: Track | null,
|
||||||
): string[] {
|
): string[] {
|
||||||
@@ -40,7 +40,7 @@ export function collectLoudnessBackfillWindowTrackIds(
|
|||||||
const start = Math.max(0, queueIndex + 1);
|
const start = Math.max(0, queueIndex + 1);
|
||||||
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
|
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
|
||||||
for (let i = start; i < end; i++) {
|
for (let i = start; i < end; i++) {
|
||||||
const tid = queue[i]?.id;
|
const tid = queue[i]?.trackId;
|
||||||
if (tid) ids.add(tid);
|
if (tid) ids.add(tid);
|
||||||
}
|
}
|
||||||
return Array.from(ids);
|
return Array.from(ids);
|
||||||
@@ -48,7 +48,7 @@ export function collectLoudnessBackfillWindowTrackIds(
|
|||||||
|
|
||||||
/** Next ~5 queue neighbours (+ immediate next when preload is on) for middle-tier analysis. */
|
/** Next ~5 queue neighbours (+ immediate next when preload is on) for middle-tier analysis. */
|
||||||
export function collectPlaybackMiddlePriorityTrackIds(
|
export function collectPlaybackMiddlePriorityTrackIds(
|
||||||
queue: Track[],
|
queue: QueueItemRef[],
|
||||||
queueIndex: number,
|
queueIndex: number,
|
||||||
currentTrack: Track | null,
|
currentTrack: Track | null,
|
||||||
preloadMode: 'off' | 'balanced' | 'early' | 'custom',
|
preloadMode: 'off' | 'balanced' | 'early' | 'custom',
|
||||||
@@ -57,13 +57,13 @@ export function collectPlaybackMiddlePriorityTrackIds(
|
|||||||
const start = Math.max(0, queueIndex + 1);
|
const start = Math.max(0, queueIndex + 1);
|
||||||
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
|
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
|
||||||
for (let i = start; i < end; i++) {
|
for (let i = start; i < end; i++) {
|
||||||
const tid = queue[i]?.id;
|
const tid = queue[i]?.trackId;
|
||||||
if (tid && tid !== currentTrack?.id) ids.add(tid);
|
if (tid && tid !== currentTrack?.id) ids.add(tid);
|
||||||
}
|
}
|
||||||
if (preloadMode !== 'off') {
|
if (preloadMode !== 'off') {
|
||||||
const nextIdx = queueIndex + 1;
|
const nextIdx = queueIndex + 1;
|
||||||
if (nextIdx >= 0 && nextIdx < queue.length) {
|
if (nextIdx >= 0 && nextIdx < queue.length) {
|
||||||
const tid = queue[nextIdx]?.id;
|
const tid = queue[nextIdx]?.trackId;
|
||||||
if (tid && tid !== currentTrack?.id) ids.add(tid);
|
if (tid && tid !== currentTrack?.id) ids.add(tid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ export function collectPlaybackMiddlePriorityTrackIds(
|
|||||||
|
|
||||||
export function loudnessBackfillPriorityForTrack(
|
export function loudnessBackfillPriorityForTrack(
|
||||||
trackId: string,
|
trackId: string,
|
||||||
queue: Track[],
|
queue: QueueItemRef[],
|
||||||
queueIndex: number,
|
queueIndex: number,
|
||||||
currentTrack: Track | null,
|
currentTrack: Track | null,
|
||||||
): 'high' | 'middle' | 'low' {
|
): 'high' | 'middle' | 'low' {
|
||||||
@@ -80,7 +80,7 @@ export function loudnessBackfillPriorityForTrack(
|
|||||||
const start = Math.max(0, queueIndex + 1);
|
const start = Math.max(0, queueIndex + 1);
|
||||||
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
|
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
|
||||||
for (let i = start; i < end; i++) {
|
for (let i = start; i < end; i++) {
|
||||||
if (queue[i]?.id === trackId) return 'middle';
|
if (queue[i]?.trackId === trackId) return 'middle';
|
||||||
}
|
}
|
||||||
return 'low';
|
return 'low';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* guard, the window collection, and the no-sync-engine flag on each
|
* guard, the window collection, and the no-sync-engine flag on each
|
||||||
* refresh call.
|
* refresh call.
|
||||||
*/
|
*/
|
||||||
import type { Track } from './playerStoreTypes';
|
import type { QueueItemRef, Track } from './playerStoreTypes';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
const hoisted = vi.hoisted(() => {
|
const hoisted = vi.hoisted(() => {
|
||||||
const auth = { normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness' };
|
const auth = { normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness' };
|
||||||
@@ -13,7 +13,7 @@ const hoisted = vi.hoisted(() => {
|
|||||||
auth,
|
auth,
|
||||||
player,
|
player,
|
||||||
refreshMock: vi.fn(async () => undefined),
|
refreshMock: vi.fn(async () => undefined),
|
||||||
collectMock: vi.fn((_q: Track[], _i: number, _c: Track | null): string[] => []),
|
collectMock: vi.fn((_q: QueueItemRef[], _i: number, _c: Track | null): string[] => []),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,6 +34,11 @@ function track(id: string): Track {
|
|||||||
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
|
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Thin-state: prefetchLoudnessForEnqueuedTracks takes queue refs now.
|
||||||
|
function ref(id: string): QueueItemRef {
|
||||||
|
return { serverId: 's', trackId: id };
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
hoisted.auth.normalizationEngine = 'loudness';
|
hoisted.auth.normalizationEngine = 'loudness';
|
||||||
hoisted.player.currentTrack = null;
|
hoisted.player.currentTrack = null;
|
||||||
@@ -46,14 +51,14 @@ describe('prefetchLoudnessForEnqueuedTracks', () => {
|
|||||||
it("is a no-op when engine isn't loudness", () => {
|
it("is a no-op when engine isn't loudness", () => {
|
||||||
hoisted.auth.normalizationEngine = 'off';
|
hoisted.auth.normalizationEngine = 'off';
|
||||||
hoisted.collectMock.mockReturnValueOnce(['t1']);
|
hoisted.collectMock.mockReturnValueOnce(['t1']);
|
||||||
prefetchLoudnessForEnqueuedTracks([track('t1')], 0);
|
prefetchLoudnessForEnqueuedTracks([ref('t1')], 0);
|
||||||
expect(hoisted.refreshMock).not.toHaveBeenCalled();
|
expect(hoisted.refreshMock).not.toHaveBeenCalled();
|
||||||
expect(hoisted.collectMock).not.toHaveBeenCalled();
|
expect(hoisted.collectMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forwards each window id to refreshLoudnessForTrack with syncPlayingEngine=false', () => {
|
it('forwards each window id to refreshLoudnessForTrack with syncPlayingEngine=false', () => {
|
||||||
hoisted.collectMock.mockReturnValueOnce(['t1', 't2', 't3']);
|
hoisted.collectMock.mockReturnValueOnce(['t1', 't2', 't3']);
|
||||||
prefetchLoudnessForEnqueuedTracks([track('t1'), track('t2'), track('t3')], 0);
|
prefetchLoudnessForEnqueuedTracks([ref('t1'), ref('t2'), ref('t3')], 0);
|
||||||
expect(hoisted.refreshMock).toHaveBeenCalledTimes(3);
|
expect(hoisted.refreshMock).toHaveBeenCalledTimes(3);
|
||||||
expect(hoisted.refreshMock).toHaveBeenCalledWith('t1', { syncPlayingEngine: false });
|
expect(hoisted.refreshMock).toHaveBeenCalledWith('t1', { syncPlayingEngine: false });
|
||||||
expect(hoisted.refreshMock).toHaveBeenCalledWith('t2', { syncPlayingEngine: false });
|
expect(hoisted.refreshMock).toHaveBeenCalledWith('t2', { syncPlayingEngine: false });
|
||||||
@@ -62,7 +67,7 @@ describe('prefetchLoudnessForEnqueuedTracks', () => {
|
|||||||
|
|
||||||
it('passes the queue + currentTrack through to the window collector', () => {
|
it('passes the queue + currentTrack through to the window collector', () => {
|
||||||
hoisted.player.currentTrack = track('cur');
|
hoisted.player.currentTrack = track('cur');
|
||||||
const q = [track('cur'), track('next')];
|
const q = [ref('cur'), ref('next')];
|
||||||
prefetchLoudnessForEnqueuedTracks(q, 0);
|
prefetchLoudnessForEnqueuedTracks(q, 0);
|
||||||
expect(hoisted.collectMock).toHaveBeenCalledWith(q, 0, hoisted.player.currentTrack);
|
expect(hoisted.collectMock).toHaveBeenCalledWith(q, 0, hoisted.player.currentTrack);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Track } from './playerStoreTypes';
|
import type { QueueItemRef } from './playerStoreTypes';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { collectLoudnessBackfillWindowTrackIds } from './loudnessBackfillWindow';
|
import { collectLoudnessBackfillWindowTrackIds } from './loudnessBackfillWindow';
|
||||||
import { refreshLoudnessForTrack } from './loudnessRefresh';
|
import { refreshLoudnessForTrack } from './loudnessRefresh';
|
||||||
@@ -15,7 +15,7 @@ import { usePlayerStore } from './playerStore';
|
|||||||
* the upcoming ones.
|
* the upcoming ones.
|
||||||
*/
|
*/
|
||||||
export function prefetchLoudnessForEnqueuedTracks(
|
export function prefetchLoudnessForEnqueuedTracks(
|
||||||
mergedQueue: Track[],
|
mergedQueue: QueueItemRef[],
|
||||||
queueIndex: number,
|
queueIndex: number,
|
||||||
): void {
|
): void {
|
||||||
if (useAuthStore.getState().normalizationEngine !== 'loudness') return;
|
if (useAuthStore.getState().normalizationEngine !== 'loudness') return;
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean):
|
|||||||
&& !isBackfillInFlight(trackId)
|
&& !isBackfillInFlight(trackId)
|
||||||
&& attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
|
&& attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
|
||||||
const live = usePlayerStore.getState();
|
const live = usePlayerStore.getState();
|
||||||
if (!isTrackInsideLoudnessBackfillWindow(trackId, live.queue, live.queueIndex, live.currentTrack)) {
|
if (!isTrackInsideLoudnessBackfillWindow(trackId, live.queueItems, live.queueIndex, live.currentTrack)) {
|
||||||
emitNormalizationDebug('backfill:skipped-outside-window', {
|
emitNormalizationDebug('backfill:skipped-outside-window', {
|
||||||
trackId,
|
trackId,
|
||||||
queueIndex: live.queueIndex,
|
queueIndex: live.queueIndex,
|
||||||
@@ -103,7 +103,7 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean):
|
|||||||
const url = buildStreamUrl(trackId);
|
const url = buildStreamUrl(trackId);
|
||||||
const priority = loudnessBackfillPriorityForTrack(
|
const priority = loudnessBackfillPriorityForTrack(
|
||||||
trackId,
|
trackId,
|
||||||
live.queue,
|
live.queueItems,
|
||||||
live.queueIndex,
|
live.queueIndex,
|
||||||
live.currentTrack,
|
live.currentTrack,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import { reseedLoudnessForTrackId } from './loudnessReseed';
|
|||||||
import { getPlaybackProgressSnapshot } from './playbackProgress';
|
import { getPlaybackProgressSnapshot } from './playbackProgress';
|
||||||
import { shouldRebindPlaybackToHotCache } from './playbackUrlRouting';
|
import { shouldRebindPlaybackToHotCache } from './playbackUrlRouting';
|
||||||
import type { PlayerState, Track } from './playerStoreTypes';
|
import type { PlayerState, Track } from './playerStoreTypes';
|
||||||
|
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
||||||
|
import { resolveQueueTrack } from '../utils/library/queueTrackView';
|
||||||
|
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
|
||||||
import { pushQueueUndoFromGetter } from './queueUndo';
|
import { pushQueueUndoFromGetter } from './queueUndo';
|
||||||
import { syncQueueToServer } from './queueSync';
|
import { syncQueueToServer } from './queueSync';
|
||||||
import {
|
import {
|
||||||
@@ -95,7 +98,7 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
|
|||||||
normalizationTargetLufs: null,
|
normalizationTargetLufs: null,
|
||||||
normalizationEngineLive: 'off',
|
normalizationEngineLive: 'off',
|
||||||
currentPlaybackSource: null,
|
currentPlaybackSource: null,
|
||||||
queue: [],
|
queueItems: [],
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
isPlaying: true,
|
isPlaying: true,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
@@ -106,7 +109,7 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
|
|||||||
},
|
},
|
||||||
|
|
||||||
previous: () => {
|
previous: () => {
|
||||||
const { queue, queueIndex, currentTrack } = get();
|
const { queueItems, queueIndex, currentTrack } = get();
|
||||||
const currentTime = getPlaybackProgressSnapshot().currentTime;
|
const currentTime = getPlaybackProgressSnapshot().currentTime;
|
||||||
if (currentTime > 3) {
|
if (currentTime > 3) {
|
||||||
// Restart current track from the beginning.
|
// Restart current track from the beginning.
|
||||||
@@ -114,7 +117,8 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
|
|||||||
const sid = authState.activeServerId ?? '';
|
const sid = authState.activeServerId ?? '';
|
||||||
if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) {
|
if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) {
|
||||||
setSeekFallbackVisualTarget({ trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() });
|
setSeekFallbackVisualTarget({ trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() });
|
||||||
get().playTrack(currentTrack, queue, true);
|
// No-arg queue: keep the canonical refs, restart in place.
|
||||||
|
get().playTrack(currentTrack, undefined, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
invoke('audio_seek', { seconds: 0 }).catch(console.error);
|
invoke('audio_seek', { seconds: 0 }).catch(console.error);
|
||||||
@@ -122,7 +126,11 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const prevIdx = queueIndex - 1;
|
const prevIdx = queueIndex - 1;
|
||||||
if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue, true, false, prevIdx);
|
if (prevIdx >= 0 && queueItems[prevIdx]) {
|
||||||
|
// Resolve the previous ref (resolver cache → placeholder); pass undefined
|
||||||
|
// for the queue arg so playTrack just moves the index.
|
||||||
|
get().playTrack(resolveQueueTrack(queueItems[prevIdx]), undefined, true, false, prevIdx);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setVolume: (v) => {
|
setVolume: (v) => {
|
||||||
@@ -155,8 +163,12 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
|
|||||||
// queue position, which may not flush before app close).
|
// queue position, which may not flush before app close).
|
||||||
const serverTime = q.position ? q.position / 1000 : 0;
|
const serverTime = q.position ? q.position / 1000 : 0;
|
||||||
const localTime = get().currentTime;
|
const localTime = get().currentTime;
|
||||||
|
const sid = get().queueServerId ?? useAuthStore.getState().activeServerId ?? '';
|
||||||
|
// Seed the resolver with the restored tracks so the queue UI / hot
|
||||||
|
// paths resolve them without a network round-trip.
|
||||||
|
if (sid) seedQueueResolver(sid, mappedTracks);
|
||||||
set({
|
set({
|
||||||
queue: mappedTracks,
|
queueItems: toQueueItemRefs(sid, mappedTracks),
|
||||||
queueIndex,
|
queueIndex,
|
||||||
currentTrack,
|
currentTrack,
|
||||||
currentTime: serverTime > 0 ? serverTime : localTime,
|
currentTime: serverTime > 0 ? serverTime : localTime,
|
||||||
@@ -185,12 +197,15 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
|
|||||||
}
|
}
|
||||||
pushQueueUndoFromGetter(get);
|
pushQueueUndoFromGetter(get);
|
||||||
const wasPlaying = s.isPlaying;
|
const wasPlaying = s.isPlaying;
|
||||||
|
const sid = s.queueServerId ?? '';
|
||||||
|
if (sid) seedQueueResolver(sid, [track]);
|
||||||
|
const newItems = toQueueItemRefs(sid, [track]);
|
||||||
set({
|
set({
|
||||||
queue: [track],
|
queueItems: newItems,
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
currentTrack: track,
|
currentTrack: track,
|
||||||
});
|
});
|
||||||
syncQueueToServer([track], track, s.currentTime);
|
syncQueueToServer(newItems, track, s.currentTime);
|
||||||
if (!wasPlaying) get().resume();
|
if (!wasPlaying) get().resume();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+71
-26
@@ -9,7 +9,10 @@ import {
|
|||||||
setInfiniteQueueFetching,
|
setInfiniteQueueFetching,
|
||||||
} from './infiniteQueueState';
|
} from './infiniteQueueState';
|
||||||
import { isInOrbitSession } from './orbitSession';
|
import { isInOrbitSession } from './orbitSession';
|
||||||
import type { PlayerState, Track } from './playerStoreTypes';
|
import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
|
||||||
|
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
||||||
|
import { resolveQueueTrack } from '../utils/library/queueTrackView';
|
||||||
|
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
|
||||||
import {
|
import {
|
||||||
addRadioSessionSeen,
|
addRadioSessionSeen,
|
||||||
getCurrentRadioArtistId,
|
getCurrentRadioArtistId,
|
||||||
@@ -24,6 +27,25 @@ type SetState = (
|
|||||||
) => void;
|
) => void;
|
||||||
type GetState = () => PlayerState;
|
type GetState = () => PlayerState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue-exhausted radio / infinite-queue refill: append the freshly fetched
|
||||||
|
* tracks to the canonical `queueItems`, seed the resolver with them (so they
|
||||||
|
* resolve without a network round-trip), then play the first appended track at
|
||||||
|
* its new tail index. Refs in / Track for the play call only — thin-state.
|
||||||
|
*/
|
||||||
|
function appendTracksAndPlayFirst(set: SetState, get: GetState, fresh: Track[]): void {
|
||||||
|
if (fresh.length === 0) return;
|
||||||
|
const state = get();
|
||||||
|
const serverId = state.queueServerId ?? '';
|
||||||
|
if (serverId) seedQueueResolver(serverId, fresh);
|
||||||
|
const incoming: QueueItemRef[] = toQueueItemRefs(serverId, fresh);
|
||||||
|
const playAt = state.queueItems.length;
|
||||||
|
// Append the refs first so playTrack (queue arg undefined) reads them off the
|
||||||
|
// canonical list and its targetQueueIndex validates against the new tail.
|
||||||
|
set({ queueItems: [...state.queueItems, ...incoming] });
|
||||||
|
get().playTrack(fresh[0], undefined, false, false, playAt);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advance to the next track. Three top-level outcomes:
|
* Advance to the next track. Three top-level outcomes:
|
||||||
*
|
*
|
||||||
@@ -44,11 +66,16 @@ type GetState = () => PlayerState;
|
|||||||
* sync to the host's next track.
|
* sync to the host's next track.
|
||||||
*/
|
*/
|
||||||
export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
||||||
const { queue, queueIndex, repeatMode, currentTrack } = get();
|
const { queueItems, queueIndex, repeatMode, currentTrack } = get();
|
||||||
applySkipStarOnManualNext(currentTrack, manual);
|
applySkipStarOnManualNext(currentTrack, manual);
|
||||||
const nextIdx = queueIndex + 1;
|
const nextIdx = queueIndex + 1;
|
||||||
if (nextIdx < queue.length) {
|
if (nextIdx < queueItems.length) {
|
||||||
get().playTrack(queue[nextIdx], queue, manual, false, nextIdx);
|
// Resolver bridge keeps the [queueIndex-50, +200] window warm, so the next
|
||||||
|
// ref is cache-hot here; resolveQueueTrack falls back to a placeholder
|
||||||
|
// (carrying the correct trackId) on a cold miss so playback still starts.
|
||||||
|
const nextRef = queueItems[nextIdx];
|
||||||
|
const nextTrack = resolveQueueTrack(nextRef);
|
||||||
|
get().playTrack(nextTrack, undefined, manual, false, nextIdx);
|
||||||
// Proactively top up auto-added tracks when ≤ 2 remain ahead,
|
// Proactively top up auto-added tracks when ≤ 2 remain ahead,
|
||||||
// so the queue never runs dry without a visible loading pause.
|
// so the queue never runs dry without a visible loading pause.
|
||||||
// Skipped while in Orbit — the host's queue is the source of
|
// Skipped while in Orbit — the host's queue is the source of
|
||||||
@@ -57,33 +84,48 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
|||||||
// the next track-end fallback.
|
// the next track-end fallback.
|
||||||
const { infiniteQueueEnabled } = useAuthStore.getState();
|
const { infiniteQueueEnabled } = useAuthStore.getState();
|
||||||
if (infiniteQueueEnabled && repeatMode === 'off' && !isInfiniteQueueFetching() && !isInOrbitSession()) {
|
if (infiniteQueueEnabled && repeatMode === 'off' && !isInfiniteQueueFetching() && !isInOrbitSession()) {
|
||||||
const remainingAuto = queue.slice(nextIdx + 1).filter(t => t.autoAdded).length;
|
const remainingAuto = queueItems.slice(nextIdx + 1).filter(r => r.autoAdded).length;
|
||||||
if (remainingAuto <= 2) {
|
if (remainingAuto <= 2) {
|
||||||
setInfiniteQueueFetching(true);
|
setInfiniteQueueFetching(true);
|
||||||
const existingIds = new Set(get().queue.map(t => t.id));
|
const existingIds = new Set(get().queueItems.map(r => r.trackId));
|
||||||
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
|
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
|
||||||
// Re-check at resolution time — the user may have joined
|
// Re-check at resolution time — the user may have joined
|
||||||
// an Orbit session between scheduling and resolving.
|
// an Orbit session between scheduling and resolving.
|
||||||
if (isInOrbitSession()) return;
|
if (isInOrbitSession()) return;
|
||||||
if (newTracks.length > 0) {
|
if (newTracks.length > 0) {
|
||||||
set(state => ({ queue: [...state.queue, ...newTracks] }));
|
set(state => {
|
||||||
|
const serverId = state.queueServerId ?? '';
|
||||||
|
if (serverId) seedQueueResolver(serverId, newTracks);
|
||||||
|
const newItems = [...state.queueItems, ...toQueueItemRefs(serverId, newTracks)];
|
||||||
|
return { queueItems: newItems };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}).catch(() => {}).finally(() => { setInfiniteQueueFetching(false); });
|
}).catch(() => {}).finally(() => { setInfiniteQueueFetching(false); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Proactively top up radio tracks when ≤ 2 remain — always, regardless
|
// Proactively top up radio tracks when ≤ 2 remain — always, regardless
|
||||||
// of infinite queue setting.
|
// of infinite queue setting.
|
||||||
const nextTrack = queue[nextIdx];
|
if (nextRef.radioAdded && !isRadioFetching()) {
|
||||||
if (nextTrack.radioAdded && !isRadioFetching()) {
|
const remainingRadio = queueItems.slice(nextIdx + 1).filter(r => r.radioAdded).length;
|
||||||
const remainingRadio = queue.slice(nextIdx + 1).filter(t => t.radioAdded).length;
|
|
||||||
if (remainingRadio <= 2) {
|
if (remainingRadio <= 2) {
|
||||||
const artistId = nextTrack.artistId ?? getCurrentRadioArtistId() ?? null;
|
// H2: nextTrack may be a placeholder if its ref is still cold — empty
|
||||||
const artistName = nextTrack.artist;
|
// artist/artistId would seed `getSimilarSongs2('')` and silently
|
||||||
if (artistId) {
|
// return nothing, leaving radio dry. Prefer the just-played
|
||||||
|
// currentTrack (always fully resolved in playerStore) and the stored
|
||||||
|
// radio seed artist; fall back to nextTrack metadata only when those
|
||||||
|
// are missing. Skip the top-up entirely when no stable seed exists
|
||||||
|
// rather than firing a non-deterministic empty request.
|
||||||
|
const seedArtistId =
|
||||||
|
currentTrack?.artistId
|
||||||
|
?? getCurrentRadioArtistId()
|
||||||
|
?? nextTrack.artistId
|
||||||
|
?? null;
|
||||||
|
const seedArtistName = currentTrack?.artist || nextTrack.artist;
|
||||||
|
if (seedArtistId && seedArtistName) {
|
||||||
setRadioFetching(true);
|
setRadioFetching(true);
|
||||||
Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)])
|
Promise.all([getSimilarSongs2(seedArtistId), getTopSongs(seedArtistName)])
|
||||||
.then(([similar, top]) => {
|
.then(([similar, top]) => {
|
||||||
const existingIds = new Set(get().queue.map(t => t.id));
|
const existingIds = new Set(get().queueItems.map(r => r.trackId));
|
||||||
// Lead with similar (other artists) for variety; top tracks
|
// Lead with similar (other artists) for variety; top tracks
|
||||||
// of the upcoming artist are only a fallback when similar
|
// of the upcoming artist are only a fallback when similar
|
||||||
// is empty. Single-pass loop dedupes against the live queue,
|
// is empty. Single-pass loop dedupes against the live queue,
|
||||||
@@ -105,9 +147,15 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
|||||||
// navigate backwards a few songs. Trimmed ids stay in the seen-set.
|
// navigate backwards a few songs. Trimmed ids stay in the seen-set.
|
||||||
const HISTORY_KEEP = 5;
|
const HISTORY_KEEP = 5;
|
||||||
set(state => {
|
set(state => {
|
||||||
|
const serverId = state.queueServerId ?? '';
|
||||||
|
if (serverId) seedQueueResolver(serverId, fresh);
|
||||||
const trimStart = Math.max(0, state.queueIndex - HISTORY_KEEP);
|
const trimStart = Math.max(0, state.queueIndex - HISTORY_KEEP);
|
||||||
|
const newItems = [
|
||||||
|
...state.queueItems.slice(trimStart),
|
||||||
|
...toQueueItemRefs(serverId, fresh),
|
||||||
|
];
|
||||||
return {
|
return {
|
||||||
queue: [...state.queue.slice(trimStart), ...fresh],
|
queueItems: newItems,
|
||||||
queueIndex: state.queueIndex - trimStart,
|
queueIndex: state.queueIndex - trimStart,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -118,8 +166,9 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (repeatMode === 'all' && queue.length > 0) {
|
} else if (repeatMode === 'all' && queueItems.length > 0) {
|
||||||
get().playTrack(queue[0], queue, manual, false, 0);
|
const firstTrack = resolveQueueTrack(queueItems[0]);
|
||||||
|
get().playTrack(firstTrack, undefined, manual, false, 0);
|
||||||
} else {
|
} else {
|
||||||
// ── Orbit short-circuit ──
|
// ── Orbit short-circuit ──
|
||||||
// The host owns the shared queue. The radio / infinite-queue
|
// The host owns the shared queue. The radio / infinite-queue
|
||||||
@@ -152,7 +201,7 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
|||||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const existingIds = new Set(get().queue.map(t => t.id));
|
const existingIds = new Set(get().queueItems.map(r => r.trackId));
|
||||||
// Same source preference + dedup contract as the proactive
|
// Same source preference + dedup contract as the proactive
|
||||||
// top-up: similar first, top only as a fallback (issue #500).
|
// top-up: similar first, top only as a fallback (issue #500).
|
||||||
const sourceList = similar.length > 0 ? similar : top;
|
const sourceList = similar.length > 0 ? similar : top;
|
||||||
@@ -165,9 +214,7 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
|||||||
fresh.push({ ...t, radioAdded: true as const });
|
fresh.push({ ...t, radioAdded: true as const });
|
||||||
}
|
}
|
||||||
if (fresh.length > 0) {
|
if (fresh.length > 0) {
|
||||||
const currentQueue = get().queue;
|
appendTracksAndPlayFirst(set, get, fresh);
|
||||||
const newQueue = [...currentQueue, ...fresh];
|
|
||||||
get().playTrack(fresh[0], newQueue, false, false, currentQueue.length);
|
|
||||||
} else {
|
} else {
|
||||||
invoke('audio_stop').catch(console.error);
|
invoke('audio_stop').catch(console.error);
|
||||||
setIsAudioPaused(false);
|
setIsAudioPaused(false);
|
||||||
@@ -187,7 +234,7 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
|||||||
if (infiniteQueueEnabled && repeatMode === 'off') {
|
if (infiniteQueueEnabled && repeatMode === 'off') {
|
||||||
if (isInfiniteQueueFetching()) return;
|
if (isInfiniteQueueFetching()) return;
|
||||||
setInfiniteQueueFetching(true);
|
setInfiniteQueueFetching(true);
|
||||||
const existingIds = new Set(get().queue.map(t => t.id));
|
const existingIds = new Set(get().queueItems.map(r => r.trackId));
|
||||||
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
|
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
|
||||||
setInfiniteQueueFetching(false);
|
setInfiniteQueueFetching(false);
|
||||||
// The user may have joined an Orbit session while this
|
// The user may have joined an Orbit session while this
|
||||||
@@ -204,9 +251,7 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
|||||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentQueue = get().queue;
|
appendTracksAndPlayFirst(set, get, newTracks);
|
||||||
const newQueue = [...currentQueue, ...newTracks];
|
|
||||||
get().playTrack(newTracks[0], newQueue, false);
|
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setInfiniteQueueFetching(false);
|
setInfiniteQueueFetching(false);
|
||||||
invoke('audio_stop').catch(console.error);
|
invoke('audio_stop').catch(console.error);
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ vi.mock('../api/subsonicStarRating', () => ({
|
|||||||
import { usePlayerStore } from './playerStore';
|
import { usePlayerStore } from './playerStore';
|
||||||
import type { Track } from './playerStoreTypes';
|
import type { Track } from './playerStoreTypes';
|
||||||
import { queueSongStar, queueSongRating, _resetPendingStarSyncForTest } from './pendingStarSync';
|
import { queueSongStar, queueSongRating, _resetPendingStarSyncForTest } from './pendingStarSync';
|
||||||
|
import {
|
||||||
|
getCachedTrack,
|
||||||
|
seedQueueResolver,
|
||||||
|
_resetQueueResolverForTest,
|
||||||
|
} from '../utils/library/queueTrackResolver';
|
||||||
|
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
||||||
|
|
||||||
const track = (id: string): Track => ({
|
const track = (id: string): Track => ({
|
||||||
id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1,
|
id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1,
|
||||||
@@ -24,9 +30,14 @@ describe('pendingStarSync', () => {
|
|||||||
unstarMock.mockReset().mockResolvedValue(undefined);
|
unstarMock.mockReset().mockResolvedValue(undefined);
|
||||||
setRatingMock.mockReset().mockResolvedValue(undefined);
|
setRatingMock.mockReset().mockResolvedValue(undefined);
|
||||||
_resetPendingStarSyncForTest();
|
_resetPendingStarSyncForTest();
|
||||||
|
_resetQueueResolverForTest();
|
||||||
|
// Thin-state: the queue's track copy lives in the resolver cache. Seed it so
|
||||||
|
// a star/rating success has a cached entry to patch in place.
|
||||||
|
seedQueueResolver('', [track('t1')]);
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
currentTrack: track('t1'),
|
currentTrack: track('t1'),
|
||||||
queue: [track('t1')],
|
queueItems: toQueueItemRefs('', [track('t1')]),
|
||||||
|
queueServerId: null,
|
||||||
starredOverrides: {},
|
starredOverrides: {},
|
||||||
userRatingOverrides: {},
|
userRatingOverrides: {},
|
||||||
});
|
});
|
||||||
@@ -46,7 +57,12 @@ describe('pendingStarSync', () => {
|
|||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
expect('t1' in s.starredOverrides).toBe(false); // cleared on success
|
expect('t1' in s.starredOverrides).toBe(false); // cleared on success
|
||||||
expect(s.currentTrack?.starred).toBeTruthy(); // in-memory track patched
|
expect(s.currentTrack?.starred).toBeTruthy(); // in-memory track patched
|
||||||
expect(s.queue[0].starred).toBeTruthy();
|
// Thin-state: the resolver cache entry is patched in place (not dropped) so
|
||||||
|
// the visible queue row keeps its title and reflects the synced star —
|
||||||
|
// dropping it would blank the row to a "…" placeholder.
|
||||||
|
const cached = getCachedTrack({ serverId: '', trackId: 't1' });
|
||||||
|
expect(cached?.title).toBe('t1');
|
||||||
|
expect(cached?.starred).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does NOT roll back on a network failure and keeps retrying', async () => {
|
it('does NOT roll back on a network failure and keeps retrying', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
||||||
import { usePlayerStore } from './playerStore';
|
import { usePlayerStore } from './playerStore';
|
||||||
|
import { patchCachedTrack } from '../utils/library/queueTrackResolver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* F4 — pending-sync for **song** star + rating (spec §6.5 / R7-18).
|
* F4 — pending-sync for **song** star + rating (spec §6.5 / R7-18).
|
||||||
@@ -89,21 +90,27 @@ function onStarSuccess(id: string, starred: boolean): void {
|
|||||||
delete next[id];
|
delete next[id];
|
||||||
return {
|
return {
|
||||||
starredOverrides: next,
|
starredOverrides: next,
|
||||||
queue: s.queue.map(t => (t.id === id ? { ...t, starred: starredVal } : t)),
|
|
||||||
currentTrack:
|
currentTrack:
|
||||||
s.currentTrack?.id === id ? { ...s.currentTrack, starred: starredVal } : s.currentTrack,
|
s.currentTrack?.id === id ? { ...s.currentTrack, starred: starredVal } : s.currentTrack,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
// Thin-state: the queue's copy lives in the resolver cache. Patch it in place
|
||||||
|
// to the synced value rather than dropping it — a dropped entry would blank the
|
||||||
|
// visible queue row to a "…" placeholder until the next window re-resolve.
|
||||||
|
patchCachedTrack(id, { starred: starredVal });
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRatingSuccess(id: string): void {
|
function onRatingSuccess(id: string): void {
|
||||||
// `setUserRatingOverride` already patched track.userRating; just drop the override.
|
const rating = usePlayerStore.getState().userRatingOverrides[id];
|
||||||
usePlayerStore.setState(s => {
|
usePlayerStore.setState(s => {
|
||||||
if (!(id in s.userRatingOverrides)) return {};
|
if (!(id in s.userRatingOverrides)) return {};
|
||||||
const next = { ...s.userRatingOverrides };
|
const next = { ...s.userRatingOverrides };
|
||||||
delete next[id];
|
delete next[id];
|
||||||
return { userRatingOverrides: next };
|
return { userRatingOverrides: next };
|
||||||
});
|
});
|
||||||
|
// Patch the cached queue track in place (see onStarSuccess) so the row keeps
|
||||||
|
// its title and shows the synced rating without flashing a placeholder.
|
||||||
|
if (rating !== undefined) patchCachedTrack(id, { userRating: rating });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Optimistically (un)star a song and sync it to the server with retry. */
|
/** Optimistically (un)star a song and sync it to the server with retry. */
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ import {
|
|||||||
recordEnginePlayUrl,
|
recordEnginePlayUrl,
|
||||||
} from './playbackUrlRouting';
|
} from './playbackUrlRouting';
|
||||||
import type { PlayerState, Track } from './playerStoreTypes';
|
import type { PlayerState, Track } from './playerStoreTypes';
|
||||||
|
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
||||||
|
import { getQueueTracksView, resolveQueueTrack } from '../utils/library/queueTrackView';
|
||||||
|
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
|
||||||
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
||||||
import { syncQueueToServer } from './queueSync';
|
import { syncQueueToServer } from './queueSync';
|
||||||
import { playListenSessionFinalize } from './playListenSession';
|
import { playListenSessionFinalize } from './playListenSession';
|
||||||
@@ -100,9 +103,9 @@ export function runPlayTrack(
|
|||||||
// to move the index; they are not bulk operations and must not
|
// to move the index; they are not bulk operations and must not
|
||||||
// trigger the confirm dialog (#234 regression).
|
// trigger the confirm dialog (#234 regression).
|
||||||
if (!_orbitConfirmed && queue && queue.length > 1) {
|
if (!_orbitConfirmed && queue && queue.length > 1) {
|
||||||
const current = get().queue;
|
const current = get().queueItems;
|
||||||
const sameAsCurrent = queue.length === current.length
|
const sameAsCurrent = queue.length === current.length
|
||||||
&& queue.every((t, i) => sameQueueTrackId(current[i]?.id, t.id));
|
&& queue.every((t, i) => sameQueueTrackId(current[i]?.trackId, t.id));
|
||||||
if (!sameAsCurrent) {
|
if (!sameAsCurrent) {
|
||||||
void orbitBulkGuard(queue.length).then(ok => {
|
void orbitBulkGuard(queue.length).then(ok => {
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -134,14 +137,17 @@ export function runPlayTrack(
|
|||||||
if (!_orbitConfirmed && queue && queue.length === 1) {
|
if (!_orbitConfirmed && queue && queue.length === 1) {
|
||||||
const orbitRole = useOrbitStore.getState().role;
|
const orbitRole = useOrbitStore.getState().role;
|
||||||
if (orbitRole === 'host') {
|
if (orbitRole === 'host') {
|
||||||
const current = get().queue;
|
const currentItems = get().queueItems;
|
||||||
const currentTrackId = current[get().queueIndex]?.id;
|
const currentTrackId = currentItems[get().queueIndex]?.trackId;
|
||||||
if (track.id !== currentTrackId) {
|
if (track.id !== currentTrackId) {
|
||||||
const existsAt = current.findIndex(t => sameQueueTrackId(t.id, track.id));
|
const existsAt = currentItems.findIndex(r => sameQueueTrackId(r.trackId, track.id));
|
||||||
if (existsAt >= 0) {
|
if (existsAt >= 0) {
|
||||||
get().playTrack(track, current, manual, true, existsAt);
|
// Re-jump within the existing queue: pass undefined so playTrack keeps
|
||||||
|
// the canonical queueItems and just moves the index.
|
||||||
|
get().playTrack(track, undefined, manual, true, existsAt);
|
||||||
} else {
|
} else {
|
||||||
const newQueue = [...current, track];
|
// Append the single track to the resolved current queue and jump to it.
|
||||||
|
const newQueue = [...getQueueTracksView(currentItems), track];
|
||||||
get().playTrack(track, newQueue, manual, true, newQueue.length - 1);
|
get().playTrack(track, newQueue, manual, true, newQueue.length - 1);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -182,22 +188,52 @@ export function runPlayTrack(
|
|||||||
if (visualOnEntry?.trackId !== track.id) {
|
if (visualOnEntry?.trackId !== track.id) {
|
||||||
setSeekFallbackVisualTarget(null);
|
setSeekFallbackVisualTarget(null);
|
||||||
}
|
}
|
||||||
const newQueue = queue ?? state.queue;
|
// Thin-state: only a real queue *replacement* (explicit `queue` arg) rebuilds
|
||||||
if (shouldBindQueueServerForPlay(state.queue, newQueue, queue)) {
|
// queueItems. A no-arg navigation (next/previous/queue-row jump) keeps the
|
||||||
|
// canonical refs and just moves the index — so we never resolve the whole
|
||||||
|
// queue here (O(visible), not O(queue length)), which would hitch + churn
|
||||||
|
// every subscriber on each track change at scale.
|
||||||
|
const replacing = queue !== undefined;
|
||||||
|
const srcLen = replacing ? queue.length : state.queueItems.length;
|
||||||
|
if (replacing && shouldBindQueueServerForPlay(state.queueItems, queue, queue)) {
|
||||||
bindQueueServerForPlayback();
|
bindQueueServerForPlayback();
|
||||||
}
|
}
|
||||||
// Prefer an explicit target index from the caller (next/previous/queue-row
|
// Prefer an explicit target index from the caller (next/previous/queue-row
|
||||||
// click already know the exact slot). `findIndex` returns the *first*
|
// click already know the exact slot). `findIndex` returns the *first*
|
||||||
// matching id, which jumps backwards when the queue contains the same
|
// matching id, which jumps backwards when the queue contains the same
|
||||||
// track twice — breaking radio playback (issue #500).
|
// track twice — breaking radio playback (issue #500).
|
||||||
|
const matchesAt = (i: number): boolean =>
|
||||||
|
replacing
|
||||||
|
? sameQueueTrackId(queue[i]?.id, track.id)
|
||||||
|
: sameQueueTrackId(state.queueItems[i]?.trackId, track.id);
|
||||||
const explicitIdxValid =
|
const explicitIdxValid =
|
||||||
typeof targetQueueIndex === 'number'
|
typeof targetQueueIndex === 'number'
|
||||||
&& targetQueueIndex >= 0
|
&& targetQueueIndex >= 0
|
||||||
&& targetQueueIndex < newQueue.length
|
&& targetQueueIndex < srcLen
|
||||||
&& sameQueueTrackId(newQueue[targetQueueIndex]?.id, track.id);
|
&& matchesAt(targetQueueIndex);
|
||||||
const idx = explicitIdxValid
|
const idx = explicitIdxValid
|
||||||
? (targetQueueIndex as number)
|
? (targetQueueIndex as number)
|
||||||
: newQueue.findIndex(t => sameQueueTrackId(t.id, track.id));
|
: replacing
|
||||||
|
? queue.findIndex(t => sameQueueTrackId(t.id, track.id))
|
||||||
|
: state.queueItems.findIndex(r => sameQueueTrackId(r.trackId, track.id));
|
||||||
|
const playIdx = idx >= 0 ? idx : 0;
|
||||||
|
// ±1 neighbours for replaygain normalization — resolve only these (not the
|
||||||
|
// whole queue). On replace they come from the provided Track[]; on navigation
|
||||||
|
// from the resolver cache (the bridge keeps that window warm).
|
||||||
|
const neighbourAt = (i: number): Track | null => {
|
||||||
|
if (i < 0 || i >= srcLen) return null;
|
||||||
|
if (replacing) return queue[i] ?? null;
|
||||||
|
if (i === playIdx) return track;
|
||||||
|
const ref = state.queueItems[i];
|
||||||
|
return ref ? resolveQueueTrack(ref) : null;
|
||||||
|
};
|
||||||
|
const prevNeighbour = neighbourAt(playIdx - 1);
|
||||||
|
const nextNeighbour = neighbourAt(playIdx + 1);
|
||||||
|
// Minimal window so deriveNormalizationSnapshot reads ±1 without a full array.
|
||||||
|
const normWindow: Track[] = prevNeighbour ? [prevNeighbour] : [];
|
||||||
|
const normIdx = normWindow.length;
|
||||||
|
normWindow.push(track);
|
||||||
|
if (nextNeighbour) normWindow.push(nextNeighbour);
|
||||||
if (manual) {
|
if (manual) {
|
||||||
pushQueueUndoFromGetter(get);
|
pushQueueUndoFromGetter(get);
|
||||||
}
|
}
|
||||||
@@ -248,12 +284,18 @@ export function runPlayTrack(
|
|||||||
|
|
||||||
// Set state immediately so the UI updates before the download completes.
|
// Set state immediately so the UI updates before the download completes.
|
||||||
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
|
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
|
||||||
|
const queueSid = get().queueServerId ?? '';
|
||||||
|
// When the caller replaced the queue (explicit `queue` arg), seed the
|
||||||
|
// resolver with those tracks so the UI / hot paths resolve them without a
|
||||||
|
// network round-trip. No-arg jumps reuse already-cached refs.
|
||||||
|
if (queue && queueSid) seedQueueResolver(queueSid, queue);
|
||||||
set({
|
set({
|
||||||
currentTrack: track,
|
currentTrack: track,
|
||||||
currentRadio: null,
|
currentRadio: null,
|
||||||
waveformBins: null,
|
waveformBins: null,
|
||||||
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0),
|
...deriveNormalizationSnapshot(track, normWindow, normIdx),
|
||||||
queue: newQueue,
|
// Only a replace rewrites the queue; navigation keeps the canonical refs.
|
||||||
|
...(replacing ? { queueItems: toQueueItemRefs(queueSid, queue) } : {}),
|
||||||
queueIndex: idx >= 0 ? idx : 0,
|
queueIndex: idx >= 0 ? idx : 0,
|
||||||
progress: initialProgress,
|
progress: initialProgress,
|
||||||
buffered: 0,
|
buffered: 0,
|
||||||
@@ -285,8 +327,6 @@ export function runPlayTrack(
|
|||||||
void refreshWaveformForTrack(track.id);
|
void refreshWaveformForTrack(track.id);
|
||||||
void refreshLoudnessForTrack(track.id);
|
void refreshLoudnessForTrack(track.id);
|
||||||
setDeferHotCachePrefetch(true);
|
setDeferHotCachePrefetch(true);
|
||||||
const playIdx = idx >= 0 ? idx : 0;
|
|
||||||
const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null;
|
|
||||||
const replayGainDb = resolveReplayGainDb(
|
const replayGainDb = resolveReplayGainDb(
|
||||||
track, prevTrack, nextNeighbour,
|
track, prevTrack, nextNeighbour,
|
||||||
isReplayGainActive(), authStateNow.replayGainMode,
|
isReplayGainActive(), authStateNow.replayGainMode,
|
||||||
@@ -356,7 +396,7 @@ export function runPlayTrack(
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
syncQueueToServer(newQueue, track, initialTime);
|
syncQueueToServer(get().queueItems, track, initialTime);
|
||||||
touchHotCacheOnPlayback(track.id, playbackCacheSid);
|
touchHotCacheOnPlayback(track.id, playbackCacheSid);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import {
|
|||||||
tauriMockListenerCount,
|
tauriMockListenerCount,
|
||||||
} from '@/test/mocks/tauri';
|
} from '@/test/mocks/tauri';
|
||||||
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
|
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
|
||||||
import { makeTrack, makeTracks } from '@/test/helpers/factories';
|
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
|
||||||
|
|
||||||
function stubPlaybackInvokes(): void {
|
function stubPlaybackInvokes(): void {
|
||||||
onInvoke('audio_play', () => undefined);
|
onInvoke('audio_play', () => undefined);
|
||||||
@@ -117,12 +117,8 @@ describe('audio:progress', () => {
|
|||||||
describe('audio:track_switched', () => {
|
describe('audio:track_switched', () => {
|
||||||
it('advances to queue[queueIndex + 1] under repeatMode=off', () => {
|
it('advances to queue[queueIndex + 1] under repeatMode=off', () => {
|
||||||
const queue = makeTracks(3);
|
const queue = makeTracks(3);
|
||||||
usePlayerStore.setState({
|
seedQueue(queue, { index: 0, currentTrack: queue[0] });
|
||||||
queue,
|
usePlayerStore.setState({ repeatMode: 'off' });
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: queue[0],
|
|
||||||
repeatMode: 'off',
|
|
||||||
});
|
|
||||||
emitTauriEvent('audio:track_switched', queue[1].duration);
|
emitTauriEvent('audio:track_switched', queue[1].duration);
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
expect(s.currentTrack?.id).toBe(queue[1].id);
|
expect(s.currentTrack?.id).toBe(queue[1].id);
|
||||||
@@ -135,12 +131,8 @@ describe('audio:track_switched', () => {
|
|||||||
|
|
||||||
it('replays the same track under repeatMode=one (queueIndex stays put)', () => {
|
it('replays the same track under repeatMode=one (queueIndex stays put)', () => {
|
||||||
const queue = makeTracks(3);
|
const queue = makeTracks(3);
|
||||||
usePlayerStore.setState({
|
seedQueue(queue, { index: 1, currentTrack: queue[1] });
|
||||||
queue,
|
usePlayerStore.setState({ repeatMode: 'one' });
|
||||||
queueIndex: 1,
|
|
||||||
currentTrack: queue[1],
|
|
||||||
repeatMode: 'one',
|
|
||||||
});
|
|
||||||
emitTauriEvent('audio:track_switched', queue[1].duration);
|
emitTauriEvent('audio:track_switched', queue[1].duration);
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
expect(s.currentTrack?.id).toBe(queue[1].id);
|
expect(s.currentTrack?.id).toBe(queue[1].id);
|
||||||
@@ -149,12 +141,8 @@ describe('audio:track_switched', () => {
|
|||||||
|
|
||||||
it('wraps to queue[0] when at the end with repeatMode=all', () => {
|
it('wraps to queue[0] when at the end with repeatMode=all', () => {
|
||||||
const queue = makeTracks(3);
|
const queue = makeTracks(3);
|
||||||
usePlayerStore.setState({
|
seedQueue(queue, { index: 2, currentTrack: queue[2] });
|
||||||
queue,
|
usePlayerStore.setState({ repeatMode: 'all' });
|
||||||
queueIndex: 2,
|
|
||||||
currentTrack: queue[2],
|
|
||||||
repeatMode: 'all',
|
|
||||||
});
|
|
||||||
emitTauriEvent('audio:track_switched', queue[0].duration);
|
emitTauriEvent('audio:track_switched', queue[0].duration);
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
expect(s.currentTrack?.id).toBe(queue[0].id);
|
expect(s.currentTrack?.id).toBe(queue[0].id);
|
||||||
@@ -163,12 +151,8 @@ describe('audio:track_switched', () => {
|
|||||||
|
|
||||||
it('is a no-op when at the end with repeatMode=off', () => {
|
it('is a no-op when at the end with repeatMode=off', () => {
|
||||||
const queue = makeTracks(2);
|
const queue = makeTracks(2);
|
||||||
usePlayerStore.setState({
|
seedQueue(queue, { index: 1, currentTrack: queue[1] });
|
||||||
queue,
|
usePlayerStore.setState({ repeatMode: 'off' });
|
||||||
queueIndex: 1,
|
|
||||||
currentTrack: queue[1],
|
|
||||||
repeatMode: 'off',
|
|
||||||
});
|
|
||||||
emitTauriEvent('audio:track_switched', queue[1].duration);
|
emitTauriEvent('audio:track_switched', queue[1].duration);
|
||||||
// No next candidate → handler returns early before state changes.
|
// No next candidate → handler returns early before state changes.
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
@@ -178,13 +162,8 @@ describe('audio:track_switched', () => {
|
|||||||
|
|
||||||
it('resets scrobbled + lastfmLoved flags so the new track can be rescored', () => {
|
it('resets scrobbled + lastfmLoved flags so the new track can be rescored', () => {
|
||||||
const queue = makeTracks(2);
|
const queue = makeTracks(2);
|
||||||
usePlayerStore.setState({
|
seedQueue(queue, { index: 0, currentTrack: queue[0] });
|
||||||
queue,
|
usePlayerStore.setState({ scrobbled: true, lastfmLoved: true });
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: queue[0],
|
|
||||||
scrobbled: true,
|
|
||||||
lastfmLoved: true,
|
|
||||||
});
|
|
||||||
emitTauriEvent('audio:track_switched', queue[1].duration);
|
emitTauriEvent('audio:track_switched', queue[1].duration);
|
||||||
expect(usePlayerStore.getState().scrobbled).toBe(false);
|
expect(usePlayerStore.getState().scrobbled).toBe(false);
|
||||||
expect(usePlayerStore.getState().lastfmLoved).toBe(false);
|
expect(usePlayerStore.getState().lastfmLoved).toBe(false);
|
||||||
@@ -201,10 +180,8 @@ describe('audio:ended', () => {
|
|||||||
|
|
||||||
it('immediately resets playback bookkeeping (before the 150 ms next() timer)', () => {
|
it('immediately resets playback bookkeeping (before the 150 ms next() timer)', () => {
|
||||||
const queue = makeTracks(2);
|
const queue = makeTracks(2);
|
||||||
|
seedQueue(queue, { index: 0, currentTrack: queue[0] });
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue,
|
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: queue[0],
|
|
||||||
isPlaying: true,
|
isPlaying: true,
|
||||||
progress: 0.99,
|
progress: 0.99,
|
||||||
currentTime: 178,
|
currentTime: 178,
|
||||||
@@ -222,10 +199,8 @@ describe('audio:ended', () => {
|
|||||||
|
|
||||||
it('clears state and currentRadio for a radio stream without advancing the queue', () => {
|
it('clears state and currentRadio for a radio stream without advancing the queue', () => {
|
||||||
const queue = makeTracks(2);
|
const queue = makeTracks(2);
|
||||||
|
seedQueue(queue, { index: 0, currentTrack: queue[0] });
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue,
|
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: queue[0],
|
|
||||||
currentRadio: { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/stream' },
|
currentRadio: { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/stream' },
|
||||||
isPlaying: true,
|
isPlaying: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import { usePlayerStore } from './playerStore';
|
|||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
|
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
|
||||||
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
|
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
|
||||||
import { makeTrack, makeTracks } from '@/test/helpers/factories';
|
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetPlayerStore();
|
resetPlayerStore();
|
||||||
@@ -209,10 +209,8 @@ describe('setProgress', () => {
|
|||||||
|
|
||||||
describe('stop', () => {
|
describe('stop', () => {
|
||||||
it('invokes audio_stop and clears playback state', () => {
|
it('invokes audio_stop and clears playback state', () => {
|
||||||
|
seedQueue(makeTracks(2), { index: 0, currentTrack: makeTrack() });
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue: makeTracks(2),
|
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: makeTrack(),
|
|
||||||
isPlaying: true,
|
isPlaying: true,
|
||||||
progress: 0.5,
|
progress: 0.5,
|
||||||
currentTime: 60,
|
currentTime: 60,
|
||||||
@@ -229,15 +227,15 @@ describe('stop', () => {
|
|||||||
describe('shuffleQueue', () => {
|
describe('shuffleQueue', () => {
|
||||||
it('is a no-op when the queue has fewer than 2 tracks', () => {
|
it('is a no-op when the queue has fewer than 2 tracks', () => {
|
||||||
const t = makeTrack({ id: 'only' });
|
const t = makeTrack({ id: 'only' });
|
||||||
usePlayerStore.setState({ queue: [t], queueIndex: 0, currentTrack: t });
|
seedQueue([t], { index: 0, currentTrack: t });
|
||||||
usePlayerStore.getState().shuffleQueue();
|
usePlayerStore.getState().shuffleQueue();
|
||||||
expect(usePlayerStore.getState().queue.map(q => q.id)).toEqual(['only']);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['only']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the current track at queueIndex 0 with the rest shuffled around it', () => {
|
it('keeps the current track at queueIndex 0 with the rest shuffled around it', () => {
|
||||||
const tracks = makeTracks(5, i => ({ id: `t-${i}` }));
|
const tracks = makeTracks(5, i => ({ id: `t-${i}` }));
|
||||||
const current = tracks[2];
|
const current = tracks[2];
|
||||||
usePlayerStore.setState({ queue: tracks, queueIndex: 2, currentTrack: current });
|
seedQueue(tracks, { index: 2, currentTrack: current });
|
||||||
|
|
||||||
// Pin the RNG so the shuffle is deterministic.
|
// Pin the RNG so the shuffle is deterministic.
|
||||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||||
@@ -245,25 +243,25 @@ describe('shuffleQueue', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
expect(s.queue[0].id).toBe(current.id);
|
expect(s.queueItems[0].trackId).toBe(current.id);
|
||||||
expect(s.queueIndex).toBe(0);
|
expect(s.queueIndex).toBe(0);
|
||||||
// The set of ids is preserved.
|
// The set of ids is preserved.
|
||||||
expect([...s.queue.map(t => t.id)].sort()).toEqual(['t-0', 't-1', 't-2', 't-3', 't-4'].sort());
|
expect([...s.queueItems.map(r => r.trackId)].sort()).toEqual(['t-0', 't-1', 't-2', 't-3', 't-4'].sort());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('shuffleUpcomingQueue', () => {
|
describe('shuffleUpcomingQueue', () => {
|
||||||
it('is a no-op when fewer than 2 upcoming tracks remain', () => {
|
it('is a no-op when fewer than 2 upcoming tracks remain', () => {
|
||||||
const tracks = makeTracks(3, i => ({ id: `t-${i}` }));
|
const tracks = makeTracks(3, i => ({ id: `t-${i}` }));
|
||||||
usePlayerStore.setState({ queue: tracks, queueIndex: 2, currentTrack: tracks[2] });
|
seedQueue(tracks, { index: 2, currentTrack: tracks[2] });
|
||||||
const beforeIds = tracks.map(t => t.id);
|
const beforeIds = tracks.map(t => t.id);
|
||||||
usePlayerStore.getState().shuffleUpcomingQueue();
|
usePlayerStore.getState().shuffleUpcomingQueue();
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(beforeIds);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(beforeIds);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the head + current in place and shuffles only the upcoming tail', () => {
|
it('keeps the head + current in place and shuffles only the upcoming tail', () => {
|
||||||
const tracks = makeTracks(5, i => ({ id: `t-${i}` }));
|
const tracks = makeTracks(5, i => ({ id: `t-${i}` }));
|
||||||
usePlayerStore.setState({ queue: tracks, queueIndex: 1, currentTrack: tracks[1] });
|
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
|
||||||
|
|
||||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||||
usePlayerStore.getState().shuffleUpcomingQueue();
|
usePlayerStore.getState().shuffleUpcomingQueue();
|
||||||
@@ -271,35 +269,35 @@ describe('shuffleUpcomingQueue', () => {
|
|||||||
|
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
// First two entries unchanged (head + current).
|
// First two entries unchanged (head + current).
|
||||||
expect(s.queue[0].id).toBe('t-0');
|
expect(s.queueItems[0].trackId).toBe('t-0');
|
||||||
expect(s.queue[1].id).toBe('t-1');
|
expect(s.queueItems[1].trackId).toBe('t-1');
|
||||||
// The tail still contains the same ids in some order.
|
// The tail still contains the same ids in some order.
|
||||||
expect([...s.queue.slice(2).map(t => t.id)].sort()).toEqual(['t-2', 't-3', 't-4'].sort());
|
expect([...s.queueItems.slice(2).map(r => r.trackId)].sort()).toEqual(['t-2', 't-3', 't-4'].sort());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('pruneUpcomingToCurrent', () => {
|
describe('pruneUpcomingToCurrent', () => {
|
||||||
it('drops everything after queueIndex', () => {
|
it('drops everything after queueIndex', () => {
|
||||||
const tracks = makeTracks(5);
|
const tracks = makeTracks(5);
|
||||||
usePlayerStore.setState({ queue: tracks, queueIndex: 1, currentTrack: tracks[1] });
|
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
|
||||||
usePlayerStore.getState().pruneUpcomingToCurrent();
|
usePlayerStore.getState().pruneUpcomingToCurrent();
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
expect(s.queue.map(t => t.id)).toEqual([tracks[0].id, tracks[1].id]);
|
expect(s.queueItems.map(r => r.trackId)).toEqual([tracks[0].id, tracks[1].id]);
|
||||||
expect(s.queueIndex).toBe(1);
|
expect(s.queueIndex).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears the queue entirely when there is no current track (orphaned queue → empty)', () => {
|
it('clears the queue entirely when there is no current track (orphaned queue → empty)', () => {
|
||||||
usePlayerStore.setState({ queue: makeTracks(3), queueIndex: 0, currentTrack: null });
|
seedQueue(makeTracks(3), { index: 0, currentTrack: null });
|
||||||
usePlayerStore.getState().pruneUpcomingToCurrent();
|
usePlayerStore.getState().pruneUpcomingToCurrent();
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
expect(s.queue).toEqual([]);
|
expect(s.queueItems).toEqual([]);
|
||||||
expect(s.queueIndex).toBe(0);
|
expect(s.queueIndex).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns early without clearing when no current track AND queue is already empty', () => {
|
it('returns early without clearing when no current track AND queue is already empty', () => {
|
||||||
usePlayerStore.setState({ queue: [], queueIndex: 0, currentTrack: null });
|
usePlayerStore.setState({ queueItems: [], queueIndex: 0, currentTrack: null });
|
||||||
usePlayerStore.getState().pruneUpcomingToCurrent();
|
usePlayerStore.getState().pruneUpcomingToCurrent();
|
||||||
expect(usePlayerStore.getState().queue).toEqual([]);
|
expect(usePlayerStore.getState().queueItems).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ vi.mock('@/api/lastfm', () => ({
|
|||||||
import { usePlayerStore } from './playerStore';
|
import { usePlayerStore } from './playerStore';
|
||||||
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
|
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
|
||||||
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
|
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
|
||||||
import { makeTrack, makeTracks } from '@/test/helpers/factories';
|
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
|
||||||
|
|
||||||
function stubInvokes(): void {
|
function stubInvokes(): void {
|
||||||
onInvoke('audio_play', () => undefined);
|
onInvoke('audio_play', () => undefined);
|
||||||
@@ -110,12 +110,8 @@ afterEach(() => {
|
|||||||
describe('flushPlayQueuePosition', () => {
|
describe('flushPlayQueuePosition', () => {
|
||||||
it('forwards the queue, current track, and millisecond position to savePlayQueue', async () => {
|
it('forwards the queue, current track, and millisecond position to savePlayQueue', async () => {
|
||||||
const [t1, t2, t3] = makeTracks(3);
|
const [t1, t2, t3] = makeTracks(3);
|
||||||
usePlayerStore.setState({
|
seedQueue([t1, t2, t3], { index: 1, currentTrack: t2 });
|
||||||
queue: [t1, t2, t3],
|
usePlayerStore.setState({ isPlaying: true });
|
||||||
queueIndex: 1,
|
|
||||||
currentTrack: t2,
|
|
||||||
isPlaying: true,
|
|
||||||
});
|
|
||||||
// Drive a live-progress snapshot so flushPlayQueuePosition has a non-zero
|
// Drive a live-progress snapshot so flushPlayQueuePosition has a non-zero
|
||||||
// position to flush — readonly snapshot is what the API call samples.
|
// position to flush — readonly snapshot is what the API call samples.
|
||||||
emitTauriEvent('audio:progress', { current_time: 12.345, duration: t2.duration });
|
emitTauriEvent('audio:progress', { current_time: 12.345, duration: t2.duration });
|
||||||
@@ -137,11 +133,7 @@ describe('flushPlayQueuePosition', () => {
|
|||||||
|
|
||||||
it('caps the song-id list at 1000 entries', async () => {
|
it('caps the song-id list at 1000 entries', async () => {
|
||||||
const tracks = makeTracks(1100);
|
const tracks = makeTracks(1100);
|
||||||
usePlayerStore.setState({
|
seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
|
||||||
queue: tracks,
|
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: tracks[0],
|
|
||||||
});
|
|
||||||
emitTauriEvent('audio:progress', { current_time: 1, duration: tracks[0].duration });
|
emitTauriEvent('audio:progress', { current_time: 1, duration: tracks[0].duration });
|
||||||
vi.mocked(savePlayQueue).mockClear(); // discard heartbeat call from emit
|
vi.mocked(savePlayQueue).mockClear(); // discard heartbeat call from emit
|
||||||
|
|
||||||
@@ -155,10 +147,8 @@ describe('flushPlayQueuePosition', () => {
|
|||||||
|
|
||||||
it('is a no-op when a radio stream is active', async () => {
|
it('is a no-op when a radio stream is active', async () => {
|
||||||
const track = makeTrack();
|
const track = makeTrack();
|
||||||
|
seedQueue([track], { index: 0, currentTrack: track });
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue: [track],
|
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: track,
|
|
||||||
currentRadio: { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/stream' },
|
currentRadio: { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/stream' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,11 +158,7 @@ describe('flushPlayQueuePosition', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('is a no-op when there is no current track', async () => {
|
it('is a no-op when there is no current track', async () => {
|
||||||
usePlayerStore.setState({
|
seedQueue(makeTracks(2), { index: 0, currentTrack: null });
|
||||||
queue: makeTracks(2),
|
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
await flushPlayQueuePosition();
|
await flushPlayQueuePosition();
|
||||||
|
|
||||||
@@ -181,7 +167,7 @@ describe('flushPlayQueuePosition', () => {
|
|||||||
|
|
||||||
it('is a no-op when the queue is empty', async () => {
|
it('is a no-op when the queue is empty', async () => {
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue: [],
|
queueItems: [],
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
currentTrack: null,
|
currentTrack: null,
|
||||||
});
|
});
|
||||||
@@ -193,7 +179,7 @@ describe('flushPlayQueuePosition', () => {
|
|||||||
|
|
||||||
it('swallows backend errors without propagating to the caller', async () => {
|
it('swallows backend errors without propagating to the caller', async () => {
|
||||||
const track = makeTrack();
|
const track = makeTrack();
|
||||||
usePlayerStore.setState({ queue: [track], queueIndex: 0, currentTrack: track });
|
seedQueue([track], { index: 0, currentTrack: track });
|
||||||
vi.mocked(savePlayQueue).mockRejectedValueOnce(new Error('offline'));
|
vi.mocked(savePlayQueue).mockRejectedValueOnce(new Error('offline'));
|
||||||
|
|
||||||
await expect(flushPlayQueuePosition()).resolves.toBeUndefined();
|
await expect(flushPlayQueuePosition()).resolves.toBeUndefined();
|
||||||
@@ -201,12 +187,8 @@ describe('flushPlayQueuePosition', () => {
|
|||||||
|
|
||||||
it('floors the position to whole milliseconds', async () => {
|
it('floors the position to whole milliseconds', async () => {
|
||||||
const track = makeTrack({ duration: 200 });
|
const track = makeTrack({ duration: 200 });
|
||||||
usePlayerStore.setState({
|
seedQueue([track], { index: 0, currentTrack: track });
|
||||||
queue: [track],
|
usePlayerStore.setState({ isPlaying: true });
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: track,
|
|
||||||
isPlaying: true,
|
|
||||||
});
|
|
||||||
emitTauriEvent('audio:progress', { current_time: 12.9999, duration: 200 });
|
emitTauriEvent('audio:progress', { current_time: 12.9999, duration: 200 });
|
||||||
vi.mocked(savePlayQueue).mockClear(); // discard heartbeat call from emit
|
vi.mocked(savePlayQueue).mockClear(); // discard heartbeat call from emit
|
||||||
|
|
||||||
@@ -218,7 +200,7 @@ describe('flushPlayQueuePosition', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// partialize: localStorage queue window (PR #756)
|
// partialize + merge: thin-state refs-only persistence
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function getPartialize() {
|
function getPartialize() {
|
||||||
@@ -228,82 +210,98 @@ function getPartialize() {
|
|||||||
.persist.getOptions().partialize;
|
.persist.getOptions().partialize;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('partialize: localStorage queue window', () => {
|
function getMerge() {
|
||||||
it('caps a large queue to at most 501 tracks and puts the current track at index 250', () => {
|
type MergeFn = (persisted: unknown, current: ReturnType<typeof usePlayerStore.getState>) => ReturnType<typeof usePlayerStore.getState>;
|
||||||
const tracks = makeTracks(10509);
|
return (usePlayerStore as unknown as { persist: { getOptions(): { merge: MergeFn } } })
|
||||||
usePlayerStore.setState({ queue: tracks, queueIndex: 5000 });
|
.persist.getOptions().merge;
|
||||||
|
}
|
||||||
|
|
||||||
const partial = getPartialize()(usePlayerStore.getState());
|
describe('partialize: thin queueItems (refs only)', () => {
|
||||||
|
it('persists the WHOLE queue as thin refs (no windowed fat `queue`)', () => {
|
||||||
expect((partial.queue as unknown[]).length).toBe(501);
|
|
||||||
expect(partial.queueIndex).toBe(250);
|
|
||||||
// the track at the remapped index must be the original track[5000]
|
|
||||||
expect((partial.queue as { id: string }[])[250].id).toBe(tracks[5000].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not shift the index when the current track is near the start (qi < 250)', () => {
|
|
||||||
const tracks = makeTracks(1000);
|
|
||||||
usePlayerStore.setState({ queue: tracks, queueIndex: 50 });
|
|
||||||
|
|
||||||
const partial = getPartialize()(usePlayerStore.getState());
|
|
||||||
|
|
||||||
// window starts at 0, index is unchanged
|
|
||||||
expect(partial.queueIndex).toBe(50);
|
|
||||||
expect((partial.queue as { id: string }[])[50].id).toBe(tracks[50].id);
|
|
||||||
// window extends 250 ahead: 0..300 = 301 tracks
|
|
||||||
expect((partial.queue as unknown[]).length).toBe(301);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles a current track near the end of the queue correctly', () => {
|
|
||||||
const tracks = makeTracks(10509);
|
|
||||||
const lastIdx = 10508;
|
|
||||||
usePlayerStore.setState({ queue: tracks, queueIndex: lastIdx });
|
|
||||||
|
|
||||||
const partial = getPartialize()(usePlayerStore.getState());
|
|
||||||
|
|
||||||
// window: [10258, 10509) = 251 tracks; remapped index = 10508 - 10258 = 250
|
|
||||||
expect((partial.queue as unknown[]).length).toBe(251);
|
|
||||||
expect(partial.queueIndex).toBe(250);
|
|
||||||
expect((partial.queue as { id: string }[])[250].id).toBe(tracks[lastIdx].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persists the entire queue unchanged when it is smaller than the window', () => {
|
|
||||||
const tracks = makeTracks(10);
|
|
||||||
usePlayerStore.setState({ queue: tracks, queueIndex: 5 });
|
|
||||||
|
|
||||||
const partial = getPartialize()(usePlayerStore.getState());
|
|
||||||
|
|
||||||
expect((partial.queue as unknown[]).length).toBe(10);
|
|
||||||
expect(partial.queueIndex).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles an empty queue without throwing', () => {
|
|
||||||
usePlayerStore.setState({ queue: [], queueIndex: 0 });
|
|
||||||
|
|
||||||
const partial = getPartialize()(usePlayerStore.getState());
|
|
||||||
|
|
||||||
expect((partial.queue as unknown[]).length).toBe(0);
|
|
||||||
expect(partial.queueIndex).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persists the whole queue as thin queueItems (refs + serverId + flags), not just the window', () => {
|
|
||||||
const tracks = makeTracks(600);
|
const tracks = makeTracks(600);
|
||||||
tracks[3].radioAdded = true;
|
tracks[3].radioAdded = true;
|
||||||
tracks[4].autoAdded = true;
|
tracks[4].autoAdded = true;
|
||||||
usePlayerStore.setState({ queue: tracks, queueIndex: 300, queueServerId: 's1' });
|
seedQueue(tracks, { index: 300, serverId: 's1', currentTrack: tracks[300] });
|
||||||
|
|
||||||
const partial = getPartialize()(usePlayerStore.getState());
|
const partial = getPartialize()(usePlayerStore.getState());
|
||||||
const items = partial.queueItems as {
|
const items = partial.queueItems as {
|
||||||
serverId: string; trackId: string; radioAdded?: boolean; autoAdded?: boolean;
|
serverId: string; trackId: string; radioAdded?: boolean; autoAdded?: boolean;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
// windowed `queue` is capped, but queueItems carries the WHOLE queue
|
// No fat `queue` key anymore.
|
||||||
expect((partial.queue as unknown[]).length).toBe(501);
|
expect(partial.queue).toBeUndefined();
|
||||||
|
// queueItems carries the WHOLE queue.
|
||||||
expect(items.length).toBe(600);
|
expect(items.length).toBe(600);
|
||||||
|
// queueItemsIndex is the restore-pending sentinel (= the live queueIndex).
|
||||||
expect(partial.queueItemsIndex).toBe(300);
|
expect(partial.queueItemsIndex).toBe(300);
|
||||||
expect(items[0].serverId).toBe('s1');
|
expect(items[0].serverId).toBe('s1');
|
||||||
expect(items[3].radioAdded).toBe(true);
|
expect(items[3].radioAdded).toBe(true);
|
||||||
expect(items[4].autoAdded).toBe(true);
|
expect(items[4].autoAdded).toBe(true);
|
||||||
expect(items[0].radioAdded).toBeUndefined();
|
expect(items[0].radioAdded).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles an empty queue without throwing', () => {
|
||||||
|
usePlayerStore.setState({ queueItems: [], queueIndex: 0 });
|
||||||
|
|
||||||
|
const partial = getPartialize()(usePlayerStore.getState());
|
||||||
|
|
||||||
|
expect((partial.queueItems as unknown[]).length).toBe(0);
|
||||||
|
expect(partial.queue).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('merge: restores the queue from any old persisted blob', () => {
|
||||||
|
const current = () => usePlayerStore.getState();
|
||||||
|
|
||||||
|
it('prefers an existing queueItems ref list + sets the sentinel', () => {
|
||||||
|
const merged = getMerge()(
|
||||||
|
{
|
||||||
|
queueServerId: 's1',
|
||||||
|
queueIndex: 2,
|
||||||
|
queueItems: [
|
||||||
|
{ serverId: 's1', trackId: 'a' },
|
||||||
|
{ serverId: 's1', trackId: 'b' },
|
||||||
|
],
|
||||||
|
queueItemsIndex: 1,
|
||||||
|
},
|
||||||
|
current(),
|
||||||
|
);
|
||||||
|
expect(merged.queueItems.map(r => r.trackId)).toEqual(['a', 'b']);
|
||||||
|
expect(merged.queueItemsIndex).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds queueItems from a legacy queueRefs string list', () => {
|
||||||
|
const merged = getMerge()(
|
||||||
|
{ queueServerId: 's2', queueRefs: ['x', 'y'], queueRefsIndex: 1 },
|
||||||
|
current(),
|
||||||
|
);
|
||||||
|
expect(merged.queueItems).toEqual([
|
||||||
|
{ serverId: 's2', trackId: 'x' },
|
||||||
|
{ serverId: 's2', trackId: 'y' },
|
||||||
|
]);
|
||||||
|
expect(merged.queueItemsIndex).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds queueItems from an old windowed fat `queue: Track[]` blob and drops the `queue` key', () => {
|
||||||
|
const blob: Record<string, unknown> = {
|
||||||
|
queueServerId: 's3',
|
||||||
|
queueIndex: 1,
|
||||||
|
queue: [makeTrack({ id: 'q0' }), makeTrack({ id: 'q1', radioAdded: true })],
|
||||||
|
};
|
||||||
|
const merged = getMerge()(blob, current());
|
||||||
|
expect(merged.queueItems).toEqual([
|
||||||
|
{ serverId: 's3', trackId: 'q0' },
|
||||||
|
{ serverId: 's3', trackId: 'q1', radioAdded: true },
|
||||||
|
]);
|
||||||
|
// The windowed fat-array key is deleted from the persisted blob.
|
||||||
|
expect('queue' in blob).toBe(false);
|
||||||
|
// Sentinel falls back to the persisted queueIndex when no explicit index.
|
||||||
|
expect(merged.queueItemsIndex).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an empty queue alone (no sentinel) when the blob has nothing to restore', () => {
|
||||||
|
const merged = getMerge()({ queueServerId: null }, current());
|
||||||
|
expect(merged.queueItems).toEqual([]);
|
||||||
|
expect(merged.queueItemsIndex).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import { usePlayerStore } from './playerStore';
|
|||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
|
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
|
||||||
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
|
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
|
||||||
import { makeTrack, makeTracks } from '@/test/helpers/factories';
|
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
|
||||||
|
|
||||||
function stubPlaybackInvokes(): void {
|
function stubPlaybackInvokes(): void {
|
||||||
onInvoke('audio_play', () => undefined);
|
onInvoke('audio_play', () => undefined);
|
||||||
@@ -201,11 +201,7 @@ describe('seek', () => {
|
|||||||
describe('next', () => {
|
describe('next', () => {
|
||||||
it('advances to queue[queueIndex + 1] when one is available', () => {
|
it('advances to queue[queueIndex + 1] when one is available', () => {
|
||||||
const queue = makeTracks(3);
|
const queue = makeTracks(3);
|
||||||
usePlayerStore.setState({
|
seedQueue(queue, { index: 0, currentTrack: queue[0] });
|
||||||
queue,
|
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: queue[0],
|
|
||||||
});
|
|
||||||
usePlayerStore.getState().next();
|
usePlayerStore.getState().next();
|
||||||
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id);
|
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id);
|
||||||
expect(usePlayerStore.getState().queueIndex).toBe(1);
|
expect(usePlayerStore.getState().queueIndex).toBe(1);
|
||||||
@@ -213,12 +209,8 @@ describe('next', () => {
|
|||||||
|
|
||||||
it('wraps to queue[0] when at the end with repeatMode=all', () => {
|
it('wraps to queue[0] when at the end with repeatMode=all', () => {
|
||||||
const queue = makeTracks(3);
|
const queue = makeTracks(3);
|
||||||
usePlayerStore.setState({
|
seedQueue(queue, { index: 2, currentTrack: queue[2] });
|
||||||
queue,
|
usePlayerStore.setState({ repeatMode: 'all' });
|
||||||
queueIndex: 2,
|
|
||||||
currentTrack: queue[2],
|
|
||||||
repeatMode: 'all',
|
|
||||||
});
|
|
||||||
usePlayerStore.getState().next();
|
usePlayerStore.getState().next();
|
||||||
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id);
|
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id);
|
||||||
expect(usePlayerStore.getState().queueIndex).toBe(0);
|
expect(usePlayerStore.getState().queueIndex).toBe(0);
|
||||||
@@ -228,13 +220,8 @@ describe('next', () => {
|
|||||||
// infiniteQueueEnabled and the radio fetch path are both off by default,
|
// infiniteQueueEnabled and the radio fetch path are both off by default,
|
||||||
// so the no-next branch falls through to `audio_stop`.
|
// so the no-next branch falls through to `audio_stop`.
|
||||||
const queue = makeTracks(2);
|
const queue = makeTracks(2);
|
||||||
usePlayerStore.setState({
|
seedQueue(queue, { index: 1, currentTrack: queue[1] });
|
||||||
queue,
|
usePlayerStore.setState({ repeatMode: 'off', isPlaying: true });
|
||||||
queueIndex: 1,
|
|
||||||
currentTrack: queue[1],
|
|
||||||
repeatMode: 'off',
|
|
||||||
isPlaying: true,
|
|
||||||
});
|
|
||||||
usePlayerStore.getState().next();
|
usePlayerStore.getState().next();
|
||||||
expect(invokeMock).toHaveBeenCalledWith('audio_stop');
|
expect(invokeMock).toHaveBeenCalledWith('audio_stop');
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
@@ -247,11 +234,7 @@ describe('next', () => {
|
|||||||
describe('previous', () => {
|
describe('previous', () => {
|
||||||
it('restarts the current track when currentTime > 3 s', () => {
|
it('restarts the current track when currentTime > 3 s', () => {
|
||||||
const queue = makeTracks(3);
|
const queue = makeTracks(3);
|
||||||
usePlayerStore.setState({
|
seedQueue(queue, { index: 1, currentTrack: queue[1] });
|
||||||
queue,
|
|
||||||
queueIndex: 1,
|
|
||||||
currentTrack: queue[1],
|
|
||||||
});
|
|
||||||
// The store's `currentTime` is the source for the "restart vs jump back"
|
// The store's `currentTime` is the source for the "restart vs jump back"
|
||||||
// branch. `getPlaybackProgressSnapshot` reads from the same field.
|
// branch. `getPlaybackProgressSnapshot` reads from the same field.
|
||||||
usePlayerStore.setState({ currentTime: 10, progress: 10 / queue[1].duration });
|
usePlayerStore.setState({ currentTime: 10, progress: 10 / queue[1].duration });
|
||||||
@@ -262,12 +245,8 @@ describe('previous', () => {
|
|||||||
|
|
||||||
it('jumps to the previous track when currentTime ≤ 3 s and queueIndex > 0', () => {
|
it('jumps to the previous track when currentTime ≤ 3 s and queueIndex > 0', () => {
|
||||||
const queue = makeTracks(3);
|
const queue = makeTracks(3);
|
||||||
usePlayerStore.setState({
|
seedQueue(queue, { index: 2, currentTrack: queue[2] });
|
||||||
queue,
|
usePlayerStore.setState({ currentTime: 1.0 });
|
||||||
queueIndex: 2,
|
|
||||||
currentTrack: queue[2],
|
|
||||||
currentTime: 1.0,
|
|
||||||
});
|
|
||||||
usePlayerStore.getState().previous();
|
usePlayerStore.getState().previous();
|
||||||
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id);
|
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id);
|
||||||
expect(usePlayerStore.getState().queueIndex).toBe(1);
|
expect(usePlayerStore.getState().queueIndex).toBe(1);
|
||||||
@@ -275,12 +254,8 @@ describe('previous', () => {
|
|||||||
|
|
||||||
it('is a no-op when queueIndex is 0 and currentTime ≤ 3 s', () => {
|
it('is a no-op when queueIndex is 0 and currentTime ≤ 3 s', () => {
|
||||||
const queue = makeTracks(2);
|
const queue = makeTracks(2);
|
||||||
usePlayerStore.setState({
|
seedQueue(queue, { index: 0, currentTrack: queue[0] });
|
||||||
queue,
|
usePlayerStore.setState({ currentTime: 0.5 });
|
||||||
queueIndex: 0,
|
|
||||||
currentTrack: queue[0],
|
|
||||||
currentTime: 0.5,
|
|
||||||
});
|
|
||||||
usePlayerStore.getState().previous();
|
usePlayerStore.getState().previous();
|
||||||
expect(usePlayerStore.getState().queueIndex).toBe(0);
|
expect(usePlayerStore.getState().queueIndex).toBe(0);
|
||||||
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id);
|
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
|
|||||||
import { usePlayerStore } from './playerStore';
|
import { usePlayerStore } from './playerStore';
|
||||||
import { onInvoke } from '@/test/mocks/tauri';
|
import { onInvoke } from '@/test/mocks/tauri';
|
||||||
import { resetPlayerStore } from '@/test/helpers/storeReset';
|
import { resetPlayerStore } from '@/test/helpers/storeReset';
|
||||||
import { makeTrack, makeTracks } from '@/test/helpers/factories';
|
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetPlayerStore();
|
resetPlayerStore();
|
||||||
@@ -50,56 +50,56 @@ describe('enqueue', () => {
|
|||||||
it('appends a single track to an empty queue', () => {
|
it('appends a single track to an empty queue', () => {
|
||||||
const t1 = makeTrack({ id: 't1' });
|
const t1 = makeTrack({ id: 't1' });
|
||||||
usePlayerStore.getState().enqueue([t1], true);
|
usePlayerStore.getState().enqueue([t1], true);
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['t1']);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['t1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('appends multiple tracks in order', () => {
|
it('appends multiple tracks in order', () => {
|
||||||
const tracks = makeTracks(3);
|
const tracks = makeTracks(3);
|
||||||
usePlayerStore.getState().enqueue(tracks, true);
|
usePlayerStore.getState().enqueue(tracks, true);
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(tracks.map(t => t.id));
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(tracks.map(t => t.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('inserts before the first upcoming auto-added separator', () => {
|
it('inserts before the first upcoming auto-added separator', () => {
|
||||||
const head = makeTrack({ id: 'head' });
|
const head = makeTrack({ id: 'head' });
|
||||||
const auto = makeTrack({ id: 'auto', autoAdded: true });
|
const auto = makeTrack({ id: 'auto', autoAdded: true });
|
||||||
const tail = makeTrack({ id: 'tail', autoAdded: true });
|
const tail = makeTrack({ id: 'tail', autoAdded: true });
|
||||||
usePlayerStore.setState({ queue: [head, auto, tail], queueIndex: 0 });
|
seedQueue([head, auto, tail], { index: 0 });
|
||||||
const incoming = makeTrack({ id: 'new' });
|
const incoming = makeTrack({ id: 'new' });
|
||||||
usePlayerStore.getState().enqueue([incoming], true);
|
usePlayerStore.getState().enqueue([incoming], true);
|
||||||
// Insert lands between `head` (the currently-playing one) and the first
|
// Insert lands between `head` (the currently-playing one) and the first
|
||||||
// auto-added track, so the auto-added group stays at the tail.
|
// auto-added track, so the auto-added group stays at the tail.
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['head', 'new', 'auto', 'tail']);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['head', 'new', 'auto', 'tail']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('appends at the end when there are no auto-added tracks after the cursor', () => {
|
it('appends at the end when there are no auto-added tracks after the cursor', () => {
|
||||||
const head = makeTrack({ id: 'head' });
|
const head = makeTrack({ id: 'head' });
|
||||||
const mid = makeTrack({ id: 'mid' });
|
const mid = makeTrack({ id: 'mid' });
|
||||||
usePlayerStore.setState({ queue: [head, mid], queueIndex: 0 });
|
seedQueue([head, mid], { index: 0 });
|
||||||
usePlayerStore.getState().enqueue([makeTrack({ id: 'tail' })], true);
|
usePlayerStore.getState().enqueue([makeTrack({ id: 'tail' })], true);
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['head', 'mid', 'tail']);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['head', 'mid', 'tail']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores auto-added separators that already passed (behind the cursor)', () => {
|
it('ignores auto-added separators that already passed (behind the cursor)', () => {
|
||||||
const past = makeTrack({ id: 'past', autoAdded: true });
|
const past = makeTrack({ id: 'past', autoAdded: true });
|
||||||
const current = makeTrack({ id: 'current' });
|
const current = makeTrack({ id: 'current' });
|
||||||
usePlayerStore.setState({ queue: [past, current], queueIndex: 1 });
|
seedQueue([past, current], { index: 1 });
|
||||||
usePlayerStore.getState().enqueue([makeTrack({ id: 'new' })], true);
|
usePlayerStore.getState().enqueue([makeTrack({ id: 'new' })], true);
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['past', 'current', 'new']);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['past', 'current', 'new']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('enqueueAt', () => {
|
describe('enqueueAt', () => {
|
||||||
it('inserts at the given index', () => {
|
it('inserts at the given index', () => {
|
||||||
const queue = makeTracks(3);
|
const queue = makeTracks(3);
|
||||||
usePlayerStore.setState({ queue, queueIndex: 0 });
|
seedQueue(queue, { index: 0 });
|
||||||
const ins = makeTrack({ id: 'ins' });
|
const ins = makeTrack({ id: 'ins' });
|
||||||
usePlayerStore.getState().enqueueAt([ins], 2, true);
|
usePlayerStore.getState().enqueueAt([ins], 2, true);
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([queue[0].id, queue[1].id, 'ins', queue[2].id]);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([queue[0].id, queue[1].id, 'ins', queue[2].id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shifts queueIndex forward when inserting at or before the cursor', () => {
|
it('shifts queueIndex forward when inserting at or before the cursor', () => {
|
||||||
const queue = makeTracks(3);
|
const queue = makeTracks(3);
|
||||||
usePlayerStore.setState({ queue, queueIndex: 2 });
|
seedQueue(queue, { index: 2 });
|
||||||
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], 1, true);
|
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], 1, true);
|
||||||
// Two tracks inserted at idx 1 → cursor (was 2) moves to 4.
|
// Two tracks inserted at idx 1 → cursor (was 2) moves to 4.
|
||||||
expect(usePlayerStore.getState().queueIndex).toBe(4);
|
expect(usePlayerStore.getState().queueIndex).toBe(4);
|
||||||
@@ -107,59 +107,57 @@ describe('enqueueAt', () => {
|
|||||||
|
|
||||||
it('keeps queueIndex when inserting after the cursor', () => {
|
it('keeps queueIndex when inserting after the cursor', () => {
|
||||||
const queue = makeTracks(3);
|
const queue = makeTracks(3);
|
||||||
usePlayerStore.setState({ queue, queueIndex: 1 });
|
seedQueue(queue, { index: 1 });
|
||||||
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' })], 3, true);
|
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' })], 3, true);
|
||||||
expect(usePlayerStore.getState().queueIndex).toBe(1);
|
expect(usePlayerStore.getState().queueIndex).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clamps a negative insertIndex to 0', () => {
|
it('clamps a negative insertIndex to 0', () => {
|
||||||
const queue = makeTracks(2);
|
const queue = makeTracks(2);
|
||||||
usePlayerStore.setState({ queue, queueIndex: 0 });
|
seedQueue(queue, { index: 0 });
|
||||||
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'front' })], -5, true);
|
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'front' })], -5, true);
|
||||||
expect(usePlayerStore.getState().queue[0].id).toBe('front');
|
expect(usePlayerStore.getState().queueItems[0].trackId).toBe('front');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clamps an over-large insertIndex to the queue length', () => {
|
it('clamps an over-large insertIndex to the queue length', () => {
|
||||||
const queue = makeTracks(2);
|
const queue = makeTracks(2);
|
||||||
usePlayerStore.setState({ queue, queueIndex: 0 });
|
seedQueue(queue, { index: 0 });
|
||||||
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'back' })], 99, true);
|
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'back' })], 99, true);
|
||||||
const q = usePlayerStore.getState().queue;
|
const q = usePlayerStore.getState().queueItems;
|
||||||
expect(q[q.length - 1].id).toBe('back');
|
expect(q[q.length - 1].trackId).toBe('back');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('playNext', () => {
|
describe('playNext', () => {
|
||||||
it('tags inserted tracks with playNextAdded', () => {
|
it('tags inserted tracks with playNextAdded', () => {
|
||||||
const queue = makeTracks(2);
|
const queue = makeTracks(2);
|
||||||
usePlayerStore.setState({ queue, queueIndex: 0, currentTrack: queue[0] });
|
seedQueue(queue, { index: 0, currentTrack: queue[0] });
|
||||||
usePlayerStore.getState().playNext([makeTrack({ id: 'pn' })]);
|
usePlayerStore.getState().playNext([makeTrack({ id: 'pn' })]);
|
||||||
const inserted = usePlayerStore.getState().queue.find(t => t.id === 'pn');
|
const inserted = usePlayerStore.getState().queueItems.find(r => r.trackId === 'pn');
|
||||||
expect(inserted?.playNextAdded).toBe(true);
|
expect(inserted?.playNextAdded).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('inserts immediately after the current track', () => {
|
it('inserts immediately after the current track', () => {
|
||||||
const a = makeTrack({ id: 'a' });
|
const a = makeTrack({ id: 'a' });
|
||||||
const b = makeTrack({ id: 'b' });
|
const b = makeTrack({ id: 'b' });
|
||||||
usePlayerStore.setState({ queue: [a, b], queueIndex: 0, currentTrack: a });
|
seedQueue([a, b], { index: 0, currentTrack: a });
|
||||||
usePlayerStore.getState().playNext([makeTrack({ id: 'pn' })]);
|
usePlayerStore.getState().playNext([makeTrack({ id: 'pn' })]);
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['a', 'pn', 'b']);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['a', 'pn', 'b']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns early on an empty input list', () => {
|
it('returns early on an empty input list', () => {
|
||||||
const queue = makeTracks(2);
|
const queue = makeTracks(2);
|
||||||
usePlayerStore.setState({ queue, queueIndex: 0, currentTrack: queue[0] });
|
seedQueue(queue, { index: 0, currentTrack: queue[0] });
|
||||||
usePlayerStore.getState().playNext([]);
|
usePlayerStore.getState().playNext([]);
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(queue.map(t => t.id));
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(queue.map(t => t.id));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('clearQueue', () => {
|
describe('clearQueue', () => {
|
||||||
it('empties the queue and resets playback bookkeeping', () => {
|
it('empties the queue and resets playback bookkeeping', () => {
|
||||||
const tracks = makeTracks(3);
|
const tracks = makeTracks(3);
|
||||||
|
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue: tracks,
|
|
||||||
queueIndex: 1,
|
|
||||||
currentTrack: tracks[1],
|
|
||||||
isPlaying: true,
|
isPlaying: true,
|
||||||
progress: 0.5,
|
progress: 0.5,
|
||||||
currentTime: 42,
|
currentTime: 42,
|
||||||
@@ -167,7 +165,7 @@ describe('clearQueue', () => {
|
|||||||
});
|
});
|
||||||
usePlayerStore.getState().clearQueue();
|
usePlayerStore.getState().clearQueue();
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
expect(s.queue).toEqual([]);
|
expect(s.queueItems).toEqual([]);
|
||||||
expect(s.queueIndex).toBe(0);
|
expect(s.queueIndex).toBe(0);
|
||||||
expect(s.currentTrack).toBeNull();
|
expect(s.currentTrack).toBeNull();
|
||||||
expect(s.isPlaying).toBe(false);
|
expect(s.isPlaying).toBe(false);
|
||||||
@@ -179,7 +177,7 @@ describe('clearQueue', () => {
|
|||||||
it('calls audio_stop on the engine', () => {
|
it('calls audio_stop on the engine', () => {
|
||||||
const stop = vi.fn(() => undefined);
|
const stop = vi.fn(() => undefined);
|
||||||
onInvoke('audio_stop', stop);
|
onInvoke('audio_stop', stop);
|
||||||
usePlayerStore.setState({ queue: makeTracks(2), queueIndex: 0 });
|
seedQueue(makeTracks(2), { index: 0 });
|
||||||
usePlayerStore.getState().clearQueue();
|
usePlayerStore.getState().clearQueue();
|
||||||
expect(stop).toHaveBeenCalled();
|
expect(stop).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -188,24 +186,24 @@ describe('clearQueue', () => {
|
|||||||
describe('reorderQueue', () => {
|
describe('reorderQueue', () => {
|
||||||
it('moves a track from startIndex to endIndex', () => {
|
it('moves a track from startIndex to endIndex', () => {
|
||||||
const [a, b, c, d] = makeTracks(4);
|
const [a, b, c, d] = makeTracks(4);
|
||||||
usePlayerStore.setState({ queue: [a, b, c, d], queueIndex: 0 });
|
seedQueue([a, b, c, d], { index: 0 });
|
||||||
usePlayerStore.getState().reorderQueue(1, 3); // b → after d
|
usePlayerStore.getState().reorderQueue(1, 3); // b → after d
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([a.id, c.id, d.id, b.id]);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([a.id, c.id, d.id, b.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves queueIndex by following the current track id, not the slot', () => {
|
it('preserves queueIndex by following the current track id, not the slot', () => {
|
||||||
const [a, b, c] = makeTracks(3);
|
const [a, b, c] = makeTracks(3);
|
||||||
usePlayerStore.setState({ queue: [a, b, c], queueIndex: 1, currentTrack: b });
|
seedQueue([a, b, c], { index: 1, currentTrack: b });
|
||||||
usePlayerStore.getState().reorderQueue(1, 2); // b moves to the end
|
usePlayerStore.getState().reorderQueue(1, 2); // b moves to the end
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([a.id, c.id, b.id]);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([a.id, c.id, b.id]);
|
||||||
expect(usePlayerStore.getState().queueIndex).toBe(2); // followed `b`
|
expect(usePlayerStore.getState().queueIndex).toBe(2); // followed `b`
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps queueIndex when the current track is unaffected by the move', () => {
|
it('keeps queueIndex when the current track is unaffected by the move', () => {
|
||||||
const [a, b, c] = makeTracks(3);
|
const [a, b, c] = makeTracks(3);
|
||||||
usePlayerStore.setState({ queue: [a, b, c], queueIndex: 1, currentTrack: b });
|
seedQueue([a, b, c], { index: 1, currentTrack: b });
|
||||||
usePlayerStore.getState().reorderQueue(0, 2); // a moves to the end
|
usePlayerStore.getState().reorderQueue(0, 2); // a moves to the end
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([b.id, c.id, a.id]);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([b.id, c.id, a.id]);
|
||||||
expect(usePlayerStore.getState().queueIndex).toBe(0); // followed `b`
|
expect(usePlayerStore.getState().queueIndex).toBe(0); // followed `b`
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -213,22 +211,22 @@ describe('reorderQueue', () => {
|
|||||||
describe('removeTrack', () => {
|
describe('removeTrack', () => {
|
||||||
it('removes the track at the given index', () => {
|
it('removes the track at the given index', () => {
|
||||||
const tracks = makeTracks(3);
|
const tracks = makeTracks(3);
|
||||||
usePlayerStore.setState({ queue: tracks, queueIndex: 0 });
|
seedQueue(tracks, { index: 0 });
|
||||||
usePlayerStore.getState().removeTrack(1);
|
usePlayerStore.getState().removeTrack(1);
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([tracks[0].id, tracks[2].id]);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([tracks[0].id, tracks[2].id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clamps queueIndex when the removal makes the queue shorter than the cursor', () => {
|
it('clamps queueIndex when the removal makes the queue shorter than the cursor', () => {
|
||||||
const tracks = makeTracks(3);
|
const tracks = makeTracks(3);
|
||||||
usePlayerStore.setState({ queue: tracks, queueIndex: 2 });
|
seedQueue(tracks, { index: 2 });
|
||||||
usePlayerStore.getState().removeTrack(2);
|
usePlayerStore.getState().removeTrack(2);
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([tracks[0].id, tracks[1].id]);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([tracks[0].id, tracks[1].id]);
|
||||||
expect(usePlayerStore.getState().queueIndex).toBe(1);
|
expect(usePlayerStore.getState().queueIndex).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps queueIndex when removing a track after the cursor', () => {
|
it('keeps queueIndex when removing a track after the cursor', () => {
|
||||||
const tracks = makeTracks(4);
|
const tracks = makeTracks(4);
|
||||||
usePlayerStore.setState({ queue: tracks, queueIndex: 1 });
|
seedQueue(tracks, { index: 1 });
|
||||||
usePlayerStore.getState().removeTrack(3);
|
usePlayerStore.getState().removeTrack(3);
|
||||||
expect(usePlayerStore.getState().queueIndex).toBe(1);
|
expect(usePlayerStore.getState().queueIndex).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -245,35 +243,35 @@ describe('undo / redo', () => {
|
|||||||
|
|
||||||
it('rolls back the most recent destructive edit', () => {
|
it('rolls back the most recent destructive edit', () => {
|
||||||
const seed = makeTracks(2);
|
const seed = makeTracks(2);
|
||||||
usePlayerStore.setState({ queue: seed, queueIndex: 0, currentTrack: seed[0] });
|
seedQueue(seed, { index: 0, currentTrack: seed[0] });
|
||||||
usePlayerStore.getState().enqueue([makeTrack({ id: 'add' })], true);
|
usePlayerStore.getState().enqueue([makeTrack({ id: 'add' })], true);
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id, seed[1].id, 'add']);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id, seed[1].id, 'add']);
|
||||||
|
|
||||||
const undone = usePlayerStore.getState().undoLastQueueEdit();
|
const undone = usePlayerStore.getState().undoLastQueueEdit();
|
||||||
expect(undone).toBe(true);
|
expect(undone).toBe(true);
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id, seed[1].id]);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id, seed[1].id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replays the undone edit via redo', () => {
|
it('replays the undone edit via redo', () => {
|
||||||
const seed = makeTracks(2);
|
const seed = makeTracks(2);
|
||||||
usePlayerStore.setState({ queue: seed, queueIndex: 0, currentTrack: seed[0] });
|
seedQueue(seed, { index: 0, currentTrack: seed[0] });
|
||||||
usePlayerStore.getState().removeTrack(1);
|
usePlayerStore.getState().removeTrack(1);
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id]);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id]);
|
||||||
|
|
||||||
usePlayerStore.getState().undoLastQueueEdit();
|
usePlayerStore.getState().undoLastQueueEdit();
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id, seed[1].id]);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id, seed[1].id]);
|
||||||
|
|
||||||
usePlayerStore.getState().redoLastQueueEdit();
|
usePlayerStore.getState().redoLastQueueEdit();
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id]);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a new edit drops any pending redo (Word-style history)', () => {
|
it('a new edit drops any pending redo (Word-style history)', () => {
|
||||||
const seed = makeTracks(3);
|
const seed = makeTracks(3);
|
||||||
usePlayerStore.setState({ queue: seed, queueIndex: 0, currentTrack: seed[0] });
|
seedQueue(seed, { index: 0, currentTrack: seed[0] });
|
||||||
|
|
||||||
usePlayerStore.getState().removeTrack(2); // edit A: [s0, s1]
|
usePlayerStore.getState().removeTrack(2); // edit A: [s0, s1]
|
||||||
usePlayerStore.getState().undoLastQueueEdit(); // [s0, s1, s2]
|
usePlayerStore.getState().undoLastQueueEdit(); // [s0, s1, s2]
|
||||||
expect(usePlayerStore.getState().queue).toHaveLength(3);
|
expect(usePlayerStore.getState().queueItems).toHaveLength(3);
|
||||||
|
|
||||||
usePlayerStore.getState().removeTrack(1); // edit B drops the pending redo
|
usePlayerStore.getState().removeTrack(1); // edit B drops the pending redo
|
||||||
|
|
||||||
|
|||||||
+81
-36
@@ -1,8 +1,10 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { createSafeJSONStorage } from './safeStorage';
|
||||||
import { emitPlaybackProgress } from './playbackProgress';
|
import { emitPlaybackProgress } from './playbackProgress';
|
||||||
import type { PlayerState } from './playerStoreTypes';
|
import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
|
||||||
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
||||||
|
import { canonicalQueueServerKey } from '../utils/server/serverIndexKey';
|
||||||
import { readInitialQueueVisibility } from './queueVisibilityStorage';
|
import { readInitialQueueVisibility } from './queueVisibilityStorage';
|
||||||
import { createLastfmActions } from './lastfmActions';
|
import { createLastfmActions } from './lastfmActions';
|
||||||
import { createMiscActions } from './miscActions';
|
import { createMiscActions } from './miscActions';
|
||||||
@@ -17,9 +19,6 @@ import { createTransportLightActions } from './transportLightActions';
|
|||||||
import { createUiStateActions } from './uiStateActions';
|
import { createUiStateActions } from './uiStateActions';
|
||||||
import { createUndoRedoActions } from './undoRedoActions';
|
import { createUndoRedoActions } from './undoRedoActions';
|
||||||
|
|
||||||
// Half-width of the localStorage queue window (see partialize below).
|
|
||||||
const PERSIST_QUEUE_HALF = 250;
|
|
||||||
|
|
||||||
export const usePlayerStore = create<PlayerState>()(
|
export const usePlayerStore = create<PlayerState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => {
|
(set, get) => {
|
||||||
@@ -39,7 +38,9 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
currentRadio: null,
|
currentRadio: null,
|
||||||
currentPlaybackSource: null,
|
currentPlaybackSource: null,
|
||||||
enginePreloadedTrackId: null,
|
enginePreloadedTrackId: null,
|
||||||
queue: [],
|
// Thin-state: the queue is a list of refs; full Tracks resolve on demand
|
||||||
|
// through the resolver. `currentTrack` stays a full resolved singleton.
|
||||||
|
queueItems: [],
|
||||||
queueServerId: null,
|
queueServerId: null,
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
@@ -81,37 +82,81 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'psysonic-player',
|
name: 'psysonic-player',
|
||||||
storage: createJSONStorage(() => localStorage),
|
// Quota-safe: a failed persist write (huge queue > localStorage quota)
|
||||||
partialize: (state) => {
|
// must never throw, or it aborts the `set()` it fires from — that is what
|
||||||
// Persist only a window around the current track: the full queue
|
// killed `playTrack` before `audio_play`. See safeStorage.ts.
|
||||||
// (10k+ on big playlists) overflows the localStorage quota.
|
storage: createSafeJSONStorage(),
|
||||||
const qi = state.queueIndex;
|
partialize: (state) => ({
|
||||||
const start = Math.max(0, qi - PERSIST_QUEUE_HALF);
|
volume: state.volume,
|
||||||
const windowedQueue = state.queue.slice(start, qi + PERSIST_QUEUE_HALF + 1);
|
repeatMode: state.repeatMode,
|
||||||
|
currentTrack: state.currentTrack,
|
||||||
|
queueServerId: state.queueServerId,
|
||||||
|
// Thin-state: persist the whole ordered ref list (tiny) — no windowed
|
||||||
|
// fat `queue: Track[]` anymore. `queueItemsIndex` doubles as the
|
||||||
|
// restore-pending sentinel a fresh rehydrate carries back, telling
|
||||||
|
// `hydrateQueueFromIndex` the refs still need a full resolve.
|
||||||
|
queueItems: state.queueItems,
|
||||||
|
queueItemsIndex: state.queueIndex,
|
||||||
|
isQueueVisible: state.isQueueVisible,
|
||||||
|
// currentTime is intentionally NOT persisted here.
|
||||||
|
// handleAudioProgress fires every 100ms and each setState with a
|
||||||
|
// persisted field triggers a full JSON serialisation to localStorage.
|
||||||
|
// Resume position is recovered from Subsonic savePlayQueue (5s debounce).
|
||||||
|
lastfmLovedCache: state.lastfmLovedCache,
|
||||||
|
}),
|
||||||
|
// Rebuild `queueItems` from ANY older persisted blob shape so an upgrade
|
||||||
|
// restores the queue. Order of preference: an existing `queueItems` ref
|
||||||
|
// list → the legacy `queueRefs` string list → a windowed `queue: Track[]`
|
||||||
|
// (the pre-thin-state shape). Sets the restore-pending sentinel and drops
|
||||||
|
// the obsolete fat `queue` key from the persisted object.
|
||||||
|
merge: (persisted, current) => {
|
||||||
|
const blob = (persisted ?? {}) as Record<string, unknown>;
|
||||||
|
const rawServerId = (blob.queueServerId as string | null | undefined) ?? null;
|
||||||
|
// B1: queue server identity is canonical (index key) on every write path.
|
||||||
|
// Migrate persisted blobs forward here once on rehydrate so the live
|
||||||
|
// store never has to handle a mix of UUID and index-key refs.
|
||||||
|
const canonicalSid = rawServerId ? canonicalQueueServerKey(rawServerId) : null;
|
||||||
|
|
||||||
|
let queueItems: QueueItemRef[] | undefined;
|
||||||
|
if (Array.isArray(blob.queueItems) && blob.queueItems.length > 0) {
|
||||||
|
queueItems = (blob.queueItems as QueueItemRef[]).map(ref => ({
|
||||||
|
...ref,
|
||||||
|
serverId: canonicalQueueServerKey(ref.serverId),
|
||||||
|
}));
|
||||||
|
} else if (Array.isArray(blob.queueRefs) && blob.queueRefs.length > 0) {
|
||||||
|
queueItems = (blob.queueRefs as string[]).map(trackId => ({
|
||||||
|
serverId: canonicalSid ?? '',
|
||||||
|
trackId,
|
||||||
|
}));
|
||||||
|
} else if (Array.isArray(blob.queue) && blob.queue.length > 0) {
|
||||||
|
queueItems = toQueueItemRefs(canonicalSid ?? '', blob.queue as Track[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore-pending sentinel: prefer the persisted one; else the legacy
|
||||||
|
// index; else 0 when we recovered a non-empty queue from an old blob.
|
||||||
|
let queueItemsIndex: number | undefined;
|
||||||
|
if (typeof blob.queueItemsIndex === 'number') {
|
||||||
|
queueItemsIndex = blob.queueItemsIndex;
|
||||||
|
} else if (typeof blob.queueRefsIndex === 'number') {
|
||||||
|
queueItemsIndex = blob.queueRefsIndex;
|
||||||
|
} else if (queueItems && queueItems.length > 0) {
|
||||||
|
queueItemsIndex = typeof blob.queueIndex === 'number' ? blob.queueIndex : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the obsolete windowed fat-array key — `queueItems` is canonical.
|
||||||
|
delete blob.queue;
|
||||||
|
// Persist the canonical form back onto the merged blob so subsequent
|
||||||
|
// reads of state.queueServerId always see the index key.
|
||||||
|
if (canonicalSid !== null) {
|
||||||
|
blob.queueServerId = canonicalSid;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
volume: state.volume,
|
...current,
|
||||||
repeatMode: state.repeatMode,
|
...blob,
|
||||||
currentTrack: state.currentTrack,
|
queueItems: queueItems ?? current.queueItems,
|
||||||
queue: windowedQueue,
|
...(queueItemsIndex !== undefined ? { queueItemsIndex } : {}),
|
||||||
queueServerId: state.queueServerId,
|
} as PlayerState;
|
||||||
queueIndex: qi - start, // remap into the windowed slice
|
|
||||||
// Phase 1: full ordered thin-ref list (tiny) so the *whole* queue can
|
|
||||||
// be rehydrated from the local index on startup. Dual-write — the
|
|
||||||
// windowed `queue` above stays as the no-index fallback (queue never
|
|
||||||
// empty when the index is off, the P6 default). Per-item serverId is
|
|
||||||
// the playback server (single-server v1); supersedes `queueRefs`.
|
|
||||||
queueItems: toQueueItemRefs(state.queueServerId ?? '', state.queue),
|
|
||||||
queueItemsIndex: qi,
|
|
||||||
isQueueVisible: state.isQueueVisible,
|
|
||||||
// currentTime is intentionally NOT persisted here.
|
|
||||||
// handleAudioProgress fires every 100ms and each setState with a
|
|
||||||
// persisted field triggers a full JSON serialisation of the queue to
|
|
||||||
// localStorage. After ~10 minutes of Artist Radio the queue grows to
|
|
||||||
// 50+ tracks; 6 000+ synchronous SQLite writes cause WKWebView's
|
|
||||||
// storage process to crash on macOS → black screen + audio stop.
|
|
||||||
// Resume position is recovered from Subsonic savePlayQueue (5s debounce).
|
|
||||||
lastfmLovedCache: state.lastfmLovedCache,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ export interface PlayerState {
|
|||||||
* Cleared after a successful `audio_play` consumed that preload, or when starting another track.
|
* Cleared after a successful `audio_play` consumed that preload, or when starting another track.
|
||||||
*/
|
*/
|
||||||
enginePreloadedTrackId: string | null;
|
enginePreloadedTrackId: string | null;
|
||||||
queue: Track[];
|
|
||||||
/** Saved server for stream/hot-cache/offline resolution while this queue plays. */
|
/** Saved server for stream/hot-cache/offline resolution while this queue plays. */
|
||||||
queueServerId: string | null;
|
queueServerId: string | null;
|
||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
@@ -79,11 +78,16 @@ export interface PlayerState {
|
|||||||
* are then cleared. Absent / index-off → the windowed `queue` is used as-is. */
|
* are then cleared. Absent / index-off → the windowed `queue` is used as-is. */
|
||||||
queueRefs?: string[];
|
queueRefs?: string[];
|
||||||
queueRefsIndex?: number;
|
queueRefsIndex?: number;
|
||||||
/** Phase 1 (transient): thin canonical ref list persisted alongside the
|
/** Canonical thin queue list (thin-state). Single playback server per item in
|
||||||
* windowed `queue`, superseding `queueRefs`. Carries per-item `serverId` +
|
* v1; carries the queue-only flags. Persisted by `partialize`; the source the
|
||||||
* queue-only flags. Rehydrated like `queueRefs` and cleared after a full
|
* resolver/consumers read from — full `Track`s resolve on demand. */
|
||||||
* hydrate from the index. The in-memory queue stays `Track[]` until Phase 2. */
|
queueItems: QueueItemRef[];
|
||||||
queueItems?: QueueItemRef[];
|
/** Restore-pending sentinel (transient). `partialize` writes it alongside the
|
||||||
|
* full `queueItems` on every persist; a fresh rehydrate brings it back, which
|
||||||
|
* is what tells `hydrateQueueFromIndex` the windowed `queue` still needs a
|
||||||
|
* full hydrate. Normal mutations keep `queueItems` canonical but never set
|
||||||
|
* this, so its presence — not `queueItems` — gates the restore. Cleared once
|
||||||
|
* a full hydrate succeeds. */
|
||||||
queueItemsIndex?: number;
|
queueItemsIndex?: number;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
/** HTTP stream still buffering (network / demux probe) — show loading on cover art. */
|
/** HTTP stream still buffering (network / demux probe) — show loading on cover art. */
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { orbitBulkGuard } from '../utils/orbitBulkGuard';
|
|||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { setIsAudioPaused } from './engineState';
|
import { setIsAudioPaused } from './engineState';
|
||||||
import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch';
|
import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch';
|
||||||
import type { PlayerState, Track } from './playerStoreTypes';
|
import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
|
||||||
|
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
||||||
|
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
|
||||||
import { pushQueueUndoFromGetter } from './queueUndo';
|
import { pushQueueUndoFromGetter } from './queueUndo';
|
||||||
import { syncQueueToServer } from './queueSync';
|
import { syncQueueToServer } from './queueSync';
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +37,22 @@ function blockCrossServerEnqueue(): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The canonical working ref list for a mutation (thin-state). Mutations
|
||||||
|
* splice/filter/reorder a copy of these refs and write the result back into
|
||||||
|
* `queueItems` — the queue source of truth. Returns a fresh array each call so
|
||||||
|
* in-place splices don't mutate the live state array.
|
||||||
|
*/
|
||||||
|
const itemsOf = (state: PlayerState): QueueItemRef[] => [...state.queueItems];
|
||||||
|
|
||||||
|
/** Seed the resolver cache with tracks entering the queue, so they resolve
|
||||||
|
* without a network round-trip once `queue: Track[]` is dropped (seed-before-
|
||||||
|
* splice). No-op without a real playback server (e.g. unit tests). */
|
||||||
|
function seedIncoming(state: PlayerState, tracks: Track[]): void {
|
||||||
|
const serverId = state.queueServerId ?? '';
|
||||||
|
if (serverId) seedQueueResolver(serverId, tracks);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eleven queue-mutation actions. Shared invariant: every action except
|
* Eleven queue-mutation actions. Shared invariant: every action except
|
||||||
* `setRadioArtistId` pushes a queue-undo snapshot and calls
|
* `setRadioArtistId` pushes a queue-undo snapshot and calls
|
||||||
@@ -67,21 +85,18 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
|||||||
}
|
}
|
||||||
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
|
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
|
||||||
set(state => {
|
set(state => {
|
||||||
|
seedIncoming(state, tracks);
|
||||||
|
const items = itemsOf(state);
|
||||||
|
const incoming = toQueueItemRefs(state.queueServerId ?? '', tracks);
|
||||||
// Insert before the first upcoming auto-added track so the
|
// Insert before the first upcoming auto-added track so the
|
||||||
// "Added automatically" separator always stays at the boundary.
|
// "Added automatically" separator always stays at the boundary.
|
||||||
const firstAutoIdx = state.queue.findIndex(
|
const firstAutoIdx = items.findIndex((r, i) => r.autoAdded && i > state.queueIndex);
|
||||||
(t, i) => t.autoAdded && i > state.queueIndex
|
const newItems = firstAutoIdx === -1
|
||||||
);
|
? [...items, ...incoming]
|
||||||
const newQueue = firstAutoIdx === -1
|
: [...items.slice(0, firstAutoIdx), ...incoming, ...items.slice(firstAutoIdx)];
|
||||||
? [...state.queue, ...tracks]
|
syncQueueToServer(newItems, state.currentTrack, state.currentTime);
|
||||||
: [
|
prefetchLoudnessForEnqueuedTracks(newItems, state.queueIndex);
|
||||||
...state.queue.slice(0, firstAutoIdx),
|
return { queueItems: newItems };
|
||||||
...tracks,
|
|
||||||
...state.queue.slice(firstAutoIdx),
|
|
||||||
];
|
|
||||||
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
|
|
||||||
prefetchLoudnessForEnqueuedTracks(newQueue, state.queueIndex);
|
|
||||||
return { queue: newQueue };
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -101,22 +116,23 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
|||||||
}
|
}
|
||||||
pushQueueUndoFromGetter(get);
|
pushQueueUndoFromGetter(get);
|
||||||
set(state => {
|
set(state => {
|
||||||
|
const items = itemsOf(state);
|
||||||
// Drop all upcoming (not yet played) radio tracks — clicking "Start Radio"
|
// Drop all upcoming (not yet played) radio tracks — clicking "Start Radio"
|
||||||
// again replaces the pending radio batch instead of stacking on top.
|
// again replaces the pending radio batch instead of stacking on top.
|
||||||
const beforeAndCurrent = state.queue.slice(0, state.queueIndex + 1);
|
const beforeAndCurrent = items.slice(0, state.queueIndex + 1);
|
||||||
const upcoming = state.queue.slice(state.queueIndex + 1).filter(t => !t.radioAdded);
|
const upcoming = items.slice(state.queueIndex + 1).filter(r => !r.radioAdded);
|
||||||
// Tracks about to leave the queue here. Callers like ContextMenu.startRadio
|
// Tracks about to leave the queue here. Callers like ContextMenu.startRadio
|
||||||
// pass the previous pending radio back in `tracks` to merge with new
|
// pass the previous pending radio back in `tracks` to merge with new
|
||||||
// similars — the seen-set must not block those re-introductions.
|
// similars — the seen-set must not block those re-introductions.
|
||||||
const droppedRadioIds = state.queue
|
const droppedRadioIds = items
|
||||||
.slice(state.queueIndex + 1)
|
.slice(state.queueIndex + 1)
|
||||||
.filter(t => t.radioAdded)
|
.filter(r => r.radioAdded)
|
||||||
.map(t => t.id);
|
.map(r => r.trackId);
|
||||||
for (const id of droppedRadioIds) deleteRadioSessionSeen(id);
|
for (const id of droppedRadioIds) deleteRadioSessionSeen(id);
|
||||||
// Capture surviving queue ids in the seen-set so the next radio top-up
|
// Capture surviving queue ids in the seen-set so the next radio top-up
|
||||||
// can dedupe against the seed track + already-queued non-radio items.
|
// can dedupe against the seed track + already-queued non-radio items.
|
||||||
for (const t of beforeAndCurrent) addRadioSessionSeen(t.id);
|
for (const r of beforeAndCurrent) addRadioSessionSeen(r.trackId);
|
||||||
for (const t of upcoming) addRadioSessionSeen(t.id);
|
for (const r of upcoming) addRadioSessionSeen(r.trackId);
|
||||||
// Drop incoming tracks already seen earlier this session AND
|
// Drop incoming tracks already seen earlier this session AND
|
||||||
// intra-batch duplicates (top + similar Last.fm responses commonly
|
// intra-batch duplicates (top + similar Last.fm responses commonly
|
||||||
// overlap). The seen-set is mutated inside the loop so a repeated
|
// overlap). The seen-set is mutated inside the loop so a repeated
|
||||||
@@ -128,18 +144,16 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
|||||||
addRadioSessionSeen(t.id);
|
addRadioSessionSeen(t.id);
|
||||||
dedupedTracks.push(t);
|
dedupedTracks.push(t);
|
||||||
}
|
}
|
||||||
|
seedIncoming(state, dedupedTracks);
|
||||||
|
const incoming = toQueueItemRefs(state.queueServerId ?? '', dedupedTracks);
|
||||||
// Insert new radio tracks before any autoAdded tracks in the upcoming section.
|
// Insert new radio tracks before any autoAdded tracks in the upcoming section.
|
||||||
const firstAutoIdx = upcoming.findIndex(t => t.autoAdded);
|
const firstAutoIdx = upcoming.findIndex(r => r.autoAdded);
|
||||||
const merged = firstAutoIdx === -1
|
const mergedItems = firstAutoIdx === -1
|
||||||
? [...upcoming, ...dedupedTracks]
|
? [...upcoming, ...incoming]
|
||||||
: [
|
: [...upcoming.slice(0, firstAutoIdx), ...incoming, ...upcoming.slice(firstAutoIdx)];
|
||||||
...upcoming.slice(0, firstAutoIdx),
|
const newItems = [...beforeAndCurrent, ...mergedItems];
|
||||||
...dedupedTracks,
|
syncQueueToServer(newItems, state.currentTrack, state.currentTime);
|
||||||
...upcoming.slice(firstAutoIdx),
|
return { queueItems: newItems };
|
||||||
];
|
|
||||||
const newQueue = [...beforeAndCurrent, ...merged];
|
|
||||||
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
|
|
||||||
return { queue: newQueue };
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -153,18 +167,17 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
|||||||
}
|
}
|
||||||
pushQueueUndoFromGetter(get);
|
pushQueueUndoFromGetter(get);
|
||||||
set(state => {
|
set(state => {
|
||||||
const idx = Math.max(0, Math.min(insertIndex, state.queue.length));
|
seedIncoming(state, tracks);
|
||||||
const newQueue = [
|
const items = itemsOf(state);
|
||||||
...state.queue.slice(0, idx),
|
const idx = Math.max(0, Math.min(insertIndex, items.length));
|
||||||
...tracks,
|
const incoming = toQueueItemRefs(state.queueServerId ?? '', tracks);
|
||||||
...state.queue.slice(idx),
|
const newItems = [...items.slice(0, idx), ...incoming, ...items.slice(idx)];
|
||||||
];
|
|
||||||
const newQueueIndex = idx <= state.queueIndex
|
const newQueueIndex = idx <= state.queueIndex
|
||||||
? state.queueIndex + tracks.length
|
? state.queueIndex + tracks.length
|
||||||
: state.queueIndex;
|
: state.queueIndex;
|
||||||
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
|
syncQueueToServer(newItems, state.currentTrack, state.currentTime);
|
||||||
prefetchLoudnessForEnqueuedTracks(newQueue, newQueueIndex);
|
prefetchLoudnessForEnqueuedTracks(newItems, newQueueIndex);
|
||||||
return { queue: newQueue, queueIndex: newQueueIndex };
|
return { queueItems: newItems, queueIndex: newQueueIndex };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -180,8 +193,8 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
|||||||
const baseIdx = state.queueIndex + 1;
|
const baseIdx = state.queueIndex + 1;
|
||||||
let insertIdx = baseIdx;
|
let insertIdx = baseIdx;
|
||||||
if (useAuthStore.getState().preservePlayNextOrder) {
|
if (useAuthStore.getState().preservePlayNextOrder) {
|
||||||
const q = state.queue;
|
const items = itemsOf(state);
|
||||||
while (insertIdx < q.length && q[insertIdx].playNextAdded) insertIdx++;
|
while (insertIdx < items.length && items[insertIdx].playNextAdded) insertIdx++;
|
||||||
}
|
}
|
||||||
get().enqueueAt(tagged, insertIdx);
|
get().enqueueAt(tagged, insertIdx);
|
||||||
},
|
},
|
||||||
@@ -190,21 +203,24 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
|||||||
const s = get();
|
const s = get();
|
||||||
if (s.currentRadio) return;
|
if (s.currentRadio) return;
|
||||||
if (!s.currentTrack) {
|
if (!s.currentTrack) {
|
||||||
if (s.queue.length === 0) return;
|
if (s.queueItems.length === 0) return;
|
||||||
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
|
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
|
||||||
set({ queue: [], queueIndex: 0 });
|
set({ queueItems: [], queueIndex: 0 });
|
||||||
syncQueueToServer([], null, 0);
|
syncQueueToServer([], null, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
|
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
|
||||||
const at = s.queue.findIndex(t => t.id === s.currentTrack!.id);
|
// Seed the resolver with the currently playing track so its ref always
|
||||||
const newQueue: Track[] =
|
// resolves even when it had not been in the cache window before.
|
||||||
at >= 0
|
seedIncoming(s, [s.currentTrack]);
|
||||||
? s.queue.slice(0, at + 1)
|
const items = itemsOf(s);
|
||||||
: [s.currentTrack!];
|
const at = items.findIndex(r => r.trackId === s.currentTrack!.id);
|
||||||
|
const newItems = at >= 0
|
||||||
|
? items.slice(0, at + 1)
|
||||||
|
: toQueueItemRefs(s.queueServerId ?? '', [s.currentTrack!]);
|
||||||
const newIndex = at >= 0 ? at : 0;
|
const newIndex = at >= 0 ? at : 0;
|
||||||
set({ queue: newQueue, queueIndex: newIndex });
|
set({ queueItems: newItems, queueIndex: newIndex });
|
||||||
syncQueueToServer(newQueue, s.currentTrack, s.currentTime);
|
syncQueueToServer(newItems, s.currentTrack, s.currentTime);
|
||||||
},
|
},
|
||||||
|
|
||||||
clearQueue: () => {
|
clearQueue: () => {
|
||||||
@@ -216,64 +232,73 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
|||||||
clearRadioSessionSeenIds();
|
clearRadioSessionSeenIds();
|
||||||
setCurrentRadioArtistId(null);
|
setCurrentRadioArtistId(null);
|
||||||
clearQueueServerForPlayback();
|
clearQueueServerForPlayback();
|
||||||
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
set({ queueItems: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||||
syncQueueToServer([], null, 0);
|
syncQueueToServer([], null, 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
reorderQueue: (startIndex, endIndex) => {
|
reorderQueue: (startIndex, endIndex) => {
|
||||||
pushQueueUndoFromGetter(get);
|
pushQueueUndoFromGetter(get);
|
||||||
const { queue, queueIndex, currentTrack } = get();
|
const state = get();
|
||||||
const result = Array.from(queue);
|
const { queueIndex, currentTrack } = state;
|
||||||
|
const result = itemsOf(state);
|
||||||
const [removed] = result.splice(startIndex, 1);
|
const [removed] = result.splice(startIndex, 1);
|
||||||
result.splice(endIndex, 0, removed);
|
result.splice(endIndex, 0, removed);
|
||||||
let newIndex = queueIndex;
|
let newIndex = queueIndex;
|
||||||
if (currentTrack) newIndex = result.findIndex(t => t.id === currentTrack.id);
|
if (currentTrack) newIndex = result.findIndex(r => r.trackId === currentTrack.id);
|
||||||
set({ queue: result, queueIndex: Math.max(0, newIndex) });
|
set({ queueItems: result, queueIndex: Math.max(0, newIndex) });
|
||||||
syncQueueToServer(result, currentTrack, get().currentTime);
|
syncQueueToServer(result, currentTrack, get().currentTime);
|
||||||
},
|
},
|
||||||
|
|
||||||
shuffleQueue: () => {
|
shuffleQueue: () => {
|
||||||
const { queue, currentTrack } = get();
|
const state = get();
|
||||||
if (queue.length < 2) return;
|
const { currentTrack } = state;
|
||||||
|
if (state.queueItems.length < 2) return;
|
||||||
pushQueueUndoFromGetter(get);
|
pushQueueUndoFromGetter(get);
|
||||||
const currentIdx = currentTrack ? queue.findIndex(t => t.id === currentTrack.id) : -1;
|
const items = itemsOf(state);
|
||||||
const others = queue.filter((_, i) => i !== currentIdx);
|
const currentIdx = currentTrack ? items.findIndex(r => r.trackId === currentTrack.id) : -1;
|
||||||
|
const others = items.filter((_, i) => i !== currentIdx);
|
||||||
for (let i = others.length - 1; i > 0; i--) {
|
for (let i = others.length - 1; i > 0; i--) {
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
[others[i], others[j]] = [others[j], others[i]];
|
[others[i], others[j]] = [others[j], others[i]];
|
||||||
}
|
}
|
||||||
const result = currentIdx >= 0
|
const result = currentIdx >= 0
|
||||||
? [queue[currentIdx], ...others]
|
? [items[currentIdx], ...others]
|
||||||
: others;
|
: others;
|
||||||
const newIndex = currentIdx >= 0 ? 0 : -1;
|
const newIndex = currentIdx >= 0 ? 0 : -1;
|
||||||
set({ queue: result, queueIndex: Math.max(0, newIndex) });
|
set({ queueItems: result, queueIndex: Math.max(0, newIndex) });
|
||||||
syncQueueToServer(result, currentTrack, get().currentTime);
|
syncQueueToServer(result, currentTrack, get().currentTime);
|
||||||
},
|
},
|
||||||
|
|
||||||
shuffleUpcomingQueue: () => {
|
shuffleUpcomingQueue: () => {
|
||||||
const { queue, queueIndex, currentTrack } = get();
|
const state = get();
|
||||||
|
const { queueIndex, currentTrack } = state;
|
||||||
const upcomingStart = queueIndex + 1;
|
const upcomingStart = queueIndex + 1;
|
||||||
const upcomingCount = queue.length - upcomingStart;
|
const upcomingCount = state.queueItems.length - upcomingStart;
|
||||||
if (upcomingCount < 2) return;
|
if (upcomingCount < 2) return;
|
||||||
pushQueueUndoFromGetter(get);
|
pushQueueUndoFromGetter(get);
|
||||||
const head = queue.slice(0, upcomingStart);
|
const items = itemsOf(state);
|
||||||
const upcoming = queue.slice(upcomingStart);
|
const head = items.slice(0, upcomingStart);
|
||||||
|
const upcoming = items.slice(upcomingStart);
|
||||||
for (let i = upcoming.length - 1; i > 0; i--) {
|
for (let i = upcoming.length - 1; i > 0; i--) {
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
[upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]];
|
[upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]];
|
||||||
}
|
}
|
||||||
const result = [...head, ...upcoming];
|
const result = [...head, ...upcoming];
|
||||||
set({ queue: result });
|
set({ queueItems: result });
|
||||||
syncQueueToServer(result, currentTrack, get().currentTime);
|
syncQueueToServer(result, currentTrack, get().currentTime);
|
||||||
},
|
},
|
||||||
|
|
||||||
removeTrack: (index) => {
|
removeTrack: (index) => {
|
||||||
pushQueueUndoFromGetter(get);
|
pushQueueUndoFromGetter(get);
|
||||||
const { queue, queueIndex } = get();
|
const state = get();
|
||||||
const newQueue = [...queue];
|
const { queueIndex } = state;
|
||||||
newQueue.splice(index, 1);
|
const newItems = itemsOf(state);
|
||||||
set({ queue: newQueue, queueIndex: Math.min(queueIndex, newQueue.length - 1) });
|
newItems.splice(index, 1);
|
||||||
syncQueueToServer(newQueue, get().currentTrack, get().currentTime);
|
set({
|
||||||
|
queueItems: newItems,
|
||||||
|
queueIndex: Math.min(queueIndex, newItems.length - 1),
|
||||||
|
});
|
||||||
|
syncQueueToServer(newItems, get().currentTrack, get().currentTime);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Side-effect wiring (queue thin-state, phase 2b): seed the queue track
|
* Side-effect wiring (queue thin-state): keep the queue track resolver cache
|
||||||
* resolver cache with the tracks around the current index whenever the queue
|
* warm around the current index whenever the canonical `queueItems` ref list or
|
||||||
* changes. The store stays `queue: Track[]`-canonical for now — this only fills
|
* the playing index changes. The store is refs-canonical now, so this fills the
|
||||||
* the resolver cache (additive; no mutation or persist change), so the queue
|
* cache (via `resolveVisibleRange` — index batch → getSong fallback) so the
|
||||||
* selectors resolve without a fetch once consumers move onto them (phase 3) and
|
* queue selectors / list rows resolve without a synchronous miss. Render paths
|
||||||
* after `queue: Track[]` is dropped (phase 4).
|
* stay pure (no cache mutation in render); the seed travels with the playing
|
||||||
|
* track here, off the render path.
|
||||||
*/
|
*/
|
||||||
import { usePlayerStore } from './playerStore';
|
import { usePlayerStore } from './playerStore';
|
||||||
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
|
import { resolveVisibleRange } from '../utils/library/queueTrackResolver';
|
||||||
|
|
||||||
const SEED_BACK = 50;
|
|
||||||
const SEED_AHEAD = 200;
|
|
||||||
|
|
||||||
usePlayerStore.subscribe((state, prev) => {
|
usePlayerStore.subscribe((state, prev) => {
|
||||||
if (state.queue === prev.queue && state.queueServerId === prev.queueServerId) return;
|
// Re-seed when the queue refs or the current index change — the prefetch
|
||||||
const serverId = state.queueServerId ?? '';
|
// window (resolveVisibleRange's PREFETCH_BACK/AHEAD) travels with the index.
|
||||||
if (!serverId || state.queue.length === 0) return;
|
if (
|
||||||
const start = Math.max(0, state.queueIndex - SEED_BACK);
|
state.queueItems === prev.queueItems &&
|
||||||
seedQueueResolver(serverId, state.queue.slice(start, state.queueIndex + SEED_AHEAD + 1));
|
state.queueIndex === prev.queueIndex
|
||||||
|
) return;
|
||||||
|
if (state.queueItems.length === 0) return;
|
||||||
|
resolveVisibleRange(state.queueItems, state.queueIndex, state.queueIndex);
|
||||||
});
|
});
|
||||||
|
|||||||
+24
-19
@@ -5,12 +5,12 @@
|
|||||||
* in for `savePlayQueue`, the playerStore, and the playback-progress
|
* in for `savePlayQueue`, the playerStore, and the playback-progress
|
||||||
* snapshot.
|
* snapshot.
|
||||||
*/
|
*/
|
||||||
import type { Track } from './playerStoreTypes';
|
import type { QueueItemRef, Track } from './playerStoreTypes';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({
|
const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({
|
||||||
savePlayQueueMock: vi.fn(async (_ids: string[], _currentId: string | undefined, _pos: number, _serverId: string) => undefined),
|
savePlayQueueMock: vi.fn(async (_ids: string[], _currentId: string | undefined, _pos: number, _serverId: string) => undefined),
|
||||||
playerState: {
|
playerState: {
|
||||||
queue: [] as Track[],
|
queueItems: [] as QueueItemRef[],
|
||||||
currentTrack: null as Track | null,
|
currentTrack: null as Track | null,
|
||||||
currentRadio: null as { id: string } | null,
|
currentRadio: null as { id: string } | null,
|
||||||
},
|
},
|
||||||
@@ -40,12 +40,17 @@ function track(id: string): Track {
|
|||||||
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
|
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Thin-state: sync helpers take queue refs.
|
||||||
|
function ref(id: string): QueueItemRef {
|
||||||
|
return { serverId: 'srv-a', trackId: id };
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2026-05-12T12:00:00Z'));
|
vi.setSystemTime(new Date('2026-05-12T12:00:00Z'));
|
||||||
savePlayQueueMock.mockClear();
|
savePlayQueueMock.mockClear();
|
||||||
savePlayQueueMock.mockResolvedValue(undefined);
|
savePlayQueueMock.mockResolvedValue(undefined);
|
||||||
playerState.queue = [];
|
playerState.queueItems = [];
|
||||||
playerState.currentTrack = null;
|
playerState.currentTrack = null;
|
||||||
playerState.currentRadio = null;
|
playerState.currentRadio = null;
|
||||||
progressSnapshot.currentTime = 0;
|
progressSnapshot.currentTime = 0;
|
||||||
@@ -57,32 +62,32 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('syncQueueToServer (debounced)', () => {
|
describe('syncQueueToServer (debounced)', () => {
|
||||||
const queue = [track('a'), track('b')];
|
const queue = [ref('a'), ref('b')];
|
||||||
|
|
||||||
it('does not fire before 5 s elapse', () => {
|
it('does not fire before 5 s elapse', () => {
|
||||||
syncQueueToServer(queue, queue[0], 30);
|
syncQueueToServer(queue, track('a'), 30);
|
||||||
vi.advanceTimersByTime(4999);
|
vi.advanceTimersByTime(4999);
|
||||||
expect(savePlayQueueMock).not.toHaveBeenCalled();
|
expect(savePlayQueueMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fires once after 5 s with id list + current id + position in ms', () => {
|
it('fires once after 5 s with id list + current id + position in ms', () => {
|
||||||
syncQueueToServer(queue, queue[0], 30);
|
syncQueueToServer(queue, track('a'), 30);
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 30000, 'srv-a');
|
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 30000, 'srv-a');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancels the previous timer when called again before fire', () => {
|
it('cancels the previous timer when called again before fire', () => {
|
||||||
syncQueueToServer(queue, queue[0], 10);
|
syncQueueToServer(queue, track('a'), 10);
|
||||||
vi.advanceTimersByTime(3000);
|
vi.advanceTimersByTime(3000);
|
||||||
syncQueueToServer([...queue, track('c')], queue[0], 20);
|
syncQueueToServer([...queue, ref('c')], track('a'), 20);
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
|
expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
|
||||||
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b', 'c'], 'a', 20000, 'srv-a');
|
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b', 'c'], 'a', 20000, 'srv-a');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caps the queue at 1000 ids', () => {
|
it('caps the queue at 1000 ids', () => {
|
||||||
const big = Array.from({ length: 1500 }, (_, i) => track(`t${i}`));
|
const big = Array.from({ length: 1500 }, (_, i) => ref(`t${i}`));
|
||||||
syncQueueToServer(big, big[0], 0);
|
syncQueueToServer(big, track('t0'), 0);
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
const ids = savePlayQueueMock.mock.calls[0][0] as string[];
|
const ids = savePlayQueueMock.mock.calls[0][0] as string[];
|
||||||
expect(ids.length).toBe(1000);
|
expect(ids.length).toBe(1000);
|
||||||
@@ -93,13 +98,13 @@ describe('syncQueueToServer (debounced)', () => {
|
|||||||
|
|
||||||
describe('flushQueueSyncToServer (immediate)', () => {
|
describe('flushQueueSyncToServer (immediate)', () => {
|
||||||
it('fires synchronously with no debounce', async () => {
|
it('fires synchronously with no debounce', async () => {
|
||||||
await flushQueueSyncToServer([track('a')], track('a'), 12);
|
await flushQueueSyncToServer([ref('a')], track('a'), 12);
|
||||||
expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000, 'srv-a');
|
expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000, 'srv-a');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancels a pending debounced sync first', async () => {
|
it('cancels a pending debounced sync first', async () => {
|
||||||
syncQueueToServer([track('a')], track('a'), 30);
|
syncQueueToServer([ref('a')], track('a'), 30);
|
||||||
await flushQueueSyncToServer([track('a')], track('a'), 31);
|
await flushQueueSyncToServer([ref('a')], track('a'), 31);
|
||||||
expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
|
expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
|
||||||
// After the flush returns, advancing past the debounce should not fire again.
|
// After the flush returns, advancing past the debounce should not fire again.
|
||||||
vi.advanceTimersByTime(10_000);
|
vi.advanceTimersByTime(10_000);
|
||||||
@@ -107,7 +112,7 @@ describe('flushQueueSyncToServer (immediate)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('is a no-op when currentTrack is null', async () => {
|
it('is a no-op when currentTrack is null', async () => {
|
||||||
await flushQueueSyncToServer([track('a')], null, 5);
|
await flushQueueSyncToServer([ref('a')], null, 5);
|
||||||
expect(savePlayQueueMock).not.toHaveBeenCalled();
|
expect(savePlayQueueMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,23 +123,23 @@ describe('flushQueueSyncToServer (immediate)', () => {
|
|||||||
|
|
||||||
it('records the heartbeat timestamp', async () => {
|
it('records the heartbeat timestamp', async () => {
|
||||||
expect(getLastQueueHeartbeatAt()).toBe(0);
|
expect(getLastQueueHeartbeatAt()).toBe(0);
|
||||||
await flushQueueSyncToServer([track('a')], track('a'), 5);
|
await flushQueueSyncToServer([ref('a')], track('a'), 5);
|
||||||
expect(getLastQueueHeartbeatAt()).toBe(Date.now());
|
expect(getLastQueueHeartbeatAt()).toBe(Date.now());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('flushPlayQueuePosition', () => {
|
describe('flushPlayQueuePosition', () => {
|
||||||
it('reads the current playerStore queue + playback-progress time', async () => {
|
it('reads the current playerStore queue + playback-progress time', async () => {
|
||||||
playerState.queue = [track('a'), track('b')];
|
playerState.queueItems = [ref('a'), ref('b')];
|
||||||
playerState.currentTrack = playerState.queue[0];
|
playerState.currentTrack = track('a');
|
||||||
progressSnapshot.currentTime = 42;
|
progressSnapshot.currentTime = 42;
|
||||||
await flushPlayQueuePosition();
|
await flushPlayQueuePosition();
|
||||||
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 42000, 'srv-a');
|
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 42000, 'srv-a');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is a no-op when a radio session is active', async () => {
|
it('is a no-op when a radio session is active', async () => {
|
||||||
playerState.queue = [track('a')];
|
playerState.queueItems = [ref('a')];
|
||||||
playerState.currentTrack = playerState.queue[0];
|
playerState.currentTrack = track('a');
|
||||||
playerState.currentRadio = { id: 'radio-1' };
|
playerState.currentRadio = { id: 'radio-1' };
|
||||||
await flushPlayQueuePosition();
|
await flushPlayQueuePosition();
|
||||||
expect(savePlayQueueMock).not.toHaveBeenCalled();
|
expect(savePlayQueueMock).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { savePlayQueue } from '../api/subsonicPlayQueue';
|
import { savePlayQueue } from '../api/subsonicPlayQueue';
|
||||||
import type { Track } from './playerStoreTypes';
|
import type { QueueItemRef, Track } from './playerStoreTypes';
|
||||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||||
import { getPlaybackProgressSnapshot } from './playbackProgress';
|
import { getPlaybackProgressSnapshot } from './playbackProgress';
|
||||||
import { usePlayerStore } from './playerStore';
|
import { usePlayerStore } from './playerStore';
|
||||||
@@ -26,11 +26,11 @@ const QUEUE_ID_LIMIT = 1000;
|
|||||||
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let lastQueueHeartbeatAt = 0;
|
let lastQueueHeartbeatAt = 0;
|
||||||
|
|
||||||
export function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number): void {
|
export function syncQueueToServer(queue: QueueItemRef[], currentTrack: Track | null, currentTime: number): void {
|
||||||
if (syncTimeout) clearTimeout(syncTimeout);
|
if (syncTimeout) clearTimeout(syncTimeout);
|
||||||
syncTimeout = setTimeout(() => {
|
syncTimeout = setTimeout(() => {
|
||||||
syncTimeout = null;
|
syncTimeout = null;
|
||||||
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(t => t.id);
|
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(r => r.trackId);
|
||||||
const pos = Math.floor(currentTime * 1000);
|
const pos = Math.floor(currentTime * 1000);
|
||||||
const serverId = getPlaybackServerId();
|
const serverId = getPlaybackServerId();
|
||||||
savePlayQueue(ids, currentTrack?.id, pos, serverId).catch(err => {
|
savePlayQueue(ids, currentTrack?.id, pos, serverId).catch(err => {
|
||||||
@@ -39,14 +39,14 @@ export function syncQueueToServer(queue: Track[], currentTrack: Track | null, cu
|
|||||||
}, SYNC_DEBOUNCE_MS);
|
}, SYNC_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function flushQueueSyncToServer(queue: Track[], currentTrack: Track | null, currentTime: number): Promise<void> {
|
export function flushQueueSyncToServer(queue: QueueItemRef[], currentTrack: Track | null, currentTime: number): Promise<void> {
|
||||||
if (syncTimeout) {
|
if (syncTimeout) {
|
||||||
clearTimeout(syncTimeout);
|
clearTimeout(syncTimeout);
|
||||||
syncTimeout = null;
|
syncTimeout = null;
|
||||||
}
|
}
|
||||||
if (!currentTrack || queue.length === 0) return Promise.resolve();
|
if (!currentTrack || queue.length === 0) return Promise.resolve();
|
||||||
lastQueueHeartbeatAt = Date.now();
|
lastQueueHeartbeatAt = Date.now();
|
||||||
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(t => t.id);
|
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(r => r.trackId);
|
||||||
const pos = Math.floor(currentTime * 1000);
|
const pos = Math.floor(currentTime * 1000);
|
||||||
const serverId = getPlaybackServerId();
|
const serverId = getPlaybackServerId();
|
||||||
return savePlayQueue(ids, currentTrack.id, pos, serverId).catch(err => {
|
return savePlayQueue(ids, currentTrack.id, pos, serverId).catch(err => {
|
||||||
@@ -68,7 +68,7 @@ export function getLastQueueHeartbeatAt(): number {
|
|||||||
export function flushPlayQueuePosition(): Promise<void> {
|
export function flushPlayQueuePosition(): Promise<void> {
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
if (s.currentRadio) return Promise.resolve();
|
if (s.currentRadio) return Promise.resolve();
|
||||||
return flushQueueSyncToServer(s.queue, s.currentTrack, getPlaybackProgressSnapshot().currentTime);
|
return flushQueueSyncToServer(s.queueItems, s.currentTrack, getPlaybackProgressSnapshot().currentTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test-only: drop the debounce + reset the heartbeat. */
|
/** Test-only: drop the debounce + reset the heartbeat. */
|
||||||
|
|||||||
+12
-10
@@ -5,6 +5,7 @@
|
|||||||
* to restore list scroll position after an undo/redo commit.
|
* to restore list scroll position after an undo/redo commit.
|
||||||
*/
|
*/
|
||||||
import type { PlayerState, Track } from './playerStoreTypes';
|
import type { PlayerState, Track } from './playerStoreTypes';
|
||||||
|
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
QUEUE_UNDO_MAX,
|
QUEUE_UNDO_MAX,
|
||||||
@@ -24,14 +25,17 @@ function track(id: string): Track {
|
|||||||
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
|
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function state(queue: Track[], overrides: Partial<PlayerState> = {}): PlayerState {
|
// Thin-state: the snapshot reads `queueItems`; the `tracks` arg is a convenience
|
||||||
|
// for tests — it's lowered to refs (with the currentTrack defaulting to the head).
|
||||||
|
function state(tracks: Track[], overrides: Partial<PlayerState> = {}): PlayerState {
|
||||||
return {
|
return {
|
||||||
queue,
|
queueItems: toQueueItemRefs('', tracks),
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
currentTrack: queue[0] ?? null,
|
currentTrack: tracks[0] ?? null,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
|
queueServerId: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
} as PlayerState;
|
} as PlayerState;
|
||||||
}
|
}
|
||||||
@@ -44,13 +48,11 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('queueUndoSnapshotFromState', () => {
|
describe('queueUndoSnapshotFromState', () => {
|
||||||
it('deep-clones queue tracks and currentTrack', () => {
|
it('captures the queue as thin refs and clones currentTrack', () => {
|
||||||
const original = state([track('a'), track('b')]);
|
const original = state([track('a'), track('b')]);
|
||||||
const snap = queueUndoSnapshotFromState(original);
|
const snap = queueUndoSnapshotFromState(original);
|
||||||
expect(snap.queue).not.toBe(original.queue);
|
|
||||||
expect(snap.queue[0]).not.toBe(original.queue[0]);
|
|
||||||
expect(snap.currentTrack).not.toBe(original.currentTrack);
|
expect(snap.currentTrack).not.toBe(original.currentTrack);
|
||||||
expect(snap.queue.map(t => t.id)).toEqual(['a', 'b']);
|
expect(snap.queueItems.map(r => r.trackId)).toEqual(['a', 'b']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves currentTrack=null', () => {
|
it('preserves currentTrack=null', () => {
|
||||||
@@ -69,7 +71,7 @@ describe('pushQueueUndoFromGetter', () => {
|
|||||||
it('captures the current state on top of the undo stack', () => {
|
it('captures the current state on top of the undo stack', () => {
|
||||||
pushQueueUndoFromGetter(() => state([track('a')]));
|
pushQueueUndoFromGetter(() => state([track('a')]));
|
||||||
const snap = popQueueUndoSnapshot();
|
const snap = popQueueUndoSnapshot();
|
||||||
expect(snap?.queue[0].id).toBe('a');
|
expect(snap?.queueItems[0].trackId).toBe('a');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wipes the redo stack — a fresh action invalidates redo history', () => {
|
it('wipes the redo stack — a fresh action invalidates redo history', () => {
|
||||||
@@ -100,8 +102,8 @@ describe('pushQueueUndoSnapshot / pushQueueRedoSnapshot', () => {
|
|||||||
it('undo-snapshot push keeps order LIFO', () => {
|
it('undo-snapshot push keeps order LIFO', () => {
|
||||||
pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('first')])));
|
pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('first')])));
|
||||||
pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('second')])));
|
pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('second')])));
|
||||||
expect(popQueueUndoSnapshot()?.queue[0].id).toBe('second');
|
expect(popQueueUndoSnapshot()?.queueItems[0].trackId).toBe('second');
|
||||||
expect(popQueueUndoSnapshot()?.queue[0].id).toBe('first');
|
expect(popQueueUndoSnapshot()?.queueItems[0].trackId).toBe('first');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -1,10 +1,14 @@
|
|||||||
import type { PlayerState, Track } from './playerStoreTypes';
|
import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
|
||||||
/** Hard cap on undo/redo depth — keeps memory bounded for very long sessions. */
|
/** Hard cap on undo/redo depth — keeps memory bounded for very long sessions. */
|
||||||
export const QUEUE_UNDO_MAX = 32;
|
export const QUEUE_UNDO_MAX = 32;
|
||||||
|
|
||||||
export type QueueUndoSnapshot = {
|
export type QueueUndoSnapshot = {
|
||||||
queue: Track[];
|
/** Thin queue refs (thin-state phase 4) — not hydrated `Track[]`, so 32
|
||||||
|
* snapshots of a 50k queue cost refs, not 32×50k full tracks. Rebuilt to a
|
||||||
|
* display `Track[]` through the resolver on restore. */
|
||||||
|
queueItems: QueueItemRef[];
|
||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
|
/** Kept full — one resolved playing track, restored to the engine on undo. */
|
||||||
currentTrack: Track | null;
|
currentTrack: Track | null;
|
||||||
/** Seconds — captured with the snapshot (older entries may omit). */
|
/** Seconds — captured with the snapshot (older entries may omit). */
|
||||||
currentTime?: number;
|
currentTime?: number;
|
||||||
@@ -12,6 +16,12 @@ export type QueueUndoSnapshot = {
|
|||||||
isPlaying?: boolean;
|
isPlaying?: boolean;
|
||||||
/** Main queue panel list `scrollTop` when the snapshot was taken. */
|
/** Main queue panel list `scrollTop` when the snapshot was taken. */
|
||||||
queueListScrollTop?: number;
|
queueListScrollTop?: number;
|
||||||
|
/** Canonical playback-server identity at snapshot time. Restore uses this
|
||||||
|
* for any ref it has to prepend (e.g. a still-playing track absent from the
|
||||||
|
* snapshot's queue) so a mid-restore server switch can't bind the prepended
|
||||||
|
* ref to the wrong server (B1/H3). Older in-memory entries may omit it;
|
||||||
|
* callers fall back to the live `queueServerId` in that case. */
|
||||||
|
queueServerId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const queueUndoStack: QueueUndoSnapshot[] = [];
|
const queueUndoStack: QueueUndoSnapshot[] = [];
|
||||||
@@ -50,12 +60,15 @@ export function consumePendingQueueListScrollTop(): number | undefined {
|
|||||||
export function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot {
|
export function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot {
|
||||||
const scrollTop = readQueueListScrollTopForUndo();
|
const scrollTop = readQueueListScrollTopForUndo();
|
||||||
return {
|
return {
|
||||||
queue: s.queue.map(t => ({ ...t })),
|
// Thin refs straight off the canonical list — 32 snapshots cost refs, not
|
||||||
|
// 32×50k full tracks (the undo "hidden multiplier" the thin-state plan kills).
|
||||||
|
queueItems: [...s.queueItems],
|
||||||
queueIndex: s.queueIndex,
|
queueIndex: s.queueIndex,
|
||||||
currentTrack: s.currentTrack ? { ...s.currentTrack } : null,
|
currentTrack: s.currentTrack ? { ...s.currentTrack } : null,
|
||||||
currentTime: s.currentTime,
|
currentTime: s.currentTime,
|
||||||
progress: s.progress,
|
progress: s.progress,
|
||||||
isPlaying: s.isPlaying,
|
isPlaying: s.isPlaying,
|
||||||
|
queueServerId: s.queueServerId,
|
||||||
...(scrollTop !== undefined ? { queueListScrollTop: scrollTop } : {}),
|
...(scrollTop !== undefined ? { queueListScrollTop: scrollTop } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
recordEnginePlayUrl,
|
recordEnginePlayUrl,
|
||||||
} from './playbackUrlRouting';
|
} from './playbackUrlRouting';
|
||||||
import type { PlayerState } from './playerStoreTypes';
|
import type { PlayerState } from './playerStoreTypes';
|
||||||
|
import { resolveQueueTrack } from '../utils/library/queueTrackView';
|
||||||
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
||||||
import { syncQueueToServer } from './queueSync';
|
import { syncQueueToServer } from './queueSync';
|
||||||
import { resumeRadio } from './radioPlayer';
|
import { resumeRadio } from './radioPlayer';
|
||||||
@@ -112,10 +113,14 @@ export function runResume(set: SetState, get: GetState): void {
|
|||||||
set({ isPlaying: true });
|
set({ isPlaying: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { currentTrack, queue, queueIndex, currentTime } = get();
|
const { currentTrack, queueItems, queueIndex, currentTime } = get();
|
||||||
if (!currentTrack) return;
|
if (!currentTrack) return;
|
||||||
const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null;
|
// ReplayGain album-mode neighbours (resolver cache → placeholder; only their
|
||||||
const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
|
// RG tags matter, which a placeholder lacks → fallback dB).
|
||||||
|
const coldPrev = queueIndex > 0 && queueItems[queueIndex - 1]
|
||||||
|
? resolveQueueTrack(queueItems[queueIndex - 1]) : null;
|
||||||
|
const coldNext = queueIndex + 1 < queueItems.length && queueItems[queueIndex + 1]
|
||||||
|
? resolveQueueTrack(queueItems[queueIndex + 1]) : null;
|
||||||
|
|
||||||
if (getIsAudioPaused()) {
|
if (getIsAudioPaused()) {
|
||||||
// Rust engine has audio loaded but paused — just resume it.
|
// Rust engine has audio loaded but paused — just resume it.
|
||||||
@@ -185,7 +190,7 @@ export function runResume(set: SetState, get: GetState): void {
|
|||||||
console.error('[psysonic] audio_play (cold resume) failed:', err);
|
console.error('[psysonic] audio_play (cold resume) failed:', err);
|
||||||
set({ isPlaying: false });
|
set({ isPlaying: false });
|
||||||
});
|
});
|
||||||
syncQueueToServer(queue, trackToPlay, currentTime);
|
syncQueueToServer(queueItems, trackToPlay, currentTime);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (getPlayGeneration() !== gen) return;
|
if (getPlayGeneration() !== gen) return;
|
||||||
// Fallback to currentTrack if fetch fails
|
// Fallback to currentTrack if fetch fails
|
||||||
@@ -221,7 +226,7 @@ export function runResume(set: SetState, get: GetState): void {
|
|||||||
console.error('[psysonic] audio_play (cold resume) failed:', err);
|
console.error('[psysonic] audio_play (cold resume) failed:', err);
|
||||||
set({ isPlaying: false });
|
set({ isPlaying: false });
|
||||||
});
|
});
|
||||||
syncQueueToServer(queue, currentTrack, currentTime);
|
syncQueueToServer(queueItems, currentTrack, currentTime);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { createSafeJSONStorage } from './safeStorage';
|
||||||
|
|
||||||
|
describe('createSafeJSONStorage', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows a QuotaExceededError from setItem instead of throwing', () => {
|
||||||
|
const storage = createSafeJSONStorage<{ a: number }>()!;
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new DOMException('exceeded', 'QuotaExceededError');
|
||||||
|
});
|
||||||
|
// Must NOT throw — zustand calls this from inside set(); a throw here would
|
||||||
|
// abort the calling action (this is what killed playback on huge queues).
|
||||||
|
expect(() => storage.setItem('k', { state: { a: 1 }, version: 0 })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a value through localStorage when there is room', () => {
|
||||||
|
const storage = createSafeJSONStorage<{ a: number }>()!;
|
||||||
|
storage.setItem('k2', { state: { a: 5 }, version: 0 });
|
||||||
|
expect(storage.getItem('k2')).toEqual({ state: { a: 5 }, version: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw across repeated quota failures (write stays a no-op)', () => {
|
||||||
|
const storage = createSafeJSONStorage<{ a: number }>()!;
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new DOMException('exceeded', 'QuotaExceededError');
|
||||||
|
});
|
||||||
|
expect(() => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
storage.setItem('quota-throttle-key', { state: { a: i }, version: 0 });
|
||||||
|
}
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null from getItem if the underlying read throws', () => {
|
||||||
|
const storage = createSafeJSONStorage<{ a: number }>()!;
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new Error('blocked');
|
||||||
|
});
|
||||||
|
expect(storage.getItem('missing')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { createJSONStorage, type StateStorage } from 'zustand/middleware';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `localStorage` wrapped so a failed write never throws.
|
||||||
|
*
|
||||||
|
* zustand's persist middleware calls the storage from *inside* `set()`. When a
|
||||||
|
* persisted slice grows past the origin quota (~5 MB) — e.g. a multi-thousand
|
||||||
|
* track queue — `localStorage.setItem` throws `QuotaExceededError`, and because
|
||||||
|
* that throw happens inside `set()` it aborts the calling action. That is how a
|
||||||
|
* full quota previously killed `playTrack` before it ever reached `audio_play`
|
||||||
|
* (no audio output at all on huge queues).
|
||||||
|
*
|
||||||
|
* Persistence is best-effort: a dropped write just means the in-memory store
|
||||||
|
* keeps working and the slice isn't saved this time. This is the same try/catch
|
||||||
|
* shape already used ad-hoc for direct `localStorage.setItem` calls elsewhere
|
||||||
|
* (e.g. mini-player geometry); this is its shared home for persist stores.
|
||||||
|
*/
|
||||||
|
// Warn once per key per quota-exceeded streak — a 50k+ queue persists on every
|
||||||
|
// mutation, so an unthrottled warning floods the console. Re-armed when a write
|
||||||
|
// to that key next succeeds (queue shrank back under the quota).
|
||||||
|
const quotaWarned = new Set<string>();
|
||||||
|
|
||||||
|
const safeLocalStorage: StateStorage = {
|
||||||
|
getItem: (name) => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(name);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setItem: (name, value) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(name, value);
|
||||||
|
quotaWarned.delete(name);
|
||||||
|
} catch (e) {
|
||||||
|
if (import.meta.env.DEV && !quotaWarned.has(name)) {
|
||||||
|
quotaWarned.add(name);
|
||||||
|
console.warn(
|
||||||
|
`[psysonic] persist write skipped for "${name}" (storage quota?) — further skips silenced until it fits`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeItem: (name) => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(name);
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop-in replacement for `createJSONStorage(() => localStorage)` whose writes
|
||||||
|
* never throw. Use for any persist store whose slice can grow unbounded.
|
||||||
|
*/
|
||||||
|
export const createSafeJSONStorage = <S>() => createJSONStorage<S>(() => safeLocalStorage);
|
||||||
@@ -56,7 +56,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
|
|||||||
setAtMs: Date.now(),
|
setAtMs: Date.now(),
|
||||||
});
|
});
|
||||||
clearSeekFallbackRetry();
|
clearSeekFallbackRetry();
|
||||||
s0.playTrack(s0.currentTrack, s0.queue, true);
|
s0.playTrack(s0.currentTrack, undefined, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
invoke('audio_seek', { seconds: time }).then(() => {
|
invoke('audio_seek', { seconds: time }).then(() => {
|
||||||
@@ -95,7 +95,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
|
|||||||
setSeekFallbackTrackId(s.currentTrack.id);
|
setSeekFallbackTrackId(s.currentTrack.id);
|
||||||
setSeekFallbackRestartAt(now);
|
setSeekFallbackRestartAt(now);
|
||||||
// Keep manual semantics (no crossfade) for seek recovery restarts.
|
// Keep manual semantics (no crossfade) for seek recovery restarts.
|
||||||
s.playTrack(s.currentTrack, s.queue, true);
|
s.playTrack(s.currentTrack, undefined, true);
|
||||||
}
|
}
|
||||||
scheduleSeekFallbackRetry(s.currentTrack.id, time);
|
scheduleSeekFallbackRetry(s.currentTrack.id, time);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Track } from './playerStoreTypes';
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
const { queueSongRatingMock, recordSkipStarMock, playerStateGet } = vi.hoisted(() => {
|
const { queueSongRatingMock, recordSkipStarMock, playerStateGet } = vi.hoisted(() => {
|
||||||
const playerState = {
|
const playerState = {
|
||||||
queue: [] as Track[],
|
queueServerId: 's1' as string | null,
|
||||||
currentTrack: null as Track | null,
|
currentTrack: null as Track | null,
|
||||||
userRatingOverrides: {} as Record<string, number>,
|
userRatingOverrides: {} as Record<string, number>,
|
||||||
};
|
};
|
||||||
@@ -28,6 +28,7 @@ vi.mock('./playerStore', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { applySkipStarOnManualNext } from './skipStarRating';
|
import { applySkipStarOnManualNext } from './skipStarRating';
|
||||||
|
import { seedQueueResolver, _resetQueueResolverForTest } from '../utils/library/queueTrackResolver';
|
||||||
|
|
||||||
function track(id: string, overrides: Partial<Track> = {}): Track {
|
function track(id: string, overrides: Partial<Track> = {}): Track {
|
||||||
return {
|
return {
|
||||||
@@ -38,8 +39,9 @@ function track(id: string, overrides: Partial<Track> = {}): Track {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
queueSongRatingMock.mockClear();
|
queueSongRatingMock.mockClear();
|
||||||
recordSkipStarMock.mockReset();
|
recordSkipStarMock.mockReset();
|
||||||
|
_resetQueueResolverForTest();
|
||||||
const s = playerStateGet();
|
const s = playerStateGet();
|
||||||
s.queue = [];
|
s.queueServerId = 's1';
|
||||||
s.currentTrack = null;
|
s.currentTrack = null;
|
||||||
s.userRatingOverrides = {};
|
s.userRatingOverrides = {};
|
||||||
});
|
});
|
||||||
@@ -76,9 +78,10 @@ describe('applySkipStarOnManualNext', () => {
|
|||||||
expect(queueSongRatingMock).not.toHaveBeenCalled();
|
expect(queueSongRatingMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips rating when the queue entry is already rated', () => {
|
it('skips rating when the resolver-cached queue entry is already rated', () => {
|
||||||
recordSkipStarMock.mockReturnValueOnce({ crossedThreshold: true });
|
recordSkipStarMock.mockReturnValueOnce({ crossedThreshold: true });
|
||||||
playerStateGet().queue = [track('t1', { userRating: 4 })];
|
// Thin-state: the queue's track copy lives in the resolver cache.
|
||||||
|
seedQueueResolver('s1', [track('t1', { userRating: 4 })]);
|
||||||
applySkipStarOnManualNext(track('t1'), true);
|
applySkipStarOnManualNext(track('t1'), true);
|
||||||
expect(queueSongRatingMock).not.toHaveBeenCalled();
|
expect(queueSongRatingMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Track } from './playerStoreTypes';
|
import type { Track } from './playerStoreTypes';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { usePlayerStore } from './playerStore';
|
import { usePlayerStore } from './playerStore';
|
||||||
|
import { getCachedTrack } from '../utils/library/queueTrackResolver';
|
||||||
import { queueSongRating } from './pendingStarSync';
|
import { queueSongRating } from './pendingStarSync';
|
||||||
/**
|
/**
|
||||||
* Skip → 1★ behaviour: every user-initiated `next()` on an unrated track
|
* Skip → 1★ behaviour: every user-initiated `next()` on an unrated track
|
||||||
@@ -18,10 +19,12 @@ export function applySkipStarOnManualNext(skippedTrack: Track | null, manual: bo
|
|||||||
const adv = useAuthStore.getState().recordSkipStarManualAdvance(id);
|
const adv = useAuthStore.getState().recordSkipStarManualAdvance(id);
|
||||||
if (!adv?.crossedThreshold) return;
|
if (!adv?.crossedThreshold) return;
|
||||||
const live = usePlayerStore.getState();
|
const live = usePlayerStore.getState();
|
||||||
const fromQueue = live.queue.find(t => t.id === id);
|
// Thin-state: the queue's copy of the rating now lives in the resolver cache.
|
||||||
|
const sid = live.queueServerId ?? '';
|
||||||
|
const fromCache = sid ? getCachedTrack({ serverId: sid, trackId: id }) : undefined;
|
||||||
const cur =
|
const cur =
|
||||||
live.userRatingOverrides[id] ??
|
live.userRatingOverrides[id] ??
|
||||||
fromQueue?.userRating ??
|
fromCache?.userRating ??
|
||||||
skippedTrack.userRating ??
|
skippedTrack.userRating ??
|
||||||
0;
|
0;
|
||||||
if (cur >= 1) return;
|
if (cur >= 1) return;
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
|
|||||||
// server with the right resume point for other devices.
|
// server with the right resume point for other devices.
|
||||||
const s = get();
|
const s = get();
|
||||||
if (s.currentTrack) {
|
if (s.currentTrack) {
|
||||||
void flushQueueSyncToServer(s.queue, s.currentTrack, s.currentTime);
|
void flushQueueSyncToServer(s.queueItems, s.currentTrack, s.currentTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
||||||
|
|||||||
@@ -37,9 +37,10 @@ export function createUiStateActions(set: SetState): Pick<
|
|||||||
const nextOverrides = { ...s.userRatingOverrides };
|
const nextOverrides = { ...s.userRatingOverrides };
|
||||||
if (rating === 0) delete nextOverrides[id];
|
if (rating === 0) delete nextOverrides[id];
|
||||||
else nextOverrides[id] = rating;
|
else nextOverrides[id] = rating;
|
||||||
|
// Thin-state: the queue's copy lives in the resolver cache; the override
|
||||||
|
// map (merged on read via applyQueueOverrides) drives the queue-row UI.
|
||||||
return {
|
return {
|
||||||
userRatingOverrides: nextOverrides,
|
userRatingOverrides: nextOverrides,
|
||||||
queue: s.queue.map(t => (t.id === id ? { ...t, userRating: rating } : t)),
|
|
||||||
currentTrack:
|
currentTrack:
|
||||||
s.currentTrack?.id === id ? { ...s.currentTrack, userRating: rating } : s.currentTrack,
|
s.currentTrack?.id === id ? { ...s.currentTrack, userRating: rating } : s.currentTrack,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
|
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
|
||||||
import { invokeAudioUpdateReplayGainDeduped } from './normalizationIpcDedupe';
|
import { invokeAudioUpdateReplayGainDeduped } from './normalizationIpcDedupe';
|
||||||
import type { PlayerState } from './playerStoreTypes';
|
import type { PlayerState } from './playerStoreTypes';
|
||||||
|
import { resolveQueueTrack } from '../utils/library/queueTrackView';
|
||||||
|
|
||||||
type SetState = (
|
type SetState = (
|
||||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||||
@@ -33,11 +34,14 @@ type GetState = () => PlayerState;
|
|||||||
* deduplicated IPC channel.
|
* deduplicated IPC channel.
|
||||||
*/
|
*/
|
||||||
export function runUpdateReplayGainForCurrentTrack(set: SetState, get: GetState): void {
|
export function runUpdateReplayGainForCurrentTrack(set: SetState, get: GetState): void {
|
||||||
const { currentTrack, queue, queueIndex, volume } = get();
|
const { currentTrack, queueItems, queueIndex, volume } = get();
|
||||||
if (!currentTrack || !currentTrack.id) return;
|
if (!currentTrack || !currentTrack.id) return;
|
||||||
const authState = useAuthStore.getState();
|
const authState = useAuthStore.getState();
|
||||||
const prev = queueIndex > 0 ? queue[queueIndex - 1] : null;
|
// ReplayGain album-mode neighbours, resolved from refs (cache → placeholder).
|
||||||
const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
|
const prev = queueIndex > 0 && queueItems[queueIndex - 1]
|
||||||
|
? resolveQueueTrack(queueItems[queueIndex - 1]) : null;
|
||||||
|
const next = queueIndex + 1 < queueItems.length && queueItems[queueIndex + 1]
|
||||||
|
? resolveQueueTrack(queueItems[queueIndex + 1]) : null;
|
||||||
const replayGainDb = resolveReplayGainDb(
|
const replayGainDb = resolveReplayGainDb(
|
||||||
currentTrack, prev, next,
|
currentTrack, prev, next,
|
||||||
isReplayGainActive(), authState.replayGainMode,
|
isReplayGainActive(), authState.replayGainMode,
|
||||||
@@ -46,7 +50,9 @@ export function runUpdateReplayGainForCurrentTrack(set: SetState, get: GetState)
|
|||||||
? (currentTrack.replayGainPeak ?? null)
|
? (currentTrack.replayGainPeak ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const normalization = deriveNormalizationSnapshot(currentTrack, queue, queueIndex);
|
// Neighbour window for the normalization snapshot: prev, current, next.
|
||||||
|
const normWindow = [prev ?? currentTrack, currentTrack, ...(next ? [next] : [])];
|
||||||
|
const normalization = deriveNormalizationSnapshot(currentTrack, normWindow, prev ? 1 : 0);
|
||||||
const cachedLoud = getCachedLoudnessGain(currentTrack.id);
|
const cachedLoud = getCachedLoudnessGain(currentTrack.id);
|
||||||
const cachedLoudDb = Number.isFinite(cachedLoud) ? cachedLoud! : null;
|
const cachedLoudDb = Number.isFinite(cachedLoud) ? cachedLoud! : null;
|
||||||
const haveStableLoud = hasStableLoudness(currentTrack.id);
|
const haveStableLoud = hasStableLoudness(currentTrack.id);
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
import type { SubsonicSong } from '@/api/subsonicTypes';
|
import type { SubsonicSong } from '@/api/subsonicTypes';
|
||||||
import type { ServerProfile } from '@/store/authStoreTypes';
|
import type { ServerProfile } from '@/store/authStoreTypes';
|
||||||
import type { Track } from '@/store/playerStoreTypes';
|
import type { Track } from '@/store/playerStoreTypes';
|
||||||
|
import { usePlayerStore } from '@/store/playerStore';
|
||||||
|
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||||
|
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
|
||||||
let trackCounter = 0;
|
let trackCounter = 0;
|
||||||
let songCounter = 0;
|
let songCounter = 0;
|
||||||
let serverCounter = 0;
|
let serverCounter = 0;
|
||||||
@@ -71,6 +74,31 @@ export function makeAuthState(opts: { servers?: ServerProfile[]; activeServerId?
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed the player store with a queue (thin-state). Replaces the old
|
||||||
|
* `setState({ queue: [...] })` seeds: it seeds the resolver cache with the full
|
||||||
|
* `tracks` (so rows / hot paths resolve them) AND sets the canonical
|
||||||
|
* `queueItems` refs + index + currentTrack + queueServerId. Pass `serverId` to
|
||||||
|
* match the active server when the test resolves through a real server id;
|
||||||
|
* defaults to `''` (the resolver / refs use that same empty id).
|
||||||
|
*/
|
||||||
|
export function seedQueue(
|
||||||
|
tracks: Track[],
|
||||||
|
opts: { index?: number; currentTrack?: Track | null; serverId?: string } = {},
|
||||||
|
): void {
|
||||||
|
const serverId = opts.serverId ?? '';
|
||||||
|
const index = opts.index ?? 0;
|
||||||
|
const currentTrack =
|
||||||
|
opts.currentTrack === undefined ? (tracks[index] ?? null) : opts.currentTrack;
|
||||||
|
seedQueueResolver(serverId, tracks);
|
||||||
|
usePlayerStore.setState({
|
||||||
|
queueItems: toQueueItemRefs(serverId, tracks),
|
||||||
|
queueIndex: index,
|
||||||
|
currentTrack,
|
||||||
|
queueServerId: serverId || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Minimal `usePlayerStore.setState(...)` partial for queue characterization tests. */
|
/** Minimal `usePlayerStore.setState(...)` partial for queue characterization tests. */
|
||||||
export function makeQueueState(opts: { queue?: Track[]; currentIndex?: number; currentTrack?: Track | null } = {}) {
|
export function makeQueueState(opts: { queue?: Track[]; currentIndex?: number; currentTrack?: Track | null } = {}) {
|
||||||
const queue = opts.queue ?? makeTracks(3);
|
const queue = opts.queue ?? makeTracks(3);
|
||||||
|
|||||||
@@ -66,5 +66,6 @@ export function restartPlaybackForRateChange(): void {
|
|||||||
setAtMs: Date.now(),
|
setAtMs: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
player.playTrack(track, player.queue, true);
|
// No-arg queue: keep the canonical refs, restart the current track in place.
|
||||||
|
player.playTrack(track, undefined, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { buildDownloadUrl } from '../../api/subsonicStreamUrl';
|
|||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
|
import { resolveQueueTrack } from '../library/queueTrackView';
|
||||||
import { useZipDownloadStore } from '../../store/zipDownloadStore';
|
import { useZipDownloadStore } from '../../store/zipDownloadStore';
|
||||||
import { useDownloadModalStore } from '../../store/downloadModalStore';
|
import { useDownloadModalStore } from '../../store/downloadModalStore';
|
||||||
import type { EntityShareKind } from '../share/shareLink';
|
import type { EntityShareKind } from '../share/shareLink';
|
||||||
@@ -90,8 +91,13 @@ export async function startRadio(
|
|||||||
.filter(t => t.id !== topTracks[0].id),
|
.filter(t => t.id !== topTracks[0].id),
|
||||||
);
|
);
|
||||||
if (similarTracks.length === 0) return;
|
if (similarTracks.length === 0) return;
|
||||||
const { queue, queueIndex } = usePlayerStore.getState();
|
const { queueItems, queueIndex } = usePlayerStore.getState();
|
||||||
const pendingRadio = queue.slice(queueIndex + 1).filter(t => t.radioAdded);
|
// Thin-state: resolve the upcoming radio refs (cache-warm window) back to
|
||||||
|
// Tracks so they merge with the new similars in enqueueRadio.
|
||||||
|
const pendingRadio = queueItems
|
||||||
|
.slice(queueIndex + 1)
|
||||||
|
.filter(r => r.radioAdded)
|
||||||
|
.map(r => resolveQueueTrack(r));
|
||||||
usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId);
|
usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
|
import { resolveQueueTrack } from '../library/queueTrackView';
|
||||||
import type { MiniSyncPayload, MiniTrackInfo } from '../miniPlayerBridge';
|
import type { MiniSyncPayload, MiniTrackInfo } from '../miniPlayerBridge';
|
||||||
|
|
||||||
|
/** Half-width of the mini initial-snapshot queue window (matches the bridge). */
|
||||||
|
const MINI_SNAPSHOT_HALF = 100;
|
||||||
|
|
||||||
export const COLLAPSED_SIZE = { w: 340, h: 260 };
|
export const COLLAPSED_SIZE = { w: 340, h: 260 };
|
||||||
export const EXPANDED_SIZE = { w: 340, h: 500 };
|
export const EXPANDED_SIZE = { w: 340, h: 500 };
|
||||||
// Minimum window dimensions per state. When the queue is open the floor must
|
// Minimum window dimensions per state. When the queue is open the floor must
|
||||||
@@ -54,10 +58,17 @@ export function toMini(t: any): MiniTrackInfo {
|
|||||||
export function initialSnapshot(): MiniSyncPayload {
|
export function initialSnapshot(): MiniSyncPayload {
|
||||||
try {
|
try {
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
|
// Thin-state: resolve a window around the index (resolver cache →
|
||||||
|
// placeholder), remapping queueIndex like the live bridge snapshot.
|
||||||
|
const idx = s.queueIndex ?? 0;
|
||||||
|
const start = Math.max(0, idx - MINI_SNAPSHOT_HALF);
|
||||||
|
const windowed = (s.queueItems ?? [])
|
||||||
|
.slice(start, idx + MINI_SNAPSHOT_HALF + 1)
|
||||||
|
.map(r => resolveQueueTrack(r));
|
||||||
return {
|
return {
|
||||||
track: s.currentTrack ? toMini(s.currentTrack) : null,
|
track: s.currentTrack ? toMini(s.currentTrack) : null,
|
||||||
queue: (s.queue ?? []).map(toMini),
|
queue: windowed.map(toMini),
|
||||||
queueIndex: s.queueIndex ?? 0,
|
queueIndex: idx - start,
|
||||||
queueServerId: s.queueServerId ?? null,
|
queueServerId: s.queueServerId ?? null,
|
||||||
isPlaying: s.isPlaying,
|
isPlaying: s.isPlaying,
|
||||||
volume: s.volume ?? 1,
|
volume: s.volume ?? 1,
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
|
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
|
||||||
|
import { canonicalQueueServerKey } from '../server/serverIndexKey';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive thin `QueueItemRef`s from a `Track[]` queue (thin-state). Per-item
|
* Derive thin `QueueItemRef`s from a `Track[]` queue (thin-state). Per-item
|
||||||
* `serverId` is the single playback server in v1; queue-only flags are carried
|
* `serverId` is the canonical server index key — every writer normalizes here
|
||||||
* through, others omitted to keep the persisted/derived list small. Pure — no
|
* so refs are unambiguous across mixed-server queues (same `trackId` on two
|
||||||
* store import, so both `playerStore` (persist) and the resolver bridge can use
|
* servers must collide on nothing, since the resolver uses `serverId:trackId`).
|
||||||
* it without a circular dependency.
|
* Queue-only flags are carried through, others omitted to keep the persisted /
|
||||||
|
* derived list small. Pure — no store import beyond the canonicalizer, so both
|
||||||
|
* `playerStore` (persist) and the resolver bridge can use it without a
|
||||||
|
* circular dependency.
|
||||||
*/
|
*/
|
||||||
export function toQueueItemRefs(serverId: string, queue: Track[]): QueueItemRef[] {
|
export function toQueueItemRefs(serverId: string, queue: Track[]): QueueItemRef[] {
|
||||||
|
const canonicalId = canonicalQueueServerKey(serverId);
|
||||||
return queue.map(t => {
|
return queue.map(t => {
|
||||||
const ref: QueueItemRef = { serverId, trackId: t.id };
|
const ref: QueueItemRef = { serverId: canonicalId, trackId: t.id };
|
||||||
if (t.autoAdded) ref.autoAdded = true;
|
if (t.autoAdded) ref.autoAdded = true;
|
||||||
if (t.radioAdded) ref.radioAdded = true;
|
if (t.radioAdded) ref.radioAdded = true;
|
||||||
if (t.playNextAdded) ref.playNextAdded = true;
|
if (t.playNextAdded) ref.playNextAdded = true;
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
|||||||
import { usePlayerStore } from '@/store/playerStore';
|
import { usePlayerStore } from '@/store/playerStore';
|
||||||
import type { TrackRefDto } from '@/api/library';
|
import type { TrackRefDto } from '@/api/library';
|
||||||
import type { Track } from '@/store/playerStoreTypes';
|
import type { Track } from '@/store/playerStoreTypes';
|
||||||
|
import {
|
||||||
|
getCachedTrack,
|
||||||
|
_resetQueueResolverForTest,
|
||||||
|
} from './queueTrackResolver';
|
||||||
import { hydrateQueueFromIndex } from './queueRestore';
|
import { hydrateQueueFromIndex } from './queueRestore';
|
||||||
|
|
||||||
const ready = () =>
|
const ready = () =>
|
||||||
@@ -34,72 +38,39 @@ const track = (id: string): Track => ({ id, title: id, artist: '', album: 'A', a
|
|||||||
|
|
||||||
function seedStore(over: Partial<ReturnType<typeof usePlayerStore.getState>> = {}) {
|
function seedStore(over: Partial<ReturnType<typeof usePlayerStore.getState>> = {}) {
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue: [track('w1')],
|
|
||||||
queueServerId: 's1',
|
queueServerId: 's1',
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
currentTrack: null,
|
currentTrack: null,
|
||||||
|
queueItems: [],
|
||||||
|
queueItemsIndex: undefined,
|
||||||
queueRefs: undefined,
|
queueRefs: undefined,
|
||||||
queueRefsIndex: undefined,
|
queueRefsIndex: undefined,
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin-state `hydrateQueueFromIndex`: the store is refs-canonical, so cold
|
||||||
|
* restore eagerly resolves the whole `queueItems` ref list into the resolver
|
||||||
|
* cache (index batch → getSong fallback) and clears the restore-pending
|
||||||
|
* sentinel. It no longer swaps a fat `Track[]` into the store — `queueItems`
|
||||||
|
* stays the source of truth.
|
||||||
|
*/
|
||||||
describe('hydrateQueueFromIndex', () => {
|
describe('hydrateQueueFromIndex', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||||
|
_resetQueueResolverForTest();
|
||||||
seedStore();
|
seedStore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does nothing without persisted refs', async () => {
|
it('does nothing without a restore-pending sentinel', async () => {
|
||||||
seedStore({ queueRefs: undefined });
|
seedStore({ queueItems: [{ serverId: 's1', trackId: 'w1' }], queueItemsIndex: undefined });
|
||||||
await hydrateQueueFromIndex();
|
await hydrateQueueFromIndex();
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['w1']);
|
// No resolve dispatched (no sentinel) → nothing cached.
|
||||||
|
expect(getCachedTrack({ serverId: 's1', trackId: 'w1' })).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the windowed fallback when the index is not ready', async () => {
|
it('resolves the whole queueItems ref list into the resolver cache and clears the sentinel', async () => {
|
||||||
onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' }));
|
|
||||||
seedStore({ queueRefs: ['t1', 't2', 't3'], queueRefsIndex: 1 });
|
|
||||||
await hydrateQueueFromIndex();
|
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['w1']);
|
|
||||||
expect(usePlayerStore.getState().queueRefs).toEqual(['t1', 't2', 't3']); // not cleared
|
|
||||||
});
|
|
||||||
|
|
||||||
it('restores the full queue and re-locates the current track when ready', async () => {
|
|
||||||
ready();
|
|
||||||
echoBatch();
|
|
||||||
seedStore({
|
|
||||||
queueRefs: ['t1', 't2', 't3'],
|
|
||||||
queueRefsIndex: 1,
|
|
||||||
currentTrack: track('t2'),
|
|
||||||
});
|
|
||||||
await hydrateQueueFromIndex();
|
|
||||||
const s = usePlayerStore.getState();
|
|
||||||
expect(s.queue.map(t => t.id)).toEqual(['t1', 't2', 't3']);
|
|
||||||
expect(s.queueIndex).toBe(1); // re-located to current track t2
|
|
||||||
expect(s.queueRefs).toBeUndefined(); // cleared after success
|
|
||||||
});
|
|
||||||
|
|
||||||
it('batches refs in chunks of 100', async () => {
|
|
||||||
ready();
|
|
||||||
echoBatch();
|
|
||||||
const refs = Array.from({ length: 150 }, (_, i) => `t${i}`);
|
|
||||||
seedStore({ queueRefs: refs, queueRefsIndex: 0 });
|
|
||||||
await hydrateQueueFromIndex();
|
|
||||||
expect(usePlayerStore.getState().queue).toHaveLength(150);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the fallback when the current track is not in the hydrated list', async () => {
|
|
||||||
ready();
|
|
||||||
echoBatch();
|
|
||||||
seedStore({
|
|
||||||
queueRefs: ['t1', 't2'],
|
|
||||||
currentTrack: track('gone'),
|
|
||||||
});
|
|
||||||
await hydrateQueueFromIndex();
|
|
||||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['w1']); // unchanged
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hydrates from queueItems (preferred) and clears the ref lists', async () => {
|
|
||||||
ready();
|
ready();
|
||||||
echoBatch();
|
echoBatch();
|
||||||
seedStore({
|
seedStore({
|
||||||
@@ -113,17 +84,31 @@ describe('hydrateQueueFromIndex', () => {
|
|||||||
});
|
});
|
||||||
await hydrateQueueFromIndex();
|
await hydrateQueueFromIndex();
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
expect(s.queue.map(t => t.id)).toEqual(['t1', 't2', 't3']);
|
// queueItems stays canonical (no fat-array swap).
|
||||||
expect(s.queueIndex).toBe(1); // re-located to current track t2
|
expect(s.queueItems.map(r => r.trackId)).toEqual(['t1', 't2', 't3']);
|
||||||
expect(s.queueItems).toBeUndefined();
|
// Restore-pending sentinel cleared so it runs at most once.
|
||||||
expect(s.queueRefs).toBeUndefined();
|
expect(s.queueItemsIndex).toBeUndefined();
|
||||||
|
// Every ref was resolved into the cache.
|
||||||
|
expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1');
|
||||||
|
expect(getCachedTrack({ serverId: 's1', trackId: 't3' })?.id).toBe('t3');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('upgrades a legacy queueRefs-only store (no queueItems) via queueServerId', async () => {
|
it('batches refs in chunks of 100', async () => {
|
||||||
|
ready();
|
||||||
|
echoBatch();
|
||||||
|
const items = Array.from({ length: 150 }, (_, i) => ({ serverId: 's1', trackId: `t${i}` }));
|
||||||
|
seedStore({ queueItems: items, queueItemsIndex: 0 });
|
||||||
|
await hydrateQueueFromIndex();
|
||||||
|
// All 150 resolved into the cache (the resolver chunks ≤100/call internally).
|
||||||
|
expect(getCachedTrack({ serverId: 's1', trackId: 't0' })?.id).toBe('t0');
|
||||||
|
expect(getCachedTrack({ serverId: 's1', trackId: 't149' })?.id).toBe('t149');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upgrades a legacy queueRefs-only blob via queueServerId, then clears it', async () => {
|
||||||
ready();
|
ready();
|
||||||
echoBatch();
|
echoBatch();
|
||||||
seedStore({
|
seedStore({
|
||||||
queueItems: undefined, // pre-Phase-1 persist shape
|
queueItems: [], // pre-thin-state in-memory shape
|
||||||
queueRefs: ['t1', 't2', 't3'],
|
queueRefs: ['t1', 't2', 't3'],
|
||||||
queueRefsIndex: 1,
|
queueRefsIndex: 1,
|
||||||
queueServerId: 's1',
|
queueServerId: 's1',
|
||||||
@@ -131,28 +116,23 @@ describe('hydrateQueueFromIndex', () => {
|
|||||||
});
|
});
|
||||||
await hydrateQueueFromIndex();
|
await hydrateQueueFromIndex();
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
expect(s.queue.map(t => t.id)).toEqual(['t1', 't2', 't3']);
|
// Legacy refs resolved into the cache and the legacy fields cleared.
|
||||||
expect(s.queueIndex).toBe(1);
|
expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1');
|
||||||
expect(s.queueRefs).toBeUndefined(); // both ref lists cleared after success
|
expect(getCachedTrack({ serverId: 's1', trackId: 't3' })?.id).toBe('t3');
|
||||||
expect(s.queueItems).toBeUndefined();
|
expect(s.queueItemsIndex).toBeUndefined();
|
||||||
|
expect(s.queueRefs).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('carries queue-only flags from queueItems onto hydrated tracks', async () => {
|
it('clears the sentinel even when the index is not ready (best-effort getSong fallback runs)', async () => {
|
||||||
ready();
|
onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' }));
|
||||||
echoBatch();
|
onInvoke('library_get_tracks_batch', () => []);
|
||||||
seedStore({
|
seedStore({
|
||||||
queueItems: [
|
queueItems: [{ serverId: 's1', trackId: 't1' }],
|
||||||
{ serverId: 's1', trackId: 't1' },
|
|
||||||
{ serverId: 's1', trackId: 't2', radioAdded: true },
|
|
||||||
{ serverId: 's1', trackId: 't3', autoAdded: true, playNextAdded: true },
|
|
||||||
],
|
|
||||||
queueItemsIndex: 0,
|
queueItemsIndex: 0,
|
||||||
});
|
});
|
||||||
await hydrateQueueFromIndex();
|
await hydrateQueueFromIndex();
|
||||||
const q = usePlayerStore.getState().queue;
|
// Sentinel cleared up front so the eager resolve runs at most once; refs stay.
|
||||||
expect(q.find(t => t.id === 't1')?.radioAdded).toBeUndefined();
|
expect(usePlayerStore.getState().queueItemsIndex).toBeUndefined();
|
||||||
expect(q.find(t => t.id === 't2')?.radioAdded).toBe(true);
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['t1']);
|
||||||
expect(q.find(t => t.id === 't3')?.autoAdded).toBe(true);
|
|
||||||
expect(q.find(t => t.id === 't3')?.playNextAdded).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,89 +1,60 @@
|
|||||||
import { libraryGetTracksBatch, type LibraryTrackDto, type TrackRefDto } from '../../api/library';
|
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { QueueItemRef } from '../../store/playerStoreTypes';
|
||||||
import { songToTrack } from '../playback/songToTrack';
|
import { canonicalQueueServerKey } from '../server/serverIndexKey';
|
||||||
import { trackToSong } from './advancedSearchLocal';
|
import { resolveBatch } from './queueTrackResolver';
|
||||||
import { libraryIsReady } from './libraryReady';
|
|
||||||
|
|
||||||
/** `library_get_tracks_batch` cap (spec §8.6 — max 100 refs/call). */
|
|
||||||
const BATCH = 100;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-queue restore. The player store rehydrates a *windowed* `queue` plus a
|
* Full-queue restore (thin-state, decision B). The player store rehydrates the
|
||||||
* full thin-ref list. When the library index is ready for the queue's server,
|
* whole thin `queueItems` ref list from localStorage on startup; this eagerly
|
||||||
* hydrate the entire queue from the index (`library_get_tracks_batch`, ≤100
|
* resolves every ref into the resolver cache so the queue UI / playback paths
|
||||||
* refs/call) and swap it in, re-locating the current track so the playback
|
* have real `Track` metadata. `resolveBatch` does the index batch
|
||||||
* position stays correct even if some refs were dropped (unknown to the index).
|
* (`library_get_tracks_batch`, ≤100 refs/call) → `getSong` network fallback (P8)
|
||||||
|
* internally, so the queue is never empty even with the index off (the P6
|
||||||
|
* default — every ref still resolves via getSong).
|
||||||
*
|
*
|
||||||
* Phase 1: prefers `queueItems` (per-item serverId + queue-only flags) and
|
* Clears the restore-pending sentinel (`queueItemsIndex`) once the eager resolve
|
||||||
* carries those flags onto the hydrated tracks; falls back to the legacy
|
* is dispatched so it runs at most once; `queueItems` stays canonical. Legacy
|
||||||
* `queueRefs` string list for stores persisted before Phase 1.
|
* pre-thin-state blobs that only carried `queueRefs` were normalised into
|
||||||
*
|
* `queueItems` by the store's persist `merge`, so this reads `queueItems` only.
|
||||||
* Best-effort: missing refs / index not ready / any failure leave the windowed
|
|
||||||
* `queue` untouched — no regression when the index is off (the P6 default).
|
|
||||||
* Clears the ref lists once a full hydrate succeeds so it runs at most once.
|
|
||||||
*/
|
*/
|
||||||
export async function hydrateQueueFromIndex(): Promise<void> {
|
export async function hydrateQueueFromIndex(): Promise<void> {
|
||||||
const player = usePlayerStore.getState();
|
const player = usePlayerStore.getState();
|
||||||
|
|
||||||
const items = player.queueItems;
|
// Restore-pending sentinel: `partialize` writes `queueItemsIndex` alongside
|
||||||
let refs: TrackRefDto[] | null = null;
|
// the full `queueItems` on every persist, so a fresh rehydrate carries it
|
||||||
if (items?.length) {
|
// back. Normal in-memory mutations keep `queueItems` canonical but never set
|
||||||
refs = items.map(it => ({ serverId: it.serverId, trackId: it.trackId }));
|
// the index, so its presence — not a non-empty `queueItems` — marks "this
|
||||||
} else if (player.queueRefs?.length) {
|
// restored queue still needs an eager resolve". Without it (steady state /
|
||||||
const sid = player.queueServerId ?? useAuthStore.getState().activeServerId;
|
// later server switch) there is nothing to do.
|
||||||
if (sid) refs = player.queueRefs.map(trackId => ({ serverId: sid, trackId }));
|
const restorePending =
|
||||||
|
player.queueItemsIndex !== undefined || (player.queueRefs?.length ?? 0) > 0;
|
||||||
|
if (!restorePending) return;
|
||||||
|
|
||||||
|
let refs: QueueItemRef[] = player.queueItems ?? [];
|
||||||
|
if (refs.length === 0 && player.queueRefs?.length) {
|
||||||
|
const rawSid = player.queueServerId ?? useAuthStore.getState().activeServerId ?? '';
|
||||||
|
const sid = canonicalQueueServerKey(rawSid);
|
||||||
|
refs = player.queueRefs.map(trackId => ({ serverId: sid, trackId }));
|
||||||
}
|
}
|
||||||
if (!refs || refs.length === 0) return;
|
|
||||||
|
|
||||||
const clearRefs = () =>
|
// Clear the restore-pending sentinel + any legacy refs; `queueItems` stays the
|
||||||
usePlayerStore.setState({
|
// canonical mirror. Done up front so a later resolve never re-triggers.
|
||||||
queueItems: undefined, queueItemsIndex: undefined,
|
usePlayerStore.setState({
|
||||||
queueRefs: undefined, queueRefsIndex: undefined,
|
queueItemsIndex: undefined,
|
||||||
});
|
queueRefs: undefined,
|
||||||
|
queueRefsIndex: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
// v1 is single-server; gate readiness on the queue's server.
|
if (refs.length === 0) return;
|
||||||
const serverId = refs[0].serverId || player.queueServerId || useAuthStore.getState().activeServerId;
|
|
||||||
if (!serverId) {
|
|
||||||
clearRefs();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Keep the windowed fallback (and the refs, for a later ready startup) when
|
|
||||||
// the index can't serve the queue yet.
|
|
||||||
if (!(await libraryIsReady(serverId))) return;
|
|
||||||
|
|
||||||
|
// Eager resolve of the whole queue into the resolver cache (best-effort —
|
||||||
|
// index batch when ready, else getSong window fallback so the queue plays
|
||||||
|
// even with the index off). Failures leave refs as placeholders until a row
|
||||||
|
// scrolls into view and the resolver bridge fetches them.
|
||||||
try {
|
try {
|
||||||
const dtos: LibraryTrackDto[] = [];
|
await resolveBatch(refs);
|
||||||
for (let i = 0; i < refs.length; i += BATCH) {
|
|
||||||
dtos.push(...(await libraryGetTracksBatch(refs.slice(i, i + BATCH))));
|
|
||||||
}
|
|
||||||
if (dtos.length === 0) return; // index has none of them → keep fallback
|
|
||||||
|
|
||||||
// The index doesn't store queue-only flags (radio/auto/play-next dividers),
|
|
||||||
// so carry them from the refs onto the hydrated tracks.
|
|
||||||
const flags = new Map(items?.map(it => [it.trackId, it]));
|
|
||||||
const hydrated: Track[] = dtos.map(d => {
|
|
||||||
const t = songToTrack(trackToSong(d));
|
|
||||||
const f = flags.get(t.id);
|
|
||||||
if (f?.autoAdded) t.autoAdded = true;
|
|
||||||
if (f?.radioAdded) t.radioAdded = true;
|
|
||||||
if (f?.playNextAdded) t.playNextAdded = true;
|
|
||||||
return t;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-locate the current track so queueIndex stays aligned with playback.
|
|
||||||
const cur = usePlayerStore.getState().currentTrack;
|
|
||||||
const idx = cur ? hydrated.findIndex(t => t.id === cur.id) : -1;
|
|
||||||
if (cur && idx < 0) return; // can't align playback → keep windowed fallback
|
|
||||||
|
|
||||||
usePlayerStore.setState({
|
|
||||||
queue: hydrated,
|
|
||||||
queueIndex: idx >= 0 ? idx : 0,
|
|
||||||
queueItems: undefined, queueItemsIndex: undefined,
|
|
||||||
queueRefs: undefined, queueRefsIndex: undefined,
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort; the windowed fallback stays in place
|
/* best-effort */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getSong } from '../../api/subsonicLibrary';
|
|||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
|
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
|
||||||
import { songToTrack } from '../playback/songToTrack';
|
import { songToTrack } from '../playback/songToTrack';
|
||||||
|
import { canonicalQueueServerKey } from '../server/serverIndexKey';
|
||||||
import { trackToSong } from './advancedSearchLocal';
|
import { trackToSong } from './advancedSearchLocal';
|
||||||
import { libraryIsReady } from './libraryReady';
|
import { libraryIsReady } from './libraryReady';
|
||||||
|
|
||||||
@@ -30,26 +31,24 @@ const refKey = (r: { serverId: string; trackId: string }) => `${r.serverId}:${r.
|
|||||||
const cache = new Map<string, Track>();
|
const cache = new Map<string, Track>();
|
||||||
const inFlight = new Set<string>();
|
const inFlight = new Set<string>();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
let cacheVersion = 0;
|
||||||
|
|
||||||
function notify(): void {
|
function notify(): void {
|
||||||
|
cacheVersion++;
|
||||||
for (const l of listeners) l();
|
for (const l of listeners) l();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Monotonic version, bumped on every cache change (for `useSyncExternalStore`). */
|
||||||
|
export function getQueueResolverVersion(): number {
|
||||||
|
return cacheVersion;
|
||||||
|
}
|
||||||
|
|
||||||
/** Subscribe to cache changes (for `useSyncExternalStore` selectors). */
|
/** Subscribe to cache changes (for `useSyncExternalStore` selectors). */
|
||||||
export function subscribeQueueResolver(cb: () => void): () => void {
|
export function subscribeQueueResolver(cb: () => void): () => void {
|
||||||
listeners.add(cb);
|
listeners.add(cb);
|
||||||
return () => { listeners.delete(cb); };
|
return () => { listeners.delete(cb); };
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheTouch(key: string): Track | undefined {
|
|
||||||
const t = cache.get(key);
|
|
||||||
if (t !== undefined) {
|
|
||||||
cache.delete(key);
|
|
||||||
cache.set(key, t); // move to most-recent
|
|
||||||
}
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cacheSet(key: string, track: Track): void {
|
function cacheSet(key: string, track: Track): void {
|
||||||
if (cache.has(key)) cache.delete(key);
|
if (cache.has(key)) cache.delete(key);
|
||||||
cache.set(key, track);
|
cache.set(key, track);
|
||||||
@@ -69,7 +68,20 @@ function carryFlags(track: Track, ref: QueueItemRef | undefined): Track {
|
|||||||
|
|
||||||
/** Synchronous cache read (no fetch); undefined on miss. */
|
/** Synchronous cache read (no fetch); undefined on miss. */
|
||||||
export function getCachedTrack(ref: QueueItemRef): Track | undefined {
|
export function getCachedTrack(ref: QueueItemRef): Track | undefined {
|
||||||
return cacheTouch(refKey(ref));
|
// Pure read — no LRU bump. Called from component render (QueueList rows), where
|
||||||
|
// a Map mutation (delete+set) is a render side-effect. Recency is set at write
|
||||||
|
// time in cacheSet instead; this cache is effectively insertion-order/FIFO.
|
||||||
|
const direct = cache.get(refKey(ref));
|
||||||
|
if (direct) return direct;
|
||||||
|
// Compat: refs persisted before B1 (queue server identity canonicalization)
|
||||||
|
// may still carry a UUID. Writes are canonical now, so the live cache key
|
||||||
|
// is `${indexKey}:${trackId}`; map UUID → indexKey on read to bridge the
|
||||||
|
// migration window.
|
||||||
|
const canonical = canonicalQueueServerKey(ref.serverId);
|
||||||
|
if (canonical && canonical !== ref.serverId) {
|
||||||
|
return cache.get(refKey({ serverId: canonical, trackId: ref.trackId }));
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lightweight placeholder shown until a ref resolves. */
|
/** Lightweight placeholder shown until a ref resolves. */
|
||||||
@@ -101,10 +113,13 @@ export function applyQueueOverrides(track: Track): Track {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seed the cache with already-known tracks (e.g. on enqueue) — no fetch. */
|
/** Seed the cache with already-known tracks (e.g. on enqueue) — no fetch.
|
||||||
|
* Canonicalizes the caller-supplied server id so seed and refs always agree
|
||||||
|
* on a single key shape. */
|
||||||
export function seedQueueResolver(serverId: string, tracks: Track[]): void {
|
export function seedQueueResolver(serverId: string, tracks: Track[]): void {
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
for (const t of tracks) cacheSet(refKey({ serverId, trackId: t.id }), t);
|
const canonicalId = canonicalQueueServerKey(serverId);
|
||||||
|
for (const t of tracks) cacheSet(refKey({ serverId: canonicalId, trackId: t.id }), t);
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,8 +192,7 @@ export function resolveVisibleRange(refs: QueueItemRef[], fromIdx: number, toIdx
|
|||||||
if (end > start) void resolveBatch(refs.slice(start, end));
|
if (end > start) void resolveBatch(refs.slice(start, end));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drop cached entries for a track id (e.g. after a star/rating sync succeeds,
|
/** Drop cached entries for a track id, forcing the next resolve to re-fetch. */
|
||||||
* so the next read re-fetches the server truth). */
|
|
||||||
export function invalidateQueueResolver(trackId: string): void {
|
export function invalidateQueueResolver(trackId: string): void {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const key of [...cache.keys()]) {
|
for (const key of [...cache.keys()]) {
|
||||||
@@ -190,6 +204,21 @@ export function invalidateQueueResolver(trackId: string): void {
|
|||||||
if (changed) notify();
|
if (changed) notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Patch cached entries for a track id in place (e.g. after a star/rating sync
|
||||||
|
* succeeds). Unlike {@link invalidateQueueResolver}, this keeps the entry so a
|
||||||
|
* visible queue row never blanks to a placeholder — the row stays resolved and
|
||||||
|
* just reflects the synced value. No-op for refs not currently cached. */
|
||||||
|
export function patchCachedTrack(trackId: string, patch: Partial<Track>): void {
|
||||||
|
let changed = false;
|
||||||
|
for (const [key, track] of cache) {
|
||||||
|
if (key.endsWith(`:${trackId}`)) {
|
||||||
|
cache.set(key, { ...track, ...patch });
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) notify();
|
||||||
|
}
|
||||||
|
|
||||||
/** Test-only: clear cache + in-flight set. */
|
/** Test-only: clear cache + in-flight set. */
|
||||||
export function _resetQueueResolverForTest(): void {
|
export function _resetQueueResolverForTest(): void {
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { usePlayerStore } from '@/store/playerStore';
|
||||||
|
import type { QueueItemRef, Track } from '@/store/playerStoreTypes';
|
||||||
|
import { seedQueueResolver, _resetQueueResolverForTest } from '@/utils/library/queueTrackResolver';
|
||||||
|
import { resolveQueueTrack, getQueueTracksView } from './queueTrackView';
|
||||||
|
|
||||||
|
const track = (id: string, over: Partial<Track> = {}): Track =>
|
||||||
|
({ id, title: id, artist: 'A', album: 'Al', albumId: 'Al', duration: 1, ...over });
|
||||||
|
const ref = (trackId: string, over: Partial<QueueItemRef> = {}): QueueItemRef =>
|
||||||
|
({ serverId: 's1', trackId, ...over });
|
||||||
|
|
||||||
|
describe('queueTrackView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetQueueResolverForTest();
|
||||||
|
usePlayerStore.setState({ starredOverrides: {}, userRatingOverrides: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves from the resolver cache when present', () => {
|
||||||
|
seedQueueResolver('s1', [track('t1', { title: 'Cached' })]);
|
||||||
|
expect(resolveQueueTrack(ref('t1')).title).toBe('Cached');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the provided Track on cache miss', () => {
|
||||||
|
expect(resolveQueueTrack(ref('t2'), track('t2', { title: 'Fallback' })).title).toBe('Fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a placeholder on miss with no fallback', () => {
|
||||||
|
const r = resolveQueueTrack(ref('t3'));
|
||||||
|
expect(r.id).toBe('t3');
|
||||||
|
expect(r.title).toBe('…');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the ref queue-only flags onto the resolved track', () => {
|
||||||
|
seedQueueResolver('s1', [track('t4')]);
|
||||||
|
const r = resolveQueueTrack(ref('t4', { radioAdded: true }));
|
||||||
|
expect(r.radioAdded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges session star/rating overrides', () => {
|
||||||
|
seedQueueResolver('s1', [track('t5')]);
|
||||||
|
usePlayerStore.setState({ starredOverrides: { t5: true }, userRatingOverrides: { t5: 4 } });
|
||||||
|
const r = resolveQueueTrack(ref('t5'));
|
||||||
|
expect(!!r.starred).toBe(true);
|
||||||
|
expect(r.userRating).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getQueueTracksView resolves each ref, preferring cache then fallback', () => {
|
||||||
|
seedQueueResolver('s1', [track('a', { title: 'CachedA' })]);
|
||||||
|
const out = getQueueTracksView([ref('a'), ref('b')], [track('a'), track('b', { title: 'FbB' })]);
|
||||||
|
expect(out.map(t => t.title)).toEqual(['CachedA', 'FbB']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
|
||||||
|
import { getCachedTrack, placeholderTrack, applyQueueOverrides } from './queueTrackResolver';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dual-write bridge (thin-state phase 4): rebuild the legacy `queue: Track[]`
|
||||||
|
* from the canonical `QueueItemRef[]` after a ref-native mutation. Each ref's
|
||||||
|
* track is sourced from the supplied `pools` (the previous queue + any tracks
|
||||||
|
* just handed to the mutation) by id — **purely structural**: no resolver cache
|
||||||
|
* read and no F4 override merge (display still applies those), so the derived
|
||||||
|
* array is byte-identical to the old fat-array mutation result. The ref is the
|
||||||
|
* source of truth for the queue-only flags. A ref with no pooled track falls
|
||||||
|
* back to a placeholder (does not happen during dual-write, where every ref's
|
||||||
|
* track is in hand). Removed in the final step together with `queue: Track[]`.
|
||||||
|
*/
|
||||||
|
export function bridgeQueueFromItems(items: QueueItemRef[], pools: Track[][]): Track[] {
|
||||||
|
const byId = new Map<string, Track>();
|
||||||
|
for (const pool of pools) {
|
||||||
|
for (const t of pool) if (!byId.has(t.id)) byId.set(t.id, t);
|
||||||
|
}
|
||||||
|
return items.map(ref => {
|
||||||
|
const base = byId.get(ref.trackId);
|
||||||
|
if (!base) return placeholderTrack(ref);
|
||||||
|
if (
|
||||||
|
base.autoAdded === ref.autoAdded &&
|
||||||
|
base.radioAdded === ref.radioAdded &&
|
||||||
|
base.playNextAdded === ref.playNextAdded
|
||||||
|
) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
return { ...base, autoAdded: ref.autoAdded, radioAdded: ref.radioAdded, playNextAdded: ref.playNextAdded };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue thin-state phase 4: turn a `QueueItemRef` into a display `Track` for the
|
||||||
|
* upcoming consumer migration off `queue: Track[]`.
|
||||||
|
*
|
||||||
|
* Resolver-first: cache → caller fallback (the legacy `queue[idx]` Track during
|
||||||
|
* the dual-write transition) → placeholder. Queue-only flags come from the ref
|
||||||
|
* (they are not in the index/cache); session star/rating overrides (F4) are
|
||||||
|
* merged last. Pure synchronous read — **no fetch, no cache mutation** — so it is
|
||||||
|
* safe to call from render (the resolver's `getCachedTrack` is a plain `cache.get`
|
||||||
|
* for exactly this reason; see the freeze fix in queueTrackResolver).
|
||||||
|
*/
|
||||||
|
export function resolveQueueTrack(ref: QueueItemRef, fallback?: Track): Track {
|
||||||
|
const base = getCachedTrack(ref) ?? fallback ?? placeholderTrack(ref);
|
||||||
|
// Carry the ref's queue-only flags onto the resolved track without mutating the
|
||||||
|
// cached object (a render-time mutation is what caused the earlier render loop).
|
||||||
|
const needsFlags =
|
||||||
|
base.autoAdded !== ref.autoAdded ||
|
||||||
|
base.radioAdded !== ref.radioAdded ||
|
||||||
|
base.playNextAdded !== ref.playNextAdded;
|
||||||
|
const flagged = needsFlags
|
||||||
|
? { ...base, autoAdded: ref.autoAdded, radioAdded: ref.radioAdded, playNextAdded: ref.playNextAdded }
|
||||||
|
: base;
|
||||||
|
return applyQueueOverrides(flagged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a whole ref list to display `Track`s (non-React call sites: snapshots,
|
||||||
|
* hot-cache planning, sync). Same per-item rules as {@link resolveQueueTrack};
|
||||||
|
* `fallbacks[i]` is the legacy `queue[i]` during the dual-write transition.
|
||||||
|
*/
|
||||||
|
export function getQueueTracksView(refs: QueueItemRef[], fallbacks?: Track[]): Track[] {
|
||||||
|
return refs.map((ref, i) => resolveQueueTrack(ref, fallbacks?.[i]));
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
|
|||||||
import { listen, emitTo } from '@tauri-apps/api/event';
|
import { listen, emitTo } from '@tauri-apps/api/event';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { resolveQueueTrack } from './library/queueTrackView';
|
||||||
import type { SubsonicOpenArtistRef } from '../api/subsonicTypes';
|
import type { SubsonicOpenArtistRef } from '../api/subsonicTypes';
|
||||||
|
|
||||||
export const MINI_WINDOW_LABEL = 'mini';
|
export const MINI_WINDOW_LABEL = 'mini';
|
||||||
@@ -56,13 +57,27 @@ function toMini(t: any): MiniTrackInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cap the queue pushed to the mini at ±100 tracks around the playing song — a
|
||||||
|
* 50k Artist-Radio queue must not serialize in full over IPC on every push. The
|
||||||
|
* mini stays slice-relative (no component change); control events (jump/reorder/
|
||||||
|
* remove) are translated back to absolute indices via {@link miniWindowStart}. */
|
||||||
|
const MINI_QUEUE_HALF = 100;
|
||||||
|
let miniWindowStart = 0;
|
||||||
|
|
||||||
function snapshot(): MiniSyncPayload {
|
function snapshot(): MiniSyncPayload {
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
const a = useAuthStore.getState();
|
const a = useAuthStore.getState();
|
||||||
|
const idx = s.queueIndex ?? 0;
|
||||||
|
const start = Math.max(0, idx - MINI_QUEUE_HALF);
|
||||||
|
// Thin-state: resolve the windowed slice (resolver cache → placeholder).
|
||||||
|
const windowed = (s.queueItems ?? [])
|
||||||
|
.slice(start, idx + MINI_QUEUE_HALF + 1)
|
||||||
|
.map(r => resolveQueueTrack(r));
|
||||||
|
miniWindowStart = start;
|
||||||
return {
|
return {
|
||||||
track: s.currentTrack ? toMini(s.currentTrack) : null,
|
track: s.currentTrack ? toMini(s.currentTrack) : null,
|
||||||
queue: (s.queue ?? []).map(toMini),
|
queue: windowed.map(toMini),
|
||||||
queueIndex: s.queueIndex ?? 0,
|
queueIndex: idx - start, // local position within the windowed slice
|
||||||
queueServerId: s.queueServerId ?? null,
|
queueServerId: s.queueServerId ?? null,
|
||||||
isPlaying: s.isPlaying,
|
isPlaying: s.isPlaying,
|
||||||
volume: s.volume,
|
volume: s.volume,
|
||||||
@@ -112,7 +127,7 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
|||||||
|| state.isPlaying !== prev.isPlaying
|
|| state.isPlaying !== prev.isPlaying
|
||||||
|| state.currentTrack?.starred !== prev.currentTrack?.starred
|
|| state.currentTrack?.starred !== prev.currentTrack?.starred
|
||||||
|| state.queueIndex !== prev.queueIndex
|
|| state.queueIndex !== prev.queueIndex
|
||||||
|| state.queue !== prev.queue
|
|| state.queueItems !== prev.queueItems
|
||||||
|| state.queueServerId !== prev.queueServerId
|
|| state.queueServerId !== prev.queueServerId
|
||||||
|| state.volume !== prev.volume) {
|
|| state.volume !== prev.volume) {
|
||||||
push();
|
push();
|
||||||
@@ -153,30 +168,37 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Jump to a specific queue index.
|
// Jump to a specific queue index. The mini sends a slice-relative index; add
|
||||||
|
// the window offset from the last push to land on the absolute queue position.
|
||||||
const jumpUnlisten = listen<{ index: number }>('mini:jump', (e) => {
|
const jumpUnlisten = listen<{ index: number }>('mini:jump', (e) => {
|
||||||
const store = usePlayerStore.getState();
|
const store = usePlayerStore.getState();
|
||||||
const idx = e.payload?.index ?? -1;
|
const idx = (e.payload?.index ?? -1) + miniWindowStart;
|
||||||
if (idx < 0 || idx >= store.queue.length) return;
|
if (idx < 0 || idx >= store.queueItems.length) return;
|
||||||
const track = store.queue[idx];
|
const ref = store.queueItems[idx];
|
||||||
if (track) store.playTrack(track, store.queue, true);
|
if (ref) {
|
||||||
|
// Resolve the target ref; pass undefined so playTrack keeps the canonical
|
||||||
|
// queue and just jumps to this slot.
|
||||||
|
store.playTrack(resolveQueueTrack(ref), undefined, true, false, idx);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// PsyDnD reorder forwarded from the mini queue.
|
// PsyDnD reorder forwarded from the mini queue (slice-relative → absolute).
|
||||||
const reorderUnlisten = listen<{ from: number; to: number }>('mini:reorder', (e) => {
|
const reorderUnlisten = listen<{ from: number; to: number }>('mini:reorder', (e) => {
|
||||||
const store = usePlayerStore.getState();
|
const store = usePlayerStore.getState();
|
||||||
const { from, to } = e.payload ?? { from: -1, to: -1 };
|
const raw = e.payload ?? { from: -1, to: -1 };
|
||||||
if (from < 0 || from >= store.queue.length) return;
|
const from = raw.from + miniWindowStart;
|
||||||
if (to < 0 || to > store.queue.length) return;
|
const to = raw.to + miniWindowStart;
|
||||||
|
if (from < 0 || from >= store.queueItems.length) return;
|
||||||
|
if (to < 0 || to > store.queueItems.length) return;
|
||||||
if (from === to) return;
|
if (from === to) return;
|
||||||
store.reorderQueue(from, to);
|
store.reorderQueue(from, to);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove a track at index (context menu → "Remove from queue").
|
// Remove a track at index (context menu → "Remove from queue"; slice-relative).
|
||||||
const removeUnlisten = listen<{ index: number }>('mini:remove', (e) => {
|
const removeUnlisten = listen<{ index: number }>('mini:remove', (e) => {
|
||||||
const store = usePlayerStore.getState();
|
const store = usePlayerStore.getState();
|
||||||
const idx = e.payload?.index ?? -1;
|
const idx = (e.payload?.index ?? -1) + miniWindowStart;
|
||||||
if (idx < 0 || idx >= store.queue.length) return;
|
if (idx < 0 || idx >= store.queueItems.length) return;
|
||||||
store.removeTrack(idx);
|
store.removeTrack(idx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getSimilarSongs } from '../../api/subsonicArtists';
|
import { getSimilarSongs } from '../../api/subsonicArtists';
|
||||||
import { filterSongsToActiveLibrary, getRandomSongs } from '../../api/subsonicLibrary';
|
import { filterSongsToActiveLibrary, getRandomSongs } from '../../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { QueueItemRef } from '../../store/playerStoreTypes';
|
||||||
import { songToTrack } from '../playback/songToTrack';
|
import { songToTrack } from '../playback/songToTrack';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import i18n from '../../i18n';
|
import i18n from '../../i18n';
|
||||||
@@ -93,14 +93,15 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
|||||||
|
|
||||||
// Snapshot the current queue *before* we prune — so if the build fails
|
// Snapshot the current queue *before* we prune — so if the build fails
|
||||||
// before we ever play a track, we can put it back the way it was instead
|
// before we ever play a track, we can put it back the way it was instead
|
||||||
// of leaving the user with an empty player.
|
// of leaving the user with an empty player. Thin-state: snapshot the refs and
|
||||||
|
// the resolved tracks (to re-seed the resolver on restore).
|
||||||
const playerStateBefore = usePlayerStore.getState();
|
const playerStateBefore = usePlayerStore.getState();
|
||||||
const queueSnapshot: {
|
const queueSnapshot: {
|
||||||
queue: Track[];
|
queueItems: QueueItemRef[];
|
||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
queueServerId: string | null;
|
queueServerId: string | null;
|
||||||
} = {
|
} = {
|
||||||
queue: [...playerStateBefore.queue],
|
queueItems: [...playerStateBefore.queueItems],
|
||||||
queueIndex: playerStateBefore.queueIndex,
|
queueIndex: playerStateBefore.queueIndex,
|
||||||
queueServerId: playerStateBefore.queueServerId,
|
queueServerId: playerStateBefore.queueServerId,
|
||||||
};
|
};
|
||||||
@@ -125,8 +126,8 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
let allSeedSongs: SubsonicSong[] = [];
|
let allSeedSongs: SubsonicSong[] = [];
|
||||||
|
|
||||||
const mixQueueSize = () => usePlayerStore.getState().queue.length;
|
const mixQueueSize = () => usePlayerStore.getState().queueItems.length;
|
||||||
const mixQueueTrackIds = () => new Set(usePlayerStore.getState().queue.map(t => t.id));
|
const mixQueueTrackIds = () => new Set(usePlayerStore.getState().queueItems.map(r => r.trackId));
|
||||||
|
|
||||||
const bailIfCancelled = () => {
|
const bailIfCancelled = () => {
|
||||||
if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled();
|
if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled();
|
||||||
@@ -156,7 +157,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
|||||||
const nextId = state.currentTrack?.id ?? null;
|
const nextId = state.currentTrack?.id ?? null;
|
||||||
if (nextId === prevId) return;
|
if (nextId === prevId) return;
|
||||||
if (!nextId) return;
|
if (!nextId) return;
|
||||||
if (state.queue.some(t => t.id === nextId)) return;
|
if (state.queueItems.some(r => r.trackId === nextId)) return;
|
||||||
useLuckyMixStore.getState().cancel();
|
useLuckyMixStore.getState().cancel();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -389,12 +390,12 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
|||||||
// whatever we managed to enqueue is more useful than the old queue.
|
// whatever we managed to enqueue is more useful than the old queue.
|
||||||
if (!startedPlayback) {
|
if (!startedPlayback) {
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue: queueSnapshot.queue,
|
queueItems: queueSnapshot.queueItems,
|
||||||
queueIndex: queueSnapshot.queueIndex,
|
queueIndex: queueSnapshot.queueIndex,
|
||||||
queueServerId: queueSnapshot.queueServerId,
|
queueServerId: queueSnapshot.queueServerId,
|
||||||
});
|
});
|
||||||
logStep('queue_restored_after_failure', {
|
logStep('queue_restored_after_failure', {
|
||||||
restoredCount: queueSnapshot.queue.length,
|
restoredCount: queueSnapshot.queueItems.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
showToast(i18n.t('luckyMix.failed'), 5000, 'error');
|
showToast(i18n.t('luckyMix.failed'), 5000, 'error');
|
||||||
|
|||||||
@@ -45,14 +45,14 @@ export async function playSongNow(song: SubsonicSong, queue?: SubsonicSong[]): P
|
|||||||
export async function enqueueAndPlay(song: SubsonicSong): Promise<void> {
|
export async function enqueueAndPlay(song: SubsonicSong): Promise<void> {
|
||||||
const track = songToTrack(song);
|
const track = songToTrack(song);
|
||||||
const store = usePlayerStore.getState();
|
const store = usePlayerStore.getState();
|
||||||
const { isPlaying, volume, queue } = store;
|
const { isPlaying, volume, queueItems } = store;
|
||||||
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await fadeOut(store.setVolume, volume, 700);
|
await fadeOut(store.setVolume, volume, 700);
|
||||||
usePlayerStore.setState({ volume });
|
usePlayerStore.setState({ volume });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!queue.some(t => t.id === track.id)) {
|
if (!queueItems.some(r => r.trackId === track.id)) {
|
||||||
usePlayerStore.getState().enqueue([track]);
|
usePlayerStore.getState().enqueue([track]);
|
||||||
}
|
}
|
||||||
// playTrack with no queue arg uses the current state.queue, finds the track by id,
|
// playTrack with no queue arg uses the current state.queue, finds the track by id,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe('playbackServer', () => {
|
|||||||
isLoggedIn: true,
|
isLoggedIn: true,
|
||||||
});
|
});
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }],
|
queueItems: [{ serverId: 'a', trackId: 't1' }],
|
||||||
queueServerId: 'a',
|
queueServerId: 'a',
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
});
|
});
|
||||||
@@ -43,21 +43,22 @@ describe('playbackServer', () => {
|
|||||||
|
|
||||||
it('getPlaybackServerId falls back to active when queue is empty', () => {
|
it('getPlaybackServerId falls back to active when queue is empty', () => {
|
||||||
clearQueueServerForPlayback();
|
clearQueueServerForPlayback();
|
||||||
usePlayerStore.setState({ queue: [] });
|
usePlayerStore.setState({ queueItems: [] });
|
||||||
useAuthStore.setState({ activeServerId: 'b' });
|
useAuthStore.setState({ activeServerId: 'b' });
|
||||||
expect(getPlaybackServerId()).toBe('b');
|
expect(getPlaybackServerId()).toBe('b');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bindQueueServerForPlayback pins active server', () => {
|
it('bindQueueServerForPlayback pins active server as canonical index key', () => {
|
||||||
useAuthStore.setState({ activeServerId: 'b' });
|
useAuthStore.setState({ activeServerId: 'b' });
|
||||||
bindQueueServerForPlayback();
|
bindQueueServerForPlayback();
|
||||||
expect(usePlayerStore.getState().queueServerId).toBe('b');
|
// B1: writers emit the canonical (URL-derived) server key, not the UUID.
|
||||||
|
expect(usePlayerStore.getState().queueServerId).toBe('b.test');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('playbackServerDiffersFromActive when queue server != active', () => {
|
it('playbackServerDiffersFromActive when queue server != active', () => {
|
||||||
useAuthStore.setState({ activeServerId: 'b' });
|
useAuthStore.setState({ activeServerId: 'b' });
|
||||||
expect(playbackServerDiffersFromActive()).toBe(true);
|
expect(playbackServerDiffersFromActive()).toBe(true);
|
||||||
usePlayerStore.setState({ queue: [] });
|
usePlayerStore.setState({ queueItems: [] });
|
||||||
expect(playbackServerDiffersFromActive()).toBe(false);
|
expect(playbackServerDiffersFromActive()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,16 +67,20 @@ describe('playbackServer', () => {
|
|||||||
useAuthStore.setState({ activeServerId: 'b' });
|
useAuthStore.setState({ activeServerId: 'b' });
|
||||||
prepareActiveServerForNewMix();
|
prepareActiveServerForNewMix();
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
expect(s.queue).toEqual([]);
|
expect(s.queueItems).toEqual([]);
|
||||||
expect(s.currentTrack).toBeNull();
|
expect(s.currentTrack).toBeNull();
|
||||||
expect(s.queueServerId).toBe('b');
|
// Canonical index key on re-pin (B1).
|
||||||
|
expect(s.queueServerId).toBe('b.test');
|
||||||
expect(playbackServerDiffersFromActive()).toBe(false);
|
expect(playbackServerDiffersFromActive()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prepareActiveServerForNewMix is a no-op when queue already matches active', () => {
|
it('prepareActiveServerForNewMix is a no-op when queue already matches active', () => {
|
||||||
useAuthStore.setState({ activeServerId: 'a' });
|
useAuthStore.setState({ activeServerId: 'a' });
|
||||||
prepareActiveServerForNewMix();
|
prepareActiveServerForNewMix();
|
||||||
expect(usePlayerStore.getState().queue).toHaveLength(1);
|
expect(usePlayerStore.getState().queueItems).toHaveLength(1);
|
||||||
|
// Pre-existing queueServerId='a' (UUID) is tolerated by the reader helpers
|
||||||
|
// even while writers emit canonical index keys — this is the migration
|
||||||
|
// window the resolver compat path covers (B1).
|
||||||
expect(usePlayerStore.getState().queueServerId).toBe('a');
|
expect(usePlayerStore.getState().queueServerId).toBe('a');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,12 +113,14 @@ describe('playbackServer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shouldBindQueueServerForPlay detects queue replacement', () => {
|
it('shouldBindQueueServerForPlay detects queue replacement', () => {
|
||||||
const prev = [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }];
|
// Thin-state: prevQueue is the canonical refs; newQueue / explicit arg are Tracks.
|
||||||
|
const prevRefs = [{ serverId: 'a', trackId: 't1' }];
|
||||||
|
const sameTrack = [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }];
|
||||||
const next = [
|
const next = [
|
||||||
{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 },
|
{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 },
|
||||||
{ id: 't2', title: 'T2', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 },
|
{ id: 't2', title: 'T2', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 },
|
||||||
];
|
];
|
||||||
expect(shouldBindQueueServerForPlay(prev, next, next)).toBe(true);
|
expect(shouldBindQueueServerForPlay(prevRefs, next, next)).toBe(true);
|
||||||
expect(shouldBindQueueServerForPlay(prev, prev, undefined)).toBe(false);
|
expect(shouldBindQueueServerForPlay(prevRefs, sameTrack, undefined)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,22 +6,26 @@ import { useAuthStore } from '../../store/authStore';
|
|||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { switchActiveServer } from '../server/switchActiveServer';
|
import { switchActiveServer } from '../server/switchActiveServer';
|
||||||
import { sameQueueTrackId } from './queueIdentity';
|
import { sameQueueTrackId } from './queueIdentity';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
|
||||||
import { resolveServerIdForIndexKey } from '../server/serverLookup';
|
import { resolveServerIdForIndexKey } from '../server/serverLookup';
|
||||||
import { resolveIndexKey, serverIndexKeyFromUrl } from '../server/serverIndexKey';
|
import {
|
||||||
|
resolveIndexKey,
|
||||||
|
serverIndexKeyForProfile,
|
||||||
|
serverIndexKeyFromUrl,
|
||||||
|
} from '../server/serverIndexKey';
|
||||||
|
|
||||||
/** Server that owns the current queue / stream URLs (may differ from the browsed server). */
|
/** Server that owns the current queue / stream URLs (may differ from the browsed server). */
|
||||||
export function getPlaybackServerId(): string {
|
export function getPlaybackServerId(): string {
|
||||||
const { queueServerId, queue } = usePlayerStore.getState();
|
const { queueServerId, queueItems } = usePlayerStore.getState();
|
||||||
if ((queue?.length ?? 0) > 0 && queueServerId) {
|
if ((queueItems?.length ?? 0) > 0 && queueServerId) {
|
||||||
return resolveServerIdForIndexKey(queueServerId);
|
return resolveServerIdForIndexKey(queueServerId);
|
||||||
}
|
}
|
||||||
return useAuthStore.getState().activeServerId ?? '';
|
return useAuthStore.getState().activeServerId ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPlaybackIndexKey(): string {
|
export function getPlaybackIndexKey(): string {
|
||||||
const { queueServerId, queue } = usePlayerStore.getState();
|
const { queueServerId, queueItems } = usePlayerStore.getState();
|
||||||
if ((queue?.length ?? 0) > 0 && queueServerId) {
|
if ((queueItems?.length ?? 0) > 0 && queueServerId) {
|
||||||
return resolveIndexKey(queueServerId);
|
return resolveIndexKey(queueServerId);
|
||||||
}
|
}
|
||||||
const activeId = useAuthStore.getState().activeServerId ?? '';
|
const activeId = useAuthStore.getState().activeServerId ?? '';
|
||||||
@@ -43,7 +47,13 @@ export function getPlaybackCacheServerKey(): string {
|
|||||||
export function bindQueueServerForPlayback(): void {
|
export function bindQueueServerForPlayback(): void {
|
||||||
const sid = useAuthStore.getState().activeServerId;
|
const sid = useAuthStore.getState().activeServerId;
|
||||||
if (!sid) return;
|
if (!sid) return;
|
||||||
usePlayerStore.setState({ queueServerId: sid });
|
const server = useAuthStore.getState().servers.find(s => s.id === sid);
|
||||||
|
// Canonical index key on writes so mixed-server queues stay unambiguous —
|
||||||
|
// every ref/queue-level server identifier follows the same shape that the
|
||||||
|
// library index already uses. Falls back to the raw id when the server
|
||||||
|
// profile cannot be resolved (e.g. tests with a stubbed auth store).
|
||||||
|
const canonical = server ? serverIndexKeyForProfile(server) || sid : sid;
|
||||||
|
usePlayerStore.setState({ queueServerId: canonical });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearQueueServerForPlayback(): void {
|
export function clearQueueServerForPlayback(): void {
|
||||||
@@ -51,8 +61,8 @@ export function clearQueueServerForPlayback(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function playbackServerDiffersFromActive(): boolean {
|
export function playbackServerDiffersFromActive(): boolean {
|
||||||
const { queueServerId, queue } = usePlayerStore.getState();
|
const { queueServerId, queueItems } = usePlayerStore.getState();
|
||||||
if ((queue?.length ?? 0) === 0 || !queueServerId) return false;
|
if ((queueItems?.length ?? 0) === 0 || !queueServerId) return false;
|
||||||
const activeSid = useAuthStore.getState().activeServerId;
|
const activeSid = useAuthStore.getState().activeServerId;
|
||||||
const resolvedQueue = resolveServerIdForIndexKey(queueServerId);
|
const resolvedQueue = resolveServerIdForIndexKey(queueServerId);
|
||||||
return !!activeSid && resolvedQueue !== activeSid;
|
return !!activeSid && resolvedQueue !== activeSid;
|
||||||
@@ -65,8 +75,8 @@ export function playbackServerDiffersFromActive(): boolean {
|
|||||||
export function shouldHandoffQueueToActiveServer(): boolean {
|
export function shouldHandoffQueueToActiveServer(): boolean {
|
||||||
const activeSid = useAuthStore.getState().activeServerId;
|
const activeSid = useAuthStore.getState().activeServerId;
|
||||||
if (!activeSid) return false;
|
if (!activeSid) return false;
|
||||||
const { queue, queueServerId } = usePlayerStore.getState();
|
const { queueItems, queueServerId } = usePlayerStore.getState();
|
||||||
if ((queue?.length ?? 0) === 0) return false;
|
if ((queueItems?.length ?? 0) === 0) return false;
|
||||||
if (!queueServerId) return true;
|
if (!queueServerId) return true;
|
||||||
return resolveServerIdForIndexKey(queueServerId) !== activeSid;
|
return resolveServerIdForIndexKey(queueServerId) !== activeSid;
|
||||||
}
|
}
|
||||||
@@ -101,7 +111,7 @@ export function playbackCoverArtForId(coverId: string, displayCssPx: number): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldBindQueueServerForPlay(
|
export function shouldBindQueueServerForPlay(
|
||||||
prevQueue: Track[],
|
prevQueue: QueueItemRef[],
|
||||||
newQueue: Track[],
|
newQueue: Track[],
|
||||||
explicitQueueArg: Track[] | undefined,
|
explicitQueueArg: Track[] | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -109,5 +119,5 @@ export function shouldBindQueueServerForPlay(
|
|||||||
if (prevQueue.length === 0) return true;
|
if (prevQueue.length === 0) return true;
|
||||||
if (explicitQueueArg === undefined) return false;
|
if (explicitQueueArg === undefined) return false;
|
||||||
if (explicitQueueArg.length !== prevQueue.length) return true;
|
if (explicitQueueArg.length !== prevQueue.length) return true;
|
||||||
return !explicitQueueArg.every((t, i) => sameQueueTrackId(prevQueue[i]?.id, t.id));
|
return !explicitQueueArg.every((t, i) => sameQueueTrackId(prevQueue[i]?.trackId, t.id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,3 +17,25 @@ export function resolveIndexKey(serverIdOrKey: string): string {
|
|||||||
if (!server) return serverIdOrKey;
|
if (!server) return serverIdOrKey;
|
||||||
return serverIndexKeyFromUrl(server.url) || serverIdOrKey;
|
return serverIndexKeyFromUrl(server.url) || serverIdOrKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical key for queue-thin-state writers: returns the URL-derived index key
|
||||||
|
* for any known server (whether the caller passed the UUID or the index key),
|
||||||
|
* and leaves unknown / already-canonical values untouched. Idempotent.
|
||||||
|
*
|
||||||
|
* Use this on every write path that lands in `QueueItemRef.serverId` or
|
||||||
|
* `PlayerState.queueServerId`. Reading sides may still receive legacy UUID
|
||||||
|
* values from persisted blobs; `serverLookup` helpers accept both shapes.
|
||||||
|
*/
|
||||||
|
export function canonicalQueueServerKey(serverIdOrKey: string): string {
|
||||||
|
if (!serverIdOrKey) return serverIdOrKey;
|
||||||
|
// Defensive: tests sometimes stub `useAuthStore` without seeding `servers`.
|
||||||
|
// Treat a missing list as "unknown server" rather than crashing the read.
|
||||||
|
const servers = useAuthStore.getState().servers;
|
||||||
|
if (!servers) return serverIdOrKey;
|
||||||
|
const server = servers.find(s => s.id === serverIdOrKey);
|
||||||
|
if (server) {
|
||||||
|
return serverIndexKeyFromUrl(server.url) || serverIdOrKey;
|
||||||
|
}
|
||||||
|
return serverIdOrKey;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user