mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-02 21:58:29 +00:00
✨(frontend) add ScreenShareZoomableVideo component for zoomable screen shares
Wraps VideoTrack with zoom/pan, keyboard nav and screen reader announcements.
This commit is contained in:
@@ -20,6 +20,7 @@ import { Track } from 'livekit-client'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { ScreenShareZoomableVideo } from '@/features/rooms/livekit/components/ScreenShareZoomableVideo'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||
@@ -48,6 +49,8 @@ interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
||||
disableTileControls?: boolean
|
||||
}
|
||||
|
||||
const MOUSE_IDLE_TIME = 3000
|
||||
|
||||
export const ParticipantTile: (
|
||||
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
|
||||
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
||||
@@ -89,6 +92,8 @@ export const ParticipantTile: (
|
||||
)
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
const isRemoteScreenShare =
|
||||
isScreenShare && !trackReference.participant.isLocal
|
||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||
|
||||
const participantColor = getParticipantColor(trackReference.participant)
|
||||
@@ -98,11 +103,38 @@ export const ParticipantTile: (
|
||||
})
|
||||
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 interactiveProps = {
|
||||
...elementProps,
|
||||
// Ensure the tile is focusable to expose contextual controls to keyboard users.
|
||||
tabIndex: 0,
|
||||
'aria-label': t('containerLabel', { name: participantName }),
|
||||
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 (
|
||||
<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}>
|
||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||
{trackReference.participant.isLocal && (
|
||||
@@ -129,23 +210,7 @@ export const ParticipantTile: (
|
||||
)}
|
||||
{children ?? (
|
||||
<>
|
||||
{isTrackReference(trackReference) &&
|
||||
(trackReference.publication?.kind === 'video' ||
|
||||
trackReference.source === Track.Source.Camera ||
|
||||
trackReference.source === Track.Source.ScreenShare) ? (
|
||||
<VideoTrack
|
||||
trackRef={trackReference}
|
||||
onSubscriptionStatusChanged={handleSubscribe}
|
||||
manageSubscription={autoManageSubscription}
|
||||
/>
|
||||
) : (
|
||||
isTrackReference(trackReference) && (
|
||||
<AudioTrack
|
||||
trackRef={trackReference}
|
||||
onSubscriptionStatusChanged={handleSubscribe}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{trackMedia}
|
||||
<div className="lk-participant-placeholder">
|
||||
<ParticipantPlaceholder
|
||||
color={participantColor}
|
||||
@@ -164,7 +229,7 @@ export const ParticipantTile: (
|
||||
{!disableMetadata && !disableTileControls && (
|
||||
<ParticipantTileFocus
|
||||
trackRef={trackReference}
|
||||
hasKeyboardFocus={hasKeyboardFocus}
|
||||
isVisible={isOverlayVisible}
|
||||
/>
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
|
||||
+12
-40
@@ -1,44 +1,22 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { ReactNode } from 'react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { FocusButton } from './FocusButton'
|
||||
import { EffectsButton } from './EffectsButton'
|
||||
import { MuteButton } from './MuteButton'
|
||||
import { ZoomButton } from './ZoomButton'
|
||||
|
||||
const MOUSE_IDLE_TIME = 3000
|
||||
|
||||
type FadeOverlayProps = {
|
||||
children: ReactNode
|
||||
hasKeyboardFocus: boolean
|
||||
isVisible: boolean
|
||||
}
|
||||
|
||||
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||
const [active, setActive] = useState(false)
|
||||
const idleTimerRef = useRef<number | null>(null)
|
||||
|
||||
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
|
||||
// Pointer-events none so this overlay doesn't block the zoom surface below.
|
||||
// Hover and idle tracking therefore lives on the tile, which still receives
|
||||
// the pointer events, and comes back in as isVisible.
|
||||
const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -50,15 +28,10 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
data-visible={isVisible || undefined}
|
||||
aria-hidden={!isVisible}
|
||||
onMouseEnter={handleActivity}
|
||||
onMouseMove={handleActivity}
|
||||
onMouseLeave={() => {
|
||||
clearIdleTimer()
|
||||
setActive(false)
|
||||
}}
|
||||
>
|
||||
{isVisible && children}
|
||||
</div>
|
||||
@@ -67,10 +40,10 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||
|
||||
export const ParticipantTileFocus = ({
|
||||
trackRef,
|
||||
hasKeyboardFocus,
|
||||
isVisible,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
hasKeyboardFocus: boolean
|
||||
isVisible: boolean
|
||||
}) => {
|
||||
const participant = trackRef.participant
|
||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||
@@ -78,7 +51,7 @@ export const ParticipantTileFocus = ({
|
||||
const canMute = useCanMute(participant)
|
||||
|
||||
return (
|
||||
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
||||
<FadeOverlay isVisible={isVisible}>
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50',
|
||||
@@ -87,6 +60,7 @@ export const ParticipantTileFocus = ({
|
||||
display: 'flex',
|
||||
opacity: 0.6,
|
||||
animation: 'overlayIn 200ms linear 300ms backwards',
|
||||
pointerEvents: 'auto',
|
||||
_hover: {
|
||||
opacity: 0.95,
|
||||
},
|
||||
@@ -94,7 +68,7 @@ export const ParticipantTileFocus = ({
|
||||
>
|
||||
<HStack gap={0.5} padding={0.5}>
|
||||
<FocusButton trackRef={trackRef} />
|
||||
{!isScreenShare ? (
|
||||
{!isScreenShare && (
|
||||
<>
|
||||
{isLocal ? (
|
||||
<EffectsButton />
|
||||
@@ -102,8 +76,6 @@ export const ParticipantTileFocus = ({
|
||||
canMute && <MuteButton participant={participant} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||
)}
|
||||
</HStack>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
RiZoomOutLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
interface ScreenShareZoomControlsProps {
|
||||
@@ -36,27 +36,34 @@ export const ScreenShareZoomControls = ({
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const wasOwnFullscreen = useRef(false)
|
||||
const [isFullscreenAvailable] = useState(
|
||||
() => typeof document !== 'undefined' && 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 entered = !!document.fullscreenElement
|
||||
setIsFullscreen(entered)
|
||||
announce(
|
||||
entered ? t('fullScreenEntered') : t('fullScreenExited'),
|
||||
'assertive'
|
||||
)
|
||||
const isThisTileFullscreen =
|
||||
document.fullscreenElement === containerRef.current
|
||||
setIsFullscreen(isThisTileFullscreen)
|
||||
|
||||
if (isThisTileFullscreen) {
|
||||
wasOwnFullscreen.current = true
|
||||
announce(t('fullScreenEntered'), 'assertive')
|
||||
} else if (wasOwnFullscreen.current) {
|
||||
wasOwnFullscreen.current = false
|
||||
announce(t('fullScreenExited'), 'assertive')
|
||||
}
|
||||
}
|
||||
document.addEventListener('fullscreenchange', onChange)
|
||||
return () => document.removeEventListener('fullscreenchange', onChange)
|
||||
}, [announce, t])
|
||||
}, [announce, t, containerRef])
|
||||
|
||||
const toggleFullScreen = useCallback(async () => {
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
if (document.fullscreenElement === containerRef.current) {
|
||||
await document.exitFullscreen()
|
||||
} else {
|
||||
// Tile container so zoom controls stay visible in fullscreen.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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()
|
||||
|
||||
// Single SR announcement per zoom change (buttons only expose the action label).
|
||||
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 so we can use { passive: false } and preventDefault.
|
||||
// React onWheel is passive — Ctrl+scroll would zoom the whole browser page.
|
||||
const zoomSurfaceRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const el = zoomSurfaceRef.current
|
||||
if (!el) return
|
||||
el.addEventListener('wheel', zoom.handleWheel, { passive: false })
|
||||
return () => el.removeEventListener('wheel', zoom.handleWheel)
|
||||
}, [zoom.handleWheel])
|
||||
|
||||
let panCursor: React.CSSProperties['cursor'] = 'default'
|
||||
if (zoom.isZoomed) {
|
||||
panCursor = zoom.isDragging ? 'grabbing' : 'grab'
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Pan/zoom surface - Ctrl+wheel to zoom, drag when zoomed. */}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
ref={zoomSurfaceRef}
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
})}
|
||||
style={{
|
||||
cursor: panCursor,
|
||||
}}
|
||||
onMouseDown={zoom.handlePanStart}
|
||||
onMouseMove={zoom.handlePanMove}
|
||||
onMouseUp={zoom.handlePanEnd}
|
||||
onMouseLeave={zoom.handlePanEnd}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
transform: `scale(${zoom.zoomLevel}) translate(${zoom.panOffset.x}%, ${zoom.panOffset.y}%)`,
|
||||
transformOrigin: 'center center',
|
||||
transition: zoom.isDragging ? 'none' : 'transform 150ms ease-out',
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user