mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
refactor(player): E.38 — extract seek action (#602)
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).
This commit is contained in:
committed by
GitHub
parent
14346d1482
commit
0aadae061f
@@ -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<PlayerState>()(
|
||||
},
|
||||
|
||||
// ── 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),
|
||||
};
|
||||
|
||||
@@ -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<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||
) => 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user