fix(audio): resume playback seamlessly on output device switch (#743) (#765)

* 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:
cucadmuh
2026-05-18 01:01:23 +03:00
committed by GitHub
parent 33a1b8709d
commit d3fc5c91fc
9 changed files with 509 additions and 31 deletions
@@ -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,
});
});
});
+36 -8
View File
@@ -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();