mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-02 21:58:29 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1b0d151fd |
+4
-9
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+40
-12
@@ -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>
|
||||||
|
|||||||
+32
@@ -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
-6
@@ -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,14 +70,12 @@ 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({
|
await videoTrack.restartTrack({
|
||||||
resolution: VideoPresets[key].resolution,
|
resolution: VideoPresets[key].resolution,
|
||||||
deviceId: { exact: videoDeviceId },
|
deviceId: { exact: videoDeviceId },
|
||||||
@@ -86,6 +83,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates video quality for all existing remote video tracks when user preference changes.
|
* Updates video quality for all existing remote video tracks when user preference changes.
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user