From 0aadae061fdc4e3cb1b93faec19d695ca880930a Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 21:38:03 +0200 Subject: [PATCH] =?UTF-8?q?refactor(player):=20E.38=20=E2=80=94=20extract?= =?UTF-8?q?=20seek=20action=20(#602)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the ~63-LOC `seek` action body into `runSeek(set, get, progress)` in `src/store/seekAction.ts`, following the helper-with-thin-wrapper pattern from E.37 (updateReplayGain). The action handles the full seek path: 0..1 fraction → bounded time, 100 ms debounce, hot-cache-rebind branch via playTrack, and the recoverable-seek-error retry burst (visual target pin + restart on "not seekable" + bounded retry window). Pure code-move. playerStore.ts: 938 → 868 LOC (−70). --- src/store/playerStore.ts | 76 ++--------------------------- src/store/seekAction.ts | 103 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 73 deletions(-) create mode 100644 src/store/seekAction.ts diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 60224bff..8a08308a 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -20,7 +20,6 @@ import { sameQueueTrackId, } from '../utils/queueIdentity'; import { waveformBlobLenOk } from '../utils/waveformParse'; -import { isRecoverableSeekError } from '../utils/seekErrors'; import { emitPlaybackProgress, getPlaybackProgressSnapshot, @@ -65,18 +64,12 @@ import { } from './radioPlayer'; import { clearSeekFallbackRetry, - getSeekFallbackRestartAt, - getSeekFallbackTrackId, getSeekFallbackVisualTarget, - scheduleSeekFallbackRetry, setSeekFallbackRestartAt, setSeekFallbackTrackId, setSeekFallbackVisualTarget, } from './seekFallbackState'; -import { - armSeekDebounce, - clearSeekDebounce, -} from './seekDebounce'; +import { clearSeekDebounce } from './seekDebounce'; import { bumpPlayGeneration, getIsAudioPaused, @@ -143,6 +136,7 @@ import type { PlayerState, Track } from './playerStoreTypes'; export type { PlayerState, Track }; import { createLastfmActions } from './lastfmActions'; import { createMiscActions } from './miscActions'; +import { runSeek } from './seekAction'; import { runUpdateReplayGainForCurrentTrack } from './updateReplayGainAction'; import { createQueueMutationActions } from './queueMutationActions'; import { createScheduleActions } from './scheduleActions'; @@ -831,71 +825,7 @@ export const usePlayerStore = create()( }, // ── seek ───────────────────────────────────────────────────────────────── - // 100 ms debounce collapses rapid slider drags into one actual seek. - seek: (progress) => { - const { currentTrack } = get(); - if (!currentTrack) return; - const dur = currentTrack.duration; - if (!dur || !isFinite(dur)) return; - const time = Math.max(0, Math.min(progress * dur, dur - 0.25)); - set({ progress: time / dur, currentTime: time }); - armSeekDebounce(100, () => { - const s0 = get(); - if (!s0.currentTrack) return; - const authSeek = useAuthStore.getState(); - const sidSeek = authSeek.activeServerId ?? ''; - if (shouldRebindPlaybackToHotCache(s0.currentTrack.id, sidSeek)) { - setSeekFallbackVisualTarget({ - trackId: s0.currentTrack.id, - seconds: time, - setAtMs: Date.now(), - }); - clearSeekFallbackRetry(); - s0.playTrack(s0.currentTrack, s0.queue, true); - return; - } - invoke('audio_seek', { seconds: time }).then(() => { - // Arm stale-progress guard only after backend acknowledged seek. - setSeekTarget(time); - setSeekFallbackVisualTarget(null); - clearSeekFallbackRetry(); - }).catch((err: unknown) => { - // Release the progress-tick guard so the UI doesn't freeze - // waiting for a target the engine will never reach. - clearSeekTarget(); - const msg = String(err ?? ''); - if (!isRecoverableSeekError(msg)) { - console.error(err); - setSeekFallbackVisualTarget(null); - clearSeekFallbackRetry(); - return; - } - // Streaming-start path can be temporarily non-seekable or busy. - // Keep UI at target and retry seek for a short bounded window. - const s = get(); - if (!s.currentTrack) return; - const now = Date.now(); - const sameBurst = - getSeekFallbackTrackId() === s.currentTrack.id - && now - getSeekFallbackRestartAt() < 600; - setSeekFallbackVisualTarget({ - trackId: s.currentTrack.id, - seconds: time, - setAtMs: Date.now(), - }); - // Keep stale progress ticks from snapping UI back to start while - // recoverable seek retries are still in flight. - setSeekTarget(time); - if (msg.includes('not seekable') && !sameBurst) { - setSeekFallbackTrackId(s.currentTrack.id); - setSeekFallbackRestartAt(now); - // Keep manual semantics (no crossfade) for seek recovery restarts. - s.playTrack(s.currentTrack, s.queue, true); - } - scheduleSeekFallbackRetry(s.currentTrack.id, time); - }); - }); - }, + seek: (progress) => runSeek(set, get, progress), updateReplayGainForCurrentTrack: () => runUpdateReplayGainForCurrentTrack(set, get), }; diff --git a/src/store/seekAction.ts b/src/store/seekAction.ts new file mode 100644 index 00000000..91da1a5a --- /dev/null +++ b/src/store/seekAction.ts @@ -0,0 +1,103 @@ +import { invoke } from '@tauri-apps/api/core'; +import { isRecoverableSeekError } from '../utils/seekErrors'; +import { useAuthStore } from './authStore'; +import { shouldRebindPlaybackToHotCache } from './playbackUrlRouting'; +import type { PlayerState } from './playerStoreTypes'; +import { armSeekDebounce } from './seekDebounce'; +import { + clearSeekFallbackRetry, + getSeekFallbackRestartAt, + getSeekFallbackTrackId, + scheduleSeekFallbackRetry, + setSeekFallbackRestartAt, + setSeekFallbackTrackId, + setSeekFallbackVisualTarget, +} from './seekFallbackState'; +import { + clearSeekTarget, + setSeekTarget, +} from './seekTargetState'; + +type SetState = ( + partial: Partial | ((state: PlayerState) => Partial), +) => void; +type GetState = () => PlayerState; + +/** + * Seek to a 0..1 fraction of the current track. 100 ms debounce + * collapses rapid slider drags into one actual engine seek; when the + * resolved playback source is about to be replaced by a hot-cache + * rebind, we re-issue `playTrack` with a visual target instead of + * a raw `audio_seek` (otherwise the seek would land in the old + * source and snap the progress UI back). + * + * Recoverable backend errors (streaming start not yet seekable / busy) + * are translated into a bounded retry burst: keep the UI's visual + * target pinned at the requested position, schedule another seek + * attempt, and on the "not seekable" subset restart the track from + * the same position via `playTrack`. + */ +export function runSeek(set: SetState, get: GetState, progress: number): void { + const { currentTrack } = get(); + if (!currentTrack) return; + const dur = currentTrack.duration; + if (!dur || !isFinite(dur)) return; + const time = Math.max(0, Math.min(progress * dur, dur - 0.25)); + set({ progress: time / dur, currentTime: time }); + armSeekDebounce(100, () => { + const s0 = get(); + if (!s0.currentTrack) return; + const authSeek = useAuthStore.getState(); + const sidSeek = authSeek.activeServerId ?? ''; + if (shouldRebindPlaybackToHotCache(s0.currentTrack.id, sidSeek)) { + setSeekFallbackVisualTarget({ + trackId: s0.currentTrack.id, + seconds: time, + setAtMs: Date.now(), + }); + clearSeekFallbackRetry(); + s0.playTrack(s0.currentTrack, s0.queue, true); + return; + } + invoke('audio_seek', { seconds: time }).then(() => { + // Arm stale-progress guard only after backend acknowledged seek. + setSeekTarget(time); + setSeekFallbackVisualTarget(null); + clearSeekFallbackRetry(); + }).catch((err: unknown) => { + // Release the progress-tick guard so the UI doesn't freeze + // waiting for a target the engine will never reach. + clearSeekTarget(); + const msg = String(err ?? ''); + if (!isRecoverableSeekError(msg)) { + console.error(err); + setSeekFallbackVisualTarget(null); + clearSeekFallbackRetry(); + return; + } + // Streaming-start path can be temporarily non-seekable or busy. + // Keep UI at target and retry seek for a short bounded window. + const s = get(); + if (!s.currentTrack) return; + const now = Date.now(); + const sameBurst = + getSeekFallbackTrackId() === s.currentTrack.id + && now - getSeekFallbackRestartAt() < 600; + setSeekFallbackVisualTarget({ + trackId: s.currentTrack.id, + seconds: time, + setAtMs: Date.now(), + }); + // Keep stale progress ticks from snapping UI back to start while + // recoverable seek retries are still in flight. + setSeekTarget(time); + if (msg.includes('not seekable') && !sameBurst) { + setSeekFallbackTrackId(s.currentTrack.id); + setSeekFallbackRestartAt(now); + // Keep manual semantics (no crossfade) for seek recovery restarts. + s.playTrack(s.currentTrack, s.queue, true); + } + scheduleSeekFallbackRetry(s.currentTrack.id, time); + }); + }); +}