mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
45b9229ceb
* 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)
424 lines
17 KiB
TypeScript
424 lines
17 KiB
TypeScript
import { reportNowPlaying } from '../api/subsonicScrobble';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { lastfmGetTrackLoved, lastfmUpdateNowPlaying } from '../api/lastfm';
|
|
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
|
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
|
|
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
|
|
import {
|
|
bindQueueServerForPlayback,
|
|
getPlaybackCacheServerKey,
|
|
getPlaybackIndexKey,
|
|
getPlaybackServerId,
|
|
shouldBindQueueServerForPlay,
|
|
} from '../utils/playback/playbackServer';
|
|
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
|
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
|
import { useAuthStore } from './authStore';
|
|
import {
|
|
bumpPlayGeneration,
|
|
getPlayGeneration,
|
|
setIsAudioPaused,
|
|
} from './engineState';
|
|
import {
|
|
clearPreloadingIds,
|
|
getLastGaplessSwitchTime,
|
|
} from './gaplessPreloadState';
|
|
import { touchHotCacheOnPlayback } from './hotCacheTouch';
|
|
import {
|
|
isReplayGainActive,
|
|
loudnessGainDbForEngineBind,
|
|
} from './loudnessGainCache';
|
|
import { refreshLoudnessForTrack } from './loudnessRefresh';
|
|
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
|
|
import { useOrbitStore } from './orbitStore';
|
|
import {
|
|
playbackSourceHintForResolvedUrl,
|
|
recordEnginePlayUrl,
|
|
} from './playbackUrlRouting';
|
|
import type { PlayerState, Track } from './playerStoreTypes';
|
|
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
|
import { getQueueTracksView, resolveQueueTrack } from '../utils/library/queueTrackView';
|
|
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
|
|
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
|
import { syncQueueToServer } from './queueSync';
|
|
import { playListenSessionFinalize } from './playListenSession';
|
|
import { pushQueueUndoFromGetter } from './queueUndo';
|
|
import { stopRadio } from './radioPlayer';
|
|
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
|
|
import { clearSeekDebounce } from './seekDebounce';
|
|
import {
|
|
clearSeekFallbackRetry,
|
|
getSeekFallbackVisualTarget,
|
|
setSeekFallbackRestartAt,
|
|
setSeekFallbackTrackId,
|
|
setSeekFallbackVisualTarget,
|
|
} from './seekFallbackState';
|
|
import {
|
|
clearSeekTarget,
|
|
setSeekTarget,
|
|
} from './seekTargetState';
|
|
import { refreshWaveformForTrack } from './waveformRefresh';
|
|
|
|
type SetState = (
|
|
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
|
) => void;
|
|
type GetState = () => PlayerState;
|
|
|
|
/**
|
|
* Play a track, optionally replacing the queue and/or jumping to an
|
|
* explicit slot. Three guard layers run before the actual play body:
|
|
*
|
|
* 1. **Orbit bulk-gate** — when `queue.length > 1` and isn't a no-op
|
|
* replace of the current queue, prompt via `orbitBulkGuard`; on
|
|
* confirm, hosts/guests append (Orbit semantics — bulk replace
|
|
* would drop guest suggestions) and non-Orbit users replace as
|
|
* normal.
|
|
* 2. **Orbit-host single-track protection** — a `playTrack(track,
|
|
* [track])` from a host would blow away the shared queue; re-route
|
|
* to append-and-jump so guest suggestions survive.
|
|
* 3. **Ghost-command guard** — a playTrack arriving within 500 ms of
|
|
* the last gapless switch is almost certainly a stale IPC echo.
|
|
*
|
|
* The play body itself: clears all scheduled timers + seek state,
|
|
* resolves the URL, updates store + normalization snapshot
|
|
* optimistically, invokes the Rust engine, and on success seeks to
|
|
* the visual target if there was a pending one. Falls back to
|
|
* `next(false)` 500 ms after an `audio_play` failure. Same-track
|
|
* replays first flush the previous play's `stream_completed_cache`
|
|
* to hot disk so `fetch_data` doesn't re-run an HTTP range request.
|
|
*/
|
|
export function runPlayTrack(
|
|
set: SetState,
|
|
get: GetState,
|
|
track: Track,
|
|
queue: Track[] | undefined,
|
|
manual: boolean,
|
|
_orbitConfirmed: boolean,
|
|
targetQueueIndex: number | undefined,
|
|
): void {
|
|
// Orbit bulk-gate: only gate when the `queue` argument *replaces*
|
|
// the current queue (Play All / Play Album / Play Playlist / Hero
|
|
// play buttons). Navigation calls — queue-row click, next(),
|
|
// previous() — pass the existing queue back through playTrack just
|
|
// to move the index; they are not bulk operations and must not
|
|
// trigger the confirm dialog (#234 regression).
|
|
if (!_orbitConfirmed && queue && queue.length > 1) {
|
|
const current = get().queueItems;
|
|
const sameAsCurrent = queue.length === current.length
|
|
&& queue.every((t, i) => sameQueueTrackId(current[i]?.trackId, t.id));
|
|
if (!sameAsCurrent) {
|
|
void orbitBulkGuard(queue.length).then(ok => {
|
|
if (!ok) return;
|
|
// Inside an Orbit session a bulk replace would discard guest
|
|
// suggestions mid-listen. Append instead — the dialog's
|
|
// "Add them all" copy already matches that semantic. Outside
|
|
// Orbit, proceed as a normal replace.
|
|
const role = useOrbitStore.getState().role;
|
|
if (role === 'host' || role === 'guest') {
|
|
get().enqueue(queue, true);
|
|
} else {
|
|
get().playTrack(track, queue, manual, true);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Orbit-host single-track protection. The host's `playerStore.queue`
|
|
// *is* the shared Orbit queue. A `playTrack(track, [track])` call
|
|
// (e.g. OfflineLibrary's "Play this album" on a single-track album,
|
|
// or any other surface that explicitly passes a 1-track replacement
|
|
// queue) would otherwise blow away every guest suggestion + every
|
|
// upcoming track. Re-route to append + jump so the queue survives.
|
|
// Guest stays unguarded — a guest clicking Play locally is choosing
|
|
// to opt out of host-sync, which is the existing "guest is running
|
|
// their own show" path. `useOrbitGuest`'s `syncToHost` is also a
|
|
// guest-only call site, so it's never intercepted here.
|
|
if (!_orbitConfirmed && queue && queue.length === 1) {
|
|
const orbitRole = useOrbitStore.getState().role;
|
|
if (orbitRole === 'host') {
|
|
const currentItems = get().queueItems;
|
|
const currentTrackId = currentItems[get().queueIndex]?.trackId;
|
|
if (track.id !== currentTrackId) {
|
|
const existsAt = currentItems.findIndex(r => sameQueueTrackId(r.trackId, track.id));
|
|
if (existsAt >= 0) {
|
|
// 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 {
|
|
// Append the single track to the resolved current queue and jump to it.
|
|
const newQueue = [...getQueueTracksView(currentItems), track];
|
|
get().playTrack(track, newQueue, manual, true, newQueue.length - 1);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ghost-command guard: if a gapless switch happened within 500 ms,
|
|
// this playTrack call is likely a stale IPC echo — suppress it.
|
|
if (Date.now() - getLastGaplessSwitchTime() < 500) {
|
|
return;
|
|
}
|
|
|
|
void playListenSessionFinalize('skip');
|
|
|
|
clearAllPlaybackScheduleTimers();
|
|
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
|
|
|
const gen = bumpPlayGeneration();
|
|
setIsAudioPaused(false);
|
|
clearPreloadingIds(); // new track — allow fresh preload for next
|
|
clearSeekDebounce(); clearSeekTarget();
|
|
clearSeekFallbackRetry();
|
|
setSeekFallbackRestartAt(0);
|
|
|
|
// If a radio stream is active, stop it before the new track starts so
|
|
// the PlayerBar clears radio mode immediately and the stream is released.
|
|
if (get().currentRadio) {
|
|
stopRadio();
|
|
}
|
|
|
|
const state = get();
|
|
const prevTrack = state.currentTrack;
|
|
if (prevTrack?.id !== track.id) {
|
|
setSeekFallbackTrackId(null);
|
|
}
|
|
const visualOnEntry = getSeekFallbackVisualTarget();
|
|
if (visualOnEntry?.trackId !== track.id) {
|
|
setSeekFallbackVisualTarget(null);
|
|
}
|
|
// Thin-state: only a real queue *replacement* (explicit `queue` arg) rebuilds
|
|
// queueItems. A no-arg navigation (next/previous/queue-row jump) keeps the
|
|
// canonical refs and just moves the index — so we never resolve the whole
|
|
// queue here (O(visible), not O(queue length)), which would hitch + churn
|
|
// every subscriber on each track change at scale.
|
|
const replacing = queue !== undefined;
|
|
const srcLen = replacing ? queue.length : state.queueItems.length;
|
|
if (replacing && shouldBindQueueServerForPlay(state.queueItems, queue, queue)) {
|
|
bindQueueServerForPlayback();
|
|
}
|
|
// Prefer an explicit target index from the caller (next/previous/queue-row
|
|
// click already know the exact slot). `findIndex` returns the *first*
|
|
// matching id, which jumps backwards when the queue contains the same
|
|
// track twice — breaking radio playback (issue #500).
|
|
const matchesAt = (i: number): boolean =>
|
|
replacing
|
|
? sameQueueTrackId(queue[i]?.id, track.id)
|
|
: sameQueueTrackId(state.queueItems[i]?.trackId, track.id);
|
|
const explicitIdxValid =
|
|
typeof targetQueueIndex === 'number'
|
|
&& targetQueueIndex >= 0
|
|
&& targetQueueIndex < srcLen
|
|
&& matchesAt(targetQueueIndex);
|
|
const idx = explicitIdxValid
|
|
? (targetQueueIndex as number)
|
|
: replacing
|
|
? queue.findIndex(t => sameQueueTrackId(t.id, track.id))
|
|
: state.queueItems.findIndex(r => sameQueueTrackId(r.trackId, track.id));
|
|
const playIdx = idx >= 0 ? idx : 0;
|
|
// ±1 neighbours for replaygain normalization — resolve only these (not the
|
|
// whole queue). On replace they come from the provided Track[]; on navigation
|
|
// from the resolver cache (the bridge keeps that window warm).
|
|
const neighbourAt = (i: number): Track | null => {
|
|
if (i < 0 || i >= srcLen) return null;
|
|
if (replacing) return queue[i] ?? null;
|
|
if (i === playIdx) return track;
|
|
const ref = state.queueItems[i];
|
|
return ref ? resolveQueueTrack(ref) : null;
|
|
};
|
|
const prevNeighbour = neighbourAt(playIdx - 1);
|
|
const nextNeighbour = neighbourAt(playIdx + 1);
|
|
// Minimal window so deriveNormalizationSnapshot reads ±1 without a full array.
|
|
const normWindow: Track[] = prevNeighbour ? [prevNeighbour] : [];
|
|
const normIdx = normWindow.length;
|
|
normWindow.push(track);
|
|
if (nextNeighbour) normWindow.push(nextNeighbour);
|
|
if (manual) {
|
|
pushQueueUndoFromGetter(get);
|
|
}
|
|
const visualForInitial = getSeekFallbackVisualTarget();
|
|
const pendingVisualTarget = visualForInitial?.trackId === track.id
|
|
? visualForInitial.seconds
|
|
: null;
|
|
const initialTime = pendingVisualTarget !== null
|
|
? Math.max(0, Math.min(pendingVisualTarget, track.duration || pendingVisualTarget))
|
|
: 0;
|
|
const initialProgress =
|
|
track.duration && track.duration > 0 ? Math.max(0, Math.min(1, initialTime / track.duration)) : 0;
|
|
|
|
const authState = useAuthStore.getState();
|
|
// Same-track replay: Rust `fetch_data` consumes `stream_completed_cache` with
|
|
// `take()` once; a second replay would full HTTP-range again unless we flush
|
|
// RAM to hot disk first (promote was only run when switching to another track).
|
|
const needSameTrackHotPromote =
|
|
Boolean(
|
|
prevTrack
|
|
&& sameQueueTrackId(prevTrack.id, track.id)
|
|
&& authState.hotCacheEnabled
|
|
&& getPlaybackCacheServerKey(),
|
|
);
|
|
|
|
const runPlayTrackBody = () => {
|
|
const authStateNow = useAuthStore.getState();
|
|
const playbackSid = getPlaybackServerId();
|
|
const playbackCacheSid = getPlaybackCacheServerKey();
|
|
const url = resolvePlaybackUrl(track.id, playbackCacheSid);
|
|
recordEnginePlayUrl(track.id, url);
|
|
const preloadedTrackId = get().enginePreloadedTrackId;
|
|
const keepPreloadHint = preloadedTrackId === track.id;
|
|
const playbackSourceHint = playbackSourceHintForResolvedUrl(
|
|
track.id,
|
|
playbackCacheSid,
|
|
url,
|
|
);
|
|
if (import.meta.env.DEV) {
|
|
console.info('[psysonic][playTrack-source]', {
|
|
trackId: track.id,
|
|
resolvedUrl: url,
|
|
preloadedTrackId,
|
|
keepPreloadHint,
|
|
playbackSourceHint,
|
|
});
|
|
}
|
|
|
|
// Set state immediately so the UI updates before the download completes.
|
|
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
|
|
const queueSid = get().queueServerId ?? '';
|
|
// When the caller replaced the queue (explicit `queue` arg), seed the
|
|
// resolver with those tracks so the UI / hot paths resolve them without a
|
|
// network round-trip. No-arg jumps reuse already-cached refs.
|
|
if (queue && queueSid) seedQueueResolver(queueSid, queue);
|
|
set({
|
|
currentTrack: track,
|
|
currentRadio: null,
|
|
waveformBins: null,
|
|
...deriveNormalizationSnapshot(track, normWindow, normIdx),
|
|
// Only a replace rewrites the queue; navigation keeps the canonical refs.
|
|
...(replacing ? { queueItems: toQueueItemRefs(queueSid, queue) } : {}),
|
|
queueIndex: idx >= 0 ? idx : 0,
|
|
progress: initialProgress,
|
|
buffered: 0,
|
|
currentTime: initialTime,
|
|
scrobbled: false,
|
|
lastfmLoved: false,
|
|
// HTTP stream: wait for Rust `audio:playing` so the seekbar does not
|
|
// extrapolate while RangedHttpSource / legacy reader is still buffering.
|
|
isPlaying: playbackSourceHint !== 'stream',
|
|
isPlaybackBuffering: playbackSourceHint === 'stream',
|
|
currentPlaybackSource: playbackSourceHint,
|
|
enginePreloadedTrackId: keepPreloadHint ? track.id : null,
|
|
});
|
|
|
|
if (
|
|
prevTrack
|
|
&& !sameQueueTrackId(prevTrack.id, track.id)
|
|
&& authStateNow.hotCacheEnabled
|
|
) {
|
|
const prevPromoteSid = getPlaybackCacheServerKey();
|
|
if (prevPromoteSid) {
|
|
void promoteCompletedStreamToHotCache(
|
|
prevTrack,
|
|
prevPromoteSid,
|
|
authStateNow.hotCacheDownloadDir || null,
|
|
);
|
|
}
|
|
}
|
|
void refreshWaveformForTrack(track.id);
|
|
void refreshLoudnessForTrack(track.id);
|
|
setDeferHotCachePrefetch(true);
|
|
const replayGainDb = resolveReplayGainDb(
|
|
track, prevTrack, nextNeighbour,
|
|
isReplayGainActive(), authStateNow.replayGainMode,
|
|
);
|
|
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
|
invoke('audio_play', {
|
|
url,
|
|
volume: state.volume,
|
|
durationHint: track.duration,
|
|
replayGainDb,
|
|
replayGainPeak,
|
|
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
|
|
preGainDb: authStateNow.replayGainPreGainDb,
|
|
fallbackDb: authStateNow.replayGainFallbackDb,
|
|
manual,
|
|
hiResEnabled: authStateNow.enableHiRes,
|
|
analysisTrackId: track.id,
|
|
serverId: getPlaybackIndexKey() || null,
|
|
streamFormatSuffix: track.suffix ?? null,
|
|
})
|
|
.then(() => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
if (keepPreloadHint) {
|
|
set({ enginePreloadedTrackId: null });
|
|
}
|
|
const durSeek = track.duration && track.duration > 0 ? track.duration : null;
|
|
const seekTo = initialTime;
|
|
const canSeekAfterPlay =
|
|
seekTo > 0.05 && (durSeek == null || seekTo < durSeek - 0.05);
|
|
if (canSeekAfterPlay) {
|
|
void invoke('audio_seek', { seconds: seekTo })
|
|
.then(() => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
setSeekTarget(seekTo);
|
|
if (getSeekFallbackVisualTarget()?.trackId === track.id) {
|
|
setSeekFallbackVisualTarget(null);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (getSeekFallbackVisualTarget()?.trackId === track.id) {
|
|
setSeekFallbackVisualTarget(null);
|
|
}
|
|
});
|
|
}
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
setDeferHotCachePrefetch(false);
|
|
console.error('[psysonic] audio_play failed:', err);
|
|
set({ isPlaying: false });
|
|
setTimeout(() => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
get().next(false);
|
|
}, 500);
|
|
});
|
|
|
|
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
|
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
|
if (npEnabled) reportNowPlaying(track.id, getPlaybackServerId());
|
|
if (lfmKey) {
|
|
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
|
|
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
|
|
const cacheKey = `${track.title}::${track.artist}`;
|
|
set(s => ({
|
|
lastfmLoved: loved,
|
|
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
|
|
}));
|
|
});
|
|
}
|
|
syncQueueToServer(get().queueItems, track, initialTime);
|
|
touchHotCacheOnPlayback(track.id, playbackCacheSid);
|
|
};
|
|
|
|
const hotPromoteSid = getPlaybackCacheServerKey();
|
|
if (needSameTrackHotPromote && hotPromoteSid) {
|
|
void promoteCompletedStreamToHotCache(
|
|
track,
|
|
hotPromoteSid,
|
|
authState.hotCacheDownloadDir || null,
|
|
)
|
|
.then(() => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
runPlayTrackBody();
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (getPlayGeneration() !== gen) return;
|
|
setDeferHotCachePrefetch(false);
|
|
console.error('[psysonic] same-track hot promote / play body failed:', err);
|
|
set({ isPlaying: false });
|
|
});
|
|
} else {
|
|
runPlayTrackBody();
|
|
}
|
|
}
|