Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/components/PlayerRuntime.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -259,6 +260,79 @@ describe('PlayerRuntime', () => {
}
})

describe('when headphones or a car disconnect', () => {
let handlers: Map<string, MediaSessionActionHandler | null>
let devices: EventTarget & { enumerateDevices: () => Promise<MediaDeviceInfo[]> }
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<string, MediaSessionActionHandler | null>()
Object.defineProperty(navigator, 'mediaSession', { configurable: true, value: { metadata: null, playbackState: 'none', setActionHandler: (action: string, handler: MediaSessionActionHandler | null) => handlers.set(action, handler), setPositionState: () => {} } })
Expand Down
29 changes: 28 additions & 1 deletion src/components/PlayerRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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'
Expand Down Expand Up @@ -79,6 +80,9 @@
const handingOff = useRef<{ from: AudioSlot; seconds: number } | null>(null)
const fade = useRef<{ from: AudioSlot; to: AudioSlot; timer: number } | null>(null)
const canFade = useRef<boolean | null>(null)
const outputs = useRef<AudioOutputWatcher | null>(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)
Expand Down Expand Up @@ -550,7 +554,13 @@
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()],
Expand All @@ -560,8 +570,25 @@
]
handlers.forEach(([action, handler]) => { try { navigator.mediaSession.setActionHandler(action, handler) } catch { /* unsupported action on this browser */ } })
return () => handlers.forEach(([action]) => { try { navigator.mediaSession.setActionHandler(action, null) } catch { /* unsupported action */ } })
}, [api, track])

Check warning on line 573 in src/components/PlayerRuntime.tsx

View workflow job for this annotation

GitHub Actions / validate

React Hook useEffect has a missing dependency: 'currentAudio'. Either include it or remove the dependency array

Check warning on line 573 in src/components/PlayerRuntime.tsx

View workflow job for this annotation

GitHub Actions / validate

React Hook useEffect has a missing dependency: 'currentAudio'. Either include it or remove the dependency array

// 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])
Expand Down
63 changes: 63 additions & 0 deletions src/lib/audioOutputs.ts
Original file line number Diff line number Diff line change
@@ -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<MediaDevices, 'addEventListener' | 'removeEventListener'> & Partial<Pick<MediaDevices, 'enumerateDevices'>>

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<Set<string> | 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<string> | 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)
},
}
}
Loading