Fixes #101 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
272 lines
7.7 KiB
TypeScript
272 lines
7.7 KiB
TypeScript
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
|
import React, { useCallback, useState } from 'react';
|
|
import { useSetAtom } from 'jotai';
|
|
import { StatusDivider } from './components';
|
|
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|
import { callEmbedAtom } from '../../state/callEmbed';
|
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
|
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
|
import { ScreenshareConfirm } from '../call/ScreenshareConfirm';
|
|
|
|
type MicrophoneButtonProps = {
|
|
enabled: boolean;
|
|
onToggle: () => Promise<unknown>;
|
|
disabled?: boolean;
|
|
};
|
|
function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
|
|
const [micState, toggleMic] = useAsyncCallback(onToggle);
|
|
const loading = micState.status === AsyncStatus.Loading;
|
|
|
|
return (
|
|
<TooltipProvider
|
|
position="Top"
|
|
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="300"
|
|
size="300"
|
|
className={MobileTouchTarget}
|
|
onClick={toggleMic}
|
|
outlined
|
|
disabled={disabled || loading}
|
|
aria-label={enabled ? 'Turn off microphone' : 'Turn on microphone'}
|
|
aria-pressed={enabled}
|
|
>
|
|
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
type SoundButtonProps = {
|
|
enabled: boolean;
|
|
onToggle: () => void;
|
|
disabled?: boolean;
|
|
};
|
|
function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
|
|
return (
|
|
<TooltipProvider
|
|
position="Top"
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text size="T200">{enabled ? 'Turn Off Sound' : 'Turn On Sound'}</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(anchorRef) => (
|
|
<IconButton
|
|
ref={anchorRef}
|
|
variant={enabled ? 'Surface' : 'Warning'}
|
|
fill="Soft"
|
|
radii="300"
|
|
size="300"
|
|
className={MobileTouchTarget}
|
|
onClick={() => onToggle()}
|
|
aria-label={enabled ? 'Deafen' : 'Undeafen'}
|
|
aria-pressed={enabled}
|
|
outlined
|
|
disabled={disabled}
|
|
>
|
|
<Icon
|
|
size="100"
|
|
src={enabled ? Icons.Headphone : Icons.HeadphoneMute}
|
|
filled={!enabled}
|
|
/>
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
type VideoButtonProps = {
|
|
enabled: boolean;
|
|
onToggle: () => Promise<unknown>;
|
|
disabled?: boolean;
|
|
};
|
|
function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
|
const [videoState, toggleVideo] = useAsyncCallback(onToggle);
|
|
const loading = videoState.status === AsyncStatus.Loading;
|
|
|
|
return (
|
|
<TooltipProvider
|
|
position="Top"
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text size="T200">{enabled ? 'Stop Camera' : 'Start Camera'}</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(anchorRef) => (
|
|
<IconButton
|
|
ref={anchorRef}
|
|
variant={enabled ? 'Success' : 'Surface'}
|
|
fill="Soft"
|
|
radii="300"
|
|
size="300"
|
|
className={MobileTouchTarget}
|
|
onClick={toggleVideo}
|
|
aria-label={enabled ? 'Stop Video' : 'Start Video'}
|
|
aria-pressed={enabled}
|
|
outlined
|
|
disabled={disabled || loading}
|
|
>
|
|
<Icon
|
|
size="100"
|
|
src={enabled ? Icons.VideoCamera : Icons.VideoCameraMute}
|
|
filled={enabled}
|
|
/>
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
type ScreenShareButtonProps = {
|
|
enabled: boolean;
|
|
onToggle: () => void;
|
|
disabled?: boolean;
|
|
};
|
|
function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) {
|
|
return (
|
|
<TooltipProvider
|
|
position="Top"
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text size="T200">{enabled ? 'Stop Screenshare' : 'Start Screenshare'}</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(anchorRef) => (
|
|
<IconButton
|
|
ref={anchorRef}
|
|
variant={enabled ? 'Success' : 'Surface'}
|
|
fill="Soft"
|
|
radii="300"
|
|
size="300"
|
|
className={MobileTouchTarget}
|
|
onClick={onToggle}
|
|
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
|
|
aria-pressed={enabled}
|
|
outlined
|
|
disabled={disabled}
|
|
>
|
|
<Icon size="100" src={Icons.ScreenShare} filled={enabled} />
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
export function CallControl({
|
|
callEmbed,
|
|
compact,
|
|
callJoined,
|
|
}: {
|
|
callEmbed: CallEmbed;
|
|
compact: boolean;
|
|
callJoined: boolean;
|
|
}) {
|
|
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
|
|
|
// [Gitea #26] Apply the same room-level camera/screenshare policy as the
|
|
// in-room CallControls bar, so the status bar can't be used to bypass it.
|
|
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;
|
|
const showScreenshare = allowScreenshare || screenshare;
|
|
const [shareConfirm, setShareConfirm] = useState(false);
|
|
|
|
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;
|
|
|
|
const handleHangup = () => {
|
|
if (!callJoined) {
|
|
setCallEmbed(undefined);
|
|
return;
|
|
}
|
|
hangup();
|
|
};
|
|
|
|
return (
|
|
<Box shrink="No" alignItems="Center" gap="300" style={{ position: 'relative' }}>
|
|
<ScreenshareConfirm
|
|
open={shareConfirm}
|
|
align="Start"
|
|
onConfirm={() => {
|
|
callEmbed.control.toggleScreenshare();
|
|
setShareConfirm(false);
|
|
}}
|
|
onCancel={() => setShareConfirm(false)}
|
|
/>
|
|
<Box alignItems="Inherit" gap="200">
|
|
<MicrophoneButton
|
|
enabled={microphone}
|
|
onToggle={handleMicrophoneToggle}
|
|
disabled={!callJoined}
|
|
/>
|
|
<SoundButton
|
|
enabled={sound}
|
|
onToggle={() => callEmbed.control.toggleSound()}
|
|
disabled={!callJoined}
|
|
/>
|
|
{!compact && (showCamera || showScreenshare) && <StatusDivider />}
|
|
{showCamera && (
|
|
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
|
|
)}
|
|
{!compact && showScreenshare && (
|
|
<ScreenShareButton
|
|
enabled={screenshare}
|
|
onToggle={() =>
|
|
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
|
}
|
|
disabled={!callJoined}
|
|
/>
|
|
)}
|
|
</Box>
|
|
<StatusDivider />
|
|
<Chip
|
|
variant="Critical"
|
|
radii="Pill"
|
|
fill="Soft"
|
|
before={
|
|
exiting ? (
|
|
<Spinner variant="Critical" fill="Soft" size="50" />
|
|
) : (
|
|
<Icon size="50" src={Icons.PhoneDown} filled />
|
|
)
|
|
}
|
|
disabled={exiting}
|
|
outlined
|
|
onClick={handleHangup}
|
|
>
|
|
{!compact && (
|
|
<Text as="span" size="L400">
|
|
End
|
|
</Text>
|
|
)}
|
|
</Chip>
|
|
</Box>
|
|
);
|
|
}
|