mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
Add a microphone level meter
- Draws the level as lit bars, and each failure state as its own message with a next action rather than as a meter that has stopped. - A bar and the space beside it keep one size wherever the meter is drawn; what the width decides is how many bars there are. Bars spread to fill would be a different shape in every place, and would close into one block where the space ran short. - Announces the level on a fixed scale, so it does not rest on hue or on how many bars happened to fit. - The hook is a bridge and nothing more: the capture belongs to the state layer. - The capture is held by a component of its own, so a level that does change redraws the meter rather than whatever is rendered beside it. A level arrives many times a second; held by the menu it would reconcile every device row on the way to the bars. A test counts those renders, because nothing else would notice the hook moving back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -163,6 +163,12 @@
|
|||||||
"login_auth_links_prompt": "Not registered yet?",
|
"login_auth_links_prompt": "Not registered yet?",
|
||||||
"login_subheading": "To continue to Element",
|
"login_subheading": "To continue to Element",
|
||||||
"login_title": "Login",
|
"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_off": "Microphone off",
|
||||||
"microphone_on": "Microphone on",
|
"microphone_on": "Microphone on",
|
||||||
"mute_microphone_button_label": "Mute microphone",
|
"mute_microphone_button_label": "Mute microphone",
|
||||||
@@ -215,11 +221,14 @@
|
|||||||
"activating": "Activating…",
|
"activating": "Activating…",
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
"camera_numbered": "Camera {{n}}",
|
"camera_numbered": "Camera {{n}}",
|
||||||
|
"camera_source": "Camera Source",
|
||||||
"change_device_button": "Change audio device",
|
"change_device_button": "Change audio device",
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
"default_named": "Default <2>({{name}})</2>",
|
"default_named": "Default <2>({{name}})</2>",
|
||||||
|
"default_named_plain": "Default ({{name}})",
|
||||||
"handset": "Handset",
|
"handset": "Handset",
|
||||||
"loudspeaker": "Loudspeaker",
|
"loudspeaker": "Loudspeaker",
|
||||||
|
"mic_source": "Mic Source",
|
||||||
"microphone": "Microphone",
|
"microphone": "Microphone",
|
||||||
"microphone_numbered": "Microphone {{n}}",
|
"microphone_numbered": "Microphone {{n}}",
|
||||||
"speaker": "Speaker",
|
"speaker": "Speaker",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(<MicrophoneLevelMeter state={{ type: "level", level: 6 }} />);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<MicrophoneLevelMeter state={{ type: "permission-denied" }} />,
|
||||||
|
);
|
||||||
|
// 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(
|
||||||
|
<MicrophoneLevelMeter state={{ type: "no-device" }} />,
|
||||||
|
);
|
||||||
|
expect(missing.getByText(/No microphone found/)).toBeInTheDocument();
|
||||||
|
expect(missing.queryByRole("meter")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<LiveMicrophoneLevelMeterProps> = ({
|
||||||
|
deviceId,
|
||||||
|
active,
|
||||||
|
className,
|
||||||
|
ref,
|
||||||
|
}) => {
|
||||||
|
const state = useMicrophoneLevel(deviceId, active);
|
||||||
|
return <MicrophoneLevelMeter state={state} className={className} ref={ref} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live input level of the selected microphone, shown beneath it, or the
|
||||||
|
* reason there is no level to show.
|
||||||
|
*/
|
||||||
|
export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
|
||||||
|
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 (
|
||||||
|
<div ref={ref} className={classNames(styles.meter, className)}>
|
||||||
|
<MicOnIcon className={styles.icon} aria-hidden />
|
||||||
|
{state.type !== "level" ? (
|
||||||
|
<Text size="sm" className={styles.message}>
|
||||||
|
{state.type === "permission-denied"
|
||||||
|
? t("microphone_level.permission_denied")
|
||||||
|
: t("microphone_level.no_device")}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={track}
|
||||||
|
className={styles.segments}
|
||||||
|
role="meter"
|
||||||
|
aria-label={t("microphone_level.label")}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={LEVEL_SCALE}
|
||||||
|
aria-valuenow={state.level}
|
||||||
|
aria-valuetext={t("microphone_level.value", {
|
||||||
|
level: state.level,
|
||||||
|
max: LEVEL_SCALE,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{Array.from({ length: barCount }, (_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
aria-hidden
|
||||||
|
className={classNames(styles.segment, {
|
||||||
|
// The level is a share of the scale, not a count of bars: how
|
||||||
|
// many stand for it depends on how many there are.
|
||||||
|
[styles.segmentLit]:
|
||||||
|
i < Math.round((state.level / LEVEL_SCALE) * barCount),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)));
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<MicrophoneState>(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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user