From 15aed8361033a3a67d1a872bece82859789fd6a5 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Thu, 23 Jul 2026 14:26:02 +0200 Subject: [PATCH] fix(call): recover call audio when autoplay is blocked - Remote audio elements relied only on the "autoplay" attribute and never called play(), so when the browser blocked autoplay (e.g. a first-time guest with no media-engagement score) the participant heard no one and no error surfaced. - Play elements explicitly and, on NotAllowedError, retry playback on the next user gesture. In mixAudio mode this also resumes the AudioContext, which starts suspended on Safari until a user gesture. Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Maksim Sukharev --- .../webrtc/CallParticipantsAudioPlayer.js | 68 +++++++++ .../CallParticipantsAudioPlayer.spec.js | 134 ++++++++++++++++++ 2 files changed, 202 insertions(+) diff --git a/src/utils/webrtc/CallParticipantsAudioPlayer.js b/src/utils/webrtc/CallParticipantsAudioPlayer.js index 7e80ff301f3..ab0901778ef 100644 --- a/src/utils/webrtc/CallParticipantsAudioPlayer.js +++ b/src/utils/webrtc/CallParticipantsAudioPlayer.js @@ -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. * @@ -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() } @@ -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() @@ -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') @@ -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 @@ -205,6 +270,9 @@ CallParticipantsAudioPlayer.prototype = { } audioElement.muted = !audioAvailable + if (audioAvailable) { + this._playAudioElement(audioElement) + } }, } diff --git a/src/utils/webrtc/CallParticipantsAudioPlayer.spec.js b/src/utils/webrtc/CallParticipantsAudioPlayer.spec.js index b2b0e5cf817..92ccc349ff3 100644 --- a/src/utils/webrtc/CallParticipantsAudioPlayer.spec.js +++ b/src/utils/webrtc/CallParticipantsAudioPlayer.spec.js @@ -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 })) + }) + }) })