Skip to content

Commit 2687145

Browse files
authored
fix: persist audio elements in DOM and forward mic change events [WPB-23195] (#20310)
* fix: persist audio elements in DOM and forward mic change events * fix: add anchor audio HTML elements * fix: Request calling speaker root element * fix: lint issue in AudioSpeakerFactory
1 parent be04bfb commit 2687145

File tree

6 files changed

+131
-9
lines changed

6 files changed

+131
-9
lines changed

apps/webapp/src/script/components/AppContainer/AppContainer.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ export const AppContainer = ({config, clientType}: AppProps) => {
129129

130130
{/* Wrapper which will hold the audio elements for playing e.g. the ringtone. The elements are created within AudioRepository.ts */}
131131
<div id="audio-elements" />
132+
{/* Wrapper which will hold the audio elements for the calling speaker */}
133+
<div id="calling-audio-speaker-elements" />
132134
</>
133135
);
134136
};

apps/webapp/src/script/components/calling/CallingOverlayContainer.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ const CallingContainer = ({
118118
callingRepository.refreshAudioInput();
119119
};
120120

121+
const switchSpeakerOutput = (deviceId: string) => {
122+
setAudioOutputDeviceId(deviceId);
123+
callingRepository.refreshAudioOutput();
124+
};
125+
121126
const sendEmoji = (emoji: string, call: Call) => {
122127
void callingRepository.sendInCallEmoji(emoji, call);
123128
};
@@ -126,10 +131,6 @@ const CallingContainer = ({
126131
void callingRepository.sendInCallHandRaised(isHandUp, call);
127132
};
128133

129-
const switchSpeakerOutput = (deviceId: string) => {
130-
setAudioOutputDeviceId(deviceId);
131-
};
132-
133134
const toggleCamera = (call: Call) => callingRepository.toggleCamera(call);
134135

135136
const toggleMute = (call: Call, muteState: boolean) => callingRepository.muteCall(call, muteState);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
describe('AudioSpeakerFactory', () => {
2+
let mockBaseElement: HTMLElement;
3+
let mockStream: MediaStream;
4+
let playMock: jest.Mock;
5+
let AudioSpeakerFactory: any;
6+
7+
beforeEach(() => {
8+
jest.resetModules();
9+
10+
mockBaseElement = document.createElement('div');
11+
mockBaseElement.id = 'calling-audio-speaker-elements';
12+
document.body.appendChild(mockBaseElement);
13+
14+
mockStream = {} as MediaStream;
15+
16+
playMock = jest.fn().mockResolvedValue(undefined);
17+
18+
(global as any).Audio = jest.fn(() => {
19+
const audio = document.createElement('audio');
20+
21+
Object.defineProperty(audio, 'play', {
22+
value: playMock,
23+
writable: true,
24+
});
25+
26+
return audio;
27+
});
28+
29+
AudioSpeakerFactory = require('./AudioSpeakerFactory').AudioSpeakerFactory;
30+
});
31+
32+
afterEach(() => {
33+
document.body.innerHTML = '';
34+
jest.clearAllMocks();
35+
});
36+
37+
it('creates new audio element and appends it to base element', () => {
38+
const audioElement =
39+
AudioSpeakerFactory.createNewCallingAudioSpeaker(mockStream);
40+
41+
expect(audioElement).toBeDefined();
42+
expect(audioElement.srcObject).toBe(mockStream);
43+
expect(playMock).toHaveBeenCalled();
44+
expect(mockBaseElement.children.length).toBe(1);
45+
});
46+
47+
it('throws error if base element does not exist', () => {
48+
document.body.innerHTML = '';
49+
jest.resetModules();
50+
AudioSpeakerFactory = require('./AudioSpeakerFactory').AudioSpeakerFactory;
51+
52+
expect(() =>
53+
AudioSpeakerFactory.createNewCallingAudioSpeaker(mockStream),
54+
).toThrow('Audio element could not be crated!');
55+
});
56+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Wire
3+
* Copyright (C) 2026 Wire Swiss GmbH
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see http://www.gnu.org/licenses/.
17+
*
18+
*/
19+
20+
import {getLogger, Logger} from 'Util/Logger';
21+
22+
export class AudioSpeakerFactory {
23+
private static readonly logger: Logger = getLogger('AudioSpeakerFactory');
24+
private static baseElement: HTMLElement | null = document.getElementById('calling-audio-speaker-elements');
25+
26+
public static createNewCallingAudioSpeaker(stream: MediaStream): HTMLAudioElement {
27+
AudioSpeakerFactory.initBaseElement();
28+
29+
if (!AudioSpeakerFactory.baseElement) {
30+
AudioSpeakerFactory.logger.error('No audio base element exist in DOM!');
31+
throw new Error('Audio element could not be crated!');
32+
}
33+
34+
AudioSpeakerFactory.logger.log('Add new audio speaker');
35+
const audioElement = new Audio();
36+
audioElement.srcObject = stream;
37+
audioElement.play().catch(error => {
38+
AudioSpeakerFactory.logger.error('Audio play failed', error);
39+
});
40+
AudioSpeakerFactory.baseElement.appendChild(audioElement);
41+
42+
return audioElement;
43+
}
44+
45+
private static initBaseElement(): void {
46+
if (!AudioSpeakerFactory.baseElement) {
47+
AudioSpeakerFactory.baseElement = document.getElementById('calling-audio-speaker-elements');
48+
}
49+
}
50+
}

apps/webapp/src/script/repositories/calling/Call.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ import ko from 'knockout';
2222

2323
import {CALL_TYPE, CONV_TYPE, STATE as CALL_STATE} from '@wireapp/avs';
2424

25+
import {AudioSpeakerFactory} from 'Repositories/calling/AudioSpeakerFactory';
2526
import {Conversation} from 'Repositories/entity/Conversation';
2627
import {CanvasMediaStreamMixer} from 'Repositories/media/CanvasMediaStreamMixer';
2728
import type {MediaDevicesHandler} from 'Repositories/media/MediaDevicesHandler';
2829
import {mediaDevicesStore} from 'Repositories/media/useMediaDevicesStore';
2930
import {chunk, getDifference, partition} from 'Util/ArrayUtil';
31+
import {getLogger, Logger} from 'Util/Logger';
3032
import {matchQualifiedIds} from 'Util/QualifiedId';
3133
import {sortUsersByPriority} from 'Util/StringUtil';
3234

@@ -45,6 +47,7 @@ interface ActiveSpeaker {
4547
}
4648

4749
export class Call {
50+
private readonly logger: Logger = getLogger('Call');
4851
public readonly reason: ko.Observable<number | undefined> = ko.observable();
4952
public readonly startedAt: ko.Observable<number | undefined> = ko.observable();
5053
public readonly endedAt: ko.Observable<number> = ko.observable(0);
@@ -67,7 +70,7 @@ export class Call {
6770
public readonly maximizedParticipant: ko.Observable<Participant | null>;
6871
public readonly isActive: ko.PureComputed<boolean>;
6972
public readonly epochCache = new CallingEpochCache();
70-
private readonly audios: Record<string, {audioElement: HTMLAudioElement; stream: MediaStream}> = {};
73+
private readonly audios: Record<string, {audioElement: HTMLAudioElement | null; stream: MediaStream}> = {};
7174
/**
7275
* set to `true` if anyone has enabled their video during a call (used for analytics)
7376
*/
@@ -158,10 +161,12 @@ export class Call {
158161
audio.audioElement.remove();
159162
audio.audioElement.srcObject = null;
160163
}
161-
const audioElement = new Audio();
162-
audioElement.srcObject = audio.stream;
163-
audioElement.play();
164-
audio.audioElement = audioElement;
164+
165+
try {
166+
audio.audioElement = AudioSpeakerFactory.createNewCallingAudioSpeaker(audio.stream);
167+
} catch (e) {
168+
this.logger.warn('Fail to playAudioStreams:', e);
169+
}
165170
});
166171
this.updateAudioStreamsSink();
167172
}

apps/webapp/src/script/repositories/calling/CallingRepository.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1865,6 +1865,14 @@ export class CallingRepository {
18651865
return stream;
18661866
}
18671867

1868+
public refreshAudioOutput() {
1869+
const activeCall = this.callState.joinedCall();
1870+
if (!activeCall) {
1871+
return;
1872+
}
1873+
activeCall.updateAudioStreamsSink();
1874+
}
1875+
18681876
/**
18691877
* @returns `true` if a media stream has been stopped.
18701878
*/

0 commit comments

Comments
 (0)