Merge pull request #32 from ibs0d/codex/add-independent-zoom-for-camera-cards

Add independent Shift+Wheel zoom for draggable live grid cards
This commit is contained in:
ibs0d 2026-03-09 10:49:11 +11:00 committed by GitHub
commit 91976498c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -49,6 +49,13 @@ import { Toaster } from "@/components/ui/sonner";
import LiveContextMenu from "@/components/menu/LiveContextMenu"; import LiveContextMenu from "@/components/menu/LiveContextMenu";
import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
CAMERA_ZOOM_MIN_SCALE,
CameraZoomRuntimeTransform,
clampScale,
getCursorRelativeZoomTransform,
getNextScaleFromWheelDelta,
} from "@/utils/cameraZoom";
type DraggableGridLayoutProps = { type DraggableGridLayoutProps = {
cameras: CameraConfig[]; cameras: CameraConfig[];
@ -424,6 +431,51 @@ export default function DraggableGridLayout({
const [audioStates, setAudioStates] = useState<AudioState>({}); const [audioStates, setAudioStates] = useState<AudioState>({});
const [volumeStates, setVolumeStates] = useState<VolumeState>({}); const [volumeStates, setVolumeStates] = useState<VolumeState>({});
const [statsStates, setStatsStates] = useState<StatsState>({}); const [statsStates, setStatsStates] = useState<StatsState>({});
const [cameraZoomStates, setCameraZoomStates] = useState<
Record<string, CameraZoomRuntimeTransform>
>({});
const getDefaultZoomTransform = useCallback(
(): CameraZoomRuntimeTransform => ({
scale: CAMERA_ZOOM_MIN_SCALE,
positionX: 0,
positionY: 0,
}),
[],
);
const handleCardWheelZoom = useCallback(
(cameraName: string, event: React.WheelEvent<HTMLDivElement>) => {
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(() => { useEffect(() => {
const initialStreamStatsState = getStreamStatsFromStorage(); const initialStreamStatsState = getStreamStatsFromStorage();
@ -629,6 +681,8 @@ export default function DraggableGridLayout({
const useWebGL = const useWebGL =
currentGroupStreamingSettings?.[camera.name] currentGroupStreamingSettings?.[camera.name]
?.compatibilityMode || false; ?.compatibilityMode || false;
const zoomTransform =
cameraZoomStates[camera.name] ?? getDefaultZoomTransform();
return ( return (
<GridLiveContextMenu <GridLiveContextMenu
className="size-full" className="size-full"
@ -660,45 +714,64 @@ export default function DraggableGridLayout({
config={config} config={config}
streamMetadata={streamMetadata} streamMetadata={streamMetadata}
> >
<LivePlayer <div
key={camera.name} className="size-full overflow-hidden rounded-lg md:rounded-2xl"
streamName={streamName} onWheel={(event) => handleCardWheelZoom(camera.name, event)}
autoLive={autoLive ?? globalAutoLive} >
showStillWithoutActivity={showStillWithoutActivity ?? true} <div
alwaysShowCameraName={displayCameraNames} className="size-full"
useWebGL={useWebGL} style={{
cameraRef={cameraRef} transform: `translate(${zoomTransform.positionX}px, ${zoomTransform.positionY}px) scale(${zoomTransform.scale})`,
className={cn( transformOrigin: "0 0",
"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)]", <LivePlayer
isEditMode && key={camera.name}
showCircles && streamName={streamName}
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing", autoLive={autoLive ?? globalAutoLive}
)} showStillWithoutActivity={
windowVisible={ showStillWithoutActivity ?? true
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; alwaysShowCameraName={displayCameraNames}
}); useWebGL={useWebGL}
}} cameraRef={cameraRef}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)} className={cn(
playAudio={audioStates[camera.name]} "draggable-live-grid-mse-cover size-full rounded-lg bg-black md:rounded-2xl",
volume={volumeStates[camera.name]} 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]}
/>
</div>
</div>
{isEditMode && showCircles && <CornerCircles />} {isEditMode && showCircles && <CornerCircles />}
</GridLiveContextMenu> </GridLiveContextMenu>
); );