From bf622d5ff7e6f8a8638514a89566167952f1cf25 Mon Sep 17 00:00:00 2001 From: ibs0d <53568938+ibs0d@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:48:58 +1100 Subject: [PATCH] Add independent shift+wheel zoom for draggable live cards --- web/src/views/live/DraggableGridLayout.tsx | 149 +++++++++++++++------ 1 file changed, 111 insertions(+), 38 deletions(-) diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index 1748a43c1..24e3ac7f8 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -49,6 +49,13 @@ import { Toaster } from "@/components/ui/sonner"; import LiveContextMenu from "@/components/menu/LiveContextMenu"; import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { useTranslation } from "react-i18next"; +import { + CAMERA_ZOOM_MIN_SCALE, + CameraZoomRuntimeTransform, + clampScale, + getCursorRelativeZoomTransform, + getNextScaleFromWheelDelta, +} from "@/utils/cameraZoom"; type DraggableGridLayoutProps = { cameras: CameraConfig[]; @@ -424,6 +431,51 @@ export default function DraggableGridLayout({ const [audioStates, setAudioStates] = useState({}); const [volumeStates, setVolumeStates] = useState({}); const [statsStates, setStatsStates] = useState({}); + const [cameraZoomStates, setCameraZoomStates] = useState< + Record + >({}); + + const getDefaultZoomTransform = useCallback( + (): CameraZoomRuntimeTransform => ({ + scale: CAMERA_ZOOM_MIN_SCALE, + positionX: 0, + positionY: 0, + }), + [], + ); + + const handleCardWheelZoom = useCallback( + (cameraName: string, event: React.WheelEvent) => { + if (!event.shiftKey) { + return; + } + + event.preventDefault(); + + const bounds = event.currentTarget.getBoundingClientRect(); + const cursorX = event.clientX - bounds.left; + const cursorY = event.clientY - bounds.top; + + setCameraZoomStates((prev) => { + const current = prev[cameraName] ?? getDefaultZoomTransform(); + const nextScale = clampScale( + getNextScaleFromWheelDelta(current.scale, event.deltaY), + ); + const next = getCursorRelativeZoomTransform( + current, + nextScale, + cursorX, + cursorY, + ); + + return { + ...prev, + [cameraName]: next, + }; + }); + }, + [getDefaultZoomTransform], + ); useEffect(() => { const initialStreamStatsState = getStreamStatsFromStorage(); @@ -629,6 +681,8 @@ export default function DraggableGridLayout({ const useWebGL = currentGroupStreamingSettings?.[camera.name] ?.compatibilityMode || false; + const zoomTransform = + cameraZoomStates[camera.name] ?? getDefaultZoomTransform(); return ( - { - !isEditMode && onSelectCamera(camera.name); - }} - onError={(e) => { - setPreferredLiveModes((prevModes) => { - const newModes = { ...prevModes }; - if (e === "mse-decode") { - delete newModes[camera.name]; +
handleCardWheelZoom(camera.name, event)} + > +
+ resetPreferredLiveMode(camera.name)} - playAudio={audioStates[camera.name]} - volume={volumeStates[camera.name]} - /> + alwaysShowCameraName={displayCameraNames} + useWebGL={useWebGL} + cameraRef={cameraRef} + className={cn( + "draggable-live-grid-mse-cover size-full rounded-lg bg-black md:rounded-2xl", + camera.ui?.rotate && + "draggable-live-grid-rotated [--frigate-mse-grid-rotated:1] [--frigate-mse-grid-rotation:rotate(90deg)]", + isEditMode && + showCircles && + "outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing", + )} + windowVisible={ + windowVisible && visibleCameras.includes(camera.name) + } + cameraConfig={camera} + preferredLiveMode={ + preferredLiveModes[camera.name] ?? "mse" + } + playInBackground={false} + showStats={statsStates[camera.name] ?? true} + onClick={() => { + !isEditMode && onSelectCamera(camera.name); + }} + onError={(e) => { + setPreferredLiveModes((prevModes) => { + const newModes = { ...prevModes }; + if (e === "mse-decode") { + delete newModes[camera.name]; + } + return newModes; + }); + }} + onResetLiveMode={() => + resetPreferredLiveMode(camera.name) + } + playAudio={audioStates[camera.name]} + volume={volumeStates[camera.name]} + /> +
+
{isEditMode && showCircles && }
);