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
26 changed files with 120 additions and 2222 deletions
+4 -9
View File
@@ -8,12 +8,12 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### 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
@@ -29,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
@@ -222,10 +221,6 @@ and this project adheres to
- ♿️(frontend) focus side panel container on open #1452 - ♿️(frontend) focus side panel container on open #1452
- 🐛(summary) whisper call error handling - 🐛(summary) whisper call error handling
### Added
- ✨(frontend) add screen share zoom controls #1498
## [1.23.0] - 2026-07-08 ## [1.23.0] - 2026-07-08
### Added ### Added
-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:
-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,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 }
}
@@ -20,7 +20,6 @@ import { Track } from 'livekit-client'
import { ParticipantPlaceholder } from './ParticipantPlaceholder' import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus' import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning' import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ScreenShareZoomableVideo } from '@/features/rooms/livekit/components/ScreenShareZoomableVideo'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog' import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels' import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
@@ -49,8 +48,6 @@ interface ParticipantTileExtendedProps extends ParticipantTileProps {
disableTileControls?: boolean disableTileControls?: boolean
} }
const MOUSE_IDLE_TIME = 3000
export const ParticipantTile: ( export const ParticipantTile: (
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement> props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef< ) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
@@ -92,8 +89,6 @@ export const ParticipantTile: (
) )
const isScreenShare = trackReference.source != Track.Source.Camera const isScreenShare = trackReference.source != Track.Source.Camera
const isRemoteScreenShare =
isScreenShare && !trackReference.participant.isLocal
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false) const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
const participantColor = getParticipantColor(trackReference.participant) const participantColor = getParticipantColor(trackReference.participant)
@@ -103,38 +98,11 @@ export const ParticipantTile: (
}) })
const participantName = name || identity || 'Unknown' const participantName = name || identity || 'Unknown'
// Hover + idle tracking for the focus overlay (pin, effects, mute buttons).
const [isTileHovered, setIsTileHovered] = React.useState(false)
const [isIdle, setIsIdle] = React.useState(false)
const idleTimerRef = React.useRef<number | null>(null)
const handleTileMouseMove = React.useCallback(() => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
idleTimerRef.current = window.setTimeout(
() => setIsIdle(true),
MOUSE_IDLE_TIME
)
setIsIdle(false)
}, [])
const isOverlayVisible = hasKeyboardFocus || (isTileHovered && !isIdle)
// tileRef: fullscreen target. setRefs merges it with the forwarded ref on the same node.
const tileRef = React.useRef<HTMLDivElement>(null)
const setRefs = React.useCallback(
(node: HTMLDivElement | null) => {
;(tileRef as React.MutableRefObject<HTMLDivElement | null>).current = node
if (typeof ref === 'function') ref(node)
else if (ref)
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
},
[ref]
)
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' }) const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const interactiveProps = { const interactiveProps = {
...elementProps, ...elementProps,
// Ensure the tile is focusable to expose contextual controls to keyboard users.
tabIndex: 0, tabIndex: 0,
'aria-label': t('containerLabel', { name: participantName }), 'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => { onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
@@ -152,57 +120,8 @@ export const ParticipantTile: (
}, },
} }
const isVideoTrack =
isTrackReference(trackReference) &&
(trackReference.publication?.kind === 'video' ||
trackReference.source === Track.Source.Camera ||
trackReference.source === Track.Source.ScreenShare)
let trackMedia: React.ReactNode = null
if (isVideoTrack) {
if (isRemoteScreenShare) {
trackMedia = (
<ScreenShareZoomableVideo
trackRef={trackReference}
tileRef={tileRef}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
)
} else {
trackMedia = (
<VideoTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
)
}
} else if (isTrackReference(trackReference)) {
trackMedia = (
<AudioTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
/>
)
}
return ( return (
<div <div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
ref={setRefs}
style={{ position: 'relative' }}
{...interactiveProps}
onMouseEnter={() => setIsTileHovered(true)}
onMouseLeave={() => {
setIsTileHovered(false)
setIsIdle(false)
if (idleTimerRef.current) {
window.clearTimeout(idleTimerRef.current)
idleTimerRef.current = null
}
}}
onMouseMove={handleTileMouseMove}
>
<TrackRefContextIfNeeded trackRef={trackReference}> <TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}> <ParticipantContextIfNeeded participant={trackReference.participant}>
{trackReference.participant.isLocal && ( {trackReference.participant.isLocal && (
@@ -210,7 +129,23 @@ export const ParticipantTile: (
)} )}
{children ?? ( {children ?? (
<> <>
{trackMedia} {isTrackReference(trackReference) &&
(trackReference.publication?.kind === 'video' ||
trackReference.source === Track.Source.Camera ||
trackReference.source === Track.Source.ScreenShare) ? (
<VideoTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
) : (
isTrackReference(trackReference) && (
<AudioTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
/>
)
)}
<div className="lk-participant-placeholder"> <div className="lk-participant-placeholder">
<ParticipantPlaceholder <ParticipantPlaceholder
color={participantColor} color={participantColor}
@@ -229,7 +164,7 @@ export const ParticipantTile: (
{!disableMetadata && !disableTileControls && ( {!disableMetadata && !disableTileControls && (
<ParticipantTileFocus <ParticipantTileFocus
trackRef={trackReference} trackRef={trackReference}
isVisible={isOverlayVisible} hasKeyboardFocus={hasKeyboardFocus}
/> />
)} )}
</ParticipantContextIfNeeded> </ParticipantContextIfNeeded>
@@ -1,22 +1,44 @@
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { TrackReferenceOrPlaceholder } from '@livekit/components-core' import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { ReactNode } from 'react' import { ReactNode, useEffect, useRef, useState } from 'react'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute' import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { FocusButton } from './FocusButton' import { FocusButton } from './FocusButton'
import { EffectsButton } from './EffectsButton' import { EffectsButton } from './EffectsButton'
import { MuteButton } from './MuteButton' import { MuteButton } from './MuteButton'
import { ZoomButton } from './ZoomButton'
const MOUSE_IDLE_TIME = 3000
type FadeOverlayProps = { type FadeOverlayProps = {
children: ReactNode children: ReactNode
isVisible: boolean hasKeyboardFocus: boolean
} }
// Pointer-events none so this overlay doesn't block the zoom surface below. const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
// Hover and idle tracking therefore lives on the tile, which still receives const [active, setActive] = useState(false)
// the pointer events, and comes back in as isVisible. const idleTimerRef = useRef<number | null>(null)
const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
const clearIdleTimer = () => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
}
const armIdleTimer = () => {
clearIdleTimer()
idleTimerRef.current = window.setTimeout(() => {
setActive(false)
}, MOUSE_IDLE_TIME)
}
const handleActivity = () => {
setActive(true)
armIdleTimer()
}
useEffect(() => clearIdleTimer, [])
const isVisible = hasKeyboardFocus || active
return ( return (
<div <div
className={css({ className={css({
@@ -28,10 +50,15 @@ const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
alignItems: 'center', alignItems: 'center',
width: '100%', width: '100%',
height: '100%', height: '100%',
pointerEvents: 'none',
})} })}
data-visible={isVisible || undefined} data-visible={isVisible || undefined}
aria-hidden={!isVisible} aria-hidden={!isVisible}
onMouseEnter={handleActivity}
onMouseMove={handleActivity}
onMouseLeave={() => {
clearIdleTimer()
setActive(false)
}}
> >
{isVisible && children} {isVisible && children}
</div> </div>
@@ -40,10 +67,10 @@ const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
export const ParticipantTileFocus = ({ export const ParticipantTileFocus = ({
trackRef, trackRef,
isVisible, hasKeyboardFocus,
}: { }: {
trackRef: TrackReferenceOrPlaceholder trackRef: TrackReferenceOrPlaceholder
isVisible: boolean hasKeyboardFocus: boolean
}) => { }) => {
const participant = trackRef.participant const participant = trackRef.participant
const isScreenShare = trackRef.source == Track.Source.ScreenShare const isScreenShare = trackRef.source == Track.Source.ScreenShare
@@ -51,7 +78,7 @@ export const ParticipantTileFocus = ({
const canMute = useCanMute(participant) const canMute = useCanMute(participant)
return ( return (
<FadeOverlay isVisible={isVisible}> <FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
<div <div
className={css({ className={css({
backgroundColor: 'primaryDark.50', backgroundColor: 'primaryDark.50',
@@ -60,7 +87,6 @@ export const ParticipantTileFocus = ({
display: 'flex', display: 'flex',
opacity: 0.6, opacity: 0.6,
animation: 'overlayIn 200ms linear 300ms backwards', animation: 'overlayIn 200ms linear 300ms backwards',
pointerEvents: 'auto',
_hover: { _hover: {
opacity: 0.95, opacity: 0.95,
}, },
@@ -68,7 +94,7 @@ export const ParticipantTileFocus = ({
> >
<HStack gap={0.5} padding={0.5}> <HStack gap={0.5} padding={0.5}>
<FocusButton trackRef={trackRef} /> <FocusButton trackRef={trackRef} />
{!isScreenShare && ( {!isScreenShare ? (
<> <>
{isLocal ? ( {isLocal ? (
<EffectsButton /> <EffectsButton />
@@ -76,6 +102,8 @@ export const ParticipantTileFocus = ({
canMute && <MuteButton participant={participant} /> canMute && <MuteButton participant={participant} />
)} )}
</> </>
) : (
!isLocal && <ZoomButton trackRef={trackRef} />
)} )}
</HStack> </HStack>
</div> </div>
@@ -0,0 +1,32 @@
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
import { Button } from '@/primitives'
import { RiFullscreenLine } from '@remixicon/react'
export const ZoomButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
trackRef,
})
if (!isFullscreenAvailable) {
return
}
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('fullScreen')}
onPress={() => toggleFullScreen()}
>
<RiFullscreenLine />
</Button>
)
}
@@ -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>
@@ -1,26 +0,0 @@
import { VideoTrack } from '@livekit/components-react'
import { type TrackReference } from '@livekit/components-core'
import { memo } from 'react'
interface ScreenShareVideoTrackProps {
trackRef: TrackReference
onSubscriptionStatusChanged: (subscribed: boolean) => void
manageSubscription?: boolean
}
// Zoom/pan updates the wrapper transform only; skip VideoTrack re-renders.
export const ScreenShareVideoTrack = memo(
({
trackRef,
onSubscriptionStatusChanged,
manageSubscription,
}: ScreenShareVideoTrackProps) => (
<VideoTrack
trackRef={trackRef}
onSubscriptionStatusChanged={onSubscriptionStatusChanged}
manageSubscription={manageSubscription}
/>
)
)
ScreenShareVideoTrack.displayName = 'ScreenShareVideoTrack'
@@ -1,223 +0,0 @@
import { css } from '@/styled-system/css'
import { Button } from '@/primitives'
import {
RiCollapseDiagonalLine,
RiExpandDiagonalLine,
RiFullscreenExitLine,
RiZoomInLine,
RiZoomOutLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Toolbar } from 'react-aria-components'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { isMacintosh } from '@/utils/livekit'
import { srOnly } from '@/styles/a11y'
interface ScreenShareZoomControlsProps {
containerRef: React.RefObject<HTMLDivElement | null>
isZoomed: boolean
zoomPercentage: number
canZoomIn: boolean
canZoomOut: boolean
onZoomIn: () => void
onZoomOut: () => void
onResetZoom: () => void
}
export const ScreenShareZoomControls = ({
containerRef,
isZoomed,
zoomPercentage,
canZoomIn,
canZoomOut,
onZoomIn,
onZoomOut,
onResetZoom,
}: ScreenShareZoomControlsProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const announce = useScreenReaderAnnounce()
const zoomInButtonRef = useRef<HTMLButtonElement>(null)
const hadFocusInCollapsibleRef = useRef(false)
const [isFullscreen, setIsFullscreen] = useState(false)
// Tracks whether this tile's container triggered fullscreen (vs another share's).
const wasThisTileFullscreen = useRef(false)
const isFullscreenAvailable = document.fullscreenEnabled
// Covers Esc and browser UI exits, not just the toolbar button.
// Only this tile's instance announces to avoid duplicates with multiple shares.
useEffect(() => {
const onChange = () => {
const isThisTileFullscreen =
document.fullscreenElement === containerRef.current
setIsFullscreen(isThisTileFullscreen)
if (isThisTileFullscreen) {
wasThisTileFullscreen.current = true
announce(t('fullScreenEntered'), 'assertive')
} else if (wasThisTileFullscreen.current) {
wasThisTileFullscreen.current = false
announce(t('fullScreenExited'), 'assertive')
}
}
document.addEventListener('fullscreenchange', onChange)
return () => document.removeEventListener('fullscreenchange', onChange)
}, [announce, t, containerRef])
// Back at 100 % the collapsible controls are disabled and hidden, which drops
// keyboard focus on the body. Hand it to the zoom in button instead, the only
// control of that group still reachable.
useEffect(() => {
if (isZoomed || !hadFocusInCollapsibleRef.current) return
hadFocusInCollapsibleRef.current = false
zoomInButtonRef.current?.focus()
}, [isZoomed])
const toggleFullScreen = useCallback(async () => {
try {
if (document.fullscreenElement === containerRef.current) {
await document.exitFullscreen()
} else {
// Tile container so zoom controls stay visible in fullscreen.
await containerRef.current?.requestFullscreen()
}
} catch (error) {
console.error('Error toggling fullscreen:', error)
}
}, [containerRef])
const wheelShortcutVisual = isMacintosh() ? '⌘+scroll' : 'Ctrl+scroll'
return (
<div
className={css({
position: 'absolute',
bottom: '12px',
right: '12px',
zIndex: 2,
pointerEvents: 'auto',
})}
>
{/* react-aria Toolbar: left/right arrows move between the controls and
Tab leaves the group as a whole, as the toolbar role implies. */}
<Toolbar
aria-label={t('toolbarLabel')}
className={css({
display: 'flex',
alignItems: 'center',
backgroundColor: 'primaryDark.50',
borderRadius: '2rem',
padding: '0.5rem',
opacity: 0.7,
transition: 'opacity 200ms linear',
_hover: {
opacity: 0.95,
},
})}
>
<span className={srOnly}>
{t(isMacintosh() ? 'wheelShortcutHintMac' : 'wheelShortcutHint')}
</span>
{/* Animated wrapper: collapses to 0 when not zoomed. padding/margin
trick keeps overflow:hidden from clipping focus rings. */}
<div
className={css({
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
transition: 'max-width 200ms ease-out, opacity 200ms ease-out',
padding: '3px',
margin: '-3px',
})}
style={{
maxWidth: isZoomed ? '12rem' : '0',
opacity: isZoomed ? 1 : 0,
}}
aria-hidden={!isZoomed}
onFocus={() => {
hadFocusInCollapsibleRef.current = true
}}
onBlur={(e) => {
// Disabling a focused button blurs it with no relatedTarget, so the
// flag must survive that case for the effect above to rescue focus.
if (e.relatedTarget) hadFocusInCollapsibleRef.current = false
}}
>
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('fitToWindow')}
aria-label={t('fitToWindow')}
isDisabled={!isZoomed}
onPress={onResetZoom}
>
<RiFullscreenExitLine size={20} />
</Button>
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('zoomOutWithShortcut', {
shortcut: wheelShortcutVisual,
})}
aria-label={t('zoomOut')}
isDisabled={!isZoomed || !canZoomOut}
onPress={onZoomOut}
>
<RiZoomOutLine size={20} />
</Button>
{/* Visual only - zoom level is announced via useScreenReaderAnnounce. */}
<span
aria-hidden="true"
className={css({
color: 'white',
fontSize: '0.8125rem',
fontWeight: 500,
minWidth: '3.25rem',
textAlign: 'center',
userSelect: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 0.25rem',
whiteSpace: 'nowrap',
})}
>
{zoomPercentage} %
</span>
</div>
<Button
ref={zoomInButtonRef}
size="sm"
variant="primaryTextDark"
square
tooltip={t('zoomInWithShortcut', { shortcut: wheelShortcutVisual })}
aria-label={t('zoomIn')}
isDisabled={!canZoomIn}
onPress={onZoomIn}
>
<RiZoomInLine size={20} />
</Button>
{isFullscreenAvailable && (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
aria-label={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
onPress={toggleFullScreen}
>
{isFullscreen ? (
<RiCollapseDiagonalLine size={20} />
) : (
<RiExpandDiagonalLine size={20} />
)}
</Button>
)}
</Toolbar>
</div>
)
}
@@ -1,106 +0,0 @@
import { css } from '@/styled-system/css'
import { type TrackReference } from '@livekit/components-core'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenShareZoom } from '../hooks/useScreenShareZoom'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { ScreenShareZoomControls } from './ScreenShareZoomControls'
import { ScreenShareVideoTrack } from './ScreenShareVideoTrack'
interface ScreenShareZoomableVideoProps {
trackRef: TrackReference
tileRef: React.RefObject<HTMLDivElement | null>
onSubscriptionStatusChanged: (subscribed: boolean) => void
manageSubscription?: boolean
}
export const ScreenShareZoomableVideo = ({
trackRef,
tileRef,
onSubscriptionStatusChanged,
manageSubscription,
}: ScreenShareZoomableVideoProps) => {
const zoom = useScreenShareZoom()
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const announce = useScreenReaderAnnounce()
// SR announcement: announce zoom level on change, with a one-time pan hint
// on the first zoom above 100 % per session.
const prevZoomRef = useRef(zoom.zoomPercentage)
const hasAnnouncedPanHint = useRef(false)
useEffect(() => {
if (prevZoomRef.current === zoom.zoomPercentage) return
const wasAtDefault = prevZoomRef.current <= 100
prevZoomRef.current = zoom.zoomPercentage
if (wasAtDefault && zoom.isZoomed && !hasAnnouncedPanHint.current) {
hasAnnouncedPanHint.current = true
announce(t('panHint', { level: zoom.zoomPercentage }), 'polite')
} else {
announce(t('currentZoomLevel', { level: zoom.zoomPercentage }), 'polite')
}
if (!zoom.isZoomed) hasAnnouncedPanHint.current = false
}, [zoom.zoomPercentage, zoom.isZoomed, announce, t])
// Attach keyboard listener on the tile container (has tabIndex=0).
useEffect(() => {
const el = tileRef.current
if (!el) return
el.addEventListener('keydown', zoom.handleKeyDown)
return () => el.removeEventListener('keydown', zoom.handleKeyDown)
}, [tileRef, zoom.handleKeyDown])
// Native wheel listener with { passive: false } so preventDefault works.
useEffect(() => {
const el = zoom.surfaceElRef.current
if (!el) return
el.addEventListener('wheel', zoom.handleWheel, { passive: false })
return () => el.removeEventListener('wheel', zoom.handleWheel)
}, [zoom.handleWheel, zoom.surfaceElRef])
return (
<>
<div
ref={zoom.surfaceElRef}
className={css({
width: '100%',
height: '100%',
overflow: 'hidden',
position: 'relative',
userSelect: 'none',
// Leaves the browser's native pinch-zoom available on touch devices
// while still routing single-pointer drags to useMove for panning.
touchAction: 'pinch-zoom',
})}
{...zoom.moveProps}
>
<div
ref={zoom.transformElRef}
style={{
width: '100%',
height: '100%',
pointerEvents: 'none',
transformOrigin: 'center center',
}}
>
<ScreenShareVideoTrack
trackRef={trackRef}
onSubscriptionStatusChanged={onSubscriptionStatusChanged}
manageSubscription={manageSubscription}
/>
</div>
</div>
<ScreenShareZoomControls
containerRef={tileRef}
isZoomed={zoom.isZoomed}
zoomPercentage={zoom.zoomPercentage}
canZoomIn={zoom.canZoomIn}
canZoomOut={zoom.canZoomOut}
onZoomIn={zoom.zoomIn}
onZoomOut={zoom.zoomOut}
onResetZoom={zoom.resetZoom}
/>
</>
)
}
@@ -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)
} }
@@ -1,284 +0,0 @@
import { useCallback, useRef, useSyncExternalStore } from 'react'
import { useMove } from 'react-aria'
import type { MoveMoveEvent } from '@react-types/shared'
import {
FULL_PICTURE_RATIO,
MIN_ZOOM,
PAN_STEP,
WHEEL_ZOOM_SPEED,
ZOOM_STEP,
type PanOffset,
type ZoomSnapshot,
buildZoomSnapshot,
clampPan,
clampZoom,
getCursorFromZoomState,
getCursorPercentsFromWheelEvent,
getPanDeltaPercentsFromMove,
getPictureRatio,
getWheelPanOffset,
getZoomTransform,
} from '../utils/screenShareZoom'
/**
* Manages zoom and pan state for a remote screen share.
*
* Performance: zoom/pan live in refs and are applied imperatively to the DOM
* (via transformElRef / surfaceElRef) so the hot path (drag, wheel) never
* triggers a React re-render. A useSyncExternalStore snapshot is flushed only
* when the toolbar UI needs to update (zoom level change, drag end).
*
* Drag/touch panning is handled by react-aria's useMove (moveProps).
* Ctrl/Cmd + wheel zoom is a native listener (must be non-passive to
* preventDefault and block browser page zoom).
* Arrow key panning and +/-/0 zoom are on a keydown listener attached to the
* tile container (which has tabIndex=0 and focus).
*/
export const useScreenShareZoom = () => {
const zoomRef = useRef(MIN_ZOOM)
const panRef = useRef<PanOffset>({ x: 0, y: 0 })
const draggingRef = useRef(false)
// The consumer binds these to the inner transform div and the outer drag surface.
const transformElRef = useRef<HTMLDivElement | null>(null)
const surfaceElRef = useRef<HTMLDivElement | null>(null)
// Snapshot store: subscribers are notified only on explicit flush() calls.
const snapshotRef = useRef<ZoomSnapshot>(
buildZoomSnapshot(MIN_ZOOM, { x: 0, y: 0 }, false)
)
const listenersRef = useRef(new Set<() => void>())
const subscribe = useCallback((cb: () => void) => {
listenersRef.current.add(cb)
return () => {
listenersRef.current.delete(cb)
}
}, [])
const getSnapshot = useCallback(() => snapshotRef.current, [])
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const flush = useCallback(() => {
snapshotRef.current = buildZoomSnapshot(
zoomRef.current,
panRef.current,
draggingRef.current
)
listenersRef.current.forEach((cb) => cb())
}, [])
const applyTransform = useCallback(() => {
const el = transformElRef.current
if (!el) return
el.style.transform = getZoomTransform(zoomRef.current, panRef.current)
}, [])
// The video is letterboxed inside the surface by object-fit: contain, so the
// pan bounds depend on how much of the surface the picture actually covers.
// Read live rather than cached: both the tile and the shared resolution can
// change at any time.
const readPictureRatio = useCallback(() => {
const surface = surfaceElRef.current
const video = transformElRef.current?.querySelector('video')
if (!surface || !video) return FULL_PICTURE_RATIO
return getPictureRatio(
surface.clientWidth,
surface.clientHeight,
video.videoWidth,
video.videoHeight
)
}, [])
const applyCursor = useCallback(() => {
const el = surfaceElRef.current
if (!el) return
el.style.cursor = getCursorFromZoomState(
zoomRef.current,
draggingRef.current
)
}, [])
const zoomIn = useCallback(() => {
const next = clampZoom(zoomRef.current + ZOOM_STEP)
zoomRef.current = next
panRef.current = clampPan(panRef.current, next, readPictureRatio())
applyTransform()
applyCursor()
flush()
}, [applyTransform, applyCursor, flush, readPictureRatio])
const zoomOut = useCallback(() => {
const next = clampZoom(zoomRef.current - ZOOM_STEP)
zoomRef.current = next
panRef.current =
next <= MIN_ZOOM
? { x: 0, y: 0 }
: clampPan(panRef.current, next, readPictureRatio())
applyTransform()
applyCursor()
flush()
}, [applyTransform, applyCursor, flush, readPictureRatio])
const resetZoom = useCallback(() => {
zoomRef.current = MIN_ZOOM
panRef.current = { x: 0, y: 0 }
applyTransform()
applyCursor()
flush()
}, [applyTransform, applyCursor, flush])
// Must be attached with { passive: false } so preventDefault() blocks
// the browser's native Ctrl+scroll page zoom.
const handleWheel = useCallback(
(e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
e.stopPropagation()
const target = e.currentTarget as HTMLElement
const prev = zoomRef.current
const delta = -e.deltaY * WHEEL_ZOOM_SPEED
const next = clampZoom(prev + delta)
if (next <= MIN_ZOOM) {
zoomRef.current = MIN_ZOOM
panRef.current = { x: 0, y: 0 }
} else {
const { cursorXPercent, cursorYPercent } =
getCursorPercentsFromWheelEvent(e, target)
zoomRef.current = next
panRef.current = getWheelPanOffset({
pan: panRef.current,
prevZoom: prev,
nextZoom: next,
cursorXPercent,
cursorYPercent,
ratio: readPictureRatio(),
})
}
applyTransform()
applyCursor()
flush()
},
[applyTransform, applyCursor, flush, readPictureRatio]
)
// useMove handles mouse drag + touch pan. Keyboard arrows are not handled
// here because moveProps is on the zoom surface, while focus is on the tile
// container, see handleKeyDown below.
const { moveProps } = useMove({
onMoveStart() {
if (zoomRef.current <= MIN_ZOOM) return
draggingRef.current = true
applyCursor()
flush()
},
onMove(e: MoveMoveEvent) {
if (zoomRef.current <= MIN_ZOOM) return
const el = surfaceElRef.current
if (!el) return
const { deltaXPercent, deltaYPercent } = getPanDeltaPercentsFromMove(
e.deltaX,
e.deltaY,
el
)
panRef.current = clampPan(
{
x: panRef.current.x + deltaXPercent,
y: panRef.current.y + deltaYPercent,
},
zoomRef.current,
readPictureRatio()
)
applyTransform()
// Mouse drag: skip flush (imperative-only) to avoid re-renders per frame.
// Keyboard: flush so the toolbar reflects the updated position.
if (e.pointerType === 'keyboard') {
flush()
}
},
onMoveEnd() {
draggingRef.current = false
applyTransform()
applyCursor()
flush()
},
})
const panBy = useCallback(
(dx: number, dy: number) => {
panRef.current = clampPan(
{ x: panRef.current.x + dx, y: panRef.current.y + dy },
zoomRef.current,
readPictureRatio()
)
applyTransform()
flush()
},
[applyTransform, flush, readPictureRatio]
)
// Attached to the tile container (not the zoom surface) where keyboard
// focus lives. Arrows pan, +/-/0 zoom.
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const isZoomed = zoomRef.current > MIN_ZOOM
if (!isZoomed && e.key !== '+' && e.key !== '=') return
if (e.key.startsWith('Arrow') && e.target !== e.currentTarget) return
switch (e.key) {
case 'ArrowLeft':
e.preventDefault()
panBy(PAN_STEP, 0)
break
case 'ArrowRight':
e.preventDefault()
panBy(-PAN_STEP, 0)
break
case 'ArrowUp':
e.preventDefault()
panBy(0, PAN_STEP)
break
case 'ArrowDown':
e.preventDefault()
panBy(0, -PAN_STEP)
break
case '+':
case '=':
e.preventDefault()
zoomIn()
break
case '-':
e.preventDefault()
zoomOut()
break
case '0':
e.preventDefault()
resetZoom()
break
}
},
[panBy, zoomIn, zoomOut, resetZoom]
)
return {
...snapshot,
transformElRef,
surfaceElRef,
moveProps,
zoomIn,
zoomOut,
resetZoom,
handleWheel,
handleKeyDown,
}
}
@@ -1,154 +0,0 @@
export const MIN_ZOOM = 1
export const MAX_ZOOM = 4
export const ZOOM_STEP = 0.1
export const WHEEL_ZOOM_SPEED = 0.002
export const PAN_STEP = 5
// Half of a 100 % axis. Geometry, not a tunable: it is both the centre the
// cursor offset is measured from and the half extent the pan is clamped
// against, so the two stay consistent by construction.
export const HALF_EXTENT_PERCENT = 50
export interface PanOffset {
x: number
y: number
}
// Fraction of the surface each axis of the picture covers, in [0, 1].
export interface PictureRatio {
x: number
y: number
}
export const FULL_PICTURE_RATIO: PictureRatio = { x: 1, y: 1 }
export interface ZoomSnapshot {
zoomLevel: number
zoomPercentage: number
panOffset: PanOffset
isZoomed: boolean
isDragging: boolean
canZoomIn: boolean
canZoomOut: boolean
}
export const clampZoom = (value: number) => {
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value))
}
// Restrict pan so the picture always covers the view. Pan is a % of the
// surface, in which object-fit: contain letterboxes the picture: its half
// extent is `ratio * 50` against a view half extent of 50, and scaling by
// `zoom` must keep `zoom * (ratio * 50 - |pan|) >= 50`. An axis whose picture
// is still smaller than the view is pinned to 0, keeping the bars symmetric.
export const clampPan = (
pan: PanOffset,
zoom: number,
ratio: PictureRatio
): PanOffset => {
const maxPanX = Math.max(0, (ratio.x - 1 / zoom) * HALF_EXTENT_PERCENT)
const maxPanY = Math.max(0, (ratio.y - 1 / zoom) * HALF_EXTENT_PERCENT)
return {
x: Math.max(-maxPanX, Math.min(maxPanX, pan.x)),
y: Math.max(-maxPanY, Math.min(maxPanY, pan.y)),
}
}
// Per-axis fraction of the surface covered by an object-fit: contain picture.
export const getPictureRatio = (
surfaceWidth: number,
surfaceHeight: number,
videoWidth: number,
videoHeight: number
): PictureRatio => {
if (!surfaceWidth || !surfaceHeight || !videoWidth || !videoHeight) {
return FULL_PICTURE_RATIO
}
const surfaceRatio = surfaceWidth / surfaceHeight
const videoRatio = videoWidth / videoHeight
return surfaceRatio > videoRatio
? { x: videoRatio / surfaceRatio, y: 1 }
: { x: 1, y: surfaceRatio / videoRatio }
}
export const buildZoomSnapshot = (
zoom: number,
pan: PanOffset,
dragging: boolean
): ZoomSnapshot => {
return {
zoomLevel: zoom,
zoomPercentage: Math.round(zoom * 100),
panOffset: pan,
isZoomed: zoom > MIN_ZOOM,
isDragging: dragging,
canZoomIn: zoom < MAX_ZOOM,
canZoomOut: zoom > MIN_ZOOM,
}
}
export const getZoomTransform = (zoom: number, pan: PanOffset) => {
return `scale(${zoom}) translate(${pan.x}%, ${pan.y}%)`
}
export const getCursorFromZoomState = (zoom: number, dragging: boolean) => {
if (zoom <= MIN_ZOOM) return 'default'
return dragging ? 'grabbing' : 'grab'
}
// Keep the content point under the cursor anchored while zooming. With
// `scale(z) translate(pan%)`, a point at `offset` from the center renders at
// `z * (offset + pan)`, so holding it still gives:
// pan' = pan + cursor * (1 / zoom' - 1 / zoom).
export const getWheelPanOffset = ({
pan,
prevZoom,
nextZoom,
cursorXPercent,
cursorYPercent,
ratio,
}: {
pan: PanOffset
prevZoom: number
nextZoom: number
cursorXPercent: number
cursorYPercent: number
ratio: PictureRatio
}): PanOffset => {
const panShift = 1 / nextZoom - 1 / prevZoom
return clampPan(
{
x: pan.x + cursorXPercent * panShift,
y: pan.y + cursorYPercent * panShift,
},
nextZoom,
ratio
)
}
// Convert cursor pixel position to a % offset from the surface center.
export const getCursorPercentsFromWheelEvent = (
e: WheelEvent,
target: HTMLElement
) => {
const rect = target.getBoundingClientRect()
return {
cursorXPercent:
((e.clientX - rect.left) / rect.width) * 100 - HALF_EXTENT_PERCENT,
cursorYPercent:
((e.clientY - rect.top) / rect.height) * 100 - HALF_EXTENT_PERCENT,
}
}
// Convert useMove pixel deltas to % of the surface dimensions.
export const getPanDeltaPercentsFromMove = (
deltaX: number,
deltaY: number,
surface: HTMLElement
) => {
const rect = surface.getBoundingClientRect()
return {
deltaXPercent: (deltaX / rect.width) * 100,
deltaYPercent: (deltaY / rect.height) * 100,
}
}
@@ -32,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,20 +70,19 @@ export const VideoTab = ({ id }: VideoTabProps) => {
} }
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => { 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),
})
} }
/** /**
+1 -28
View File
@@ -16,10 +16,6 @@ export type ShortcutId =
| 'recording' | 'recording'
| 'reaction' | 'reaction'
| 'fullscreen' | 'fullscreen'
| 'zoom-in'
| 'zoom-out'
| 'zoom-reset'
| 'zoom-pan'
export const getShortcutDescriptorById = (id: ShortcutId) => export const getShortcutDescriptorById = (id: ShortcutId) =>
shortcutCatalog.find((item) => item.id === id) shortcutCatalog.find((item) => item.id === id)
@@ -28,7 +24,7 @@ export type ShortcutDescriptor = {
id: ShortcutId id: ShortcutId
category: ShortcutCategory category: ShortcutCategory
shortcut?: Shortcut shortcut?: Shortcut
kind?: 'press' | 'longPress' | 'arrows' kind?: 'press' | 'longPress'
code?: string // used when kind === 'longPress' (KeyboardEvent.code) code?: string // used when kind === 'longPress' (KeyboardEvent.code)
description?: string description?: string
} }
@@ -90,27 +86,4 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
category: 'interaction', category: 'interaction',
shortcut: { key: 'P', ctrlKey: true, shiftKey: true }, shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
}, },
// Screen share zoom keys are unmodified, so they are bound on the focused
// tile instead of being registered globally. They are listed here so the
// shortcuts panel stays exhaustive.
{
id: 'zoom-in',
category: 'interaction',
shortcut: { key: '+' },
},
{
id: 'zoom-out',
category: 'interaction',
shortcut: { key: '-' },
},
{
id: 'zoom-reset',
category: 'interaction',
shortcut: { key: '0' },
},
{
id: 'zoom-pan',
category: 'interaction',
kind: 'arrows',
},
] ]
@@ -25,7 +25,6 @@ export const formatShortcutLabelForSR = (
shiftLabel, shiftLabel,
plusLabel, plusLabel,
noShortcutLabel, noShortcutLabel,
keyLabels,
}: { }: {
controlLabel: string controlLabel: string
commandLabel: string commandLabel: string
@@ -34,12 +33,10 @@ export const formatShortcutLabelForSR = (
shiftLabel: string shiftLabel: string
plusLabel: string plusLabel: string
noShortcutLabel: string noShortcutLabel: string
// Spelled-out names for keys screen readers may skip or mispronounce.
keyLabels?: Record<string, string>
} }
) => { ) => {
if (!shortcut) return noShortcutLabel if (!shortcut) return noShortcutLabel
const key = keyLabels?.[shortcut.key] ?? shortcut.key?.toUpperCase() const key = shortcut.key?.toUpperCase()
if (!key) return noShortcutLabel if (!key) return noShortcutLabel
const ctrlWord = isMacintosh() ? commandLabel : controlLabel const ctrlWord = isMacintosh() ? commandLabel : controlLabel
const altWord = isMacintosh() ? optionLabel : altLabel const altWord = isMacintosh() ? optionLabel : altLabel
@@ -12,9 +12,6 @@ export const useShortcutFormatting = () => {
const formatVisual = useCallback( const formatVisual = useCallback(
(shortcut?: Shortcut, code?: string, kind?: string) => { (shortcut?: Shortcut, code?: string, kind?: string) => {
if (kind === 'arrows') {
return t('shortcutsPanel.visual.arrows')
}
if (code && kind === 'longPress') { if (code && kind === 'longPress') {
const label = getKeyLabelFromCode(code) const label = getKeyLabelFromCode(code)
return t('shortcutsPanel.visual.hold', { key: label || '?' }) return t('shortcutsPanel.visual.hold', { key: label || '?' })
@@ -26,9 +23,6 @@ export const useShortcutFormatting = () => {
const formatForSR = useCallback( const formatForSR = useCallback(
(shortcut?: Shortcut, code?: string, kind?: string) => { (shortcut?: Shortcut, code?: string, kind?: string) => {
if (kind === 'arrows') {
return t('shortcutsPanel.sr.arrows')
}
if (code && kind === 'longPress') { if (code && kind === 'longPress') {
const label = getKeyLabelFromCode(code) const label = getKeyLabelFromCode(code)
return t('shortcutsPanel.sr.hold', { key: label || '?' }) return t('shortcutsPanel.sr.hold', { key: label || '?' })
@@ -41,10 +35,6 @@ export const useShortcutFormatting = () => {
shiftLabel: t('shortcutsPanel.sr.shift'), shiftLabel: t('shortcutsPanel.sr.shift'),
plusLabel: t('shortcutsPanel.sr.plus'), plusLabel: t('shortcutsPanel.sr.plus'),
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'), noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
keyLabels: {
'+': t('shortcutsPanel.sr.plusKey'),
'-': t('shortcutsPanel.sr.minusKey'),
},
}) })
}, },
[t] [t]
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "{{name}} stummschalten", "muteParticipant": "{{name}} stummschalten",
"fullScreen": "Vollbild" "fullScreen": "Vollbild"
}, },
"screenShareZoom": {
"toolbarLabel": "Zoom-Steuerung für Bildschirmfreigabe",
"zoomIn": "Vergrößern",
"zoomInWithShortcut": "Vergrößern ({{shortcut}})",
"zoomOut": "Verkleinern",
"zoomOutWithShortcut": "Verkleinern ({{shortcut}})",
"wheelShortcut": "Steuerung plus Mausrad",
"wheelShortcutMac": "Befehl plus Mausrad",
"wheelShortcutHint": "Tastenkürzel: Steuerung plus Mausrad zum Vergrößern oder Verkleinern.",
"wheelShortcutHintMac": "Tastenkürzel: Befehl plus Mausrad zum Vergrößern oder Verkleinern.",
"fitToWindow": "An Fenster anpassen",
"fullScreen": "Vollbild",
"exitFullScreen": "Vollbild beenden",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Mit der Maus ziehen oder den Fokus zurück auf die Bildschirmfreigabe setzen und mit den Pfeiltasten im Bild navigieren.",
"fullScreenEntered": "Vollbild aktiviert",
"fullScreenExited": "Vollbild deaktiviert"
},
"shortcutsPanel": { "shortcutsPanel": {
"title": "Tastenkürzel", "title": "Tastenkürzel",
"categories": { "categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Hand heben oder senken", "raise-hand": "Hand heben oder senken",
"toggle-chat": "Chat anzeigen/ausblenden", "toggle-chat": "Chat anzeigen/ausblenden",
"toggle-participants": "Teilnehmende anzeigen/ausblenden", "toggle-participants": "Teilnehmende anzeigen/ausblenden",
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen", "open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
"zoom-in": "In einen geteilten Bildschirm hineinzoomen",
"zoom-out": "Aus einem geteilten Bildschirm herauszoomen",
"zoom-reset": "Zoom des geteilten Bildschirms zurücksetzen",
"zoom-pan": "Im gezoomten geteilten Bildschirm navigieren"
}, },
"sr": { "sr": {
"control": "Steuerung", "control": "Steuerung",
@@ -807,14 +785,10 @@
"shift": "Umschalt", "shift": "Umschalt",
"plus": "plus", "plus": "plus",
"hold": "Halte {{key}} gedrückt", "hold": "Halte {{key}} gedrückt",
"arrows": "Pfeiltasten",
"plusKey": "Plus-Taste",
"minusKey": "Minus-Taste",
"noShortcut": "Kein Tastenkürzel" "noShortcut": "Kein Tastenkürzel"
}, },
"visual": { "visual": {
"hold": "Halte {{key}} gedrückt", "hold": "Halte {{key}} gedrückt"
"arrows": "↑ ↓ ← →"
} }
}, },
"fullScreenWarning": { "fullScreenWarning": {
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "Mute {{name}}", "muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen" "fullScreen": "Full screen"
}, },
"screenShareZoom": {
"toolbarLabel": "Screen share zoom controls",
"zoomIn": "Zoom in",
"zoomInWithShortcut": "Zoom in ({{shortcut}})",
"zoomOut": "Zoom out",
"zoomOutWithShortcut": "Zoom out ({{shortcut}})",
"wheelShortcut": "Control plus scroll wheel",
"wheelShortcutMac": "Command plus scroll wheel",
"wheelShortcutHint": "Shortcut: Control plus scroll wheel to zoom in or out.",
"wheelShortcutHintMac": "Shortcut: Command plus scroll wheel to zoom in or out.",
"fitToWindow": "Fit to window",
"fullScreen": "Full screen",
"exitFullScreen": "Exit full screen",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Drag with mouse, or move focus back to the screen share and use arrow keys to navigate the picture.",
"fullScreenEntered": "Full screen enabled",
"fullScreenExited": "Full screen disabled"
},
"shortcutsPanel": { "shortcutsPanel": {
"title": "Keyboard shortcuts", "title": "Keyboard shortcuts",
"categories": { "categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Raise or lower hand", "raise-hand": "Raise or lower hand",
"toggle-chat": "Toggle chat", "toggle-chat": "Toggle chat",
"toggle-participants": "Toggle participants", "toggle-participants": "Toggle participants",
"open-shortcuts-settings": "Open shortcuts settings", "open-shortcuts-settings": "Open shortcuts settings"
"zoom-in": "Zoom in on a shared screen",
"zoom-out": "Zoom out on a shared screen",
"zoom-reset": "Reset the shared screen zoom",
"zoom-pan": "Move around a zoomed shared screen"
}, },
"sr": { "sr": {
"control": "Control", "control": "Control",
@@ -807,14 +785,10 @@
"shift": "Shift", "shift": "Shift",
"plus": "plus", "plus": "plus",
"hold": "Hold {{key}}", "hold": "Hold {{key}}",
"arrows": "Arrow keys",
"plusKey": "Plus key",
"minusKey": "Minus key",
"noShortcut": "No shortcut" "noShortcut": "No shortcut"
}, },
"visual": { "visual": {
"hold": "Hold {{key}}", "hold": "Hold {{key}}"
"arrows": "↑ ↓ ← →"
} }
}, },
"fullScreenWarning": { "fullScreenWarning": {
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "Couper le micro de {{name}}", "muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran" "fullScreen": "Plein écran"
}, },
"screenShareZoom": {
"toolbarLabel": "Contrôles de zoom du partage d'écran",
"zoomIn": "Zoomer",
"zoomInWithShortcut": "Zoomer ({{shortcut}})",
"zoomOut": "Dézoomer",
"zoomOutWithShortcut": "Dézoomer ({{shortcut}})",
"wheelShortcut": "Contrôle plus molette",
"wheelShortcutMac": "Commande plus molette",
"wheelShortcutHint": "Raccourci : Contrôle plus molette pour zoomer ou dézoomer.",
"wheelShortcutHintMac": "Raccourci : Commande plus molette pour zoomer ou dézoomer.",
"fitToWindow": "Ajuster à la fenêtre",
"fullScreen": "Plein écran",
"exitFullScreen": "Quitter le plein écran",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Glissez avec la souris, ou revenez sur le partage d'écran et utilisez les touches fléchées pour naviguer dans l'image.",
"fullScreenEntered": "Plein écran activé",
"fullScreenExited": "Plein écran désactivé"
},
"shortcutsPanel": { "shortcutsPanel": {
"title": "Raccourcis clavier", "title": "Raccourcis clavier",
"categories": { "categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Lever ou baisser la main", "raise-hand": "Lever ou baisser la main",
"toggle-chat": "Afficher/Masquer le chat", "toggle-chat": "Afficher/Masquer le chat",
"toggle-participants": "Afficher/Masquer les participants", "toggle-participants": "Afficher/Masquer les participants",
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis", "open-shortcuts-settings": "Ouvrir les réglages des raccourcis"
"zoom-in": "Zoomer sur un écran partagé",
"zoom-out": "Dézoomer sur un écran partagé",
"zoom-reset": "Réinitialiser le zoom de l’écran partagé",
"zoom-pan": "Se déplacer dans un écran partagé zoomé"
}, },
"sr": { "sr": {
"control": "Contrôle", "control": "Contrôle",
@@ -807,14 +785,10 @@
"shift": "Majuscule", "shift": "Majuscule",
"plus": "plus", "plus": "plus",
"hold": "Maintenir {{key}}", "hold": "Maintenir {{key}}",
"arrows": "Touches fléchées",
"plusKey": "Touche plus",
"minusKey": "Touche moins",
"noShortcut": "Aucun raccourci" "noShortcut": "Aucun raccourci"
}, },
"visual": { "visual": {
"hold": "Maintenir {{key}}", "hold": "Maintenir {{key}}"
"arrows": "↑ ↓ ← →"
} }
}, },
"fullScreenWarning": { "fullScreenWarning": {
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "Demp {{name}}", "muteParticipant": "Demp {{name}}",
"fullScreen": "Volledig scherm" "fullScreen": "Volledig scherm"
}, },
"screenShareZoom": {
"toolbarLabel": "Zoombediening voor schermdeling",
"zoomIn": "Inzoomen",
"zoomInWithShortcut": "Inzoomen ({{shortcut}})",
"zoomOut": "Uitzoomen",
"zoomOutWithShortcut": "Uitzoomen ({{shortcut}})",
"wheelShortcut": "Control plus scrollwiel",
"wheelShortcutMac": "Command plus scrollwiel",
"wheelShortcutHint": "Sneltoets: Control plus scrollwiel om in of uit te zoomen.",
"wheelShortcutHintMac": "Sneltoets: Command plus scrollwiel om in of uit te zoomen.",
"fitToWindow": "Aanpassen aan venster",
"fullScreen": "Volledig scherm",
"exitFullScreen": "Volledig scherm verlaten",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Sleep met de muis, of zet de focus terug op de schermdeling en gebruik de pijltjestoetsen om door het beeld te navigeren.",
"fullScreenEntered": "Volledig scherm ingeschakeld",
"fullScreenExited": "Volledig scherm uitgeschakeld"
},
"shortcutsPanel": { "shortcutsPanel": {
"title": "Sneltoetsen", "title": "Sneltoetsen",
"categories": { "categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Hand opsteken of laten zakken", "raise-hand": "Hand opsteken of laten zakken",
"toggle-chat": "Chat tonen/verbergen", "toggle-chat": "Chat tonen/verbergen",
"toggle-participants": "Deelnemers tonen/verbergen", "toggle-participants": "Deelnemers tonen/verbergen",
"open-shortcuts-settings": "Sneltoets-instellingen openen", "open-shortcuts-settings": "Sneltoets-instellingen openen"
"zoom-in": "Inzoomen op een gedeeld scherm",
"zoom-out": "Uitzoomen op een gedeeld scherm",
"zoom-reset": "Zoom van het gedeelde scherm herstellen",
"zoom-pan": "Navigeren in een ingezoomd gedeeld scherm"
}, },
"sr": { "sr": {
"control": "Control", "control": "Control",
@@ -807,14 +785,10 @@
"shift": "Shift", "shift": "Shift",
"plus": "plus", "plus": "plus",
"hold": "Houd {{key}} ingedrukt", "hold": "Houd {{key}} ingedrukt",
"arrows": "Pijltoetsen",
"plusKey": "Plus-toets",
"minusKey": "Min-toets",
"noShortcut": "Geen sneltoets" "noShortcut": "Geen sneltoets"
}, },
"visual": { "visual": {
"hold": "Houd {{key}} ingedrukt", "hold": "Houd {{key}} ingedrukt"
"arrows": "↑ ↓ ← →"
} }
}, },
"fullScreenWarning": { "fullScreenWarning": {