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
+51 -97
View File
@@ -120,6 +120,17 @@ import {
markStoreProgressCommit,
resetProgressEmitThrottles,
} from './playbackThrottles';
import {
SEEK_FALLBACK_VISUAL_GUARD_MS,
clearSeekFallbackRetry,
getSeekFallbackRestartAt,
getSeekFallbackTrackId,
getSeekFallbackVisualTarget,
scheduleSeekFallbackRetry,
setSeekFallbackRestartAt,
setSeekFallbackTrackId,
setSeekFallbackVisualTarget,
} from './seekFallbackState';
// Re-export so TauriEventBridge + persistence test keep their existing
// `from './playerStore'` imports.
@@ -473,70 +484,8 @@ function queueUndoRestoreAudioEngine(opts: {
// Debounce timer for seek slider drags.
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
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;
const SEEK_FALLBACK_VISUAL_GUARD_MS = 1600;
const SEEK_FALLBACK_RETRY_INTERVAL_MS = 180;
const SEEK_FALLBACK_RETRY_MAX_MS = 6000;
function clearSeekFallbackRetry() {
if (seekFallbackRetryTimer) {
clearTimeout(seekFallbackRetryTimer);
seekFallbackRetryTimer = null;
}
seekFallbackRetryStartedAt = 0;
seekFallbackRetryTarget = null;
}
function scheduleSeekFallbackRetry(trackId: string, seconds: number) {
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);
}
function prefetchLoudnessForEnqueuedTracks(
mergedQueue: Track[],
queueIndex: number,
@@ -585,23 +534,24 @@ function handleAudioProgress(current_time: number, duration: number) {
// Some backends can emit stale progress ticks shortly after pause/stop.
// Ignoring them avoids reactivating UI redraw loops while transport is idle.
const transportActive = store.isPlaying || store.currentRadio != null;
if (!transportActive && !seekFallbackVisualTarget) return;
if (seekFallbackVisualTarget && seekFallbackVisualTarget.trackId !== track.id) {
seekFallbackVisualTarget = null;
let visualTarget = getSeekFallbackVisualTarget();
if (!transportActive && !visualTarget) return;
if (visualTarget && visualTarget.trackId !== track.id) {
setSeekFallbackVisualTarget(null);
visualTarget = null;
}
let displayTime = current_time;
if (
seekFallbackVisualTarget
&& seekFallbackVisualTarget.trackId === track.id
) {
const nearTarget = Math.abs(current_time - seekFallbackVisualTarget.seconds) <= 2.0;
if (visualTarget && visualTarget.trackId === track.id) {
const nearTarget = Math.abs(current_time - visualTarget.seconds) <= 2.0;
if (nearTarget) {
seekFallbackVisualTarget = null;
} else if (Date.now() - seekFallbackVisualTarget.setAtMs <= SEEK_FALLBACK_VISUAL_GUARD_MS) {
setSeekFallbackVisualTarget(null);
visualTarget = null;
} else if (Date.now() - visualTarget.setAtMs <= SEEK_FALLBACK_VISUAL_GUARD_MS) {
// Keep UI at the requested position while backend catches up.
displayTime = seekFallbackVisualTarget.seconds;
displayTime = visualTarget.seconds;
} else {
seekFallbackVisualTarget = null;
setSeekFallbackVisualTarget(null);
visualTarget = null;
}
}
const dur = duration > 0 ? duration : track.duration;
@@ -614,7 +564,7 @@ function handleAudioProgress(current_time: number, duration: number) {
if (
nowLive - getLastLiveProgressEmitAt() >= LIVE_PROGRESS_EMIT_MIN_MS ||
liveTimeDelta >= LIVE_PROGRESS_EMIT_MIN_DELTA_SEC ||
seekFallbackVisualTarget != null
visualTarget != null
) {
emitPlaybackProgress({
currentTime: displayTime,
@@ -649,7 +599,7 @@ function handleAudioProgress(current_time: number, duration: number) {
const nowCommit = Date.now();
const commitDelta = Math.abs(store.currentTime - displayTime);
const shouldCommitStore =
seekFallbackVisualTarget != null ||
visualTarget != null ||
nowCommit - getLastStoreProgressCommitAt() >= STORE_PROGRESS_COMMIT_MIN_MS ||
commitDelta >= STORE_PROGRESS_COMMIT_MIN_DELTA_SEC;
if (shouldCommitStore) {
@@ -1776,7 +1726,7 @@ export const usePlayerStore = create<PlayerState>()(
clearPreloadingIds(); // new track — allow fresh preload for next
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget();
clearSeekFallbackRetry();
seekFallbackRestartAt = 0;
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.
@@ -1786,9 +1736,12 @@ export const usePlayerStore = create<PlayerState>()(
const state = get();
const prevTrack = state.currentTrack;
seekFallbackTrackId = prevTrack?.id === track.id ? seekFallbackTrackId : null;
if (seekFallbackVisualTarget?.trackId !== track.id) {
seekFallbackVisualTarget = null;
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
@@ -1806,8 +1759,9 @@ export const usePlayerStore = create<PlayerState>()(
if (manual) {
pushQueueUndoFromGetter(get);
}
const pendingVisualTarget = seekFallbackVisualTarget?.trackId === track.id
? seekFallbackVisualTarget.seconds
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))
@@ -1919,13 +1873,13 @@ export const usePlayerStore = create<PlayerState>()(
.then(() => {
if (playGeneration !== gen) return;
setSeekTarget(seekTo);
if (seekFallbackVisualTarget?.trackId === track.id) {
seekFallbackVisualTarget = null;
if (getSeekFallbackVisualTarget()?.trackId === track.id) {
setSeekFallbackVisualTarget(null);
}
})
.catch(() => {
if (seekFallbackVisualTarget?.trackId === track.id) {
seekFallbackVisualTarget = null;
if (getSeekFallbackVisualTarget()?.trackId === track.id) {
setSeekFallbackVisualTarget(null);
}
});
}
@@ -2435,7 +2389,7 @@ export const usePlayerStore = create<PlayerState>()(
const authState = useAuthStore.getState();
const sid = authState.activeServerId ?? '';
if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) {
seekFallbackVisualTarget = { trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() };
setSeekFallbackVisualTarget({ trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() });
get().playTrack(currentTrack, queue, true);
return;
}
@@ -2464,11 +2418,11 @@ export const usePlayerStore = create<PlayerState>()(
const authSeek = useAuthStore.getState();
const sidSeek = authSeek.activeServerId ?? '';
if (shouldRebindPlaybackToHotCache(s0.currentTrack.id, sidSeek)) {
seekFallbackVisualTarget = {
setSeekFallbackVisualTarget({
trackId: s0.currentTrack.id,
seconds: time,
setAtMs: Date.now(),
};
});
clearSeekFallbackRetry();
s0.playTrack(s0.currentTrack, s0.queue, true);
return;
@@ -2476,7 +2430,7 @@ export const usePlayerStore = create<PlayerState>()(
invoke('audio_seek', { seconds: time }).then(() => {
// Arm stale-progress guard only after backend acknowledged seek.
setSeekTarget(time);
seekFallbackVisualTarget = null;
setSeekFallbackVisualTarget(null);
clearSeekFallbackRetry();
}).catch((err: unknown) => {
// Release the progress-tick guard so the UI doesn't freeze
@@ -2485,7 +2439,7 @@ export const usePlayerStore = create<PlayerState>()(
const msg = String(err ?? '');
if (!isRecoverableSeekError(msg)) {
console.error(err);
seekFallbackVisualTarget = null;
setSeekFallbackVisualTarget(null);
clearSeekFallbackRetry();
return;
}
@@ -2495,19 +2449,19 @@ export const usePlayerStore = create<PlayerState>()(
if (!s.currentTrack) return;
const now = Date.now();
const sameBurst =
seekFallbackTrackId === s.currentTrack.id
&& now - seekFallbackRestartAt < 600;
seekFallbackVisualTarget = {
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) {
seekFallbackTrackId = s.currentTrack.id;
seekFallbackRestartAt = now;
setSeekFallbackTrackId(s.currentTrack.id);
setSeekFallbackRestartAt(now);
// Keep manual semantics (no crossfade) for seek recovery restarts.
s.playTrack(s.currentTrack, s.queue, true);
}
+201
View File
@@ -0,0 +1,201 @@
/**
* Seek-fallback retry loop + visual target accessors. The retry timer
* fires every 180 ms (SEEK_FALLBACK_RETRY_INTERVAL_MS) up to 6 s
* (SEEK_FALLBACK_RETRY_MAX_MS). Success calls setSeekTarget + clears the
* visual target; recoverable errors re-schedule; non-recoverable errors
* abort + clear visual target.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const playerState = { currentTrack: null as { id: string } | null };
return {
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => undefined),
setSeekTargetMock: vi.fn(),
isRecoverableSeekErrorMock: vi.fn((msg: string) => msg.includes('not seekable')),
playerStateGet: () => playerState,
playerState,
};
});
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
vi.mock('../utils/seekErrors', () => ({ isRecoverableSeekError: hoisted.isRecoverableSeekErrorMock }));
vi.mock('./seekTargetState', () => ({ setSeekTarget: hoisted.setSeekTargetMock }));
vi.mock('./playerStore', () => ({
usePlayerStore: { getState: hoisted.playerStateGet },
}));
import {
SEEK_FALLBACK_RETRY_INTERVAL_MS,
SEEK_FALLBACK_RETRY_MAX_MS,
SEEK_FALLBACK_VISUAL_GUARD_MS,
_resetSeekFallbackStateForTest,
clearSeekFallbackRetry,
getSeekFallbackRestartAt,
getSeekFallbackTrackId,
getSeekFallbackVisualTarget,
scheduleSeekFallbackRetry,
setSeekFallbackRestartAt,
setSeekFallbackTrackId,
setSeekFallbackVisualTarget,
} from './seekFallbackState';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-12T12:00:00Z'));
hoisted.invokeMock.mockReset();
hoisted.invokeMock.mockResolvedValue(undefined);
hoisted.setSeekTargetMock.mockClear();
hoisted.isRecoverableSeekErrorMock.mockReset();
hoisted.isRecoverableSeekErrorMock.mockImplementation((msg: string) => msg.includes('not seekable'));
hoisted.playerState.currentTrack = { id: 't1' };
});
afterEach(() => {
_resetSeekFallbackStateForTest();
vi.useRealTimers();
});
describe('constants', () => {
it('match the values the runtime expects', () => {
expect(SEEK_FALLBACK_VISUAL_GUARD_MS).toBe(1600);
expect(SEEK_FALLBACK_RETRY_INTERVAL_MS).toBe(180);
expect(SEEK_FALLBACK_RETRY_MAX_MS).toBe(6000);
});
});
describe('visual target accessors', () => {
it('start at null', () => {
expect(getSeekFallbackVisualTarget()).toBeNull();
});
it('round-trip through set/get', () => {
const target = { trackId: 't1', seconds: 42, setAtMs: 1000 };
setSeekFallbackVisualTarget(target);
expect(getSeekFallbackVisualTarget()).toEqual(target);
});
it('accept null to clear', () => {
setSeekFallbackVisualTarget({ trackId: 't1', seconds: 1, setAtMs: 0 });
setSeekFallbackVisualTarget(null);
expect(getSeekFallbackVisualTarget()).toBeNull();
});
});
describe('trackId + restartAt accessors', () => {
it('start at null / 0', () => {
expect(getSeekFallbackTrackId()).toBeNull();
expect(getSeekFallbackRestartAt()).toBe(0);
});
it('round-trip', () => {
setSeekFallbackTrackId('t1');
setSeekFallbackRestartAt(12345);
expect(getSeekFallbackTrackId()).toBe('t1');
expect(getSeekFallbackRestartAt()).toBe(12345);
});
});
describe('clearSeekFallbackRetry', () => {
it('is a no-op when nothing is scheduled', () => {
expect(() => clearSeekFallbackRetry()).not.toThrow();
});
it('cancels a pending retry', () => {
scheduleSeekFallbackRetry('t1', 10);
clearSeekFallbackRetry();
vi.advanceTimersByTime(SEEK_FALLBACK_RETRY_MAX_MS);
// No invoke fired because the timer was cancelled.
expect(hoisted.invokeMock).not.toHaveBeenCalled();
});
});
describe('scheduleSeekFallbackRetry — happy path', () => {
it('fires `audio_seek` after the retry interval', async () => {
scheduleSeekFallbackRetry('t1', 30);
vi.advanceTimersByTime(SEEK_FALLBACK_RETRY_INTERVAL_MS);
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 30 });
});
it('calls setSeekTarget + clears visual target on a successful invoke', async () => {
setSeekFallbackVisualTarget({ trackId: 't1', seconds: 30, setAtMs: Date.now() });
scheduleSeekFallbackRetry('t1', 30);
hoisted.invokeMock.mockResolvedValueOnce(undefined);
await vi.advanceTimersByTimeAsync(SEEK_FALLBACK_RETRY_INTERVAL_MS);
await Promise.resolve();
expect(hoisted.setSeekTargetMock).toHaveBeenCalledWith(30);
expect(getSeekFallbackVisualTarget()).toBeNull();
});
});
describe('scheduleSeekFallbackRetry — error branches', () => {
it('re-schedules on a recoverable error', async () => {
hoisted.invokeMock
.mockRejectedValueOnce(new Error('not seekable'))
.mockResolvedValueOnce(undefined);
scheduleSeekFallbackRetry('t1', 30);
await vi.advanceTimersByTimeAsync(SEEK_FALLBACK_RETRY_INTERVAL_MS);
await Promise.resolve();
// Second attempt scheduled — fire after another interval.
await vi.advanceTimersByTimeAsync(SEEK_FALLBACK_RETRY_INTERVAL_MS);
await Promise.resolve();
expect(hoisted.invokeMock).toHaveBeenCalledTimes(2);
expect(hoisted.setSeekTargetMock).toHaveBeenCalledWith(30);
});
it('clears visual target + aborts on non-recoverable error', async () => {
setSeekFallbackVisualTarget({ trackId: 't1', seconds: 30, setAtMs: Date.now() });
hoisted.invokeMock.mockRejectedValueOnce(new Error('codec broken'));
hoisted.isRecoverableSeekErrorMock.mockReturnValueOnce(false);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
scheduleSeekFallbackRetry('t1', 30);
await vi.advanceTimersByTimeAsync(SEEK_FALLBACK_RETRY_INTERVAL_MS);
await Promise.resolve();
expect(getSeekFallbackVisualTarget()).toBeNull();
consoleSpy.mockRestore();
});
it('aborts when the timer fires after the track has changed', async () => {
scheduleSeekFallbackRetry('t1', 30);
hoisted.playerState.currentTrack = { id: 't2' };
await vi.advanceTimersByTimeAsync(SEEK_FALLBACK_RETRY_INTERVAL_MS);
expect(hoisted.invokeMock).not.toHaveBeenCalled();
});
it('aborts when the retry budget is exhausted', async () => {
setSeekFallbackVisualTarget({ trackId: 't1', seconds: 30, setAtMs: Date.now() });
// Advance system time past the budget BEFORE the scheduled callback runs.
scheduleSeekFallbackRetry('t1', 30);
vi.advanceTimersByTime(SEEK_FALLBACK_RETRY_INTERVAL_MS / 2);
vi.setSystemTime(new Date(Date.now() + SEEK_FALLBACK_RETRY_MAX_MS + 1));
await vi.advanceTimersByTimeAsync(SEEK_FALLBACK_RETRY_INTERVAL_MS);
expect(hoisted.invokeMock).not.toHaveBeenCalled();
expect(getSeekFallbackVisualTarget()).toBeNull();
});
});
describe('scheduleSeekFallbackRetry — coalescing', () => {
it('replaces the target when track id changes', () => {
scheduleSeekFallbackRetry('t1', 30);
scheduleSeekFallbackRetry('t2', 45);
hoisted.playerState.currentTrack = { id: 't2' };
vi.advanceTimersByTime(SEEK_FALLBACK_RETRY_INTERVAL_MS);
expect(hoisted.invokeMock).toHaveBeenCalledTimes(1);
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 45 });
});
it('replaces the target when seconds differ by more than 0.25 s', () => {
scheduleSeekFallbackRetry('t1', 30);
scheduleSeekFallbackRetry('t1', 31);
vi.advanceTimersByTime(SEEK_FALLBACK_RETRY_INTERVAL_MS);
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 31 });
});
it('reuses the target when seconds are close (≤0.25 s)', () => {
scheduleSeekFallbackRetry('t1', 30);
scheduleSeekFallbackRetry('t1', 30.1);
vi.advanceTimersByTime(SEEK_FALLBACK_RETRY_INTERVAL_MS);
// Original target (30) used because seconds are within 0.25.
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 30 });
});
});
+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;
}