From 7e902b918c691d53939ad2b65c62810c302f6f66 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 21:46:20 +0200 Subject: [PATCH] =?UTF-8?q?refactor(player):=20E.39=20=E2=80=94=20extract?= =?UTF-8?q?=20resume=20action=20(#603)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the ~165-LOC `resume` action body into `runResume(set, get)` in `src/store/resumeAction.ts`, following the helper-with-thin-wrapper pattern from E.37 + E.38. Covers all three resume branches: - Orbit guest catch-up to host's live position (same-track → seek; different-track → playTrack + deferred seek). - Radio resume via HTML5 audio. - Regular track resume: warm engine (audio_resume) or cold start (hot-cache promote → getSong refetch → audio_play → seek to persisted currentTime, with currentTrack fallback on fetch fail). Pure code-move. playerStore.ts: 868 → 702 LOC (−166). --- src/store/playerStore.ts | 172 +---------------------------- src/store/resumeAction.ts | 221 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 169 deletions(-) create mode 100644 src/store/resumeAction.ts diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 8a08308a..58b9dbcb 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -10,7 +10,6 @@ import { useOfflineStore } from './offlineStore'; import { useHotCacheStore } from './hotCacheStore'; import { orbitBulkGuard } from '../utils/orbitBulkGuard'; import { useOrbitStore } from './orbitStore'; -import { estimateLivePosition } from '../api/orbit'; import { resolveReplayGainDb } from '../utils/resolveReplayGainDb'; import { shuffleArray } from '../utils/shuffleArray'; import { songToTrack } from '../utils/songToTrack'; @@ -58,10 +57,7 @@ import { } from './seekTargetState'; import { refreshWaveformForTrack } from './waveformRefresh'; import { refreshLoudnessForTrack } from './loudnessRefresh'; -import { - resumeRadio, - stopRadio, -} from './radioPlayer'; +import { stopRadio } from './radioPlayer'; import { clearSeekFallbackRetry, getSeekFallbackVisualTarget, @@ -136,6 +132,7 @@ import type { PlayerState, Track } from './playerStoreTypes'; export type { PlayerState, Track }; import { createLastfmActions } from './lastfmActions'; import { createMiscActions } from './miscActions'; +import { runResume } from './resumeAction'; import { runSeek } from './seekAction'; import { runUpdateReplayGainForCurrentTrack } from './updateReplayGainAction'; import { createQueueMutationActions } from './queueMutationActions'; @@ -480,170 +477,7 @@ export const usePlayerStore = create()( }, // ── resume ─────────────────────────────────────────────────────────────── - resume: () => { - clearAllPlaybackScheduleTimers(); - set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null }); - - // Orbit guest: resume means "catch up to the host's live stream". - // The user hit pause at some earlier point; resuming shouldn't drop - // them back at the stale local position while the host is already - // two songs ahead. Covers PlayerBar, media keys, MPRIS — everything - // that funnels through resume(). - const orbit = useOrbitStore.getState(); - const hostState = orbit.state; - if (orbit.role === 'guest' && hostState?.isPlaying && hostState.currentTrack) { - const trackId = hostState.currentTrack.trackId; - const targetMs = estimateLivePosition(hostState, Date.now()); - const targetSec = Math.max(0, targetMs / 1000); - const localTrackId = get().currentTrack?.id; - void (async () => { - try { - const song = await getSong(trackId); - if (!song) return; - const track = songToTrack(song); - const fraction = Math.max(0, Math.min(0.99, targetSec / Math.max(1, track.duration))); - if (localTrackId === trackId) { - // Same track: seek + un-pause via the Rust engine directly. - // Bypasses this resume() branch re-entry via the early return below. - get().seek(fraction); - if (getIsAudioPaused()) { - invoke('audio_resume').catch(console.error); - setIsAudioPaused(false); - set({ isPlaying: true }); - } else { - set({ isPlaying: true }); - } - } else { - // Host has a different track — load it (`_orbitConfirmed=true` - // skips the bulk gate; single-track play isn't a bulk replace - // anyway). Seek after a short defer once the engine loads. - get().playTrack(track, [track], false, true); - window.setTimeout(() => { - if (get().currentTrack?.id === trackId) get().seek(fraction); - }, 400); - } - } catch { /* silent */ } - })(); - return; - } - - if (get().currentRadio) { - resumeRadio().catch(console.error); - set({ isPlaying: true }); - return; - } - const { currentTrack, queue, queueIndex, currentTime } = get(); - if (!currentTrack) return; - const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null; - const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; - - if (getIsAudioPaused()) { - // Rust engine has audio loaded but paused — just resume it. - invoke('audio_resume').catch(console.error); - setIsAudioPaused(false); - set({ isPlaying: true }); - touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? ''); - } else { - // Engine has no loaded paused stream (app relaunch, or track ended and user - // hits play — `isAudioPaused` is false after `audio:ended`). Flush any - // `stream_completed_cache` from the prior play to hot disk before resolving URL. - const gen = bumpPlayGeneration(); - const vol = get().volume; - set({ isPlaying: true }); - - void (async () => { - const authHot = useAuthStore.getState(); - const resumePromoteSid = authHot.activeServerId; - if (authHot.hotCacheEnabled && resumePromoteSid) { - await promoteCompletedStreamToHotCache( - currentTrack, - resumePromoteSid, - authHot.hotCacheDownloadDir || null, - ); - } - if (getPlayGeneration() !== gen) return; - - // Fetch fresh track data from server to get replay gain metadata - getSong(currentTrack.id).then(freshSong => { - if (getPlayGeneration() !== gen) return; - const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack; - // Update store with fresh track data if available - if (freshSong) set({ currentTrack: trackToPlay }); - const authStateCold = useAuthStore.getState(); - const replayGainDbCold = resolveReplayGainDb( - trackToPlay, coldPrev, coldNext, - isReplayGainActive(), authStateCold.replayGainMode, - ); - const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null; - const coldServerId = useAuthStore.getState().activeServerId ?? ''; - setDeferHotCachePrefetch(true); - const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId); - set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) }); - recordEnginePlayUrl(trackToPlay.id, coldUrl); - touchHotCacheOnPlayback(trackToPlay.id, coldServerId); - invoke('audio_play', { - url: coldUrl, - volume: vol, - durationHint: trackToPlay.duration, - replayGainDb: replayGainDbCold, - replayGainPeak: replayGainPeakCold, - loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id), - preGainDb: authStateCold.replayGainPreGainDb, - fallbackDb: authStateCold.replayGainFallbackDb, - manual: false, - hiResEnabled: useAuthStore.getState().enableHiRes, - analysisTrackId: trackToPlay.id, - streamFormatSuffix: trackToPlay.suffix ?? null, - }).then(() => { - if (getPlayGeneration() === gen && currentTime > 1) { - invoke('audio_seek', { seconds: currentTime }).catch(console.error); - } - }).catch((err: unknown) => { - if (getPlayGeneration() !== gen) return; - setDeferHotCachePrefetch(false); - console.error('[psysonic] audio_play (cold resume) failed:', err); - set({ isPlaying: false }); - }); - syncQueueToServer(queue, trackToPlay, currentTime); - }).catch(() => { - if (getPlayGeneration() !== gen) return; - // Fallback to currentTrack if fetch fails - const authStateCold = useAuthStore.getState(); - const replayGainDbCold = resolveReplayGainDb( - currentTrack, coldPrev, coldNext, - isReplayGainActive(), authStateCold.replayGainMode, - ); - const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null; - const coldServerId = useAuthStore.getState().activeServerId ?? ''; - setDeferHotCachePrefetch(true); - const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId); - set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) }); - recordEnginePlayUrl(currentTrack.id, coldUrl); - touchHotCacheOnPlayback(currentTrack.id, coldServerId); - invoke('audio_play', { - url: coldUrl, - volume: vol, - durationHint: currentTrack.duration, - replayGainDb: replayGainDbCold, - replayGainPeak: replayGainPeakCold, - loudnessGainDb: loudnessGainDbForEngineBind(currentTrack.id), - preGainDb: authStateCold.replayGainPreGainDb, - fallbackDb: authStateCold.replayGainFallbackDb, - manual: false, - hiResEnabled: useAuthStore.getState().enableHiRes, - analysisTrackId: currentTrack.id, - streamFormatSuffix: currentTrack.suffix ?? null, - }).catch((err: unknown) => { - if (getPlayGeneration() !== gen) return; - setDeferHotCachePrefetch(false); - console.error('[psysonic] audio_play (cold resume) failed:', err); - set({ isPlaying: false }); - }); - syncQueueToServer(queue, currentTrack, currentTime); - }); - })(); - } - }, + resume: () => runResume(set, get), // ── next / previous ────────────────────────────────────────────────────── next: (manual = true) => { diff --git a/src/store/resumeAction.ts b/src/store/resumeAction.ts new file mode 100644 index 00000000..dde031b1 --- /dev/null +++ b/src/store/resumeAction.ts @@ -0,0 +1,221 @@ +import { invoke } from '@tauri-apps/api/core'; +import { estimateLivePosition } from '../api/orbit'; +import { getSong } from '../api/subsonic'; +import { setDeferHotCachePrefetch } from '../utils/hotCacheGate'; +import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl'; +import { resolveReplayGainDb } from '../utils/resolveReplayGainDb'; +import { songToTrack } from '../utils/songToTrack'; +import { useAuthStore } from './authStore'; +import { + bumpPlayGeneration, + getIsAudioPaused, + getPlayGeneration, + setIsAudioPaused, +} from './engineState'; +import { touchHotCacheOnPlayback } from './hotCacheTouch'; +import { + isReplayGainActive, + loudnessGainDbForEngineBind, +} from './loudnessGainCache'; +import { useOrbitStore } from './orbitStore'; +import { + playbackSourceHintForResolvedUrl, + recordEnginePlayUrl, +} from './playbackUrlRouting'; +import type { PlayerState } from './playerStoreTypes'; +import { promoteCompletedStreamToHotCache } from './promoteStreamCache'; +import { syncQueueToServer } from './queueSync'; +import { resumeRadio } from './radioPlayer'; +import { clearAllPlaybackScheduleTimers } from './scheduleTimers'; + +type SetState = ( + partial: Partial | ((state: PlayerState) => Partial), +) => void; +type GetState = () => PlayerState; + +/** + * Resume playback from a paused state. Three mutually-exclusive + * branches: + * + * 1. **Orbit guest** — catches the local player up to the host's live + * position. The user hit pause at some earlier point; resuming + * shouldn't drop them back at a stale local position while the + * host is already two songs ahead. Same-track → seek + un-pause; + * different-track → `playTrack` with a deferred seek. + * + * 2. **Radio** — HTML5 audio resume; no Rust engine involved. + * + * 3. **Regular track** — two sub-branches keyed off `getIsAudioPaused`: + * - **Warm**: engine still has the stream loaded but paused; + * `audio_resume` is enough. + * - **Cold**: engine has no loaded stream (app relaunch, or track + * ended and user hit play again). Promote any + * `stream_completed_cache` to hot disk, refetch the song from + * Navidrome for fresh ReplayGain metadata, call `audio_play`, + * then seek to the persisted `currentTime`. A `getSong` failure + * falls back to the in-memory `currentTrack`. + */ +export function runResume(set: SetState, get: GetState): void { + clearAllPlaybackScheduleTimers(); + set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null }); + + // Orbit guest: resume means "catch up to the host's live stream". + // The user hit pause at some earlier point; resuming shouldn't drop + // them back at the stale local position while the host is already + // two songs ahead. Covers PlayerBar, media keys, MPRIS — everything + // that funnels through resume(). + const orbit = useOrbitStore.getState(); + const hostState = orbit.state; + if (orbit.role === 'guest' && hostState?.isPlaying && hostState.currentTrack) { + const trackId = hostState.currentTrack.trackId; + const targetMs = estimateLivePosition(hostState, Date.now()); + const targetSec = Math.max(0, targetMs / 1000); + const localTrackId = get().currentTrack?.id; + void (async () => { + try { + const song = await getSong(trackId); + if (!song) return; + const track = songToTrack(song); + const fraction = Math.max(0, Math.min(0.99, targetSec / Math.max(1, track.duration))); + if (localTrackId === trackId) { + // Same track: seek + un-pause via the Rust engine directly. + // Bypasses this resume() branch re-entry via the early return below. + get().seek(fraction); + if (getIsAudioPaused()) { + invoke('audio_resume').catch(console.error); + setIsAudioPaused(false); + set({ isPlaying: true }); + } else { + set({ isPlaying: true }); + } + } else { + // Host has a different track — load it (`_orbitConfirmed=true` + // skips the bulk gate; single-track play isn't a bulk replace + // anyway). Seek after a short defer once the engine loads. + get().playTrack(track, [track], false, true); + window.setTimeout(() => { + if (get().currentTrack?.id === trackId) get().seek(fraction); + }, 400); + } + } catch { /* silent */ } + })(); + return; + } + + if (get().currentRadio) { + resumeRadio().catch(console.error); + set({ isPlaying: true }); + return; + } + const { currentTrack, queue, queueIndex, currentTime } = get(); + if (!currentTrack) return; + const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null; + const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; + + if (getIsAudioPaused()) { + // Rust engine has audio loaded but paused — just resume it. + invoke('audio_resume').catch(console.error); + setIsAudioPaused(false); + set({ isPlaying: true }); + touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? ''); + } else { + // Engine has no loaded paused stream (app relaunch, or track ended and user + // hits play — `isAudioPaused` is false after `audio:ended`). Flush any + // `stream_completed_cache` from the prior play to hot disk before resolving URL. + const gen = bumpPlayGeneration(); + const vol = get().volume; + set({ isPlaying: true }); + + void (async () => { + const authHot = useAuthStore.getState(); + const resumePromoteSid = authHot.activeServerId; + if (authHot.hotCacheEnabled && resumePromoteSid) { + await promoteCompletedStreamToHotCache( + currentTrack, + resumePromoteSid, + authHot.hotCacheDownloadDir || null, + ); + } + if (getPlayGeneration() !== gen) return; + + // Fetch fresh track data from server to get replay gain metadata + getSong(currentTrack.id).then(freshSong => { + if (getPlayGeneration() !== gen) return; + const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack; + // Update store with fresh track data if available + if (freshSong) set({ currentTrack: trackToPlay }); + const authStateCold = useAuthStore.getState(); + const replayGainDbCold = resolveReplayGainDb( + trackToPlay, coldPrev, coldNext, + isReplayGainActive(), authStateCold.replayGainMode, + ); + const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null; + const coldServerId = useAuthStore.getState().activeServerId ?? ''; + setDeferHotCachePrefetch(true); + const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId); + set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) }); + recordEnginePlayUrl(trackToPlay.id, coldUrl); + touchHotCacheOnPlayback(trackToPlay.id, coldServerId); + invoke('audio_play', { + url: coldUrl, + volume: vol, + durationHint: trackToPlay.duration, + replayGainDb: replayGainDbCold, + replayGainPeak: replayGainPeakCold, + loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id), + preGainDb: authStateCold.replayGainPreGainDb, + fallbackDb: authStateCold.replayGainFallbackDb, + manual: false, + hiResEnabled: useAuthStore.getState().enableHiRes, + analysisTrackId: trackToPlay.id, + streamFormatSuffix: trackToPlay.suffix ?? null, + }).then(() => { + if (getPlayGeneration() === gen && currentTime > 1) { + invoke('audio_seek', { seconds: currentTime }).catch(console.error); + } + }).catch((err: unknown) => { + if (getPlayGeneration() !== gen) return; + setDeferHotCachePrefetch(false); + console.error('[psysonic] audio_play (cold resume) failed:', err); + set({ isPlaying: false }); + }); + syncQueueToServer(queue, trackToPlay, currentTime); + }).catch(() => { + if (getPlayGeneration() !== gen) return; + // Fallback to currentTrack if fetch fails + const authStateCold = useAuthStore.getState(); + const replayGainDbCold = resolveReplayGainDb( + currentTrack, coldPrev, coldNext, + isReplayGainActive(), authStateCold.replayGainMode, + ); + const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null; + const coldServerId = useAuthStore.getState().activeServerId ?? ''; + setDeferHotCachePrefetch(true); + const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId); + set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) }); + recordEnginePlayUrl(currentTrack.id, coldUrl); + touchHotCacheOnPlayback(currentTrack.id, coldServerId); + invoke('audio_play', { + url: coldUrl, + volume: vol, + durationHint: currentTrack.duration, + replayGainDb: replayGainDbCold, + replayGainPeak: replayGainPeakCold, + loudnessGainDb: loudnessGainDbForEngineBind(currentTrack.id), + preGainDb: authStateCold.replayGainPreGainDb, + fallbackDb: authStateCold.replayGainFallbackDb, + manual: false, + hiResEnabled: useAuthStore.getState().enableHiRes, + analysisTrackId: currentTrack.id, + streamFormatSuffix: currentTrack.suffix ?? null, + }).catch((err: unknown) => { + if (getPlayGeneration() !== gen) return; + setDeferHotCachePrefetch(false); + console.error('[psysonic] audio_play (cold resume) failed:', err); + set({ isPlaying: false }); + }); + syncQueueToServer(queue, currentTrack, currentTime); + }); + })(); + } +}