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>
+  );
+}