mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
* fix(audio): resume playback seamlessly on output device switch (#743) When the OS default output device changed (Bluetooth, USB DAC, HDMI), rodio/cpal had to reopen the stream, which silently stopped the active sink and left the engine with no playback — causing the track to restart from the beginning (or not restart at all after the null-payload bug). Root cause (null-payload): audio_set_device emitted () (unit), which Tauri serialises to JSON null. The null-guard added for the "Rust handled replay internally" signal was therefore also triggered by manual device switches, so playTrack was never called and the engine stayed silent. Fix: audio_set_device now emits the current playback position as f64 (null remains the exclusive "Rust handled" sentinel). Rust-side seamless replay (device watcher path): reopen_output_stream captures a ResumeSnapshot before the blocking stream reopen, then calls try_resume_after_device_change, which: - local files (psysonic-local://): reopens the file, builds a new seekable source, seeks to the saved position — zero frontend round-trip, no audible restart. - fully-cached HTTP tracks (stream_completed_cache / spill file): replays from the in-memory or on-disk bytes — no re-download. - partial downloads / radio / paused: returns false → falls back to the existing frontend path (seekFallbackVisualTarget + playTrack). Frontend (useAudioDeviceBridge): null payload → Rust already resumed; skip playTrack. number payload → call playTrack + seekFallbackVisualTarget(position). Visibility: pub(super) → pub(crate) on the play_input / progress_task helpers that device_watcher.rs needs to call directly. * refactor(audio): split device_resume module; add bridge tests Split the 551-line device_watcher.rs (above the ~500-line soft ceiling) by extracting ResumeSnapshot and try_resume_after_device_change into a dedicated device_resume.rs module. device_watcher.rs is now 320 lines, device_resume.rs 258 lines. Add useAudioDeviceBridge.test.ts: 9 characterisation tests covering the null-payload guard ("Rust replayed, skip playTrack"), the seek-fallback path (position > 0.5 s sets seekFallbackVisualTarget), paused-device branch (resetAudioPause), and the device-reset event path. * chore(changelog): add entry for #765 (device switch seamless resume)
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* useAudioDeviceBridge — characterisation tests for the payload-shape contract
|
||||
* introduced in PR #743.
|
||||
*
|
||||
* Payload shapes:
|
||||
* null → Rust replayed internally; frontend must NOT call playTrack.
|
||||
* number → position captured by Rust; frontend calls playTrack + optionally
|
||||
* sets seekFallbackVisualTarget (only when > 0.5 s).
|
||||
*
|
||||
* Both `audio:device-changed` and `audio:device-reset` share the same logic
|
||||
* (device-reset also clears the stored output device).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { emitTauriEvent } from '@/test/mocks/tauri';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack } from '@/test/helpers/factories';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { getSeekFallbackVisualTarget, setSeekFallbackVisualTarget } from '@/store/seekFallbackState';
|
||||
import { useAudioDeviceBridge } from './useAudioDeviceBridge';
|
||||
|
||||
const track = makeTrack({ id: 't1', duration: 300 });
|
||||
|
||||
function mountBridge() {
|
||||
renderHook(() => useAudioDeviceBridge());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
setSeekFallbackVisualTarget(null);
|
||||
// Default: a track is playing.
|
||||
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
|
||||
});
|
||||
|
||||
// ─── audio:device-changed ───────────────────────────────────────────────────
|
||||
|
||||
describe('audio:device-changed', () => {
|
||||
it('skips playTrack when Rust handled replay (null payload)', () => {
|
||||
const playTrack = vi.fn();
|
||||
usePlayerStore.setState({ playTrack } as never);
|
||||
mountBridge();
|
||||
|
||||
emitTauriEvent('audio:device-changed', null);
|
||||
|
||||
expect(playTrack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls playTrack and sets seek target when position > 0.5', () => {
|
||||
const playTrack = vi.fn();
|
||||
usePlayerStore.setState({ playTrack } as never);
|
||||
mountBridge();
|
||||
|
||||
emitTauriEvent('audio:device-changed', 120.5);
|
||||
|
||||
expect(playTrack).toHaveBeenCalledWith(track);
|
||||
expect(getSeekFallbackVisualTarget()).toMatchObject({
|
||||
trackId: track.id,
|
||||
seconds: 120.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls playTrack without seek target when position ≤ 0.5', () => {
|
||||
const playTrack = vi.fn();
|
||||
usePlayerStore.setState({ playTrack } as never);
|
||||
mountBridge();
|
||||
|
||||
emitTauriEvent('audio:device-changed', 0.0);
|
||||
|
||||
expect(playTrack).toHaveBeenCalledWith(track);
|
||||
expect(getSeekFallbackVisualTarget()).toBeNull();
|
||||
});
|
||||
|
||||
it('calls resetAudioPause instead of playTrack when paused', () => {
|
||||
const playTrack = vi.fn();
|
||||
const resetAudioPause = vi.fn();
|
||||
usePlayerStore.setState({ playTrack, resetAudioPause, isPlaying: false } as never);
|
||||
mountBridge();
|
||||
|
||||
emitTauriEvent('audio:device-changed', 45.0);
|
||||
|
||||
expect(playTrack).not.toHaveBeenCalled();
|
||||
expect(resetAudioPause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when there is no current track', () => {
|
||||
const playTrack = vi.fn();
|
||||
usePlayerStore.setState({ playTrack, currentTrack: null } as never);
|
||||
mountBridge();
|
||||
|
||||
emitTauriEvent('audio:device-changed', 30.0);
|
||||
|
||||
expect(playTrack).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── audio:device-reset ─────────────────────────────────────────────────────
|
||||
|
||||
describe('audio:device-reset', () => {
|
||||
it('always clears the stored output device', () => {
|
||||
useAuthStore.setState({ audioOutputDevice: 'My DAC' } as never);
|
||||
mountBridge();
|
||||
|
||||
emitTauriEvent('audio:device-reset', null);
|
||||
|
||||
expect(useAuthStore.getState().audioOutputDevice).toBeNull();
|
||||
});
|
||||
|
||||
it('skips playTrack when Rust handled replay (null payload)', () => {
|
||||
const playTrack = vi.fn();
|
||||
usePlayerStore.setState({ playTrack } as never);
|
||||
mountBridge();
|
||||
|
||||
emitTauriEvent('audio:device-reset', null);
|
||||
|
||||
expect(playTrack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls playTrack and sets seek target when position > 0.5', () => {
|
||||
const playTrack = vi.fn();
|
||||
usePlayerStore.setState({ playTrack } as never);
|
||||
mountBridge();
|
||||
|
||||
emitTauriEvent('audio:device-reset', 90.0);
|
||||
|
||||
expect(playTrack).toHaveBeenCalledWith(track);
|
||||
expect(getSeekFallbackVisualTarget()).toMatchObject({
|
||||
trackId: track.id,
|
||||
seconds: 90.0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,20 +2,35 @@ import { useEffect } from 'react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { setSeekFallbackVisualTarget } from '../../store/seekFallbackState';
|
||||
|
||||
/** Audio output device lifecycle: device switches (Bluetooth headphones, USB
|
||||
* DAC, …) and pinned-device-unplugged fallbacks emitted by the Rust
|
||||
* device-watcher. */
|
||||
* device-watcher.
|
||||
*
|
||||
* Rust emits two different payload shapes:
|
||||
* null → Rust replayed the track internally; no frontend restart needed.
|
||||
* number → Rust could not replay (radio, uncached HTTP, paused); frontend
|
||||
* must call playTrack and seek to that position.
|
||||
*/
|
||||
export function useAudioDeviceBridge() {
|
||||
// Audio output device changed (Bluetooth headphones, USB DAC, etc.)
|
||||
// The Rust device-watcher has already reopened the stream on the new device
|
||||
// and dropped the old Sink, so we just need to restart playback.
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen('audio:device-changed', () => {
|
||||
listen('audio:device-changed', (event) => {
|
||||
// null payload = Rust handled internal replay; nothing to do here.
|
||||
if (event.payload === null) return;
|
||||
|
||||
const resumeAt = typeof event.payload === 'number' ? event.payload : 0;
|
||||
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
|
||||
if (!currentTrack) return;
|
||||
if (isPlaying) {
|
||||
if (resumeAt > 0.5 && currentTrack.duration > 0) {
|
||||
setSeekFallbackVisualTarget({
|
||||
trackId: currentTrack.id,
|
||||
seconds: resumeAt,
|
||||
setAtMs: Date.now(),
|
||||
});
|
||||
}
|
||||
playTrack(currentTrack);
|
||||
} else {
|
||||
// Paused: clear warm-pause flag so the next resume uses the cold path
|
||||
@@ -27,14 +42,27 @@ export function useAudioDeviceBridge() {
|
||||
}, []);
|
||||
|
||||
// Pinned output device was unplugged — Rust already fell back to system default.
|
||||
// Clear the stored device so the Settings dropdown resets to "System Default".
|
||||
// Always clear the stored device so the Settings dropdown resets to "System Default",
|
||||
// even when Rust handled internal replay (null payload).
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen('audio:device-reset', () => {
|
||||
listen('audio:device-reset', (event) => {
|
||||
useAuthStore.getState().setAudioOutputDevice(null);
|
||||
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
|
||||
|
||||
// null payload = Rust handled internal replay on the new default device.
|
||||
if (event.payload === null) return;
|
||||
|
||||
const resumeAt = typeof event.payload === 'number' ? event.payload : 0;
|
||||
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
|
||||
if (!currentTrack) return;
|
||||
if (isPlaying) {
|
||||
if (resumeAt > 0.5 && currentTrack.duration > 0) {
|
||||
setSeekFallbackVisualTarget({
|
||||
trackId: currentTrack.id,
|
||||
seconds: resumeAt,
|
||||
setAtMs: Date.now(),
|
||||
});
|
||||
}
|
||||
playTrack(currentTrack);
|
||||
} else {
|
||||
resetAudioPause();
|
||||
|
||||
Reference in New Issue
Block a user