diff --git a/src/frontend/src/features/devtools/MeetDevtools.tsx b/src/frontend/src/features/devtools/MeetDevtools.tsx new file mode 100644 index 00000000..e2f665e9 --- /dev/null +++ b/src/frontend/src/features/devtools/MeetDevtools.tsx @@ -0,0 +1,608 @@ +import { CSSProperties, useState } from 'react' +import { useConnectionState, useRoomContext } from '@livekit/components-react' +import { ConnectionState } from 'livekit-client' +import { StatsSnapshot, TrackRow, useWebRTCStats } from './useWebRTCStats' +import { readRoomConfig } from './roomConfig' +import { + forceTransport, + releaseForcedTransport, + Scenario, + SCENARIOS, + setDownlinkCap, + setUplinkCap, + stepBitrate, + TransportMode, + transportModeFromRoute, +} from './simulation' + +const SANS = + "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" +const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' + +const COLOR = { + text: '#e8e8e8', + muted: '#9a9a9a', + faint: '#6f6f6f', + hairline: '#2c2d31', + border: '#3d3e44', + surface: '#1b1c1e', + chartBg: '#141517', + down: '#f6821f', + up: '#5a9cf8', + ok: '#10b981', + bad: '#f05a4a', + busy: '#e5a13c', +} + +const stateColor = (state: ConnectionState): string => { + switch (state) { + case ConnectionState.Connected: + return COLOR.ok + case ConnectionState.Reconnecting: + case ConnectionState.SignalReconnecting: + return COLOR.busy + case ConnectionState.Connecting: + return COLOR.up + case ConnectionState.Disconnected: + return COLOR.bad + } +} + +const styles: Record = { + toggle: { + position: 'fixed', + bottom: 12, + right: 12, + zIndex: 9999, + fontFamily: SANS, + fontSize: 12, + lineHeight: 1, + padding: '8px 12px', + borderRadius: 999, + borderWidth: 1, + borderStyle: 'solid', + borderColor: COLOR.border, + background: COLOR.surface, + color: COLOR.text, + cursor: 'pointer', + boxShadow: '0 2px 10px #0006', + }, + panel: { + position: 'fixed', + bottom: 12, + right: 12, + zIndex: 9999, + width: 460, + maxWidth: 'calc(100vw - 24px)', + maxHeight: 'calc(100vh - 24px)', + overflowY: 'auto', + fontFamily: SANS, + fontSize: 11, + lineHeight: 1.5, + color: COLOR.text, + background: COLOR.surface, + borderWidth: 1, + borderStyle: 'solid', + borderColor: COLOR.border, + borderRadius: 10, + padding: 14, + boxShadow: '0 10px 34px #00000080', + }, + sectionTitle: { + margin: '14px 0 5px', + color: COLOR.faint, + textTransform: 'uppercase', + letterSpacing: 1.2, + fontSize: 9, + fontWeight: 600, + display: 'flex', + justifyContent: 'space-between', + }, + metricsLine: { + display: 'flex', + alignItems: 'center', + gap: 16, + margin: '0 0 10px', + paddingBottom: 10, + borderBottom: `1px solid ${COLOR.hairline}`, + fontVariantNumeric: 'tabular-nums', + }, + legend: { + display: 'flex', + gap: 12, + marginTop: 3, + color: COLOR.faint, + fontSize: 10, + }, + list: { display: 'flex', flexDirection: 'column', maxHeight: '200px', overflowY: 'auto' }, + listRow: { display: 'flex', gap: 8, padding: '1px 0' }, + listLabel: { flex: '0 0 34%', color: COLOR.muted }, + trackRow: { + display: 'flex', + gap: 8, + padding: '3px 0', + borderBottom: `1px solid ${COLOR.hairline}`, + alignItems: 'baseline', + }, + trackLabel: { + flex: '0 0 34%', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + trackCodec: { + flex: '0 0 11%', + fontFamily: MONO, + fontSize: 10, + color: COLOR.muted, + }, + trackKbps: { + flex: '0 0 9%', + textAlign: 'right', + fontVariantNumeric: 'tabular-nums', + }, + trackDetail: { flex: 1, minWidth: 0 }, + mono: { fontFamily: MONO, fontSize: 10, color: COLOR.muted }, + button: { + fontFamily: SANS, + fontSize: 11, + padding: '3px 9px', + borderRadius: 5, + borderWidth: 1, + borderStyle: 'solid', + borderColor: COLOR.border, + background: '#232428', + color: COLOR.text, + cursor: 'pointer', + }, + buttonActive: { borderColor: COLOR.down, color: COLOR.down }, + buttonDisabled: { color: COLOR.faint, cursor: 'not-allowed' }, + row: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }, + stepperValue: { + minWidth: 92, + textAlign: 'center', + borderWidth: 1, + borderStyle: 'solid', + borderColor: COLOR.hairline, + borderRadius: 4, + padding: '2px 6px', + background: COLOR.chartBg, + fontVariantNumeric: 'tabular-nums', + }, + close: { + background: 'none', + border: 'none', + color: COLOR.faint, + cursor: 'pointer', + fontSize: 14, + }, +} + +const Sparkline = ({ history }: { history: StatsSnapshot[] }) => { + const width = 430 + const height = 54 + const max = Math.max(...history.map((s) => Math.max(s.upKbps, s.downKbps)), 1) + const points = (pick: (s: StatsSnapshot) => number) => + history + .map( + (s, i) => + `${((i / Math.max(history.length - 1, 1)) * width).toFixed(1)},${( + height - + (pick(s) / max) * (height - 6) - + 3 + ).toFixed(1)}` + ) + .join(' ') + return ( + <> + + {history.length >= 2 && ( + <> + s.downKbps)} + fill="none" + stroke={COLOR.down} + strokeWidth="1.5" + /> + s.upKbps)} + fill="none" + stroke={COLOR.up} + strokeWidth="1.5" + /> + + )} + + {/* Legend outside the plot: text over the lines was unreadable. */} +
+ + down + + + up + + last 60s · max {Math.round(max)} kbps +
+ + ) +} + +const TrackDetail = ({ track }: { track: TrackRow }) => { + const parts: Array<{ text: string; tone?: 'warn' | 'muted' }> = [] + if (track.res) parts.push({ text: track.res }) + if (track.fps !== undefined) { + parts.push({ text: `${Math.round(track.fps)}fps` }) + } + if (track.lossPct !== undefined && track.lossPct > 0) { + parts.push({ text: `loss ${track.lossPct.toFixed(1)}%`, tone: 'warn' }) + } + if (track.froze) parts.push({ text: 'freeze', tone: 'warn' }) + if (track.limitation) { + parts.push({ text: `lim:${track.limitation}`, tone: 'warn' }) + } + const layer = track.layer + if (layer) { + if (layer.availRes && layer.availRes !== track.res) { + parts.push({ + text: `≤${layer.availRes}${layer.layerCount ? `(${layer.layerCount}L)` : ''}`, + tone: 'muted', + }) + } + if (layer.reason === 'adaptive' && layer.elementRes) { + parts.push({ text: `fit:${layer.elementRes}`, tone: 'muted' }) + } else if (layer.reason === 'bandwidth') { + parts.push({ text: 'bw-limited', tone: 'warn' }) + } else if (layer.reason === 'paused') { + parts.push({ text: 'paused:SFU', tone: 'warn' }) + } else if (layer.reason === 'off-screen') { + parts.push({ text: 'off-screen', tone: 'muted' }) + } + } + return ( + <> + {parts.map((part, i) => ( + + {i > 0 && ' · '} + {part.text} + + ))} + + ) +} + +const CapStepper = ({ + label, + hint, + valueKbps, + onChange, +}: { + label: string + hint: string + valueKbps: number | null + onChange: (kbps: number | null) => void +}) => ( +
+ {label} + + + {valueKbps === null ? 'unlimited' : `≤ ${valueKbps} kbps`} + + + {hint} +
+) + +const TrackList = ({ title, rows }: { title: string; rows: TrackRow[] }) => ( + <> +
+ + {title} ({rows.length}) + + + {Math.round(rows.reduce((sum, t) => sum + t.kbps, 0))} kbps media + +
+
+ {rows.map((track) => ( +
+ {track.label} + {track.codec ?? '–'} + {Math.round(track.kbps)} + + + +
+ ))} + {rows.length === 0 && none} +
+ +) + +const MeetDevtools = () => { + const room = useRoomContext() + const connState = useConnectionState(room) + const [open, setOpen] = useState(false) + const [firedScenario, setFiredScenario] = useState() + const [uplinkCap, setUplinkCapState] = useState(null) + const [downlinkCap, setDownlinkCapState] = useState(null) + const { snapshot, history } = useWebRTCStats(room, open) + + const transportMode: TransportMode = transportModeFromRoute(snapshot?.route) + + const fireScenario = (scenario: Scenario) => { + setFiredScenario(scenario.id) + scenario.run(room).catch((e) => { + console.warn('[MeetDevtools] simulateScenario failed', e) + }) + window.setTimeout( + () => + setFiredScenario((current) => + current === scenario.id ? undefined : current + ), + 1200 + ) + } + + if (!open) { + return ( + + ) + } + + const config = readRoomConfig(room) + const published = snapshot?.tracks.filter((t) => t.dir === 'up') ?? [] + const subscribed = snapshot?.tracks.filter((t) => t.dir === 'down') ?? [] + const turnProtocols = snapshot?.turnProtocols ?? [] + const transports: Array<{ + mode: TransportMode + label: string + requires?: string + title: string + }> = [ + { + mode: 'auto', + label: 'auto (udp)', + title: + 'clears the server-cached transport preference and relay-only policy, then full reconnect', + }, + { + mode: 'tcp', + label: 'tcp', + title: 'force-tcp — server prefers TCP candidates (ICE/TCP)', + }, + { + mode: 'turn-udp', + label: 'turn:udp', + requires: 'udp', + title: + 'client-side: iceTransportPolicy relay + full reconnect (no server hook exists)', + }, + { + mode: 'turn-tcp', + label: 'turn:tcp', + requires: 'tcp', + title: 'force-tcp — lands on TURN/TCP when it is the TCP path', + }, + { + mode: 'turn-tls', + label: 'turn:tls', + requires: 'tls', + title: 'force-tls — server switches you to TURN over TLS', + }, + ] + + return ( +
+
+ + {snapshot?.downKbps ?? 0}{' '} + kbps + + + {snapshot?.upKbps ?? 0}{' '} + kbps + + + rtt{' '} + {snapshot?.rttMs ?? '–'} ms + + + jitter{' '} + {snapshot?.jitterMs ?? '–'}{' '} + ms + + + ● {connState} + + +
+ + + +
room configuration (live)
+
+ {config.map((entry) => ( +
+ + ● + + {entry.label} + {entry.value} +
+ ))} +
+ + + + +
bandwidth
+
+ { + setUplinkCapState(kbps) + void setUplinkCap(room, kbps) + }} + /> + { + setDownlinkCapState(kbps) + void setDownlinkCap(room, kbps).catch((e) => + console.warn('[MeetDevtools] downlink cap failed', e) + ) + }} + /> +
+ +
transport
+
+ {transports.map(({ mode, label, requires, title }) => { + const available = !requires || turnProtocols.includes(requires) + const active = transportMode === mode + const clickable = available && !active + return ( + + ) + })} + + route:{' '} + {snapshot?.route + ? [ + snapshot.route.protocol, + snapshot.route.type, + snapshot.route.relayProtocol && + `relay:${snapshot.route.relayProtocol}`, + ] + .filter(Boolean) + .join('·') + : '–'} + +
+ +
connection scenarios
+
+ {SCENARIOS.map((scenario) => ( + + ))} +
+
+ ) +} + +export default MeetDevtools diff --git a/src/frontend/src/features/devtools/index.tsx b/src/frontend/src/features/devtools/index.tsx new file mode 100644 index 00000000..3b123265 --- /dev/null +++ b/src/frontend/src/features/devtools/index.tsx @@ -0,0 +1,15 @@ +import { lazy, Suspense } from 'react' + + +const LazyPanel = import.meta.env.DEV + ? lazy(() => import('./MeetDevtools')) + : null + +export const MeetDevtools = () => { + if (!LazyPanel) return null + return ( + + + + ) +} diff --git a/src/frontend/src/features/devtools/roomConfig.ts b/src/frontend/src/features/devtools/roomConfig.ts new file mode 100644 index 00000000..e1720119 --- /dev/null +++ b/src/frontend/src/features/devtools/roomConfig.ts @@ -0,0 +1,89 @@ +import { Room } from 'livekit-client' + + +export type ConfigEntry = { + label: string + value: string + /** true = feature actively on, false = off, undefined = informational */ + on?: boolean +} + +const formatBackupCodec = (backup: unknown): string => { + if (backup === undefined || backup === true) return 'auto' + if (backup === false) return 'off' + if (typeof backup === 'object' && backup !== null && 'codec' in backup) { + return String((backup as { codec: unknown }).codec) + } + return String(backup) +} + +export const readRoomConfig = (room: Room): ConfigEntry[] => { + const options = room.options + const publish = options.publishDefaults + const adaptive = options.adaptiveStream + + const entries: ConfigEntry[] = [ + { + label: 'adaptiveStream', + value: + typeof adaptive === 'object' + ? JSON.stringify(adaptive) + : String(!!adaptive), + on: !!adaptive, + }, + { + label: 'dynacast', + value: String(!!options.dynacast), + on: !!options.dynacast, + }, + { + label: 'e2ee', + value: String(room.isE2EEEnabled), + on: room.isE2EEEnabled, + }, + { + label: 'videoCodec', + value: publish?.videoCodec ?? 'default', + }, + { + label: 'backupCodec', + value: formatBackupCodec(publish?.backupCodec), + }, + { + label: 'simulcast', + value: String(publish?.simulcast ?? true), + on: publish?.simulcast ?? true, + }, + { + label: 'audio dtx', + value: String(publish?.dtx ?? true), + on: publish?.dtx ?? true, + }, + { + label: 'audio red', + value: String(publish?.red ?? true), + on: publish?.red ?? true, + }, + { + label: 'quality (local)', + value: room.localParticipant.connectionQuality, + }, + ] + + const server = room.serverInfo + if (server) { + entries.push({ + label: 'server', + value: [ + server.version && `v${server.version}`, + server.region, + server.protocol !== undefined && `proto ${server.protocol}`, + server.edition !== undefined && `edition ${server.edition}`, + ] + .filter(Boolean) + .join(' · '), + }) + } + + return entries +} diff --git a/src/frontend/src/features/devtools/simulation.ts b/src/frontend/src/features/devtools/simulation.ts new file mode 100644 index 00000000..fc5579f1 --- /dev/null +++ b/src/frontend/src/features/devtools/simulation.ts @@ -0,0 +1,193 @@ +import { Room, Track } from 'livekit-client' +// Transitive dependency of livekit-client (pinned by it); used only to +// build the one signal request room.simulateScenario cannot express. +import { SimulateScenario } from '@livekit/protocol' + +/** Shared bandwidth ladder for the −/+ steppers; null = unlimited. */ +export const BITRATE_LADDER_KBPS: Array = [ + null, + 2000, + 1000, + 600, + 300, + 150, +] + +export const stepBitrate = ( + current: number | null, + direction: 'decrease' | 'increase' +): number | null => { + const index = BITRATE_LADDER_KBPS.indexOf(current) + const safeIndex = index === -1 ? 0 : index + const next = + direction === 'decrease' + ? Math.min(safeIndex + 1, BITRATE_LADDER_KBPS.length - 1) + : Math.max(safeIndex - 1, 0) + return BITRATE_LADDER_KBPS[next] +} + +const savedEncodings = new WeakMap>() + +export const setUplinkCap = async ( + room: Room, + kbps: number | null +): Promise => { + const senders: RTCRtpSender[] = [] + room.localParticipant.trackPublications.forEach((pub) => { + const track = pub.track + if (track?.kind === Track.Kind.Video && track.sender) { + senders.push(track.sender) + } + }) + + for (const sender of senders) { + const params = sender.getParameters() + if (!params.encodings || params.encodings.length === 0) continue + + if (kbps === null) { + const original = savedEncodings.get(sender) + params.encodings.forEach((encoding, i) => { + encoding.maxBitrate = original?.[i] + }) + savedEncodings.delete(sender) + } else { + if (!savedEncodings.has(sender)) { + savedEncodings.set( + sender, + params.encodings.map((encoding) => encoding.maxBitrate) + ) + } + const activeCount = + params.encodings.filter((encoding) => encoding.active !== false) + .length || 1 + // Split the budget across active simulcast layers / SVC encoding. + const perEncoding = Math.max( + 30_000, + Math.floor((kbps * 1000) / activeCount) + ) + params.encodings.forEach((encoding) => { + encoding.maxBitrate = perEncoding + }) + } + + try { + await sender.setParameters(params) + } catch (e) { + console.warn('[MeetDevtools] setParameters failed', e) + } + } +} + + +export const setDownlinkCap = ( + room: Room, + kbps: number | null +): Promise => + room.simulateScenario('subscriber-bandwidth', kbps === null ? 0 : kbps * 1000) + +export type Scenario = { + id: string + label: string + /** where the simulation happens */ + side: 'client' | 'server' + run: (room: Room) => Promise +} + +export const SCENARIOS: Scenario[] = [ + { + id: 'resume', + label: 'reconnect (resume)', + side: 'client', + // Replays a signaling WebSocket loss; media keeps flowing, client resumes. + run: (room) => room.simulateScenario('signal-reconnect'), + }, + { + id: 'resume-fail', + label: 'reconnect (resume fails)', + side: 'client', + // Same, but the next resume attempt fails → exercises the retry ladder. + run: (room) => room.simulateScenario('resume-reconnect'), + }, + { + id: 'full-reconnect', + label: 'full reconnect', + side: 'client', + // Complete rejoin with brand-new peer connections. + run: (room) => room.simulateScenario('full-reconnect'), + }, + { + id: 'migration', + label: 'server migration', + side: 'server', + run: (room) => room.simulateScenario('migration'), + }, + { + id: 'node-failure', + label: 'SFU node failure', + side: 'server', + run: (room) => room.simulateScenario('node-failure'), + }, + { + id: 'server-leave', + label: 'server disconnect', + side: 'server', + // Server-initiated leave: the closest thing to "you got kicked". + run: (room) => room.simulateScenario('server-leave'), + }, +] + +export type TransportMode = + | 'auto' + | 'tcp' + | 'turn-udp' + | 'turn-tcp' + | 'turn-tls' + +const clearServerTransportPreference = (room: Room): Promise => + room.engine.client.sendSimulateScenario( + new SimulateScenario({ + scenario: { case: 'switchCandidateProtocol', value: 0 }, + }) + ) + +const setRelayOnly = (room: Room, relay: boolean) => { + room.engine.rtcConfig = { + ...room.engine.rtcConfig, + iceTransportPolicy: relay ? 'relay' : 'all', + } +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 300)) + +export const forceTransport = async ( + room: Room, + mode: 'tcp' | 'turn-udp' | 'turn-tcp' | 'turn-tls' +): Promise => { + if (mode === 'turn-udp') { + await clearServerTransportPreference(room) + setRelayOnly(room, true) + await settle() + return room.simulateScenario('full-reconnect') + } + setRelayOnly(room, false) + return room.simulateScenario(mode === 'turn-tls' ? 'force-tls' : 'force-tcp') +} + +export const releaseForcedTransport = async (room: Room): Promise => { + setRelayOnly(room, false) + await clearServerTransportPreference(room) + await settle() + await room.simulateScenario('full-reconnect') +} + +export const transportModeFromRoute = (route?: { + protocol?: string + relayProtocol?: string +}): TransportMode => { + // relayProtocol is the client→TURN leg; protocol alone means no relay. + if (route?.relayProtocol === 'tls') return 'turn-tls' + if (route?.relayProtocol === 'tcp') return 'turn-tcp' + if (route?.relayProtocol === 'udp') return 'turn-udp' + if (route?.protocol === 'tcp') return 'tcp' + return 'auto' +} diff --git a/src/frontend/src/features/devtools/useWebRTCStats.ts b/src/frontend/src/features/devtools/useWebRTCStats.ts new file mode 100644 index 00000000..bb3df922 --- /dev/null +++ b/src/frontend/src/features/devtools/useWebRTCStats.ts @@ -0,0 +1,357 @@ +import { useEffect, useRef, useState } from 'react' +import { Room, Track } from 'livekit-client' + + +export type LayerInfo = { + /** top published layer, e.g. '1280x720' */ + availRes?: string + /** number of published spatial layers (simulcast/SVC), when known */ + layerCount?: number + /** largest attached element size in device px, e.g. '480x270' */ + elementRes?: string + /** why the forwarded layer is below the top one */ + reason?: 'adaptive' | 'bandwidth' | 'paused' | 'off-screen' +} + +export type TrackRow = { + key: string + dir: 'up' | 'down' + kind: string + label: string + codec?: string + kbps: number + fps?: number + res?: string + lossPct?: number + limitation?: string + /** subscribed video only: SFU layer forwarding context */ + layer?: LayerInfo + /** this track froze during the last tick */ + froze?: boolean +} + +export type StatsSnapshot = { + ts: number + /** wire totals from transport stats (includes headers, RTCP, FEC) */ + upKbps: number + downKbps: number + rttMs?: number + /** worst inbound RTP jitter across subscribed tracks */ + jitterMs?: number + availableOutKbps?: number + /** selected ICE route of the publisher transport (measured, not assumed) */ + route?: { protocol?: string; type?: string; relayProtocol?: string } + /** + * relayProtocol values of gathered relay local candidates — i.e. which + * client→TURN transports are actually configured (udp/tcp/tls). Empty + * when no TURN server is configured. + */ + turnProtocols: string[] + tracks: TrackRow[] +} + +type StatDict = Record +type Counters = Record + +const asNumber = (v: unknown): number | undefined => + typeof v === 'number' && Number.isFinite(v) ? v : undefined + +const asString = (v: unknown): string | undefined => + typeof v === 'string' ? v : undefined + +const shortCodec = (mimeType?: string) => + mimeType ? mimeType.replace(/^(audio|video)\//, '') : undefined + +type TrackContext = { label: string; layer?: LayerInfo; publishedH?: number } + +/** + * msTrackId -> label + subscribed-layer context, rebuilt on every tick. + * The "why is this tile blurry" answer needs SDK state (publication + + * element size) joined with getStats (forwarded resolution); this is the + * SDK-state half. + */ +const buildTrackContext = (room: Room) => { + const contexts = new Map() + room.localParticipant.trackPublications.forEach((pub) => { + const id = pub.track?.mediaStreamTrack?.id + if (id) contexts.set(id, { label: `local ${pub.source}` }) + }) + const dpr = window.devicePixelRatio || 1 + room.remoteParticipants.forEach((participant) => { + // Keep rows scannable: participant names capped at 10 chars. + const rawName = participant.name || participant.identity + const name = rawName.length > 15 ? `${rawName.slice(0, 15)}.` : rawName + participant.trackPublications.forEach((pub) => { + const track = pub.track + const id = track?.mediaStreamTrack?.id + if (!track || !id) return + const label = `${name} ${pub.source}` + if (track.kind !== Track.Kind.Video) { + contexts.set(id, { label }) + return + } + let elementRes: string | undefined + let elementH: number | undefined + for (const element of track.attachedElements) { + const w = Math.round(element.clientWidth * dpr) + const h = Math.round(element.clientHeight * dpr) + if (elementH === undefined || h > elementH) { + elementH = h + elementRes = `${w}x${h}` + } + } + const dims = pub.dimensions + const layers = pub.trackInfo?.layers?.length + contexts.set(id, { + label, + publishedH: dims?.height, + layer: { + availRes: dims ? `${dims.width}x${dims.height}` : undefined, + layerCount: layers && layers > 1 ? layers : undefined, + elementRes, + reason: !pub.isEnabled + ? 'off-screen' + : track.streamState === Track.StreamState.Paused + ? 'paused' + : undefined, + }, + }) + }) + }) + return contexts +} + +/** Decide why a forwarded layer is below the published top layer. */ +const resolveLayerReason = ( + context: TrackContext, + forwardedH: number | undefined +): LayerInfo | undefined => { + const layer = context.layer + if (!layer) return undefined + if (layer.reason) return layer // off-screen / paused already decided + const publishedH = context.publishedH + if (!publishedH || !forwardedH || forwardedH >= publishedH * 0.9) { + return { ...layer, reason: undefined } // full quality, nothing to explain + } + // Below top layer: if the element only needs about what we get, it's + // adaptiveStream fitting the element; otherwise the SFU is holding back + // a layer the element could use — congestion. + const elementH = layer.elementRes + ? Number(layer.elementRes.split('x')[1]) + : undefined + const adaptive = elementH !== undefined && forwardedH >= elementH * 0.7 + return { ...layer, reason: adaptive ? 'adaptive' : 'bandwidth' } +} + +export const useWebRTCStats = ( + room: Room, + enabled: boolean, + intervalMs = 1000 +) => { + // Single source of truth: the snapshot is just the last history entry. + const [history, setHistory] = useState([]) + const prevRef = useRef(new Map()) + + useEffect(() => { + if (!enabled) return + + let cancelled = false + const prev = prevRef.current + + /** Per-stat counter deltas; returns 0 on the first sighting. */ + const deltas = (key: string, now: Counters): Counters => { + const before = prev.get(key) + prev.set(key, now) + const out: Counters = {} + for (const [name, value] of Object.entries(now)) { + out[name] = before?.[name] !== undefined ? value - before[name] : 0 + } + return out + } + + const collect = async () => { + const reports: Array<{ pc: 'pub' | 'sub'; report: RTCStatsReport }> = [] + try { + // Not public API — see file header. + const manager = room.engine?.pcManager + const pub = await manager?.publisher?.getStats() + if (pub) reports.push({ pc: 'pub', report: pub }) + const sub = await manager?.subscriber?.getStats() + if (sub) reports.push({ pc: 'sub', report: sub }) + } catch { + // Engine not ready or SDK internals changed; panel shows nothing. + } + if (cancelled || reports.length === 0) return + + const contexts = buildTrackContext(room) + const tracks: TrackRow[] = [] + let upKbps = 0 + let downKbps = 0 + let rttMs: number | undefined + let jitterMs: number | undefined + let availableOutKbps: number | undefined + let route: StatsSnapshot['route'] + const turnProtocols = new Set() + + for (const { pc, report } of reports) { + const byId = new Map() + report.forEach((stat) => byId.set(stat.id as string, stat as StatDict)) + + report.forEach((raw) => { + const stat = raw as StatDict + const type = asString(stat.type) + const ts = asNumber(stat.timestamp) ?? Date.now() + const key = `${pc}:${asString(stat.id) ?? ''}` + + if ( + type === 'local-candidate' && + asString(stat.candidateType) === 'relay' + ) { + // Relay candidates are only gathered when a TURN server is + // configured and reachable; relayProtocol says how the client + // reaches it (udp/tcp/tls) — a turn:…?transport=udp server + // must NOT be presented as a TURN/TLS capability. + turnProtocols.add( + asString(stat.relayProtocol) ?? asString(stat.protocol) ?? 'udp' + ) + } + + if (type === 'transport') { + const d = deltas(key, { + sent: asNumber(stat.bytesSent) ?? 0, + received: asNumber(stat.bytesReceived) ?? 0, + ts, + }) + if (d.ts > 0) { + upKbps += Math.max(0, (d.sent * 8) / d.ts) + downKbps += Math.max(0, (d.received * 8) / d.ts) + } + } + + if (type === 'candidate-pair' && stat.nominated === true) { + const rtt = asNumber(stat.currentRoundTripTime) + if (rtt !== undefined) rttMs = Math.round(rtt * 1000) + const available = asNumber(stat.availableOutgoingBitrate) + if (available !== undefined && pc === 'pub') { + availableOutKbps = Math.round(available / 1000) + } + if (pc === 'pub') { + const local = byId.get(asString(stat.localCandidateId) ?? '') + route = { + protocol: asString(local?.protocol), + type: asString(local?.candidateType), + relayProtocol: asString(local?.relayProtocol), + } + } + } + + if (type === 'outbound-rtp' || type === 'inbound-rtp') { + const isUp = type === 'outbound-rtp' + const kind = asString(stat.kind) + + if (!isUp) { + const jitter = asNumber(stat.jitter) + if (jitter !== undefined) { + const ms = Math.round(jitter * 1000) + if (jitterMs === undefined || ms > jitterMs) jitterMs = ms + } + } + + // Packet loss: reported directly on inbound-rtp; for outbound it + // lives on the matching remote-inbound-rtp (what the SFU got). + let packetsLost = asNumber(stat.packetsLost) ?? 0 + if (isUp) { + const remote = byId.get(asString(stat.remoteId) ?? '') + packetsLost = asNumber(remote?.packetsLost) ?? 0 + } + + const d = deltas(key, { + bytes: asNumber(isUp ? stat.bytesSent : stat.bytesReceived) ?? 0, + packets: + asNumber(isUp ? stat.packetsSent : stat.packetsReceived) ?? 0, + packetsLost, + ts, + freezeCount: asNumber(stat.freezeCount) ?? 0, + }) + + const kbps = d.ts > 0 ? Math.max(0, (d.bytes * 8) / d.ts) : 0 + const lostTotal = d.packetsLost + d.packets + const lossPct = + lostTotal > 0 + ? Math.max(0, Math.min(100, (d.packetsLost / lostTotal) * 100)) + : 0 + + // Resolve codec + source track. + const codecStat = byId.get(asString(stat.codecId) ?? '') + let msTrackId = asString(stat.trackIdentifier) + if (!msTrackId && isUp) { + const mediaSource = byId.get(asString(stat.mediaSourceId) ?? '') + msTrackId = asString(mediaSource?.trackIdentifier) + } + const rid = asString(stat.rid) + const context = msTrackId ? contexts.get(msTrackId) : undefined + const baseLabel = + context?.label ?? + `${kind ?? 'media'} ssrc ${asNumber(stat.ssrc) ?? '?'}` + + const width = asNumber(stat.frameWidth) + const height = asNumber(stat.frameHeight) + + tracks.push({ + key, + dir: isUp ? 'up' : 'down', + kind: kind ?? 'unknown', + label: rid ? `${baseLabel} [${rid}]` : baseLabel, + codec: shortCodec(asString(codecStat?.mimeType)), + kbps, + fps: asNumber(stat.framesPerSecond), + res: width && height ? `${width}x${height}` : undefined, + lossPct, + limitation: + asString(stat.qualityLimitationReason) === 'none' + ? undefined + : asString(stat.qualityLimitationReason), + layer: + !isUp && kind === 'video' && context + ? resolveLayerReason(context, height) + : undefined, + froze: !isUp && kind === 'video' && d.freezeCount > 0, + }) + } + }) + } + + // Stable order (direction, then label): sorting by bitrate would + // reshuffle rows on every tick as kbps fluctuates. + tracks.sort((a, b) => + a.dir === b.dir + ? a.label.localeCompare(b.label) + : a.dir === 'up' + ? -1 + : 1 + ) + + const next: StatsSnapshot = { + ts: Date.now(), + upKbps: Math.round(upKbps), + downKbps: Math.round(downKbps), + rttMs, + jitterMs, + availableOutKbps, + route, + turnProtocols: Array.from(turnProtocols).sort(), + tracks, + } + setHistory((h) => [...h.slice(-59), next]) + } + + void collect() + const id = window.setInterval(() => void collect(), intervalMs) + return () => { + cancelled = true + window.clearInterval(id) + } + }, [room, enabled, intervalMs]) + + return { snapshot: history[history.length - 1], history } +} diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index ec877d09..f471833c 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -43,6 +43,7 @@ import { useSnapshot } from 'valtio' import { userPreferencesStore } from '@/stores/userPreferences' import { userStore } from '@/stores/user' import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors' +import { MeetDevtools } from '@/features/devtools' import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants' export const Conference = ({ @@ -298,6 +299,7 @@ export const Conference = ({ {!isMobile && } +