Skip to content
Draft
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
68 changes: 68 additions & 0 deletions src/utils/webrtc/CallParticipantsAudioPlayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
import attachMediaStream from '../attachmediastream.js'
import { mediaDevicesManager } from '../webrtc/index.js'

// Autoplay of an unmuted element can be blocked until the user interacts with
// the page; these gestures are used to retry playback of blocked elements.
const AUTOPLAY_RESUME_EVENTS = ['touchend', 'mousedown', 'keydown']

/**
* Player for audio of call participants.
*
Expand Down Expand Up @@ -37,6 +41,13 @@ export default function CallParticipantsAudioPlayer(callParticipantCollection, m
this._audioDestination = this._audioContext.createMediaStreamDestination()
this._audioElement = attachMediaStream(this._audioDestination.stream, null, { audio: true })
this._audioNodes = new Map()

// On Safari the AudioContext starts suspended until a user gesture, so
// resume it (and (re)play the mixed element) on the next interaction.
this._playAudioElement(this._audioElement)
if (this._audioContext.state !== 'running') {
this._resumeAudioOnGesture()
}
} else {
this._audioElements = new Map()
}
Expand Down Expand Up @@ -66,6 +77,13 @@ CallParticipantsAudioPlayer.prototype = {
this._handleCallParticipantRemovedBound(this._callParticipantCollection, callParticipantModel)
})

if (this._resumeAudioBound) {
AUTOPLAY_RESUME_EVENTS.forEach((event) => {
document.removeEventListener(event, this._resumeAudioBound, { capture: true })
})
this._resumeAudioBound = null
}

if (this._mixAudio) {
this._audioElement.srcObject = null
this._audioContext.close()
Expand Down Expand Up @@ -148,11 +166,57 @@ CallParticipantsAudioPlayer.prototype = {

if (mute) {
audioElement.muted = true
} else {
this._playAudioElement(audioElement)
}

this._audioElements.set(id, audioElement)
},

_playAudioElement(audioElement) {
// Relying on the "autoplay" attribute fails silently when playback is
// blocked, so play explicitly to detect it and retry on a user gesture.
audioElement.play()?.catch((error) => {
if (error.name === 'NotAllowedError') {
this._resumeAudioOnGesture()
}
})
},

_resumeAudioOnGesture() {
if (this._resumeAudioBound) {
return
}

this._resumeAudioBound = () => {
AUTOPLAY_RESUME_EVENTS.forEach((event) => {
document.removeEventListener(event, this._resumeAudioBound, { capture: true })
})
this._resumeAudioBound = null

this._resumeAudio()
}

AUTOPLAY_RESUME_EVENTS.forEach((event) => {
document.addEventListener(event, this._resumeAudioBound, { capture: true, passive: true })
})
},

_resumeAudio() {
if (this._mixAudio) {
if (this._audioContext.state !== 'running') {
this._audioContext.resume().catch(() => {})
}
this._playAudioElement(this._audioElement)
} else {
this._audioElements.forEach((audioElement) => {
if (audioElement.paused && !audioElement.muted) {
this._playAudioElement(audioElement)
}
})
}
},

async setGeneralAudioOutput(deviceId) {
if (!mediaDevicesManager.isAudioOutputSelectSupported) {
console.debug('Your browser does not support audio output selecting')
Expand Down Expand Up @@ -194,6 +258,7 @@ CallParticipantsAudioPlayer.prototype = {
// Force creating a new audio renderer to work around broken
// audio output in Safari after disconnecting a node.
this._audioElement.srcObject = this._audioDestination.stream
this._playAudioElement(this._audioElement)
}

return
Expand All @@ -205,6 +270,9 @@ CallParticipantsAudioPlayer.prototype = {
}

audioElement.muted = !audioAvailable
if (audioAvailable) {
this._playAudioElement(audioElement)
}
},

}
134 changes: 134 additions & 0 deletions src/utils/webrtc/CallParticipantsAudioPlayer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -712,4 +712,138 @@ describe('CallParticipantsAudioPlayer', () => {
expect(callParticipantsAudioPlayer._audioElements.size).toBe(0)
})
})

describe('autoplay recovery', () => {
let playSpy

/**
* Flushes pending microtasks so the "play()" rejection handler runs.
*/
function flushPromises() {
return new Promise((resolve) => setTimeout(resolve))
}

