From d32fdb9cbdce92ce38ea0382b4b348d74c4184e9 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Sat, 23 Nov 2024 08:43:53 +0000 Subject: [PATCH] Mute state improvements including muting if skipLobby=true in SPA If skipLobby=true then always default to muted. Use the mute states from the config file. Refactor logic so that defaults are handled by useMuteStates(). Test cases. --- docs/url-params.md | 2 +- src/livekit/MediaDevicesContext.tsx | 2 +- src/room/GroupCallView.tsx | 22 ++-- src/room/MuteStates.test.tsx | 180 ++++++++++++++++++++++++++++ src/room/MuteStates.ts | 12 +- 5 files changed, 198 insertions(+), 20 deletions(-) create mode 100644 src/room/MuteStates.test.tsx diff --git a/docs/url-params.md b/docs/url-params.md index 6ff5a4c9..2d366f59 100644 --- a/docs/url-params.md +++ b/docs/url-params.md @@ -56,7 +56,7 @@ There are two formats for Element Call urls. | `returnToLobby` | `true` or `false` | No, defaults to `false` | Not applicable | Displays the lobby in widget mode after leaving a call; shows a blank page if set to `false`. Useful for video rooms. | | `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. | | `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. | -| `skipLobby` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. | +| `skipLobby` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. | | `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. | | `userId` | [Matrix User Identifier](https://spec.matrix.org/v1.12/appendices/#user-identifiers) | Yes | Not applicable | The Matrix user ID. | | `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. | diff --git a/src/livekit/MediaDevicesContext.tsx b/src/livekit/MediaDevicesContext.tsx index d405eec0..4977f021 100644 --- a/src/livekit/MediaDevicesContext.tsx +++ b/src/livekit/MediaDevicesContext.tsx @@ -128,7 +128,7 @@ const devicesStub: MediaDevices = { stopUsingDeviceNames: () => {}, }; -const MediaDevicesContext = createContext(devicesStub); +export const MediaDevicesContext = createContext(devicesStub); interface Props { children: JSX.Element; diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index cc4fea07..0fdf2705 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -135,44 +135,41 @@ export const GroupCallView: FC = ({ latestMuteStates.current = muteStates; useEffect(() => { - const defaultDeviceSetup = async ( - requestedDeviceData: JoinCallData, - ): Promise => { + const defaultDeviceSetup = async ({ + audioInput, + videoInput, + }: JoinCallData): Promise => { // XXX: I think this is broken currently - LiveKit *won't* request // permissions and give you device names unless you specify a kind, but // here we want all kinds of devices. This needs a fix in livekit-client // for the following name-matching logic to do anything useful. const devices = await Room.getLocalDevices(undefined, true); - const { audioInput, videoInput } = requestedDeviceData; - if (audioInput === null) { - latestMuteStates.current!.audio.setEnabled?.(false); - } else { + + if (audioInput) { const deviceId = findDeviceByName(audioInput, "audioinput", devices); if (!deviceId) { logger.warn("Unknown audio input: " + audioInput); + // override the default mute state latestMuteStates.current!.audio.setEnabled?.(false); } else { logger.debug( `Found audio input ID ${deviceId} for name ${audioInput}`, ); latestDevices.current!.audioInput.select(deviceId); - latestMuteStates.current!.audio.setEnabled?.(true); } } - if (videoInput === null) { - latestMuteStates.current!.video.setEnabled?.(false); - } else { + if (videoInput) { const deviceId = findDeviceByName(videoInput, "videoinput", devices); if (!deviceId) { logger.warn("Unknown video input: " + videoInput); + // override the default mute state latestMuteStates.current!.video.setEnabled?.(false); } else { logger.debug( `Found video input ID ${deviceId} for name ${videoInput}`, ); latestDevices.current!.videoInput.select(deviceId); - latestMuteStates.current!.video.setEnabled?.(true); } } }; @@ -199,7 +196,6 @@ export const GroupCallView: FC = ({ } else { // No lobby and no preload: we enter the rtc session right away (async (): Promise => { - await defaultDeviceSetup({ audioInput: null, videoInput: null }); await enterRTCSession(rtcSession, perParticipantE2EE); })().catch((e) => { logger.error("Error joining RTC session", e); diff --git a/src/room/MuteStates.test.tsx b/src/room/MuteStates.test.tsx new file mode 100644 index 00000000..efeff5ba --- /dev/null +++ b/src/room/MuteStates.test.tsx @@ -0,0 +1,180 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import React, { ReactNode } from "react"; +import { beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; + +import { Config } from "../config/Config"; +import { DEFAULT_CONFIG } from "../config/ConfigOptions"; +import { useMuteStates } from "./MuteStates"; +import { + MediaDevice, + MediaDevices, + MediaDevicesContext, +} from "../livekit/MediaDevicesContext"; + +function TestComponent(): ReactNode { + const muteStates = useMuteStates(); + return ( +
+
+ {muteStates.audio.enabled.toString()} +
+
+ {muteStates.video.enabled.toString()} +
+
+ ); +} + +const mockMicrophone: MediaDeviceInfo = { + deviceId: "", + kind: "audioinput", + label: "", + groupId: "", + toJSON() { + return {}; + }, +}; + +const mockSpeaker: MediaDeviceInfo = { + deviceId: "", + kind: "audiooutput", + label: "", + groupId: "", + toJSON() { + return {}; + }, +}; + +const mockCamera: MediaDeviceInfo = { + deviceId: "", + kind: "videoinput", + label: "", + groupId: "", + toJSON() { + return {}; + }, +}; + +function mockDevices(available: MediaDeviceInfo[]): MediaDevice { + return { + available, + selectedId: "", + select: (): void => {}, + }; +} + +function mockMediaDevices( + { + microphone, + speaker, + camera, + }: { + microphone?: boolean; + speaker?: boolean; + camera?: boolean; + } = { microphone: true, speaker: true, camera: true }, +): MediaDevices { + return { + audioInput: mockDevices(microphone ? [mockMicrophone] : []), + audioOutput: mockDevices(speaker ? [mockSpeaker] : []), + videoInput: mockDevices(camera ? [mockCamera] : []), + startUsingDeviceNames: (): void => {}, + stopUsingDeviceNames: (): void => {}, + }; +} + +describe("useMuteStates", () => { + beforeEach(() => { + vi.spyOn(React, "useContext").mockReturnValue({}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(() => { + vi.clearAllMocks(); + }); + + it("disabled when no input devices", () => { + vi.spyOn(Config, "get").mockReturnValue({ + ...DEFAULT_CONFIG, + }); + + render( + + + + + , + ); + expect(screen.getByTestId("audio-enabled").textContent).toBe("false"); + expect(screen.getByTestId("video-enabled").textContent).toBe("false"); + }); + + it("should be enabled by default", () => { + vi.spyOn(Config, "get").mockReturnValue({ + ...DEFAULT_CONFIG, + }); + + render( + + + + + , + ); + expect(screen.getByTestId("audio-enabled").textContent).toBe("true"); + expect(screen.getByTestId("video-enabled").textContent).toBe("true"); + }); + + it("uses defaults from config", () => { + vi.spyOn(Config, "get").mockReturnValue({ + ...DEFAULT_CONFIG, + media_devices: { + enable_audio: false, + enable_video: false, + }, + }); + + render( + + + + + , + ); + expect(screen.getByTestId("audio-enabled").textContent).toBe("false"); + expect(screen.getByTestId("video-enabled").textContent).toBe("false"); + }); + + it("skipLobby mutes inputs", () => { + vi.spyOn(Config, "get").mockReturnValue({ + ...DEFAULT_CONFIG, + }); + + render( + + + + + , + ); + expect(screen.getByTestId("audio-enabled").textContent).toBe("false"); + expect(screen.getByTestId("video-enabled").textContent).toBe("false"); + }); +}); diff --git a/src/room/MuteStates.ts b/src/room/MuteStates.ts index 261be59e..1452c250 100644 --- a/src/room/MuteStates.ts +++ b/src/room/MuteStates.ts @@ -19,6 +19,7 @@ import { MediaDevice, useMediaDevices } from "../livekit/MediaDevicesContext"; import { useReactiveState } from "../useReactiveState"; import { ElementWidgetActions, widget } from "../widget"; import { Config } from "../config/Config"; +import { useUrlParams } from "../UrlParams"; /** * If there already are this many participants in the call, we automatically mute @@ -72,13 +73,14 @@ function useMuteState( export function useMuteStates(): MuteStates { const devices = useMediaDevices(); - const audio = useMuteState( - devices.audioInput, - () => Config.get().media_devices.enable_audio, - ); + const { skipLobby } = useUrlParams(); + + const audio = useMuteState(devices.audioInput, () => { + return Config.get().media_devices.enable_audio && !skipLobby; + }); const video = useMuteState( devices.videoInput, - () => Config.get().media_devices.enable_video, + () => Config.get().media_devices.enable_video && !skipLobby, ); useEffect(() => {