mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
* 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.
This commit is contained in:
@@ -162,6 +162,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Navidrome Now Playing and scrobble with hot cache, offline pins, and mixed-server playback reachability (PR #1055)',
|
||||
'What\'s New: remote WHATS_NEW.md from release assets, dev workspace mode, Highlights vs changelog tabs (PR #1058)',
|
||||
'Local library index: multi-genre browse, filters, and counts via track_genre table and blocking backfill (PR #1059)',
|
||||
'Audio: lazy-open output stream, 60s idle release (#1071), cold-start paused restore with silent engine prepare (PR #1073)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -96,6 +96,20 @@ describe('audio:device-changed', () => {
|
||||
|
||||
// ─── audio:device-reset ─────────────────────────────────────────────────────
|
||||
|
||||
describe('audio:output-released', () => {
|
||||
it('calls resetAudioPause so the next resume uses the cold path', () => {
|
||||
const resetAudioPause = vi.fn();
|
||||
usePlayerStore.setState({ resetAudioPause } as never);
|
||||
mountBridge();
|
||||
|
||||
emitTauriEvent('audio:output-released', null);
|
||||
|
||||
expect(resetAudioPause).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── audio:device-reset ─────────────────────────────────────────────────────
|
||||
|
||||
describe('audio:device-reset', () => {
|
||||
it('always clears the stored output device', () => {
|
||||
useAuthStore.setState({ audioOutputDevice: 'My DAC' } as never);
|
||||
|
||||
@@ -70,4 +70,13 @@ export function useAudioDeviceBridge() {
|
||||
}).then(u => { unlisten = u; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
// Output stream was released after idle — next resume must use the cold path.
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen('audio:output-released', () => {
|
||||
usePlayerStore.getState().resetAudioPause();
|
||||
}).then(u => { unlisten = u; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,16 @@ function scheduleEvictAfterPreviousGrace(): void {
|
||||
}, ms);
|
||||
}
|
||||
|
||||
/** Prefetch the current (paused) track so cold resume can hit disk instead of HTTP. */
|
||||
export function scheduleHotCachePrefetchForTrack(track: { id: string; suffix?: string }, serverId: string | null): void {
|
||||
const auth = useAuthStore.getState();
|
||||
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !serverId) return;
|
||||
if (hasLocalPersistentPlaybackBytes(track.id, serverId)) return;
|
||||
const hotIndex = selectHotCacheEntries(useLocalPlaybackStore.getState().entries);
|
||||
if (hotIndex[entryKey(serverId, track.id)]) return;
|
||||
enqueueJobs([{ trackId: track.id, serverId, suffix: track.suffix || 'mp3' }]);
|
||||
}
|
||||
|
||||
function enqueueJobs(jobs: PrefetchJob[]) {
|
||||
const seen = new Set(pendingQueue.map(j => `${j.serverId}:${j.trackId}`));
|
||||
let merged = 0;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Track } from './playerStoreTypes';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
||||
import {
|
||||
getPlaybackIndexKey,
|
||||
playbackCacheKeyForTrack,
|
||||
} from '../utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { getPlayGeneration, setIsAudioPaused } from './engineState';
|
||||
import { touchHotCacheOnPlayback } from './hotCacheTouch';
|
||||
import { isReplayGainActive, loudnessGainDbForEngineBind } from './loudnessGainCache';
|
||||
import { playbackSourceHintForResolvedUrl, recordEnginePlayUrl } from './playbackUrlRouting';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
|
||||
/**
|
||||
* Load a track into the Rust engine at `atSeconds`, optionally leaving transport
|
||||
* playing or paused. Shared by queue-undo restore and cold-start paused prepare.
|
||||
*/
|
||||
export function engineLoadTrackAtPosition(opts: {
|
||||
generation: number;
|
||||
track: Track;
|
||||
queue: Track[];
|
||||
queueIndex: number;
|
||||
atSeconds: number;
|
||||
wantPlaying: boolean;
|
||||
}): void {
|
||||
const { generation, track, queue, queueIndex, atSeconds, wantPlaying } = opts;
|
||||
const authState = useAuthStore.getState();
|
||||
const vol = usePlayerStore.getState().volume;
|
||||
const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null;
|
||||
const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
|
||||
const replayGainDb = resolveReplayGainDb(
|
||||
track, coldPrev, coldNext,
|
||||
isReplayGainActive(), authState.replayGainMode,
|
||||
);
|
||||
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
||||
const playbackCacheSid = playbackCacheKeyForTrack(track);
|
||||
const playbackIndexKey = playbackCacheKeyForTrack(track) || getPlaybackIndexKey();
|
||||
const url = resolvePlaybackUrl(track.id, playbackCacheSid);
|
||||
recordEnginePlayUrl(track.id, url);
|
||||
usePlayerStore.setState({
|
||||
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackCacheSid, url),
|
||||
});
|
||||
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
|
||||
const startPaused = !wantPlaying;
|
||||
setDeferHotCachePrefetch(true);
|
||||
invoke('audio_play', {
|
||||
url,
|
||||
volume: vol,
|
||||
durationHint: track.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
manual: false,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
analysisTrackId: track.id,
|
||||
serverId: playbackIndexKey || null,
|
||||
streamFormatSuffix: track.suffix ?? null,
|
||||
startPaused,
|
||||
})
|
||||
.then(() => {
|
||||
if (getPlayGeneration() !== generation) return;
|
||||
if (keepPreloadHint) {
|
||||
usePlayerStore.setState({ enginePreloadedTrackId: null });
|
||||
}
|
||||
const dur = track.duration && track.duration > 0 ? track.duration : null;
|
||||
const seekTo = Math.max(0, atSeconds);
|
||||
const canSeek = seekTo > 0.05 && (dur == null || seekTo < dur - 0.05);
|
||||
const afterSeek = () => {
|
||||
if (getPlayGeneration() !== generation) return;
|
||||
if (!wantPlaying) {
|
||||
if (!startPaused) {
|
||||
invoke('audio_pause').catch(console.error);
|
||||
}
|
||||
setIsAudioPaused(true);
|
||||
usePlayerStore.setState({ isPlaying: false });
|
||||
} else {
|
||||
setIsAudioPaused(false);
|
||||
}
|
||||
};
|
||||
if (canSeek) {
|
||||
void invoke('audio_seek', { seconds: seekTo }).then(afterSeek).catch(afterSeek);
|
||||
} else {
|
||||
afterSeek();
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (getPlayGeneration() !== generation) return;
|
||||
console.error('[psysonic] engineLoadTrackAtPosition failed:', err);
|
||||
usePlayerStore.setState({ isPlaying: false });
|
||||
})
|
||||
.finally(() => {
|
||||
setDeferHotCachePrefetch(false);
|
||||
});
|
||||
touchHotCacheOnPlayback(track.id, playbackCacheSid);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
setSeekFallbackVisualTarget,
|
||||
} from './seekFallbackState';
|
||||
import { clearSeekTarget } from './seekTargetState';
|
||||
import { preparePausedRestoreOnStartup } from './pausedRestorePrepare';
|
||||
import { refreshWaveformForTrack } from './waveformRefresh';
|
||||
|
||||
type SetState = (
|
||||
@@ -167,13 +168,16 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
|
||||
// Seed the resolver with the restored tracks so the queue UI / hot
|
||||
// paths resolve them without a network round-trip.
|
||||
if (sid) seedQueueResolver(sid, mappedTracks);
|
||||
const atSeconds = serverTime > 0 ? serverTime : localTime;
|
||||
const queueItems = toQueueItemRefs(sid, mappedTracks);
|
||||
set({
|
||||
queueItems: toQueueItemRefs(sid, mappedTracks),
|
||||
queueItems,
|
||||
queueIndex,
|
||||
currentTrack,
|
||||
currentTime: serverTime > 0 ? serverTime : localTime,
|
||||
currentTime: atSeconds,
|
||||
});
|
||||
void refreshWaveformForTrack(currentTrack.id);
|
||||
preparePausedRestoreOnStartup(currentTrack, queueItems, queueIndex, atSeconds);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize queue from server', e);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Track } from './playerStoreTypes';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
invokeMock: vi.fn(async () => undefined),
|
||||
emitMock: vi.fn(),
|
||||
setSeekMock: vi.fn(),
|
||||
prefetchMock: vi.fn(),
|
||||
promoteMock: vi.fn(async () => undefined),
|
||||
engineLoadMock: vi.fn(),
|
||||
bumpGenMock: vi.fn(() => 7),
|
||||
getGenMock: vi.fn(() => 7),
|
||||
playerState: {
|
||||
isPlaying: false,
|
||||
currentRadio: null as { streamUrl: string } | null,
|
||||
volume: 0.8,
|
||||
},
|
||||
authState: {
|
||||
hotCacheEnabled: true,
|
||||
hotCacheDownloadDir: '/tmp/hot',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
|
||||
vi.mock('./playbackProgress', () => ({ emitPlaybackProgress: hoisted.emitMock }));
|
||||
vi.mock('./seekFallbackState', () => ({ setSeekFallbackVisualTarget: hoisted.setSeekMock }));
|
||||
vi.mock('../hotCachePrefetch', () => ({ scheduleHotCachePrefetchForTrack: hoisted.prefetchMock }));
|
||||
vi.mock('./promoteStreamCache', () => ({ promoteCompletedStreamToHotCache: hoisted.promoteMock }));
|
||||
vi.mock('./engineLoadTrackAtPosition', () => ({ engineLoadTrackAtPosition: hoisted.engineLoadMock }));
|
||||
vi.mock('./engineState', () => ({
|
||||
bumpPlayGeneration: hoisted.bumpGenMock,
|
||||
getPlayGeneration: hoisted.getGenMock,
|
||||
}));
|
||||
vi.mock('./authStore', () => ({ useAuthStore: { getState: () => hoisted.authState } }));
|
||||
vi.mock('./playerStore', () => ({
|
||||
usePlayerStore: {
|
||||
getState: () => hoisted.playerState,
|
||||
setState: vi.fn((partial: Record<string, unknown>) => {
|
||||
Object.assign(hoisted.playerState, partial);
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../utils/library/queueTrackView', () => ({
|
||||
getQueueTracksView: vi.fn((refs: { trackId: string }[]) =>
|
||||
refs.map(r => ({
|
||||
id: r.trackId,
|
||||
title: r.trackId,
|
||||
artist: 'A',
|
||||
album: 'B',
|
||||
albumId: 'B',
|
||||
duration: 200,
|
||||
})),
|
||||
),
|
||||
}));
|
||||
vi.mock('../utils/playback/playbackServer', () => ({
|
||||
getPlaybackCacheServerKey: () => 'srv-key',
|
||||
}));
|
||||
|
||||
import {
|
||||
applyRestoredPlaybackVisual,
|
||||
preparePausedRestoreOnStartup,
|
||||
} from './pausedRestorePrepare';
|
||||
|
||||
function track(id: string, duration = 200): Track {
|
||||
return { id, title: id, artist: 'A', album: 'B', albumId: 'B', duration };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.invokeMock.mockClear();
|
||||
hoisted.emitMock.mockClear();
|
||||
hoisted.setSeekMock.mockClear();
|
||||
hoisted.prefetchMock.mockClear();
|
||||
hoisted.promoteMock.mockClear();
|
||||
hoisted.engineLoadMock.mockClear();
|
||||
hoisted.bumpGenMock.mockClear();
|
||||
hoisted.getGenMock.mockReturnValue(7);
|
||||
hoisted.playerState.isPlaying = false;
|
||||
hoisted.playerState.currentRadio = null;
|
||||
});
|
||||
|
||||
describe('applyRestoredPlaybackVisual', () => {
|
||||
it('updates store progress and emits playback progress', () => {
|
||||
applyRestoredPlaybackVisual(track('t1'), 50);
|
||||
expect(hoisted.emitMock).toHaveBeenCalledWith({
|
||||
currentTime: 50,
|
||||
progress: 0.25,
|
||||
buffered: 0,
|
||||
buffering: false,
|
||||
});
|
||||
expect(hoisted.setSeekMock).toHaveBeenCalledWith({
|
||||
trackId: 't1',
|
||||
seconds: 50,
|
||||
setAtMs: expect.any(Number),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('preparePausedRestoreOnStartup', () => {
|
||||
it('prefetches, promotes stream cache, and loads the engine paused', async () => {
|
||||
preparePausedRestoreOnStartup(
|
||||
track('t1'),
|
||||
[{ serverId: 'srv', trackId: 't1' }],
|
||||
0,
|
||||
42,
|
||||
);
|
||||
expect(hoisted.prefetchMock).toHaveBeenCalled();
|
||||
expect(hoisted.bumpGenMock).toHaveBeenCalled();
|
||||
await vi.waitFor(() => {
|
||||
expect(hoisted.promoteMock).toHaveBeenCalled();
|
||||
expect(hoisted.engineLoadMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
generation: 7,
|
||||
atSeconds: 42,
|
||||
wantPlaying: false,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it('skips when transport is already playing', () => {
|
||||
hoisted.playerState.isPlaying = true;
|
||||
preparePausedRestoreOnStartup(track('t1'), [], 0, 10);
|
||||
expect(hoisted.engineLoadMock).not.toHaveBeenCalled();
|
||||
expect(hoisted.prefetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { QueueItemRef, Track } from './playerStoreTypes';
|
||||
import { getQueueTracksView } from '../utils/library/queueTrackView';
|
||||
import { scheduleHotCachePrefetchForTrack } from '../hotCachePrefetch';
|
||||
import { getPlaybackCacheServerKey } from '../utils/playback/playbackServer';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { bumpPlayGeneration, getPlayGeneration } from './engineState';
|
||||
import { engineLoadTrackAtPosition } from './engineLoadTrackAtPosition';
|
||||
import { emitPlaybackProgress } from './playbackProgress';
|
||||
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { setSeekFallbackVisualTarget } from './seekFallbackState';
|
||||
|
||||
/** Push restored position into the store + progress channel so the seekbar paints immediately. */
|
||||
export function applyRestoredPlaybackVisual(track: Track, atSeconds: number): void {
|
||||
const dur = track.duration > 0 ? track.duration : 0;
|
||||
const seconds = Math.max(0, atSeconds);
|
||||
const progress = dur > 0 ? Math.min(1, seconds / dur) : 0;
|
||||
usePlayerStore.setState({ currentTime: seconds, progress, buffered: 0 });
|
||||
emitPlaybackProgress({
|
||||
currentTime: seconds,
|
||||
progress,
|
||||
buffered: 0,
|
||||
buffering: false,
|
||||
});
|
||||
if (seconds > 0.05) {
|
||||
setSeekFallbackVisualTarget({
|
||||
trackId: track.id,
|
||||
seconds,
|
||||
setAtMs: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After `getPlayQueue` restores a paused session: show the saved seek position,
|
||||
* prefetch bytes for the current track, and load the engine paused at that spot
|
||||
* so the next Play is a warm `audio_resume`.
|
||||
*/
|
||||
export function preparePausedRestoreOnStartup(
|
||||
track: Track,
|
||||
queueItems: QueueItemRef[],
|
||||
queueIndex: number,
|
||||
atSeconds: number,
|
||||
): void {
|
||||
const player = usePlayerStore.getState();
|
||||
if (player.isPlaying || player.currentRadio) return;
|
||||
|
||||
applyRestoredPlaybackVisual(track, atSeconds);
|
||||
scheduleHotCachePrefetchForTrack(track, getPlaybackCacheServerKey());
|
||||
|
||||
const generation = bumpPlayGeneration();
|
||||
void (async () => {
|
||||
const auth = useAuthStore.getState();
|
||||
const promoteSid = getPlaybackCacheServerKey();
|
||||
if (auth.hotCacheEnabled && promoteSid) {
|
||||
await promoteCompletedStreamToHotCache(
|
||||
track,
|
||||
promoteSid,
|
||||
auth.hotCacheDownloadDir || null,
|
||||
);
|
||||
}
|
||||
if (getPlayGeneration() !== generation) return;
|
||||
if (usePlayerStore.getState().isPlaying) return;
|
||||
|
||||
const queue = getQueueTracksView(queueItems, [track]);
|
||||
engineLoadTrackAtPosition({
|
||||
generation,
|
||||
track,
|
||||
queue,
|
||||
queueIndex,
|
||||
atSeconds,
|
||||
wantPlaying: false,
|
||||
});
|
||||
})();
|
||||
}
|
||||
@@ -375,6 +375,7 @@ export function runPlayTrack(
|
||||
analysisTrackId: scopedTrack.id,
|
||||
serverId: getPlaybackIndexKey() || null,
|
||||
streamFormatSuffix: scopedTrack.suffix ?? null,
|
||||
startPaused: false,
|
||||
})
|
||||
.then(() => {
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
|
||||
@@ -230,6 +230,20 @@ describe('stop', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('queueUndoRestoreAudioEngine', () => {
|
||||
expect(seekCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it('issues audio_pause when wantPlaying=false', async () => {
|
||||
it('loads with startPaused and skips audio_pause when wantPlaying=false', async () => {
|
||||
queueUndoRestoreAudioEngine({
|
||||
generation: 1,
|
||||
track: track('t1'),
|
||||
@@ -141,7 +141,11 @@ describe('queueUndoRestoreAudioEngine', () => {
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_pause');
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
import type { Track } from './playerStoreTypes';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
||||
import {
|
||||
getPlaybackIndexKey,
|
||||
playbackCacheKeyForTrack,
|
||||
playbackProfileIdForTrack,
|
||||
} from '../utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { getPlayGeneration, setIsAudioPaused } from './engineState';
|
||||
import { touchHotCacheOnPlayback } from './hotCacheTouch';
|
||||
import { isReplayGainActive, loudnessGainDbForEngineBind } from './loudnessGainCache';
|
||||
import { playbackSourceHintForResolvedUrl, recordEnginePlayUrl } from './playbackUrlRouting';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { engineLoadTrackAtPosition } from './engineLoadTrackAtPosition';
|
||||
|
||||
/**
|
||||
* Reload the Rust audio engine to match a queue-undo snapshot. Zustand
|
||||
* alone can rewrite the queue + currentTrack, but the engine is still
|
||||
@@ -33,72 +20,5 @@ export function queueUndoRestoreAudioEngine(opts: {
|
||||
atSeconds: number;
|
||||
wantPlaying: boolean;
|
||||
}): void {
|
||||
const { generation, track, queue, queueIndex, atSeconds, wantPlaying } = opts;
|
||||
const authState = useAuthStore.getState();
|
||||
const vol = usePlayerStore.getState().volume;
|
||||
const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null;
|
||||
const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
|
||||
const replayGainDb = resolveReplayGainDb(
|
||||
track, coldPrev, coldNext,
|
||||
isReplayGainActive(), authState.replayGainMode,
|
||||
);
|
||||
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
||||
const playbackSid = playbackProfileIdForTrack(track);
|
||||
const playbackCacheSid = playbackCacheKeyForTrack(track);
|
||||
const playbackIndexKey = playbackCacheKeyForTrack(track) || getPlaybackIndexKey();
|
||||
const url = resolvePlaybackUrl(track.id, playbackCacheSid);
|
||||
recordEnginePlayUrl(track.id, url);
|
||||
usePlayerStore.setState({
|
||||
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackCacheSid, url),
|
||||
});
|
||||
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
|
||||
setDeferHotCachePrefetch(true);
|
||||
invoke('audio_play', {
|
||||
url,
|
||||
volume: vol,
|
||||
durationHint: track.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
manual: false,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
analysisTrackId: track.id,
|
||||
serverId: playbackIndexKey || null,
|
||||
streamFormatSuffix: track.suffix ?? null,
|
||||
})
|
||||
.then(() => {
|
||||
if (getPlayGeneration() !== generation) return;
|
||||
if (keepPreloadHint) {
|
||||
usePlayerStore.setState({ enginePreloadedTrackId: null });
|
||||
}
|
||||
const dur = track.duration && track.duration > 0 ? track.duration : null;
|
||||
const seekTo = Math.max(0, atSeconds);
|
||||
const canSeek = seekTo > 0.05 && (dur == null || seekTo < dur - 0.05);
|
||||
const afterSeek = () => {
|
||||
if (getPlayGeneration() !== generation) return;
|
||||
if (!wantPlaying) {
|
||||
invoke('audio_pause').catch(console.error);
|
||||
setIsAudioPaused(true);
|
||||
usePlayerStore.setState({ isPlaying: false });
|
||||
} else {
|
||||
setIsAudioPaused(false);
|
||||
}
|
||||
};
|
||||
if (canSeek) {
|
||||
void invoke('audio_seek', { seconds: seekTo }).then(afterSeek).catch(afterSeek);
|
||||
} else {
|
||||
afterSeek();
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (getPlayGeneration() !== generation) return;
|
||||
console.error('[psysonic] queue-undo audio_play failed:', err);
|
||||
usePlayerStore.setState({ isPlaying: false });
|
||||
})
|
||||
.finally(() => {
|
||||
setDeferHotCachePrefetch(false);
|
||||
});
|
||||
touchHotCacheOnPlayback(track.id, playbackCacheSid);
|
||||
engineLoadTrackAtPosition(opts);
|
||||
}
|
||||
|
||||
@@ -180,6 +180,7 @@ export function runResume(set: SetState, get: GetState): void {
|
||||
analysisTrackId: trackToPlay.id,
|
||||
serverId: coldServerId || null,
|
||||
streamFormatSuffix: trackToPlay.suffix ?? null,
|
||||
startPaused: false,
|
||||
}).then(() => {
|
||||
if (getPlayGeneration() === gen && currentTime > 1) {
|
||||
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
||||
@@ -220,6 +221,7 @@ export function runResume(set: SetState, get: GetState): void {
|
||||
analysisTrackId: currentTrack.id,
|
||||
serverId: coldServerId || null,
|
||||
streamFormatSuffix: currentTrack.suffix ?? null,
|
||||
startPaused: false,
|
||||
}).catch((err: unknown) => {
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
setDeferHotCachePrefetch(false);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { clearSeekDebounce } from './seekDebounce';
|
||||
import { clearSeekFallbackRetry } from './seekFallbackState';
|
||||
import { clearSeekTarget } from './seekTargetState';
|
||||
import { tryAcquireTogglePlayLock } from './togglePlayLock';
|
||||
import { refreshWaveformForTrack } from './waveformRefresh';
|
||||
|
||||
type SetState = (
|
||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||
@@ -31,7 +32,8 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
|
||||
stop: () => {
|
||||
void playListenSessionFinalize('stop');
|
||||
clearAllPlaybackScheduleTimers();
|
||||
if (get().currentRadio) {
|
||||
const wasRadio = !!get().currentRadio;
|
||||
if (wasRadio) {
|
||||
stopRadio();
|
||||
} else {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
@@ -39,13 +41,16 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
|
||||
setIsAudioPaused(false);
|
||||
clearSeekFallbackRetry();
|
||||
clearSeekDebounce(); clearSeekTarget();
|
||||
// Stop keeps `currentTrack` (the bar still shows the stopped song), so its
|
||||
// waveform stays valid. Radio has no analysis waveform — drop the bins.
|
||||
const keptTrackId = wasRadio ? null : get().currentTrack?.id ?? null;
|
||||
set({
|
||||
isPlaying: false,
|
||||
progress: 0,
|
||||
buffered: 0,
|
||||
currentTime: 0,
|
||||
currentRadio: null,
|
||||
waveformBins: null,
|
||||
...(keptTrackId ? {} : { waveformBins: null }),
|
||||
normalizationNowDb: null,
|
||||
normalizationTargetLufs: null,
|
||||
normalizationEngineLive: 'off',
|
||||
@@ -56,6 +61,9 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
|
||||
scheduledResumeAtMs: null,
|
||||
scheduledResumeStartMs: null,
|
||||
});
|
||||
// Re-hydrate from the analysis DB in case the bins were never loaded or
|
||||
// only partially filled while the (now stopped) track was playing.
|
||||
if (keptTrackId) void refreshWaveformForTrack(keptTrackId);
|
||||
},
|
||||
|
||||
pause: () => {
|
||||
|
||||
Reference in New Issue
Block a user