From b93b446614cabb1342b60632a98bcc634c4c661c Mon Sep 17 00:00:00 2001 From: Toba Ojo Date: Tue, 9 Dec 2025 08:47:21 +0000 Subject: [PATCH] - implement camera zoom controls and state management --- package.json | 4 +- src/app/providers/CameraFeedProvider.tsx | 42 ++++++++++++++++ src/app/reducers/cameraFeedReducer.ts | 14 +++++- .../components/CameraSettings/CameraPanel.tsx | 6 +-- .../cameraControls/CameraControls.tsx | 42 ++++++++++++++++ src/features/cameras/hooks/useCameraZoom.ts | 48 +++++++++++++++++++ src/types/types.ts | 11 +++++ src/ui/SliderComponent.tsx | 24 ++++++++++ yarn.lock | 37 ++++++++++++++ 9 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 src/features/cameras/components/CameraSettings/cameraControls/CameraControls.tsx create mode 100644 src/features/cameras/hooks/useCameraZoom.ts create mode 100644 src/ui/SliderComponent.tsx diff --git a/package.json b/package.json index 83f6739..430eff4 100644 --- a/package.json +++ b/package.json @@ -21,13 +21,15 @@ "clsx": "^2.1.1", "formik": "^2.4.9", "konva": "^10.0.11", + "rc-slider": "^11.1.9", "react": "^19.2.0", "react-dom": "^19.2.0", "react-konva": "^19.2.0", "react-modal": "^3.16.3", "react-tabs": "^6.1.0", "react-use-websocket": "3.0.0", - "sonner": "^2.0.7" + "sonner": "^2.0.7", + "use-debounce": "^10.0.6" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/src/app/providers/CameraFeedProvider.tsx b/src/app/providers/CameraFeedProvider.tsx index 8e5f657..8e818e2 100644 --- a/src/app/providers/CameraFeedProvider.tsx +++ b/src/app/providers/CameraFeedProvider.tsx @@ -3,9 +3,14 @@ import { CameraFeedContext } from "../context/CameraFeedContext"; import { initialState, reducer } from "../reducers/cameraFeedReducer"; import { useBlackBoard } from "../../hooks/useBlackBoard"; import type { CameraFeedState } from "../../types/types"; +import { useCameraZoom } from "../../features/cameras/hooks/useCameraZoom"; export const CameraFeedProvider = ({ children }: { children: ReactNode }) => { const { blackboardMutation } = useBlackBoard(); + const { cameraZoomQuery: cameraZoomQueryA } = useCameraZoom("A"); + const { cameraZoomQuery: cameraZoomQueryB } = useCameraZoom("B"); + const { cameraZoomQuery: cameraZoomQueryC } = useCameraZoom("C"); + const [state, dispatch] = useReducer(reducer, initialState); useEffect(() => { @@ -29,5 +34,42 @@ export const CameraFeedProvider = ({ children }: { children: ReactNode }) => { fetchBlackBoardData(); }, []); + useEffect(() => { + const fetchZoomLevels = async () => { + const [resultA, resultB, resultC] = await Promise.all([ + cameraZoomQueryA.refetch(), + cameraZoomQueryB.refetch(), + cameraZoomQueryC.refetch(), + ]); + + console.log(resultA?.data); + const zoomLevelAnumber = parseFloat(resultA.data?.propPhysCurrent?.value); + const zoomLevelBnumber = parseFloat(resultB.data?.propPhysCurrent?.value); + const zoomLevelCnumber = parseFloat(resultC.data?.propPhysCurrent?.value); + + if (resultA.data) { + dispatch({ + type: "SET_ZOOM_LEVEL", + payload: { cameraFeedID: "A", zoomLevel: zoomLevelAnumber }, + }); + } + + if (resultB.data) { + dispatch({ + type: "SET_ZOOM_LEVEL", + payload: { cameraFeedID: "B", zoomLevel: zoomLevelBnumber }, + }); + } + + if (resultC.data) { + dispatch({ + type: "SET_ZOOM_LEVEL", + payload: { cameraFeedID: "C", zoomLevel: zoomLevelCnumber }, + }); + } + }; + fetchZoomLevels(); + }, []); + return {children}; }; diff --git a/src/app/reducers/cameraFeedReducer.ts b/src/app/reducers/cameraFeedReducer.ts index cd8e397..898435b 100644 --- a/src/app/reducers/cameraFeedReducer.ts +++ b/src/app/reducers/cameraFeedReducer.ts @@ -37,6 +37,11 @@ export const initialState: CameraFeedState = { B: "painter", C: "painter", }, + zoomLevel: { + A: 1, + B: 1, + C: 1, + }, }; export function reducer(state: CameraFeedState, action: CameraFeedAction) { @@ -106,7 +111,14 @@ export function reducer(state: CameraFeedState, action: CameraFeedAction) { return { ...initialState, }; - + case "SET_ZOOM_LEVEL": + return { + ...state, + zoomLevel: { + ...state.zoomLevel, + [action.payload.cameraFeedID]: action.payload.zoomLevel, + }, + }; default: return state; } diff --git a/src/features/cameras/components/CameraSettings/CameraPanel.tsx b/src/features/cameras/components/CameraSettings/CameraPanel.tsx index 1b7a651..d1899bc 100644 --- a/src/features/cameras/components/CameraSettings/CameraPanel.tsx +++ b/src/features/cameras/components/CameraSettings/CameraPanel.tsx @@ -2,6 +2,7 @@ import { Tabs, Tab, TabList, TabPanel } from "react-tabs"; import { useEffect } from "react"; import { useCameraFeedContext } from "../../../../app/context/CameraFeedContext"; import RegionSelector from "./RegionSelector"; +import CameraControls from "./cameraControls/CameraControls"; type CameraPanelProps = { tabIndex: number; @@ -54,10 +55,7 @@ const CameraPanel = ({ tabIndex, isResetAllModalOpen, handleClose, setIsResetMod /> -
-

Camera Controls

-

Controls for camera {cameraFeedID} will go here.

-
+
); diff --git a/src/features/cameras/components/CameraSettings/cameraControls/CameraControls.tsx b/src/features/cameras/components/CameraSettings/cameraControls/CameraControls.tsx new file mode 100644 index 0000000..4bafa47 --- /dev/null +++ b/src/features/cameras/components/CameraSettings/cameraControls/CameraControls.tsx @@ -0,0 +1,42 @@ +import { useCameraFeedContext } from "../../../../../app/context/CameraFeedContext"; +import SliderComponent from "../../../../../ui/SliderComponent"; +import { useCameraZoom } from "../../../hooks/useCameraZoom"; +import { useDebouncedCallback } from "use-debounce"; + +type CameraControlsProps = { + cameraFeedID: "A" | "B" | "C"; +}; + +const CameraControls = ({ cameraFeedID }: CameraControlsProps) => { + const { state, dispatch } = useCameraFeedContext(); + const { cameraZoomMutation } = useCameraZoom(cameraFeedID); + + const zoomLevel = state.zoomLevel ? state.zoomLevel[cameraFeedID] : 1; + const debouncedMutation = useDebouncedCallback(async (value) => { + await cameraZoomMutation.mutateAsync({ + cameraFeedID, + zoomLevel: value as number, + }); + }, 1000); + + const handleChange = (value: number | number[]) => { + const newZoom = value as number; + dispatch({ + type: "SET_ZOOM_LEVEL", + payload: { cameraFeedID: cameraFeedID, zoomLevel: value as number }, + }); + debouncedMutation(newZoom); + }; + + return ( +
+

Camera {cameraFeedID}

+
+ + +
+
+ ); +}; + +export default CameraControls; diff --git a/src/features/cameras/hooks/useCameraZoom.ts b/src/features/cameras/hooks/useCameraZoom.ts new file mode 100644 index 0000000..9235f8e --- /dev/null +++ b/src/features/cameras/hooks/useCameraZoom.ts @@ -0,0 +1,48 @@ +import { useQuery, useMutation } from "@tanstack/react-query"; +import { CAMBASE } from "../../../utils/config"; +import type { CameraZoomConfig } from "../../../types/types"; + +const fetchZoomLevel = async (cameraFeedID: string) => { + const response = await fetch(`${CAMBASE}/api/fetch-config?id=Camera${cameraFeedID}-onvif-controller`); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); +}; + +const postZoomLevel = async (zoomConfig: CameraZoomConfig) => { + const fields = [ + { property: "propPhysCurrent", value: zoomConfig.zoomLevel }, + { property: "propCameraHost", value: "192.168.0.101" }, + { property: "propCameraPort", value: 80 }, + { property: "propCameraUsername", value: "administrator" }, + { property: "propCameraPassword", value: "MAV12345" }, + ]; + const zoomPayload = { + id: `Camera${zoomConfig.cameraFeedID}-onvif-controller`, + fields, + }; + console.log(zoomPayload); + const response = await fetch(`${CAMBASE}/api/update-config`, { + method: "POST", + body: JSON.stringify(zoomPayload), + }); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); +}; + +export const useCameraZoom = (cameraFeedID: "A" | "B" | "C") => { + const cameraZoomQuery = useQuery({ + queryKey: ["cameraZoom", cameraFeedID], + queryFn: () => fetchZoomLevel(cameraFeedID), + }); + + const cameraZoomMutation = useMutation({ + mutationKey: ["postCameraZoom"], + mutationFn: (zoomConfig: CameraZoomConfig) => postZoomLevel(zoomConfig), + }); + return { cameraZoomQuery, cameraZoomMutation }; +}; diff --git a/src/types/types.ts b/src/types/types.ts index fc0befb..a6c1138 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -143,6 +143,11 @@ export type CameraFeedState = { }; tabIndex?: number; + zoomLevel: { + A: number; + B: number; + C: number; + }; }; export type CameraFeedAction = @@ -177,6 +182,10 @@ export type CameraFeedAction = } | { type: "RESET_CAMERA_FEED"; + } + | { + type: "SET_ZOOM_LEVEL"; + payload: { cameraFeedID: "A" | "B" | "C"; zoomLevel: number }; }; export type DecodeReading = { @@ -227,3 +236,5 @@ export type BlackBoardOptions = { path?: string; value?: object | string | number | (string | number)[] | null; }; + +export type CameraZoomConfig = { cameraFeedID: string; zoomLevel: number }; diff --git a/src/ui/SliderComponent.tsx b/src/ui/SliderComponent.tsx new file mode 100644 index 0000000..26bde6f --- /dev/null +++ b/src/ui/SliderComponent.tsx @@ -0,0 +1,24 @@ +import Slider from "rc-slider"; +import "rc-slider/assets/index.css"; + +type SliderComponentProps = { + id: string; + onChange: (value: number | number[]) => void; + value?: number; + min?: number; + max?: number; + step?: number; +}; + +const SliderComponent = ({ id, onChange, value = 0, min = 0, max = 100, step = 1 }: SliderComponentProps) => { + const handleChange = (val: number | number[]) => { + onChange(val); + }; + return ( + <> + + + ); +}; + +export default SliderComponent; diff --git a/yarn.lock b/yarn.lock index 612afcf..d4996c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -226,6 +226,11 @@ "@babel/plugin-transform-modules-commonjs" "^7.27.1" "@babel/plugin-transform-typescript" "^7.28.5" +"@babel/runtime@^7.10.1", "@babel/runtime@^7.18.3": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326" + integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ== + "@babel/template@^7.27.2": version "7.27.2" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" @@ -1314,6 +1319,11 @@ chokidar@^3.6.0: optionalDependencies: fsevents "~2.3.2" +classnames@^2.2.5: + version "2.5.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" + integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== + clsx@^2.0.0, clsx@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" @@ -2142,6 +2152,23 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +rc-slider@^11.1.9: + version "11.1.9" + resolved "https://registry.yarnpkg.com/rc-slider/-/rc-slider-11.1.9.tgz#d872130fbf4ec51f28543d62e90451091d6f5208" + integrity sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A== + dependencies: + "@babel/runtime" "^7.10.1" + classnames "^2.2.5" + rc-util "^5.36.0" + +rc-util@^5.36.0: + version "5.44.4" + resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-5.44.4.tgz#89ee9037683cca01cd60f1a6bbda761457dd6ba5" + integrity sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w== + dependencies: + "@babel/runtime" "^7.18.3" + react-is "^18.2.0" + react-dom@^19.2.0: version "19.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.0.tgz#00ed1e959c365e9a9d48f8918377465466ec3af8" @@ -2159,6 +2186,11 @@ react-is@^16.13.1, react-is@^16.7.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-is@^18.2.0: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== + react-konva@^19.2.0: version "19.2.0" resolved "https://registry.yarnpkg.com/react-konva/-/react-konva-19.2.0.tgz#b4cc5d73cd6d642569e4df36a0139996c3dcf8e6" @@ -2461,6 +2493,11 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +use-debounce@^10.0.6: + version "10.0.6" + resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-10.0.6.tgz#e05060a5e561432ec740c653698f3eb162bd28ec" + integrity sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg== + use-sync-external-store@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"