diff --git a/packages/web/src/components/voice/ConnectionInfoPopover.tsx b/packages/web/src/components/voice/ConnectionInfoPopover.tsx index bf6e728a..6ac20936 100644 --- a/packages/web/src/components/voice/ConnectionInfoPopover.tsx +++ b/packages/web/src/components/voice/ConnectionInfoPopover.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Track } from 'livekit-client'; +import React, { useEffect, useRef } from 'react'; +import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTrackStats'; import { getActiveRoom } from '../../hooks/useLiveKit'; interface ConnectionInfoPopoverProps { @@ -7,77 +7,9 @@ interface ConnectionInfoPopoverProps { onClose: () => void; } -interface ConnectionStats { - ping: number | null; - packetLoss: number | null; - jitter: number | null; - audioUp: number | null; - audioDown: number | null; - videoUp: number | null; - videoDown: number | null; - audioCodec: string | null; - videoCodec: string | null; - resolution: string | null; - fps: number | null; - qualityLimitation: string | null; - serverAddress: string | null; - protocol: string | null; - candidateType: string | null; -} - -interface PrevSample { - bytes: number; - timestamp: number; -} - -/** - * Discover all unique RTCPeerConnections from the LiveKit Room engine. - * Different livekit-client versions expose the PC at different internal paths. - * We collect all of them and deduplicate, so the stats loop can process - * outbound-rtp and inbound-rtp reports regardless of transport architecture - * (split publisher/subscriber vs unified-plan single PC). - */ -function discoverPeerConnections(room: any): RTCPeerConnection[] { - const engine = room?.engine; - if (!engine) return []; - - const pcs: RTCPeerConnection[] = []; - const seen = new WeakSet(); - - const tryAdd = (val: any) => { - if (val && typeof val.getStats === 'function' && !seen.has(val)) { - seen.add(val); - pcs.push(val); - } - }; - - // Current livekit-client (1.x+): engine.pcManager.{publisher,subscriber}.pc - tryAdd(engine.pcManager?.publisher?.pc); - tryAdd(engine.pcManager?.subscriber?.pc); - // Private backing field fallback - tryAdd(engine.pcManager?.publisher?._pc); - tryAdd(engine.pcManager?.subscriber?._pc); - // Older livekit-client paths - tryAdd(engine.publisher?.pc); - tryAdd(engine.subscriber?.pc); - // Unified-plan single PC - tryAdd(engine.pc); - tryAdd(room.pc); - - return pcs; -} - -/** Determine media kind from a WebRTC stats report (handles both spec and legacy fields). */ -function reportKind(report: any): 'audio' | 'video' | null { - const k = report.kind ?? report.mediaType; - if (k === 'audio' || k === 'video') return k; - return null; -} - -function formatBitrate(bps: number | null): string { - if (bps === null) return '\u2014'; - if (bps < 1000) return `${Math.round(bps)} kbps`; - return `${(bps / 1000).toFixed(bps >= 10000 ? 0 : 1)} Mbps`; +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 { @@ -98,10 +30,89 @@ function jitterColor(ms: number): string { return 'text-discord-red'; } +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) && ( +
+ + {resolution && `${resolution}`} + {track.fps !== null && ` @${track.fps}`} + + + {track.direction === 'send' && track.qualityLimitation && track.qualityLimitation !== 'none' + ? track.qualityLimitation + : ''} + {track.direction === 'recv' && track.simulcastLayer + ? track.simulcastLayer + : ''} + +
+ )} +
+ ); +} + export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverProps) { const popoverRef = useRef(null); - const prevSampleRef = useRef>(new Map()); - const [stats, setStats] = useState(null); + const stats = useTrackStats(open); // Click-outside to close useEffect(() => { @@ -115,266 +126,9 @@ export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverPr return () => document.removeEventListener('mousedown', handleClick); }, [open, onClose]); - // Stats polling — only while open - useEffect(() => { - if (!open) { - prevSampleRef.current.clear(); - setStats(null); - return; - } - - const poll = async () => { - const room = getActiveRoom(); - if (!room) { - setStats(null); - return; - } - - const pcs = discoverPeerConnections(room as any); - if (pcs.length === 0) { - setStats(null); - return; - } - - const result: ConnectionStats = { - ping: null, - packetLoss: null, - jitter: null, - audioUp: null, - audioDown: null, - videoUp: null, - videoDown: null, - audioCodec: null, - videoCodec: null, - resolution: null, - fps: null, - qualityLimitation: null, - serverAddress: null, - protocol: null, - candidateType: null, - }; - - const prev = prevSampleRef.current; - const now = performance.now(); - - // Accumulators for inbound aggregate metrics - let totalPacketsReceived = 0; - let totalPacketsLost = 0; - let totalAudioDown = 0; - let hasAudioDown = false; - let totalVideoDown = 0; - let hasVideoDown = false; - - // Track candidate-pair remote ID for Safari fallback (see after forEach) - let selectedCandidatePairRemoteId: string | null = null; - - // Process stats from ALL discovered PeerConnections. - // Report types (outbound-rtp, inbound-rtp, candidate-pair, etc.) are - // self-describing, so we don't need to know which PC they came from. - for (const pc of pcs) { - let reports: RTCStatsReport; - try { - reports = await pc.getStats(); - } catch { - continue; - } - - // Build codec map for this PC's stats (codecId → short name) - const codecMap = new Map(); - reports.forEach((report: any) => { - if (report.type === 'codec') { - codecMap.set(report.id, report.mimeType?.split('/')[1] ?? report.mimeType ?? ''); - } - }); - - reports.forEach((report: any) => { - const kind = reportKind(report); - - // ── RTT from candidate-pair ── - if ( - report.type === 'candidate-pair' && - report.state === 'succeeded' - ) { - if (report.currentRoundTripTime != null) { - result.ping = Math.round(report.currentRoundTripTime * 1000); - } - // Safari fallback: collect remote candidate ID for post-loop lookup - if (!result.serverAddress && report.remoteCandidateId) { - selectedCandidatePairRemoteId = report.remoteCandidateId; - } - } - - // ── Outbound (publisher) ── - if (report.type === 'outbound-rtp') { - const key = `out-${report.ssrc}`; - const prevEntry = prev.get(key); - - if (kind === 'audio') { - if (prevEntry) { - const deltaBits = (report.bytesSent - prevEntry.bytes) * 8; - const deltaMs = now - prevEntry.timestamp; - if (deltaMs > 0) result.audioUp = deltaBits / deltaMs; - } - prev.set(key, { bytes: report.bytesSent, timestamp: now }); - - if (report.codecId && codecMap.has(report.codecId)) { - result.audioCodec = codecMap.get(report.codecId)!; - } - } - - if (kind === 'video') { - if (prevEntry) { - const deltaBits = (report.bytesSent - prevEntry.bytes) * 8; - const deltaMs = now - prevEntry.timestamp; - if (deltaMs > 0) { - const bitrate = deltaBits / deltaMs; - // Only accumulate when bitrate > 0 — filters stale outbound-rtp - // reports that Safari/Chrome retain after camera/screenshare stops - if (bitrate > 0) { - result.videoUp = (result.videoUp ?? 0) + bitrate; - } - } - } - prev.set(key, { bytes: report.bytesSent, timestamp: now }); - - if (report.codecId && codecMap.has(report.codecId)) { - result.videoCodec = codecMap.get(report.codecId)!; - } - - // Resolution + FPS — take the active track (non-zero dimensions) - if (report.frameWidth > 0 && report.frameHeight > 0) { - result.resolution = `${report.frameWidth}\u00d7${report.frameHeight}`; - result.fps = Math.round(report.framesPerSecond || 0); - } - - if (report.qualityLimitationReason) { - result.qualityLimitation = report.qualityLimitationReason; - } - } - } - - // ── Inbound (subscriber) ── - if (report.type === 'inbound-rtp') { - const key = `in-${report.ssrc}`; - const prevEntry = prev.get(key); - - if (kind === 'audio') { - if (result.jitter === null && report.jitter != null) { - result.jitter = Math.round(report.jitter * 1000); - } - totalPacketsReceived += report.packetsReceived || 0; - totalPacketsLost += report.packetsLost || 0; - - if (prevEntry) { - const deltaBits = (report.bytesReceived - prevEntry.bytes) * 8; - const deltaMs = now - prevEntry.timestamp; - if (deltaMs > 0) { - totalAudioDown += deltaBits / deltaMs; - hasAudioDown = true; - } - } - prev.set(key, { bytes: report.bytesReceived, timestamp: now }); - } - - if (kind === 'video') { - totalPacketsReceived += report.packetsReceived || 0; - totalPacketsLost += report.packetsLost || 0; - - if (prevEntry) { - const deltaBits = (report.bytesReceived - prevEntry.bytes) * 8; - const deltaMs = now - prevEntry.timestamp; - if (deltaMs > 0) { - const bitrate = deltaBits / deltaMs; - // Only accumulate when bitrate > 0 — filters stale inbound-rtp - if (bitrate > 0) { - totalVideoDown += bitrate; - hasVideoDown = true; - } - } - } - prev.set(key, { bytes: report.bytesReceived, timestamp: now }); - } - } - - // ── Server address from remote-candidate ── - // Safari may leave `address` empty but populate the legacy `ip` field - if (report.type === 'remote-candidate' && !result.serverAddress) { - const addr = report.address || report.ip; - if (addr) { - result.serverAddress = addr; - result.protocol = report.protocol ?? null; - result.candidateType = report.candidateType ?? null; - } - } - }); - - // Safari fallback: look up remote candidate by ID from candidate-pair - if (!result.serverAddress && selectedCandidatePairRemoteId) { - const remoteCandidate = reports.get(selectedCandidatePairRemoteId); - if (remoteCandidate) { - const addr = remoteCandidate.address || remoteCandidate.ip; - if (addr) { - result.serverAddress = addr; - result.protocol = remoteCandidate.protocol ?? null; - result.candidateType = remoteCandidate.candidateType ?? null; - } - } - } - } - - // Safari fallback: resolution from MediaStreamTrack.getSettings() - // Safari omits frameWidth/frameHeight from outbound-rtp stats entirely - if (!result.resolution && result.videoUp !== null && result.videoUp > 0) { - const localPart = room.localParticipant; - for (const pub of localPart.trackPublications.values()) { - if (pub.source === Track.Source.Camera || pub.source === Track.Source.ScreenShare) { - const mt = pub.track?.mediaStreamTrack; - if (mt && mt.readyState === 'live') { - const s = mt.getSettings(); - if (s.width && s.height) { - result.resolution = `${s.width}\u00d7${s.height}`; - result.fps = s.frameRate ? Math.round(s.frameRate) : null; - break; - } - } - } - } - } - - // Finalize aggregated inbound metrics - if (hasAudioDown) result.audioDown = totalAudioDown; - if (hasVideoDown) result.videoDown = totalVideoDown; - - const totalPackets = totalPacketsReceived + totalPacketsLost; - if (totalPackets > 0) { - result.packetLoss = (totalPacketsLost / totalPackets) * 100; - } - - setStats(result); - }; - - poll(); - const interval = setInterval(poll, 1500); - return () => clearInterval(interval); - }, [open]); - if (!open) return null; const room = getActiveRoom(); - const hasVideo = stats && ( - (stats.videoUp !== null && stats.videoUp > 0) || - (stats.videoDown !== null && stats.videoDown > 0) || - stats.resolution !== null - ); - - const Row = ({ label, value, colorClass }: { label: string; value: string; colorClass?: string }) => ( -
- {label} - {value} -
- ); - - const Divider = () =>
; return (
{/* Network */} + - - - - {/* Bitrates */} - - - {hasVideo && ( - <> - - - - )} - - - - {/* Codec + media info */} - - {hasVideo && ( - <> - - - - )} - - - - {/* Server */} - + + + {/* Audio Tracks */} + {stats.audioTracks.length > 0 && ( + <> + + + {stats.audioTracks.map((t) => ( + + ))} + + )} + + {/* Video Tracks */} + {stats.videoTracks.length > 0 && ( + <> + + + {stats.videoTracks.map((t) => ( + + ))} + + )} )}
diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index be11111e..7ed76b5e 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -536,29 +536,6 @@ export function useLiveKit() { updateActiveTracks().catch(() => {}); }, [room, videoQuality, isScreenSharing, isCameraOn]); - useEffect(() => { - if (!room) return; - const interval = setInterval(async () => { - try { - const engine = (room as any).engine; - const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc || (room as any).pc; - if (!pc) return; - const stats = await (pc as RTCPeerConnection).getStats(); - stats.forEach((report: any) => { - if (report.type === 'outbound-rtp' && report.kind === 'video' && report.frameWidth > 0) { - const fps = Math.round(report.framesPerSecond || 0); - const key = `_lastBytes_${report.ssrc}`; - const lastBytes = (window as any)[key] || report.bytesSent; - const bitrate = (((report.bytesSent - lastBytes) * 8) / 5000 / 1000).toFixed(2); - (window as any)[key] = report.bytesSent; - console.log(`[Soft-Launch Diagnostic] ${report.frameWidth}x${report.frameHeight} @ ${fps} FPS (~${bitrate} Mbps) | ${report.qualityLimitationReason}`); - } - }); - } catch (err) { } - }, 5000); - return () => clearInterval(interval); - }, [room]); - useEffect(() => { return () => { _connectGeneration++; SpeakingDetector.getInstance().clear(); if (roomRef.current) { destroyRoom(roomRef.current); roomRef.current = null; _activeRoom = null; } }; }, []); diff --git a/packages/web/src/hooks/useTrackStats.ts b/packages/web/src/hooks/useTrackStats.ts new file mode 100644 index 00000000..bd3d6885 --- /dev/null +++ b/packages/web/src/hooks/useTrackStats.ts @@ -0,0 +1,535 @@ +import { useState, useEffect, useRef } from 'react'; +import { Track } from 'livekit-client'; +import { getActiveRoom } from './useLiveKit'; + +// ── Types ── + +type TrackSource = 'microphone' | 'camera' | 'screen_share' | 'screen_share_audio' | 'unknown'; +type TrackDirection = 'send' | 'recv'; + +export interface AudioTrackStat { + key: string; + direction: TrackDirection; + source: TrackSource; + participantName: string | null; + bitrate: number; + codec: string | null; + packetLoss: number | null; + jitter: number | null; +} + +export interface VideoTrackStat { + key: string; + direction: TrackDirection; + source: TrackSource; + participantName: string | null; + bitrate: number; + codec: string | null; + width: number | null; + height: number | null; + fps: number | null; + qualityLimitation: string | null; + simulcastLayer: string | null; +} + +export interface NetworkStats { + ping: number | null; + packetLoss: number | null; + jitter: number | null; + serverAddress: string | null; + protocol: string | null; + candidateType: string | null; +} + +export interface TrackStatsSnapshot { + network: NetworkStats; + audioTracks: AudioTrackStat[]; + videoTracks: VideoTrackStat[]; +} + +// ── Internal types ── + +interface TrackIdentity { + source: TrackSource; + direction: TrackDirection; + participantName: string | null; +} + +interface PrevSample { + bytes: number; + frames: number; + timestamp: number; + packetsRecv: number; + packetsLost: number; +} + +// ── Helpers ── + +function mapSource(lkSource: Track.Source): TrackSource { + switch (lkSource) { + case Track.Source.Microphone: return 'microphone'; + case Track.Source.Camera: return 'camera'; + case Track.Source.ScreenShare: return 'screen_share'; + case Track.Source.ScreenShareAudio: return 'screen_share_audio'; + default: return 'unknown'; + } +} + +function parseUsername(identity: string): string { + const parts = identity.split(':'); + return parts[1] ?? identity; +} + +/** Determine media kind from a WebRTC stats report (handles both spec and legacy fields). */ +function reportKind(report: any): 'audio' | 'video' | null { + const k = report.kind ?? report.mediaType; + if (k === 'audio' || k === 'video') return k; + return null; +} + +/** + * Discover all unique RTCPeerConnections from the LiveKit Room engine. + * Different livekit-client versions expose the PC at different internal paths. + */ +function discoverPeerConnections(room: any): RTCPeerConnection[] { + const engine = room?.engine; + if (!engine) return []; + + const pcs: RTCPeerConnection[] = []; + const seen = new WeakSet(); + + const tryAdd = (val: any) => { + if (val && typeof val.getStats === 'function' && !seen.has(val)) { + seen.add(val); + pcs.push(val); + } + }; + + // Current livekit-client (1.x+): engine.pcManager.{publisher,subscriber}.pc + tryAdd(engine.pcManager?.publisher?.pc); + tryAdd(engine.pcManager?.subscriber?.pc); + // Private backing field fallback + tryAdd(engine.pcManager?.publisher?._pc); + tryAdd(engine.pcManager?.subscriber?._pc); + // Older livekit-client paths + tryAdd(engine.publisher?.pc); + tryAdd(engine.subscriber?.pc); + // Unified-plan single PC + tryAdd(engine.pc); + tryAdd(room.pc); + + return pcs; +} + +function inferSimulcastLayer(width: number | null): string | null { + if (width === null || width <= 0) return null; + if (width >= 1920) return 'High'; + if (width >= 1280) return 'Medium'; + return 'Low'; +} + +// ── Hook ── + +export function useTrackStats(enabled: boolean): TrackStatsSnapshot | null { + const [snapshot, setSnapshot] = useState(null); + const prevSampleRef = useRef>(new Map()); + + useEffect(() => { + if (!enabled) { + prevSampleRef.current.clear(); + setSnapshot(null); + return; + } + + const poll = async () => { + const room = getActiveRoom(); + if (!room) { + setSnapshot(null); + return; + } + + const pcs = discoverPeerConnections(room as any); + if (pcs.length === 0) { + setSnapshot(null); + return; + } + + const prev = prevSampleRef.current; + const now = performance.now(); + + // ── Step A: Network stats + codec map from pc.getStats() ── + const network: NetworkStats = { + ping: null, + packetLoss: null, + jitter: null, + serverAddress: null, + protocol: null, + candidateType: null, + }; + + // Global codec map across all PCs + const globalCodecMap = new Map(); + let selectedCandidatePairRemoteId: string | null = null; + + for (const pc of pcs) { + let reports: RTCStatsReport; + try { + reports = await pc.getStats(); + } catch { + continue; + } + + reports.forEach((report: any) => { + if (report.type === 'codec') { + globalCodecMap.set(report.id, report.mimeType?.split('/')[1] ?? report.mimeType ?? ''); + } + + if (report.type === 'candidate-pair' && report.state === 'succeeded') { + if (report.currentRoundTripTime != null) { + network.ping = Math.round(report.currentRoundTripTime * 1000); + } + if (!network.serverAddress && report.remoteCandidateId) { + selectedCandidatePairRemoteId = report.remoteCandidateId; + } + } + + if (report.type === 'remote-candidate' && !network.serverAddress) { + const addr = report.address || report.ip; + if (addr) { + network.serverAddress = addr; + network.protocol = report.protocol ?? null; + network.candidateType = report.candidateType ?? null; + } + } + }); + + // Safari fallback: look up remote candidate by ID + if (!network.serverAddress && selectedCandidatePairRemoteId) { + let reports2: RTCStatsReport; + try { + reports2 = await pc.getStats(); + } catch { + continue; + } + const remoteCandidate = reports2.get(selectedCandidatePairRemoteId); + if (remoteCandidate) { + const addr = remoteCandidate.address || remoteCandidate.ip; + if (addr) { + network.serverAddress = addr; + network.protocol = remoteCandidate.protocol ?? null; + network.candidateType = remoteCandidate.candidateType ?? null; + } + } + } + } + + // ── Step B: Build TrackIdentityMap ── + const identityMap = new Map(); + + // Local tracks + for (const pub of room.localParticipant.trackPublications.values()) { + const mst = pub.track?.mediaStreamTrack; + if (mst) { + identityMap.set(mst.id, { + source: mapSource(pub.source), + direction: 'send', + participantName: null, + }); + } + } + + // Remote tracks + for (const [, rp] of room.remoteParticipants) { + const name = parseUsername(rp.identity); + for (const pub of rp.trackPublications.values()) { + const mst = pub.track?.mediaStreamTrack; + if (mst) { + identityMap.set(mst.id, { + source: mapSource(pub.source), + direction: 'recv', + participantName: name, + }); + } + } + } + + // ── Step C & D: Per-sender and per-receiver stats ── + const audioTracks: AudioTrackStat[] = []; + const videoTracks: VideoTrackStat[] = []; + let totalPacketsReceived = 0; + let totalPacketsLost = 0; + const seenKeys = new Set(); + + for (const pc of pcs) { + // Process senders (outbound) + for (const sender of pc.getSenders()) { + if (!sender.track) continue; + + const identity = identityMap.get(sender.track.id); + if (!identity) continue; + + let senderStats: RTCStatsReport; + try { + senderStats = await sender.getStats(); + } catch { + continue; + } + + // Build per-sender codec map + const senderCodecMap = new Map(); + senderStats.forEach((report: any) => { + if (report.type === 'codec') { + senderCodecMap.set(report.id, report.mimeType?.split('/')[1] ?? report.mimeType ?? ''); + } + }); + + senderStats.forEach((report: any) => { + if (report.type !== 'outbound-rtp') return; + + const kind = reportKind(report); + if (!kind) return; + + const ssrcKey = `out-${report.ssrc}`; + if (seenKeys.has(ssrcKey)) return; + seenKeys.add(ssrcKey); + + const prevEntry = prev.get(ssrcKey); + const deltaMs = prevEntry ? now - prevEntry.timestamp : 0; + const deltaSeconds = deltaMs / 1000; + + // Delta bitrate + let bitrate = 0; + if (prevEntry && deltaMs > 0) { + bitrate = ((report.bytesSent - prevEntry.bytes) * 8) / deltaMs; // kbps + } + + // Codec: try sender-scoped first, then global fallback + let codec: string | null = null; + if (report.codecId) { + codec = senderCodecMap.get(report.codecId) ?? globalCodecMap.get(report.codecId) ?? null; + } + + if (kind === 'audio') { + prev.set(ssrcKey, { + bytes: report.bytesSent, + frames: 0, + timestamp: now, + packetsRecv: 0, + packetsLost: 0, + }); + + if (bitrate > 0) { + audioTracks.push({ + key: ssrcKey, + direction: 'send', + source: identity.source, + participantName: null, + bitrate, + codec, + packetLoss: null, + jitter: null, + }); + } + } + + if (kind === 'video') { + const framesEncoded = report.framesEncoded ?? 0; + const prevFrames = prevEntry?.frames ?? 0; + const deltaFrames = framesEncoded - prevFrames; + const fps = (prevEntry && deltaSeconds > 0) ? Math.round(deltaFrames / deltaSeconds) : null; + + let width: number | null = report.frameWidth > 0 ? report.frameWidth : null; + let height: number | null = report.frameHeight > 0 ? report.frameHeight : null; + + // Safari fallback: resolution from MediaStreamTrack.getSettings() + const senderTrack = sender.track; + if (width === null && senderTrack && senderTrack.readyState === 'live') { + const settings = senderTrack.getSettings(); + if (settings.width && settings.height) { + width = settings.width; + height = settings.height; + } + } + + prev.set(ssrcKey, { + bytes: report.bytesSent, + frames: framesEncoded, + timestamp: now, + packetsRecv: 0, + packetsLost: 0, + }); + + if (bitrate > 0 || (width !== null && height !== null)) { + videoTracks.push({ + key: ssrcKey, + direction: 'send', + source: identity.source, + participantName: null, + bitrate, + codec, + width, + height, + fps, + qualityLimitation: report.qualityLimitationReason ?? null, + simulcastLayer: null, + }); + } + } + }); + } + + // Process receivers (inbound) + for (const receiver of pc.getReceivers()) { + if (!receiver.track) continue; + + const identity = identityMap.get(receiver.track.id); + if (!identity) continue; + + let receiverStats: RTCStatsReport; + try { + receiverStats = await receiver.getStats(); + } catch { + continue; + } + + // Build per-receiver codec map + const recvCodecMap = new Map(); + receiverStats.forEach((report: any) => { + if (report.type === 'codec') { + recvCodecMap.set(report.id, report.mimeType?.split('/')[1] ?? report.mimeType ?? ''); + } + }); + + receiverStats.forEach((report: any) => { + if (report.type !== 'inbound-rtp') return; + + const kind = reportKind(report); + if (!kind) return; + + const ssrcKey = `in-${report.ssrc}`; + if (seenKeys.has(ssrcKey)) return; + seenKeys.add(ssrcKey); + + const prevEntry = prev.get(ssrcKey); + const deltaMs = prevEntry ? now - prevEntry.timestamp : 0; + const deltaSeconds = deltaMs / 1000; + + // Delta bitrate + let bitrate = 0; + if (prevEntry && deltaMs > 0) { + bitrate = ((report.bytesReceived - prevEntry.bytes) * 8) / deltaMs; // kbps + } + + // Packet loss + const packetsRecv = report.packetsReceived ?? 0; + const packetsLost = report.packetsLost ?? 0; + totalPacketsReceived += packetsRecv; + totalPacketsLost += packetsLost; + + let perTrackLoss: number | null = null; + if (prevEntry) { + const deltaRecv = packetsRecv - prevEntry.packetsRecv; + const deltaLost = packetsLost - prevEntry.packetsLost; + const deltaTotal = deltaRecv + deltaLost; + if (deltaTotal > 0) { + perTrackLoss = (deltaLost / deltaTotal) * 100; + } + } + + // Jitter + const jitter = report.jitter != null ? Math.round(report.jitter * 1000) : null; + + // Codec + let codec: string | null = null; + if (report.codecId) { + codec = recvCodecMap.get(report.codecId) ?? globalCodecMap.get(report.codecId) ?? null; + } + + if (kind === 'audio') { + prev.set(ssrcKey, { + bytes: report.bytesReceived, + frames: 0, + timestamp: now, + packetsRecv: packetsRecv, + packetsLost: packetsLost, + }); + + // Set network jitter from first inbound audio + if (network.jitter === null && jitter !== null) { + network.jitter = jitter; + } + + if (bitrate > 0) { + audioTracks.push({ + key: ssrcKey, + direction: 'recv', + source: identity.source, + participantName: identity.participantName, + bitrate, + codec, + packetLoss: perTrackLoss, + jitter, + }); + } + } + + if (kind === 'video') { + const framesDecoded = report.framesDecoded ?? 0; + const prevFrames = prevEntry?.frames ?? 0; + const deltaFrames = framesDecoded - prevFrames; + const fps = (prevEntry && deltaSeconds > 0) ? Math.round(deltaFrames / deltaSeconds) : null; + + const width: number | null = report.frameWidth > 0 ? report.frameWidth : null; + const height: number | null = report.frameHeight > 0 ? report.frameHeight : null; + + prev.set(ssrcKey, { + bytes: report.bytesReceived, + frames: framesDecoded, + timestamp: now, + packetsRecv: packetsRecv, + packetsLost: packetsLost, + }); + + if (bitrate > 0 || (width !== null && height !== null)) { + videoTracks.push({ + key: ssrcKey, + direction: 'recv', + source: identity.source, + participantName: identity.participantName, + bitrate, + codec, + width, + height, + fps, + qualityLimitation: null, + simulcastLayer: inferSimulcastLayer(width), + }); + } + } + }); + } + } + + // ── Step E: Aggregate packet loss ── + const totalPackets = totalPacketsReceived + totalPacketsLost; + if (totalPackets > 0) { + network.packetLoss = (totalPacketsLost / totalPackets) * 100; + } + + // ── Step F: Cleanup stale prevSample entries ── + for (const key of prev.keys()) { + if (!seenKeys.has(key)) { + prev.delete(key); + } + } + + setSnapshot({ network, audioTracks, videoTracks }); + }; + + poll(); + const interval = setInterval(poll, 1000); + return () => clearInterval(interval); + }, [enabled]); + + return snapshot; +}