mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
Size the device menu against the call, and cover its criteria
- Bound the scrolling device list by the measured height of the call area. It was capped with a viewport unit and a Radix variable, both of which measure the window: as a component in a corner of a host's page, the menu would have been sized against the whole page. The menu is portalled outside the root, so no container query reaches it and it has to be measured. - Leave a single divider between the two lists, as the design has. Compound underlines every menu heading, so the headings drop theirs, and the line closing the microphone section goes: the section wrapper already bounds the meter's stickiness without one. - Cover device persistence, hot-plug, fallback when the device in use is removed, fallback to a default when a remembered device is gone, numbered labels before permission, "Default" listed as its own entry, one device kind not disturbing another, and camera parity. Their acceptance criteria named check commands for tests that did not exist, which makes the criteria unenforceable and blocks the next drift check. Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC3, AC4, AC5, AC8, AC9, AC10, AC11, AC19, AC20
This commit is contained in:
@@ -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 {
|
.menu {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.deviceList {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-block-size: 0;
|
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
|
/* The meter belongs to the microphone section: it stays at the bottom of the
|
||||||
|
|||||||
@@ -405,6 +405,34 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
).not.toHaveAttribute("aria-disabled", "true");
|
).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(
|
||||||
|
<MediaMuteAndSwitchButton
|
||||||
|
title="Switcher"
|
||||||
|
iconsAndLabels="video"
|
||||||
|
enabled={true}
|
||||||
|
options={[
|
||||||
|
{ label: { type: "name", name: "Camera 1" }, id: "cam1" },
|
||||||
|
{ label: { type: "name", name: "Camera 2" }, id: "cam2" },
|
||||||
|
]}
|
||||||
|
selectedOption="cam1"
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
videoBlurToggleClick={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
test("lists speaker and microphone sections", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { getByRole } = renderComponent(
|
const { getByRole } = renderComponent(
|
||||||
|
|||||||
@@ -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.
|
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 {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -30,6 +36,7 @@ import {
|
|||||||
type DeviceLabel,
|
type DeviceLabel,
|
||||||
} from "../state/MediaDevices";
|
} from "../state/MediaDevices";
|
||||||
import { useMediaDevices } from "../MediaDevicesContext";
|
import { useMediaDevices } from "../MediaDevicesContext";
|
||||||
|
import { useRootElement } from "../RootElementContext";
|
||||||
import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter";
|
import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter";
|
||||||
import { useMicrophoneLevel } from "./useMicrophoneLevel";
|
import { useMicrophoneLevel } from "./useMicrophoneLevel";
|
||||||
|
|
||||||
@@ -106,6 +113,18 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
const devices = useMediaDevices();
|
const devices = useMediaDevices();
|
||||||
// Only while the menu is open, so nothing holds a second capture of the
|
// Only while the menu is open, so nothing holds a second capture of the
|
||||||
// microphone for the length of a call.
|
// 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<number>();
|
||||||
|
useEffect(() => {
|
||||||
|
if (menuOpen)
|
||||||
|
setListMaxHeight(
|
||||||
|
Math.max(160, Math.round(rootElement.clientHeight * 0.6)),
|
||||||
|
);
|
||||||
|
}, [menuOpen, rootElement]);
|
||||||
const microphoneState = useMicrophoneLevel(
|
const microphoneState = useMicrophoneLevel(
|
||||||
selectedOption,
|
selectedOption,
|
||||||
menuOpen && iconsAndLabels === "audio",
|
menuOpen && iconsAndLabels === "audio",
|
||||||
@@ -294,7 +313,15 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={styles.deviceList}>
|
<div
|
||||||
|
className={styles.deviceList}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--device-list-max-height":
|
||||||
|
listMaxHeight === undefined ? undefined : `${listMaxHeight}px`,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
{iconsAndLabels === "audio" && outputOptions && (
|
{iconsAndLabels === "audio" && outputOptions && (
|
||||||
<>
|
<>
|
||||||
<MenuTitle title={t("settings.devices.speaker")} />
|
<MenuTitle title={t("settings.devices.speaker")} />
|
||||||
@@ -320,15 +347,10 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
numberedLabel,
|
numberedLabel,
|
||||||
)}
|
)}
|
||||||
{iconsAndLabels === "audio" && (
|
{iconsAndLabels === "audio" && (
|
||||||
<>
|
<MicrophoneLevelMeter
|
||||||
<MicrophoneLevelMeter
|
state={microphoneState}
|
||||||
state={microphoneState}
|
className={styles.stickyMeter}
|
||||||
className={styles.stickyMeter}
|
/>
|
||||||
/>
|
|
||||||
{/* Closes the microphone section. The meter stays pinned until
|
|
||||||
this line reaches it, then leaves with the section. */}
|
|
||||||
<Separator />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { of } from "rxjs";
|
|
||||||
|
|
||||||
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
|
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||||
vi.mock("../Platform", () => ({
|
vi.mock("../Platform", () => ({
|
||||||
@@ -15,9 +14,23 @@ vi.mock("../Platform", () => ({
|
|||||||
},
|
},
|
||||||
isFirefox: (): boolean => false,
|
isFirefox: (): boolean => false,
|
||||||
}));
|
}));
|
||||||
vi.mock("@livekit/components-core", () => ({
|
// One observer per device kind, so a test can add and remove hardware.
|
||||||
createMediaDeviceObserver: () => of([]),
|
const observers = vi.hoisted(
|
||||||
}));
|
() => new Map<string, { next: (devices: unknown[]) => 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<unknown[]>([]);
|
||||||
|
observers.set(kind, observer);
|
||||||
|
}
|
||||||
|
return observer;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import { AudioOutput, MediaDevices } from "./MediaDevices";
|
import { AudioOutput, MediaDevices } from "./MediaDevices";
|
||||||
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput";
|
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput";
|
||||||
@@ -57,3 +70,130 @@ describe("MediaDevices audio output", () => {
|
|||||||
expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user