diff --git a/src/hooks/tauriBridge/useAudioDeviceBridge.test.ts b/src/hooks/tauriBridge/useAudioDeviceBridge.test.ts index d9e5253c..9ebd1a75 100644 --- a/src/hooks/tauriBridge/useAudioDeviceBridge.test.ts +++ b/src/hooks/tauriBridge/useAudioDeviceBridge.test.ts @@ -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 ───────────────────────────────────────────────────── diff --git a/src/hooks/tauriBridge/useAudioDeviceBridge.ts b/src/hooks/tauriBridge/useAudioDeviceBridge.ts index 4f95a415..ced55f25 100644 --- a/src/hooks/tauriBridge/useAudioDeviceBridge.ts +++ b/src/hooks/tauriBridge/useAudioDeviceBridge.ts @@ -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,