mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
e8e41752a7
* feat(playback): global speed with three strategies Add Settings → Audio and player-bar controls for global playback speed (speed with auto pitch correction as default, varispeed, manual pitch shift). Time-stretch runs on a background worker; Orbit sessions force 1.0× passthrough. * fix(playback): align seekbar, seek, and progress on content timeline Unify UI timebase across varispeed and preserve strategies: full-track duration, speed-scaled progress for DSP paths, and content-timeline seeks without varispeed scaling. Reset the sample counter after seek so clicks land correctly; restart playback on strategy/enable changes instead of fragile hot-switching. * fix(ui): anchor playback speed popover like volume controls Replace the centered EQ-style modal with a player-bar popover (outside click, Escape, reposition on scroll). Show compact controls in the bar and overflow menu; keep strategy hints and labels in Settings only. * docs(release): CHANGELOG and credits for playback speed (PR #852) * docs(changelog): add playback speed entry for PR #852 * fix(clippy): simplify raw_counter_samples branch for CI Collapse duplicate if branches flagged by clippy::if-same-then-else. * fix(ui): wheel on pitch slider adjusts pitch in speed popover In compact player-bar controls, scroll over the pitch row changes pitch; elsewhere in the panel changes speed. Stop propagation so overflow menu wheel does not tweak volume. * fix(playback): address PR #852 review and drop ineffective dynamic imports Translate playback-rate strings for de/fr/es/zh/nb/nl/ro; restamp sample counter on live preserve-path speed changes; use neutral rate atomics for radio progress; static-import playerStore in playListenSession (move preview volume sync to previewPlayerVolumeSync side-effect module). * fix(i18n): translate playback-rate strategy labels in all locales Replace leftover English Varispeed/Pitch strings in ru and other non-en settings blocks so popover strategy buttons and hints read natively. * fix(i18n): refine German varispeed label to "Tonhöhe folgt dem Tempo" --------- Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
/**
|
|
* Characterization tests for `previewStore`.
|
|
*
|
|
* Phases F0 (bootstrap, _on* handlers + stopPreview) + F4 (startPreview
|
|
* cross-store reads + failure paths + main-playback volume sync).
|
|
*
|
|
* Drives the store through its public action surface with the real
|
|
* Zustand instance, stubs the Tauri commands via `onInvoke`, and uses
|
|
* `vi.mock('@/api/subsonic')` because `startPreview` calls `buildStreamUrl`.
|
|
*/
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
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),
|
|
}));
|
|
|
|
import { usePreviewStore } from './previewStore';
|
|
import { useAuthStore } from './authStore';
|
|
import { useOrbitStore } from './orbitStore';
|
|
import { usePlayerStore } from './playerStore';
|
|
import './previewPlayerVolumeSync';
|
|
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
|
|
import { resetAuthStore, resetPreviewStore, resetPlayerStore, resetOrbitStore } from '@/test/helpers/storeReset';
|
|
|
|
function resetStore() {
|
|
usePreviewStore.setState({
|
|
previewingId: null,
|
|
previewingTrack: null,
|
|
elapsed: 0,
|
|
duration: 30,
|
|
audioStarted: false,
|
|
});
|
|
}
|
|
|
|
describe('previewStore — event handlers', () => {
|
|
beforeEach(resetStore);
|
|
|
|
describe('_onStart', () => {
|
|
it('flips audioStarted to true when the id matches the active preview', () => {
|
|
usePreviewStore.setState({
|
|
previewingId: 'song-1',
|
|
previewingTrack: { id: 'song-1', title: 't', artist: 'a' },
|
|
});
|
|
|
|
usePreviewStore.getState()._onStart('song-1');
|
|
|
|
expect(usePreviewStore.getState().audioStarted).toBe(true);
|
|
expect(usePreviewStore.getState().previewingId).toBe('song-1');
|
|
});
|
|
|
|
it('takes over the previewingId when the engine fires start for an unknown id', () => {
|
|
usePreviewStore.setState({ previewingId: null });
|
|
|
|
usePreviewStore.getState()._onStart('song-99');
|
|
|
|
const state = usePreviewStore.getState();
|
|
expect(state.previewingId).toBe('song-99');
|
|
expect(state.elapsed).toBe(0);
|
|
expect(state.audioStarted).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('_onProgress', () => {
|
|
it('updates elapsed + duration when the id matches', () => {
|
|
usePreviewStore.setState({ previewingId: 'song-1' });
|
|
|
|
usePreviewStore.getState()._onProgress('song-1', 12.5, 30);
|
|
|
|
const state = usePreviewStore.getState();
|
|
expect(state.elapsed).toBe(12.5);
|
|
expect(state.duration).toBe(30);
|
|
});
|
|
|
|
it('ignores progress for a stale id', () => {
|
|
usePreviewStore.setState({ previewingId: 'song-1', elapsed: 5 });
|
|
|
|
usePreviewStore.getState()._onProgress('song-stale', 99, 30);
|
|
|
|
expect(usePreviewStore.getState().elapsed).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('_onEnd', () => {
|
|
it('clears state when the id matches', () => {
|
|
usePreviewStore.setState({
|
|
previewingId: 'song-1',
|
|
previewingTrack: { id: 'song-1', title: 't', artist: 'a' },
|
|
elapsed: 27,
|
|
audioStarted: true,
|
|
});
|
|
|
|
usePreviewStore.getState()._onEnd('song-1');
|
|
|
|
const state = usePreviewStore.getState();
|
|
expect(state.previewingId).toBeNull();
|
|
expect(state.previewingTrack).toBeNull();
|
|
expect(state.elapsed).toBe(0);
|
|
expect(state.audioStarted).toBe(false);
|
|
});
|
|
|
|
it('ignores end events for a stale id', () => {
|
|
usePreviewStore.setState({ previewingId: 'song-1', elapsed: 5, audioStarted: true });
|
|
|
|
usePreviewStore.getState()._onEnd('song-stale');
|
|
|
|
expect(usePreviewStore.getState().previewingId).toBe('song-1');
|
|
expect(usePreviewStore.getState().audioStarted).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('previewStore — stopPreview', () => {
|
|
beforeEach(resetStore);
|
|
|
|
it('returns early without invoking when no preview is active', async () => {
|
|
await usePreviewStore.getState().stopPreview();
|
|
|
|
expect(invokeMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('invokes audio_preview_stop when a preview is active', async () => {
|
|
usePreviewStore.setState({ previewingId: 'song-1' });
|
|
onInvoke('audio_preview_stop', () => undefined);
|
|
|
|
await usePreviewStore.getState().stopPreview();
|
|
|
|
expect(invokeMock).toHaveBeenCalledWith('audio_preview_stop');
|
|
});
|
|
|
|
it('falls back to clearing state locally if invoke rejects', async () => {
|
|
usePreviewStore.setState({
|
|
previewingId: 'song-1',
|
|
previewingTrack: { id: 'song-1', title: 't', artist: 'a' },
|
|
audioStarted: true,
|
|
});
|
|
onInvoke('audio_preview_stop', () => {
|
|
throw new Error('engine offline');
|
|
});
|
|
|
|
await usePreviewStore.getState().stopPreview();
|
|
|
|
const state = usePreviewStore.getState();
|
|
expect(state.previewingId).toBeNull();
|
|
expect(state.previewingTrack).toBeNull();
|
|
expect(state.audioStarted).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('previewStore — startPreview', () => {
|
|
beforeEach(() => {
|
|
resetPreviewStore();
|
|
resetAuthStore();
|
|
resetOrbitStore();
|
|
resetPlayerStore();
|
|
onInvoke('audio_preview_play', () => undefined);
|
|
onInvoke('audio_preview_stop', () => undefined);
|
|
onInvoke('audio_preview_set_volume', () => undefined);
|
|
});
|
|
|
|
const song = (id = 'song-1') => ({
|
|
id,
|
|
title: `Title ${id}`,
|
|
artist: 'Artist',
|
|
coverArt: id,
|
|
duration: 240,
|
|
});
|
|
|
|
it('invokes audio_preview_play with the configured args and stores the previewing track', async () => {
|
|
await usePreviewStore.getState().startPreview(song('song-1'), 'suggestions');
|
|
|
|
expect(invokeMock).toHaveBeenCalledWith(
|
|
'audio_preview_play',
|
|
expect.objectContaining({
|
|
id: 'song-1',
|
|
url: expect.stringContaining('song-1'),
|
|
durationSec: 30,
|
|
startSec: 240 * 0.33,
|
|
}),
|
|
);
|
|
|
|
const state = usePreviewStore.getState();
|
|
expect(state.previewingId).toBe('song-1');
|
|
expect(state.previewingTrack).toEqual({
|
|
id: 'song-1', title: 'Title song-1', artist: 'Artist', coverArt: 'song-1',
|
|
});
|
|
expect(state.elapsed).toBe(0);
|
|
expect(state.audioStarted).toBe(false);
|
|
expect(state.duration).toBe(30);
|
|
});
|
|
|
|
it('starts at 0 when the track is too short to need a mid-track seek', async () => {
|
|
// duration <= previewDuration * 1.5 → start at 0.
|
|
await usePreviewStore.getState().startPreview({ ...song(), duration: 30 }, 'suggestions');
|
|
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
|
|
expect(call?.[1]).toEqual(expect.objectContaining({ startSec: 0 }));
|
|
});
|
|
|
|
it('passes camelCase keys (Tauri IPC contract — snake_case silently drops to undefined)', async () => {
|
|
await usePreviewStore.getState().startPreview(song(), 'suggestions');
|
|
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
|
|
const args = call?.[1] as Record<string, unknown>;
|
|
expect(args).toHaveProperty('startSec');
|
|
expect(args).toHaveProperty('durationSec');
|
|
expect(args).not.toHaveProperty('start_sec');
|
|
expect(args).not.toHaveProperty('duration_sec');
|
|
});
|
|
|
|
it('no-ops when previews are globally disabled', async () => {
|
|
useAuthStore.setState({ trackPreviewsEnabled: false });
|
|
await usePreviewStore.getState().startPreview(song(), 'suggestions');
|
|
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
|
|
expect(usePreviewStore.getState().previewingId).toBeNull();
|
|
});
|
|
|
|
it('no-ops when previews are disabled at the calling location', async () => {
|
|
useAuthStore.setState({
|
|
trackPreviewLocations: {
|
|
suggestions: false,
|
|
albums: true, playlists: true, favorites: true, artist: true, randomMix: true,
|
|
},
|
|
});
|
|
await usePreviewStore.getState().startPreview(song(), 'suggestions');
|
|
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
|
|
});
|
|
|
|
it.each(['active', 'joining', 'starting'] as const)(
|
|
'no-ops while the user is a host inside an Orbit %s phase',
|
|
async (phase) => {
|
|
useOrbitStore.setState({ role: 'host', phase });
|
|
await usePreviewStore.getState().startPreview(song(), 'suggestions');
|
|
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
|
|
},
|
|
);
|
|
|
|
it.each(['active', 'joining', 'starting'] as const)(
|
|
'no-ops while the user is a guest inside an Orbit %s phase',
|
|
async (phase) => {
|
|
useOrbitStore.setState({ role: 'guest', phase });
|
|
await usePreviewStore.getState().startPreview(song(), 'suggestions');
|
|
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
|
|
},
|
|
);
|
|
|
|
it('falls through to startPreview when no orbit session is active (role=null)', async () => {
|
|
useOrbitStore.setState({ role: null, phase: 'idle' });
|
|
await usePreviewStore.getState().startPreview(song(), 'suggestions');
|
|
expect(invokeMock).toHaveBeenCalledWith('audio_preview_play', expect.anything());
|
|
});
|
|
|
|
it('treats re-clicking the active preview id as a stop', async () => {
|
|
usePreviewStore.setState({ previewingId: 'song-1' });
|
|
await usePreviewStore.getState().startPreview(song('song-1'), 'suggestions');
|
|
// Goes through stopPreview, not audio_preview_play.
|
|
expect(invokeMock).toHaveBeenCalledWith('audio_preview_stop');
|
|
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
|
|
});
|
|
|
|
it('rolls back optimistic state when the engine invoke rejects', async () => {
|
|
usePreviewStore.setState({
|
|
previewingId: 'older',
|
|
previewingTrack: { id: 'older', title: 'x', artist: 'y' },
|
|
audioStarted: true,
|
|
});
|
|
onInvoke('audio_preview_play', () => {
|
|
throw new Error('engine offline');
|
|
});
|
|
|
|
await expect(usePreviewStore.getState().startPreview(song('song-2'), 'suggestions')).rejects.toThrow(/engine offline/);
|
|
|
|
// Only rolls back when the rolled-back id is still the optimistic one.
|
|
const state = usePreviewStore.getState();
|
|
expect(state.previewingId).toBeNull();
|
|
expect(state.previewingTrack).toBeNull();
|
|
expect(state.audioStarted).toBe(false);
|
|
});
|
|
|
|
it('folds in the loudness pre-attenuation when normalization=loudness', async () => {
|
|
useAuthStore.setState({
|
|
normalizationEngine: 'loudness',
|
|
loudnessPreAnalysisAttenuationDb: -6,
|
|
});
|
|
usePlayerStore.setState({ volume: 1.0 });
|
|
|
|
await usePreviewStore.getState().startPreview(song(), 'suggestions');
|
|
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
|
|
const args = call?.[1] as { volume: number };
|
|
// 1.0 * 10^(-6/20) ≈ 0.501 — clamped to [0, 1].
|
|
expect(args.volume).toBeCloseTo(Math.pow(10, -6 / 20), 4);
|
|
});
|
|
|
|
it('does NOT fold pre-attenuation when normalizationEngine is off', async () => {
|
|
useAuthStore.setState({ normalizationEngine: 'off' });
|
|
usePlayerStore.setState({ volume: 0.7 });
|
|
|
|
await usePreviewStore.getState().startPreview(song(), 'suggestions');
|
|
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
|
|
const args = call?.[1] as { volume: number };
|
|
expect(args.volume).toBeCloseTo(0.7, 5);
|
|
});
|
|
|
|
it('does NOT fold a positive pre-attenuation value (Math.min(0, …) guard)', async () => {
|
|
useAuthStore.setState({
|
|
normalizationEngine: 'loudness',
|
|
loudnessPreAnalysisAttenuationDb: 3, // positive — guard pulls to 0
|
|
});
|
|
usePlayerStore.setState({ volume: 0.5 });
|
|
|
|
await usePreviewStore.getState().startPreview(song(), 'suggestions');
|
|
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
|
|
const args = call?.[1] as { volume: number };
|
|
expect(args.volume).toBeCloseTo(0.5, 5);
|
|
});
|
|
});
|
|
|
|
describe('previewStore — main-player volume sync during preview', () => {
|
|
beforeEach(() => {
|
|
resetPreviewStore();
|
|
resetAuthStore();
|
|
resetPlayerStore();
|
|
onInvoke('audio_preview_set_volume', () => undefined);
|
|
invokeMock.mockClear();
|
|
});
|
|
|
|
it('pings the engine when the main player volume changes mid-preview', () => {
|
|
usePreviewStore.setState({ previewingId: 'song-1' });
|
|
usePlayerStore.setState({ volume: 0.5 });
|
|
|
|
usePlayerStore.setState({ volume: 0.8 });
|
|
|
|
expect(invokeMock).toHaveBeenCalledWith(
|
|
'audio_preview_set_volume',
|
|
expect.objectContaining({ volume: 0.8 }),
|
|
);
|
|
});
|
|
|
|
it('does NOT ping the engine when no preview is active', () => {
|
|
usePreviewStore.setState({ previewingId: null });
|
|
usePlayerStore.setState({ volume: 0.5 });
|
|
|
|
usePlayerStore.setState({ volume: 0.8 });
|
|
|
|
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_set_volume', expect.anything());
|
|
});
|
|
|
|
it('does NOT ping when the volume value did not actually change', () => {
|
|
usePreviewStore.setState({ previewingId: 'song-1' });
|
|
usePlayerStore.setState({ volume: 0.5 });
|
|
invokeMock.mockClear();
|
|
|
|
// Setting to the same value should be skipped by the subscription guard.
|
|
usePlayerStore.setState({ volume: 0.5 });
|
|
|
|
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_set_volume', expect.anything());
|
|
});
|
|
});
|