import type { ConversationSummary } from '@chat-app/shared/chat'; import type { RemoteParticipant, Room } from 'livekit-client'; import { Track } from 'livekit-client'; import { useEffect, useMemo, 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 { ParticipantVolumeMenu } from './ParticipantVolumeMenu'; import { ScreenShareDialog } from './ScreenShareDialog'; import { ScreenShareViewer } from './ScreenShareViewer'; import { SoundboardPanel } from './SoundboardPanel'; // 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 { // `user` = person with avatar/video/mute info. Speaking ring applies here. // `screen` = a separate screen-share window from a user. No speaking // indicator, no mute, no avatar — just the stream. kind: 'user' | 'screen'; // Stable id used for focus tracking + React keys. `user:${userId}` or // `screen:${userId}`. id: string; userId: string; displayName: string; avatarUrl: string | null; self: boolean; muted: boolean; deafened: boolean; video: boolean; videoTrack: MediaStreamTrack | null; // True iff this is the current user's own screen-share tile. sharing: boolean; // True iff this is a remote user's screen-share tile. remoteSharing: boolean; } export function InCallPanel({ conversation }: Props) { const { t } = useTranslation(['app']); const { state, room, remoteParticipants, isMuted, isE2EEActive, isScreenSharing, isCameraEnabled, isDeafened, remoteDeafen, remoteMute, remoteScreenShares, callMode, focusedId, toggleMute, startScreenShare, stopScreenShare, toggleCamera, toggleDeafen, hangup, setCallMode, setFocusedId, } = useCall(); const { session } = useAuth(); const myId = session?.user.id ?? null; const activeSpeakers = useActiveSpeakers(room); const [shareDialogOpen, setShareDialogOpen] = useState(false); const [soundboardOpen, setSoundboardOpen] = useState(false); const [volumeMenu, setVolumeMenu] = useState< { userId: string; displayName: string; x: number; y: number } | null >(null); const openVolumeMenu = (tile: Tile, e: React.MouseEvent) => { if (tile.self) return; if (tile.kind !== 'user') return; e.preventDefault(); setVolumeMenu({ userId: tile.userId, displayName: tile.displayName, x: e.clientX, y: e.clientY, }); }; const active = (state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'outgoing') && state.conversationId === conversation.id; if (!active) return null; const tiles = buildTiles({ conversation, myId, room, remoteParticipants, isMuted, isDeafened, remoteDeafen, remoteMute, isScreenSharing, isCameraEnabled, 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'); // A screen-share tile becomes the auto-focus target when no one explicitly // picked a tile yet. Focus ids now use Tile.id (kind-prefixed) so we can // distinguish a user's own avatar tile from their screen tile. const screenTile = tiles.find((p) => p.kind === 'screen'); const effectiveFocusedId = focusedId ?? screenTile?.id ?? tiles[0]?.id ?? null; const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0]; const controls = ( { if (isScreenSharing) { void stopScreenShare(); } else { setShareDialogOpen(true); } }} onToggleVideo={() => void toggleCamera()} onToggleDeafen={toggleDeafen} onToggleSoundboard={() => setSoundboardOpen((v) => !v)} soundboardOpen={soundboardOpen} onHangup={() => void hangup()} compact={callMode !== 'fullscreen'} glass={callMode === 'fullscreen'} disabledMedia={state.kind !== 'connected'} /> ); if (callMode === 'fullscreen') { // In fullscreen, a "manual focus" = user explicitly picked someone, OR // someone is sharing a screen, OR exactly one non-self speaker is talking // (auto-promote). Without that we show an even grid of all participants // (Discord default). Clicking a tile switches to the big-speaker layout. const speakingNonSelf = tiles.filter( (t: Tile) => activeSpeakers.has(t.userId) && !t.self && t.kind === 'user', ); const autoSpeaker = focusedId === null && screenTile === undefined && speakingNonSelf.length === 1 ? speakingNonSelf[0] : undefined; const hasFocus = focusedId !== null || screenTile !== undefined || autoSpeaker !== undefined; const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined; return ( <> setCallMode('grid')} onFocusTile={(id) => { // Toggle: click the already-focused tile to return to grid. setFocusedId(focusedId === id ? null : id); }} onTileContextMenu={openVolumeMenu} controls={controls} /> {volumeMenu && ( setVolumeMenu(null)} /> )} setSoundboardOpen(false)} /> > ); } const title = conversation.type === 'group' ? conversation.name ?? t('app:chats.new_group') : conversation.peer?.displayName ?? '—'; // Focus mode dedicates the entire call-panel vertical slot to the speaker so // the tile can grow in height (grid mode's 420px cap leaves it squashed). const sectionClass = callMode === 'focus' ? 'flex min-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2' : 'flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2'; const sectionHeight = callMode === 'focus' ? '75%' : '50%'; return ( {title} · {duration ?? statusLabel} {state.kind !== 'connected' && ( )} {isE2EEActive && ( {t('app:call.e2ee_active', { defaultValue: 'E2E verschlüsselt' })} )} { setFocusedId(id); if (callMode === 'grid') setCallMode('focus'); }} onTileContextMenu={openVolumeMenu} compact /> {controls} setShareDialogOpen(false)} onStart={async (opts) => { await startScreenShare(opts); }} /> {volumeMenu && ( setVolumeMenu(null)} /> )} setSoundboardOpen(false)} /> ); } // Fixed-position overlay so the popover sits above both docked + fullscreen // call modes without needing a portal or parent-relative anchoring. function SoundboardPopover({ open, onClose }: { open: boolean; onClose: () => void }) { if (!open) return null; return ( ); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- interface BuildArgs { conversation: ConversationSummary; myId: string | null; room: Room | null; remoteParticipants: RemoteParticipant[]; isMuted: boolean; isDeafened: boolean; remoteDeafen: Record; remoteMute: Record; isScreenSharing: boolean; isCameraEnabled: boolean; remoteSharerIds: Set; } function cameraTrackFor( participant: { videoTrackPublications: Map } | null, ): MediaStreamTrack | null { if (!participant) return null; for (const pub of participant.videoTrackPublications.values()) { if (pub.source === Track.Source.Camera && pub.track) { return pub.track.mediaStreamTrack ?? null; } } return null; } function buildTiles({ conversation, myId, room, remoteParticipants, isMuted, isDeafened, remoteDeafen, remoteMute, isScreenSharing, isCameraEnabled, remoteSharerIds, }: BuildArgs): Tile[] { const remoteById = new Map(); for (const p of remoteParticipants) { if (p.identity) remoteById.set(p.identity, p); } const out: Tile[] = []; if (myId) { const me = conversation.members.find((m) => m.userId === myId) ?? null; // Source-of-truth for own mic: the LocalParticipant publication state. // `isMuted` reflects the toggle button, but if mic never published (no // device / permission denied) the toggle stays false while the real // state is "muted". Combine both so the badge always matches reality. const micLive = room?.localParticipant?.isMicrophoneEnabled ?? false; out.push({ kind: 'user', id: 'user:' + myId, userId: myId, displayName: me?.profile?.displayName ?? '?', avatarUrl: me?.profile?.avatarUrl ?? null, self: true, muted: isMuted || !micLive, deafened: isDeafened, video: isCameraEnabled, videoTrack: cameraTrackFor(room?.localParticipant ?? null), sharing: false, remoteSharing: false, }); if (isScreenSharing) { out.push({ kind: 'screen', id: 'screen:' + myId, userId: myId, displayName: (me?.profile?.displayName ?? '?') + ' · Bildschirm', avatarUrl: me?.profile?.avatarUrl ?? null, self: true, muted: false, deafened: false, video: false, videoTrack: null, sharing: true, remoteSharing: false, }); } } for (const m of conversation.members) { if (m.userId === myId) continue; const rp = remoteById.get(m.userId); if (!rp) continue; out.push({ kind: 'user', id: 'user:' + m.userId, userId: m.userId, displayName: m.profile?.displayName ?? '?', avatarUrl: m.profile?.avatarUrl ?? null, self: false, // Peer's self-reported mute state via data channel. LiveKit's own // `isMicrophoneEnabled` no longer flips on mute since the pipeline // output track stays published. See remoteMute broadcast in CallContext. muted: remoteMute[m.userId] ?? false, // Deafen state arrives via LiveKit data channel; see CallContext. deafened: remoteDeafen[m.userId] ?? false, video: rp.isCameraEnabled, videoTrack: cameraTrackFor(rp), sharing: false, remoteSharing: false, }); if (remoteSharerIds.has(m.userId)) { out.push({ kind: 'screen', id: 'screen:' + m.userId, userId: m.userId, displayName: (m.profile?.displayName ?? '?') + ' · Bildschirm', avatarUrl: m.profile?.avatarUrl ?? null, self: false, muted: false, deafened: false, video: false, videoTrack: null, sharing: false, remoteSharing: true, }); } } 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 ( {children} ); } 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; onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void; compact?: boolean; } // Dispatches a Tile to the right renderer. Screen-tiles show the screenshare // stream directly (no avatar, no speaking ring, no mic indicator); user-tiles // render via CallParticipantTile with all its chrome. function TileRender({ tile, activeSpeakers, e2ee, remoteScreenShares, conversationMembers, size, focused, onClick, onContextMenu, }: { tile: Tile; activeSpeakers: Set; e2ee: boolean; remoteScreenShares: StageProps['remoteScreenShares']; conversationMembers: StageProps['conversationMembers']; size?: 'default' | 'small'; focused?: boolean; onClick?: () => void; onContextMenu?: (e: React.MouseEvent) => void; }): JSX.Element { if (tile.kind === 'screen') { if (tile.self) { // Local screenshare preview — we don't mirror a copy of the outgoing // stream. Render a labelled placeholder card so the user knows their // share is live without double-encoding the stream. return ( Live {tile.displayName} ); } const share = remoteScreenShares.find((s) => s.participantId === tile.userId); const member = conversationMembers.find((m) => m.userId === tile.userId); if (!share) return ; return ( div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')} > ); } return ( ); } function CallStage({ tiles, speaker, mode, activeSpeakers, e2ee, remoteScreenShares, conversationMembers, onFocusTile, onTileContextMenu, compact = false, }: StageProps) { if (mode === 'focus' && speaker) { const others = tiles.filter((p) => p.id !== speaker.id); return ( {others.length > 0 && ( {others.map((p) => ( onFocusTile(p.id)} {...(onTileContextMenu ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) } : {})} /> ))} )} ); } // Grid const gridClass = gridColsFor(tiles.length); return ( {tiles.map((p) => ( onFocusTile(p.id)} {...(onTileContextMenu ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) } : {})} /> ))} ); } function FocusedTile({ tile, e2ee, activeSpeakers, remoteScreenShares, conversationMembers, }: { tile: Tile; e2ee: boolean; activeSpeakers: Set; remoteScreenShares: StageProps['remoteScreenShares']; conversationMembers: StageProps['conversationMembers']; }) { return ( ); } const GRID_PAGE_SIZE = 12; function gridColsFor(n: number): string { // Explicit `grid-rows-*` so cells get a defined height (1fr of available // space). Without this, implicit rows default to auto → they size to // content, and a video element's intrinsic size blows the tile past the // container bounds (overlapping the toolbar below). if (n <= 1) return 'grid-cols-1 grid-rows-1'; if (n === 2) return 'grid-cols-2 grid-rows-1'; if (n === 3) return 'grid-cols-3 grid-rows-1'; if (n === 4) return 'grid-cols-2 grid-rows-2'; if (n <= 6) return 'grid-cols-3 grid-rows-2'; if (n <= 9) return 'grid-cols-3 grid-rows-3'; return 'grid-cols-4 grid-rows-3'; } // Promote self + active speakers to the front of the tile list. Stable // otherwise. Used by both pagination (so page 1 always carries the most // "useful" tiles) and active-speaker promotion in fullscreen. function prioritizeTiles(tiles: Tile[], activeSpeakers: Set): Tile[] { const score = (t: Tile): number => { if (t.self) return 3; if (activeSpeakers.has(t.userId)) return 2; if (t.kind === 'screen') return 1; return 0; }; return [...tiles].sort((a, b) => score(b) - score(a)); } // --------------------------------------------------------------------------- // 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; onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void; controls: React.ReactNode; } function FullscreenCall({ tiles, speaker, remoteScreenShares, conversationMembers, activeSpeakers, e2ee, onExit: _onExit, onFocusTile, onTileContextMenu, controls, }: FullscreenProps) { const [hintGone, setHintGone] = useState(false); const [page, setPage] = useState(0); useEffect(() => { const id = window.setTimeout(() => setHintGone(true), 3500); return () => window.clearTimeout(id); }, []); const hasFocus = speaker !== undefined; const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : []; // Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles // exist, slice them into pages. Reset to page 0 if the page count drops // below the current page (someone left). const sortedGridTiles = useMemo( () => prioritizeTiles(tiles, activeSpeakers), [tiles, activeSpeakers], ); const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE)); useEffect(() => { if (page >= pageCount) setPage(0); }, [pageCount, page]); const visibleTiles = sortedGridTiles.slice( page * GRID_PAGE_SIZE, (page + 1) * GRID_PAGE_SIZE, ); const gridClass = gridColsFor(visibleTiles.length); return ( {/* Content area. pb-24 reserves ~96px space at the bottom for the floating controls bar so tiles never sit behind it. */} {hasFocus ? ( <> onFocusTile(speaker!.id)} title="Zurück zur Übersicht" > onTileContextMenu(speaker!, e) } : {})} /> {others.length > 0 && ( {others.map((p) => ( onFocusTile(p.id)} {...(onTileContextMenu ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) } : {})} /> ))} )} > ) : ( {visibleTiles.map((p) => ( onFocusTile(p.id)} {...(onTileContextMenu ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) } : {})} /> ))} {pageCount > 1 && ( setPage((p) => (p === 0 ? pageCount - 1 : p - 1))} className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3" aria-label="Vorherige Seite" > ‹ {page + 1} / {pageCount} setPage((p) => (p + 1) % pageCount)} className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3" aria-label="Nächste Seite" > › )} )} {!hintGone && ( Esc zum Verlassen )} {controls} ); } function PttHint() { const [ptt, setPtt] = useState(() => getPttSettings()); useEffect(() => subscribePttSettings(setPtt), []); if (!ptt.enabled) return null; return ( Push-to-Talk: {ptt.keyLabel} ); }
Push-to-Talk: {ptt.keyLabel}