From 43a5cf1c9bdeb430a963751cb44d15090fde2161 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:51:29 +0000 Subject: [PATCH] Ignore the play headphones send as they disconnect The earlier fix only stopped plays the browser starts on the audio element by itself. On Android, Bluetooth headphones dropping out often send a "play" through the media session instead, the same route as the listener pressing play, so the music still came back out of the speaker. A media session play within a few seconds of an audio device change is now ignored, and one that lands just before the change is reported is undone. A device that is known to have gone pauses playback, as unplugging wired headphones does. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016uTomBBsEAYGxqVqiM58P6 --- src/components/PlayerRuntime.test.tsx | 74 +++++++++++++++++++++++++++ src/components/PlayerRuntime.tsx | 29 ++++++++++- src/lib/audioOutputs.ts | 63 +++++++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 src/lib/audioOutputs.ts diff --git a/src/components/PlayerRuntime.test.tsx b/src/components/PlayerRuntime.test.tsx index b2e1afe..01fa2f0 100644 --- a/src/components/PlayerRuntime.test.tsx +++ b/src/components/PlayerRuntime.test.tsx @@ -5,6 +5,7 @@ import { useAuth } from '../stores/auth' import { usePlayer } from '../stores/player' import { useAlbumArtwork } from '../stores/albumArtwork' import { ADAPTIVE_START_TIMEOUT_MS, RETRY_DELAYS_MS, STALL_TIMEOUT_MS } from '../lib/playbackRecovery' +import { OUTPUT_CHANGE_GUARD_MS } from '../lib/audioOutputs' import { PlayerRuntime } from './PlayerRuntime' import { loadHlsLibrary, resetAdaptiveAvailability } from '../lib/adaptiveStream' import { DEFAULT_NAVIDROME_SERVER } from '../lib/navidrome' @@ -259,6 +260,79 @@ describe('PlayerRuntime', () => { } }) + describe('when headphones or a car disconnect', () => { + let handlers: Map + let devices: EventTarget & { enumerateDevices: () => Promise } + let deviceList: { kind: string; deviceId: string }[] + const changeDevices = async (list: { kind: string; deviceId: string }[]) => { + deviceList = list + await act(async () => { devices.dispatchEvent(new Event('devicechange')) }) + await act(async () => { await Promise.resolve() }) + } + const sessionAction = async (action: string) => { await act(async () => { handlers.get(action)?.({ action: action as MediaSessionAction }) }) } + + beforeEach(() => { + handlers = new Map() + deviceList = [{ kind: 'audiooutput', deviceId: '' }] + devices = Object.assign(new EventTarget(), { enumerateDevices: async () => deviceList as MediaDeviceInfo[] }) + Object.defineProperty(navigator, 'mediaSession', { configurable: true, value: { metadata: null, playbackState: 'none', setActionHandler: (action: string, handler: MediaSessionActionHandler | null) => handlers.set(action, handler), setPositionState: () => {} } }) + Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: devices }) + }) + afterEach(() => { + delete (navigator as { mediaSession?: unknown }).mediaSession + delete (navigator as { mediaDevices?: unknown }).mediaDevices + }) + + it('ignores the play the phone sends as the headphones drop out', async () => { + const [current] = await startPlaying() + // Taking the headphones out pauses... + await sessionAction('pause') + expect(usePlayer.getState().playing).toBe(false) + // ...then as they disconnect, the phone sends a play of its own. + await changeDevices([]) + vi.mocked(HTMLMediaElement.prototype.play).mockClear() + await sessionAction('play') + expect(usePlayer.getState().playing).toBe(false) + expect(vi.mocked(HTMLMediaElement.prototype.play).mock.contexts).not.toContain(current) + }) + + it('pauses again when that play arrives just before the disconnect is reported', async () => { + await startPlaying() + await sessionAction('pause') + await sessionAction('play') + expect(usePlayer.getState().playing).toBe(true) + await changeDevices([]) + expect(usePlayer.getState().playing).toBe(false) + }) + + it('still plays when the listener presses play later on', async () => { + vi.useFakeTimers() + await startPlaying() + await sessionAction('pause') + await changeDevices([]) + await act(async () => { vi.advanceTimersByTime(OUTPUT_CHANGE_GUARD_MS) }) + await sessionAction('play') + expect(usePlayer.getState().playing).toBe(true) + }) + + it('keeps playing when a device change has nothing to do with a play request', async () => { + vi.useFakeTimers() + await startPlaying() + await act(async () => { vi.advanceTimersByTime(OUTPUT_CHANGE_GUARD_MS) }) + await changeDevices([{ kind: 'audiooutput', deviceId: '' }, { kind: 'audiooutput', deviceId: '' }]) + expect(usePlayer.getState().playing).toBe(true) + }) + + it('pauses when a known audio device goes away while playing', async () => { + deviceList = [{ kind: 'audiooutput', deviceId: 'speaker' }, { kind: 'audiooutput', deviceId: 'buds' }] + vi.useFakeTimers() + await startPlaying() + await act(async () => { vi.advanceTimersByTime(OUTPUT_CHANGE_GUARD_MS) }) + await changeDevices([{ kind: 'audiooutput', deviceId: 'speaker' }]) + expect(usePlayer.getState().playing).toBe(false) + }) + }) + it('stops the DJ\'s link and keeps the music paused when pause is pressed on the headphones or lock screen', async () => { const handlers = new Map() Object.defineProperty(navigator, 'mediaSession', { configurable: true, value: { metadata: null, playbackState: 'none', setActionHandler: (action: string, handler: MediaSessionActionHandler | null) => handlers.set(action, handler), setPositionState: () => {} } }) diff --git a/src/components/PlayerRuntime.tsx b/src/components/PlayerRuntime.tsx index 338a288..37ce52f 100644 --- a/src/components/PlayerRuntime.tsx +++ b/src/components/PlayerRuntime.tsx @@ -7,6 +7,7 @@ import { setMediaMetadata } from '../lib/mediaSession' import { ListeningTracker, sendListeningEvent } from '../lib/listening-log' import { localPlaybackDeviceName } from '../stores/livePlayback' import { albumArtworkUrl, useAlbumArtwork } from '../stores/albumArtwork' +import { OUTPUT_CHANGE_GUARD_MS, watchAudioOutputs, type AudioOutputWatcher } from '../lib/audioOutputs' import { ADAPTIVE_START_TIMEOUT_MS, RETRY_DELAYS_MS, STALL_TIMEOUT_MS } from '../lib/playbackRecovery' import { AdaptiveSources, chooseSource, loadHlsLibrary, markAdaptiveUnavailable, wantsAdaptive, type StreamSource } from '../lib/adaptiveStream' import { isLanUrl, mediaStreamFailed } from '../lib/mediaRoute' @@ -79,6 +80,9 @@ export function PlayerRuntime() { const handingOff = useRef<{ from: AudioSlot; seconds: number } | null>(null) const fade = useRef<{ from: AudioSlot; to: AudioSlot; timer: number } | null>(null) const canFade = useRef(null) + const outputs = useRef(null) + // When the lock screen, headphones or car last asked to play (see the media session handlers). + const sessionPlayAt = useRef(-Infinity) const api = useApi() const queue = usePlayer((s) => s.queue) const currentIndex = usePlayer((s) => s.currentIndex) @@ -550,7 +554,13 @@ export function PlayerRuntime() { useEffect(() => { if (!track || !('mediaSession' in navigator)) return const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [ - ['play', () => usePlayer.getState().setPlaying(true)], + ['play', () => { + // Headphones or a car dropping out can send a play of their own as they go: that would start the + // music out of the speaker, so a play right as an audio device comes or goes is ignored. + if (!usePlayer.getState().playing && outputs.current?.changedRecently()) return + sessionPlayAt.current = Date.now() + usePlayer.getState().setPlaying(true) + }], ['pause', () => { useDj.getState().hush(); usePlayer.getState().setPlaying(false) }], ['previoustrack', () => usePlayer.getState().previous()], ['nexttrack', () => usePlayer.getState().next()], @@ -562,6 +572,23 @@ export function PlayerRuntime() { return () => handlers.forEach(([action]) => { try { navigator.mediaSession.setActionHandler(action, null) } catch { /* unsupported action */ } }) }, [api, track]) + // The play from headphones dropping out can also arrive just before the browser reports the device change: + // undo a lock-screen or headphone play that close to it. A device that is known to have gone pauses the + // music as unplugging wired headphones does. + useEffect(() => { + const watcher = watchAudioOutputs((removed) => { + if (!usePlayer.getState().playing) return + if (!removed && Date.now() - sessionPlayAt.current >= OUTPUT_CHANGE_GUARD_MS) return + useDj.getState().hush() + usePlayer.getState().setPlaying(false) + }) + outputs.current = watcher + return () => { + outputs.current = null + watcher.stop() + } + }, []) + useEffect(() => { if (track && 'mediaSession' in navigator) setMediaMetadata(track, api) }, [api, track, artworkOverride]) diff --git a/src/lib/audioOutputs.ts b/src/lib/audioOutputs.ts new file mode 100644 index 0000000..17e29f8 --- /dev/null +++ b/src/lib/audioOutputs.ts @@ -0,0 +1,63 @@ +// Headphones, a car or a speaker connecting or dropping out shows up as a 'devicechange' event. +// When Bluetooth headphones disconnect, Android often sends a "play" to the media session at the +// same moment, exactly as if the listener had pressed play, and the music comes back out of the +// phone's speaker. A play that close to a device change is taken as that, not as the listener. +export const OUTPUT_CHANGE_GUARD_MS = 4000 + +export type AudioOutputWatcher = { + // True while a device change happened in the last OUTPUT_CHANGE_GUARD_MS. + changedRecently(now?: number): boolean + stop(): void +} + +type Devices = Pick & Partial> + +function mediaDevices(): Devices | undefined { + if (typeof navigator === 'undefined') return undefined + const devices = (navigator as { mediaDevices?: Devices }).mediaDevices + return devices && typeof devices.addEventListener === 'function' ? devices : undefined +} + +// Ids of the audio devices the browser shows. Without microphone permission they are blank, +// so a removal can only be told apart from an addition when the ids are known. +async function audioDeviceIds(devices: Devices): Promise | null> { + if (!devices.enumerateDevices) return null + try { + const list = await devices.enumerateDevices() + const ids = list.filter((device) => device.kind !== 'videoinput').map((device) => `${device.kind}:${device.deviceId}`) + return ids.every((id) => !id.endsWith(':')) ? new Set(ids) : null + } catch { + return null + } +} + +// onChange runs on every device change; `removed` is true only when a device is known to have gone. +export function watchAudioOutputs(onChange: (removed: boolean) => void): AudioOutputWatcher { + const devices = mediaDevices() + let lastChangeAt = -Infinity + let known: Set | null = null + let stopped = false + if (!devices) return { changedRecently: () => false, stop: () => undefined } + + void audioDeviceIds(devices).then((ids) => { if (!stopped) known = ids }) + + const changed = () => { + lastChangeAt = Date.now() + const before = known + onChange(false) + void audioDeviceIds(devices).then((ids) => { + if (stopped) return + known = ids + if (before && ids && [...before].some((id) => !ids.has(id))) onChange(true) + }) + } + devices.addEventListener('devicechange', changed) + + return { + changedRecently: (now = Date.now()) => now - lastChangeAt < OUTPUT_CHANGE_GUARD_MS, + stop: () => { + stopped = true + devices.removeEventListener('devicechange', changed) + }, + } +}