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
+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 });
});
});