fix(audio): don't restart playback on device change when engine is paused

Defence-in-depth for #1094: the device-changed/-reset handlers restarted
playback based on the UI `isPlaying` flag alone, which can be stale or
desynced at the moment of a device change. Gate the restart on the
engine-paused flag as well (`isPlaying && !getIsAudioPaused()`), so a
paused engine never auto-restarts regardless of how `isPlaying` got set;
when paused it just resets for the cold path. Adds a regression test.
This commit is contained in:
Psychotoxical
2026-06-16 14:43:23 +02:00
parent 9b24957595
commit 588dd8c48d
2 changed files with 25 additions and 2 deletions
@@ -18,6 +18,7 @@ import { makeTrack } from '@/test/helpers/factories';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { getSeekFallbackVisualTarget, setSeekFallbackVisualTarget } from '@/store/seekFallbackState';
import { setIsAudioPaused } from '@/store/engineState';
import { useAudioDeviceBridge } from './useAudioDeviceBridge';
const track = makeTrack({ id: 't1', duration: 300 });
@@ -29,6 +30,8 @@ function mountBridge() {
beforeEach(() => {
resetAllStores();
setSeekFallbackVisualTarget(null);
// Module-level engine flag isn't covered by resetAllStores — reset explicitly.
setIsAudioPaused(false);
// Default: a track is playing.
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
});
@@ -92,6 +95,19 @@ describe('audio:device-changed', () => {
expect(playTrack).not.toHaveBeenCalled();
});
it('does not restart when the engine is paused even if isPlaying is stale-true (#1094)', () => {
const playTrack = vi.fn();
const resetAudioPause = vi.fn();
usePlayerStore.setState({ playTrack, resetAudioPause, isPlaying: true } as never);
setIsAudioPaused(true);
mountBridge();
emitTauriEvent('audio:device-changed', 45.0);
expect(playTrack).not.toHaveBeenCalled();
expect(resetAudioPause).toHaveBeenCalled();
});
});
// ─── audio:device-reset ─────────────────────────────────────────────────────
@@ -3,6 +3,7 @@ import { listen } from '@tauri-apps/api/event';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
import { setSeekFallbackVisualTarget } from '../../store/seekFallbackState';
import { getIsAudioPaused } from '../../store/engineState';
/** Audio output device lifecycle: device switches (Bluetooth headphones, USB
* DAC, …) and pinned-device-unplugged fallbacks emitted by the Rust
@@ -23,7 +24,10 @@ export function useAudioDeviceBridge() {
const resumeAt = typeof event.payload === 'number' ? event.payload : 0;
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return;
if (isPlaying) {
// Only restart playback when transport is *provably* active. `isPlaying`
// alone can be stale/desynced on a device change (#1094); the engine-paused
// flag is the source of truth — if paused, just reset for the cold path.
if (isPlaying && !getIsAudioPaused()) {
if (resumeAt > 0.5 && currentTrack.duration > 0) {
setSeekFallbackVisualTarget({
trackId: currentTrack.id,
@@ -55,7 +59,10 @@ export function useAudioDeviceBridge() {
const resumeAt = typeof event.payload === 'number' ? event.payload : 0;
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return;
if (isPlaying) {
// Only restart playback when transport is *provably* active. `isPlaying`
// alone can be stale/desynced on a device change (#1094); the engine-paused
// flag is the source of truth — if paused, just reset for the cold path.
if (isPlaying && !getIsAudioPaused()) {
if (resumeAt > 0.5 && currentTrack.duration > 0) {
setSeekFallbackVisualTarget({
trackId: currentTrack.id,