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:
co-authored by
Claude Opus 5.5
parent
75d55e861b
commit
3449338b3e
@@ -1,8 +1,9 @@
|
|||||||
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { MicLevelBars } from '../call/MicLevelBars';
|
||||||
import { StatusDivider } from './components';
|
import { StatusDivider } from './components';
|
||||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
import { CallEmbed, useCallControlState, useCallMicLevel } from '../../plugins/call';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
@@ -13,9 +14,11 @@ import { AudioOutputButton, audioOutputSelectable } from './AudioOutputButton';
|
|||||||
type MicrophoneButtonProps = {
|
type MicrophoneButtonProps = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
onToggle: () => Promise<unknown>;
|
onToggle: () => Promise<unknown>;
|
||||||
|
/** [Gitea #146] Your input level, 0–3 bars; shown only while unmuted. */
|
||||||
|
level?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
|
function MicrophoneButton({ enabled, onToggle, disabled, level = 0 }: MicrophoneButtonProps) {
|
||||||
const [micState, toggleMic] = useAsyncCallback(onToggle);
|
const [micState, toggleMic] = useAsyncCallback(onToggle);
|
||||||
const loading = micState.status === AsyncStatus.Loading;
|
const loading = micState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
@@ -41,7 +44,10 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
|
|||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
aria-label={enabled ? 'Turn off microphone' : 'Turn on microphone'}
|
aria-label={enabled ? 'Turn off microphone' : 'Turn on microphone'}
|
||||||
>
|
>
|
||||||
|
<span style={{ position: 'relative', display: 'inline-flex' }}>
|
||||||
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
|
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
|
||||||
|
{enabled && <MicLevelBars level={level} />}
|
||||||
|
</span>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -177,6 +183,7 @@ export function CallControl({
|
|||||||
callJoined: boolean;
|
callJoined: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
||||||
|
const micLevel = useCallMicLevel(callEmbed);
|
||||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
|
||||||
// [Gitea #26] Apply the same room-level camera/screenshare policy as the
|
// [Gitea #26] Apply the same room-level camera/screenshare policy as the
|
||||||
@@ -224,6 +231,7 @@ export function CallControl({
|
|||||||
enabled={microphone}
|
enabled={microphone}
|
||||||
onToggle={handleMicrophoneToggle}
|
onToggle={handleMicrophoneToggle}
|
||||||
disabled={!callJoined}
|
disabled={!callJoined}
|
||||||
|
level={micLevel}
|
||||||
/>
|
/>
|
||||||
<SoundButton
|
<SoundButton
|
||||||
enabled={sound}
|
enabled={sound}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Box, Spinner, Text, color } from 'folds';
|
import { Box, Spinner, Text, color } from 'folds';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@@ -14,6 +14,7 @@ import { StatusDivider } from './components';
|
|||||||
import { CallEmbed } from '../../plugins/call/CallEmbed';
|
import { CallEmbed } from '../../plugins/call/CallEmbed';
|
||||||
import { useCallJoined } from '../../hooks/useCallEmbed';
|
import { useCallJoined } from '../../hooks/useCallEmbed';
|
||||||
import { useCallSpeakers } from '../../hooks/useCallSpeakers';
|
import { useCallSpeakers } from '../../hooks/useCallSpeakers';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { MemberSpeaking } from './MemberSpeaking';
|
import { MemberSpeaking } from './MemberSpeaking';
|
||||||
import { clockSkewAtom } from '../../state/clockSkew';
|
import { clockSkewAtom } from '../../state/clockSkew';
|
||||||
import { describeSkewVsServer } from '../../utils/clockSkew';
|
import { describeSkewVsServer } from '../../utils/clockSkew';
|
||||||
@@ -29,6 +30,14 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
|
|||||||
const screenSize = useScreenSize();
|
const screenSize = useScreenSize();
|
||||||
const callJoined = useCallJoined(callEmbed);
|
const callJoined = useCallJoined(callEmbed);
|
||||||
const speakers = useCallSpeakers(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.
|
// [Gitea #158] Same warning as the top banner, where the user is looking during a call.
|
||||||
const clockSkew = useAtomValue(clockSkewAtom);
|
const clockSkew = useAtomValue(clockSkewAtom);
|
||||||
|
|
||||||
@@ -69,11 +78,11 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{speakers.size > 0 && (
|
{otherSpeakers.size > 0 && (
|
||||||
<>
|
<>
|
||||||
<StatusDivider />
|
<StatusDivider />
|
||||||
<span data-spacing-node />
|
<span data-spacing-node />
|
||||||
<MemberSpeaking room={room} speakers={speakers} />
|
<MemberSpeaking room={room} speakers={otherSpeakers} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
SoundButton,
|
SoundButton,
|
||||||
VideoButton,
|
VideoButton,
|
||||||
} from './Controls';
|
} from './Controls';
|
||||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
import { CallEmbed, useCallControlState, useCallMicLevel } from '../../plugins/call';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
@@ -85,6 +85,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
|
|
||||||
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
|
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
|
||||||
useCallControlState(callEmbed.control);
|
useCallControlState(callEmbed.control);
|
||||||
|
const micLevel = useCallMicLevel(callEmbed);
|
||||||
|
|
||||||
const [cords, setCords] = useState<RectCords>();
|
const [cords, setCords] = useState<RectCords>();
|
||||||
const [shareConfirm, setShareConfirm] = useState(false);
|
const [shareConfirm, setShareConfirm] = useState(false);
|
||||||
@@ -200,7 +201,11 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
>
|
>
|
||||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
|
<MicrophoneButton
|
||||||
|
enabled={microphone}
|
||||||
|
onToggle={handleMicrophoneToggle}
|
||||||
|
level={micLevel}
|
||||||
|
/>
|
||||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||||
</Box>
|
</Box>
|
||||||
{!compact && showVideoGroup && <ControlDivider />}
|
{!compact && showVideoGroup && <ControlDivider />}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds';
|
import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
|
import { MicLevelBars } from './MicLevelBars';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
import { callChatAtom } from '../../state/callEmbed';
|
import { callChatAtom } from '../../state/callEmbed';
|
||||||
@@ -15,8 +16,10 @@ export function ControlDivider() {
|
|||||||
type MicrophoneButtonProps = {
|
type MicrophoneButtonProps = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
onToggle: () => Promise<unknown>;
|
onToggle: () => Promise<unknown>;
|
||||||
|
/** [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 [micState, toggleMic] = useAsyncCallback(onToggle);
|
||||||
const loading = micState.status === AsyncStatus.Loading;
|
const loading = micState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
@@ -43,7 +46,10 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
|
|||||||
outlined
|
outlined
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
|
<span style={{ position: 'relative', display: 'inline-flex' }}>
|
||||||
<Icon size="400" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
|
<Icon size="400" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
|
||||||
|
{enabled && <MicLevelBars level={level} />}
|
||||||
|
</span>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-mic-level={level}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 1,
|
||||||
|
bottom: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 1,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{HEIGHTS.map((h, i) => (
|
||||||
|
<span
|
||||||
|
key={h}
|
||||||
|
style={{
|
||||||
|
width: 2,
|
||||||
|
height: h,
|
||||||
|
borderRadius: 1,
|
||||||
|
background: i < level ? color.Success.Main : 'transparent',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -85,6 +85,11 @@ export class CallEmbed {
|
|||||||
|
|
||||||
private lotusCallStateListeners = new Set<() => void>();
|
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.
|
// [Gitea #17] Listeners notified when the fork sends io.lotus.request_state.
|
||||||
private forkStateRequestListeners = new Set<() => void>();
|
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
|
// [Gitea #43] Screenshare + layout state from the fork; also switches the
|
||||||
// call bar's layout / settings / reactions buttons to widget actions.
|
// call bar's layout / settings / reactions buttons to widget actions.
|
||||||
this.disposables.push(
|
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 {
|
public onLotusCallState(cb: () => void): () => void {
|
||||||
this.lotusCallStateListeners.add(cb);
|
this.lotusCallStateListeners.add(cb);
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import {
|
|||||||
IWidgetApiAcknowledgeResponseData,
|
IWidgetApiAcknowledgeResponseData,
|
||||||
IWidgetApiRequestData,
|
IWidgetApiRequestData,
|
||||||
} from 'matrix-widget-api';
|
} from 'matrix-widget-api';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
|
||||||
import { CallControl, CallControlEvent } from './CallControl';
|
import { CallControl, CallControlEvent } from './CallControl';
|
||||||
import { CallControlState } from './CallControlState';
|
import { CallControlState } from './CallControlState';
|
||||||
|
import type { CallEmbed } from './CallEmbed';
|
||||||
|
|
||||||
export const useClientWidgetApiEvent = <T>(
|
export const useClientWidgetApiEvent = <T>(
|
||||||
api: ClientWidgetApi | undefined,
|
api: ClientWidgetApi | undefined,
|
||||||
@@ -61,3 +62,14 @@ export const useCallControlState = (control: CallControl | undefined): CallContr
|
|||||||
if (entry.control !== control) return control?.getState() ?? DEFAULT_CONTROL_STATE;
|
if (entry.control !== control) return control?.getState() ?? DEFAULT_CONTROL_STATE;
|
||||||
return entry.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);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user