beforeEach(() => {
// jsdom does not implement "play()"; mock it to control its result.
playSpy = vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockResolvedValue()
})

test('plays the element when a stream with available audio is added', () => {
const callParticipantModel = new CallParticipantModelStub('peerId1')
addCallParticipantModel(callParticipantModel)

callParticipantModel.attributes.audioAvailable = true

callParticipantModel.set('stream', new MediaStreamMock('stream1'))

expect(playSpy).toHaveBeenCalledTimes(1)
})

test('plays the screen element, which is always unmuted', () => {
const callParticipantModel = new CallParticipantModelStub('peerId1')
addCallParticipantModel(callParticipantModel)

callParticipantModel.set('screen', new MediaStreamMock('screen1'))

expect(playSpy).toHaveBeenCalledTimes(1)
})

test('does not play a muted stream element', () => {
const callParticipantModel = new CallParticipantModelStub('peerId1')
addCallParticipantModel(callParticipantModel)

callParticipantModel.attributes.audioAvailable = false

callParticipantModel.set('stream', new MediaStreamMock('stream1'))

expect(playSpy).not.toHaveBeenCalled()
})

test('plays the element when audio becomes available', () => {
const callParticipantModel = new CallParticipantModelStub('peerId1')
addCallParticipantModel(callParticipantModel)

callParticipantModel.attributes.audioAvailable = false

callParticipantModel.set('stream', new MediaStreamMock('stream1'))

expect(playSpy).not.toHaveBeenCalled()

callParticipantModel.set('audioAvailable', true)

expect(playSpy).toHaveBeenCalledTimes(1)
})

test('retries playback on a user gesture when autoplay is blocked', async () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
playSpy.mockRejectedValueOnce(new DOMException('blocked', 'NotAllowedError'))

const callParticipantModel = new CallParticipantModelStub('peerId1')
addCallParticipantModel(callParticipantModel)

callParticipantModel.attributes.audioAvailable = true

callParticipantModel.set('stream', new MediaStreamMock('stream1'))

await flushPromises()

expect(playSpy).toHaveBeenCalledTimes(1)
expect(addEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function), expect.objectContaining({ capture: true }))

document.dispatchEvent(new Event('mousedown'))

expect(playSpy).toHaveBeenCalledTimes(2)
})

test('does not arm a gesture listener for other playback errors', async () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
playSpy.mockRejectedValueOnce(new DOMException('boom', 'AbortError'))

const callParticipantModel = new CallParticipantModelStub('peerId1')
addCallParticipantModel(callParticipantModel)

callParticipantModel.attributes.audioAvailable = true

callParticipantModel.set('stream', new MediaStreamMock('stream1'))

await flushPromises()

expect(addEventListenerSpy).not.toHaveBeenCalledWith('mousedown', expect.any(Function), expect.anything())
})

test('arms the gesture listener only once for several blocked elements', async () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
playSpy.mockRejectedValue(new DOMException('blocked', 'NotAllowedError'))

const callParticipantModel = new CallParticipantModelStub('peerId1')
addCallParticipantModel(callParticipantModel)

callParticipantModel.attributes.audioAvailable = true

callParticipantModel.set('stream', new MediaStreamMock('stream1'))
callParticipantModel.set('screen', new MediaStreamMock('screen1'))

await flushPromises()

expect(addEventListenerSpy).toHaveBeenCalledTimes(3)
})

test('removes the gesture listener on destroy', async () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
playSpy.mockRejectedValueOnce(new DOMException('blocked', 'NotAllowedError'))

const callParticipantModel = new CallParticipantModelStub('peerId1')
addCallParticipantModel(callParticipantModel)

callParticipantModel.attributes.audioAvailable = true

callParticipantModel.set('stream', new MediaStreamMock('stream1'))

await flushPromises()

callParticipantsAudioPlayer.destroy()

expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function), expect.objectContaining({ capture: true }))
})
})
})
Loading