import type { ConversationSummary } from '@chat-app/shared/chat'; import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client'; import { RoomEvent, 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 { listSounds, subscribeSoundboardChanges, } from '../lib/soundboardStorage'; import { getLiveCaptionsSettings, isLiveCaptionsSupported, subscribeLiveCaptionsSettings, updateLiveCaptionsSettings, } from '../lib/liveCaptions'; import { useActiveSpeakers } from '../lib/useActiveSpeakers'; import { CallCaptionsOverlay } from './CallCaptionsOverlay'; import { CallControls } from './CallControls'; import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile'; import { CallStatsOverlay } from './CallStatsOverlay'; import { ScreenSharePickerModal } from './ScreenSharePickerModal'; import { GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons'; import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover'; import { ParticipantVolumeMenu } from './ParticipantVolumeMenu'; import { ScreenShareContextMenu } from './ScreenShareContextMenu'; import { ScreenShareViewer } from './ScreenShareViewer'; import { SoundboardPanel } from './SoundboardPanel'; import { UserProfilePopover } from './UserProfilePopover'; // 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; // LiveKit-measured connection quality for this participant. Drives the // Discord-style Wifi badge on user-tiles. Undefined for screen-tiles. connectionQuality?: TileConnectionQuality; // Discord-style host marker — only true for the call initiator's tile in // group calls. Drives the crown. isHost: boolean; // True iff the user pinned this tile via right-click → "Anpinnen". // Mirrors the value of CallContext.focusedId for this tile id. pinned: boolean; // True iff this user-tile's owner is currently publishing a screen share. // Drives the LIVE pill on the avatar tile so the share-tile next to it // visually belongs to the same person. Always false on screen-kind tiles. streaming: 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, stopScreenShare, toggleCamera, toggleDeafen, hangup, setCallMode, setFocusedId, micError, clearMicError, retryMic, dismissedShareUserIds, connectionQualities, callHostId, } = useCall(); const { session } = useAuth(); const myId = session?.user.id ?? null; const activeSpeakers = useActiveSpeakers(room); const [soundboardOpen, setSoundboardOpen] = useState(false); const [participantsOpen, setParticipantsOpen] = useState(false); // Discord-style screen-share configure modal. Opens when the user clicks // the Share button while not yet sharing — collects FPS/resolution/audio // before triggering the OS source picker. const [sharePickerOpen, setSharePickerOpen] = useState(false); // Discord-style debug stats overlay (Ctrl+Shift+S toggles). const [statsOverlayOpen, setStatsOverlayOpen] = useState(false); // Live-captions toggle — mirror of getLiveCaptionsSettings().enabled so the // controls bar can show an "active" state without polling. Captions // broadcasting is wired in CallContext via useLiveCaptions; this only // tracks the toggle state for the button. const [captionsEnabled, setCaptionsEnabled] = useState( () => getLiveCaptionsSettings().enabled, ); useEffect( () => subscribeLiveCaptionsSettings((s) => setCaptionsEnabled(s.enabled)), [], ); useEffect(() => { const onKey = (e: KeyboardEvent) => { // Match the modifier exactly to avoid clobbering other Ctrl+Shift combos. if ( (e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey && e.code === 'KeyS' ) { e.preventDefault(); setStatsOverlayOpen((v) => !v); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); const [volumeMenu, setVolumeMenu] = useState< { userId: string; displayName: string; x: number; y: number; tileId: string; self: boolean; } | null >(null); const [shareMenu, setShareMenu] = useState< { userId: string; displayName: string; hasAudio: boolean; x: number; y: number } | null >(null); // Discord-style profile popover triggered from the right-click context menu // on a participant tile. Anchored at the same coords as the volume menu. const [profileMenu, setProfileMenu] = useState< { userId: string; x: number; y: number } | null >(null); // Soundboard-count so the in-call bar only surfaces the music button when // the user actually has something to play. Matches Discord's "hide soundboard // when empty" behaviour — no point dangling a button that opens to a blank // "Keine Sounds" popover. Subscribes live so a sound added mid-call makes // the button pop in without reopening the call. const [soundboardCount, setSoundboardCount] = useState(null); useEffect(() => { let cancelled = false; const refresh = async () => { try { const all = await listSounds(); if (!cancelled) setSoundboardCount(all.length); } catch { if (!cancelled) setSoundboardCount(0); } }; void refresh(); const unsub = subscribeSoundboardChanges(() => { void refresh(); }); return () => { cancelled = true; unsub(); }; }, []); // Close the popover if the user just cleared their last sound while it was // open — keeps the panel from lingering over an empty list. useEffect(() => { if (soundboardCount === 0) setSoundboardOpen(false); }, [soundboardCount]); // Single right-click dispatcher for all tiles. User-tiles open the volume // menu; screen-tiles open the share-specific menu (volume + mute + stop // watching). Self-tiles get no menu — no volume to control, and you can // stop your own share from the control bar. const openTileContextMenu = (tile: Tile, e: React.MouseEvent) => { e.preventDefault(); if (tile.kind === 'user') { // Self-tile gets the pin row only — there's no remote volume to control. setShareMenu(null); setVolumeMenu({ userId: tile.userId, displayName: tile.displayName, x: e.clientX, y: e.clientY, tileId: tile.id, self: tile.self, }); return; } // Self screen-tiles: same — pin row is still useful, share menu is not. if (tile.self) return; // Screen-tile. Check whether the participant has a published screen-share // audio track so the menu can hide the volume/mute rows when there's // nothing to control. const participant = remoteParticipants.find((p) => p.identity === tile.userId); let hasAudio = false; if (participant) { for (const pub of participant.audioTrackPublications.values()) { if (pub.source === Track.Source.ScreenShareAudio) { hasAudio = true; break; } } } setVolumeMenu(null); setShareMenu({ userId: tile.userId, displayName: tile.displayName.replace(/\s·\sBildschirm$/, ''), hasAudio, x: e.clientX, y: e.clientY, }); }; const active = (state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'reconnecting' || state.kind === 'outgoing') && state.conversationId === conversation.id; if (!active) return null; const tiles = buildTiles({ conversation, myId, room, remoteParticipants, isMuted, isDeafened, remoteDeafen, remoteMute, isScreenSharing, isCameraEnabled, // Sharer ids that survived the user's dismiss-set. If the user did // "Zuschauen beenden" on someone's share, they drop out of the tile // grid until that sharer stops + restarts (TrackUnsubscribed clears // dismissedShareUserIds — see CallContext). remoteSharerIds: new Set( remoteScreenShares .map((s) => s.participantId) .filter((id) => !dismissedShareUserIds.has(id)), ), connectionQualities, // Discord-style host crown — only show in group calls (>2 members). // 1:1s have no concept of "host" so the crown stays hidden. hostUserId: conversation.members.length > 2 ? callHostId : null, // Pin marker — focusedId === tile.id means the user explicitly pinned. pinnedTileId: focusedId, }); // Duration keeps ticking during reconnecting so the user sees the call is // still alive — but the status label below takes precedence in the header // so the "Verbinde neu…" message is prominent, not buried under the timer. const duration = state.kind === 'connected' ? : null; const statusLabel = state.kind === 'outgoing' ? t('app:call.outgoing_ringing') : state.kind === 'connecting' ? t('app:call.connecting') : state.kind === 'reconnecting' ? t('app:call.reconnecting', { defaultValue: 'Verbinde neu…' }) : remoteParticipants.length === 0 ? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' }) : t('app:call.connected'); // Discord-style precedence: // 1. focusedId set → 'focus', that tile is the stage. // 2. ≥2 shares, no pin → 'bento', shares fill the stage, webcams strip. // 3. exactly 1 share, no pin → 'focus' (auto-promote share). // 4. no shares, no pin → 'equal-grid'. type StageLayout = | { kind: 'equal-grid' } | { kind: 'focus'; bigTileId: string } | { kind: 'bento'; shareIds: string[] }; const shareIds = tiles.filter((t) => t.kind === 'screen').map((t) => t.id); const stageLayout: StageLayout = (() => { if (focusedId !== null && tiles.some((t) => t.id === focusedId)) { return { kind: 'focus', bigTileId: focusedId }; } if (shareIds.length >= 2) return { kind: 'bento', shareIds }; if (shareIds.length === 1 && shareIds[0]) { return { kind: 'focus', bigTileId: shareIds[0] }; } return { kind: 'equal-grid' }; })(); // Tile that owns the big stage when layout is 'focus'. Resolved lazily by // callers below — kept here just so the speaker prop on CallStage/Fullscreen // stays consistent with the layout decision. const bigTile = stageLayout.kind === 'focus' ? tiles.find((t) => t.id === stageLayout.bigTileId) : undefined; const controls = ( { if (isScreenSharing) { void stopScreenShare(); } else { // Discord-parity: open the configure-then-share modal instead of // jumping straight into the OS picker. Modal calls // startScreenShare with the chosen overrides on confirm. setSharePickerOpen(true); } }} onShareContextMenu={(e) => { e.preventDefault(); if (!isScreenSharing) setSharePickerOpen(true); }} onToggleVideo={() => void toggleCamera()} onToggleDeafen={toggleDeafen} onOpenParticipants={() => setParticipantsOpen((v) => !v)} participantsOpen={participantsOpen} // Soundboard-Button nur wenn mind. ein Sound existiert. Bis der Count // aus IndexedDB geladen ist (null), auch nicht rendern — verhindert // einen Flash des Buttons beim Call-Start wenn der User eh keine // Sounds hat. {...(soundboardCount && soundboardCount > 0 ? { onToggleSoundboard: () => setSoundboardOpen((v) => !v), soundboardOpen, } : {})} // Live-Captions only when SpeechRecognition is available in the // runtime — Firefox lacks it, would just show a dead button. {...(isLiveCaptionsSupported() ? { onToggleCaptions: () => updateLiveCaptionsSettings({ enabled: !captionsEnabled }), captionsOn: captionsEnabled, } : {})} onHangup={() => void hangup()} compact={callMode !== 'fullscreen'} glass={callMode === 'fullscreen'} disabledMedia={state.kind !== 'connected' && state.kind !== 'reconnecting'} /> ); // Only participant-tiles feed the popover (screen-share tiles aren't // people). Own row is always first, rest follows conversation order. const participantRows: ParticipantRow[] = tiles .filter((t) => t.kind === 'user') .map((t) => ({ userId: t.userId, displayName: t.displayName, avatarUrl: t.avatarUrl, self: t.self, muted: t.muted, deafened: t.deafened, })); if (callMode === 'fullscreen') { return ( <> {micError && (
void retryMic()} onDismiss={clearMicError} />
)} setCallMode('grid')} onFocusTile={(id) => { // Toggle: click the already-focused tile to return to grid. setFocusedId(focusedId === id ? null : id); }} onTileContextMenu={openTileContextMenu} controls={controls} // Any active popover / menu / banner pins the controls so the user // can interact with them without the chrome fading out under their // cursor while they're mid-action. keepControlsVisible={ soundboardOpen || participantsOpen || volumeMenu !== null || shareMenu !== null || micError !== null } /> {volumeMenu && ( { // We're already in fullscreen here — toggling focusedId is enough, // no mode-switch needed. setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId); }} // Self-tiles get no slider — the volume control would adjust the // remote volume of someone the local user isn't hearing. {...(volumeMenu.self ? { renderVolume: false } : {})} {...(volumeMenu.self ? {} : { onShowProfile: () => setProfileMenu({ userId: volumeMenu.userId, x: volumeMenu.x, y: volumeMenu.y, }), })} onClose={() => setVolumeMenu(null)} /> )} {shareMenu && ( setShareMenu(null)} /> )} setSoundboardOpen(false)} /> setParticipantsOpen(false)} /> {profileMenu && ( m.userId === profileMenu.userId)?.profile ?? null } x={profileMenu.x} y={profileMenu.y} onClose={() => setProfileMenu(null)} /> )} {sharePickerOpen && ( setSharePickerOpen(false)} /> )} {statsOverlayOpen && room && ( setStatsOverlayOpen(false)} /> )} {captionsEnabled && } ); } 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' })}
)}
{micError && ( void retryMic()} onDismiss={clearMicError} /> )} { setFocusedId(focusedId === id ? null : id); }} onTileContextMenu={openTileContextMenu} compact /> {controls} {volumeMenu && ( { setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId); }} {...(volumeMenu.self ? { renderVolume: false } : {})} {...(volumeMenu.self ? {} : { onShowProfile: () => setProfileMenu({ userId: volumeMenu.userId, x: volumeMenu.x, y: volumeMenu.y, }), })} onClose={() => setVolumeMenu(null)} /> )} {profileMenu && ( m.userId === profileMenu.userId)?.profile ?? null } x={profileMenu.x} y={profileMenu.y} onClose={() => setProfileMenu(null)} /> )} {shareMenu && ( setShareMenu(null)} /> )} setSoundboardOpen(false)} /> setParticipantsOpen(false)} /> {sharePickerOpen && ( setSharePickerOpen(false)} /> )} {statsOverlayOpen && room && ( setStatsOverlayOpen(false)} /> )} {captionsEnabled && }
); } // 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; connectionQualities: Record; /** Host userId (call initiator). Null in re-joined calls and 1:1 calls. * Used to mark exactly one user's tile with the crown. */ hostUserId: string | null; /** Tile id (e.g. "user:abc") the user has pinned via right-click. Null * while no pin is active or while auto-tracking the active speaker. */ pinnedTileId: string | null; } 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, connectionQualities, hostUserId, pinnedTileId, }: 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; const myQuality = connectionQualities[myId] as TileConnectionQuality | undefined; 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, ...(myQuality ? { connectionQuality: myQuality } : {}), isHost: hostUserId === myId, pinned: pinnedTileId === 'user:' + myId, streaming: isScreenSharing, }); 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, isHost: false, pinned: pinnedTileId === 'screen:' + myId, streaming: false, }); } } for (const m of conversation.members) { if (m.userId === myId) continue; const rp = remoteById.get(m.userId); if (!rp) continue; const peerQuality = connectionQualities[m.userId] as TileConnectionQuality | undefined; 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, ...(peerQuality ? { connectionQuality: peerQuality } : {}), isHost: hostUserId === m.userId, pinned: pinnedTileId === 'user:' + m.userId, streaming: remoteSharerIds.has(m.userId), }); 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, isHost: false, pinned: pinnedTileId === 'screen:' + m.userId, streaming: false, }); } } 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('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; 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) { return ; } 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 (
onTileContextMenu(speaker, e) } : {})} />
{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, onContextMenu, }: { tile: Tile; e2ee: boolean; activeSpeakers: Set; remoteScreenShares: StageProps['remoteScreenShares']; conversationMembers: StageProps['conversationMembers']; onContextMenu?: (e: React.MouseEvent) => void; }) { return (
); } const GRID_PAGE_SIZE = 12; function gridColsFor(n: number): string { // Discord-style: column count only. Cells are `aspect-video` so their // height follows from their width, and the container centers them // vertically when the row stack is shorter than the available area. 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'; if (n <= 6) return 'grid-cols-3'; if (n <= 9) return 'grid-cols-3'; return 'grid-cols-4'; } // 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; /** When true, controls stay visible regardless of mouse idle (used while * a popover / menu / error banner is open). */ keepControlsVisible?: boolean; } const CONTROLS_IDLE_MS = 5_000; function FullscreenCall({ tiles, speaker, remoteScreenShares, conversationMembers, activeSpeakers, e2ee, onExit: _onExit, onFocusTile, onTileContextMenu, controls, keepControlsVisible = false, }: FullscreenProps) { const [hintGone, setHintGone] = useState(false); const [page, setPage] = useState(0); // Discord-style auto-hide: controls fade out after 5s of mouse idle in // fullscreen so tiles aren't partially obscured. Any mousemove (or a // popover opening via keepControlsVisible) brings them back immediately. const [controlsVisible, setControlsVisible] = useState(true); // Session-only toggle to collapse the participant strip while watching a // focused tile (screen share, speaker). Matches Discord's "Hide non-video // participants" — gives the focused content the full fullscreen height. const [stripHidden, setStripHidden] = useState(false); useEffect(() => { const id = window.setTimeout(() => setHintGone(true), 3500); return () => window.clearTimeout(id); }, []); useEffect(() => { if (keepControlsVisible) { setControlsVisible(true); return; } let timer: number | null = window.setTimeout( () => setControlsVisible(false), CONTROLS_IDLE_MS, ); const reset = () => { setControlsVisible(true); if (timer !== null) window.clearTimeout(timer); timer = window.setTimeout( () => setControlsVisible(false), CONTROLS_IDLE_MS, ); }; window.addEventListener('mousemove', reset); window.addEventListener('touchstart', reset); return () => { if (timer !== null) window.clearTimeout(timer); window.removeEventListener('mousemove', reset); window.removeEventListener('touchstart', reset); }; }, [keepControlsVisible]); 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 and prioritize active speakers onto page 1. // Otherwise keep a stable order (Discord-style) so tiles don't shuffle // whenever someone speaks. const needsPagination = tiles.length > GRID_PAGE_SIZE; const sortedGridTiles = useMemo( () => (needsPagination ? prioritizeTiles(tiles, activeSpeakers) : tiles), [tiles, activeSpeakers, needsPagination], ); 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 && !stripHidden && (
{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 && (
{page + 1} / {pageCount}
)}
)}
{!hintGone && ( )} {/* Hide-participant-strip toggle, only meaningful when there's a focus + extras to hide. Fades alongside the bottom control bar so idle fullscreen still goes clean. */} {hasFocus && others.length > 0 && ( )}
{controls}
); } // Users-icon with a diagonal slash when the strip is hidden — mirrors the // MicOff/HeadphonesOff naming convention used elsewhere. function StripToggleIcon({ hidden }: { hidden: boolean }) { return ( ); } function PttHint() { const [ptt, setPtt] = useState(() => getPttSettings()); useEffect(() => subscribePttSettings(setPtt), []); if (!ptt.enabled) return null; return (

