import React, { useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTrackStats'; import { getActiveRoom } from '../../hooks/useLiveKit'; import { useFloatingPosition } from '../../hooks/useFloatingPosition'; import { usePortalContainer } from '../../hooks/usePortalContainer'; interface ConnectionInfoPopoverProps { open: boolean; onClose: () => void; anchorRef: React.RefObject; } function formatBitrate(kbps: number): string { if (kbps < 1000) return `${Math.round(kbps)} kbps`; return `${(kbps / 1000).toFixed(kbps >= 10000 ? 0 : 1)} Mbps`; } function pingColor(ms: number): string { if (ms <= 80) return 'text-status-online'; if (ms <= 200) return 'text-status-idle'; return 'text-txt-danger'; } function lossColor(pct: number): string { if (pct <= 1) return 'text-status-online'; if (pct <= 5) return 'text-status-idle'; return 'text-txt-danger'; } function jitterColor(ms: number): string { if (ms <= 30) return 'text-status-online'; if (ms <= 80) return 'text-status-idle'; return 'text-txt-danger'; } function sourceLabel(source: string): string { switch (source) { case 'microphone': return 'Microphone'; case 'camera': return 'Camera'; case 'screen_share': return 'Screen'; case 'screen_share_audio': return 'Screen Audio'; default: return 'Unknown'; } } function trackLabel(direction: 'send' | 'recv', source: string, participantName: string | null): string { const arrow = direction === 'send' ? '\u2191' : '\u2193'; if (direction === 'send') { return `${sourceLabel(source)} ${arrow}`; } const name = participantName ?? 'Remote'; return `${name} ${sourceLabel(source)} ${arrow}`; } const Row = ({ label, value, colorClass }: { label: string; value: string; colorClass?: string }) => (
{label} {value}
); const Divider = () =>
; const SectionHeader = ({ title }: { title: string }) => (
{title}
); function AudioTrackRow({ track }: { track: AudioTrackStat }) { const label = trackLabel(track.direction, track.source, track.participantName); return (
{label} {formatBitrate(track.bitrate)} {track.codec && {track.codec}}
); } function VideoTrackRow({ track }: { track: VideoTrackStat }) { const label = trackLabel(track.direction, track.source, track.participantName); const resolution = (track.width && track.height) ? `${track.width}\u00d7${track.height}` : null; return (
{label} {formatBitrate(track.bitrate)} {track.codec && {track.codec}}
{(resolution || track.fps !== null || track.qualityLimitation || track.simulcastLayer || track.encoderImpl) && (
{resolution && `${resolution}`} {track.fps !== null && ` @${track.fps}`} {track.direction === 'send' && (() => { const parts: string[] = []; if (track.encoderImpl) parts.push(track.encoderImpl); if (track.qualityLimitation && track.qualityLimitation !== 'none') parts.push(track.qualityLimitation); return parts.join(' ยท ') || null; })()} {track.direction === 'recv' && track.simulcastLayer ? track.simulcastLayer : ''}
)}
); } export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionInfoPopoverProps) { const popoverRef = useRef(null); const portalContainer = usePortalContainer(); const stats = useTrackStats(open); const { style } = useFloatingPosition(anchorRef, popoverRef, { placement: 'top', offset: 12, enabled: open, }); // Click-outside to close useEffect(() => { if (!open) return; const handleClick = (e: MouseEvent) => { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { onClose(); } }; document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, [open, onClose]); if (!open) return null; const room = getActiveRoom(); return createPortal(
Connection Info
{!room ? (
Not connected
) : !stats ? (
Gathering stats...
) : ( <> {/* Network */} {/* Audio Tracks */} {stats.audioTracks.length > 0 && ( <> {stats.audioTracks.map((t) => ( ))} )} {/* Video Tracks */} {stats.videoTracks.length > 0 && ( <> {stats.videoTracks.map((t) => ( ))} )} )}
, portalContainer, ); }