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"
@ -659,12 +713,25 @@ export default function DraggableGridLayout({
} }
config={config} config={config}
streamMetadata={streamMetadata} streamMetadata={streamMetadata}
>
<div
className="size-full overflow-hidden rounded-lg md:rounded-2xl"
onWheel={(event) => handleCardWheelZoom(camera.name, event)}
>
<div
className="size-full"
style={{
transform: `translate(${zoomTransform.positionX}px, ${zoomTransform.positionY}px) scale(${zoomTransform.scale})`,
transformOrigin: "0 0",
}}
> >
<LivePlayer <LivePlayer
key={camera.name} key={camera.name}
streamName={streamName} streamName={streamName}
autoLive={autoLive ?? globalAutoLive} autoLive={autoLive ?? globalAutoLive}
showStillWithoutActivity={showStillWithoutActivity ?? true} showStillWithoutActivity={
showStillWithoutActivity ?? true
}
alwaysShowCameraName={displayCameraNames} alwaysShowCameraName={displayCameraNames}
useWebGL={useWebGL} useWebGL={useWebGL}
cameraRef={cameraRef} cameraRef={cameraRef}
@ -680,7 +747,9 @@ export default function DraggableGridLayout({
windowVisible && visibleCameras.includes(camera.name) windowVisible && visibleCameras.includes(camera.name)
} }
cameraConfig={camera} cameraConfig={camera}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"} preferredLiveMode={
preferredLiveModes[camera.name] ?? "mse"
}
playInBackground={false} playInBackground={false}
showStats={statsStates[camera.name] ?? true} showStats={statsStates[camera.name] ?? true}
onClick={() => { onClick={() => {
@ -695,10 +764,14 @@ export default function DraggableGridLayout({
return newModes; return newModes;
}); });
}} }}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)} onResetLiveMode={() =>
resetPreferredLiveMode(camera.name)
}
playAudio={audioStates[camera.name]} playAudio={audioStates[camera.name]}
volume={volumeStates[camera.name]} volume={volumeStates[camera.name]}
/> />
</div>
</div>
{isEditMode && showCircles && <CornerCircles />} {isEditMode && showCircles && <CornerCircles />}
</GridLiveContextMenu> </GridLiveContextMenu>
); );