diff --git a/src/components/MediaMuteAndSwitchButton.module.css b/src/components/MediaMuteAndSwitchButton.module.css index 4c266f122..128fdd217 100644 --- a/src/components/MediaMuteAndSwitchButton.module.css +++ b/src/components/MediaMuteAndSwitchButton.module.css @@ -36,19 +36,27 @@ Please see LICENSE in the repository root for full details. } } -/* The menu is portalled outside the call root, so it cannot be sized against - the app's container. Radix measures the space it actually has and publishes - it here, which is neither a viewport unit nor a guessed pixel height. */ .menu { display: flex; flex-direction: column; - max-block-size: var(--radix-dropdown-menu-content-available-height); } -/* Only the device lists scroll; the level meter stays put beneath them. */ +/* Compound underlines every menu heading. The design has a single line, the + one dividing the speaker list from the microphone list, so the headings + carry none and that divider is drawn explicitly. */ +.menu h3 { + border-block-end: none; + padding-block-end: var(--cpd-space-2x); +} + +/* Only the device lists scroll; the level meter stays put beneath them. + The bound comes from the measured height of the call area, set by the + component: the menu is portalled outside the root, so neither a container + query nor a viewport unit describes the space it is allowed to fill. */ .deviceList { overflow-y: auto; min-block-size: 0; + max-block-size: var(--device-list-max-height); } /* The meter belongs to the microphone section: it stays at the bottom of the diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx index 09546c619..77219b85a 100644 --- a/src/components/MediaMuteAndSwitchButton.test.tsx +++ b/src/components/MediaMuteAndSwitchButton.test.tsx @@ -405,6 +405,34 @@ describe("MediaMuteAndSwitchButton", () => { ).not.toHaveAttribute("aria-disabled", "true"); }); + test("camera menu uses the same selection pattern and keeps the blur toggle", async () => { + const user = userEvent.setup(); + const { getByRole } = renderComponent( + , + ); + + await user.click(getByRole("button", { name: "Camera" })); + + // Same selection pattern as the microphone menu. + screen.getByRole("menuitemradio", { name: "Camera 1", checked: true }); + screen.getByRole("menuitemradio", { name: "Camera 2", checked: false }); + // And background blur is still reachable from here. + expect( + screen.getByRole("menuitemcheckbox", { name: "Blur background" }), + ).toBeInTheDocument(); + }); + test("lists speaker and microphone sections", async () => { const user = userEvent.setup(); const { getByRole } = renderComponent( diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index a078a8428..6c44bb931 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { useState, type FC, useEffect, type ReactElement } from "react"; +import { + useState, + type CSSProperties, + type FC, + useEffect, + type ReactElement, +} from "react"; import { Button, Menu, @@ -30,6 +36,7 @@ import { type DeviceLabel, } from "../state/MediaDevices"; import { useMediaDevices } from "../MediaDevicesContext"; +import { useRootElement } from "../RootElementContext"; import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter"; import { useMicrophoneLevel } from "./useMicrophoneLevel"; @@ -106,6 +113,18 @@ export const MediaMuteAndSwitchButton: FC = ({ const devices = useMediaDevices(); // Only while the menu is open, so nothing holds a second capture of the // microphone for the length of a call. + // The menu is portalled outside the call root, so nothing in the stylesheets + // can size it against the call. Measure the call area rather than the window, + // or the menu is wrong wherever Element Call is not the whole page. Measured + // when the menu opens: it is short-lived enough not to need watching. + const rootElement = useRootElement(); + const [listMaxHeight, setListMaxHeight] = useState(); + useEffect(() => { + if (menuOpen) + setListMaxHeight( + Math.max(160, Math.round(rootElement.clientHeight * 0.6)), + ); + }, [menuOpen, rootElement]); const microphoneState = useMicrophoneLevel( selectedOption, menuOpen && iconsAndLabels === "audio", @@ -294,7 +313,15 @@ export const MediaMuteAndSwitchButton: FC = ({ /> } > -
+
{iconsAndLabels === "audio" && outputOptions && ( <> @@ -320,15 +347,10 @@ export const MediaMuteAndSwitchButton: FC = ({ numberedLabel, )} {iconsAndLabels === "audio" && ( - <> - - {/* Closes the microphone section. The meter stays pinned until - this line reaches it, then leaves with the section. */} - - + )}
diff --git a/src/state/MediaDevices.test.ts b/src/state/MediaDevices.test.ts index f65b024e2..dfe59e98d 100644 --- a/src/state/MediaDevices.test.ts +++ b/src/state/MediaDevices.test.ts @@ -5,8 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { describe, expect, test, vi } from "vitest"; -import { of } from "rxjs"; +import { afterEach, describe, expect, test, vi } from "vitest"; const getPlatform = vi.hoisted(() => vi.fn(() => "desktop")); vi.mock("../Platform", () => ({ @@ -15,9 +14,23 @@ vi.mock("../Platform", () => ({ }, isFirefox: (): boolean => false, })); -vi.mock("@livekit/components-core", () => ({ - createMediaDeviceObserver: () => of([]), -})); +// One observer per device kind, so a test can add and remove hardware. +const observers = vi.hoisted( + () => new Map void }>(), +); +vi.mock("@livekit/components-core", async () => { + const { BehaviorSubject: Subject } = await import("rxjs"); + return { + createMediaDeviceObserver: (kind: string) => { + let observer = observers.get(kind); + if (observer === undefined) { + observer = new Subject([]); + observers.set(kind, observer); + } + return observer; + }, + }; +}); import { AudioOutput, MediaDevices } from "./MediaDevices"; import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput"; @@ -57,3 +70,130 @@ describe("MediaDevices audio output", () => { expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput); }); }); + +function device(deviceId: string, label: string, groupId = deviceId): object { + return { deviceId, label, groupId, kind: "audioinput" }; +} + +/** Replaces the hardware of one kind, as the browser would report it. */ +function setDevices(kind: string, devices: object[]): void { + const observer = observers.get(kind); + if (observer === undefined) throw new Error(`nothing observing ${kind}`); + observer.next(devices); +} + +function newMediaDevices(): MediaDevices { + return new MediaDevices(new ObservableScope(), { + controlledAudioDevices: false, + }); +} + +describe("MediaDevices selection", () => { + afterEach(() => { + localStorage.clear(); + for (const kind of observers.keys()) setDevices(kind, []); + }); + + test("persists the selected device across sessions", () => { + const devices = newMediaDevices(); + setDevices("audioinput", [ + device("mic1", "Microphone 1"), + device("mic2", "Microphone 2"), + ]); + + devices.audioInput.select("mic2"); + + // A later call on the same machine reads the same stored preference. + expect(newMediaDevices().audioInput.selected$.value?.id).toBe("mic2"); + }); + + test("updates the available devices when hardware changes", () => { + const devices = newMediaDevices(); + setDevices("audioinput", [device("mic1", "Microphone 1")]); + expect([...devices.audioInput.available$.value.keys()]).toEqual(["mic1"]); + + // A headset is plugged in. + setDevices("audioinput", [ + device("mic1", "Microphone 1"), + device("mic2", "Headset"), + ]); + expect([...devices.audioInput.available$.value.keys()]).toEqual([ + "mic1", + "mic2", + ]); + + // And unplugged again. + setDevices("audioinput", [device("mic1", "Microphone 1")]); + expect([...devices.audioInput.available$.value.keys()]).toEqual(["mic1"]); + }); + + test("falls back to the default device when the selected device disappears", () => { + const devices = newMediaDevices(); + setDevices("audioinput", [ + device("mic1", "Microphone 1"), + device("mic2", "Headset"), + ]); + devices.audioInput.select("mic2"); + expect(devices.audioInput.selected$.value?.id).toBe("mic2"); + + // The headset is unplugged mid-call. + setDevices("audioinput", [device("mic1", "Microphone 1")]); + + expect(devices.audioInput.selected$.value?.id).toBe("mic1"); + }); + + test("falls back when the remembered device is absent", () => { + const devices = newMediaDevices(); + setDevices("audioinput", [device("mic1", "Microphone 1")]); + // Remembered from a previous call, on hardware that is not here now. + devices.audioInput.select("a-device-from-last-time"); + + expect(devices.audioInput.selected$.value?.id).toBe("mic1"); + }); + + test("falls back to numbered labels when labels are unavailable", () => { + const devices = newMediaDevices(); + // The browser withholds names until permission has been granted. + setDevices("audioinput", [device("mic1", ""), device("mic2", "")]); + + expect([...devices.audioInput.available$.value.values()]).toEqual([ + { type: "number", number: 1 }, + { type: "number", number: 2 }, + ]); + }); + + test("lists Default as a distinct entry", () => { + const devices = newMediaDevices(); + setDevices("audiooutput", [device("spk1", "Speakers")]); + + const available = devices.audioOutput.available$.value; + // Default follows the operating system and re-points when it changes, so + // it is its own choice rather than an alias for the device it resolves to. + expect(available.get("spk1")).toEqual({ type: "name", name: "Speakers" }); + expect(available.get("")).toEqual({ type: "default", name: "Speakers" }); + }); + + test("selecting one device kind leaves the others unchanged", () => { + const devices = newMediaDevices(); + setDevices("audioinput", [ + device("mic1", "Microphone 1"), + device("mic2", "Headset"), + ]); + setDevices("audiooutput", [ + device("spk1", "Speakers"), + device("spk2", "Headset"), + ]); + setDevices("videoinput", [device("cam1", "Camera 1")]); + + devices.audioOutput.select("spk2"); + const audioInputBefore = devices.audioInput.selected$.value?.id; + const videoInputBefore = devices.videoInput.selected$.value?.id; + + devices.audioInput.select("mic2"); + + expect(devices.audioOutput.selected$.value?.id).toBe("spk2"); + expect(devices.videoInput.selected$.value?.id).toBe(videoInputBefore); + expect(audioInputBefore).not.toBe("mic2"); + expect(devices.audioInput.selected$.value?.id).toBe("mic2"); + }); +});