mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
committed by
GitHub
parent
c10eabe114
commit
0eb084c5f8
+51
-97
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user