diff --git a/src/frontend/src/features/rooms/livekit/hooks/useScreenShareZoom.ts b/src/frontend/src/features/rooms/livekit/hooks/useScreenShareZoom.ts index 89ba1cf0..1dbabf9d 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useScreenShareZoom.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useScreenShareZoom.ts @@ -2,6 +2,7 @@ import { useCallback, useRef, useSyncExternalStore } from 'react' import { useMove } from 'react-aria' import type { MoveMoveEvent } from '@react-types/shared' import { + FULL_PICTURE_RATIO, MIN_ZOOM, PAN_STEP, WHEEL_ZOOM_SPEED, @@ -14,6 +15,7 @@ import { getCursorFromZoomState, getCursorPercentsFromWheelEvent, getPanDeltaPercentsFromMove, + getPictureRatio, getWheelPanOffset, getZoomTransform, } from '../utils/screenShareZoom' @@ -73,6 +75,22 @@ export const useScreenShareZoom = () => { el.style.transform = getZoomTransform(zoomRef.current, panRef.current) }, []) + // The video is letterboxed inside the surface by object-fit: contain, so the + // pan bounds depend on how much of the surface the picture actually covers. + // Read live rather than cached: both the tile and the shared resolution can + // change at any time. + const readPictureRatio = useCallback(() => { + const surface = surfaceElRef.current + const video = transformElRef.current?.querySelector('video') + if (!surface || !video) return FULL_PICTURE_RATIO + return getPictureRatio( + surface.clientWidth, + surface.clientHeight, + video.videoWidth, + video.videoHeight + ) + }, []) + const applyCursor = useCallback(() => { const el = surfaceElRef.current if (!el) return @@ -85,21 +103,23 @@ export const useScreenShareZoom = () => { const zoomIn = useCallback(() => { const next = clampZoom(zoomRef.current + ZOOM_STEP) zoomRef.current = next - panRef.current = clampPan(panRef.current, next) + panRef.current = clampPan(panRef.current, next, readPictureRatio()) applyTransform() applyCursor() flush() - }, [applyTransform, applyCursor, flush]) + }, [applyTransform, applyCursor, flush, readPictureRatio]) const zoomOut = useCallback(() => { const next = clampZoom(zoomRef.current - ZOOM_STEP) zoomRef.current = next panRef.current = - next <= MIN_ZOOM ? { x: 0, y: 0 } : clampPan(panRef.current, next) + next <= MIN_ZOOM + ? { x: 0, y: 0 } + : clampPan(panRef.current, next, readPictureRatio()) applyTransform() applyCursor() flush() - }, [applyTransform, applyCursor, flush]) + }, [applyTransform, applyCursor, flush, readPictureRatio]) const resetZoom = useCallback(() => { zoomRef.current = MIN_ZOOM @@ -136,6 +156,7 @@ export const useScreenShareZoom = () => { nextZoom: next, cursorXPercent, cursorYPercent, + ratio: readPictureRatio(), }) } @@ -143,7 +164,7 @@ export const useScreenShareZoom = () => { applyCursor() flush() }, - [applyTransform, applyCursor, flush] + [applyTransform, applyCursor, flush, readPictureRatio] ) // useMove handles mouse drag + touch pan. Keyboard arrows are not handled @@ -173,7 +194,8 @@ export const useScreenShareZoom = () => { x: panRef.current.x + deltaXPercent, y: panRef.current.y + deltaYPercent, }, - zoomRef.current + zoomRef.current, + readPictureRatio() ) applyTransform() @@ -195,12 +217,13 @@ export const useScreenShareZoom = () => { (dx: number, dy: number) => { panRef.current = clampPan( { x: panRef.current.x + dx, y: panRef.current.y + dy }, - zoomRef.current + zoomRef.current, + readPictureRatio() ) applyTransform() flush() }, - [applyTransform, flush] + [applyTransform, flush, readPictureRatio] ) // Attached to the tile container (not the zoom surface) where keyboard diff --git a/src/frontend/src/features/rooms/livekit/utils/screenShareZoom.ts b/src/frontend/src/features/rooms/livekit/utils/screenShareZoom.ts index 8be7f7ba..dc86fd52 100644 --- a/src/frontend/src/features/rooms/livekit/utils/screenShareZoom.ts +++ b/src/frontend/src/features/rooms/livekit/utils/screenShareZoom.ts @@ -10,6 +10,14 @@ export interface PanOffset { y: number } +// Fraction of the surface each axis of the picture covers, in [0, 1]. +export interface PictureRatio { + x: number + y: number +} + +export const FULL_PICTURE_RATIO: PictureRatio = { x: 1, y: 1 } + export interface ZoomSnapshot { zoomLevel: number zoomPercentage: number @@ -24,15 +32,41 @@ export const clampZoom = (value: number) => { return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value)) } -// Restrict pan so the picture always covers the view. -export const clampPan = (pan: PanOffset, zoom: number): PanOffset => { - const maxPan = ((zoom - 1) / zoom) * PAN_CLAMP_HALF +// Restrict pan so the picture always covers the view. Pan is a % of the +// surface, in which object-fit: contain letterboxes the picture: its half +// extent is `ratio * 50` against a view half extent of 50, and scaling by +// `zoom` must keep `zoom * (ratio * 50 - |pan|) >= 50`. An axis whose picture +// is still smaller than the view is pinned to 0, keeping the bars symmetric. +export const clampPan = ( + pan: PanOffset, + zoom: number, + ratio: PictureRatio +): PanOffset => { + const maxPanX = Math.max(0, (ratio.x - 1 / zoom) * PAN_CLAMP_HALF) + const maxPanY = Math.max(0, (ratio.y - 1 / zoom) * PAN_CLAMP_HALF) return { - x: Math.max(-maxPan, Math.min(maxPan, pan.x)), - y: Math.max(-maxPan, Math.min(maxPan, pan.y)), + x: Math.max(-maxPanX, Math.min(maxPanX, pan.x)), + y: Math.max(-maxPanY, Math.min(maxPanY, pan.y)), } } +// Per-axis fraction of the surface covered by an object-fit: contain picture. +export const getPictureRatio = ( + surfaceWidth: number, + surfaceHeight: number, + videoWidth: number, + videoHeight: number +): PictureRatio => { + if (!surfaceWidth || !surfaceHeight || !videoWidth || !videoHeight) { + return FULL_PICTURE_RATIO + } + const surfaceRatio = surfaceWidth / surfaceHeight + const videoRatio = videoWidth / videoHeight + return surfaceRatio > videoRatio + ? { x: videoRatio / surfaceRatio, y: 1 } + : { x: 1, y: surfaceRatio / videoRatio } +} + export const buildZoomSnapshot = ( zoom: number, pan: PanOffset, @@ -68,12 +102,14 @@ export const getWheelPanOffset = ({ nextZoom, cursorXPercent, cursorYPercent, + ratio, }: { pan: PanOffset prevZoom: number nextZoom: number cursorXPercent: number cursorYPercent: number + ratio: PictureRatio }): PanOffset => { const panShift = 1 / nextZoom - 1 / prevZoom return clampPan( @@ -81,7 +117,8 @@ export const getWheelPanOffset = ({ x: pan.x + cursorXPercent * panShift, y: pan.y + cursorYPercent * panShift, }, - nextZoom + nextZoom, + ratio ) }