Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 07a5a1d

Browse files
authored
Fix device selection in pre-join screen for Element Call video rooms (#9321)
* Fix device selection in pre-join screen for Element Call video rooms As per element-hq/element-call#609 * Update unit test * Lint * Hold a media stream while we enumerate device so we can do so reliably. This means we can remove the device fallback labels. * i18n * Remove unnecessary useState * Fix fetching video devices when video muted * Actually fix preview stream code * Fix unit test now fallback is no longer a thing * Test changing devices
1 parent eaff7e9 commit 07a5a1d

File tree

7 files changed

+123
-66
lines changed

7 files changed

+123
-66
lines changed

src/MediaDeviceHandler.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,20 @@ export default class MediaDeviceHandler extends EventEmitter {
5050
return devices.some(d => Boolean(d.label));
5151
}
5252

53+
/**
54+
* Gets the available audio input/output and video input devices
55+
* from the browser: a thin wrapper around mediaDevices.enumerateDevices()
56+
* that also returns results by type of devices. Note that this requires
57+
* user media permissions and an active stream, otherwise you'll get blank
58+
* device labels.
59+
*
60+
* Once the Permissions API
61+
* (https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API)
62+
* is ready for primetime, it might help make this simpler.
63+
*
64+
* @return Promise<IMediaDevices> The available media devices
65+
*/
5366
public static async getDevices(): Promise<IMediaDevices> {
54-
// Only needed for Electron atm, though should work in modern browsers
55-
// once permission has been granted to the webapp
56-
5767
try {
5868
const devices = await navigator.mediaDevices.enumerateDevices();
5969
const output = {

src/components/views/voip/CallView.tsx

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ interface DeviceButtonProps {
4545
devices: MediaDeviceInfo[];
4646
setDevice: (device: MediaDeviceInfo) => void;
4747
deviceListLabel: string;
48-
fallbackDeviceLabel: (n: number) => string;
4948
muted: boolean;
5049
disabled: boolean;
5150
toggle: () => void;
@@ -54,7 +53,7 @@ interface DeviceButtonProps {
5453
}
5554

5655
const DeviceButton: FC<DeviceButtonProps> = ({
57-
kind, devices, setDevice, deviceListLabel, fallbackDeviceLabel, muted, disabled, toggle, unmutedTitle, mutedTitle,
56+
kind, devices, setDevice, deviceListLabel, muted, disabled, toggle, unmutedTitle, mutedTitle,
5857
}) => {
5958
const [showMenu, buttonRef, openMenu, closeMenu] = useContextMenu();
6059
const selectDevice = useCallback((device: MediaDeviceInfo) => {
@@ -67,10 +66,10 @@ const DeviceButton: FC<DeviceButtonProps> = ({
6766
const buttonRect = buttonRef.current!.getBoundingClientRect();
6867
contextMenu = <IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
6968
<IconizedContextMenuOptionList>
70-
{ devices.map((d, index) =>
69+
{ devices.map((d) =>
7170
<IconizedContextMenuOption
7271
key={d.deviceId}
73-
label={d.label || fallbackDeviceLabel(index + 1)}
72+
label={d.label}
7473
onClick={() => selectDevice(d)}
7574
/>,
7675
) }
@@ -119,26 +118,8 @@ export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
119118
const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
120119
const videoRef = useRef<HTMLVideoElement>(null);
121120

122-
const [audioInputs, videoInputs] = useAsyncMemo(async () => {
123-
try {
124-
const devices = await MediaDeviceHandler.getDevices();
125-
return [devices[MediaDeviceKindEnum.AudioInput], devices[MediaDeviceKindEnum.VideoInput]];
126-
} catch (e) {
127-
logger.warn(`Failed to get media device list`, e);
128-
return [[], []];
129-
}
130-
}, [], [[], []]);
131-
132121
const [videoInputId, setVideoInputId] = useState<string>(() => MediaDeviceHandler.getVideoInput());
133122

134-
const setAudioInput = useCallback((device: MediaDeviceInfo) => {
135-
MediaDeviceHandler.instance.setAudioInput(device.deviceId);
136-
}, []);
137-
const setVideoInput = useCallback((device: MediaDeviceInfo) => {
138-
MediaDeviceHandler.instance.setVideoInput(device.deviceId);
139-
setVideoInputId(device.deviceId);
140-
}, []);
141-
142123
const [audioMuted, setAudioMuted] = useState(() => MediaDeviceHandler.startWithAudioMuted);
143124
const [videoMuted, setVideoMuted] = useState(() => MediaDeviceHandler.startWithVideoMuted);
144125

@@ -151,18 +132,46 @@ export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
151132
setVideoMuted(!videoMuted);
152133
}, [videoMuted, setVideoMuted]);
153134

154-
const videoStream = useAsyncMemo(async () => {
155-
if (videoInputId && !videoMuted) {
156-
try {
157-
return await navigator.mediaDevices.getUserMedia({
158-
video: { deviceId: videoInputId },
159-
});
160-
} catch (e) {
161-
logger.error(`Failed to get stream for device ${videoInputId}`, e);
162-
}
135+
const [videoStream, audioInputs, videoInputs] = useAsyncMemo(async () => {
136+
let previewStream: MediaStream;
137+
try {
138+
// We get the preview stream before requesting devices: this is because
139+
// we need (in some browsers) an active media stream in order to get
140+
// non-blank labels for the devices. According to the docs, we
141+
// need a stream of each type (audio + video) if we want to enumerate
142+
// audio & video devices, although this didn't seem to be the case
143+
// in practice for me. We request both anyway.
144+
// For similar reasons, we also request a stream even if video is muted,
145+
// which could be a bit strange but allows us to get the device list
146+
// reliably. One option could be to try & get devices without a stream,
147+
// then try again with a stream if we get blank deviceids, but... ew.
148+
previewStream = await navigator.mediaDevices.getUserMedia({
149+
video: { deviceId: videoInputId },
150+
audio: { deviceId: MediaDeviceHandler.getAudioInput() },
151+
});
152+
} catch (e) {
153+
logger.error(`Failed to get stream for device ${videoInputId}`, e);
154+
}
155+
156+
const devices = await MediaDeviceHandler.getDevices();
157+
158+
// If video is muted, we don't actually want the stream, so we can get rid of
159+
// it now.
160+
if (videoMuted) {
161+
previewStream.getTracks().forEach(t => t.stop());
162+
previewStream = undefined;
163163
}
164-
return null;
165-
}, [videoInputId, videoMuted]);
164+
165+
return [previewStream, devices[MediaDeviceKindEnum.AudioInput], devices[MediaDeviceKindEnum.VideoInput]];
166+
}, [videoInputId, videoMuted], [null, [], []]);
167+
168+
const setAudioInput = useCallback((device: MediaDeviceInfo) => {
169+
MediaDeviceHandler.instance.setAudioInput(device.deviceId);
170+
}, []);
171+
const setVideoInput = useCallback((device: MediaDeviceInfo) => {
172+
MediaDeviceHandler.instance.setVideoInput(device.deviceId);
173+
setVideoInputId(device.deviceId);
174+
}, []);
166175

167176
useEffect(() => {
168177
if (videoStream) {
@@ -205,7 +214,6 @@ export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
205214
devices={audioInputs}
206215
setDevice={setAudioInput}
207216
deviceListLabel={_t("Audio devices")}
208-
fallbackDeviceLabel={n => _t("Audio input %(n)s", { n })}
209217
muted={audioMuted}
210218
disabled={connecting}
211219
toggle={toggleAudio}
@@ -217,7 +225,6 @@ export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
217225
devices={videoInputs}
218226
setDevice={setVideoInput}
219227
deviceListLabel={_t("Video devices")}
220-
fallbackDeviceLabel={n => _t("Video input %(n)s", { n })}
221228
muted={videoMuted}
222229
disabled={connecting}
223230
toggle={toggleVideo}

src/i18n/strings/en_EN.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,11 +1047,9 @@
10471047
"Hint: Begin your message with <code>//</code> to start it with a slash.": "Hint: Begin your message with <code>//</code> to start it with a slash.",
10481048
"Send as message": "Send as message",
10491049
"Audio devices": "Audio devices",
1050-
"Audio input %(n)s": "Audio input %(n)s",
10511050
"Mute microphone": "Mute microphone",
10521051
"Unmute microphone": "Unmute microphone",
10531052
"Video devices": "Video devices",
1054-
"Video input %(n)s": "Video input %(n)s",
10551053
"Turn off camera": "Turn off camera",
10561054
"Turn on camera": "Turn on camera",
10571055
"Join": "Join",

src/models/Call.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -771,8 +771,8 @@ export class ElementCall extends Call {
771771
): Promise<void> {
772772
try {
773773
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
774-
audioInput: audioInput?.deviceId ?? null,
775-
videoInput: videoInput?.deviceId ?? null,
774+
audioInput: audioInput?.label ?? null,
775+
videoInput: videoInput?.label ?? null,
776776
});
777777
} catch (e) {
778778
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);

test/components/views/voip/CallView-test.tsx

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -187,20 +187,43 @@ describe("CallLobby", () => {
187187
});
188188

189189
describe("device buttons", () => {
190+
const fakeVideoInput1: MediaDeviceInfo = {
191+
deviceId: "v1",
192+
groupId: "v1",
193+
label: "Webcam",
194+
kind: "videoinput",
195+
toJSON: () => {},
196+
};
197+
const fakeVideoInput2: MediaDeviceInfo = {
198+
deviceId: "v2",
199+
groupId: "v2",
200+
label: "Othercam",
201+
kind: "videoinput",
202+
toJSON: () => {},
203+
};
204+
const fakeAudioInput1: MediaDeviceInfo = {
205+
deviceId: "v1",
206+
groupId: "v1",
207+
label: "Headphones",
208+
kind: "audioinput",
209+
toJSON: () => {},
210+
};
211+
const fakeAudioInput2: MediaDeviceInfo = {
212+
deviceId: "v2",
213+
groupId: "v2",
214+
label: "Tailphones",
215+
kind: "audioinput",
216+
toJSON: () => {},
217+
};
218+
190219
it("hide when no devices are available", async () => {
191220
await renderView();
192221
expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null);
193222
expect(screen.queryByRole("button", { name: /camera/ })).toBe(null);
194223
});
195224

196225
it("show without dropdown when only one device is available", async () => {
197-
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([{
198-
deviceId: "1",
199-
groupId: "1",
200-
label: "Webcam",
201-
kind: "videoinput",
202-
toJSON: () => {},
203-
}]);
226+
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeVideoInput1]);
204227

205228
await renderView();
206229
screen.getByRole("button", { name: /camera/ });
@@ -209,27 +232,40 @@ describe("CallLobby", () => {
209232

210233
it("show with dropdown when multiple devices are available", async () => {
211234
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([
212-
{
213-
deviceId: "1",
214-
groupId: "1",
215-
label: "Headphones",
216-
kind: "audioinput",
217-
toJSON: () => {},
218-
},
219-
{
220-
deviceId: "2",
221-
groupId: "1",
222-
label: "", // Should fall back to "Audio input 2"
223-
kind: "audioinput",
224-
toJSON: () => {},
225-
},
235+
fakeAudioInput1, fakeAudioInput2,
226236
]);
227237

228238
await renderView();
229239
screen.getByRole("button", { name: /microphone/ });
230240
fireEvent.click(screen.getByRole("button", { name: "Audio devices" }));
231241
screen.getByRole("menuitem", { name: "Headphones" });
232-
screen.getByRole("menuitem", { name: "Audio input 2" });
242+
screen.getByRole("menuitem", { name: "Tailphones" });
243+
});
244+
245+
it("sets video device when selected", async () => {
246+
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([
247+
fakeVideoInput1, fakeVideoInput2,
248+
]);
249+
250+
await renderView();
251+
screen.getByRole("button", { name: /camera/ });
252+
fireEvent.click(screen.getByRole("button", { name: "Video devices" }));
253+
fireEvent.click(screen.getByRole("menuitem", { name: fakeVideoInput2.label }));
254+
255+
expect(client.getMediaHandler().setVideoInput).toHaveBeenCalledWith(fakeVideoInput2.deviceId);
256+
});
257+
258+
it("sets audio device when selected", async () => {
259+
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([
260+
fakeAudioInput1, fakeAudioInput2,
261+
]);
262+
263+
await renderView();
264+
screen.getByRole("button", { name: /microphone/ });
265+
fireEvent.click(screen.getByRole("button", { name: "Audio devices" }));
266+
fireEvent.click(screen.getByRole("menuitem", { name: fakeAudioInput2.label }));
267+
268+
expect(client.getMediaHandler().setAudioInput).toHaveBeenCalledWith(fakeAudioInput2.deviceId);
233269
});
234270
});
235271
});

test/models/Call-test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -616,8 +616,8 @@ describe("ElementCall", () => {
616616
await call.connect();
617617
expect(call.connectionState).toBe(ConnectionState.Connected);
618618
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
619-
audioInput: "1",
620-
videoInput: "2",
619+
audioInput: "Headphones",
620+
videoInput: "Built-in webcam",
621621
});
622622
});
623623

test/test-utils/test-utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
} from 'matrix-js-sdk/src/matrix';
3535
import { normalize } from "matrix-js-sdk/src/utils";
3636
import { ReEmitter } from "matrix-js-sdk/src/ReEmitter";
37+
import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler";
3738

3839
import { MatrixClientPeg as peg } from '../../src/MatrixClientPeg';
3940
import { makeType } from "../../src/utils/TypeUtils";
@@ -175,6 +176,11 @@ export function createTestClient(): MatrixClient {
175176
sendToDevice: jest.fn().mockResolvedValue(undefined),
176177
queueToDevice: jest.fn().mockResolvedValue(undefined),
177178
encryptAndSendToDevices: jest.fn().mockResolvedValue(undefined),
179+
180+
getMediaHandler: jest.fn().mockReturnValue({
181+
setVideoInput: jest.fn(),
182+
setAudioInput: jest.fn(),
183+
} as unknown as MediaHandler),
178184
} as unknown as MatrixClient;
179185

180186
client.reEmitter = new ReEmitter(client);

0 commit comments

Comments
 (0)