Compare commits

...

21 Commits

Author SHA1 Message Date
Ovgodd 43e0a1f9ee (frontend) add keyboard navigation to screen share zoom toolbar
Arrows move between controls instead of panning, w/ en/fr/nl/de hint update.
2026-09-02 10:18:15 +02:00
Ovgodd 91a91da1ef fixup! ♻️(frontend) refactor screen share zoom pan with useMove and imperative DOM updates 2026-09-02 10:18:15 +02:00
Ovgodd 0e0485b694 fixup! ♻️(frontend) refactor screen share zoom pan with useMove and imperative DOM updates 2026-09-02 10:18:15 +02:00
Ovgodd 7b95c3e6df fixup! ♻️(frontend) refactor screen share zoom pan with useMove and imperative DOM updates 2026-09-02 10:18:15 +02:00
Ovgodd 2f908cdaf8 fixup! 💄(frontend) use distinct expand/collapse icons for fullscreen actions 2026-09-02 10:18:14 +02:00
Ovgodd 649211bdd8 fixup! ♻️(frontend) refactor screen share zoom pan with useMove and imperative DOM updates 2026-09-02 10:18:14 +02:00
Ovgodd 7bf13245a6 fixup! ♻️(frontend) refactor screen share zoom pan with useMove and imperative DOM updates 2026-09-02 10:18:14 +02:00
Ovgodd d656367fa7 fixup! ♻️(frontend) refactor screen share zoom pan with useMove and imperative DOM updates 2026-09-02 10:18:14 +02:00
Ovgodd 414494d864 fixup! ♻️(frontend) refactor screen share zoom pan with useMove and imperative DOM updates 2026-09-02 10:18:14 +02:00
Ovgodd f52c92f584 fixup! ♻️(frontend) refactor screen share zoom pan with useMove and imperative DOM updates 2026-09-02 10:18:14 +02:00
Ovgodd bdf6163d45 fixup! ♻️(frontend) refactor screen share zoom pan with useMove and imperative DOM updates 2026-09-02 10:18:14 +02:00
Cyril 15e9f509b1 ♻️(frontend) refactor screen share zoom pan with useMove and imperative DOM updates
use react-aria useMove for pan, zoom/pan refs for DOM updates,
2026-09-02 10:18:14 +02:00
Cyril 13841573ea (frontend) add wheel zoom shortcut hints to screen share controls
Show Ctrl/Cmd+scroll zoom shortcut in tooltips, aria, SR hints, w/ en/fr/nl/de.
2026-09-02 10:18:14 +02:00
Cyril 3f3c9337c8 💄(frontend) improve screen share zoom toolbar sizing and containment
Slightly enlarge toolbar controls while clipping hover states inside the pill so buttons no longer overflow the bar.
2026-09-02 10:18:14 +02:00
Cyril d7206e9e46 💄(frontend) use distinct expand/collapse icons for fullscreen actions
Replace fullscreen icons with expand-diagonal-line and collapse-diagonal-line
2026-09-02 10:18:14 +02:00
Cyril e0e3b91f5d 🌐(frontend) add i18n keys for screen share zoom controls
English and French labels, SR announcements and pan navigation hint.
2026-09-02 10:18:14 +02:00
Cyril d4fa88d9ca (frontend) add ScreenShareZoomableVideo component for zoomable screen shares
Wraps VideoTrack with zoom/pan, keyboard nav and screen reader announcements.
2026-09-02 10:18:14 +02:00
Cyril 3c8b247a99 (frontend) add ScreenShareZoomControls toolbar component
Bottom-right toolbar with zoom, fit-to-window and fullscreen buttons.
2026-09-02 10:13:54 +02:00
Cyril 6b2d67cf61 (frontend) add useScreenShareZoom hook for zoom and pan state management
Manages zoom level, pan offset, wheel zoom, drag-to-pan and keyboard panBy.
2026-09-02 10:13:54 +02:00
kaelvar 1ac1778521 🐛(frontend) keep the sending resolution picked while the camera is off
`handleVideoResolutionChange` did all of its work inside `if (videoTrack)`,
including `saveVideoPublishResolution`. With the camera off there is no camera
publication, so choosing a resolution did nothing at all: it was neither applied
nor recorded, while the selector went on showing the value the user had just
picked. Turning the camera back on then published at the old resolution, and so
did the next session.

Found on a self-hosted instance: a user set the sending resolution with the
camera off, turned it back on, and the publisher kept sending 720p. Nothing in
the UI suggested the choice had been dropped.

Persist the choice first and unconditionally, then restart the track only when
there is one to restart.

Persisting alone is not enough within a session. `roomOptions` is only read by
`new Room(...)`, so a store update never reaches a room that is already built.

Sync the VideoDeviceControl with the userChoiesStore resolution, as we did for
the device id and the processor configuration.

The early return is the honest shape here: with no live track there is nothing
to await, and the defaults above already cover what happens next.
2026-09-02 00:11:43 +02:00
lebaudantoine fcc58065d2 🩹(changelog) fix changelog entry ordering
Restore the correct order of entries in the CHANGELOG, which got
shuffled somewhere between rebases.
2026-09-01 22:08:37 +02:00
18 changed files with 1073 additions and 120 deletions
+9 -4
View File
@@ -8,12 +8,12 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Fixed
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
## [1.30.0] - 2026-09-01 ## [1.30.0] - 2026-09-01
### Changed
- ♿️(frontend) close side panel with Escape key #1507
### Added ### Added
- ✨(agent) support Voxtral realtime as inference engine - ✨(agent) support Voxtral realtime as inference engine
@@ -29,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
@@ -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>
@@ -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>
@@ -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>
)
}
@@ -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}
/>
</>
)
}
@@ -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,19 +71,20 @@ export const VideoTab = ({ id }: VideoTabProps) => {
} }
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => { const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
const videoPublication = localParticipant.getTrackPublication( saveVideoPublishResolution(key)
const videoTrack = localParticipant.getTrackPublication(
Track.Source.Camera Track.Source.Camera
) )?.track
const videoTrack = videoPublication?.track if (!videoTrack) {
if (videoTrack) { return
saveVideoPublishResolution(key)
await videoTrack.restartTrack({
resolution: VideoPresets[key].resolution,
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
})
} }
await videoTrack.restartTrack({
resolution: VideoPresets[key].resolution,
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
})
} }
/** /**
+28 -1
View File
@@ -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]
+28 -2
View File
@@ -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": {
+28 -2
View File
@@ -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": {
+28 -2
View File
@@ -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": {
+28 -2
View File
@@ -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": {