import { ConnectionQuality, type Room } from 'livekit-client'; import { useEffect, useRef, useState } from 'react'; import { type ParticipantStats, makeSampleCache, sampleStats, } from '../lib/callStats'; import { useCall } from '../context/CallContext'; import { XIcon } from './icons'; interface Props { room: Room; members: { userId: string; profile?: { displayName?: string | null } | null }[]; onClose: () => void; } const POLL_INTERVAL_MS = 1500; const QUALITY_LABEL: Record = { [ConnectionQuality.Excellent]: 'Sehr gut', [ConnectionQuality.Good]: 'Gut', [ConnectionQuality.Poor]: 'Schlecht', [ConnectionQuality.Lost]: 'Verloren', [ConnectionQuality.Unknown]: 'Unbekannt', }; const QUALITY_TONE: Record = { [ConnectionQuality.Excellent]: 'text-emerald-400', [ConnectionQuality.Good]: 'text-emerald-400', [ConnectionQuality.Poor]: 'text-amber-400', [ConnectionQuality.Lost]: 'text-rose-400', [ConnectionQuality.Unknown]: 'text-fg-muted', }; /** * Discord-style debug overlay (Ctrl+Shift+S). Polls WebRTC getStats() every * 1.5s and renders bitrate/loss/jitter/RTT per participant + audio + video. * Pin to corner; designed to stay readable on top of any video stream. */ export function CallStatsOverlay({ room, members, onClose }: Props) { const { connectionQualities } = useCall(); const [stats, setStats] = useState([]); const cacheRef = useRef(makeSampleCache()); useEffect(() => { let cancelled = false; const tick = async () => { try { const next = await sampleStats(room, cacheRef.current); if (!cancelled) setStats(next); } catch { /* ignore — getStats can throw mid-reconnect */ } }; void tick(); const id = window.setInterval(() => { void tick(); }, POLL_INTERVAL_MS); return () => { cancelled = true; window.clearInterval(id); }; }, [room]); const nameFor = (identity: string): string => { const m = members.find((mm) => mm.userId === identity); return m?.profile?.displayName ?? identity.slice(0, 8); }; return (
Debug
Call-Stats
{stats.map((p) => { const cq = connectionQualities[p.identity] ?? ConnectionQuality.Unknown; return (
{nameFor(p.identity)} {p.isLocal && (du)} {QUALITY_LABEL[cq]}
{p.isLocal ? ( ) : ( )} {(p.video.videoInKbps ?? p.video.videoOutKbps ?? 0) > 0 && ( p.isLocal ? ( ) : ( ) )}
); })} {stats.length === 0 && (

Sammle Stats …

)}
Aktualisiert alle 1,5 s · Strg+Shift+S zum Schließen
); } function Row({ label, values }: { label: string; values: (string | null)[] }) { const visible = values.filter(Boolean) as string[]; return (
{label} {visible.length === 0 ? '—' : visible.map((v, i) => {v})}
); } function fmtKbps(v: number | undefined): string | null { if (v === undefined) return null; return v.toLocaleString('de') + ' kbps'; } function fmtPct(v: number | undefined, prefix: string): string | null { if (v === undefined) return null; return prefix + ' ' + v.toFixed(1) + '%'; } function fmtMs(v: number | undefined, prefix: string): string | null { if (v === undefined) return null; return prefix + ' ' + v + 'ms'; }