diff --git a/locales/en/app.json b/locales/en/app.json index f3d568bb8..a852e6505 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -163,6 +163,12 @@ "login_auth_links_prompt": "Not registered yet?", "login_subheading": "To continue to Element", "login_title": "Login", + "microphone_level": { + "label": "Microphone level", + "no_device": "No microphone found. Connect one, then try again.", + "permission_denied": "Element Call cannot use your microphone. Allow access in your browser settings.", + "value": "{{level}} of {{max}}" + }, "microphone_off": "Microphone off", "microphone_on": "Microphone on", "mute_microphone_button_label": "Mute microphone", @@ -215,11 +221,14 @@ "activating": "Activating…", "camera": "Camera", "camera_numbered": "Camera {{n}}", + "camera_source": "Camera Source", "change_device_button": "Change audio device", "default": "Default", "default_named": "Default <2>({{name}})", + "default_named_plain": "Default ({{name}})", "handset": "Handset", "loudspeaker": "Loudspeaker", + "mic_source": "Mic Source", "microphone": "Microphone", "microphone_numbered": "Microphone {{n}}", "speaker": "Speaker", diff --git a/src/components/MicrophoneLevelMeter.module.css b/src/components/MicrophoneLevelMeter.module.css new file mode 100644 index 000000000..25444da4f --- /dev/null +++ b/src/components/MicrophoneLevelMeter.module.css @@ -0,0 +1,87 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +.meter { + /* The icon column, the same width as a device row's, so the bars start where + the device names start. */ + --meter-icon-size: 24px; + + /* Compound's radio control, which the icon has to share a centre line with. */ + --device-control-size: 20px; + + display: flex; + align-items: center; + gap: var(--cpd-space-3x); + padding-block: var(--cpd-space-2x); + /* The icon shares a centre with the radio controls in the device rows above, + which sit one 4x padding in from the menu's edge. Two things pull this row + out of line with them, and both come off the padding: the row is inset by a + border width so that the menu's frame stays visible behind it (see + `.stickyMeter`), and the icon is wider than a radio control, so it starts + half that difference further left for the two to share a centre. Both are + measured in the MeterAlignsWithTheDeviceRows story rather than trusted: + jsdom lays nothing out, so only a real browser can hold this. */ + padding-inline-start: calc( + var(--cpd-space-4x) - var(--cpd-border-width-1) - + (var(--meter-icon-size) - var(--device-control-size)) / 2 + ); + padding-inline-end: calc(var(--cpd-space-4x) * 2 + var(--cpd-space-2x)); +} + +.icon { + color: var(--cpd-color-icon-secondary); + flex-shrink: 0; + inline-size: var(--meter-icon-size); + block-size: var(--meter-icon-size); +} + +.segments { + /* A bar and the space beside it are always the same size; what changes with + the width available is how many bars there are. Spreading a fixed number of + bars instead would make the meter a different shape in every place it is + used, and close them up into one solid block wherever the space ran short — + and a meter whose bars touch stops reading as a count, which is what + carries the level for anyone who cannot rely on the colour. + + Both sizes are set here and nowhere else: the component reads them back off + the rendered bars to work out how many fit. */ + flex: 1; + /* The width decides the count, never the other way round. Containment is + what enforces that: without it the bars are both a floor under the width + and the widest thing in the menu, so the meter would set the menu's width + and the device names — which are what a person is reading — would have to + fit around it. */ + contain: inline-size; + min-inline-size: 0; + display: flex; + align-items: center; + /* Half again as wide as a bar, as the design has it: bars set as close as + their own width read as a solid block long before they touch. */ + gap: var(--cpd-space-1-5x); +} + +.segment { + flex: none; + inline-size: var(--cpd-space-1x); + block-size: var(--cpd-space-4x); + border-radius: var(--cpd-space-1x); + background: var(--cpd-color-bg-subtle-primary); +} + +/* Filled segments carry the level by count as well as by colour, so the meter + stays readable without relying on hue. */ +.segmentLit { + background: var(--cpd-color-bg-accent-rest); +} + +.message { + color: var(--cpd-color-text-secondary); + /* A paragraph brings a margin below it, which in a centred row does not push + the text down but lifts it: the margin box is what gets centred, so the + words end up above the middle and the icon beside them looks low. */ + margin-block: 0; +} diff --git a/src/components/MicrophoneLevelMeter.test.tsx b/src/components/MicrophoneLevelMeter.test.tsx new file mode 100644 index 000000000..cd7a2ceb6 --- /dev/null +++ b/src/components/MicrophoneLevelMeter.test.tsx @@ -0,0 +1,40 @@ +/* +Copyright 2026 Element Creations Ltd. + +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 } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter"; +import { LEVEL_SCALE } from "../state/MicrophoneLevel"; + +describe("MicrophoneLevelMeter", () => { + test("announces the level rather than relying on hue", () => { + render(); + + const meter = screen.getByRole("meter", { name: "Microphone level" }); + expect(meter).toHaveAttribute("aria-valuenow", "6"); + expect(meter).toHaveAttribute("aria-valuemax", String(LEVEL_SCALE)); + expect(meter).toHaveAttribute("aria-valuetext", `6 of ${LEVEL_SCALE}`); + }); + + test("shows distinct messages for denied permission and no input device", () => { + const denied = render( + , + ); + // A next action, not a flat meter that reads as silence. + expect( + denied.getByText(/Allow access in your browser settings/), + ).toBeInTheDocument(); + expect(denied.queryByRole("meter")).toBeNull(); + + const missing = render( + , + ); + expect(missing.getByText(/No microphone found/)).toBeInTheDocument(); + expect(missing.queryByRole("meter")).toBeNull(); + }); +}); diff --git a/src/components/MicrophoneLevelMeter.tsx b/src/components/MicrophoneLevelMeter.tsx new file mode 100644 index 000000000..38516b20a --- /dev/null +++ b/src/components/MicrophoneLevelMeter.tsx @@ -0,0 +1,139 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { useCallback, useState, type FC, type Ref } from "react"; +import { Text } from "@vector-im/compound-web"; +import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import classNames from "classnames"; +import { useTranslation } from "react-i18next"; + +import { distinctUntilChanged, map } from "rxjs"; + +import styles from "./MicrophoneLevelMeter.module.css"; +import { LEVEL_SCALE, type MicrophoneState } from "../state/MicrophoneLevel"; +import { observeElementSize$ } from "../utils/elementSize"; +import { useMicrophoneLevel } from "./useMicrophoneLevel"; + +export interface LiveMicrophoneLevelMeterProps { + /** The microphone to listen to. */ + deviceId: string | undefined; + /** Whether to hold a capture at all. */ + active: boolean; + className?: string; + ref?: Ref; +} + +/** + * The level meter, wired to a live capture of a microphone. + * + * A component of its own so that a level which does change redraws the meter + * and not whatever is rendered beside it. A moving level arrives many times a + * second; held in the menu, it would reconcile every device row on the way to + * the bars. + */ +export const LiveMicrophoneLevelMeter: FC = ({ + deviceId, + active, + className, + ref, +}) => { + const state = useMicrophoneLevel(deviceId, active); + return ; +}; + +export interface MicrophoneLevelMeterProps { + state: MicrophoneState; + className?: string; + /** + * The meter's own element. Its height is what a scroll container has to keep + * clear to stop the meter covering the row it has just scrolled to. + */ + ref?: Ref; +} + +/** + * The live input level of the selected microphone, shown beneath it, or the + * reason there is no level to show. + */ +export const MicrophoneLevelMeter: FC = ({ + state, + className, + ref, +}) => { + const { t } = useTranslation(); + // How many bars there is room for. The bars never change size, so this is + // what absorbs a change of width. Starts at the full count so that the first + // paint is a meter rather than a single bar, and so that a renderer with no + // layout at all — jsdom — still draws the whole thing. + const [barCount, setBarCount] = useState(LEVEL_SCALE); + const track = useCallback( + (element: HTMLDivElement | null): (() => void) | undefined => { + if (element === null) return; + const subscription = observeElementSize$(element) + .pipe( + map(({ width }) => barsThatFit(element, width)), + distinctUntilChanged(), + ) + .subscribe(setBarCount); + return (): void => subscription.unsubscribe(); + }, + [], + ); + + return ( +
+ + {state.type !== "level" ? ( + + {state.type === "permission-denied" + ? t("microphone_level.permission_denied") + : t("microphone_level.no_device")} + + ) : ( +
+ {Array.from({ length: barCount }, (_, i) => ( + + ))} +
+ )} +
+ ); +}; + +/** + * How many bars fit across `width`, measured off a rendered one rather than + * told: a bar's size is a design question, settled in the stylesheet, and + * reading it back is what keeps it from being settled twice. + */ +function barsThatFit(track: HTMLElement, width: number): number { + const gap = Number.parseFloat(getComputedStyle(track).columnGap); + const bar = track.firstElementChild?.getBoundingClientRect().width ?? 0; + // A renderer that lays nothing out tells us nothing; keep the full count. + if (!(bar > 0) || !(gap >= 0)) return LEVEL_SCALE; + return Math.max(1, Math.floor((width + gap) / (bar + gap))); +} diff --git a/src/components/useMicrophoneLevel.test.tsx b/src/components/useMicrophoneLevel.test.tsx new file mode 100644 index 000000000..1258bfeea --- /dev/null +++ b/src/components/useMicrophoneLevel.test.tsx @@ -0,0 +1,42 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { afterEach, describe, expect, test } from "vitest"; +import { renderHook } from "@testing-library/react"; + +import { useMicrophoneLevel } from "./useMicrophoneLevel"; +import { restoreAudioCapture, stubAudioCapture } from "../utils/test"; + +// The capture itself, and releasing it, belong to observeMicrophoneState$ and +// are covered in src/state/MicrophoneLevel.test.ts. What is left here is the +// bridging: when the hook watches, and what it reports before it has an answer. +describe("useMicrophoneLevel", () => { + afterEach(restoreAudioCapture); + + test("holds no capture while the meter is not shown", () => { + const capture = stubAudioCapture(); + + renderHook(() => useMicrophoneLevel("mic1", false)); + + expect(capture.getUserMedia).not.toHaveBeenCalled(); + }); + + test("starts from nothing rather than the previous device's level", () => { + const capture = stubAudioCapture(); + + const { result, rerender } = renderHook( + ({ id }: { id: string }) => useMicrophoneLevel(id, true), + { initialProps: { id: "mic1" } }, + ); + capture.grant(); + + rerender({ id: "mic2" }); + + // No level carried over: the meter reads the new device or nothing at all. + expect(result.current).toEqual({ type: "level", level: 0 }); + }); +}); diff --git a/src/components/useMicrophoneLevel.ts b/src/components/useMicrophoneLevel.ts new file mode 100644 index 000000000..6e295d3e4 --- /dev/null +++ b/src/components/useMicrophoneLevel.ts @@ -0,0 +1,41 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { useEffect, useState } from "react"; + +import { + type MicrophoneState, + observeMicrophoneState$, +} from "../state/MicrophoneLevel"; + +const IDLE: MicrophoneState = { type: "level", level: 0 }; + +/** + * Reads the live input level of a microphone, while `active`. + * + * A bridge and nothing else: the capture, its lifetime and the maths belong to + * {@link observeMicrophoneState$}. Scoped to `active` so the device is held + * only while whatever shows the meter is on screen, rather than for the length + * of a call. + */ +export function useMicrophoneLevel( + deviceId: string | undefined, + active: boolean, +): MicrophoneState { + const [state, setState] = useState(IDLE); + + useEffect(() => { + if (!active) return; + // Idle first, so a new device starts from nothing rather than from the + // level the previous one was reading. + setState(IDLE); + const subscription = observeMicrophoneState$(deviceId).subscribe(setState); + return (): void => subscription.unsubscribe(); + }, [deviceId, active]); + + return state; +}