feat(call): mic level meter on the mute button (#146)

While unmuted, the microphone button (call view and the bottom call bar)
shows three small green bars in the icon's corner driven by the fork's
io.lotus.mic_level (element-call v0.25.0-lotus.16), so you can see the mic is
actually picking you up. Nothing is drawn while muted or silent; the button's
label is unchanged (the bars are decorative).

Your own name no longer appears in the "… is speaking" line: that is now for
everyone else, and your voice is on the meter. Avatar speaking rings still
include you, and the "You're muted" talking-while-muted notice is unchanged.

Verified in a local call with a fake-tone mic: data-mic-level cycles 3/2/1
with the tone on both buttons, disappears on mute and returns on unmute; the
status line reads "bob is speaking..." only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-25 12:09:34 -04:00
co-authored by Claude Opus 5.5
parent 75d55e861b
commit 3449338b3e
7 changed files with 123 additions and 11 deletions
+11 -3
View File
@@ -1,8 +1,9 @@
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback, useState } from 'react';
import { useSetAtom } from 'jotai';
import { MicLevelBars } from '../call/MicLevelBars';
import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { CallEmbed, useCallControlState, useCallMicLevel } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed';
import { MobileTouchTarget } from '../../styles/mobile.css';
@@ -13,9 +14,11 @@ import { AudioOutputButton, audioOutputSelectable } from './AudioOutputButton';
type MicrophoneButtonProps = {
enabled: boolean;
onToggle: () => Promise<unknown>;
/** [Gitea #146] Your input level, 0–3 bars; shown only while unmuted. */
level?: number;
disabled?: boolean;
};
function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
function MicrophoneButton({ enabled, onToggle, disabled, level = 0 }: MicrophoneButtonProps) {
const [micState, toggleMic] = useAsyncCallback(onToggle);
const loading = micState.status === AsyncStatus.Loading;
@@ -41,7 +44,10 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
disabled={disabled || loading}
aria-label={enabled ? 'Turn off microphone' : 'Turn on microphone'}
>
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
<span style={{ position: 'relative', display: 'inline-flex' }}>
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
{enabled && <MicLevelBars level={level} />}
</span>
</IconButton>
)}
</TooltipProvider>
@@ -177,6 +183,7 @@ export function CallControl({
callJoined: boolean;
}) {
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const micLevel = useCallMicLevel(callEmbed);
const setCallEmbed = useSetAtom(callEmbedAtom);
// [Gitea #26] Apply the same room-level camera/screenshare policy as the
@@ -224,6 +231,7 @@ export function CallControl({
enabled={microphone}
onToggle={handleMicrophoneToggle}
disabled={!callJoined}
level={micLevel}
/>
<SoundButton
enabled={sound}
+12 -3
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Box, Spinner, Text, color } from 'folds';
import { useAtomValue } from 'jotai';
import classNames from 'classnames';
@@ -14,6 +14,7 @@ import { StatusDivider } from './components';
import { CallEmbed } from '../../plugins/call/CallEmbed';
import { useCallJoined } from '../../hooks/useCallEmbed';
import { useCallSpeakers } from '../../hooks/useCallSpeakers';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MemberSpeaking } from './MemberSpeaking';
import { clockSkewAtom } from '../../state/clockSkew';
import { describeSkewVsServer } from '../../utils/clockSkew';
@@ -29,6 +30,14 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
const screenSize = useScreenSize();
const callJoined = useCallJoined(callEmbed);
const speakers = useCallSpeakers(callEmbed);
// [Gitea #146] Your own voice is on the mic button's level meter; the
// "… is speaking" line is for everyone else. (Avatar rings still include you.)
const mx = useMatrixClient();
const myUserId = mx.getUserId();
const otherSpeakers = useMemo(
() => new Set([...speakers].filter((id) => id !== myUserId)),
[speakers, myUserId],
);
// [Gitea #158] Same warning as the top banner, where the user is looking during a call.
const clockSkew = useAtomValue(clockSkewAtom);
@@ -69,11 +78,11 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
</Text>
</>
)}
{speakers.size > 0 && (
{otherSpeakers.size > 0 && (
<>
<StatusDivider />
<span data-spacing-node />
<MemberSpeaking room={room} speakers={speakers} />
<MemberSpeaking room={room} speakers={otherSpeakers} />
</>
)}
</>