mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
refactor(playback): move the audio engine into features/playback
Relocate the playback/queue/transport/audio-output engine out of the type-first
store/ + utils/playback/ + utils/audio/ dirs into a cohesive src/features/playback/,
structure-preserving:
store/<x> -> features/playback/store/<x>
store/audioListenerSetup/<x> -> features/playback/store/audioListenerSetup/<x>
utils/playback/<x> -> features/playback/utils/playback/<x>
utils/audio/<x> -> features/playback/utils/audio/<x>
184 files moved (107 source + 77 tests), 365 consumers rewritten. Pure move — no
behavior change, no state-split (the playerStore state-split stays a separate M5
question). Enabled by this session's decouple seams (artist/offline/orbit/auth →
core registries), so the engine carries no inbound core->feature inversion: store/
now holds only the 50 cross-cutting global stores (auth family, the seams, library
index, UI/settings stores).
KEPT OUT of the move (would re-create global->engine edges): the 3 pure config
helpers utils/audio/{loudnessPreAnalysisSlider,hiResCrossfadeResample} +
utils/playback/autodjOverlapCap (authStore + settings UI read them — they stay in
utils/). Ambiguous view-state stores (eqStore, queueToolbarStore,
playerBarLayoutStore) stay global (no engine imports).
Consumers use DEEP paths (@/features/playback/...), no barrel — matches the lib/
approach and avoids barrel-mock-collapse across the 140 usePlayerStore consumers.
Two tolerated type-only core->feature edges remain (localPlaybackStore->QueueItemRef,
localPlaybackMigration->HotCacheEntry, both erased).
tsc 0, lint 0, full suite 319/2353 green, iron-rule clean (no runtime store->feature
import). Behavior-touching only via the prerequisite bridge seam (already QA-flagged);
the move itself is pure.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Smoke test for the queue-undo engine-restore orchestrator. Verifies
|
||||
* the audio_play payload shape, the seek follow-up that fires when
|
||||
* atSeconds > 0.05, the wantPlaying=false branch that issues audio_pause,
|
||||
* and the generation-mismatch bail-out.
|
||||
*/
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const auth = {
|
||||
activeServerId: 'srv',
|
||||
servers: [],
|
||||
replayGainMode: 'track' as 'track' | 'album',
|
||||
replayGainPreGainDb: 0,
|
||||
replayGainFallbackDb: -6,
|
||||
enableHiRes: false,
|
||||
};
|
||||
const player = {
|
||||
volume: 0.8,
|
||||
enginePreloadedTrackId: null as string | null,
|
||||
};
|
||||
return {
|
||||
auth,
|
||||
player,
|
||||
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => undefined),
|
||||
setDeferHotCachePrefetchMock: vi.fn(),
|
||||
resolvePlaybackUrlMock: vi.fn((id: string) => `https://mock/${id}`),
|
||||
resolveReplayGainDbMock: vi.fn(() => -6),
|
||||
isReplayGainActiveMock: vi.fn(() => false),
|
||||
loudnessGainDbForEngineBindMock: vi.fn(() => null as number | null),
|
||||
recordEnginePlayUrlMock: vi.fn(),
|
||||
playbackSourceHintMock: vi.fn(() => 'stream'),
|
||||
touchHotCacheOnPlaybackMock: vi.fn(),
|
||||
playerSetStateMock: vi.fn(),
|
||||
setIsAudioPausedMock: vi.fn(),
|
||||
getPlayGeneration: vi.fn(() => 1),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
|
||||
vi.mock('@/utils/cache/hotCacheGate', () => ({ setDeferHotCachePrefetch: hoisted.setDeferHotCachePrefetchMock }));
|
||||
vi.mock('@/features/playback/utils/playback/resolvePlaybackUrl', () => ({ resolvePlaybackUrl: hoisted.resolvePlaybackUrlMock }));
|
||||
vi.mock('@/features/playback/utils/audio/resolveReplayGainDb', () => ({ resolveReplayGainDb: hoisted.resolveReplayGainDbMock }));
|
||||
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => hoisted.auth } }));
|
||||
vi.mock('@/features/playback/store/engineState', () => ({
|
||||
getPlayGeneration: hoisted.getPlayGeneration,
|
||||
setIsAudioPaused: hoisted.setIsAudioPausedMock,
|
||||
}));
|
||||
vi.mock('@/features/playback/store/hotCacheTouch', () => ({ touchHotCacheOnPlayback: hoisted.touchHotCacheOnPlaybackMock }));
|
||||
vi.mock('@/features/playback/store/loudnessGainCache', () => ({
|
||||
isReplayGainActive: hoisted.isReplayGainActiveMock,
|
||||
loudnessGainDbForEngineBind: hoisted.loudnessGainDbForEngineBindMock,
|
||||
}));
|
||||
vi.mock('@/features/playback/store/playbackUrlRouting', () => ({
|
||||
playbackSourceHintForResolvedUrl: hoisted.playbackSourceHintMock,
|
||||
recordEnginePlayUrl: hoisted.recordEnginePlayUrlMock,
|
||||
}));
|
||||
vi.mock('@/features/playback/store/playerStore', () => ({
|
||||
usePlayerStore: {
|
||||
getState: () => hoisted.player,
|
||||
setState: hoisted.playerSetStateMock,
|
||||
},
|
||||
}));
|
||||
|
||||
import { queueUndoRestoreAudioEngine } from '@/features/playback/store/queueUndoAudioRestore';
|
||||
|
||||
function track(id: string, duration = 100): Track {
|
||||
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.invokeMock.mockReset();
|
||||
hoisted.invokeMock.mockResolvedValue(undefined);
|
||||
hoisted.setDeferHotCachePrefetchMock.mockClear();
|
||||
hoisted.touchHotCacheOnPlaybackMock.mockClear();
|
||||
hoisted.playerSetStateMock.mockClear();
|
||||
hoisted.setIsAudioPausedMock.mockClear();
|
||||
hoisted.recordEnginePlayUrlMock.mockClear();
|
||||
hoisted.getPlayGeneration.mockReturnValue(1);
|
||||
hoisted.player.enginePreloadedTrackId = null;
|
||||
});
|
||||
|
||||
describe('queueUndoRestoreAudioEngine', () => {
|
||||
it('issues audio_play with the snapshot track parameters', async () => {
|
||||
queueUndoRestoreAudioEngine({
|
||||
generation: 1,
|
||||
track: track('t1'),
|
||||
queue: [track('t1')],
|
||||
queueIndex: 0,
|
||||
atSeconds: 0,
|
||||
wantPlaying: true,
|
||||
});
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
|
||||
url: 'https://mock/t1',
|
||||
durationHint: 100,
|
||||
manual: false,
|
||||
analysisTrackId: 't1',
|
||||
}));
|
||||
expect(hoisted.recordEnginePlayUrlMock).toHaveBeenCalledWith('t1', 'https://mock/t1');
|
||||
expect(hoisted.setDeferHotCachePrefetchMock).toHaveBeenCalledWith(true);
|
||||
expect(hoisted.touchHotCacheOnPlaybackMock).toHaveBeenCalledWith('t1', 'srv');
|
||||
});
|
||||
|
||||
it('fires audio_seek when atSeconds > 0.05', async () => {
|
||||
queueUndoRestoreAudioEngine({
|
||||
generation: 1,
|
||||
track: track('t1'),
|
||||
queue: [track('t1')],
|
||||
queueIndex: 0,
|
||||
atSeconds: 30,
|
||||
wantPlaying: true,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 30 });
|
||||
});
|
||||
|
||||
it('skips audio_seek when atSeconds is near zero', async () => {
|
||||
queueUndoRestoreAudioEngine({
|
||||
generation: 1,
|
||||
track: track('t1'),
|
||||
queue: [track('t1')],
|
||||
queueIndex: 0,
|
||||
atSeconds: 0,
|
||||
wantPlaying: true,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const seekCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_seek');
|
||||
expect(seekCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it('loads with startPaused and skips audio_pause when wantPlaying=false', async () => {
|
||||
queueUndoRestoreAudioEngine({
|
||||
generation: 1,
|
||||
track: track('t1'),
|
||||
queue: [track('t1')],
|
||||
queueIndex: 0,
|
||||
atSeconds: 0,
|
||||
wantPlaying: false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
|
||||
startPaused: true,
|
||||
}));
|
||||
const pauseCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_pause');
|
||||
expect(pauseCall).toBeUndefined();
|
||||
expect(hoisted.setIsAudioPausedMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('bails out of the .then chain when generation has moved on', async () => {
|
||||
hoisted.getPlayGeneration.mockReturnValue(2); // user navigated, new gen
|
||||
queueUndoRestoreAudioEngine({
|
||||
generation: 1,
|
||||
track: track('t1'),
|
||||
queue: [track('t1')],
|
||||
queueIndex: 0,
|
||||
atSeconds: 30,
|
||||
wantPlaying: false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
// Should NOT have called audio_seek or audio_pause — gen moved on.
|
||||
const seekCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_seek');
|
||||
const pauseCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_pause');
|
||||
expect(seekCall).toBeUndefined();
|
||||
expect(pauseCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears the deferHotCachePrefetch gate in .finally even on error', async () => {
|
||||
hoisted.invokeMock.mockRejectedValueOnce(new Error('rust down'));
|
||||
queueUndoRestoreAudioEngine({
|
||||
generation: 1,
|
||||
track: track('t1'),
|
||||
queue: [track('t1')],
|
||||
queueIndex: 0,
|
||||
atSeconds: 0,
|
||||
wantPlaying: true,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(hoisted.setDeferHotCachePrefetchMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user