mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35: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,
|
markStoreProgressCommit,
|
||||||
resetProgressEmitThrottles,
|
resetProgressEmitThrottles,
|
||||||
} from './playbackThrottles';
|
} 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
|
// Re-export so TauriEventBridge + persistence test keep their existing
|
||||||
// `from './playerStore'` imports.
|
// `from './playerStore'` imports.
|
||||||
@@ -473,70 +484,8 @@ function queueUndoRestoreAudioEngine(opts: {
|
|||||||
// Debounce timer for seek slider drags.
|
// Debounce timer for seek slider drags.
|
||||||
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
|
// 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(
|
function prefetchLoudnessForEnqueuedTracks(
|
||||||
mergedQueue: Track[],
|
mergedQueue: Track[],
|
||||||
queueIndex: number,
|
queueIndex: number,
|
||||||
@@ -585,23 +534,24 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
// Some backends can emit stale progress ticks shortly after pause/stop.
|
// Some backends can emit stale progress ticks shortly after pause/stop.
|
||||||
// Ignoring them avoids reactivating UI redraw loops while transport is idle.
|
// Ignoring them avoids reactivating UI redraw loops while transport is idle.
|
||||||
const transportActive = store.isPlaying || store.currentRadio != null;
|
const transportActive = store.isPlaying || store.currentRadio != null;
|
||||||
if (!transportActive && !seekFallbackVisualTarget) return;
|
let visualTarget = getSeekFallbackVisualTarget();
|
||||||
if (seekFallbackVisualTarget && seekFallbackVisualTarget.trackId !== track.id) {
|
if (!transportActive && !visualTarget) return;
|
||||||
seekFallbackVisualTarget = null;
|
if (visualTarget && visualTarget.trackId !== track.id) {
|
||||||
|
setSeekFallbackVisualTarget(null);
|
||||||
|
visualTarget = null;
|
||||||
}
|
}
|
||||||
let displayTime = current_time;
|
let displayTime = current_time;
|
||||||
if (
|
if (visualTarget && visualTarget.trackId === track.id) {
|
||||||
seekFallbackVisualTarget
|
const nearTarget = Math.abs(current_time - visualTarget.seconds) <= 2.0;
|
||||||
&& seekFallbackVisualTarget.trackId === track.id
|
|
||||||
) {
|
|
||||||
const nearTarget = Math.abs(current_time - seekFallbackVisualTarget.seconds) <= 2.0;
|
|
||||||
if (nearTarget) {
|
if (nearTarget) {
|
||||||
seekFallbackVisualTarget = null;
|
setSeekFallbackVisualTarget(null);
|
||||||
} else if (Date.now() - seekFallbackVisualTarget.setAtMs <= SEEK_FALLBACK_VISUAL_GUARD_MS) {
|
visualTarget = null;
|
||||||
|
} else if (Date.now() - visualTarget.setAtMs <= SEEK_FALLBACK_VISUAL_GUARD_MS) {
|
||||||
// Keep UI at the requested position while backend catches up.
|
// Keep UI at the requested position while backend catches up.
|
||||||
displayTime = seekFallbackVisualTarget.seconds;
|
displayTime = visualTarget.seconds;
|
||||||
} else {
|
} else {
|
||||||
seekFallbackVisualTarget = null;
|
setSeekFallbackVisualTarget(null);
|
||||||
|
visualTarget = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const dur = duration > 0 ? duration : track.duration;
|
const dur = duration > 0 ? duration : track.duration;
|
||||||
@@ -614,7 +564,7 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
if (
|
if (
|
||||||
nowLive - getLastLiveProgressEmitAt() >= LIVE_PROGRESS_EMIT_MIN_MS ||
|
nowLive - getLastLiveProgressEmitAt() >= LIVE_PROGRESS_EMIT_MIN_MS ||
|
||||||
liveTimeDelta >= LIVE_PROGRESS_EMIT_MIN_DELTA_SEC ||
|
liveTimeDelta >= LIVE_PROGRESS_EMIT_MIN_DELTA_SEC ||
|
||||||
seekFallbackVisualTarget != null
|
visualTarget != null
|
||||||
) {
|
) {
|
||||||
emitPlaybackProgress({
|
emitPlaybackProgress({
|
||||||
currentTime: displayTime,
|
currentTime: displayTime,
|
||||||
@@ -649,7 +599,7 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
const nowCommit = Date.now();
|
const nowCommit = Date.now();
|
||||||
const commitDelta = Math.abs(store.currentTime - displayTime);
|
const commitDelta = Math.abs(store.currentTime - displayTime);
|
||||||
const shouldCommitStore =
|
const shouldCommitStore =
|
||||||
seekFallbackVisualTarget != null ||
|
visualTarget != null ||
|
||||||
nowCommit - getLastStoreProgressCommitAt() >= STORE_PROGRESS_COMMIT_MIN_MS ||
|
nowCommit - getLastStoreProgressCommitAt() >= STORE_PROGRESS_COMMIT_MIN_MS ||
|
||||||
commitDelta >= STORE_PROGRESS_COMMIT_MIN_DELTA_SEC;
|
commitDelta >= STORE_PROGRESS_COMMIT_MIN_DELTA_SEC;
|
||||||
if (shouldCommitStore) {
|
if (shouldCommitStore) {
|
||||||
@@ -1776,7 +1726,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
clearPreloadingIds(); // new track — allow fresh preload for next
|
clearPreloadingIds(); // new track — allow fresh preload for next
|
||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget();
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget();
|
||||||
clearSeekFallbackRetry();
|
clearSeekFallbackRetry();
|
||||||
seekFallbackRestartAt = 0;
|
setSeekFallbackRestartAt(0);
|
||||||
|
|
||||||
// If a radio stream is active, stop it before the new track starts so
|
// 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.
|
// the PlayerBar clears radio mode immediately and the stream is released.
|
||||||
@@ -1786,9 +1736,12 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
|
|
||||||
const state = get();
|
const state = get();
|
||||||
const prevTrack = state.currentTrack;
|
const prevTrack = state.currentTrack;
|
||||||
seekFallbackTrackId = prevTrack?.id === track.id ? seekFallbackTrackId : null;
|
if (prevTrack?.id !== track.id) {
|
||||||
if (seekFallbackVisualTarget?.trackId !== track.id) {
|
setSeekFallbackTrackId(null);
|
||||||
seekFallbackVisualTarget = null;
|
}
|
||||||
|
const visualOnEntry = getSeekFallbackVisualTarget();
|
||||||
|
if (visualOnEntry?.trackId !== track.id) {
|
||||||
|
setSeekFallbackVisualTarget(null);
|
||||||
}
|
}
|
||||||
const newQueue = queue ?? state.queue;
|
const newQueue = queue ?? state.queue;
|
||||||
// Prefer an explicit target index from the caller (next/previous/queue-row
|
// Prefer an explicit target index from the caller (next/previous/queue-row
|
||||||
@@ -1806,8 +1759,9 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
if (manual) {
|
if (manual) {
|
||||||
pushQueueUndoFromGetter(get);
|
pushQueueUndoFromGetter(get);
|
||||||
}
|
}
|
||||||
const pendingVisualTarget = seekFallbackVisualTarget?.trackId === track.id
|
const visualForInitial = getSeekFallbackVisualTarget();
|
||||||
? seekFallbackVisualTarget.seconds
|
const pendingVisualTarget = visualForInitial?.trackId === track.id
|
||||||
|
? visualForInitial.seconds
|
||||||
: null;
|
: null;
|
||||||
const initialTime = pendingVisualTarget !== null
|
const initialTime = pendingVisualTarget !== null
|
||||||
? Math.max(0, Math.min(pendingVisualTarget, track.duration || pendingVisualTarget))
|
? Math.max(0, Math.min(pendingVisualTarget, track.duration || pendingVisualTarget))
|
||||||
@@ -1919,13 +1873,13 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
if (playGeneration !== gen) return;
|
if (playGeneration !== gen) return;
|
||||||
setSeekTarget(seekTo);
|
setSeekTarget(seekTo);
|
||||||
if (seekFallbackVisualTarget?.trackId === track.id) {
|
if (getSeekFallbackVisualTarget()?.trackId === track.id) {
|
||||||
seekFallbackVisualTarget = null;
|
setSeekFallbackVisualTarget(null);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (seekFallbackVisualTarget?.trackId === track.id) {
|
if (getSeekFallbackVisualTarget()?.trackId === track.id) {
|
||||||
seekFallbackVisualTarget = null;
|
setSeekFallbackVisualTarget(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2435,7 +2389,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
const authState = useAuthStore.getState();
|
const authState = useAuthStore.getState();
|
||||||
const sid = authState.activeServerId ?? '';
|
const sid = authState.activeServerId ?? '';
|
||||||
if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) {
|
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);
|
get().playTrack(currentTrack, queue, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2464,11 +2418,11 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
const authSeek = useAuthStore.getState();
|
const authSeek = useAuthStore.getState();
|
||||||
const sidSeek = authSeek.activeServerId ?? '';
|
const sidSeek = authSeek.activeServerId ?? '';
|
||||||
if (shouldRebindPlaybackToHotCache(s0.currentTrack.id, sidSeek)) {
|
if (shouldRebindPlaybackToHotCache(s0.currentTrack.id, sidSeek)) {
|
||||||
seekFallbackVisualTarget = {
|
setSeekFallbackVisualTarget({
|
||||||
trackId: s0.currentTrack.id,
|
trackId: s0.currentTrack.id,
|
||||||
seconds: time,
|
seconds: time,
|
||||||
setAtMs: Date.now(),
|
setAtMs: Date.now(),
|
||||||
};
|
});
|
||||||
clearSeekFallbackRetry();
|
clearSeekFallbackRetry();
|
||||||
s0.playTrack(s0.currentTrack, s0.queue, true);
|
s0.playTrack(s0.currentTrack, s0.queue, true);
|
||||||
return;
|
return;
|
||||||
@@ -2476,7 +2430,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
invoke('audio_seek', { seconds: time }).then(() => {
|
invoke('audio_seek', { seconds: time }).then(() => {
|
||||||
// Arm stale-progress guard only after backend acknowledged seek.
|
// Arm stale-progress guard only after backend acknowledged seek.
|
||||||
setSeekTarget(time);
|
setSeekTarget(time);
|
||||||
seekFallbackVisualTarget = null;
|
setSeekFallbackVisualTarget(null);
|
||||||
clearSeekFallbackRetry();
|
clearSeekFallbackRetry();
|
||||||
}).catch((err: unknown) => {
|
}).catch((err: unknown) => {
|
||||||
// Release the progress-tick guard so the UI doesn't freeze
|
// Release the progress-tick guard so the UI doesn't freeze
|
||||||
@@ -2485,7 +2439,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
const msg = String(err ?? '');
|
const msg = String(err ?? '');
|
||||||
if (!isRecoverableSeekError(msg)) {
|
if (!isRecoverableSeekError(msg)) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
seekFallbackVisualTarget = null;
|
setSeekFallbackVisualTarget(null);
|
||||||
clearSeekFallbackRetry();
|
clearSeekFallbackRetry();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2495,19 +2449,19 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
if (!s.currentTrack) return;
|
if (!s.currentTrack) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const sameBurst =
|
const sameBurst =
|
||||||
seekFallbackTrackId === s.currentTrack.id
|
getSeekFallbackTrackId() === s.currentTrack.id
|
||||||
&& now - seekFallbackRestartAt < 600;
|
&& now - getSeekFallbackRestartAt() < 600;
|
||||||
seekFallbackVisualTarget = {
|
setSeekFallbackVisualTarget({
|
||||||
trackId: s.currentTrack.id,
|
trackId: s.currentTrack.id,
|
||||||
seconds: time,
|
seconds: time,
|
||||||
setAtMs: Date.now(),
|
setAtMs: Date.now(),
|
||||||
};
|
});
|
||||||
// Keep stale progress ticks from snapping UI back to start while
|
// Keep stale progress ticks from snapping UI back to start while
|
||||||
// recoverable seek retries are still in flight.
|
// recoverable seek retries are still in flight.
|
||||||
setSeekTarget(time);
|
setSeekTarget(time);
|
||||||
if (msg.includes('not seekable') && !sameBurst) {
|
if (msg.includes('not seekable') && !sameBurst) {
|
||||||
seekFallbackTrackId = s.currentTrack.id;
|
setSeekFallbackTrackId(s.currentTrack.id);
|
||||||
seekFallbackRestartAt = now;
|
setSeekFallbackRestartAt(now);
|
||||||
// Keep manual semantics (no crossfade) for seek recovery restarts.
|
// Keep manual semantics (no crossfade) for seek recovery restarts.
|
||||||
s.playTrack(s.currentTrack, s.queue, true);
|
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