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) + }, + } +}