diff --git a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx index e1405d2a..211604b1 100644 --- a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx +++ b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx @@ -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 ) => 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(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(null) + const setRefs = React.useCallback( + (node: HTMLDivElement | null) => { + ;(tileRef as React.MutableRefObject).current = node + if (typeof ref === 'function') ref(node) + else if (ref) + (ref as React.MutableRefObject).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) => { @@ -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 = ( + + ) + } else { + trackMedia = ( + + ) + } + } else if (isTrackReference(trackReference)) { + trackMedia = ( + + ) + } + return ( -
+
setIsTileHovered(true)} + onMouseLeave={() => { + setIsTileHovered(false) + setIsIdle(false) + if (idleTimerRef.current) { + window.clearTimeout(idleTimerRef.current) + idleTimerRef.current = null + } + }} + onMouseMove={handleTileMouseMove} + > {children ?? ( <> - {isTrackReference(trackReference) && - (trackReference.publication?.kind === 'video' || - trackReference.source === Track.Source.Camera || - trackReference.source === Track.Source.ScreenShare) ? ( - - ) : ( - isTrackReference(trackReference) && ( - - ) - )} + {trackMedia}
)} diff --git a/src/frontend/src/features/rooms/livekit/components/ParticipantTileFocus.tsx b/src/frontend/src/features/rooms/livekit/components/ParticipantTileFocus.tsx index 8caf64ab..028b98a8 100644 --- a/src/frontend/src/features/rooms/livekit/components/ParticipantTileFocus.tsx +++ b/src/frontend/src/features/rooms/livekit/components/ParticipantTileFocus.tsx @@ -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 ( - - ) -} - 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(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.
setHovered(true)} - onMouseLeave={() => setHovered(false)} - onMouseMove={handleMouseMove} > {isVisible && (
- {!isScreenShare ? ( + {!isScreenShare && ( <> {participant.isLocal ? ( @@ -221,8 +172,6 @@ export const ParticipantTileFocus = ({ canMute && )} - ) : ( - !isLocal && )}
diff --git a/src/frontend/src/features/rooms/livekit/components/ScreenShareZoomControls.tsx b/src/frontend/src/features/rooms/livekit/components/ScreenShareZoomControls.tsx index 7cf62229..509d80cc 100644 --- a/src/frontend/src/features/rooms/livekit/components/ScreenShareZoomControls.tsx +++ b/src/frontend/src/features/rooms/livekit/components/ScreenShareZoomControls.tsx @@ -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 { diff --git a/src/frontend/src/features/rooms/livekit/components/ScreenShareZoomableVideo.tsx b/src/frontend/src/features/rooms/livekit/components/ScreenShareZoomableVideo.tsx new file mode 100644 index 00000000..bfbf2d77 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/ScreenShareZoomableVideo.tsx @@ -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 + 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 */} +
+
+ +
+
+ + + ) +}