mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-01 21:28:00 +00:00
🧑💻(devx) add a WebRTC stats and network throttling devtool
Introduce an in-app devtool that monitors WebRTC statistics in real time and lets developers simulate various network scenarios, including constraining the uplink and downlink bandwidth. Makes it much easier to reproduce and investigate connectivity or quality issues locally without depending on external tools. The code was AI generated, and might contain some smell. It's only enabled in dev, and not included in the production build. Feel free to enhance it as needed.
This commit is contained in:
committed by
aleb_the_flash
parent
bd81c99495
commit
21c57bffb4
@@ -0,0 +1,595 @@
|
|||||||
|
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<string, CSSProperties> = {
|
||||||
|
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: '400px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
},
|
||||||
|
configList: { display: 'flex', flexWrap: 'wrap' },
|
||||||
|
configRow: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 6,
|
||||||
|
flex: '0 0 50%',
|
||||||
|
minWidth: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
padding: '1px 8px 1px 0',
|
||||||
|
},
|
||||||
|
configLabel: {
|
||||||
|
color: COLOR.muted,
|
||||||
|
flex: '0 0 45%',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
},
|
||||||
|
configValue: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
},
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
role="img"
|
||||||
|
aria-label="Bandwidth over the last minute"
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
background: COLOR.chartBg,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: `1px solid ${COLOR.hairline}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{history.length >= 2 && (
|
||||||
|
<>
|
||||||
|
<polyline
|
||||||
|
points={points((s) => s.downKbps)}
|
||||||
|
fill="none"
|
||||||
|
stroke={COLOR.down}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points={points((s) => s.upKbps)}
|
||||||
|
fill="none"
|
||||||
|
stroke={COLOR.up}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{/* Legend outside the plot: text over the lines was unreadable. */}
|
||||||
|
<div style={styles.legend}>
|
||||||
|
<span>
|
||||||
|
<span style={{ color: COLOR.down }}>●</span> down
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span style={{ color: COLOR.up }}>●</span> up
|
||||||
|
</span>
|
||||||
|
<span>last 60s · max {Math.round(max)} kbps</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CapStepper = ({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
valueKbps,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
hint: string
|
||||||
|
valueKbps: number | null
|
||||||
|
onChange: (kbps: number | null) => void
|
||||||
|
}) => (
|
||||||
|
<div style={styles.row}>
|
||||||
|
<span style={{ flex: '0 0 70px' }}>{label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={styles.button}
|
||||||
|
aria-label={`Decrease ${label} bandwidth`}
|
||||||
|
onClick={() => onChange(stepBitrate(valueKbps, 'decrease'))}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
...styles.stepperValue,
|
||||||
|
...(valueKbps !== null ? { color: COLOR.down } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{valueKbps === null ? 'unlimited' : `≤ ${valueKbps} kbps`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={styles.button}
|
||||||
|
aria-label={`Increase ${label} bandwidth`}
|
||||||
|
onClick={() => onChange(stepBitrate(valueKbps, 'increase'))}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<span style={styles.mono}>{hint}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const TrackList = ({ title, rows }: { title: string; rows: TrackRow[] }) => (
|
||||||
|
<>
|
||||||
|
<div style={styles.sectionTitle}>
|
||||||
|
<span>
|
||||||
|
{title} ({rows.length})
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{Math.round(rows.reduce((sum, t) => sum + t.kbps, 0))} kbps media
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.list}>
|
||||||
|
{rows.map((track) => (
|
||||||
|
<div key={track.key} style={styles.trackRow}>
|
||||||
|
<span style={styles.trackLabel}>{track.label}</span>
|
||||||
|
<span style={styles.trackCodec}>{track.codec ?? '–'}</span>
|
||||||
|
<span style={styles.trackKbps}>{Math.round(track.kbps)}</span>
|
||||||
|
<span style={styles.trackDetail}>
|
||||||
|
{[
|
||||||
|
track.res,
|
||||||
|
track.fps !== undefined && `${Math.round(track.fps)}fps`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 && <span style={{ color: COLOR.faint }}>none</span>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const MeetDevtools = () => {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const connState = useConnectionState(room)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [firedScenario, setFiredScenario] = useState<string>()
|
||||||
|
const [uplinkCap, setUplinkCapState] = useState<number | null>(null)
|
||||||
|
const [downlinkCap, setDownlinkCapState] = useState<number | null>(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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={styles.toggle}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
aria-label="Open WebRTC devtools"
|
||||||
|
>
|
||||||
|
<span style={{ color: stateColor(connState) }}>●</span> rtc
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section style={styles.panel} aria-label="WebRTC devtools">
|
||||||
|
<div style={styles.metricsLine}>
|
||||||
|
<span>
|
||||||
|
<span style={{ color: COLOR.down }}>↓</span> {snapshot?.downKbps ?? 0}{' '}
|
||||||
|
kbps
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span style={{ color: COLOR.up }}>↑</span> {snapshot?.upKbps ?? 0}{' '}
|
||||||
|
kbps
|
||||||
|
</span>
|
||||||
|
<span style={{ color: COLOR.muted }}>
|
||||||
|
rtt{' '}
|
||||||
|
<span style={{ color: COLOR.text }}>{snapshot?.rttMs ?? '–'}</span> ms
|
||||||
|
</span>
|
||||||
|
<span style={{ color: COLOR.muted }}>
|
||||||
|
jitter{' '}
|
||||||
|
<span style={{ color: COLOR.text }}>{snapshot?.jitterMs ?? '–'}</span>{' '}
|
||||||
|
ms
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: stateColor(connState),
|
||||||
|
fontSize: 10,
|
||||||
|
marginLeft: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
● {connState}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={styles.close}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-label="Close WebRTC devtools"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Sparkline history={history} />
|
||||||
|
|
||||||
|
<div style={styles.sectionTitle}>room configuration (live)</div>
|
||||||
|
<div style={styles.configList}>
|
||||||
|
{config.map((entry) => (
|
||||||
|
<div key={entry.label} style={styles.configRow}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
entry.on === undefined
|
||||||
|
? COLOR.faint
|
||||||
|
: entry.on
|
||||||
|
? COLOR.ok
|
||||||
|
: COLOR.bad,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
●
|
||||||
|
</span>
|
||||||
|
<span style={styles.configLabel}>{entry.label}</span>
|
||||||
|
<span style={styles.configValue} title={entry.value}>
|
||||||
|
{entry.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TrackList title="published ↑" rows={published} />
|
||||||
|
<TrackList title="subscribed ↓" rows={subscribed} />
|
||||||
|
|
||||||
|
<div style={styles.sectionTitle}>bandwidth</div>
|
||||||
|
<div style={{ ...styles.list, gap: 4 }}>
|
||||||
|
<CapStepper
|
||||||
|
label="uplink"
|
||||||
|
hint="encoder cap (setParameters)"
|
||||||
|
valueKbps={uplinkCap}
|
||||||
|
onChange={(kbps) => {
|
||||||
|
setUplinkCapState(kbps)
|
||||||
|
void setUplinkCap(room, kbps)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CapStepper
|
||||||
|
label="downlink"
|
||||||
|
hint="SFU limit (subscriber-bandwidth)"
|
||||||
|
valueKbps={downlinkCap}
|
||||||
|
onChange={(kbps) => {
|
||||||
|
setDownlinkCapState(kbps)
|
||||||
|
void setDownlinkCap(room, kbps).catch((e) =>
|
||||||
|
console.warn('[MeetDevtools] downlink cap failed', e)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.sectionTitle}>transport</div>
|
||||||
|
<div style={styles.row}>
|
||||||
|
{transports.map(({ mode, label, requires, title }) => {
|
||||||
|
const available = !requires || turnProtocols.includes(requires)
|
||||||
|
const active = transportMode === mode
|
||||||
|
const clickable = available && !active
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
disabled={!clickable}
|
||||||
|
title={available ? title : `${title} — not configured`}
|
||||||
|
style={{
|
||||||
|
...styles.button,
|
||||||
|
...(clickable || active ? {} : styles.buttonDisabled),
|
||||||
|
...(active ? styles.buttonActive : {}),
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!clickable) return
|
||||||
|
void (
|
||||||
|
mode === 'auto'
|
||||||
|
? releaseForcedTransport(room)
|
||||||
|
: forceTransport(
|
||||||
|
room,
|
||||||
|
mode as 'tcp' | 'turn-udp' | 'turn-tcp' | 'turn-tls'
|
||||||
|
)
|
||||||
|
).catch((e) =>
|
||||||
|
console.warn('[MeetDevtools] transport change failed', e)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{requires && (
|
||||||
|
<span style={{ color: available ? COLOR.ok : COLOR.faint }}>
|
||||||
|
●{' '}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<span style={styles.mono}>
|
||||||
|
route:{' '}
|
||||||
|
{snapshot?.route
|
||||||
|
? [
|
||||||
|
snapshot.route.protocol,
|
||||||
|
snapshot.route.type,
|
||||||
|
snapshot.route.relayProtocol &&
|
||||||
|
`relay:${snapshot.route.relayProtocol}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('·')
|
||||||
|
: '–'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.sectionTitle}>connection scenarios</div>
|
||||||
|
<div style={styles.row}>
|
||||||
|
{SCENARIOS.map((scenario) => (
|
||||||
|
<button
|
||||||
|
key={scenario.id}
|
||||||
|
type="button"
|
||||||
|
title={`${scenario.side}-side simulation — momentary, watch the state dot`}
|
||||||
|
style={{
|
||||||
|
...styles.button,
|
||||||
|
...(firedScenario === scenario.id ? styles.buttonActive : {}),
|
||||||
|
}}
|
||||||
|
onClick={() => fireScenario(scenario)}
|
||||||
|
>
|
||||||
|
{scenario.side === 'server' ? '☁ ' : ''}
|
||||||
|
{scenario.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MeetDevtools
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { lazy, Suspense } from 'react'
|
||||||
|
|
||||||
|
const LazyPanel = import.meta.env.DEV
|
||||||
|
? lazy(() => import('./MeetDevtools'))
|
||||||
|
: null
|
||||||
|
|
||||||
|
export const MeetDevtools = () => {
|
||||||
|
if (!LazyPanel) return null
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LazyPanel />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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<number | null> = [
|
||||||
|
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<RTCRtpSender, Array<number | undefined>>()
|
||||||
|
|
||||||
|
export const setUplinkCap = async (
|
||||||
|
room: Room,
|
||||||
|
kbps: number | null
|
||||||
|
): Promise<void> => {
|
||||||
|
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<void> =>
|
||||||
|
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<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> =>
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Room } from 'livekit-client'
|
||||||
|
|
||||||
|
export type TrackRow = {
|
||||||
|
key: string
|
||||||
|
dir: 'up' | 'down'
|
||||||
|
label: string
|
||||||
|
codec?: string
|
||||||
|
kbps: number
|
||||||
|
fps?: number
|
||||||
|
res?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
type Counters = Record<string, number>
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
const buildTrackLabels = (room: Room) => {
|
||||||
|
const labels = new Map<string, string>()
|
||||||
|
room.localParticipant.trackPublications.forEach((pub) => {
|
||||||
|
const id = pub.track?.mediaStreamTrack?.id
|
||||||
|
if (id) labels.set(id, `local ${pub.source}`)
|
||||||
|
})
|
||||||
|
room.remoteParticipants.forEach((participant) => {
|
||||||
|
// Keep rows scannable: participant names capped at 10 chars.
|
||||||
|
const rawName = participant.name || participant.identity
|
||||||
|
const name = rawName.length > 10 ? `${rawName.slice(0, 10)}.` : rawName
|
||||||
|
participant.trackPublications.forEach((pub) => {
|
||||||
|
const id = pub.track?.mediaStreamTrack?.id
|
||||||
|
if (id) labels.set(id, `${name} ${pub.source}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
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<StatsSnapshot[]>([])
|
||||||
|
const prevRef = useRef(new Map<string, Counters>())
|
||||||
|
|
||||||
|
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 labels = buildTrackLabels(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<string>()
|
||||||
|
|
||||||
|
for (const { pc, report } of reports) {
|
||||||
|
const byId = new Map<string, StatDict>()
|
||||||
|
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).
|
||||||
|
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'
|
||||||
|
|
||||||
|
if (!isUp) {
|
||||||
|
const jitter = asNumber(stat.jitter)
|
||||||
|
if (jitter !== undefined) {
|
||||||
|
const ms = Math.round(jitter * 1000)
|
||||||
|
if (jitterMs === undefined || ms > jitterMs) jitterMs = ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = deltas(key, {
|
||||||
|
bytes: asNumber(isUp ? stat.bytesSent : stat.bytesReceived) ?? 0,
|
||||||
|
ts,
|
||||||
|
})
|
||||||
|
const kbps = d.ts > 0 ? Math.max(0, (d.bytes * 8) / d.ts) : 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 baseLabel =
|
||||||
|
(msTrackId && labels.get(msTrackId)) ??
|
||||||
|
`${asString(stat.kind) ?? 'media'} ssrc ${asNumber(stat.ssrc) ?? '?'}`
|
||||||
|
|
||||||
|
const width = asNumber(stat.frameWidth)
|
||||||
|
const height = asNumber(stat.frameHeight)
|
||||||
|
|
||||||
|
tracks.push({
|
||||||
|
key,
|
||||||
|
dir: isUp ? 'up' : 'down',
|
||||||
|
label: rid ? `${baseLabel} [${rid}]` : baseLabel,
|
||||||
|
codec: shortCodec(asString(codecStat?.mimeType)),
|
||||||
|
kbps,
|
||||||
|
fps: asNumber(stat.framesPerSecond),
|
||||||
|
res: width && height ? `${width}x${height}` : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ import { useSnapshot } from 'valtio'
|
|||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
import { userStore } from '@/stores/user'
|
import { userStore } from '@/stores/user'
|
||||||
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
||||||
|
import { MeetDevtools } from '@/features/devtools'
|
||||||
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
|
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
@@ -298,6 +299,7 @@ export const Conference = ({
|
|||||||
<VideoConference />
|
<VideoConference />
|
||||||
{!isMobile && <InviteDialog mode={mode} />}
|
{!isMobile && <InviteDialog mode={mode} />}
|
||||||
<PictureInPictureConference />
|
<PictureInPictureConference />
|
||||||
|
<MeetDevtools />
|
||||||
</LiveKitRoom>
|
</LiveKitRoom>
|
||||||
</Screen>
|
</Screen>
|
||||||
</QueryAware>
|
</QueryAware>
|
||||||
|
|||||||
Reference in New Issue
Block a user