mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-10 18:57:06 +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:
@@ -28,6 +28,7 @@ import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { ScreenShareZoomableVideo } from './ScreenShareZoomableVideo'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -55,6 +56,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<
|
||||
@@ -109,14 +112,40 @@ export const ParticipantTile: (
|
||||
})
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
const isRemoteScreenShare =
|
||||
isScreenShare && !trackReference.participant.isLocal
|
||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||
|
||||
// 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 participantName = getParticipantName(trackReference.participant)
|
||||
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>) => {
|
||||
@@ -134,30 +163,63 @@ 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}>
|
||||
<FullScreenShareWarning trackReference={trackReference} />
|
||||
{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
|
||||
participant={trackReference.participant}
|
||||
@@ -236,7 +298,7 @@ export const ParticipantTile: (
|
||||
{!disableMetadata && !disableTileControls && (
|
||||
<ParticipantTileFocus
|
||||
trackRef={trackReference}
|
||||
hasKeyboardFocus={hasKeyboardFocus}
|
||||
isVisible={isOverlayVisible}
|
||||
/>
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Button } from '@/primitives'
|
||||
import {
|
||||
RiFullscreenLine,
|
||||
RiImageCircleAiFill,
|
||||
RiMicLine,
|
||||
RiMicOffLine,
|
||||
@@ -15,41 +14,13 @@ import {
|
||||
} from '@livekit/components-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useFullScreen } from '../hooks/useFullScreen'
|
||||
import { type Participant, Track } from 'livekit-client'
|
||||
import { MuteAlertDialog } from './MuteAlertDialog'
|
||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const FocusButton = ({
|
||||
trackRef,
|
||||
}: {
|
||||
@@ -127,26 +98,17 @@ const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const MOUSE_IDLE_TIME = 3000
|
||||
|
||||
export const ParticipantTileFocus = ({
|
||||
trackRef,
|
||||
hasKeyboardFocus,
|
||||
isVisible,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
hasKeyboardFocus: boolean
|
||||
isVisible: boolean
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [opacity, setOpacity] = useState(0)
|
||||
|
||||
const idleTimerRef = useRef<number | null>(null)
|
||||
const [isIdleRef, setIsIdleRef] = useState(false)
|
||||
|
||||
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
// Wait for next frame to ensure element is mounted
|
||||
requestAnimationFrame(() => {
|
||||
setOpacity(0.6)
|
||||
})
|
||||
@@ -155,24 +117,14 @@ export const ParticipantTileFocus = ({
|
||||
}
|
||||
}, [isVisible])
|
||||
|
||||
const handleMouseMove = () => {
|
||||
if (idleTimerRef.current) {
|
||||
window.clearTimeout(idleTimerRef.current)
|
||||
}
|
||||
idleTimerRef.current = window.setTimeout(() => {
|
||||
setIsIdleRef(true)
|
||||
}, MOUSE_IDLE_TIME)
|
||||
setIsIdleRef(false)
|
||||
}
|
||||
|
||||
const participant = trackRef.participant
|
||||
|
||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||
const isLocal = trackRef.participant.isLocal
|
||||
|
||||
const canMute = useCanMute(participant)
|
||||
|
||||
return (
|
||||
// Pointer-events: none so this overlay doesn't block the zoom surface below.
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
@@ -183,11 +135,9 @@ export const ParticipantTileFocus = ({
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
aria-hidden={!isVisible}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{isVisible && (
|
||||
<div
|
||||
@@ -197,6 +147,7 @@ export const ParticipantTileFocus = ({
|
||||
zIndex: 1,
|
||||
borderRadius: '0.25rem',
|
||||
display: 'flex',
|
||||
pointerEvents: 'auto',
|
||||
_hover: {
|
||||
opacity: '0.95 !important',
|
||||
},
|
||||
@@ -213,7 +164,7 @@ export const ParticipantTileFocus = ({
|
||||
})}
|
||||
>
|
||||
<FocusButton trackRef={trackRef} />
|
||||
{!isScreenShare ? (
|
||||
{!isScreenShare && (
|
||||
<>
|
||||
{participant.isLocal ? (
|
||||
<EffectsButton />
|
||||
@@ -221,8 +172,6 @@ export const ParticipantTileFocus = ({
|
||||
canMute && <MuteButton participant={participant} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
@@ -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,23 +36,29 @@ 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'
|
||||
)
|
||||
|
||||
if (entered && document.fullscreenElement === containerRef.current) {
|
||||
wasOwnFullscreen.current = true
|
||||
announce(t('fullScreenEntered'), 'assertive')
|
||||
} else if (!entered && 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 {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { VideoTrack } from '@livekit/components-react'
|
||||
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'
|
||||
|
||||
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])
|
||||
|
||||
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
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
})}
|
||||
style={{
|
||||
cursor: panCursor,
|
||||
}}
|
||||
onWheel={zoom.handleWheel}
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<VideoTrack
|
||||
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