mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-01 05:07:56 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 432adf84e6 | |||
| 5747381eff |
@@ -213,6 +213,8 @@ services:
|
|||||||
- "7880:7880"
|
- "7880:7880"
|
||||||
- "7881:7881"
|
- "7881:7881"
|
||||||
- "7882:7882/udp"
|
- "7882:7882/udp"
|
||||||
|
- "3478:3478/udp"
|
||||||
|
- "30000-30100:30000-30100/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -8,3 +8,16 @@ webhook:
|
|||||||
api_key: devkey
|
api_key: devkey
|
||||||
urls:
|
urls:
|
||||||
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
||||||
|
|
||||||
|
turn:
|
||||||
|
enabled: true
|
||||||
|
domain: turn.192.168.1.50.nip.io
|
||||||
|
udp_port: 3478
|
||||||
|
tls_port: 0
|
||||||
|
external_tls: false
|
||||||
|
relay_range_start: 30000
|
||||||
|
relay_range_end: 30100
|
||||||
|
allow_restricted_peer_cidrs:
|
||||||
|
- 192.168.0.0/16
|
||||||
|
- 172.16.0.0/12
|
||||||
|
|
||||||
|
|||||||
@@ -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<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: '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 (
|
||||||
|
<>
|
||||||
|
<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 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) => (
|
||||||
|
<span
|
||||||
|
key={part.text}
|
||||||
|
style={
|
||||||
|
part.tone === 'warn'
|
||||||
|
? { color: COLOR.bad }
|
||||||
|
: part.tone === 'muted'
|
||||||
|
? { color: COLOR.faint }
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{i > 0 && ' · '}
|
||||||
|
{part.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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}>
|
||||||
|
<TrackDetail track={track} />
|
||||||
|
</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.list}>
|
||||||
|
{config.map((entry) => (
|
||||||
|
<div key={entry.label} style={styles.listRow}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
entry.on === undefined
|
||||||
|
? COLOR.faint
|
||||||
|
: entry.on
|
||||||
|
? COLOR.ok
|
||||||
|
: COLOR.bad,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
●
|
||||||
|
</span>
|
||||||
|
<span style={styles.listLabel}>{entry.label}</span>
|
||||||
|
<span>{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,15 @@
|
|||||||
|
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,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
|
||||||
|
}
|
||||||
@@ -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<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,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<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
|
||||||
|
|
||||||
|
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<string, TrackContext>()
|
||||||
|
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<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 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<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) — 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 }
|
||||||
|
}
|
||||||
@@ -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