From 3449338b3e2ebe0914a9fadd9b8010fc7a9739a1 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 25 Sep 2026 12:09:34 -0400 Subject: [PATCH] feat(call): mic level meter on the mute button (#146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/call-status/CallControl.tsx | 14 ++++-- src/app/features/call-status/CallStatus.tsx | 15 +++++-- src/app/features/call/CallControls.tsx | 9 +++- src/app/features/call/Controls.tsx | 10 ++++- src/app/features/call/MicLevelBars.tsx | 45 ++++++++++++++++++++ src/app/plugins/call/CallEmbed.ts | 27 ++++++++++++ src/app/plugins/call/hooks.ts | 14 +++++- 7 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 src/app/features/call/MicLevelBars.tsx diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index c37dd3a33..08b2454ec 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -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; + /** [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'} > - + + + {enabled && } + )} @@ -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} /> 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) { )} - {speakers.size > 0 && ( + {otherSpeakers.size > 0 && ( <> - + )} diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx index 4cb368c54..c3ae2b1fc 100644 --- a/src/app/features/call/CallControls.tsx +++ b/src/app/features/call/CallControls.tsx @@ -29,7 +29,7 @@ import { SoundButton, VideoButton, } from './Controls'; -import { CallEmbed, useCallControlState } from '../../plugins/call'; +import { CallEmbed, useCallControlState, useCallMicLevel } from '../../plugins/call'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { callEmbedAtom } from '../../state/callEmbed'; @@ -85,6 +85,7 @@ export function CallControls({ callEmbed }: CallControlsProps) { const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } = useCallControlState(callEmbed.control); + const micLevel = useCallMicLevel(callEmbed); const [cords, setCords] = useState(); const [shareConfirm, setShareConfirm] = useState(false); @@ -200,7 +201,11 @@ export function CallControls({ callEmbed }: CallControlsProps) { > - + callEmbed.control.toggleSound()} /> {!compact && showVideoGroup && } diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx index bbe764ff7..7354e2237 100644 --- a/src/app/features/call/Controls.tsx +++ b/src/app/features/call/Controls.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds'; import { useAtom } from 'jotai'; +import { MicLevelBars } from './MicLevelBars'; import * as css from './styles.css'; import { MobileTouchTarget } from '../../styles/mobile.css'; import { callChatAtom } from '../../state/callEmbed'; @@ -15,8 +16,10 @@ export function ControlDivider() { type MicrophoneButtonProps = { enabled: boolean; onToggle: () => Promise; + /** [Gitea #146] Your input level, 0–3 bars; shown only while unmuted. */ + level?: number; }; -export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { +export function MicrophoneButton({ enabled, onToggle, level = 0 }: MicrophoneButtonProps) { const [micState, toggleMic] = useAsyncCallback(onToggle); const loading = micState.status === AsyncStatus.Loading; @@ -43,7 +46,10 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { outlined disabled={loading} > - + + + {enabled && } + )} diff --git a/src/app/features/call/MicLevelBars.tsx b/src/app/features/call/MicLevelBars.tsx new file mode 100644 index 000000000..74fafe95d --- /dev/null +++ b/src/app/features/call/MicLevelBars.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { color } from 'folds'; + +type MicLevelBarsProps = { + /** 0–3; nothing is drawn at 0. */ + level: number; +}; + +const HEIGHTS = [3, 5, 7]; + +/** + * [Gitea #146] Three small bars in the corner of the mic button showing your + * own input level while unmuted, so you can see the mic is actually picking + * you up. Decorative: the button's label already says whether it's on. + */ +export function MicLevelBars({ level }: MicLevelBarsProps) { + if (level <= 0) return null; + return ( + + {HEIGHTS.map((h, i) => ( + + ))} + + ); +} diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 07fb8ef1a..950e5cd78 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -85,6 +85,11 @@ export class CallEmbed { private lotusCallStateListeners = new Set<() => void>(); + // [Gitea #146] Local mic level (0–3 bars) from io.lotus.mic_level. + private micLevel = 0; + + private micLevelListeners = new Set<() => void>(); + // [Gitea #17] Listeners notified when the fork sends io.lotus.request_state. private forkStateRequestListeners = new Set<() => void>(); @@ -417,6 +422,15 @@ export class CallEmbed { } }), ); + this.disposables.push( + this.listenAction('io.lotus.mic_level', (evt) => { + const bars = (evt.detail as { data?: { bars?: unknown } } | undefined)?.data?.bars; + const level = typeof bars === 'number' ? Math.max(0, Math.min(3, Math.round(bars))) : 0; + if (level === this.micLevel) return; + this.micLevel = level; + this.micLevelListeners.forEach((l) => l()); + }), + ); // [Gitea #43] Screenshare + layout state from the fork; also switches the // call bar's layout / settings / reactions buttons to widget actions. this.disposables.push( @@ -766,6 +780,19 @@ export class CallEmbed { }; } + /** [Gitea #146] Latest local mic level, 0–3 bars (0 while muted). */ + public getMicLevel(): number { + return this.micLevel; + } + + /** [Gitea #146] Subscribe to mic level changes. Returns an unsubscribe. */ + public onMicLevel(cb: () => void): () => void { + this.micLevelListeners.add(cb); + return () => { + this.micLevelListeners.delete(cb); + }; + } + public onLotusCallState(cb: () => void): () => void { this.lotusCallStateListeners.add(cb); return () => { diff --git a/src/app/plugins/call/hooks.ts b/src/app/plugins/call/hooks.ts index 8df8a37c6..ac03355b1 100644 --- a/src/app/plugins/call/hooks.ts +++ b/src/app/plugins/call/hooks.ts @@ -3,9 +3,10 @@ import { IWidgetApiAcknowledgeResponseData, IWidgetApiRequestData, } from 'matrix-widget-api'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; import { CallControl, CallControlEvent } from './CallControl'; import { CallControlState } from './CallControlState'; +import type { CallEmbed } from './CallEmbed'; export const useClientWidgetApiEvent = ( api: ClientWidgetApi | undefined, @@ -61,3 +62,14 @@ export const useCallControlState = (control: CallControl | undefined): CallContr if (entry.control !== control) return control?.getState() ?? DEFAULT_CONTROL_STATE; return entry.state; }; + +const noSubscribe = () => () => undefined; + +/** [Gitea #146] The local mic level (0–3 bars) reported by the call, or 0. */ +export const useCallMicLevel = (callEmbed: CallEmbed | undefined): number => { + const subscribe = useCallback( + (cb: () => void) => (callEmbed ? callEmbed.onMicLevel(cb) : noSubscribe()), + [callEmbed], + ); + return useSyncExternalStore(subscribe, () => callEmbed?.getMicLevel() ?? 0); +};