diff --git a/src/frontend/src/features/participantTile/components/ParticipantTile.tsx b/src/frontend/src/features/participantTile/components/ParticipantTile.tsx index 2d754507..5ec475dd 100644 --- a/src/frontend/src/features/participantTile/components/ParticipantTile.tsx +++ b/src/frontend/src/features/participantTile/components/ParticipantTile.tsx @@ -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 ) => 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(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 { 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) => { @@ -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 = ( + + ) + } 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} + > {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) ? ( - - ) : ( - isTrackReference(trackReference) && ( - - ) - )} + {trackMedia}
)} diff --git a/src/frontend/src/features/participantTile/components/participantTileFocus/ParticipantTileFocus.tsx b/src/frontend/src/features/participantTile/components/participantTileFocus/ParticipantTileFocus.tsx index 7facb036..ce80f705 100644 --- a/src/frontend/src/features/participantTile/components/participantTileFocus/ParticipantTileFocus.tsx +++ b/src/frontend/src/features/participantTile/components/participantTileFocus/ParticipantTileFocus.tsx @@ -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(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 (
{ 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}
@@ -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 ( - +
- {!isScreenShare ? ( + {!isScreenShare && ( <> {isLocal ? ( @@ -102,8 +76,6 @@ export const ParticipantTileFocus = ({ canMute && )} - ) : ( - !isLocal && )}
diff --git a/src/frontend/src/features/participantTile/components/participantTileFocus/ZoomButton.tsx b/src/frontend/src/features/participantTile/components/participantTileFocus/ZoomButton.tsx deleted file mode 100644 index 4e637c08..00000000 --- a/src/frontend/src/features/participantTile/components/participantTileFocus/ZoomButton.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/src/frontend/src/features/rooms/livekit/components/ScreenShareVideoTrack.tsx b/src/frontend/src/features/rooms/livekit/components/ScreenShareVideoTrack.tsx new file mode 100644 index 00000000..5bafb478 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/ScreenShareVideoTrack.tsx @@ -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) => ( + + ) +) + +ScreenShareVideoTrack.displayName = 'ScreenShareVideoTrack' diff --git a/src/frontend/src/features/rooms/livekit/components/ScreenShareZoomControls.tsx b/src/frontend/src/features/rooms/livekit/components/ScreenShareZoomControls.tsx index 7cf62229..b0dcfa41 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,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. 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..ce59442c --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/ScreenShareZoomableVideo.tsx @@ -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 + 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(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 */} +
+
+ +
+
+ + + ) +}