232 lines
6.6 KiB
TypeScript
232 lines
6.6 KiB
TypeScript
import { useTranslation } from 'react-i18next';
|
|
|
|
import {
|
|
HeadphonesIcon,
|
|
HeadphonesOffIcon,
|
|
MicIcon,
|
|
MicOffIcon,
|
|
MonitorShareIcon,
|
|
MonitorStopIcon,
|
|
MusicIcon,
|
|
PhoneOffIcon,
|
|
UsersIcon,
|
|
VideoIcon,
|
|
} from './icons';
|
|
|
|
interface Props {
|
|
muted: boolean;
|
|
sharing: boolean;
|
|
video: boolean;
|
|
deafened: boolean;
|
|
onToggleMute: () => void;
|
|
onToggleShare: () => void;
|
|
/** Right-click on the share button opens the quality picker dialog while
|
|
* left-click just starts with last-used settings. Optional so pages that
|
|
* don't need the advanced path (mobile, etc.) can skip it. */
|
|
onShareContextMenu?: (e: React.MouseEvent) => void;
|
|
onToggleVideo?: () => void;
|
|
onToggleDeafen: () => void;
|
|
onHangup: () => void;
|
|
onOpenParticipants?: () => void;
|
|
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
|
onToggleSoundboard?: () => void;
|
|
soundboardOpen?: boolean;
|
|
participantsOpen?: boolean;
|
|
/** Compact variant used inside the docked call (36px buttons). */
|
|
compact?: boolean;
|
|
/** Glass variant used when controls float on fullscreen cinema mode. */
|
|
glass?: boolean;
|
|
/** Disable transient interactions while the call isn't fully connected. */
|
|
disabledMedia?: boolean;
|
|
}
|
|
|
|
export function CallControls({
|
|
muted,
|
|
sharing,
|
|
video,
|
|
deafened,
|
|
onToggleMute,
|
|
onToggleShare,
|
|
onShareContextMenu,
|
|
onToggleVideo,
|
|
onToggleDeafen,
|
|
onHangup,
|
|
onOpenParticipants,
|
|
onToggleSoundboard,
|
|
soundboardOpen = false,
|
|
participantsOpen = false,
|
|
compact = false,
|
|
glass = false,
|
|
disabledMedia = false,
|
|
}: Props) {
|
|
const { t } = useTranslation(['app']);
|
|
const wrapBase = glass
|
|
? 'glass-pill flex items-center justify-center gap-2 rounded-[18px] p-2.5'
|
|
: 'flex items-center justify-center gap-2 border-t border-line bg-surface-3 p-3';
|
|
const btnSize = compact ? 'h-9 w-9 rounded-[10px]' : 'h-12 w-12 rounded-[14px]';
|
|
const hangupSize = compact ? 'h-9 w-[54px] rounded-[10px]' : 'h-12 w-[72px] rounded-[14px]';
|
|
return (
|
|
<div className={wrapBase}>
|
|
<CallButton
|
|
label={muted ? t('app:call.unmute') : t('app:call.mute')}
|
|
active={muted}
|
|
activeTone="danger"
|
|
onClick={onToggleMute}
|
|
disabled={disabledMedia}
|
|
glass={glass}
|
|
className={btnSize}
|
|
>
|
|
{muted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
|
|
</CallButton>
|
|
<CallButton
|
|
label={
|
|
deafened
|
|
? t('app:call.undeafen', { defaultValue: 'Ton wieder aktiv' })
|
|
: t('app:call.deafen', { defaultValue: 'Alle stumm' })
|
|
}
|
|
active={deafened}
|
|
activeTone="danger"
|
|
onClick={onToggleDeafen}
|
|
glass={glass}
|
|
className={btnSize}
|
|
>
|
|
{deafened ? (
|
|
<HeadphonesOffIcon className="h-5 w-5" />
|
|
) : (
|
|
<HeadphonesIcon className="h-5 w-5" />
|
|
)}
|
|
</CallButton>
|
|
{onToggleVideo && (
|
|
<CallButton
|
|
label={t('app:call.start_video', { defaultValue: 'Video' })}
|
|
active={video}
|
|
activeTone="accent"
|
|
onClick={onToggleVideo}
|
|
disabled={disabledMedia}
|
|
glass={glass}
|
|
className={btnSize}
|
|
>
|
|
<VideoIcon className="h-5 w-5" />
|
|
</CallButton>
|
|
)}
|
|
<CallButton
|
|
label={
|
|
sharing
|
|
? t('app:call.stop_share_screen', { defaultValue: 'Screen-Share stoppen' })
|
|
: t('app:call.share_screen', { defaultValue: 'Bildschirm teilen' })
|
|
}
|
|
active={sharing}
|
|
activeTone="accent"
|
|
onClick={onToggleShare}
|
|
{...(onShareContextMenu ? { onContextMenu: onShareContextMenu } : {})}
|
|
disabled={disabledMedia}
|
|
glass={glass}
|
|
className={btnSize}
|
|
>
|
|
{sharing ? (
|
|
<MonitorStopIcon className="h-5 w-5" />
|
|
) : (
|
|
<MonitorShareIcon className="h-5 w-5" />
|
|
)}
|
|
</CallButton>
|
|
{onToggleSoundboard && (
|
|
<CallButton
|
|
label={t('app:soundboard.toggle', { defaultValue: 'Soundboard' })}
|
|
active={soundboardOpen}
|
|
activeTone="accent"
|
|
onClick={onToggleSoundboard}
|
|
glass={glass}
|
|
className={btnSize}
|
|
>
|
|
<MusicIcon className="h-5 w-5" />
|
|
</CallButton>
|
|
)}
|
|
{onOpenParticipants && (
|
|
<CallButton
|
|
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
|
onClick={onOpenParticipants}
|
|
active={participantsOpen}
|
|
activeTone="accent"
|
|
dataTrigger="participants"
|
|
glass={glass}
|
|
className={btnSize}
|
|
>
|
|
<UsersIcon className="h-5 w-5" />
|
|
</CallButton>
|
|
)}
|
|
<CallButton
|
|
label={t('app:call.hangup')}
|
|
onClick={onHangup}
|
|
tone="danger"
|
|
glass={glass}
|
|
className={hangupSize}
|
|
>
|
|
<PhoneOffIcon className="h-5 w-5" />
|
|
</CallButton>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface CallButtonProps {
|
|
label: string;
|
|
onClick: () => void;
|
|
onContextMenu?: (e: React.MouseEvent) => void;
|
|
disabled?: boolean;
|
|
active?: boolean;
|
|
activeTone?: 'accent' | 'danger';
|
|
tone?: 'default' | 'danger';
|
|
glass?: boolean;
|
|
className?: string;
|
|
/** Stable trigger id so portals (popovers) can skip outside-click dismiss
|
|
* when the user is toggling their own trigger. */
|
|
dataTrigger?: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
function CallButton({
|
|
label,
|
|
onClick,
|
|
onContextMenu,
|
|
disabled,
|
|
active,
|
|
activeTone = 'accent',
|
|
tone = 'default',
|
|
glass = false,
|
|
className = '',
|
|
dataTrigger,
|
|
children,
|
|
}: CallButtonProps) {
|
|
const base =
|
|
'inline-flex cursor-pointer items-center justify-center border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-50';
|
|
|
|
let toneClass: string;
|
|
if (tone === 'danger') {
|
|
toneClass = 'bg-rose-600 text-white border-rose-600 hover:bg-rose-500';
|
|
} else if (active && activeTone === 'danger') {
|
|
toneClass = 'bg-rose-600 text-white border-rose-600 hover:bg-rose-500';
|
|
} else if (active) {
|
|
toneClass = 'bg-accent text-accent-fg border-accent hover:brightness-110';
|
|
} else if (glass) {
|
|
toneClass =
|
|
'border-white/15 bg-white/10 text-white hover:bg-white/15';
|
|
} else {
|
|
toneClass = 'border-line bg-surface-2 text-fg hover:bg-surface';
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
onContextMenu={onContextMenu}
|
|
disabled={disabled}
|
|
aria-label={label}
|
|
aria-pressed={active}
|
|
title={label}
|
|
className={`${base} ${toneClass} ${className}`}
|
|
{...(dataTrigger ? { [`data-${dataTrigger}-trigger`]: 'true' } : {})}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|