import type { ConversationSummary } from '@chat-app/shared/chat'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../context/AuthContext'; import { type CallMode, useCall } from '../context/CallContext'; import { getPttSettings, type PttSettings, subscribePttSettings, } from '../lib/pttSettings'; import { useActiveSpeakers } from '../lib/useActiveSpeakers'; import { CallControls } from './CallControls'; import { CallParticipantTile } from './CallParticipantTile'; import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons'; import { ScreenShareViewer } from './ScreenShareViewer'; // Discord-style in-call dock rendered above the message list. Renders three // visual modes driven by CallContext.callMode: grid, focus, fullscreen. // The fullscreen variant absolute-positions itself over the conversation // container so the rail+chat-list remain visible on the left. interface Props { conversation: ConversationSummary; } interface Tile { userId: string; displayName: string; avatarUrl: string | null; self: boolean; muted: boolean; video: boolean; sharing: boolean; remoteSharing: boolean; } export function InCallPanel({ conversation }: Props) { const { t } = useTranslation(['app']); const { state, room, remoteParticipants, isMuted, isE2EEActive, isScreenSharing, remoteScreenShares, callMode, focusedId, toggleMute, toggleScreenShare, hangup, setCallMode, setFocusedId, } = useCall(); const { session } = useAuth(); const myId = session?.user.id ?? null; const activeSpeakers = useActiveSpeakers(room); const active = (state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'outgoing') && state.conversationId === conversation.id; if (!active) return null; const tiles = buildTiles({ conversation, myId, remoteIdentities: remoteParticipants.map((p) => p.identity), isMuted, isScreenSharing, remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)), }); const duration = state.kind === 'connected' ? : null; const statusLabel = state.kind === 'outgoing' ? t('app:call.outgoing_ringing') : state.kind === 'connecting' ? t('app:call.connecting') : remoteParticipants.length === 0 ? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' }) : t('app:call.connected'); const sharingTile = tiles.find((p) => p.sharing); const effectiveFocusedId = focusedId ?? sharingTile?.userId ?? tiles[0]?.userId ?? null; const speaker = tiles.find((p) => p.userId === effectiveFocusedId) ?? tiles[0]; const controls = ( void toggleScreenShare()} onHangup={() => void hangup()} compact={callMode !== 'fullscreen'} glass={callMode === 'fullscreen'} disabledMedia={state.kind !== 'connected'} /> ); if (callMode === 'fullscreen') { return ( setCallMode('grid')} onFocusTile={(id) => { setFocusedId(id); }} controls={controls} /> ); } const title = conversation.type === 'group' ? conversation.name ?? t('app:chats.new_group') : conversation.peer?.displayName ?? '—'; return (
{title} {duration ?? statusLabel} {state.kind !== 'connected' && ( )}
{isE2EEActive && (
{t('app:call.e2ee_active', { defaultValue: 'E2E verschlüsselt' })}
)}
{ setFocusedId(id); if (callMode === 'grid') setCallMode('focus'); }} compact /> {controls}
); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- interface BuildArgs { conversation: ConversationSummary; myId: string | null; remoteIdentities: string[]; isMuted: boolean; isScreenSharing: boolean; remoteSharerIds: Set; } function buildTiles({ conversation, myId, remoteIdentities, isMuted, isScreenSharing, remoteSharerIds, }: BuildArgs): Tile[] { const remoteSet = new Set(remoteIdentities); const out: Tile[] = []; if (myId) { const me = conversation.members.find((m) => m.userId === myId) ?? null; out.push({ userId: myId, displayName: me?.profile?.displayName ?? '?', avatarUrl: me?.profile?.avatarUrl ?? null, self: true, muted: isMuted, video: false, sharing: isScreenSharing, remoteSharing: false, }); } for (const m of conversation.members) { if (m.userId === myId) continue; if (!remoteSet.has(m.userId)) continue; const sharing = remoteSharerIds.has(m.userId); out.push({ userId: m.userId, displayName: m.profile?.displayName ?? '?', avatarUrl: m.profile?.avatarUrl ?? null, self: false, muted: false, video: false, sharing, remoteSharing: sharing, }); } return out; } function LiveDuration({ startedAt }: { startedAt: string }) { const [, tick] = useState(0); useEffect(() => { const id = window.setInterval(() => tick((v) => v + 1), 1000); return () => window.clearInterval(id); }, []); return <>{formatElapsed(Date.now() - new Date(startedAt).getTime())}; } function formatElapsed(ms: number): string { const total = Math.max(0, Math.floor(ms / 1000)); const hh = Math.floor(total / 3600); const mm = Math.floor((total % 3600) / 60); const ss = total % 60; const pad = (n: number) => n.toString().padStart(2, '0'); return `${pad(hh)}:${pad(mm)}:${pad(ss)}`; } function ModeToggles({ mode, onChange, }: { mode: CallMode; onChange: (mode: CallMode) => void; }) { return (
onChange('grid')} label="Grid"> onChange('focus')} label="Fokus"> onChange('fullscreen')} label="Vollbild" >
); } function ModeButton({ active, onClick, label, children, }: { active: boolean; onClick: () => void; label: string; children: React.ReactNode; }) { return ( ); } interface StageProps { tiles: Tile[]; speaker: Tile | undefined; mode: CallMode; activeSpeakers: Set; e2ee: boolean; remoteScreenShares: { track: import('livekit-client').RemoteTrack; participantId: string; participantName: string; }[]; conversationMembers: ConversationSummary['members']; onFocusTile: (id: string) => void; compact?: boolean; } function CallStage({ tiles, speaker, mode, activeSpeakers, e2ee, remoteScreenShares, conversationMembers, onFocusTile, compact = false, }: StageProps) { if (mode === 'focus' && speaker) { const others = tiles.filter((p) => p.userId !== speaker.userId); return (
{others.length > 0 && (
{others.map((p) => (
onFocusTile(p.userId)} />
))}
)}
); } // Grid const gridClass = gridColsFor(tiles.length); return (
{tiles.map((p) => ( onFocusTile(p.userId)} /> ))}
); } function FocusedTile({ tile, e2ee, speaking, remoteScreenShares, conversationMembers, }: { tile: Tile; e2ee: boolean; speaking: boolean; remoteScreenShares: StageProps['remoteScreenShares']; conversationMembers: StageProps['conversationMembers']; }) { // When the focused participant is remotely sharing their screen, embed the // real video stream rather than the fake-window placeholder. if (tile.remoteSharing) { const share = remoteScreenShares.find((s) => s.participantId === tile.userId); const member = conversationMembers.find((m) => m.userId === tile.userId); if (share) { return (
); } } return (
); } function gridColsFor(n: number): string { if (n <= 1) return 'grid-cols-1'; if (n === 2) return 'grid-cols-2'; if (n === 3) return 'grid-cols-3'; if (n === 4) return 'grid-cols-2 grid-rows-2'; return 'grid-cols-3 grid-rows-2'; } // --------------------------------------------------------------------------- // Fullscreen cinema mode // --------------------------------------------------------------------------- interface FullscreenProps { tiles: Tile[]; speaker: Tile | undefined; remoteScreenShares: StageProps['remoteScreenShares']; conversationMembers: StageProps['conversationMembers']; activeSpeakers: Set; e2ee: boolean; onExit: () => void; onFocusTile: (id: string) => void; controls: React.ReactNode; } function FullscreenCall({ tiles, speaker, remoteScreenShares, conversationMembers, activeSpeakers, e2ee, onExit: _onExit, onFocusTile, controls, }: FullscreenProps) { const others = speaker ? tiles.filter((p) => p.userId !== speaker.userId) : tiles; const [hintGone, setHintGone] = useState(false); useEffect(() => { const id = window.setTimeout(() => setHintGone(true), 3500); return () => window.clearTimeout(id); }, []); return (
{speaker && (
{speaker.remoteSharing ? ( ) : (
)}
)} {others.length > 0 && (
{others.slice(0, 4).map((p) => (
onFocusTile(p.userId)} />
))}
)} {!hintGone && ( )}
{controls}
); } function FullscreenShare({ speaker, remoteScreenShares, conversationMembers, }: { speaker: Tile; remoteScreenShares: StageProps['remoteScreenShares']; conversationMembers: StageProps['conversationMembers']; }) { const share = remoteScreenShares.find((s) => s.participantId === speaker.userId); const member = conversationMembers.find((m) => m.userId === speaker.userId); if (!share) return null; return (
); } function PttHint() { const [ptt, setPtt] = useState(() => getPttSettings()); useEffect(() => subscribePttSettings(setPtt), []); if (!ptt.enabled) return null; return (

Push-to-Talk:  {ptt.keyLabel}

); }