diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index bf551419a..0b466a01c 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -315,6 +315,9 @@ class Dispatcher: if "preset" in payload.lower(): command = OnvifCommandEnum.preset param = payload.lower()[payload.index("_") + 1 :] + elif "move_relative" in payload.lower(): + command = OnvifCommandEnum.move_relative + param = payload.lower()[payload.index("_") + 1 :] else: command = OnvifCommandEnum[payload.lower()] param = "" diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py index 38b61c2f9..d8af877e9 100644 --- a/frigate/ptz/onvif.py +++ b/frigate/ptz/onvif.py @@ -21,6 +21,7 @@ class OnvifCommandEnum(str, Enum): init = "init" move_down = "move_down" move_left = "move_left" + move_relative = "move_relative" move_right = "move_right" move_up = "move_up" preset = "preset" @@ -536,6 +537,9 @@ class OnvifController: self._stop(camera_name) elif command == OnvifCommandEnum.preset: self._move_to_preset(camera_name, param) + elif command == OnvifCommandEnum.move_relative: + _, pan, tilt = param.split("_") + self._move_relative(camera_name, float(pan), float(tilt), 0, 1) elif ( command == OnvifCommandEnum.zoom_in or command == OnvifCommandEnum.zoom_out ): diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index a2f849ff0..5f01e2698 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -45,6 +45,7 @@ import { FaMicrophoneSlash, } from "react-icons/fa"; import { GiSpeaker, GiSpeakerOff } from "react-icons/gi"; +import { HiViewfinderCircle } from "react-icons/hi2"; import { IoMdArrowBack } from "react-icons/io"; import { LuEar, LuEarOff, LuVideo, LuVideoOff } from "react-icons/lu"; import { @@ -82,6 +83,45 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { ); const { payload: audioState, send: sendAudio } = useAudioState(camera.name); + // click overlay for ptzs + + const [clickOverlay, setClickOverlay] = useState(false); + const clickOverlayRef = useRef(null); + const { send: sendPtz } = usePtzCommand(camera.name); + + const handleOverlayClick = useCallback( + ( + e: React.MouseEvent | React.TouchEvent, + ) => { + if (!clickOverlay) { + return; + } + + let clientX; + let clientY; + if (isMobile && e.nativeEvent instanceof TouchEvent) { + clientX = e.nativeEvent.touches[0].clientX; + clientY = e.nativeEvent.touches[0].clientY; + } else if (e.nativeEvent instanceof MouseEvent) { + clientX = e.nativeEvent.clientX; + clientY = e.nativeEvent.clientY; + } + + if (clickOverlayRef.current && clientX && clientY) { + const rect = clickOverlayRef.current.getBoundingClientRect(); + + const normalizedX = (clientX - rect.left) / rect.width; + const normalizedY = (clientY - rect.top) / rect.height; + + const pan = (normalizedX - 0.5) * 2; + const tilt = (0.5 - normalizedY) * 2; + + sendPtz(`move_relative_${pan}_${tilt}`); + } + }, + [clickOverlayRef, clickOverlay, sendPtz], + ); + // fullscreen state useEffect(() => { @@ -277,6 +317,8 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { >
- {camera.onvif.host != "" && } + {camera.onvif.host != "" && ( + + )} ); } -function PtzControlPanel({ camera }: { camera: string }) { +function PtzControlPanel({ + camera, + clickOverlay, + setClickOverlay, +}: { + camera: string; + clickOverlay: boolean; + setClickOverlay: React.Dispatch>; +}) { const { data: ptz } = useSWR(`${camera}/ptz/info`); const { send: sendPtz } = usePtzCommand(camera); @@ -442,6 +498,16 @@ function PtzControlPanel({ camera }: { camera: string }) { )} + {ptz?.features?.includes("pt-r-fov") && ( + <> + + + )} {(ptz?.presets?.length ?? 0) > 0 && (