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 https://github.com/vector-im/element-call/pull/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
This commit is contained in:
parent
eaff7e945c
commit
07a5a1dc6f
7 changed files with 123 additions and 66 deletions
|
@ -50,10 +50,20 @@ export default class MediaDeviceHandler extends EventEmitter {
|
||||||
return devices.some(d => Boolean(d.label));
|
return devices.some(d => Boolean(d.label));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the available audio input/output and video input devices
|
||||||
|
* from the browser: a thin wrapper around mediaDevices.enumerateDevices()
|
||||||
|
* that also returns results by type of devices. Note that this requires
|
||||||
|
* user media permissions and an active stream, otherwise you'll get blank
|
||||||
|
* device labels.
|
||||||
|
*
|
||||||
|
* Once the Permissions API
|
||||||
|
* (https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API)
|
||||||
|
* is ready for primetime, it might help make this simpler.
|
||||||
|
*
|
||||||
|
* @return Promise<IMediaDevices> The available media devices
|
||||||
|
*/
|
||||||
public static async getDevices(): Promise<IMediaDevices> {
|
public static async getDevices(): Promise<IMediaDevices> {
|
||||||
// Only needed for Electron atm, though should work in modern browsers
|
|
||||||
// once permission has been granted to the webapp
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
const output = {
|
const output = {
|
||||||
|
|
|
@ -45,7 +45,6 @@ interface DeviceButtonProps {
|
||||||
devices: MediaDeviceInfo[];
|
devices: MediaDeviceInfo[];
|
||||||
setDevice: (device: MediaDeviceInfo) => void;
|
setDevice: (device: MediaDeviceInfo) => void;
|
||||||
deviceListLabel: string;
|
deviceListLabel: string;
|
||||||
fallbackDeviceLabel: (n: number) => string;
|
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
|
@ -54,7 +53,7 @@ interface DeviceButtonProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DeviceButton: FC<DeviceButtonProps> = ({
|
const DeviceButton: FC<DeviceButtonProps> = ({
|
||||||
kind, devices, setDevice, deviceListLabel, fallbackDeviceLabel, muted, disabled, toggle, unmutedTitle, mutedTitle,
|
kind, devices, setDevice, deviceListLabel, muted, disabled, toggle, unmutedTitle, mutedTitle,
|
||||||
}) => {
|
}) => {
|
||||||
const [showMenu, buttonRef, openMenu, closeMenu] = useContextMenu();
|
const [showMenu, buttonRef, openMenu, closeMenu] = useContextMenu();
|
||||||
const selectDevice = useCallback((device: MediaDeviceInfo) => {
|
const selectDevice = useCallback((device: MediaDeviceInfo) => {
|
||||||
|
@ -67,10 +66,10 @@ const DeviceButton: FC<DeviceButtonProps> = ({
|
||||||
const buttonRect = buttonRef.current!.getBoundingClientRect();
|
const buttonRect = buttonRef.current!.getBoundingClientRect();
|
||||||
contextMenu = <IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
|
contextMenu = <IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
|
||||||
<IconizedContextMenuOptionList>
|
<IconizedContextMenuOptionList>
|
||||||
{ devices.map((d, index) =>
|
{ devices.map((d) =>
|
||||||
<IconizedContextMenuOption
|
<IconizedContextMenuOption
|
||||||
key={d.deviceId}
|
key={d.deviceId}
|
||||||
label={d.label || fallbackDeviceLabel(index + 1)}
|
label={d.label}
|
||||||
onClick={() => selectDevice(d)}
|
onClick={() => selectDevice(d)}
|
||||||
/>,
|
/>,
|
||||||
) }
|
) }
|
||||||
|
@ -119,26 +118,8 @@ export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
|
||||||
const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
|
const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
const [audioInputs, videoInputs] = useAsyncMemo(async () => {
|
|
||||||
try {
|
|
||||||
const devices = await MediaDeviceHandler.getDevices();
|
|
||||||
return [devices[MediaDeviceKindEnum.AudioInput], devices[MediaDeviceKindEnum.VideoInput]];
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn(`Failed to get media device list`, e);
|
|
||||||
return [[], []];
|
|
||||||
}
|
|
||||||
}, [], [[], []]);
|
|
||||||
|
|
||||||
const [videoInputId, setVideoInputId] = useState<string>(() => MediaDeviceHandler.getVideoInput());
|
const [videoInputId, setVideoInputId] = useState<string>(() => MediaDeviceHandler.getVideoInput());
|
||||||
|
|
||||||
const setAudioInput = useCallback((device: MediaDeviceInfo) => {
|
|
||||||
MediaDeviceHandler.instance.setAudioInput(device.deviceId);
|
|
||||||
}, []);
|
|
||||||
const setVideoInput = useCallback((device: MediaDeviceInfo) => {
|
|
||||||
MediaDeviceHandler.instance.setVideoInput(device.deviceId);
|
|
||||||
setVideoInputId(device.deviceId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [audioMuted, setAudioMuted] = useState(() => MediaDeviceHandler.startWithAudioMuted);
|
const [audioMuted, setAudioMuted] = useState(() => MediaDeviceHandler.startWithAudioMuted);
|
||||||
const [videoMuted, setVideoMuted] = useState(() => MediaDeviceHandler.startWithVideoMuted);
|
const [videoMuted, setVideoMuted] = useState(() => MediaDeviceHandler.startWithVideoMuted);
|
||||||
|
|
||||||
|
@ -151,18 +132,46 @@ export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
|
||||||
setVideoMuted(!videoMuted);
|
setVideoMuted(!videoMuted);
|
||||||
}, [videoMuted, setVideoMuted]);
|
}, [videoMuted, setVideoMuted]);
|
||||||
|
|
||||||
const videoStream = useAsyncMemo(async () => {
|
const [videoStream, audioInputs, videoInputs] = useAsyncMemo(async () => {
|
||||||
if (videoInputId && !videoMuted) {
|
let previewStream: MediaStream;
|
||||||
try {
|
try {
|
||||||
return await navigator.mediaDevices.getUserMedia({
|
// We get the preview stream before requesting devices: this is because
|
||||||
video: { deviceId: videoInputId },
|
// we need (in some browsers) an active media stream in order to get
|
||||||
});
|
// non-blank labels for the devices. According to the docs, we
|
||||||
} catch (e) {
|
// need a stream of each type (audio + video) if we want to enumerate
|
||||||
logger.error(`Failed to get stream for device ${videoInputId}`, e);
|
// audio & video devices, although this didn't seem to be the case
|
||||||
}
|
// in practice for me. We request both anyway.
|
||||||
|
// For similar reasons, we also request a stream even if video is muted,
|
||||||
|
// which could be a bit strange but allows us to get the device list
|
||||||
|
// reliably. One option could be to try & get devices without a stream,
|
||||||
|
// then try again with a stream if we get blank deviceids, but... ew.
|
||||||
|
previewStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { deviceId: videoInputId },
|
||||||
|
audio: { deviceId: MediaDeviceHandler.getAudioInput() },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to get stream for device ${videoInputId}`, e);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}, [videoInputId, videoMuted]);
|
const devices = await MediaDeviceHandler.getDevices();
|
||||||
|
|
||||||
|
// If video is muted, we don't actually want the stream, so we can get rid of
|
||||||
|
// it now.
|
||||||
|
if (videoMuted) {
|
||||||
|
previewStream.getTracks().forEach(t => t.stop());
|
||||||
|
previewStream = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [previewStream, devices[MediaDeviceKindEnum.AudioInput], devices[MediaDeviceKindEnum.VideoInput]];
|
||||||
|
}, [videoInputId, videoMuted], [null, [], []]);
|
||||||
|
|
||||||
|
const setAudioInput = useCallback((device: MediaDeviceInfo) => {
|
||||||
|
MediaDeviceHandler.instance.setAudioInput(device.deviceId);
|
||||||
|
}, []);
|
||||||
|
const setVideoInput = useCallback((device: MediaDeviceInfo) => {
|
||||||
|
MediaDeviceHandler.instance.setVideoInput(device.deviceId);
|
||||||
|
setVideoInputId(device.deviceId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (videoStream) {
|
if (videoStream) {
|
||||||
|
@ -205,7 +214,6 @@ export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
|
||||||
devices={audioInputs}
|
devices={audioInputs}
|
||||||
setDevice={setAudioInput}
|
setDevice={setAudioInput}
|
||||||
deviceListLabel={_t("Audio devices")}
|
deviceListLabel={_t("Audio devices")}
|
||||||
fallbackDeviceLabel={n => _t("Audio input %(n)s", { n })}
|
|
||||||
muted={audioMuted}
|
muted={audioMuted}
|
||||||
disabled={connecting}
|
disabled={connecting}
|
||||||
toggle={toggleAudio}
|
toggle={toggleAudio}
|
||||||
|
@ -217,7 +225,6 @@ export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
|
||||||
devices={videoInputs}
|
devices={videoInputs}
|
||||||
setDevice={setVideoInput}
|
setDevice={setVideoInput}
|
||||||
deviceListLabel={_t("Video devices")}
|
deviceListLabel={_t("Video devices")}
|
||||||
fallbackDeviceLabel={n => _t("Video input %(n)s", { n })}
|
|
||||||
muted={videoMuted}
|
muted={videoMuted}
|
||||||
disabled={connecting}
|
disabled={connecting}
|
||||||
toggle={toggleVideo}
|
toggle={toggleVideo}
|
||||||
|
|
|
@ -1047,11 +1047,9 @@
|
||||||
"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.",
|
"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.",
|
||||||
"Send as message": "Send as message",
|
"Send as message": "Send as message",
|
||||||
"Audio devices": "Audio devices",
|
"Audio devices": "Audio devices",
|
||||||
"Audio input %(n)s": "Audio input %(n)s",
|
|
||||||
"Mute microphone": "Mute microphone",
|
"Mute microphone": "Mute microphone",
|
||||||
"Unmute microphone": "Unmute microphone",
|
"Unmute microphone": "Unmute microphone",
|
||||||
"Video devices": "Video devices",
|
"Video devices": "Video devices",
|
||||||
"Video input %(n)s": "Video input %(n)s",
|
|
||||||
"Turn off camera": "Turn off camera",
|
"Turn off camera": "Turn off camera",
|
||||||
"Turn on camera": "Turn on camera",
|
"Turn on camera": "Turn on camera",
|
||||||
"Join": "Join",
|
"Join": "Join",
|
||||||
|
|
|
@ -771,8 +771,8 @@ export class ElementCall extends Call {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
|
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
|
||||||
audioInput: audioInput?.deviceId ?? null,
|
audioInput: audioInput?.label ?? null,
|
||||||
videoInput: videoInput?.deviceId ?? null,
|
videoInput: videoInput?.label ?? null,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
|
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
|
||||||
|
|
|
@ -187,6 +187,35 @@ describe("CallLobby", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("device buttons", () => {
|
describe("device buttons", () => {
|
||||||
|
const fakeVideoInput1: MediaDeviceInfo = {
|
||||||
|
deviceId: "v1",
|
||||||
|
groupId: "v1",
|
||||||
|
label: "Webcam",
|
||||||
|
kind: "videoinput",
|
||||||
|
toJSON: () => {},
|
||||||
|
};
|
||||||
|
const fakeVideoInput2: MediaDeviceInfo = {
|
||||||
|
deviceId: "v2",
|
||||||
|
groupId: "v2",
|
||||||
|
label: "Othercam",
|
||||||
|
kind: "videoinput",
|
||||||
|
toJSON: () => {},
|
||||||
|
};
|
||||||
|
const fakeAudioInput1: MediaDeviceInfo = {
|
||||||
|
deviceId: "v1",
|
||||||
|
groupId: "v1",
|
||||||
|
label: "Headphones",
|
||||||
|
kind: "audioinput",
|
||||||
|
toJSON: () => {},
|
||||||
|
};
|
||||||
|
const fakeAudioInput2: MediaDeviceInfo = {
|
||||||
|
deviceId: "v2",
|
||||||
|
groupId: "v2",
|
||||||
|
label: "Tailphones",
|
||||||
|
kind: "audioinput",
|
||||||
|
toJSON: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
it("hide when no devices are available", async () => {
|
it("hide when no devices are available", async () => {
|
||||||
await renderView();
|
await renderView();
|
||||||
expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null);
|
expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null);
|
||||||
|
@ -194,13 +223,7 @@ describe("CallLobby", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("show without dropdown when only one device is available", async () => {
|
it("show without dropdown when only one device is available", async () => {
|
||||||
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([{
|
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeVideoInput1]);
|
||||||
deviceId: "1",
|
|
||||||
groupId: "1",
|
|
||||||
label: "Webcam",
|
|
||||||
kind: "videoinput",
|
|
||||||
toJSON: () => {},
|
|
||||||
}]);
|
|
||||||
|
|
||||||
await renderView();
|
await renderView();
|
||||||
screen.getByRole("button", { name: /camera/ });
|
screen.getByRole("button", { name: /camera/ });
|
||||||
|
@ -209,27 +232,40 @@ describe("CallLobby", () => {
|
||||||
|
|
||||||
it("show with dropdown when multiple devices are available", async () => {
|
it("show with dropdown when multiple devices are available", async () => {
|
||||||
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([
|
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([
|
||||||
{
|
fakeAudioInput1, fakeAudioInput2,
|
||||||
deviceId: "1",
|
|
||||||
groupId: "1",
|
|
||||||
label: "Headphones",
|
|
||||||
kind: "audioinput",
|
|
||||||
toJSON: () => {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
deviceId: "2",
|
|
||||||
groupId: "1",
|
|
||||||
label: "", // Should fall back to "Audio input 2"
|
|
||||||
kind: "audioinput",
|
|
||||||
toJSON: () => {},
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await renderView();
|
await renderView();
|
||||||
screen.getByRole("button", { name: /microphone/ });
|
screen.getByRole("button", { name: /microphone/ });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Audio devices" }));
|
fireEvent.click(screen.getByRole("button", { name: "Audio devices" }));
|
||||||
screen.getByRole("menuitem", { name: "Headphones" });
|
screen.getByRole("menuitem", { name: "Headphones" });
|
||||||
screen.getByRole("menuitem", { name: "Audio input 2" });
|
screen.getByRole("menuitem", { name: "Tailphones" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets video device when selected", async () => {
|
||||||
|
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([
|
||||||
|
fakeVideoInput1, fakeVideoInput2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await renderView();
|
||||||
|
screen.getByRole("button", { name: /camera/ });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Video devices" }));
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: fakeVideoInput2.label }));
|
||||||
|
|
||||||
|
expect(client.getMediaHandler().setVideoInput).toHaveBeenCalledWith(fakeVideoInput2.deviceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets audio device when selected", async () => {
|
||||||
|
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([
|
||||||
|
fakeAudioInput1, fakeAudioInput2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await renderView();
|
||||||
|
screen.getByRole("button", { name: /microphone/ });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Audio devices" }));
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: fakeAudioInput2.label }));
|
||||||
|
|
||||||
|
expect(client.getMediaHandler().setAudioInput).toHaveBeenCalledWith(fakeAudioInput2.deviceId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -616,8 +616,8 @@ describe("ElementCall", () => {
|
||||||
await call.connect();
|
await call.connect();
|
||||||
expect(call.connectionState).toBe(ConnectionState.Connected);
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
||||||
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
|
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
|
||||||
audioInput: "1",
|
audioInput: "Headphones",
|
||||||
videoInput: "2",
|
videoInput: "Built-in webcam",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -34,6 +34,7 @@ import {
|
||||||
} from 'matrix-js-sdk/src/matrix';
|
} from 'matrix-js-sdk/src/matrix';
|
||||||
import { normalize } from "matrix-js-sdk/src/utils";
|
import { normalize } from "matrix-js-sdk/src/utils";
|
||||||
import { ReEmitter } from "matrix-js-sdk/src/ReEmitter";
|
import { ReEmitter } from "matrix-js-sdk/src/ReEmitter";
|
||||||
|
import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler";
|
||||||
|
|
||||||
import { MatrixClientPeg as peg } from '../../src/MatrixClientPeg';
|
import { MatrixClientPeg as peg } from '../../src/MatrixClientPeg';
|
||||||
import { makeType } from "../../src/utils/TypeUtils";
|
import { makeType } from "../../src/utils/TypeUtils";
|
||||||
|
@ -175,6 +176,11 @@ export function createTestClient(): MatrixClient {
|
||||||
sendToDevice: jest.fn().mockResolvedValue(undefined),
|
sendToDevice: jest.fn().mockResolvedValue(undefined),
|
||||||
queueToDevice: jest.fn().mockResolvedValue(undefined),
|
queueToDevice: jest.fn().mockResolvedValue(undefined),
|
||||||
encryptAndSendToDevices: jest.fn().mockResolvedValue(undefined),
|
encryptAndSendToDevices: jest.fn().mockResolvedValue(undefined),
|
||||||
|
|
||||||
|
getMediaHandler: jest.fn().mockReturnValue({
|
||||||
|
setVideoInput: jest.fn(),
|
||||||
|
setAudioInput: jest.fn(),
|
||||||
|
} as unknown as MediaHandler),
|
||||||
} as unknown as MatrixClient;
|
} as unknown as MatrixClient;
|
||||||
|
|
||||||
client.reEmitter = new ReEmitter(client);
|
client.reEmitter = new ReEmitter(client);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue