mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-03 14:17:59 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43e0a1f9ee | |||
| 91a91da1ef | |||
| 0e0485b694 | |||
| 7b95c3e6df | |||
| 2f908cdaf8 | |||
| 649211bdd8 | |||
| 7bf13245a6 | |||
| d656367fa7 | |||
| 414494d864 | |||
| f52c92f584 | |||
| bdf6163d45 | |||
| 15e9f509b1 | |||
| 13841573ea | |||
| 3f3c9337c8 | |||
| d7206e9e46 | |||
| e0e3b91f5d | |||
| d4fa88d9ca | |||
| 3c8b247a99 | |||
| 6b2d67cf61 | |||
| 1ac1778521 | |||
| fcc58065d2 | |||
| 21c57bffb4 | |||
| bd81c99495 | |||
| 839cfa4b80 |
+9
-4
@@ -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,6 +29,7 @@ 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
|
||||||
|
|
||||||
@@ -221,6 +222,10 @@ 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
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ the following command inside your docker container:
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## v1.30.0
|
||||||
|
|
||||||
### Removing S3 storage-event webhooks for recordings
|
### Removing S3 storage-event webhooks for recordings
|
||||||
|
|
||||||
Recordings were previously confirmed as saved by an S3 storage-event webhook posting to `/api/v1.0/recordings/storage-hook/`. That endpoint has been removed: recordings are now always finalized from LiveKit's own `egress_ended` webhook, which has been the default path since v1.22.0.
|
Recordings were previously confirmed as saved by an S3 storage-event webhook posting to `/api/v1.0/recordings/storage-hook/`. That endpoint has been removed: recordings are now always finalized from LiveKit's own `egress_ended` webhook, which has been the default path since v1.22.0.
|
||||||
|
|||||||
@@ -213,6 +213,8 @@ services:
|
|||||||
- "7880:7880"
|
- "7880:7880"
|
||||||
- "7881:7881"
|
- "7881:7881"
|
||||||
- "7882:7882/udp"
|
- "7882:7882/udp"
|
||||||
|
- "3478:3478/udp"
|
||||||
|
- "30000-30100:30000-30100/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -8,3 +8,16 @@ webhook:
|
|||||||
api_key: devkey
|
api_key: devkey
|
||||||
urls:
|
urls:
|
||||||
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
||||||
|
|
||||||
|
turn:
|
||||||
|
enabled: true
|
||||||
|
domain: turn.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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,595 @@
|
|||||||
|
import { CSSProperties, useState } from 'react'
|
||||||
|
import { useConnectionState, useRoomContext } from '@livekit/components-react'
|
||||||
|
import { ConnectionState } from 'livekit-client'
|
||||||
|
import { StatsSnapshot, TrackRow, useWebRTCStats } from './useWebRTCStats'
|
||||||
|
import { readRoomConfig } from './roomConfig'
|
||||||
|
import {
|
||||||
|
forceTransport,
|
||||||
|
releaseForcedTransport,
|
||||||
|
Scenario,
|
||||||
|
SCENARIOS,
|
||||||
|
setDownlinkCap,
|
||||||
|
setUplinkCap,
|
||||||
|
stepBitrate,
|
||||||
|
TransportMode,
|
||||||
|
transportModeFromRoute,
|
||||||
|
} from './simulation'
|
||||||
|
|
||||||
|
const SANS =
|
||||||
|
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||||
|
const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
|
||||||
|
|
||||||
|
const COLOR = {
|
||||||
|
text: '#e8e8e8',
|
||||||
|
muted: '#9a9a9a',
|
||||||
|
faint: '#6f6f6f',
|
||||||
|
hairline: '#2c2d31',
|
||||||
|
border: '#3d3e44',
|
||||||
|
surface: '#1b1c1e',
|
||||||
|
chartBg: '#141517',
|
||||||
|
down: '#f6821f',
|
||||||
|
up: '#5a9cf8',
|
||||||
|
ok: '#10b981',
|
||||||
|
bad: '#f05a4a',
|
||||||
|
busy: '#e5a13c',
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateColor = (state: ConnectionState): string => {
|
||||||
|
switch (state) {
|
||||||
|
case ConnectionState.Connected:
|
||||||
|
return COLOR.ok
|
||||||
|
case ConnectionState.Reconnecting:
|
||||||
|
case ConnectionState.SignalReconnecting:
|
||||||
|
return COLOR.busy
|
||||||
|
case ConnectionState.Connecting:
|
||||||
|
return COLOR.up
|
||||||
|
case ConnectionState.Disconnected:
|
||||||
|
return COLOR.bad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, CSSProperties> = {
|
||||||
|
toggle: {
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 12,
|
||||||
|
right: 12,
|
||||||
|
zIndex: 9999,
|
||||||
|
fontFamily: SANS,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1,
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 999,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderColor: COLOR.border,
|
||||||
|
background: COLOR.surface,
|
||||||
|
color: COLOR.text,
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: '0 2px 10px #0006',
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 12,
|
||||||
|
right: 12,
|
||||||
|
zIndex: 9999,
|
||||||
|
width: 460,
|
||||||
|
maxWidth: 'calc(100vw - 24px)',
|
||||||
|
maxHeight: 'calc(100vh - 24px)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
fontFamily: SANS,
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: COLOR.text,
|
||||||
|
background: COLOR.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderColor: COLOR.border,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 14,
|
||||||
|
boxShadow: '0 10px 34px #00000080',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
margin: '14px 0 5px',
|
||||||
|
color: COLOR.faint,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 600,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
metricsLine: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
margin: '0 0 10px',
|
||||||
|
paddingBottom: 10,
|
||||||
|
borderBottom: `1px solid ${COLOR.hairline}`,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 3,
|
||||||
|
color: COLOR.faint,
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
maxHeight: '400px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
},
|
||||||
|
configList: { display: 'flex', flexWrap: 'wrap' },
|
||||||
|
configRow: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 6,
|
||||||
|
flex: '0 0 50%',
|
||||||
|
minWidth: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
padding: '1px 8px 1px 0',
|
||||||
|
},
|
||||||
|
configLabel: {
|
||||||
|
color: COLOR.muted,
|
||||||
|
flex: '0 0 45%',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
},
|
||||||
|
configValue: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
},
|
||||||
|
trackRow: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
padding: '3px 0',
|
||||||
|
borderBottom: `1px solid ${COLOR.hairline}`,
|
||||||
|
alignItems: 'baseline',
|
||||||
|
},
|
||||||
|
trackLabel: {
|
||||||
|
flex: '0 0 34%',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
},
|
||||||
|
trackCodec: {
|
||||||
|
flex: '0 0 11%',
|
||||||
|
fontFamily: MONO,
|
||||||
|
fontSize: 10,
|
||||||
|
color: COLOR.muted,
|
||||||
|
},
|
||||||
|
trackKbps: {
|
||||||
|
flex: '0 0 9%',
|
||||||
|
textAlign: 'right',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
},
|
||||||
|
trackDetail: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
},
|
||||||
|
mono: { fontFamily: MONO, fontSize: 10, color: COLOR.muted },
|
||||||
|
button: {
|
||||||
|
fontFamily: SANS,
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '3px 9px',
|
||||||
|
borderRadius: 5,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderColor: COLOR.border,
|
||||||
|
background: '#232428',
|
||||||
|
color: COLOR.text,
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
buttonActive: { borderColor: COLOR.down, color: COLOR.down },
|
||||||
|
buttonDisabled: { color: COLOR.faint, cursor: 'not-allowed' },
|
||||||
|
row: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
|
||||||
|
stepperValue: {
|
||||||
|
minWidth: 92,
|
||||||
|
textAlign: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderColor: COLOR.hairline,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: COLOR.chartBg,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
},
|
||||||
|
close: {
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: COLOR.faint,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const Sparkline = ({ history }: { history: StatsSnapshot[] }) => {
|
||||||
|
const width = 430
|
||||||
|
const height = 54
|
||||||
|
const max = Math.max(...history.map((s) => Math.max(s.upKbps, s.downKbps)), 1)
|
||||||
|
const points = (pick: (s: StatsSnapshot) => number) =>
|
||||||
|
history
|
||||||
|
.map(
|
||||||
|
(s, i) =>
|
||||||
|
`${((i / Math.max(history.length - 1, 1)) * width).toFixed(1)},${(
|
||||||
|
height -
|
||||||
|
(pick(s) / max) * (height - 6) -
|
||||||
|
3
|
||||||
|
).toFixed(1)}`
|
||||||
|
)
|
||||||
|
.join(' ')
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
role="img"
|
||||||
|
aria-label="Bandwidth over the last minute"
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
background: COLOR.chartBg,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: `1px solid ${COLOR.hairline}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{history.length >= 2 && (
|
||||||
|
<>
|
||||||
|
<polyline
|
||||||
|
points={points((s) => s.downKbps)}
|
||||||
|
fill="none"
|
||||||
|
stroke={COLOR.down}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points={points((s) => s.upKbps)}
|
||||||
|
fill="none"
|
||||||
|
stroke={COLOR.up}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{/* Legend outside the plot: text over the lines was unreadable. */}
|
||||||
|
<div style={styles.legend}>
|
||||||
|
<span>
|
||||||
|
<span style={{ color: COLOR.down }}>●</span> down
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span style={{ color: COLOR.up }}>●</span> up
|
||||||
|
</span>
|
||||||
|
<span>last 60s · max {Math.round(max)} kbps</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CapStepper = ({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
valueKbps,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
hint: string
|
||||||
|
valueKbps: number | null
|
||||||
|
onChange: (kbps: number | null) => void
|
||||||
|
}) => (
|
||||||
|
<div style={styles.row}>
|
||||||
|
<span style={{ flex: '0 0 70px' }}>{label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={styles.button}
|
||||||
|
aria-label={`Decrease ${label} bandwidth`}
|
||||||
|
onClick={() => onChange(stepBitrate(valueKbps, 'decrease'))}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
...styles.stepperValue,
|
||||||
|
...(valueKbps !== null ? { color: COLOR.down } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{valueKbps === null ? 'unlimited' : `≤ ${valueKbps} kbps`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={styles.button}
|
||||||
|
aria-label={`Increase ${label} bandwidth`}
|
||||||
|
onClick={() => onChange(stepBitrate(valueKbps, 'increase'))}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<span style={styles.mono}>{hint}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const TrackList = ({ title, rows }: { title: string; rows: TrackRow[] }) => (
|
||||||
|
<>
|
||||||
|
<div style={styles.sectionTitle}>
|
||||||
|
<span>
|
||||||
|
{title} ({rows.length})
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{Math.round(rows.reduce((sum, t) => sum + t.kbps, 0))} kbps media
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.list}>
|
||||||
|
{rows.map((track) => (
|
||||||
|
<div key={track.key} style={styles.trackRow}>
|
||||||
|
<span style={styles.trackLabel}>{track.label}</span>
|
||||||
|
<span style={styles.trackCodec}>{track.codec ?? '–'}</span>
|
||||||
|
<span style={styles.trackKbps}>{Math.round(track.kbps)}</span>
|
||||||
|
<span style={styles.trackDetail}>
|
||||||
|
{[
|
||||||
|
track.res,
|
||||||
|
track.fps !== undefined && `${Math.round(track.fps)}fps`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 && <span style={{ color: COLOR.faint }}>none</span>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const MeetDevtools = () => {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const connState = useConnectionState(room)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [firedScenario, setFiredScenario] = useState<string>()
|
||||||
|
const [uplinkCap, setUplinkCapState] = useState<number | null>(null)
|
||||||
|
const [downlinkCap, setDownlinkCapState] = useState<number | null>(null)
|
||||||
|
const { snapshot, history } = useWebRTCStats(room, open)
|
||||||
|
|
||||||
|
const transportMode: TransportMode = transportModeFromRoute(snapshot?.route)
|
||||||
|
|
||||||
|
const fireScenario = (scenario: Scenario) => {
|
||||||
|
setFiredScenario(scenario.id)
|
||||||
|
scenario.run(room).catch((e) => {
|
||||||
|
console.warn('[MeetDevtools] simulateScenario failed', e)
|
||||||
|
})
|
||||||
|
window.setTimeout(
|
||||||
|
() =>
|
||||||
|
setFiredScenario((current) =>
|
||||||
|
current === scenario.id ? undefined : current
|
||||||
|
),
|
||||||
|
1200
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={styles.toggle}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
aria-label="Open WebRTC devtools"
|
||||||
|
>
|
||||||
|
<span style={{ color: stateColor(connState) }}>●</span> rtc
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = readRoomConfig(room)
|
||||||
|
const published = snapshot?.tracks.filter((t) => t.dir === 'up') ?? []
|
||||||
|
const subscribed = snapshot?.tracks.filter((t) => t.dir === 'down') ?? []
|
||||||
|
const turnProtocols = snapshot?.turnProtocols ?? []
|
||||||
|
const transports: Array<{
|
||||||
|
mode: TransportMode
|
||||||
|
label: string
|
||||||
|
requires?: string
|
||||||
|
title: string
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
mode: 'auto',
|
||||||
|
label: 'auto (udp)',
|
||||||
|
title:
|
||||||
|
'clears the server-cached transport preference and relay-only policy, then full reconnect',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: 'tcp',
|
||||||
|
label: 'tcp',
|
||||||
|
title: 'force-tcp — server prefers TCP candidates (ICE/TCP)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: 'turn-udp',
|
||||||
|
label: 'turn:udp',
|
||||||
|
requires: 'udp',
|
||||||
|
title:
|
||||||
|
'client-side: iceTransportPolicy relay + full reconnect (no server hook exists)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: 'turn-tcp',
|
||||||
|
label: 'turn:tcp',
|
||||||
|
requires: 'tcp',
|
||||||
|
title: 'force-tcp — lands on TURN/TCP when it is the TCP path',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: 'turn-tls',
|
||||||
|
label: 'turn:tls',
|
||||||
|
requires: 'tls',
|
||||||
|
title: 'force-tls — server switches you to TURN over TLS',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={styles.panel} aria-label="WebRTC devtools">
|
||||||
|
<div style={styles.metricsLine}>
|
||||||
|
<span>
|
||||||
|
<span style={{ color: COLOR.down }}>↓</span> {snapshot?.downKbps ?? 0}{' '}
|
||||||
|
kbps
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span style={{ color: COLOR.up }}>↑</span> {snapshot?.upKbps ?? 0}{' '}
|
||||||
|
kbps
|
||||||
|
</span>
|
||||||
|
<span style={{ color: COLOR.muted }}>
|
||||||
|
rtt{' '}
|
||||||
|
<span style={{ color: COLOR.text }}>{snapshot?.rttMs ?? '–'}</span> ms
|
||||||
|
</span>
|
||||||
|
<span style={{ color: COLOR.muted }}>
|
||||||
|
jitter{' '}
|
||||||
|
<span style={{ color: COLOR.text }}>{snapshot?.jitterMs ?? '–'}</span>{' '}
|
||||||
|
ms
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: stateColor(connState),
|
||||||
|
fontSize: 10,
|
||||||
|
marginLeft: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
● {connState}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={styles.close}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-label="Close WebRTC devtools"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Sparkline history={history} />
|
||||||
|
|
||||||
|
<div style={styles.sectionTitle}>room configuration (live)</div>
|
||||||
|
<div style={styles.configList}>
|
||||||
|
{config.map((entry) => (
|
||||||
|
<div key={entry.label} style={styles.configRow}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
entry.on === undefined
|
||||||
|
? COLOR.faint
|
||||||
|
: entry.on
|
||||||
|
? COLOR.ok
|
||||||
|
: COLOR.bad,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
●
|
||||||
|
</span>
|
||||||
|
<span style={styles.configLabel}>{entry.label}</span>
|
||||||
|
<span style={styles.configValue} title={entry.value}>
|
||||||
|
{entry.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TrackList title="published ↑" rows={published} />
|
||||||
|
<TrackList title="subscribed ↓" rows={subscribed} />
|
||||||
|
|
||||||
|
<div style={styles.sectionTitle}>bandwidth</div>
|
||||||
|
<div style={{ ...styles.list, gap: 4 }}>
|
||||||
|
<CapStepper
|
||||||
|
label="uplink"
|
||||||
|
hint="encoder cap (setParameters)"
|
||||||
|
valueKbps={uplinkCap}
|
||||||
|
onChange={(kbps) => {
|
||||||
|
setUplinkCapState(kbps)
|
||||||
|
void setUplinkCap(room, kbps)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CapStepper
|
||||||
|
label="downlink"
|
||||||
|
hint="SFU limit (subscriber-bandwidth)"
|
||||||
|
valueKbps={downlinkCap}
|
||||||
|
onChange={(kbps) => {
|
||||||
|
setDownlinkCapState(kbps)
|
||||||
|
void setDownlinkCap(room, kbps).catch((e) =>
|
||||||
|
console.warn('[MeetDevtools] downlink cap failed', e)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.sectionTitle}>transport</div>
|
||||||
|
<div style={styles.row}>
|
||||||
|
{transports.map(({ mode, label, requires, title }) => {
|
||||||
|
const available = !requires || turnProtocols.includes(requires)
|
||||||
|
const active = transportMode === mode
|
||||||
|
const clickable = available && !active
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
disabled={!clickable}
|
||||||
|
title={available ? title : `${title} — not configured`}
|
||||||
|
style={{
|
||||||
|
...styles.button,
|
||||||
|
...(clickable || active ? {} : styles.buttonDisabled),
|
||||||
|
...(active ? styles.buttonActive : {}),
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!clickable) return
|
||||||
|
void (
|
||||||
|
mode === 'auto'
|
||||||
|
? releaseForcedTransport(room)
|
||||||
|
: forceTransport(
|
||||||
|
room,
|
||||||
|
mode as 'tcp' | 'turn-udp' | 'turn-tcp' | 'turn-tls'
|
||||||
|
)
|
||||||
|
).catch((e) =>
|
||||||
|
console.warn('[MeetDevtools] transport change failed', e)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{requires && (
|
||||||
|
<span style={{ color: available ? COLOR.ok : COLOR.faint }}>
|
||||||
|
●{' '}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<span style={styles.mono}>
|
||||||
|
route:{' '}
|
||||||
|
{snapshot?.route
|
||||||
|
? [
|
||||||
|
snapshot.route.protocol,
|
||||||
|
snapshot.route.type,
|
||||||
|
snapshot.route.relayProtocol &&
|
||||||
|
`relay:${snapshot.route.relayProtocol}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('·')
|
||||||
|
: '–'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.sectionTitle}>connection scenarios</div>
|
||||||
|
<div style={styles.row}>
|
||||||
|
{SCENARIOS.map((scenario) => (
|
||||||
|
<button
|
||||||
|
key={scenario.id}
|
||||||
|
type="button"
|
||||||
|
title={`${scenario.side}-side simulation — momentary, watch the state dot`}
|
||||||
|
style={{
|
||||||
|
...styles.button,
|
||||||
|
...(firedScenario === scenario.id ? styles.buttonActive : {}),
|
||||||
|
}}
|
||||||
|
onClick={() => fireScenario(scenario)}
|
||||||
|
>
|
||||||
|
{scenario.side === 'server' ? '☁ ' : ''}
|
||||||
|
{scenario.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MeetDevtools
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { lazy, Suspense } from 'react'
|
||||||
|
|
||||||
|
const LazyPanel = import.meta.env.DEV
|
||||||
|
? lazy(() => import('./MeetDevtools'))
|
||||||
|
: null
|
||||||
|
|
||||||
|
export const MeetDevtools = () => {
|
||||||
|
if (!LazyPanel) return null
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LazyPanel />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Room } from 'livekit-client'
|
||||||
|
|
||||||
|
export type ConfigEntry = {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
/** true = feature actively on, false = off, undefined = informational */
|
||||||
|
on?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBackupCodec = (backup: unknown): string => {
|
||||||
|
if (backup === undefined || backup === true) return 'auto'
|
||||||
|
if (backup === false) return 'off'
|
||||||
|
if (typeof backup === 'object' && backup !== null && 'codec' in backup) {
|
||||||
|
return String((backup as { codec: unknown }).codec)
|
||||||
|
}
|
||||||
|
return String(backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readRoomConfig = (room: Room): ConfigEntry[] => {
|
||||||
|
const options = room.options
|
||||||
|
const publish = options.publishDefaults
|
||||||
|
const adaptive = options.adaptiveStream
|
||||||
|
|
||||||
|
const entries: ConfigEntry[] = [
|
||||||
|
{
|
||||||
|
label: 'adaptiveStream',
|
||||||
|
value:
|
||||||
|
typeof adaptive === 'object'
|
||||||
|
? JSON.stringify(adaptive)
|
||||||
|
: String(!!adaptive),
|
||||||
|
on: !!adaptive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'dynacast',
|
||||||
|
value: String(!!options.dynacast),
|
||||||
|
on: !!options.dynacast,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'e2ee',
|
||||||
|
value: String(room.isE2EEEnabled),
|
||||||
|
on: room.isE2EEEnabled,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'videoCodec',
|
||||||
|
value: publish?.videoCodec ?? 'default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'backupCodec',
|
||||||
|
value: formatBackupCodec(publish?.backupCodec),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'simulcast',
|
||||||
|
value: String(publish?.simulcast ?? true),
|
||||||
|
on: publish?.simulcast ?? true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'audio dtx',
|
||||||
|
value: String(publish?.dtx ?? true),
|
||||||
|
on: publish?.dtx ?? true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'audio red',
|
||||||
|
value: String(publish?.red ?? true),
|
||||||
|
on: publish?.red ?? true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'quality (local)',
|
||||||
|
value: room.localParticipant.connectionQuality,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const server = room.serverInfo
|
||||||
|
if (server) {
|
||||||
|
entries.push({
|
||||||
|
label: 'server',
|
||||||
|
value: [
|
||||||
|
server.version && `v${server.version}`,
|
||||||
|
server.region,
|
||||||
|
server.protocol !== undefined && `proto ${server.protocol}`,
|
||||||
|
server.edition !== undefined && `edition ${server.edition}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · '),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { Room, Track } from 'livekit-client'
|
||||||
|
// Transitive dependency of livekit-client (pinned by it); used only to
|
||||||
|
// build the one signal request room.simulateScenario cannot express.
|
||||||
|
import { SimulateScenario } from '@livekit/protocol'
|
||||||
|
|
||||||
|
/** Shared bandwidth ladder for the −/+ steppers; null = unlimited. */
|
||||||
|
export const BITRATE_LADDER_KBPS: Array<number | null> = [
|
||||||
|
null,
|
||||||
|
2000,
|
||||||
|
1000,
|
||||||
|
600,
|
||||||
|
300,
|
||||||
|
150,
|
||||||
|
]
|
||||||
|
|
||||||
|
export const stepBitrate = (
|
||||||
|
current: number | null,
|
||||||
|
direction: 'decrease' | 'increase'
|
||||||
|
): number | null => {
|
||||||
|
const index = BITRATE_LADDER_KBPS.indexOf(current)
|
||||||
|
const safeIndex = index === -1 ? 0 : index
|
||||||
|
const next =
|
||||||
|
direction === 'decrease'
|
||||||
|
? Math.min(safeIndex + 1, BITRATE_LADDER_KBPS.length - 1)
|
||||||
|
: Math.max(safeIndex - 1, 0)
|
||||||
|
return BITRATE_LADDER_KBPS[next]
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedEncodings = new WeakMap<RTCRtpSender, Array<number | undefined>>()
|
||||||
|
|
||||||
|
export const setUplinkCap = async (
|
||||||
|
room: Room,
|
||||||
|
kbps: number | null
|
||||||
|
): Promise<void> => {
|
||||||
|
const senders: RTCRtpSender[] = []
|
||||||
|
room.localParticipant.trackPublications.forEach((pub) => {
|
||||||
|
const track = pub.track
|
||||||
|
if (track?.kind === Track.Kind.Video && track.sender) {
|
||||||
|
senders.push(track.sender)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const sender of senders) {
|
||||||
|
const params = sender.getParameters()
|
||||||
|
if (!params.encodings || params.encodings.length === 0) continue
|
||||||
|
|
||||||
|
if (kbps === null) {
|
||||||
|
const original = savedEncodings.get(sender)
|
||||||
|
params.encodings.forEach((encoding, i) => {
|
||||||
|
encoding.maxBitrate = original?.[i]
|
||||||
|
})
|
||||||
|
savedEncodings.delete(sender)
|
||||||
|
} else {
|
||||||
|
if (!savedEncodings.has(sender)) {
|
||||||
|
savedEncodings.set(
|
||||||
|
sender,
|
||||||
|
params.encodings.map((encoding) => encoding.maxBitrate)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const activeCount =
|
||||||
|
params.encodings.filter((encoding) => encoding.active !== false)
|
||||||
|
.length || 1
|
||||||
|
// Split the budget across active simulcast layers / SVC encoding.
|
||||||
|
const perEncoding = Math.max(
|
||||||
|
30_000,
|
||||||
|
Math.floor((kbps * 1000) / activeCount)
|
||||||
|
)
|
||||||
|
params.encodings.forEach((encoding) => {
|
||||||
|
encoding.maxBitrate = perEncoding
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sender.setParameters(params)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[MeetDevtools] setParameters failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setDownlinkCap = (
|
||||||
|
room: Room,
|
||||||
|
kbps: number | null
|
||||||
|
): Promise<void> =>
|
||||||
|
room.simulateScenario('subscriber-bandwidth', kbps === null ? 0 : kbps * 1000)
|
||||||
|
|
||||||
|
export type Scenario = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
/** where the simulation happens */
|
||||||
|
side: 'client' | 'server'
|
||||||
|
run: (room: Room) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCENARIOS: Scenario[] = [
|
||||||
|
{
|
||||||
|
id: 'resume',
|
||||||
|
label: 'reconnect (resume)',
|
||||||
|
side: 'client',
|
||||||
|
// Replays a signaling WebSocket loss; media keeps flowing, client resumes.
|
||||||
|
run: (room) => room.simulateScenario('signal-reconnect'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'resume-fail',
|
||||||
|
label: 'reconnect (resume fails)',
|
||||||
|
side: 'client',
|
||||||
|
// Same, but the next resume attempt fails → exercises the retry ladder.
|
||||||
|
run: (room) => room.simulateScenario('resume-reconnect'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'full-reconnect',
|
||||||
|
label: 'full reconnect',
|
||||||
|
side: 'client',
|
||||||
|
// Complete rejoin with brand-new peer connections.
|
||||||
|
run: (room) => room.simulateScenario('full-reconnect'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'migration',
|
||||||
|
label: 'server migration',
|
||||||
|
side: 'server',
|
||||||
|
run: (room) => room.simulateScenario('migration'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'node-failure',
|
||||||
|
label: 'SFU node failure',
|
||||||
|
side: 'server',
|
||||||
|
run: (room) => room.simulateScenario('node-failure'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'server-leave',
|
||||||
|
label: 'server disconnect',
|
||||||
|
side: 'server',
|
||||||
|
// Server-initiated leave: the closest thing to "you got kicked".
|
||||||
|
run: (room) => room.simulateScenario('server-leave'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export type TransportMode =
|
||||||
|
| 'auto'
|
||||||
|
| 'tcp'
|
||||||
|
| 'turn-udp'
|
||||||
|
| 'turn-tcp'
|
||||||
|
| 'turn-tls'
|
||||||
|
|
||||||
|
const clearServerTransportPreference = (room: Room): Promise<void> =>
|
||||||
|
room.engine.client.sendSimulateScenario(
|
||||||
|
new SimulateScenario({
|
||||||
|
scenario: { case: 'switchCandidateProtocol', value: 0 },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const setRelayOnly = (room: Room, relay: boolean) => {
|
||||||
|
room.engine.rtcConfig = {
|
||||||
|
...room.engine.rtcConfig,
|
||||||
|
iceTransportPolicy: relay ? 'relay' : 'all',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
|
||||||
|
export const forceTransport = async (
|
||||||
|
room: Room,
|
||||||
|
mode: 'tcp' | 'turn-udp' | 'turn-tcp' | 'turn-tls'
|
||||||
|
): Promise<void> => {
|
||||||
|
if (mode === 'turn-udp') {
|
||||||
|
await clearServerTransportPreference(room)
|
||||||
|
setRelayOnly(room, true)
|
||||||
|
await settle()
|
||||||
|
return room.simulateScenario('full-reconnect')
|
||||||
|
}
|
||||||
|
setRelayOnly(room, false)
|
||||||
|
return room.simulateScenario(mode === 'turn-tls' ? 'force-tls' : 'force-tcp')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const releaseForcedTransport = async (room: Room): Promise<void> => {
|
||||||
|
setRelayOnly(room, false)
|
||||||
|
await clearServerTransportPreference(room)
|
||||||
|
await settle()
|
||||||
|
await room.simulateScenario('full-reconnect')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const transportModeFromRoute = (route?: {
|
||||||
|
protocol?: string
|
||||||
|
relayProtocol?: string
|
||||||
|
}): TransportMode => {
|
||||||
|
// relayProtocol is the client→TURN leg; protocol alone means no relay.
|
||||||
|
if (route?.relayProtocol === 'tls') return 'turn-tls'
|
||||||
|
if (route?.relayProtocol === 'tcp') return 'turn-tcp'
|
||||||
|
if (route?.relayProtocol === 'udp') return 'turn-udp'
|
||||||
|
if (route?.protocol === 'tcp') return 'tcp'
|
||||||
|
return 'auto'
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Room } from 'livekit-client'
|
||||||
|
|
||||||
|
export type TrackRow = {
|
||||||
|
key: string
|
||||||
|
dir: 'up' | 'down'
|
||||||
|
label: string
|
||||||
|
codec?: string
|
||||||
|
kbps: number
|
||||||
|
fps?: number
|
||||||
|
res?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatsSnapshot = {
|
||||||
|
ts: number
|
||||||
|
/** wire totals from transport stats (includes headers, RTCP, FEC) */
|
||||||
|
upKbps: number
|
||||||
|
downKbps: number
|
||||||
|
rttMs?: number
|
||||||
|
/** worst inbound RTP jitter across subscribed tracks */
|
||||||
|
jitterMs?: number
|
||||||
|
availableOutKbps?: number
|
||||||
|
/** selected ICE route of the publisher transport (measured, not assumed) */
|
||||||
|
route?: { protocol?: string; type?: string; relayProtocol?: string }
|
||||||
|
/**
|
||||||
|
* relayProtocol values of gathered relay local candidates — i.e. which
|
||||||
|
* client→TURN transports are actually configured (udp/tcp/tls). Empty
|
||||||
|
* when no TURN server is configured.
|
||||||
|
*/
|
||||||
|
turnProtocols: string[]
|
||||||
|
tracks: TrackRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatDict = Record<string, unknown>
|
||||||
|
type Counters = Record<string, number>
|
||||||
|
|
||||||
|
const asNumber = (v: unknown): number | undefined =>
|
||||||
|
typeof v === 'number' && Number.isFinite(v) ? v : undefined
|
||||||
|
|
||||||
|
const asString = (v: unknown): string | undefined =>
|
||||||
|
typeof v === 'string' ? v : undefined
|
||||||
|
|
||||||
|
const shortCodec = (mimeType?: string) =>
|
||||||
|
mimeType ? mimeType.replace(/^(audio|video)\//, '') : undefined
|
||||||
|
|
||||||
|
const buildTrackLabels = (room: Room) => {
|
||||||
|
const labels = new Map<string, string>()
|
||||||
|
room.localParticipant.trackPublications.forEach((pub) => {
|
||||||
|
const id = pub.track?.mediaStreamTrack?.id
|
||||||
|
if (id) labels.set(id, `local ${pub.source}`)
|
||||||
|
})
|
||||||
|
room.remoteParticipants.forEach((participant) => {
|
||||||
|
// Keep rows scannable: participant names capped at 10 chars.
|
||||||
|
const rawName = participant.name || participant.identity
|
||||||
|
const name = rawName.length > 10 ? `${rawName.slice(0, 10)}.` : rawName
|
||||||
|
participant.trackPublications.forEach((pub) => {
|
||||||
|
const id = pub.track?.mediaStreamTrack?.id
|
||||||
|
if (id) labels.set(id, `${name} ${pub.source}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWebRTCStats = (
|
||||||
|
room: Room,
|
||||||
|
enabled: boolean,
|
||||||
|
intervalMs = 1000
|
||||||
|
) => {
|
||||||
|
// Single source of truth: the snapshot is just the last history entry.
|
||||||
|
const [history, setHistory] = useState<StatsSnapshot[]>([])
|
||||||
|
const prevRef = useRef(new Map<string, Counters>())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
const prev = prevRef.current
|
||||||
|
|
||||||
|
/** Per-stat counter deltas; returns 0 on the first sighting. */
|
||||||
|
const deltas = (key: string, now: Counters): Counters => {
|
||||||
|
const before = prev.get(key)
|
||||||
|
prev.set(key, now)
|
||||||
|
const out: Counters = {}
|
||||||
|
for (const [name, value] of Object.entries(now)) {
|
||||||
|
out[name] = before?.[name] !== undefined ? value - before[name] : 0
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const collect = async () => {
|
||||||
|
const reports: Array<{ pc: 'pub' | 'sub'; report: RTCStatsReport }> = []
|
||||||
|
try {
|
||||||
|
// Not public API — see file header.
|
||||||
|
const manager = room.engine?.pcManager
|
||||||
|
const pub = await manager?.publisher?.getStats()
|
||||||
|
if (pub) reports.push({ pc: 'pub', report: pub })
|
||||||
|
const sub = await manager?.subscriber?.getStats()
|
||||||
|
if (sub) reports.push({ pc: 'sub', report: sub })
|
||||||
|
} catch {
|
||||||
|
// Engine not ready or SDK internals changed; panel shows nothing.
|
||||||
|
}
|
||||||
|
if (cancelled || reports.length === 0) return
|
||||||
|
|
||||||
|
const labels = buildTrackLabels(room)
|
||||||
|
const tracks: TrackRow[] = []
|
||||||
|
let upKbps = 0
|
||||||
|
let downKbps = 0
|
||||||
|
let rttMs: number | undefined
|
||||||
|
let jitterMs: number | undefined
|
||||||
|
let availableOutKbps: number | undefined
|
||||||
|
let route: StatsSnapshot['route']
|
||||||
|
const turnProtocols = new Set<string>()
|
||||||
|
|
||||||
|
for (const { pc, report } of reports) {
|
||||||
|
const byId = new Map<string, StatDict>()
|
||||||
|
report.forEach((stat) => byId.set(stat.id as string, stat as StatDict))
|
||||||
|
|
||||||
|
report.forEach((raw) => {
|
||||||
|
const stat = raw as StatDict
|
||||||
|
const type = asString(stat.type)
|
||||||
|
const ts = asNumber(stat.timestamp) ?? Date.now()
|
||||||
|
const key = `${pc}:${asString(stat.id) ?? ''}`
|
||||||
|
|
||||||
|
if (
|
||||||
|
type === 'local-candidate' &&
|
||||||
|
asString(stat.candidateType) === 'relay'
|
||||||
|
) {
|
||||||
|
// Relay candidates are only gathered when a TURN server is
|
||||||
|
// configured and reachable; relayProtocol says how the client
|
||||||
|
// reaches it (udp/tcp/tls).
|
||||||
|
turnProtocols.add(
|
||||||
|
asString(stat.relayProtocol) ?? asString(stat.protocol) ?? 'udp'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'transport') {
|
||||||
|
const d = deltas(key, {
|
||||||
|
sent: asNumber(stat.bytesSent) ?? 0,
|
||||||
|
received: asNumber(stat.bytesReceived) ?? 0,
|
||||||
|
ts,
|
||||||
|
})
|
||||||
|
if (d.ts > 0) {
|
||||||
|
upKbps += Math.max(0, (d.sent * 8) / d.ts)
|
||||||
|
downKbps += Math.max(0, (d.received * 8) / d.ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'candidate-pair' && stat.nominated === true) {
|
||||||
|
const rtt = asNumber(stat.currentRoundTripTime)
|
||||||
|
if (rtt !== undefined) rttMs = Math.round(rtt * 1000)
|
||||||
|
const available = asNumber(stat.availableOutgoingBitrate)
|
||||||
|
if (available !== undefined && pc === 'pub') {
|
||||||
|
availableOutKbps = Math.round(available / 1000)
|
||||||
|
}
|
||||||
|
if (pc === 'pub') {
|
||||||
|
const local = byId.get(asString(stat.localCandidateId) ?? '')
|
||||||
|
route = {
|
||||||
|
protocol: asString(local?.protocol),
|
||||||
|
type: asString(local?.candidateType),
|
||||||
|
relayProtocol: asString(local?.relayProtocol),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'outbound-rtp' || type === 'inbound-rtp') {
|
||||||
|
const isUp = type === 'outbound-rtp'
|
||||||
|
|
||||||
|
if (!isUp) {
|
||||||
|
const jitter = asNumber(stat.jitter)
|
||||||
|
if (jitter !== undefined) {
|
||||||
|
const ms = Math.round(jitter * 1000)
|
||||||
|
if (jitterMs === undefined || ms > jitterMs) jitterMs = ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = deltas(key, {
|
||||||
|
bytes: asNumber(isUp ? stat.bytesSent : stat.bytesReceived) ?? 0,
|
||||||
|
ts,
|
||||||
|
})
|
||||||
|
const kbps = d.ts > 0 ? Math.max(0, (d.bytes * 8) / d.ts) : 0
|
||||||
|
|
||||||
|
// Resolve codec + source track.
|
||||||
|
const codecStat = byId.get(asString(stat.codecId) ?? '')
|
||||||
|
let msTrackId = asString(stat.trackIdentifier)
|
||||||
|
if (!msTrackId && isUp) {
|
||||||
|
const mediaSource = byId.get(asString(stat.mediaSourceId) ?? '')
|
||||||
|
msTrackId = asString(mediaSource?.trackIdentifier)
|
||||||
|
}
|
||||||
|
const rid = asString(stat.rid)
|
||||||
|
const baseLabel =
|
||||||
|
(msTrackId && labels.get(msTrackId)) ??
|
||||||
|
`${asString(stat.kind) ?? 'media'} ssrc ${asNumber(stat.ssrc) ?? '?'}`
|
||||||
|
|
||||||
|
const width = asNumber(stat.frameWidth)
|
||||||
|
const height = asNumber(stat.frameHeight)
|
||||||
|
|
||||||
|
tracks.push({
|
||||||
|
key,
|
||||||
|
dir: isUp ? 'up' : 'down',
|
||||||
|
label: rid ? `${baseLabel} [${rid}]` : baseLabel,
|
||||||
|
codec: shortCodec(asString(codecStat?.mimeType)),
|
||||||
|
kbps,
|
||||||
|
fps: asNumber(stat.framesPerSecond),
|
||||||
|
res: width && height ? `${width}x${height}` : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable order (direction, then label): sorting by bitrate would
|
||||||
|
// reshuffle rows on every tick as kbps fluctuates.
|
||||||
|
tracks.sort((a, b) =>
|
||||||
|
a.dir === b.dir
|
||||||
|
? a.label.localeCompare(b.label)
|
||||||
|
: a.dir === 'up'
|
||||||
|
? -1
|
||||||
|
: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
const next: StatsSnapshot = {
|
||||||
|
ts: Date.now(),
|
||||||
|
upKbps: Math.round(upKbps),
|
||||||
|
downKbps: Math.round(downKbps),
|
||||||
|
rttMs,
|
||||||
|
jitterMs,
|
||||||
|
availableOutKbps,
|
||||||
|
route,
|
||||||
|
turnProtocols: Array.from(turnProtocols).sort(),
|
||||||
|
tracks,
|
||||||
|
}
|
||||||
|
setHistory((h) => [...h.slice(-59), next])
|
||||||
|
}
|
||||||
|
|
||||||
|
void collect()
|
||||||
|
const id = window.setInterval(() => void collect(), intervalMs)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearInterval(id)
|
||||||
|
}
|
||||||
|
}, [room, enabled, intervalMs])
|
||||||
|
|
||||||
|
return { snapshot: history[history.length - 1], history }
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ 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'
|
||||||
@@ -48,6 +49,8 @@ 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<
|
||||||
@@ -89,6 +92,8 @@ 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)
|
||||||
@@ -98,11 +103,38 @@ 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>) => {
|
||||||
@@ -120,8 +152,57 @@ 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 ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
<div
|
||||||
|
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 && (
|
||||||
@@ -129,23 +210,7 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
{isTrackReference(trackReference) &&
|
{trackMedia}
|
||||||
(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}
|
||||||
@@ -164,7 +229,7 @@ export const ParticipantTile: (
|
|||||||
{!disableMetadata && !disableTileControls && (
|
{!disableMetadata && !disableTileControls && (
|
||||||
<ParticipantTileFocus
|
<ParticipantTileFocus
|
||||||
trackRef={trackReference}
|
trackRef={trackReference}
|
||||||
hasKeyboardFocus={hasKeyboardFocus}
|
isVisible={isOverlayVisible}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ParticipantContextIfNeeded>
|
</ParticipantContextIfNeeded>
|
||||||
|
|||||||
+12
-40
@@ -1,44 +1,22 @@
|
|||||||
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, useEffect, useRef, useState } from 'react'
|
import { ReactNode } 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
|
||||||
hasKeyboardFocus: boolean
|
isVisible: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
// Pointer-events none so this overlay doesn't block the zoom surface below.
|
||||||
const [active, setActive] = useState(false)
|
// Hover and idle tracking therefore lives on the tile, which still receives
|
||||||
const idleTimerRef = useRef<number | null>(null)
|
// the pointer events, and comes back in as isVisible.
|
||||||
|
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({
|
||||||
@@ -50,15 +28,10 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: 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>
|
||||||
@@ -67,10 +40,10 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
|||||||
|
|
||||||
export const ParticipantTileFocus = ({
|
export const ParticipantTileFocus = ({
|
||||||
trackRef,
|
trackRef,
|
||||||
hasKeyboardFocus,
|
isVisible,
|
||||||
}: {
|
}: {
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
hasKeyboardFocus: boolean
|
isVisible: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const participant = trackRef.participant
|
const participant = trackRef.participant
|
||||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||||
@@ -78,7 +51,7 @@ export const ParticipantTileFocus = ({
|
|||||||
const canMute = useCanMute(participant)
|
const canMute = useCanMute(participant)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
<FadeOverlay isVisible={isVisible}>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
backgroundColor: 'primaryDark.50',
|
backgroundColor: 'primaryDark.50',
|
||||||
@@ -87,6 +60,7 @@ 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,
|
||||||
},
|
},
|
||||||
@@ -94,7 +68,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 />
|
||||||
@@ -102,8 +76,6 @@ export const ParticipantTileFocus = ({
|
|||||||
canMute && <MuteButton participant={participant} />
|
canMute && <MuteButton participant={participant} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
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,6 +43,7 @@ import { useSnapshot } from 'valtio'
|
|||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
import { userStore } from '@/stores/user'
|
import { userStore } from '@/stores/user'
|
||||||
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
||||||
|
import { MeetDevtools } from '@/features/devtools'
|
||||||
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
|
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
@@ -298,6 +299,7 @@ export const Conference = ({
|
|||||||
<VideoConference />
|
<VideoConference />
|
||||||
{!isMobile && <InviteDialog mode={mode} />}
|
{!isMobile && <InviteDialog mode={mode} />}
|
||||||
<PictureInPictureConference />
|
<PictureInPictureConference />
|
||||||
|
<MeetDevtools />
|
||||||
</LiveKitRoom>
|
</LiveKitRoom>
|
||||||
</Screen>
|
</Screen>
|
||||||
</QueryAware>
|
</QueryAware>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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'
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+6
-2
@@ -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 } from 'livekit-client'
|
import { Track, type VideoCaptureOptions, VideoPresets } from 'livekit-client'
|
||||||
|
|
||||||
import { ToggleDevice } from './ToggleDevice'
|
import { ToggleDevice } from './ToggleDevice'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
@@ -57,7 +57,8 @@ export const VideoDeviceControl = ({
|
|||||||
}: VideoDeviceControlProps) => {
|
}: VideoDeviceControlProps) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
|
||||||
const { videoDeviceId, processorConfig } = useSnapshot(userChoicesStore)
|
const { videoDeviceId, processorConfig, videoPublishResolution } =
|
||||||
|
useSnapshot(userChoicesStore)
|
||||||
|
|
||||||
const onChange = React.useCallback(
|
const onChange = React.useCallback(
|
||||||
(enabled: boolean, isUserInitiated: boolean) =>
|
(enabled: boolean, isUserInitiated: boolean) =>
|
||||||
@@ -97,6 +98,9 @@ export const VideoDeviceControl = ({
|
|||||||
|
|
||||||
await toggle(!trackProps.enabled, {
|
await toggle(!trackProps.enabled, {
|
||||||
processor: processor,
|
processor: processor,
|
||||||
|
...(videoPublishResolution && {
|
||||||
|
resolution: VideoPresets[videoPublishResolution].resolution,
|
||||||
|
}),
|
||||||
} as VideoCaptureOptions)
|
} as VideoCaptureOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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,7 +32,8 @@ 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 { localParticipant, remoteParticipants } = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
const { localParticipant, remoteParticipants } = room
|
||||||
|
|
||||||
const {
|
const {
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
@@ -70,12 +71,14 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
|
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
|
||||||
const videoPublication = localParticipant.getTrackPublication(
|
|
||||||
Track.Source.Camera
|
|
||||||
)
|
|
||||||
const videoTrack = videoPublication?.track
|
|
||||||
if (videoTrack) {
|
|
||||||
saveVideoPublishResolution(key)
|
saveVideoPublishResolution(key)
|
||||||
|
const videoTrack = localParticipant.getTrackPublication(
|
||||||
|
Track.Source.Camera
|
||||||
|
)?.track
|
||||||
|
if (!videoTrack) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await videoTrack.restartTrack({
|
await videoTrack.restartTrack({
|
||||||
resolution: VideoPresets[key].resolution,
|
resolution: VideoPresets[key].resolution,
|
||||||
deviceId: { exact: videoDeviceId },
|
deviceId: { exact: videoDeviceId },
|
||||||
@@ -83,7 +86,6 @@ 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,6 +16,10 @@ 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)
|
||||||
@@ -24,7 +28,7 @@ export type ShortcutDescriptor = {
|
|||||||
id: ShortcutId
|
id: ShortcutId
|
||||||
category: ShortcutCategory
|
category: ShortcutCategory
|
||||||
shortcut?: Shortcut
|
shortcut?: Shortcut
|
||||||
kind?: 'press' | 'longPress'
|
kind?: 'press' | 'longPress' | 'arrows'
|
||||||
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
@@ -86,4 +90,27 @@ 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,6 +25,7 @@ export const formatShortcutLabelForSR = (
|
|||||||
shiftLabel,
|
shiftLabel,
|
||||||
plusLabel,
|
plusLabel,
|
||||||
noShortcutLabel,
|
noShortcutLabel,
|
||||||
|
keyLabels,
|
||||||
}: {
|
}: {
|
||||||
controlLabel: string
|
controlLabel: string
|
||||||
commandLabel: string
|
commandLabel: string
|
||||||
@@ -33,10 +34,12 @@ 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 = shortcut.key?.toUpperCase()
|
const key = keyLabels?.[shortcut.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,6 +12,9 @@ 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 || '?' })
|
||||||
@@ -23,6 +26,9 @@ 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 || '?' })
|
||||||
@@ -35,6 +41,10 @@ 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,6 +756,24 @@
|
|||||||
"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": {
|
||||||
@@ -775,7 +793,11 @@
|
|||||||
"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",
|
||||||
@@ -785,10 +807,14 @@
|
|||||||
"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,6 +756,24 @@
|
|||||||
"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": {
|
||||||
@@ -775,7 +793,11 @@
|
|||||||
"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",
|
||||||
@@ -785,10 +807,14 @@
|
|||||||
"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,6 +756,24 @@
|
|||||||
"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": {
|
||||||
@@ -775,7 +793,11 @@
|
|||||||
"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",
|
||||||
@@ -785,10 +807,14 @@
|
|||||||
"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,6 +756,24 @@
|
|||||||
"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": {
|
||||||
@@ -775,7 +793,11 @@
|
|||||||
"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",
|
||||||
@@ -785,10 +807,14 @@
|
|||||||
"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