mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
184e87a469
* fix(audio): release idle output stream after 60s (#1071) Lazy-open CPAL on first playback and close the device handle after one minute without active audio so Windows can sleep; emit output-released for cold resume and skip post-wake reopen when idle. * docs: CHANGELOG and credits for idle audio stream fix (PR #1073) * fix(audio): satisfy clippy if-same-then-else in idle watcher * fix(audio): silence rodio DeviceSink drop unless logging is debug Gate log_on_drop(false) on runtime should_log_debug() so normal/off logging modes avoid stderr noise from intentional idle stream release. * feat(audio): cold-start paused restore and silent engine prepare After getPlayQueue on startup, apply saved seek position to the UI, prefetch the current track to hot cache, and load the engine paused via new audio_play startPaused so playback does not audibly start before pause. Shared engineLoadTrackAtPosition with queue-undo restore. * fix(audio): satisfy clippy too_many_arguments on stream arm helper Bundle spawn_legacy_stream_start_when_armed parameters into LegacyStreamStartWhenArmed so workspace clippy passes. * fix(audio): release output stream immediately on stop (#1071) Stop and natural queue end call audio_stop; close the CPAL device right away instead of waiting for the 60s idle timer. Pause keeps the grace period for warm resume. * fix(audio): keep waveform mounted after stop (#1071) Stop preserves currentTrack, so its cached analysis waveform stays valid. Stop no longer nulls waveformBins for the still-shown track and re-hydrates them from the analysis DB, instead of dropping to flat placeholder bars. * test(audio): cover output_stream_is_needed branches; harden audio_play arg (#1071) - Add unit tests for the idle-keepalive decision: empty/playing/paused main sink, preview and fading-out sinks, and radio playing/paused. Players are built device-less via rodio's Player::new + a Zero source, so empty()/state are exercised without an audio device. - Make audio_play's `start_paused` an Option<bool> defaulting to false, so the new field is strictly additive (omitting startPaused no longer fails serde). - Drop the unused `_engine` parameter from start_stream_idle_watcher; it resolves the engine from the AppHandle each poll. * refactor(audio): extract sink-swap lifecycle into sink_swap module (#1071) Move SinkSwapInputs/swap_in_new_sink and the legacy stream-arm helper (LegacyStreamStartWhenArmed/spawn_legacy_stream_start_when_armed) out of play_input.rs into a focused sink_swap.rs, so source selection and source building stay separate from sink lifecycle. play_input.rs drops from 953 to 799 lines. No behavior change.
333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
/**
|
|
* Miscellaneous-action characterization for `playerStore` — pushes Phase F1
|
|
* past the 50 % line-coverage floor without touching `playTrack` (which is
|
|
* its own async beast).
|
|
*
|
|
* Covers the smaller surfaces 2a / 2b / 2c skipped: shuffleQueue,
|
|
* shuffleUpcomingQueue, stop, setStarredOverride / setUserRatingOverride,
|
|
* toggleQueue / setQueueVisible, toggleFullscreen, openContextMenu /
|
|
* closeContextMenu, openSongInfo / closeSongInfo, setNetworkLoved /
|
|
* setNetworkLovedForSong, pruneUpcomingToCurrent, setProgress.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const runtimeMock = {
|
|
getEnrichmentPrimaryId: vi.fn<() => string | null>(() => null),
|
|
setTrackLoved: vi.fn(async () => undefined),
|
|
isTrackLoved: vi.fn(async () => false),
|
|
syncLovedTracks: vi.fn(async () => ({})),
|
|
};
|
|
|
|
vi.mock('@/api/subsonic', () => ({
|
|
savePlayQueue: vi.fn(async () => undefined),
|
|
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
|
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
|
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
|
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
|
|
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
|
|
getSong: vi.fn(async () => null),
|
|
getRandomSongs: vi.fn(async () => []),
|
|
getSimilarSongs2: vi.fn(async () => []),
|
|
getTopSongs: vi.fn(async () => []),
|
|
getAlbumInfo2: vi.fn(async () => null),
|
|
reportNowPlaying: vi.fn(async () => undefined),
|
|
scrobbleSong: vi.fn(async () => undefined),
|
|
setRating: vi.fn(async () => undefined),
|
|
star: vi.fn(async () => undefined),
|
|
unstar: vi.fn(async () => undefined),
|
|
}));
|
|
|
|
vi.mock('@/music-network', () => ({
|
|
getMusicNetworkRuntime: () => runtimeMock,
|
|
getMusicNetworkRuntimeOrNull: () => runtimeMock,
|
|
}));
|
|
|
|
vi.mock('@/utils/orbitBulkGuard', () => ({
|
|
orbitBulkGuard: vi.fn(async () => true),
|
|
}));
|
|
|
|
import { usePlayerStore } from './playerStore';
|
|
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
|
|
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
|
|
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
|
|
|
|
beforeEach(() => {
|
|
resetPlayerStore();
|
|
resetAuthStore();
|
|
runtimeMock.getEnrichmentPrimaryId.mockReturnValue(null);
|
|
runtimeMock.setTrackLoved.mockClear();
|
|
runtimeMock.isTrackLoved.mockResolvedValue(false);
|
|
runtimeMock.syncLovedTracks.mockResolvedValue({});
|
|
onInvoke('audio_play', () => undefined);
|
|
onInvoke('audio_pause', () => undefined);
|
|
onInvoke('audio_stop', () => undefined);
|
|
onInvoke('audio_seek', () => undefined);
|
|
onInvoke('audio_get_state', () => ({ playing: false }));
|
|
onInvoke('audio_update_replay_gain', () => undefined);
|
|
onInvoke('audio_set_normalization', () => undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('setStarredOverride', () => {
|
|
it('stores per-track starred booleans', () => {
|
|
usePlayerStore.getState().setStarredOverride('t-1', true);
|
|
usePlayerStore.getState().setStarredOverride('t-2', false);
|
|
expect(usePlayerStore.getState().starredOverrides).toEqual({
|
|
't-1': true,
|
|
't-2': false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('setUserRatingOverride', () => {
|
|
it('stores per-track rating overrides', () => {
|
|
usePlayerStore.getState().setUserRatingOverride('t-1', 4);
|
|
usePlayerStore.getState().setUserRatingOverride('t-2', 5);
|
|
expect(usePlayerStore.getState().userRatingOverrides).toEqual({
|
|
't-1': 4,
|
|
't-2': 5,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('openContextMenu / closeContextMenu', () => {
|
|
it('opens with position + item + type + queueIndex', () => {
|
|
const track = makeTrack();
|
|
usePlayerStore.getState().openContextMenu(100, 200, track, 'song', 5);
|
|
const cm = usePlayerStore.getState().contextMenu;
|
|
expect(cm.isOpen).toBe(true);
|
|
expect(cm.x).toBe(100);
|
|
expect(cm.y).toBe(200);
|
|
expect(cm.type).toBe('song');
|
|
expect(cm.queueIndex).toBe(5);
|
|
});
|
|
|
|
it('closeContextMenu flips isOpen but preserves the rest of the menu state', () => {
|
|
const track = makeTrack();
|
|
usePlayerStore.getState().openContextMenu(50, 50, track, 'song');
|
|
usePlayerStore.getState().closeContextMenu();
|
|
const cm = usePlayerStore.getState().contextMenu;
|
|
expect(cm.isOpen).toBe(false);
|
|
expect(cm.x).toBe(50);
|
|
expect(cm.type).toBe('song');
|
|
});
|
|
});
|
|
|
|
describe('openSongInfo / closeSongInfo', () => {
|
|
it('opens with the song id and clears on close', () => {
|
|
usePlayerStore.getState().openSongInfo('song-1');
|
|
expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: true, songId: 'song-1' });
|
|
|
|
usePlayerStore.getState().closeSongInfo();
|
|
expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: false, songId: null });
|
|
});
|
|
});
|
|
|
|
describe('toggleQueue / setQueueVisible', () => {
|
|
it('toggleQueue flips isQueueVisible', () => {
|
|
const before = usePlayerStore.getState().isQueueVisible;
|
|
usePlayerStore.getState().toggleQueue();
|
|
expect(usePlayerStore.getState().isQueueVisible).toBe(!before);
|
|
usePlayerStore.getState().toggleQueue();
|
|
expect(usePlayerStore.getState().isQueueVisible).toBe(before);
|
|
});
|
|
|
|
it('setQueueVisible writes through verbatim', () => {
|
|
usePlayerStore.getState().setQueueVisible(true);
|
|
expect(usePlayerStore.getState().isQueueVisible).toBe(true);
|
|
usePlayerStore.getState().setQueueVisible(false);
|
|
expect(usePlayerStore.getState().isQueueVisible).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('toggleFullscreen', () => {
|
|
it('flips isFullscreenOpen', () => {
|
|
expect(usePlayerStore.getState().isFullscreenOpen).toBe(false);
|
|
usePlayerStore.getState().toggleFullscreen();
|
|
expect(usePlayerStore.getState().isFullscreenOpen).toBe(true);
|
|
usePlayerStore.getState().toggleFullscreen();
|
|
expect(usePlayerStore.getState().isFullscreenOpen).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('setNetworkLoved / toggleNetworkLove', () => {
|
|
it('setNetworkLoved writes the flag verbatim (no primary gate inside the setter)', () => {
|
|
usePlayerStore.setState({ currentTrack: makeTrack(), networkLoved: false });
|
|
usePlayerStore.getState().setNetworkLoved(true);
|
|
expect(usePlayerStore.getState().networkLoved).toBe(true);
|
|
});
|
|
|
|
it('setNetworkLoved also caches the value under "title::artist" when there is a current track', () => {
|
|
usePlayerStore.setState({
|
|
currentTrack: makeTrack({ title: 'Hello', artist: 'Adele' }),
|
|
networkLoved: false,
|
|
});
|
|
usePlayerStore.getState().setNetworkLoved(true);
|
|
expect(usePlayerStore.getState().networkLovedCache['Hello::Adele']).toBe(true);
|
|
});
|
|
|
|
it('setNetworkLoved without a current track only updates the flag, not the cache', () => {
|
|
usePlayerStore.setState({ currentTrack: null, networkLoved: false, networkLovedCache: {} });
|
|
usePlayerStore.getState().setNetworkLoved(true);
|
|
expect(usePlayerStore.getState().networkLoved).toBe(true);
|
|
expect(usePlayerStore.getState().networkLovedCache).toEqual({});
|
|
});
|
|
|
|
it('toggleNetworkLove is a no-op without a current track', () => {
|
|
runtimeMock.getEnrichmentPrimaryId.mockReturnValue('primary');
|
|
usePlayerStore.setState({ currentTrack: null, networkLoved: false });
|
|
usePlayerStore.getState().toggleNetworkLove();
|
|
expect(usePlayerStore.getState().networkLoved).toBe(false);
|
|
expect(runtimeMock.setTrackLoved).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('toggleNetworkLove flips state and writes through the runtime when a track + primary are present', () => {
|
|
runtimeMock.getEnrichmentPrimaryId.mockReturnValue('primary');
|
|
usePlayerStore.setState({ currentTrack: makeTrack({ title: 'T', artist: 'A' }), networkLoved: false });
|
|
|
|
usePlayerStore.getState().toggleNetworkLove();
|
|
expect(usePlayerStore.getState().networkLoved).toBe(true);
|
|
expect(usePlayerStore.getState().networkLovedCache['T::A']).toBe(true);
|
|
expect(runtimeMock.setTrackLoved).toHaveBeenCalledWith({ title: 'T', artist: 'A' }, true);
|
|
});
|
|
});
|
|
|
|
describe('setNetworkLovedForSong', () => {
|
|
it('caches loved state under the "title::artist" key', () => {
|
|
usePlayerStore.getState().setNetworkLovedForSong('Hello', 'Adele', true);
|
|
expect(usePlayerStore.getState().networkLovedCache['Hello::Adele']).toBe(true);
|
|
|
|
usePlayerStore.getState().setNetworkLovedForSong('Hello', 'Adele', false);
|
|
expect(usePlayerStore.getState().networkLovedCache['Hello::Adele']).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('setProgress', () => {
|
|
it('writes currentTime / progress / duration', () => {
|
|
usePlayerStore.setState({ currentTrack: makeTrack({ duration: 200 }) });
|
|
usePlayerStore.getState().setProgress(50, 200);
|
|
const s = usePlayerStore.getState();
|
|
expect(s.currentTime).toBe(50);
|
|
expect(s.progress).toBeCloseTo(0.25, 4);
|
|
});
|
|
});
|
|
|
|
describe('stop', () => {
|
|
it('invokes audio_stop and clears playback state', () => {
|
|
seedQueue(makeTracks(2), { index: 0, currentTrack: makeTrack() });
|
|
usePlayerStore.setState({
|
|
isPlaying: true,
|
|
progress: 0.5,
|
|
currentTime: 60,
|
|
});
|
|
usePlayerStore.getState().stop();
|
|
expect(invokeMock).toHaveBeenCalledWith('audio_stop');
|
|
const s = usePlayerStore.getState();
|
|
expect(s.isPlaying).toBe(false);
|
|
expect(s.progress).toBe(0);
|
|
expect(s.currentTime).toBe(0);
|
|
});
|
|
|
|
it('keeps the waveform of the still-shown track and re-hydrates it from the DB', () => {
|
|
const track = makeTrack({ id: 'wf-keep' });
|
|
seedQueue([track], { index: 0, currentTrack: track });
|
|
usePlayerStore.setState({ isPlaying: true, waveformBins: [10, 20, 30] });
|
|
onInvoke('analysis_get_waveform_for_track', () => null);
|
|
usePlayerStore.getState().stop();
|
|
// currentTrack survives a stop, so its waveform bins must not be wiped.
|
|
expect(usePlayerStore.getState().waveformBins).toEqual([10, 20, 30]);
|
|
expect(invokeMock).toHaveBeenCalledWith(
|
|
'analysis_get_waveform_for_track',
|
|
expect.objectContaining({ trackId: 'wf-keep' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('shuffleQueue', () => {
|
|
it('is a no-op when the queue has fewer than 2 tracks', () => {
|
|
const t = makeTrack({ id: 'only' });
|
|
seedQueue([t], { index: 0, currentTrack: t });
|
|
usePlayerStore.getState().shuffleQueue();
|
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['only']);
|
|
});
|
|
|
|
it('keeps the current track at queueIndex 0 with the rest shuffled around it', () => {
|
|
const tracks = makeTracks(5, i => ({ id: `t-${i}` }));
|
|
const current = tracks[2];
|
|
seedQueue(tracks, { index: 2, currentTrack: current });
|
|
|
|
// Pin the RNG so the shuffle is deterministic.
|
|
vi.spyOn(Math, 'random').mockReturnValue(0);
|
|
usePlayerStore.getState().shuffleQueue();
|
|
vi.restoreAllMocks();
|
|
|
|
const s = usePlayerStore.getState();
|
|
expect(s.queueItems[0].trackId).toBe(current.id);
|
|
expect(s.queueIndex).toBe(0);
|
|
// The set of ids is preserved.
|
|
expect([...s.queueItems.map(r => r.trackId)].sort()).toEqual(['t-0', 't-1', 't-2', 't-3', 't-4'].sort());
|
|
});
|
|
});
|
|
|
|
describe('shuffleUpcomingQueue', () => {
|
|
it('is a no-op when fewer than 2 upcoming tracks remain', () => {
|
|
const tracks = makeTracks(3, i => ({ id: `t-${i}` }));
|
|
seedQueue(tracks, { index: 2, currentTrack: tracks[2] });
|
|
const beforeIds = tracks.map(t => t.id);
|
|
usePlayerStore.getState().shuffleUpcomingQueue();
|
|
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(beforeIds);
|
|
});
|
|
|
|
it('keeps the head + current in place and shuffles only the upcoming tail', () => {
|
|
const tracks = makeTracks(5, i => ({ id: `t-${i}` }));
|
|
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
|
|
|
|
vi.spyOn(Math, 'random').mockReturnValue(0);
|
|
usePlayerStore.getState().shuffleUpcomingQueue();
|
|
vi.restoreAllMocks();
|
|
|
|
const s = usePlayerStore.getState();
|
|
// First two entries unchanged (head + current).
|
|
expect(s.queueItems[0].trackId).toBe('t-0');
|
|
expect(s.queueItems[1].trackId).toBe('t-1');
|
|
// The tail still contains the same ids in some order.
|
|
expect([...s.queueItems.slice(2).map(r => r.trackId)].sort()).toEqual(['t-2', 't-3', 't-4'].sort());
|
|
});
|
|
});
|
|
|
|
describe('pruneUpcomingToCurrent', () => {
|
|
it('drops everything after queueIndex', () => {
|
|
const tracks = makeTracks(5);
|
|
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
|
|
usePlayerStore.getState().pruneUpcomingToCurrent();
|
|
const s = usePlayerStore.getState();
|
|
expect(s.queueItems.map(r => r.trackId)).toEqual([tracks[0].id, tracks[1].id]);
|
|
expect(s.queueIndex).toBe(1);
|
|
});
|
|
|
|
it('clears the queue entirely when there is no current track (orphaned queue → empty)', () => {
|
|
seedQueue(makeTracks(3), { index: 0, currentTrack: null });
|
|
usePlayerStore.getState().pruneUpcomingToCurrent();
|
|
const s = usePlayerStore.getState();
|
|
expect(s.queueItems).toEqual([]);
|
|
expect(s.queueIndex).toBe(0);
|
|
});
|
|
|
|
it('returns early without clearing when no current track AND queue is already empty', () => {
|
|
usePlayerStore.setState({ queueItems: [], queueIndex: 0, currentTrack: null });
|
|
usePlayerStore.getState().pruneUpcomingToCurrent();
|
|
expect(usePlayerStore.getState().queueItems).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('setRadioArtistId', () => {
|
|
it('accepts an artist id without throwing (module-level state, observable via radio playback)', () => {
|
|
// No public getter for radioArtistId — assertion via does-not-throw.
|
|
expect(() => usePlayerStore.getState().setRadioArtistId('ar-1')).not.toThrow();
|
|
expect(() => usePlayerStore.getState().setRadioArtistId('ar-2')).not.toThrow();
|
|
});
|
|
});
|