Push-to-Talk:  {ptt.keyLabel}

); } // Non-terminal mic error banner. Shown inside the call panel when mic setup // fails — the call itself stays alive, the user just can't be heard. Retry // invokes the pipeline setup again with the current audioSettings so a // permission granted in OS settings mid-call works without rejoin. function MicErrorBanner({ message, onRetry, onDismiss, }: { message: string; onRetry: () => void; onDismiss: () => void; }) { return (

{message}

); } // Preview card for the local sharer's screen-tile. Shows a "Live" label and, // when the share carries a ScreenShareAudio track, a small overlay button to // mute that outgoing audio publication without tearing down the share. The // mute state lives in CallContext so the toggle survives re-renders and // resets cleanly when the share ends. function LocalSharePreview({ displayName, onClick, }: { displayName: string; onClick?: () => void; }) { const { room, outgoingShareAudioMuted, toggleOutgoingShareAudioMute } = useCall(); const [hasShareAudio, setHasShareAudio] = useState(false); useEffect(() => { if (!room) { setHasShareAudio(false); return; } const compute = () => { const lp = room.localParticipant; let found = false; for (const pub of lp.audioTrackPublications.values()) { if (pub.source === Track.Source.ScreenShareAudio) { found = true; break; } } setHasShareAudio(found); }; compute(); const onPub = () => compute(); room.on(RoomEvent.LocalTrackPublished, onPub); room.on(RoomEvent.LocalTrackUnpublished, onPub); return () => { room.off(RoomEvent.LocalTrackPublished, onPub); room.off(RoomEvent.LocalTrackUnpublished, onPub); }; }, [room]); return (
Live
{displayName}
{hasShareAudio && ( )}
); } function SpeakerOnIconInline() { return ( ); } function SpeakerOffIconInline() { return ( ); } // Tiny inline variant so we don't pull MicOffIcon's default sizing. function MicOffIconInline() { return ( ); }