mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-03 14:17:59 +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 { 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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,
|
RiZoomOutLine,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
interface ScreenShareZoomControlsProps {
|
interface ScreenShareZoomControlsProps {
|
||||||
@@ -36,27 +36,34 @@ export const ScreenShareZoomControls = ({
|
|||||||
const announce = useScreenReaderAnnounce()
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
|
const wasOwnFullscreen = useRef(false)
|
||||||
const [isFullscreenAvailable] = useState(
|
const [isFullscreenAvailable] = useState(
|
||||||
() => typeof document !== 'undefined' && document.fullscreenEnabled
|
() => typeof document !== 'undefined' && document.fullscreenEnabled
|
||||||
)
|
)
|
||||||
|
|
||||||
// Covers Esc and browser UI exits, not just the toolbar button.
|
// Covers Esc and browser UI exits, not just the toolbar button.
|
||||||
|
// Only this tile's instance announces to avoid duplicates with multiple shares.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChange = () => {
|
const onChange = () => {
|
||||||
const entered = !!document.fullscreenElement
|
const isThisTileFullscreen =
|
||||||
setIsFullscreen(entered)
|
document.fullscreenElement === containerRef.current
|
||||||
announce(
|
setIsFullscreen(isThisTileFullscreen)
|
||||||
entered ? t('fullScreenEntered') : t('fullScreenExited'),
|
|
||||||
'assertive'
|
if (isThisTileFullscreen) {
|
||||||
)
|
wasOwnFullscreen.current = true
|
||||||
|
announce(t('fullScreenEntered'), 'assertive')
|
||||||
|
} else if (wasOwnFullscreen.current) {
|
||||||
|
wasOwnFullscreen.current = false
|
||||||
|
announce(t('fullScreenExited'), 'assertive')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('fullscreenchange', onChange)
|
document.addEventListener('fullscreenchange', onChange)
|
||||||
return () => document.removeEventListener('fullscreenchange', onChange)
|
return () => document.removeEventListener('fullscreenchange', onChange)
|
||||||
}, [announce, t])
|
}, [announce, t, containerRef])
|
||||||
|
|
||||||
const toggleFullScreen = useCallback(async () => {
|
const toggleFullScreen = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (document.fullscreenElement) {
|
if (document.fullscreenElement === containerRef.current) {
|
||||||
await document.exitFullscreen()
|
await document.exitFullscreen()
|
||||||
} else {
|
} else {
|
||||||
// Tile container so zoom controls stay visible in fullscreen.
|
// 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