Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine f1b0d151fd 📝(docs) document v1.30.0 in UPGRADE.md
Add the missing v1.30.0 entry in `UPGRADE.md`, which was overlooked
when the release was published.
2026-09-01 16:21:52 +02:00
19 changed files with 35 additions and 1205 deletions
+4 -9
View File
@@ -8,16 +8,12 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Added
- ✨(frontend) add 1080p sending resolution option #1660
### Fixed
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
## [1.30.0] - 2026-09-01 ## [1.30.0] - 2026-09-01
### Changed
- ♿️(frontend) close side panel with Escape key #1507
### Added ### Added
- ✨(agent) support Voxtral realtime as inference engine - ✨(agent) support Voxtral realtime as inference engine
@@ -33,7 +29,6 @@ and this project adheres to
- ⬆️(frontend) upgrade @fontsource/opendyslexic from 5.2.5 to 5.3.0 - ⬆️(frontend) upgrade @fontsource/opendyslexic from 5.2.5 to 5.3.0
- ⬆️(addons) upgrade core-js from 3.49.0 to 3.50.0 - ⬆️(addons) upgrade core-js from 3.49.0 to 3.50.0
- ♻️(backend) factorize s3 client creation in utils - ♻️(backend) factorize s3 client creation in utils
- ♿️(frontend) close side panel with Escape key #1507
### Fixed ### Fixed
-2
View File
@@ -213,8 +213,6 @@ 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:
-1
View File
@@ -65,7 +65,6 @@ RUN apk update && apk upgrade \
musl \ musl \
musl-utils \ musl-utils \
zlib>=1.3.2-r0 \ zlib>=1.3.2-r0 \
libexpat>=2.8.4-r0 \
&& apk del curl && apk del curl
USER nginx USER nginx
-13
View File
@@ -8,16 +8,3 @@ 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.127.0.0.1.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
-1
View File
@@ -53,7 +53,6 @@ RUN apk update && apk upgrade \
musl \ musl \
musl-utils \ musl-utils \
zlib>=1.3.2-r0 \ zlib>=1.3.2-r0 \
libexpat>=2.8.4-r0 \
&& apk del curl && apk del curl
USER nginx USER nginx
@@ -1,595 +0,0 @@
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
@@ -1,14 +0,0 @@
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>
)
}
@@ -1,88 +0,0 @@
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
}
@@ -1,192 +0,0 @@
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'
}
@@ -1,243 +0,0 @@
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,7 +43,6 @@ 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 = ({
@@ -299,7 +298,6 @@ export const Conference = ({
<VideoConference /> <VideoConference />
{!isMobile && <InviteDialog mode={mode} />} {!isMobile && <InviteDialog mode={mode} />}
<PictureInPictureConference /> <PictureInPictureConference />
<MeetDevtools />
</LiveKitRoom> </LiveKitRoom>
</Screen> </Screen>
</QueryAware> </QueryAware>
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react' import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Button, Popover } from '@/primitives' import { Button, Popover } from '@/primitives'
import { RiArrowUpSLine, RiImageCircleAiFill } from '@remixicon/react' import { RiArrowUpSLine, RiImageCircleAiFill } from '@remixicon/react'
import { Track, type VideoCaptureOptions, VideoPresets } from 'livekit-client' import { Track, type VideoCaptureOptions } from 'livekit-client'
import { ToggleDevice } from './ToggleDevice' import { ToggleDevice } from './ToggleDevice'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
@@ -57,8 +57,7 @@ export const VideoDeviceControl = ({
}: VideoDeviceControlProps) => { }: VideoDeviceControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { videoDeviceId, processorConfig, videoPublishResolution } = const { videoDeviceId, processorConfig } = useSnapshot(userChoicesStore)
useSnapshot(userChoicesStore)
const onChange = React.useCallback( const onChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) => (enabled: boolean, isUserInitiated: boolean) =>
@@ -98,9 +97,6 @@ export const VideoDeviceControl = ({
await toggle(!trackProps.enabled, { await toggle(!trackProps.enabled, {
processor: processor, processor: processor,
...(videoPublishResolution && {
resolution: VideoPresets[videoPublishResolution].resolution,
}),
} as VideoCaptureOptions) } as VideoCaptureOptions)
} }
@@ -18,7 +18,6 @@ import {
saveVideoPublishResolution, saveVideoPublishResolution,
saveVideoSubscribeQuality, saveVideoSubscribeQuality,
userChoicesStore, userChoicesStore,
VIDEO_RESOLUTIONS,
VideoResolution, VideoResolution,
} from '@/stores/userChoices' } from '@/stores/userChoices'
import { RowWrapper } from './layout/RowWrapper' import { RowWrapper } from './layout/RowWrapper'
@@ -33,8 +32,7 @@ const EMPTY_PROPS = {}
export const VideoTab = ({ id }: VideoTabProps) => { export const VideoTab = ({ id }: VideoTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'video' }) const { t } = useTranslation('settings', { keyPrefix: 'video' })
const room = useRoomContext() const { localParticipant, remoteParticipants } = useRoomContext()
const { localParticipant, remoteParticipants } = room
const { const {
videoDeviceId, videoDeviceId,
@@ -71,21 +69,20 @@ export const VideoTab = ({ id }: VideoTabProps) => {
isDisabled: true, isDisabled: true,
} }
const handleVideoResolutionChange = async (key: VideoResolution) => { const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
saveVideoPublishResolution(key) const videoPublication = localParticipant.getTrackPublication(
const videoTrack = localParticipant.getTrackPublication(
Track.Source.Camera Track.Source.Camera
)?.track )
if (!videoTrack) { const videoTrack = videoPublication?.track
return if (videoTrack) {
saveVideoPublishResolution(key)
await videoTrack.restartTrack({
resolution: VideoPresets[key].resolution,
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
})
} }
await videoTrack.restartTrack({
resolution: VideoPresets[key].resolution,
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
})
} }
/** /**
@@ -125,13 +122,20 @@ export const VideoTab = ({ id }: VideoTabProps) => {
}, [videoDeviceId, videoElement]) }, [videoDeviceId, videoElement])
const resolutionItems = useMemo(() => { const resolutionItems = useMemo(() => {
const labels: Record<VideoResolution, string> = { return [
h1080: `${t('resolution.publish.items.veryHigh')} (1080p)`, {
h720: `${t('resolution.publish.items.high')} (720p)`, value: 'h720',
h360: `${t('resolution.publish.items.medium')} (360p)`, label: `${t('resolution.publish.items.high')} (720p)`,
h180: `${t('resolution.publish.items.low')} (180p)`, },
} {
return VIDEO_RESOLUTIONS.map((value) => ({ value, label: labels[value] })) value: 'h360',
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]
}, [t]) }, [t])
const videoQualityItems = useMemo(() => { const videoQualityItems = useMemo(() => {
@@ -56,7 +56,6 @@
"publish": { "publish": {
"label": "Wähle die maximale Auflösung beim Senden", "label": "Wähle die maximale Auflösung beim Senden",
"items": { "items": {
"veryHigh": "Sehr hohe Auflösung",
"high": "Hohe Auflösung", "high": "Hohe Auflösung",
"medium": "Mittlere Auflösung", "medium": "Mittlere Auflösung",
"low": "Niedrige Auflösung" "low": "Niedrige Auflösung"
@@ -56,7 +56,6 @@
"publish": { "publish": {
"label": "Select your sending resolution (max.)", "label": "Select your sending resolution (max.)",
"items": { "items": {
"veryHigh": "Very high definition",
"high": "High definition", "high": "High definition",
"medium": "Standard definition", "medium": "Standard definition",
"low": "Low definition" "low": "Low definition"
@@ -56,7 +56,6 @@
"publish": { "publish": {
"label": "Selecciona tu resolución de envío (máx.)", "label": "Selecciona tu resolución de envío (máx.)",
"items": { "items": {
"veryHigh": "Muy alta definición",
"high": "Alta definición", "high": "Alta definición",
"medium": "Definición estándar", "medium": "Definición estándar",
"low": "Baja definición" "low": "Baja definición"
@@ -56,7 +56,6 @@
"publish": { "publish": {
"label": "Sélectionner votre résolution d'envoi (max.)", "label": "Sélectionner votre résolution d'envoi (max.)",
"items": { "items": {
"veryHigh": "Très haute définition",
"high": "Haute définition", "high": "Haute définition",
"medium": "Définition standard", "medium": "Définition standard",
"low": "Basse définition" "low": "Basse définition"
@@ -56,7 +56,6 @@
"publish": { "publish": {
"label": "Selecteer uw verzendresolutie (max.)", "label": "Selecteer uw verzendresolutie (max.)",
"items": { "items": {
"veryHigh": "Zeer hoge definitie",
"high": "Hoge definitie", "high": "Hoge definitie",
"medium": "Standaarddefinitie", "medium": "Standaarddefinitie",
"low": "Lage definitie" "low": "Lage definitie"
+2 -11
View File
@@ -10,12 +10,7 @@ import {
} from '@livekit/components-core' } from '@livekit/components-core'
import { VideoQuality } from 'livekit-client' import { VideoQuality } from 'livekit-client'
export const VIDEO_RESOLUTIONS = ['h1080', 'h720', 'h360', 'h180'] as const export type VideoResolution = 'h720' | 'h360' | 'h180'
export type VideoResolution = (typeof VIDEO_RESOLUTIONS)[number]
const isVideoResolution = (value: unknown): value is VideoResolution =>
VIDEO_RESOLUTIONS.includes(value as VideoResolution)
export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & { export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
processorConfig?: ProcessorConfig processorConfig?: ProcessorConfig
@@ -26,17 +21,13 @@ export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
} }
function getUserChoicesState(): LocalUserChoices { function getUserChoicesState(): LocalUserChoices {
const stored: LocalUserChoices = { return {
noiseReductionEnabled: false, noiseReductionEnabled: false,
audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior
videoPublishResolution: 'h720', videoPublishResolution: 'h720',
videoSubscribeQuality: VideoQuality.HIGH, videoSubscribeQuality: VideoQuality.HIGH,
...loadUserChoices(), ...loadUserChoices(),
} }
if (!isVideoResolution(stored.videoPublishResolution)) {
stored.videoPublishResolution = 'h720'
}
return stored
} }
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState()) export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())