From 5028a9632e03262c8adb5a4e7afdf5f995b47f87 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen <nickmowen213@gmail.com> Date: Fri, 1 Mar 2024 17:43:02 -0700 Subject: [PATCH] Individual live view (#10178) * Get live camera view working * Get ptz working * Add button for ptz presets * Add camera feature buttons * Add button for camera audio * Cleanup * Cleanup mobile live * Only use landscape check on mobile --- web/src/App.tsx | 2 +- .../dynamic/CameraFeatureToggle.tsx | 60 ++++ web/src/components/player/LivePlayer.tsx | 15 +- web/src/components/player/MsePlayer.tsx | 4 +- web/src/components/player/WebRTCPlayer.tsx | 4 +- web/src/pages/Live.tsx | 142 +------- web/src/types/ptz.ts | 7 + web/src/views/live/LiveCameraView.tsx | 330 ++++++++++++++++++ web/src/views/live/LiveDashboardView.tsx | 143 ++++++++ 9 files changed, 575 insertions(+), 132 deletions(-) create mode 100644 web/src/components/dynamic/CameraFeatureToggle.tsx create mode 100644 web/src/types/ptz.ts create mode 100644 web/src/views/live/LiveCameraView.tsx create mode 100644 web/src/views/live/LiveDashboardView.tsx 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 && <Bottombar />} <div id="pageRoot" - className="absolute left-0 top-2 right-0 bottom-16 md:left-16 md:bottom-8 overflow-hidden" + className={`absolute top-2 right-0 overflow-hidden ${isMobile ? "left-0 bottom-16" : "left-16 bottom-8"}`} > <Routes> <Route path="/" element={<Live />} /> 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 = ( + <div + onClick={onClick} + className={`${className} flex flex-col justify-center items-center rounded-lg ${ + variants[variant][isActive ? "active" : "inactive"] + }`} + > + <Icon className="size-5 md:m-[6px]" /> + </div> + ); + + if (isDesktop) { + return ( + <Tooltip> + <TooltipTrigger>{content}</TooltipTrigger> + <TooltipContent side="bottom"> + <p>{title}</p> + </TooltipContent> + </Tooltip> + ); + } + + 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 ( <div - className={`relative flex justify-center w-full outline ${ + className={`relative flex justify-center w-full outline cursor-pointer ${ activeTracking ? "outline-severity_alert outline-1 rounded-2xl shadow-[0_0_6px_2px] shadow-severity_alert" : "outline-0 outline-background" } transition-all duration-500 ${className}`} + onClick={onClick} > <div className="absolute top-0 inset-x-0 rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none"></div> <div className="absolute bottom-0 inset-x-0 rounded-2xl z-10 w-full h-[10%] bg-gradient-to-t from-black/20 to-transparent pointer-events-none"></div> 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<FrigateConfig>("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<ReviewSegment[]>([ - "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 <LiveCameraView camera={selectedCamera} />; + } return ( - <div className="size-full overflow-y-scroll px-2"> - {isMobile && ( - <div className="relative h-9 flex items-center justify-between"> - <Logo className="absolute inset-y-0 inset-x-1/2 -translate-x-1/2 h-8" /> - <div /> - <div className="flex items-center gap-1"> - <Button - className={layout == "grid" ? "text-blue-600 bg-blue-200" : ""} - size="xs" - variant="secondary" - onClick={() => setLayout("grid")} - > - <CiGrid31 className="m-1" /> - </Button> - <Button - className={layout == "list" ? "text-blue-600 bg-blue-200" : ""} - size="xs" - variant="secondary" - onClick={() => setLayout("list")} - > - <CiGrid2H className="m-1" /> - </Button> - </div> - </div> - )} - - {events && events.length > 0 && ( - <ScrollArea> - <TooltipProvider> - <div className="flex"> - {events.map((event) => { - return <AnimatedEventThumbnail key={event.id} event={event} />; - })} - </div> - </TooltipProvider> - <ScrollBar orientation="horizontal" /> - </ScrollArea> - )} - - <div - className={`mt-4 grid ${layout == "grid" ? "grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" : ""} gap-2 md:gap-4 *:rounded-2xl *:bg-black`} - > - {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 ( - <LivePlayer - key={camera.name} - className={grow} - windowVisible={windowVisible} - cameraConfig={camera} - preferredLiveMode={isSafari ? "webrtc" : "mse"} - /> - ); - })} - </div> - </div> + <LiveDashboardView + cameras={cameras} + onSelectCamera={setSelectedCameraName} + /> ); } 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 ( + <div + className={`size-full flex flex-col ${isMobile ? "landscape:flex-row" : ""}`} + > + <div + className={`w-full h-12 flex flex-row items-center justify-between ${isMobile ? "landscape:w-min landscape:h-full landscape:flex-col" : ""}`} + > + <Button + className={`rounded-lg ${isMobile ? "ml-2" : "ml-0"}`} + size={isMobile ? "icon" : "default"} + onClick={() => navigate(-1)} + > + <IoMdArrowBack className="size-5 lg:mr-[10px]" /> + {isDesktop && "Back"} + </Button> + <TooltipProvider> + <div + className={`flex flex-row items-center gap-1 mr-1 *:rounded-lg ${isMobile ? "landscape:flex-col" : ""}`} + > + <CameraFeatureToggle + className="p-2 md:p-0" + Icon={audio ? GiSpeaker : GiSpeakerOff} + isActive={audio} + title={`${audio ? "Disable" : "Enable"} Camera Audio`} + onClick={() => setAudio(!audio)} + /> + <CameraFeatureToggle + className="p-2 md:p-0" + Icon={detectState == "ON" ? MdPersonSearch : MdPersonOff} + isActive={detectState == "ON"} + title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`} + onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")} + /> + <CameraFeatureToggle + className="p-2 md:p-0" + Icon={recordState == "ON" ? LuVideo : LuVideoOff} + isActive={recordState == "ON"} + title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`} + onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")} + /> + <CameraFeatureToggle + className="p-2 md:p-0" + Icon={snapshotState == "ON" ? MdPhotoCamera : MdNoPhotography} + isActive={snapshotState == "ON"} + title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`} + onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} + /> + {camera.audio.enabled_in_config && ( + <CameraFeatureToggle + className="p-2 md:p-0" + Icon={audioState == "ON" ? LuEar : LuEarOff} + isActive={audioState == "ON"} + title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`} + onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")} + /> + )} + </div> + </TooltipProvider> + </div> + + <div className="relative size-full"> + <div + className={growClassName} + style={{ aspectRatio: camera.detect.width / camera.detect.height }} + > + <LivePlayer + key={camera.name} + className="size-full" + windowVisible + showStillWithoutActivity={false} + cameraConfig={camera} + playAudio={audio} + preferredLiveMode={isSafari ? "webrtc" : "mse"} + /> + </div> + {camera.onvif.host != "" && <PtzControlPanel camera={camera.name} />} + </div> + </div> + ); +} + +function PtzControlPanel({ camera }: { camera: string }) { + const { data: ptz } = useSWR<CameraPtzInfo>(`${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 ( + <div className="absolute left-[50%] -translate-x-[50%] bottom-[10%] flex items-center gap-1"> + {ptz?.features?.includes("pt") && ( + <> + <Button + onMouseDown={(e) => { + e.preventDefault(); + sendPtz("MOVE_LEFT"); + }} + onTouchStart={(e) => { + e.preventDefault(); + sendPtz("MOVE_LEFT"); + }} + onMouseUp={onStop} + onTouchEnd={onStop} + > + <FaAngleLeft /> + </Button> + <Button + onMouseDown={(e) => { + e.preventDefault(); + sendPtz("MOVE_UP"); + }} + onTouchStart={(e) => { + e.preventDefault(); + sendPtz("MOVE_UP"); + }} + onMouseUp={onStop} + onTouchEnd={onStop} + > + <FaAngleUp /> + </Button> + <Button + onMouseDown={(e) => { + e.preventDefault(); + sendPtz("MOVE_DOWN"); + }} + onTouchStart={(e) => { + e.preventDefault(); + sendPtz("MOVE_DOWN"); + }} + onMouseUp={onStop} + onTouchEnd={onStop} + > + <FaAngleDown /> + </Button> + <Button + onMouseDown={(e) => { + e.preventDefault(); + sendPtz("MOVE_RIGHT"); + }} + onTouchStart={(e) => { + e.preventDefault(); + sendPtz("MOVE_RIGHT"); + }} + onMouseUp={onStop} + onTouchEnd={onStop} + > + <FaAngleRight /> + </Button> + </> + )} + {ptz?.features?.includes("zoom") && ( + <> + <Button + onMouseDown={(e) => { + e.preventDefault(); + sendPtz("ZOOM_IN"); + }} + onTouchStart={(e) => { + e.preventDefault(); + sendPtz("ZOOM_IN"); + }} + onMouseUp={onStop} + onTouchEnd={onStop} + > + <MdZoomIn /> + </Button> + <Button + onMouseDown={(e) => { + e.preventDefault(); + sendPtz("ZOOM_OUT"); + }} + onTouchStart={(e) => { + e.preventDefault(); + sendPtz("ZOOM_OUT"); + }} + onMouseUp={onStop} + onTouchEnd={onStop} + > + <MdZoomOut /> + </Button> + </> + )} + {(ptz?.presets?.length ?? 0) > 0 && ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button> + <BsThreeDotsVertical /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent> + {ptz?.presets.map((preset) => { + return ( + <DropdownMenuItem onSelect={() => sendPtz(`preset_${preset}`)}> + {preset} + </DropdownMenuItem> + ); + })} + </DropdownMenuContent> + </DropdownMenu> + )} + </div> + ); +} 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<ReviewSegment[]>([ + "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 ( + <div className="size-full overflow-y-scroll px-2"> + {isMobile && ( + <div className="relative h-9 flex items-center justify-between"> + <Logo className="absolute inset-y-0 inset-x-1/2 -translate-x-1/2 h-8" /> + <div /> + <div className="flex items-center gap-1"> + <Button + className={layout == "grid" ? "text-blue-600 bg-blue-200" : ""} + size="xs" + variant="secondary" + onClick={() => setLayout("grid")} + > + <CiGrid31 className="m-1" /> + </Button> + <Button + className={layout == "list" ? "text-blue-600 bg-blue-200" : ""} + size="xs" + variant="secondary" + onClick={() => setLayout("list")} + > + <CiGrid2H className="m-1" /> + </Button> + </div> + </div> + )} + + {events && events.length > 0 && ( + <ScrollArea> + <TooltipProvider> + <div className="flex"> + {events.map((event) => { + return <AnimatedEventThumbnail key={event.id} event={event} />; + })} + </div> + </TooltipProvider> + <ScrollBar orientation="horizontal" /> + </ScrollArea> + )} + + <div + className={`mt-4 grid ${layout == "grid" ? "grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" : ""} gap-2 md:gap-4 *:rounded-2xl *:bg-black`} + > + {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 ( + <LivePlayer + key={camera.name} + className={grow} + windowVisible={windowVisible} + cameraConfig={camera} + preferredLiveMode={isSafari ? "webrtc" : "mse"} + onClick={() => onSelectCamera(camera.name)} + /> + ); + })} + </div> + </div> + ); +}