From e92535a5f5cbbe4867426dd360fcafbc65eadb2a Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 22:21:44 +0200 Subject: [PATCH] =?UTF-8?q?refactor(player):=20E.41=20=E2=80=94=20extract?= =?UTF-8?q?=20playTrack=20action=20(#605)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the ~280-LOC `playTrack` action body into `runPlayTrack(set, get, track, queue, manual, _orbitConfirmed, targetQueueIndex)` in `src/store/playTrackAction.ts`, following the helper-with-thin-wrapper pattern from E.37–E.40. Covers all three guard layers (Orbit bulk-gate, Orbit-host single-track protection, ghost-command 500 ms guard), the same-track-hot-promote branch, and the inner `runPlayTrackBody` closure that resolves the URL, updates store + normalization optimistically, invokes the Rust engine, and on success seeks to the visual target if there was a pending one. With playTrack extracted, every action body has been moved out of the playerStore create() body. playerStore.ts: 515 → 180 LOC (−335), down from Phase E's starting 3732 LOC (~95% smaller). --- src/store/playTrackAction.ts | 364 +++++++++++++++++++++++++++++++++++ src/store/playerStore.ts | 343 +-------------------------------- 2 files changed, 368 insertions(+), 339 deletions(-) create mode 100644 src/store/playTrackAction.ts diff --git a/src/store/playTrackAction.ts b/src/store/playTrackAction.ts new file mode 100644 index 00000000..c8a51c9c --- /dev/null +++ b/src/store/playTrackAction.ts @@ -0,0 +1,364 @@ +import { invoke } from '@tauri-apps/api/core'; +import { lastfmGetTrackLoved, lastfmUpdateNowPlaying } from '../api/lastfm'; +import { reportNowPlaying } from '../api/subsonic'; +import { setDeferHotCachePrefetch } from '../utils/hotCacheGate'; +import { orbitBulkGuard } from '../utils/orbitBulkGuard'; +import { sameQueueTrackId } from '../utils/queueIdentity'; +import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl'; +import { resolveReplayGainDb } from '../utils/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 { promoteCompletedStreamToHotCache } from './promoteStreamCache'; +import { syncQueueToServer } from './queueSync'; +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 | ((state: PlayerState) => Partial), +) => 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().queue; + const sameAsCurrent = queue.length === current.length + && queue.every((t, i) => sameQueueTrackId(current[i]?.id, 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 current = get().queue; + const currentTrackId = current[get().queueIndex]?.id; + if (track.id !== currentTrackId) { + const existsAt = current.findIndex(t => sameQueueTrackId(t.id, track.id)); + if (existsAt >= 0) { + get().playTrack(track, current, manual, true, existsAt); + } else { + const newQueue = [...current, 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; + } + + 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); + } + const newQueue = queue ?? state.queue; + // 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 explicitIdxValid = + typeof targetQueueIndex === 'number' + && targetQueueIndex >= 0 + && targetQueueIndex < newQueue.length + && sameQueueTrackId(newQueue[targetQueueIndex]?.id, track.id); + const idx = explicitIdxValid + ? (targetQueueIndex as number) + : newQueue.findIndex(t => sameQueueTrackId(t.id, track.id)); + 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 + && authState.activeServerId, + ); + + const runPlayTrackBody = () => { + const authStateNow = useAuthStore.getState(); + const url = resolvePlaybackUrl(track.id, authStateNow.activeServerId ?? ''); + recordEnginePlayUrl(track.id, url); + const preloadedTrackId = get().enginePreloadedTrackId; + const keepPreloadHint = preloadedTrackId === track.id; + const playbackSourceHint = playbackSourceHintForResolvedUrl( + track.id, + authStateNow.activeServerId ?? '', + 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. + set({ + currentTrack: track, + currentRadio: null, + waveformBins: null, + ...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0), + queue: newQueue, + queueIndex: idx >= 0 ? idx : 0, + progress: initialProgress, + buffered: 0, + currentTime: initialTime, + scrobbled: false, + lastfmLoved: false, + isPlaying: true, // optimistic — reverted on error + currentPlaybackSource: playbackSourceHint, + enginePreloadedTrackId: keepPreloadHint ? track.id : null, + }); + + if ( + prevTrack + && !sameQueueTrackId(prevTrack.id, track.id) + && authStateNow.hotCacheEnabled + ) { + const prevPromoteSid = authStateNow.activeServerId; + if (prevPromoteSid) { + void promoteCompletedStreamToHotCache( + prevTrack, + prevPromoteSid, + authStateNow.hotCacheDownloadDir || null, + ); + } + } + void refreshWaveformForTrack(track.id); + void refreshLoudnessForTrack(track.id); + setDeferHotCachePrefetch(true); + const playIdx = idx >= 0 ? idx : 0; + const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null; + const replayGainDb = resolveReplayGainDb( + track, prevTrack, nextNeighbour, + isReplayGainActive(), authStateNow.replayGainMode, + ); + 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, + 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); + 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(newQueue, track, initialTime); + touchHotCacheOnPlayback(track.id, authStateNow.activeServerId ?? ''); + }; + + const hotPromoteSid = authState.activeServerId; + 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(); + } +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 2a7733db..0a2c75f9 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -1,75 +1,15 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; -import { invoke } from '@tauri-apps/api/core'; -import { buildStreamUrl, savePlayQueue, reportNowPlaying, getSong, setRating } from '../api/subsonic'; -import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl'; -import { setDeferHotCachePrefetch } from '../utils/hotCacheGate'; -import { lastfmUpdateNowPlaying, lastfmGetTrackLoved } from '../api/lastfm'; -import { useAuthStore } from './authStore'; -import { useOfflineStore } from './offlineStore'; -import { useHotCacheStore } from './hotCacheStore'; -import { orbitBulkGuard } from '../utils/orbitBulkGuard'; -import { useOrbitStore } from './orbitStore'; import { resolveReplayGainDb } from '../utils/resolveReplayGainDb'; import { shuffleArray } from '../utils/shuffleArray'; import { songToTrack } from '../utils/songToTrack'; -import { - queuesStructuralEqual, - sameQueueTrackId, -} from '../utils/queueIdentity'; -import { waveformBlobLenOk } from '../utils/waveformParse'; import { emitPlaybackProgress, getPlaybackProgressSnapshot, subscribePlaybackProgress, type PlaybackProgressSnapshot, } from './playbackProgress'; -import { - playbackSourceHintForResolvedUrl, - recordEnginePlayUrl, - shouldRebindPlaybackToHotCache, -} from './playbackUrlRouting'; -import { deriveNormalizationSnapshot } from './normalizationSnapshot'; -import { isInOrbitSession } from './orbitSession'; -import { - isReplayGainActive, - loudnessGainDbForEngineBind, -} from './loudnessGainCache'; -import { - clearAllPlaybackScheduleTimers, -} from './scheduleTimers'; -import { touchHotCacheOnPlayback } from './hotCacheTouch'; -import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState'; -import { - flushPlayQueuePosition, - syncQueueToServer, -} from './queueSync'; -import { - clearPreloadingIds, - getLastGaplessSwitchTime, -} from './gaplessPreloadState'; -import { promoteCompletedStreamToHotCache } from './promoteStreamCache'; -import { - clearSeekTarget, - setSeekTarget, -} from './seekTargetState'; -import { refreshWaveformForTrack } from './waveformRefresh'; -import { refreshLoudnessForTrack } from './loudnessRefresh'; -import { stopRadio } from './radioPlayer'; -import { - clearSeekFallbackRetry, - getSeekFallbackVisualTarget, - setSeekFallbackRestartAt, - setSeekFallbackTrackId, - setSeekFallbackVisualTarget, -} from './seekFallbackState'; -import { clearSeekDebounce } from './seekDebounce'; -import { - bumpPlayGeneration, - getIsAudioPaused, - getPlayGeneration, - setIsAudioPaused, -} from './engineState'; +import { flushPlayQueuePosition } from './queueSync'; import { initAudioListeners } from './initAudioListeners'; import { installQueueUndoHotkey } from './queueUndoHotkey'; import { readInitialQueueVisibility } from './queueVisibilityStorage'; @@ -120,6 +60,7 @@ export type { PlayerState, Track }; import { createLastfmActions } from './lastfmActions'; import { createMiscActions } from './miscActions'; import { runNext } from './nextAction'; +import { runPlayTrack } from './playTrackAction'; import { runResume } from './resumeAction'; import { runSeek } from './seekAction'; import { runUpdateReplayGainForCurrentTrack } from './updateReplayGainAction'; @@ -185,284 +126,8 @@ export const usePlayerStore = create()( ...createScheduleActions(set, get), // ── playTrack ──────────────────────────────────────────────────────────── - playTrack: (track, queue, manual = true, _orbitConfirmed = false, targetQueueIndex) => { - // 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().queue; - const sameAsCurrent = queue.length === current.length - && queue.every((t, i) => sameQueueTrackId(current[i]?.id, 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 current = get().queue; - const currentTrackId = current[get().queueIndex]?.id; - if (track.id !== currentTrackId) { - const existsAt = current.findIndex(t => sameQueueTrackId(t.id, track.id)); - if (existsAt >= 0) { - get().playTrack(track, current, manual, true, existsAt); - } else { - const newQueue = [...current, 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; - } - - 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); - } - const newQueue = queue ?? state.queue; - // 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 explicitIdxValid = - typeof targetQueueIndex === 'number' - && targetQueueIndex >= 0 - && targetQueueIndex < newQueue.length - && sameQueueTrackId(newQueue[targetQueueIndex]?.id, track.id); - const idx = explicitIdxValid - ? (targetQueueIndex as number) - : newQueue.findIndex(t => sameQueueTrackId(t.id, track.id)); - 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 - && authState.activeServerId, - ); - - const runPlayTrackBody = () => { - const authStateNow = useAuthStore.getState(); - const url = resolvePlaybackUrl(track.id, authStateNow.activeServerId ?? ''); - recordEnginePlayUrl(track.id, url); - const preloadedTrackId = get().enginePreloadedTrackId; - const keepPreloadHint = preloadedTrackId === track.id; - const playbackSourceHint = playbackSourceHintForResolvedUrl( - track.id, - authStateNow.activeServerId ?? '', - 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. - set({ - currentTrack: track, - currentRadio: null, - waveformBins: null, - ...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0), - queue: newQueue, - queueIndex: idx >= 0 ? idx : 0, - progress: initialProgress, - buffered: 0, - currentTime: initialTime, - scrobbled: false, - lastfmLoved: false, - isPlaying: true, // optimistic — reverted on error - currentPlaybackSource: playbackSourceHint, - enginePreloadedTrackId: keepPreloadHint ? track.id : null, - }); - - if ( - prevTrack - && !sameQueueTrackId(prevTrack.id, track.id) - && authStateNow.hotCacheEnabled - ) { - const prevPromoteSid = authStateNow.activeServerId; - if (prevPromoteSid) { - void promoteCompletedStreamToHotCache( - prevTrack, - prevPromoteSid, - authStateNow.hotCacheDownloadDir || null, - ); - } - } - void refreshWaveformForTrack(track.id); - void refreshLoudnessForTrack(track.id); - setDeferHotCachePrefetch(true); - const playIdx = idx >= 0 ? idx : 0; - const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null; - const replayGainDb = resolveReplayGainDb( - track, prevTrack, nextNeighbour, - isReplayGainActive(), authStateNow.replayGainMode, - ); - 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, - streamFormatSuffix: track.suffix ?? null, - }) - .then(() => { - if (getPlayGeneration() !== gen) return; - if (keepPreloadHint) { - usePlayerStore.setState({ 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); - if (lfmKey) { - if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey); - lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => { - const cacheKey = `${track.title}::${track.artist}`; - usePlayerStore.setState(s => ({ - lastfmLoved: loved, - lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved }, - })); - }); - } - syncQueueToServer(newQueue, track, initialTime); - touchHotCacheOnPlayback(track.id, authStateNow.activeServerId ?? ''); - }; - - const hotPromoteSid = authState.activeServerId; - 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(); - } - }, + playTrack: (track, queue, manual = true, _orbitConfirmed = false, targetQueueIndex) => + runPlayTrack(set, get, track, queue, manual, _orbitConfirmed, targetQueueIndex), // ── resume ─────────────────────────────────────────────────────────────── resume: () => runResume(set, get),