mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user