Files
cinny/src/app/features/call/Controls.tsx
T
Lotus CIandClaude Opus 5.5 3449338b3e 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
2026-09-25 12:09:34 -04:00

293 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
export function ControlDivider() {
return (
<Line variant="SurfaceVariant" size="300" direction="Vertical" className={css.ControlDivider} />
);
}
type MicrophoneButtonProps = {
enabled: boolean;
onToggle: () => Promise<unknown>;
/** [Gitea #146] Your input level, 0–3 bars; shown only while unmuted. */
level?: number;
};
export function MicrophoneButton({ enabled, onToggle, level = 0 }: MicrophoneButtonProps) {
const [micState, toggleMic] = useAsyncCallback(onToggle);
const loading = micState.status === AsyncStatus.Loading;
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Turn Off Microphone' : 'Turn On Microphone'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'}
fill="Soft"
radii="400"
size="400"
className={MobileTouchTarget}
onClick={toggleMic}
aria-label={enabled ? 'Turn Off Microphone' : 'Turn On Microphone'}
outlined
disabled={loading}
>
<span style={{ position: 'relative', display: 'inline-flex' }}>
<Icon size="400" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
{enabled && <MicLevelBars level={level} />}
</span>
</IconButton>
)}
</TooltipProvider>
);
}
type SoundButtonProps = {
enabled: boolean;
onToggle: () => void;
};
export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Deafen' : 'Undeafen'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'}
fill="Soft"
radii="400"
size="400"
className={MobileTouchTarget}
onClick={() => onToggle()}
// No aria-pressed on these call toggles (#187 DP7/8): their labels already
// name the action ("Deafen" / "Undeafen"), and pressed-state on top
// read as "Deafen, pressed" while NOT deafened.
aria-label={enabled ? 'Deafen' : 'Undeafen'}
outlined
>
<Icon
size="400"
src={enabled ? Icons.Headphone : Icons.HeadphoneMute}
filled={!enabled}
/>
</IconButton>
)}
</TooltipProvider>
);
}
type VideoButtonProps = {
enabled: boolean;
onToggle: () => Promise<unknown>;
disabled?: boolean;
};
export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
const [videoState, toggleVideo] = useAsyncCallback(onToggle);
const loading = videoState.status === AsyncStatus.Loading;
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">
{disabled ? 'Camera disabled in settings' : enabled ? 'Stop Camera' : 'Start Camera'}
</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
radii="400"
size="400"
className={MobileTouchTarget}
onClick={toggleVideo}
outlined
disabled={disabled || loading}
aria-label={
disabled ? 'Camera disabled in settings' : enabled ? 'Stop camera' : 'Start camera'
}
style={disabled ? { opacity: 0.4, cursor: 'not-allowed' } : undefined}
>
<Icon
size="400"
src={enabled ? Icons.VideoCamera : Icons.VideoCameraMute}
filled={enabled}
/>
</IconButton>
)}
</TooltipProvider>
);
}
type ScreenShareButtonProps = {
enabled: boolean;
onToggle: () => void;
};
export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Stop Screenshare' : 'Start Screenshare'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
radii="400"
size="400"
className={MobileTouchTarget}
onClick={() => onToggle()}
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
outlined
>
<Icon size="400" src={Icons.ScreenShare} filled={enabled} />
</IconButton>
)}
</TooltipProvider>
);
}
export const FullscreenIcon = () => (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 3h6v2H5v4H3V3zm12 0h6v6h-2V5h-4V3zM3 15h2v4h4v2H3v-6zm16 4h-4v2h6v-6h-2v4z" />
</svg>
);
export const ExitFullscreenIcon = () => (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 3H7v4H3v2h6V3zm6 0v6h6V7h-4V3h-2zM3 13v2h4v4h2v-6H3zm14 4v-4h2v6h-6v-2h4z" />
</svg>
);
type FullscreenButtonProps = {
isFullscreen: boolean;
onToggle: () => void;
};
export function FullscreenButton({ isFullscreen, onToggle }: FullscreenButtonProps) {
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant="Surface"
fill="Soft"
radii="400"
size="400"
className={MobileTouchTarget}
onClick={onToggle}
aria-label={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
outlined
>
{isFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
</IconButton>
)}
</TooltipProvider>
);
}
type ScreenshareAudioButtonProps = {
muted: boolean;
onToggle: () => void;
};
export function ScreenshareAudioButton({ muted, onToggle }: ScreenshareAudioButtonProps) {
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{muted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={muted ? 'Warning' : 'Surface'}
fill="Soft"
radii="400"
size="400"
className={MobileTouchTarget}
onClick={onToggle}
aria-label={muted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'}
outlined
>
<Icon size="400" src={muted ? Icons.VolumeMute : Icons.VolumeHigh} filled={muted} />
</IconButton>
)}
</TooltipProvider>
);
}
export function ChatButton() {
const [chat, setChat] = useAtom(callChatAtom);
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{chat ? 'Close Chat' : 'Open Chat'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={chat ? 'Success' : 'Surface'}
fill="Soft"
radii="400"
size="400"
className={MobileTouchTarget}
onClick={() => setChat(!chat)}
aria-label={chat ? 'Close Chat' : 'Open Chat'}
outlined
>
<Icon size="400" src={Icons.Message} filled={chat} />
</IconButton>
)}
</TooltipProvider>
);
}