Files
cinny/src/app/features/call/CallControls.tsx
T
Lotus CIandClaude Opus 5.5 3418a8865d fix(call): one-row call bar on phones; screenshare-audio mute only when useful (#265, #277)
#265: in the compact layout (phones, narrow windows) the control bar
stacked into four rows (~330px, 40% of a portrait phone). It is now one
row: mic, deafen, camera, chat, soundboard, More and an icon-only End.
Screenshare, its audio mute and fullscreen move into More; Share Screen
is only offered where getDisplayMedia exists. (The self-view comes from
the fork, lotus.27.)

#277: "Mute Screenshare Audio" showed whenever screensharing was allowed,
as a speaker icon that did nothing. It now shows while the fork reports
remote screenshare audio (io.lotus.screenshare_audio_state), or while it
is muted, so a hidden mute cannot silence the next share.

Fixes #265
Fixes #277

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 17:24:38 -04:00

441 lines
16 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, { MouseEventHandler, useCallback, useRef, useState, useEffect } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import {
Box,
Button,
Chip,
config,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css';
import {
ChatButton,
ControlDivider,
FullscreenButton,
MicrophoneButton,
ScreenShareButton,
ScreenshareAudioButton,
SoundButton,
VideoButton,
} from './Controls';
import {
CallEmbed,
useCallControlState,
useCallMicLevel,
useScreenshareMode,
} from '../../plugins/call';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { callEmbedAtom } from '../../state/callEmbed';
import { useResizeObserver } from '../../hooks/useResizeObserver';
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
import { pttActiveAtom } from '../../hooks/useCallHotkeys';
import { SCREENSHARE_AUDIO_STATE } from '../../plugins/call/CallControl';
import { CallSoundboard } from './CallSoundboard';
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
import { ScreenshareConfirm } from './ScreenshareConfirm';
type CallControlsProps = {
callEmbed: CallEmbed;
};
export function CallControls({ callEmbed }: CallControlsProps) {
const controlRef = useRef<HTMLDivElement>(null);
const callEmbedRef = useCallEmbedRef();
const setCallEmbed = useSetAtom(callEmbedAtom);
const screenSize = useScreenSize();
const [narrowContainer, setNarrowContainer] = useState(document.body.clientWidth < 500);
const [isFullscreen, setIsFullscreen] = useState(false);
useResizeObserver(
useCallback(() => {
const element = controlRef.current;
if (!element) return;
setNarrowContainer(element.clientWidth < 500);
}, []),
useCallback(() => controlRef.current, []),
);
// Collapse to the stacked/compact layout whenever the bar's own container is
// narrow (a small desktop call window) OR the viewport is a phone. The old
// element-only `< 500` check left the ~11-control row overflowing off-screen
// in the 500–750px band (landscape phones / small tablets).
const compact = narrowContainer || screenSize === ScreenSize.Mobile;
useEffect(() => {
const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFullscreenChange);
return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
}, []);
const handleFullscreen = useCallback(() => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
callEmbedRef.current?.requestFullscreen();
}
}, [callEmbedRef]);
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
useCallControlState(callEmbed.control);
// [Gitea #277] Only offer "Mute Screenshare Audio" while a remote share's
// audio is playing, or while it is muted (so a hidden mute can't silently
// swallow the next share).
const [remoteScreenshareAudio, setRemoteScreenshareAudio] = useState(
callEmbed.control.remoteScreenshareAudio,
);
useEffect(() => {
const onState = () => setRemoteScreenshareAudio(callEmbed.control.remoteScreenshareAudio);
onState();
callEmbed.control.on(SCREENSHARE_AUDIO_STATE, onState);
return () => {
callEmbed.control.off(SCREENSHARE_AUDIO_STATE, onState);
};
}, [callEmbed]);
const showScreenshareAudio = remoteScreenshareAudio || screenshareAudioMuted;
const micLevel = useCallMicLevel(callEmbed);
const [cords, setCords] = useState<RectCords>();
const [shareConfirm, setShareConfirm] = useState(false);
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
// [Gitea #9] PTT/deafen key handling and AFK auto-mute live in useCallHotkeys
// / useAfkAutoMute, mounted from CallEmbedProvider for the embed's lifetime
// (this component only renders while the call room is selected). Only the
// visual PTT chip remains here.
const pttActive = useAtomValue(pttActiveAtom);
// [P5-31 / Gitea #101] Hard room publish policy — hide controls the server
// will refuse so users don't click dead buttons. Absent/true = allowed.
// Shared with the app-wide CallStatus bar's CallControl via useRoomCallPolicy
// so both surfaces apply the same gating.
const { allowCamera, allowScreenshare } = useRoomCallPolicy(callEmbed.room);
// Keep a forbidden control visible while its track is still live (so the user
// can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video;
// [Gitea #43] Where EC shows its own screenshare button in the frame, ours
// is hidden (the screenshare-audio mute stays); where the click can't be
// delegated, starting asks for the fork's in-frame "Share your screen?".
const screenshareMode = useScreenshareMode(callEmbed.control);
const handleScreenshareToggle = () => {
if (screenshare) callEmbed.control.toggleScreenshare();
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
else setShareConfirm(true);
};
const showScreenshare = allowScreenshare || screenshare;
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
// [Gitea #265] Phones can't share their screen from a browser; don't offer it.
const canShareScreen = typeof navigator.mediaDevices?.getDisplayMedia === 'function';
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleSpotlightClick = () => {
callEmbed.control.toggleSpotlight();
setCords(undefined);
};
const handleReactionsClick = () => {
callEmbed.control.toggleReactions();
setCords(undefined);
};
const handleSettingsClick = () => {
callEmbed.control.toggleSettings();
setCords(undefined);
};
const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(),
[callEmbed],
);
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed]),
);
const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
// C-M4: the normal teardown relies on EC echoing a Close/Hangup action after
// it ACKs HangupCall (useCallHangupEvent -> clears callEmbedAtom -> dispose).
// If EC ACKs but never echoes, the End button would spin forever. Fall back to
// disposing the embed a few seconds after a successful hangup send, unless it
// was already torn down by the normal path.
useEffect(() => {
if (hangupState.status !== AsyncStatus.Success) return undefined;
const id = setTimeout(() => {
if (!callEmbed.disposed) setCallEmbed(undefined);
}, 4000);
return () => clearTimeout(id);
}, [hangupState.status, callEmbed, setCallEmbed]);
const moreMenu = (
<PopOut
anchor={cords}
position="Top"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" style={{ padding: config.space.S100 }}>
{compact && showScreenshare && screenshareMode !== 'hidden' && canShareScreen && (
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={() => {
setCords(undefined);
handleScreenshareToggle();
}}
>
<Text size="B300" truncate>
{screenshare ? 'Stop Screenshare' : 'Share Screen'}
</Text>
</MenuItem>
)}
{compact && showScreenshareAudio && (
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={() => {
setCords(undefined);
callEmbed.control.toggleScreenshareAudio();
}}
>
<Text size="B300" truncate>
{screenshareAudioMuted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'}
</Text>
</MenuItem>
)}
{compact && !!document.fullscreenEnabled && (
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={() => {
setCords(undefined);
handleFullscreen();
}}
>
<Text size="B300" truncate>
{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
</Text>
</MenuItem>
)}
<MenuItem size="300" variant="Surface" radii="300" onClick={handleSpotlightClick}>
<Text size="B300" truncate>
{spotlight ? 'Grid View' : 'Spotlight View'}
</Text>
</MenuItem>
<MenuItem size="300" variant="Surface" radii="300" onClick={handleReactionsClick}>
<Text size="B300" truncate>
Reactions
</Text>
</MenuItem>
<MenuItem size="300" variant="Surface" radii="300" onClick={handleSettingsClick}>
<Text size="B300" truncate>
Settings
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
>
<IconButton
variant="Surface"
fill="Soft"
radii="400"
size="400"
onClick={handleOpenMenu}
outlined
aria-label="More options"
aria-expanded={!!cords}
aria-haspopup="menu"
>
<Icon size="400" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
);
const hangupIcon = exiting ? (
<Spinner variant="Critical" fill="Solid" size="200" />
) : (
<Icon src={Icons.PhoneDown} size="200" filled />
);
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
return (
<Box
ref={controlRef}
className={css.CallControlContainer}
justifyContent="Center"
alignItems="Center"
>
{pttMode && (
<Chip
variant={pttActive ? 'Success' : 'Warning'}
fill="Soft"
radii="400"
style={{
position: 'absolute',
top: '-2.2rem',
left: '50%',
transform: 'translateX(-50%)',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
outlined
>
<Text size="T200" style={{ fontWeight: 700 }}>
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
</Text>
</Chip>
)}
<ScreenshareConfirm
open={shareConfirm}
align="Center"
onConfirm={() => {
callEmbed.control.toggleScreenshare();
setShareConfirm(false);
}}
onCancel={() => setShareConfirm(false)}
/>
{compact ? (
// [Gitea #265] Phones / narrow windows: ONE row. Screenshare, its audio
// mute and fullscreen live in More (they rarely matter on a phone), and
// End is icon-only. The old stacked layout took four rows (~40% of a
// portrait phone).
<SequenceCard
className={css.ControlCard}
variant="SurfaceVariant"
gap="200"
radii="500"
alignItems="Center"
justifyContent="SpaceBetween"
wrap="NoWrap"
>
<MicrophoneButton
enabled={microphone}
onToggle={handleMicrophoneToggle}
level={micLevel}
/>
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
<ChatButton />
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
{moreMenu}
<Button
variant="Critical"
fill="Solid"
radii="400"
size="400"
onClick={hangup}
disabled={exiting}
aria-label="End Call"
style={{ paddingInline: config.space.S300 }}
>
{hangupIcon}
</Button>
</SequenceCard>
) : (
<SequenceCard
className={css.ControlCard}
variant="SurfaceVariant"
gap="400"
radii="500"
alignItems="Center"
justifyContent="SpaceBetween"
wrap="Wrap"
>
<Box alignItems="Center" gap="Inherit" grow="Yes">
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<MicrophoneButton
enabled={microphone}
onToggle={handleMicrophoneToggle}
level={micLevel}
/>
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
</Box>
{showVideoGroup && <ControlDivider />}
{showVideoGroup && (
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
{/* Show a forbidden control while its track is still live so the
user can stop it; once stopped it hides and can't be restarted. */}
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
{showScreenshare && (
<>
{screenshareMode !== 'hidden' && (
<ScreenShareButton
enabled={screenshare}
onToggle={() => handleScreenshareToggle()}
/>
)}
{/* Mute-screenshare-audio sits directly next to the screenshare
control since they're the same concern. */}
{showScreenshareAudio && (
<ScreenshareAudioButton
muted={screenshareAudioMuted}
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
/>
)}
</>
)}
{!!document.fullscreenEnabled && (
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
)}
</Box>
)}
</Box>
<ControlDivider />
<Box alignItems="Center" gap="Inherit" grow="Yes">
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<ChatButton />
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
{moreMenu}
</Box>
<Box shrink="No" direction="Column">
<Button
style={{ minWidth: toRem(88) }}
variant="Critical"
fill="Solid"
onClick={hangup}
before={hangupIcon}
disabled={exiting}
>
<Text size="B400">End</Text>
</Button>
</Box>
</Box>
</SequenceCard>
)}
</Box>
);
}