refactor(player): E.22 — extract seek-fallback retry + visual target state (#586)

The streaming-fallback seek recovery loop + its visual coverup move
into `src/store/seekFallbackState.ts`:

  - 6 module mutables (retry timer + start + target; fallback trackId +
    restartAt; visual target)
  - 3 const gates (180 ms retry interval, 6 s retry budget, 1.6 s
    visual guard)
  - `scheduleSeekFallbackRetry(trackId, seconds)` — bounded retry loop
    that hits `audio_seek` every 180 ms up to 6 s, re-scheduling on
    recoverable errors and clearing the visual target on success or
    hard failure
  - `clearSeekFallbackRetry()` — cancel timer + reset state
  - get/set accessors for the three caller-touched fields
    (`SeekFallbackVisualTarget`, trackId, restartAt)

playerStore's ~30 direct-access sites become accessor calls. Inside
the progress handler the visual target is captured into a local once
per call so type narrowing + repeated reads stay clean.

17 focused tests pin the constants, get/set round-trips, the retry
loop's happy path (setSeekTarget + clear visual), recoverable retry
re-schedule, non-recoverable abort, track-change abort, retry-budget
exhaustion, and the three coalesce cases (different track id /
seconds delta > 0.25 s / seconds delta ≤ 0.25 s).

playerStore 2909 → 2869 LOC.
This commit is contained in:
Frank Stellmacher
2026-05-12 17:01:22 +02:00
committed by GitHub
parent c10eabe114
commit 0eb084c5f8
3 changed files with 380 additions and 97 deletions
+128
View File
@@ -0,0 +1,128 @@
import { invoke } from '@tauri-apps/api/core';
import { isRecoverableSeekError } from '../utils/seekErrors';
import { usePlayerStore } from './playerStore';
import { setSeekTarget } from './seekTargetState';
/**
* Streaming-fallback seek recovery + visual coverup.
*
* The Rust seek pipeline can reject a seek as "not seekable" while the
* stream is still settling (sink not bound yet, codec hasn't reported
* seekability). Instead of surfacing the failure straight away, this
* module schedules bounded retries every 180 ms up to a 6 s ceiling.
*
* In parallel, `seekFallbackVisualTarget` holds the seekbar at the
* requested position so the user doesn't see it snap back to the
* pre-seek time while the retry loop is in flight. The progress handler
* reads this and prefers the target value until the engine actually
* reaches it (within ~2 s) or the 1.6 s visual-guard window elapses.
*
* `seekFallbackTrackId` + `seekFallbackRestartAt` track the most recent
* fallback-restart so a quick subsequent seek on the same track can
* decide whether to debounce a full restart vs reuse the loop.
*/
export const SEEK_FALLBACK_VISUAL_GUARD_MS = 1600;
export const SEEK_FALLBACK_RETRY_INTERVAL_MS = 180;
export const SEEK_FALLBACK_RETRY_MAX_MS = 6000;
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null;
let seekFallbackRetryStartedAt = 0;
let seekFallbackRetryTarget: { trackId: string; seconds: number } | null = null;
let seekFallbackTrackId: string | null = null;
let seekFallbackRestartAt = 0;
let seekFallbackVisualTarget: { trackId: string; seconds: number; setAtMs: number } | null = null;
export function clearSeekFallbackRetry(): void {
if (seekFallbackRetryTimer) {
clearTimeout(seekFallbackRetryTimer);
seekFallbackRetryTimer = null;
}
seekFallbackRetryStartedAt = 0;
seekFallbackRetryTarget = null;
}
export function scheduleSeekFallbackRetry(trackId: string, seconds: number): void {
const now = Date.now();
if (
!seekFallbackRetryTarget
|| seekFallbackRetryTarget.trackId !== trackId
|| Math.abs(seekFallbackRetryTarget.seconds - seconds) > 0.25
) {
clearSeekFallbackRetry();
seekFallbackRetryStartedAt = now;
seekFallbackRetryTarget = { trackId, seconds };
} else if (seekFallbackRetryStartedAt === 0) {
seekFallbackRetryStartedAt = now;
}
if (seekFallbackRetryTimer) clearTimeout(seekFallbackRetryTimer);
seekFallbackRetryTimer = setTimeout(() => {
seekFallbackRetryTimer = null;
const target = seekFallbackRetryTarget;
const s = usePlayerStore.getState();
if (!target || !s.currentTrack || s.currentTrack.id !== target.trackId) {
clearSeekFallbackRetry();
return;
}
if (Date.now() - seekFallbackRetryStartedAt > SEEK_FALLBACK_RETRY_MAX_MS) {
clearSeekFallbackRetry();
seekFallbackVisualTarget = null;
return;
}
invoke('audio_seek', { seconds: target.seconds }).then(() => {
setSeekTarget(target.seconds);
seekFallbackVisualTarget = null;
clearSeekFallbackRetry();
}).catch((err: unknown) => {
const msg = String(err ?? '');
if (!isRecoverableSeekError(msg)) {
console.error(err);
seekFallbackVisualTarget = null;
clearSeekFallbackRetry();
return;
}
scheduleSeekFallbackRetry(target.trackId, target.seconds);
});
}, SEEK_FALLBACK_RETRY_INTERVAL_MS);
}
export type SeekFallbackVisualTarget = {
trackId: string;
seconds: number;
setAtMs: number;
};
export function getSeekFallbackVisualTarget(): SeekFallbackVisualTarget | null {
return seekFallbackVisualTarget;
}
export function setSeekFallbackVisualTarget(target: SeekFallbackVisualTarget | null): void {
seekFallbackVisualTarget = target;
}
export function getSeekFallbackTrackId(): string | null {
return seekFallbackTrackId;
}
export function setSeekFallbackTrackId(id: string | null): void {
seekFallbackTrackId = id;
}
export function getSeekFallbackRestartAt(): number {
return seekFallbackRestartAt;
}
export function setSeekFallbackRestartAt(t: number): void {
seekFallbackRestartAt = t;
}
/** Test-only: reset every mutable to its initial value. */
export function _resetSeekFallbackStateForTest(): void {
if (seekFallbackRetryTimer) clearTimeout(seekFallbackRetryTimer);
seekFallbackRetryTimer = null;
seekFallbackRetryStartedAt = 0;
seekFallbackRetryTarget = null;
seekFallbackTrackId = null;
seekFallbackRestartAt = 0;
seekFallbackVisualTarget = null;
}