diff --git a/web/src/App.tsx b/web/src/App.tsx index 9bbb666f8..0512d15d3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -27,7 +27,7 @@ function App() { {isMobile && }
} /> diff --git a/web/src/components/dynamic/CameraFeatureToggle.tsx b/web/src/components/dynamic/CameraFeatureToggle.tsx new file mode 100644 index 000000000..666bfb33b --- /dev/null +++ b/web/src/components/dynamic/CameraFeatureToggle.tsx @@ -0,0 +1,60 @@ +import { IconType } from "react-icons"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { isDesktop } from "react-device-detect"; + +const variants = { + primary: { + active: "font-bold text-primary-foreground bg-primary", + inactive: "text-muted-foreground bg-muted", + }, + secondary: { + active: "font-bold text-primary", + inactive: "text-muted-foreground", + }, +}; + +type CameraFeatureToggleProps = { + className?: string; + variant?: "primary" | "secondary"; + isActive: boolean; + Icon: IconType; + title: string; + onClick?: () => void; +}; + +export default function CameraFeatureToggle({ + className = "", + variant = "primary", + isActive, + Icon, + title, + onClick, +}: CameraFeatureToggleProps) { + const content = ( +
+ +
+ ); + + if (isDesktop) { + return ( + + {content} + +

{title}

+
+
+ ); + } + + return content; +} diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 769028788..663e60808 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -20,6 +20,8 @@ type LivePlayerProps = { preferredLiveMode?: LivePlayerMode; showStillWithoutActivity?: boolean; windowVisible?: boolean; + playAudio?: boolean; + onClick?: () => void; }; export default function LivePlayer({ @@ -28,6 +30,8 @@ export default function LivePlayer({ preferredLiveMode, showStillWithoutActivity = true, windowVisible = true, + playAudio = false, + onClick, }: LivePlayerProps) { // camera activity @@ -35,8 +39,10 @@ export default function LivePlayer({ useCameraActivity(cameraConfig); const cameraActive = useMemo( - () => windowVisible && (activeMotion || activeTracking), - [activeMotion, activeTracking, windowVisible], + () => + !showStillWithoutActivity || + (windowVisible && (activeMotion || activeTracking)), + [activeMotion, activeTracking, showStillWithoutActivity, windowVisible], ); // camera live state @@ -91,6 +97,7 @@ export default function LivePlayer({ className={`rounded-2xl h-full ${liveReady ? "" : "hidden"}`} camera={cameraConfig.live.stream_name} playbackEnabled={cameraActive} + audioEnabled={playAudio} onPlaying={() => setLiveReady(true)} /> ); @@ -101,6 +108,7 @@ export default function LivePlayer({ className={`rounded-2xl h-full ${liveReady ? "" : "hidden"}`} camera={cameraConfig.name} playbackEnabled={cameraActive} + audioEnabled={playAudio} onPlaying={() => setLiveReady(true)} /> ); @@ -127,11 +135,12 @@ export default function LivePlayer({ return (
diff --git a/web/src/components/player/MsePlayer.tsx b/web/src/components/player/MsePlayer.tsx index 318d97121..176fc59f0 100644 --- a/web/src/components/player/MsePlayer.tsx +++ b/web/src/components/player/MsePlayer.tsx @@ -5,6 +5,7 @@ type MSEPlayerProps = { camera: string; className?: string; playbackEnabled?: boolean; + audioEnabled?: boolean; onPlaying?: () => void; }; @@ -12,6 +13,7 @@ function MSEPlayer({ camera, className, playbackEnabled = true, + audioEnabled = false, onPlaying, }: MSEPlayerProps) { let connectTS: number = 0; @@ -273,7 +275,7 @@ function MSEPlayer({ playsInline preload="auto" onLoadedData={onPlaying} - muted + muted={!audioEnabled} /> ); } diff --git a/web/src/components/player/WebRTCPlayer.tsx b/web/src/components/player/WebRTCPlayer.tsx index 2eb18266c..d964b3460 100644 --- a/web/src/components/player/WebRTCPlayer.tsx +++ b/web/src/components/player/WebRTCPlayer.tsx @@ -5,6 +5,7 @@ type WebRtcPlayerProps = { className?: string; camera: string; playbackEnabled?: boolean; + audioEnabled?: boolean; onPlaying?: () => void; }; @@ -12,6 +13,7 @@ export default function WebRtcPlayer({ className, camera, playbackEnabled = true, + audioEnabled = false, onPlaying, }: WebRtcPlayerProps) { // camera states @@ -160,7 +162,7 @@ export default function WebRtcPlayer({ className={className} autoPlay playsInline - muted + muted={!audioEnabled} onLoadedData={onPlaying} /> ); diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index 8e3d58193..25c6fdb1b 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -1,59 +1,13 @@ -import { useFrigateReviews } from "@/api/ws"; -import Logo from "@/components/Logo"; -import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail"; -import LivePlayer from "@/components/player/LivePlayer"; -import { Button } from "@/components/ui/button"; -import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; -import { TooltipProvider } from "@/components/ui/tooltip"; -import { usePersistence } from "@/hooks/use-persistence"; +import useOverlayState from "@/hooks/use-overlay-state"; import { FrigateConfig } from "@/types/frigateConfig"; -import { ReviewSegment } from "@/types/review"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { isDesktop, isMobile, isSafari } from "react-device-detect"; -import { CiGrid2H, CiGrid31 } from "react-icons/ci"; +import LiveCameraView from "@/views/live/LiveCameraView"; +import LiveDashboardView from "@/views/live/LiveDashboardView"; +import { useMemo } from "react"; import useSWR from "swr"; function Live() { const { data: config } = useSWR("config"); - - // layout - - const [layout, setLayout] = usePersistence<"grid" | "list">( - "live-layout", - isDesktop ? "grid" : "list", - ); - - // recent events - const { payload: eventUpdate } = useFrigateReviews(); - const { data: allEvents, mutate: updateEvents } = useSWR([ - "review", - { limit: 10, severity: "alert" }, - ]); - - useEffect(() => { - if (!eventUpdate) { - return; - } - - // if event is ended and was saved, update events list - if (eventUpdate.type == "end" && eventUpdate.review.severity == "alert") { - updateEvents(); - return; - } - }, [eventUpdate, updateEvents]); - - const events = useMemo(() => { - if (!allEvents) { - return []; - } - - const date = new Date(); - date.setHours(date.getHours() - 1); - const cutoff = date.getTime() / 1000; - return allEvents.filter((event) => event.start_time > cutoff); - }, [allEvents]); - - // camera live views + const [selectedCameraName, setSelectedCameraName] = useOverlayState("camera"); const cameras = useMemo(() => { if (!config) { @@ -65,84 +19,20 @@ function Live() { .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); - const [windowVisible, setWindowVisible] = useState(true); - const visibilityListener = useCallback(() => { - setWindowVisible(document.visibilityState == "visible"); - }, []); + const selectedCamera = useMemo( + () => cameras.find((cam) => cam.name == selectedCameraName), + [cameras, selectedCameraName], + ); - useEffect(() => { - addEventListener("visibilitychange", visibilityListener); - - return () => { - removeEventListener("visibilitychange", visibilityListener); - }; - }, [visibilityListener]); + if (selectedCamera) { + return ; + } return ( -
- {isMobile && ( -
- -
-
- - -
-
- )} - - {events && events.length > 0 && ( - - -
- {events.map((event) => { - return ; - })} -
-
- -
- )} - -
- {cameras.map((camera) => { - let grow; - const aspectRatio = camera.detect.width / camera.detect.height; - if (aspectRatio > 2) { - grow = `${layout == "grid" ? "col-span-2" : ""} aspect-wide`; - } else if (aspectRatio < 1) { - grow = `${layout == "grid" ? "row-span-2 aspect-tall md:h-full" : ""} aspect-tall`; - } else { - grow = "aspect-video"; - } - return ( - - ); - })} -
-
+ ); } diff --git a/web/src/types/ptz.ts b/web/src/types/ptz.ts new file mode 100644 index 000000000..1a626972e --- /dev/null +++ b/web/src/types/ptz.ts @@ -0,0 +1,7 @@ +type PtzFeature = "pt" | "zoom" | "pt-r" | "zoom-r" | "zoom-a" | "pt-r-fov"; + +export type CameraPtzInfo = { + name: string; + features: PtzFeature[]; + presets: string[]; +}; diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx new file mode 100644 index 000000000..3dfd76109 --- /dev/null +++ b/web/src/views/live/LiveCameraView.tsx @@ -0,0 +1,330 @@ +import { + useAudioState, + useDetectState, + usePtzCommand, + useRecordingsState, + useSnapshotsState, +} from "@/api/ws"; +import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle"; +import LivePlayer from "@/components/player/LivePlayer"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import useKeyboardListener from "@/hooks/use-keyboard-listener"; +import { CameraConfig } from "@/types/frigateConfig"; +import { CameraPtzInfo } from "@/types/ptz"; +import React, { useCallback, useMemo, useState } from "react"; +import { + isDesktop, + isMobile, + isSafari, + useMobileOrientation, +} from "react-device-detect"; +import { BsThreeDotsVertical } from "react-icons/bs"; +import { + FaAngleDown, + FaAngleLeft, + FaAngleRight, + FaAngleUp, +} from "react-icons/fa"; +import { GiSpeaker, GiSpeakerOff } from "react-icons/gi"; +import { IoMdArrowBack } from "react-icons/io"; +import { LuEar, LuEarOff, LuVideo, LuVideoOff } from "react-icons/lu"; +import { + MdNoPhotography, + MdPersonOff, + MdPersonSearch, + MdPhotoCamera, + MdZoomIn, + MdZoomOut, +} from "react-icons/md"; +import { useNavigate } from "react-router-dom"; +import useSWR from "swr"; + +type LiveCameraViewProps = { + camera: CameraConfig; +}; +export default function LiveCameraView({ camera }: LiveCameraViewProps) { + const navigate = useNavigate(); + const { isPortrait } = useMobileOrientation(); + + // camera features + + const { payload: detectState, send: sendDetect } = useDetectState( + camera.name, + ); + const { payload: recordState, send: sendRecord } = useRecordingsState( + camera.name, + ); + const { payload: snapshotState, send: sendSnapshot } = useSnapshotsState( + camera.name, + ); + const { payload: audioState, send: sendAudio } = useAudioState(camera.name); + + // playback state + + const [audio, setAudio] = useState(false); + + const growClassName = useMemo(() => { + if (isMobile) { + if (isPortrait) { + return "absolute left-2 right-2 top-[50%] -translate-y-[50%]"; + } else { + return "absolute top-2 bottom-2 left-[50%] -translate-x-[50%]"; + } + } else if (camera.detect.width / camera.detect.height > 2) { + return "absolute left-2 right-2 top-[50%] -translate-y-[50%]"; + } else { + return "absolute top-2 bottom-2 left-[50%] -translate-x-[50%]"; + } + }, [camera, isPortrait]); + + return ( +
+
+ + +
+ setAudio(!audio)} + /> + sendDetect(detectState == "ON" ? "OFF" : "ON")} + /> + sendRecord(recordState == "ON" ? "OFF" : "ON")} + /> + sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} + /> + {camera.audio.enabled_in_config && ( + sendAudio(audioState == "ON" ? "OFF" : "ON")} + /> + )} +
+
+
+ +
+
+ +
+ {camera.onvif.host != "" && } +
+
+ ); +} + +function PtzControlPanel({ camera }: { camera: string }) { + const { data: ptz } = useSWR(`${camera}/ptz/info`); + + const { send: sendPtz } = usePtzCommand(camera); + + const onStop = useCallback( + (e: React.SyntheticEvent) => { + e.preventDefault(); + sendPtz("STOP"); + }, + [sendPtz], + ); + + useKeyboardListener( + ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "-"], + (key, down, repeat) => { + if (repeat) { + return; + } + + if (!down) { + sendPtz("STOP"); + return; + } + + switch (key) { + case "ArrowLeft": + sendPtz("MOVE_LEFT"); + break; + case "ArrowRight": + sendPtz("MOVE_RIGHT"); + break; + case "ArrowUp": + sendPtz("MOVE_UP"); + break; + case "ArrowDown": + sendPtz("MOVE_DOWN"); + break; + case "+": + sendPtz("ZOOM_IN"); + break; + case "-": + sendPtz("ZOOM_OUT"); + break; + } + }, + ); + + return ( +
+ {ptz?.features?.includes("pt") && ( + <> + + + + + + )} + {ptz?.features?.includes("zoom") && ( + <> + + + + )} + {(ptz?.presets?.length ?? 0) > 0 && ( + + + + + + {ptz?.presets.map((preset) => { + return ( + sendPtz(`preset_${preset}`)}> + {preset} + + ); + })} + + + )} +
+ ); +} diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx new file mode 100644 index 000000000..ba60fadcf --- /dev/null +++ b/web/src/views/live/LiveDashboardView.tsx @@ -0,0 +1,143 @@ +import { useFrigateReviews } from "@/api/ws"; +import Logo from "@/components/Logo"; +import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail"; +import LivePlayer from "@/components/player/LivePlayer"; +import { Button } from "@/components/ui/button"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { usePersistence } from "@/hooks/use-persistence"; +import { CameraConfig } from "@/types/frigateConfig"; +import { ReviewSegment } from "@/types/review"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { isDesktop, isMobile, isSafari } from "react-device-detect"; +import { CiGrid2H, CiGrid31 } from "react-icons/ci"; +import useSWR from "swr"; + +type LiveDashboardViewProps = { + cameras: CameraConfig[]; + onSelectCamera: (camera: string) => void; +}; +export default function LiveDashboardView({ + cameras, + onSelectCamera, +}: LiveDashboardViewProps) { + // layout + + const [layout, setLayout] = usePersistence<"grid" | "list">( + "live-layout", + isDesktop ? "grid" : "list", + ); + + // recent events + const { payload: eventUpdate } = useFrigateReviews(); + const { data: allEvents, mutate: updateEvents } = useSWR([ + "review", + { limit: 10, severity: "alert" }, + ]); + + useEffect(() => { + if (!eventUpdate) { + return; + } + + // if event is ended and was saved, update events list + if (eventUpdate.type == "end" && eventUpdate.review.severity == "alert") { + updateEvents(); + return; + } + }, [eventUpdate, updateEvents]); + + const events = useMemo(() => { + if (!allEvents) { + return []; + } + + const date = new Date(); + date.setHours(date.getHours() - 1); + const cutoff = date.getTime() / 1000; + return allEvents.filter((event) => event.start_time > cutoff); + }, [allEvents]); + + // camera live views + + const [windowVisible, setWindowVisible] = useState(true); + const visibilityListener = useCallback(() => { + setWindowVisible(document.visibilityState == "visible"); + }, []); + + useEffect(() => { + addEventListener("visibilitychange", visibilityListener); + + return () => { + removeEventListener("visibilitychange", visibilityListener); + }; + }, [visibilityListener]); + + return ( +
+ {isMobile && ( +
+ +
+
+ + +
+
+ )} + + {events && events.length > 0 && ( + + +
+ {events.map((event) => { + return ; + })} +
+
+ +
+ )} + +
+ {cameras.map((camera) => { + let grow; + const aspectRatio = camera.detect.width / camera.detect.height; + if (aspectRatio > 2) { + grow = `${layout == "grid" ? "col-span-2" : ""} aspect-wide`; + } else if (aspectRatio < 1) { + grow = `${layout == "grid" ? "row-span-2 aspect-tall md:h-full" : ""} aspect-tall`; + } else { + grow = "aspect-video"; + } + return ( + onSelectCamera(camera.name)} + /> + ); + })} +
+
+ ); +}