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
This commit is contained in:
Nicolas Mowen 2024-03-01 17:43:02 -07:00 committed by GitHub
parent a67e970fca
commit 5028a9632e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 575 additions and 132 deletions

View File

@ -27,7 +27,7 @@ function App() {
{isMobile && <Bottombar />} {isMobile && <Bottombar />}
<div <div
id="pageRoot" 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> <Routes>
<Route path="/" element={<Live />} /> <Route path="/" element={<Live />} />

View File

@ -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;
}

View File

@ -20,6 +20,8 @@ type LivePlayerProps = {
preferredLiveMode?: LivePlayerMode; preferredLiveMode?: LivePlayerMode;
showStillWithoutActivity?: boolean; showStillWithoutActivity?: boolean;
windowVisible?: boolean; windowVisible?: boolean;
playAudio?: boolean;
onClick?: () => void;
}; };
export default function LivePlayer({ export default function LivePlayer({
@ -28,6 +30,8 @@ export default function LivePlayer({
preferredLiveMode, preferredLiveMode,
showStillWithoutActivity = true, showStillWithoutActivity = true,
windowVisible = true, windowVisible = true,
playAudio = false,
onClick,
}: LivePlayerProps) { }: LivePlayerProps) {
// camera activity // camera activity
@ -35,8 +39,10 @@ export default function LivePlayer({
useCameraActivity(cameraConfig); useCameraActivity(cameraConfig);
const cameraActive = useMemo( const cameraActive = useMemo(
() => windowVisible && (activeMotion || activeTracking), () =>
[activeMotion, activeTracking, windowVisible], !showStillWithoutActivity ||
(windowVisible && (activeMotion || activeTracking)),
[activeMotion, activeTracking, showStillWithoutActivity, windowVisible],
); );
// camera live state // camera live state
@ -91,6 +97,7 @@ export default function LivePlayer({
className={`rounded-2xl h-full ${liveReady ? "" : "hidden"}`} className={`rounded-2xl h-full ${liveReady ? "" : "hidden"}`}
camera={cameraConfig.live.stream_name} camera={cameraConfig.live.stream_name}
playbackEnabled={cameraActive} playbackEnabled={cameraActive}
audioEnabled={playAudio}
onPlaying={() => setLiveReady(true)} onPlaying={() => setLiveReady(true)}
/> />
); );
@ -101,6 +108,7 @@ export default function LivePlayer({
className={`rounded-2xl h-full ${liveReady ? "" : "hidden"}`} className={`rounded-2xl h-full ${liveReady ? "" : "hidden"}`}
camera={cameraConfig.name} camera={cameraConfig.name}
playbackEnabled={cameraActive} playbackEnabled={cameraActive}
audioEnabled={playAudio}
onPlaying={() => setLiveReady(true)} onPlaying={() => setLiveReady(true)}
/> />
); );
@ -127,11 +135,12 @@ export default function LivePlayer({
return ( return (
<div <div
className={`relative flex justify-center w-full outline ${ className={`relative flex justify-center w-full outline cursor-pointer ${
activeTracking activeTracking
? "outline-severity_alert outline-1 rounded-2xl shadow-[0_0_6px_2px] shadow-severity_alert" ? "outline-severity_alert outline-1 rounded-2xl shadow-[0_0_6px_2px] shadow-severity_alert"
: "outline-0 outline-background" : "outline-0 outline-background"
} transition-all duration-500 ${className}`} } 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 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> <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>

View File

@ -5,6 +5,7 @@ type MSEPlayerProps = {
camera: string; camera: string;
className?: string; className?: string;
playbackEnabled?: boolean; playbackEnabled?: boolean;
audioEnabled?: boolean;
onPlaying?: () => void; onPlaying?: () => void;
}; };
@ -12,6 +13,7 @@ function MSEPlayer({
camera, camera,
className, className,
playbackEnabled = true, playbackEnabled = true,
audioEnabled = false,
onPlaying, onPlaying,
}: MSEPlayerProps) { }: MSEPlayerProps) {
let connectTS: number = 0; let connectTS: number = 0;
@ -273,7 +275,7 @@ function MSEPlayer({
playsInline playsInline
preload="auto" preload="auto"
onLoadedData={onPlaying} onLoadedData={onPlaying}
muted muted={!audioEnabled}
/> />
); );
} }

View File

@ -5,6 +5,7 @@ type WebRtcPlayerProps = {
className?: string; className?: string;
camera: string; camera: string;
playbackEnabled?: boolean; playbackEnabled?: boolean;
audioEnabled?: boolean;
onPlaying?: () => void; onPlaying?: () => void;
}; };
@ -12,6 +13,7 @@ export default function WebRtcPlayer({
className, className,
camera, camera,
playbackEnabled = true, playbackEnabled = true,
audioEnabled = false,
onPlaying, onPlaying,
}: WebRtcPlayerProps) { }: WebRtcPlayerProps) {
// camera states // camera states
@ -160,7 +162,7 @@ export default function WebRtcPlayer({
className={className} className={className}
autoPlay autoPlay
playsInline playsInline
muted muted={!audioEnabled}
onLoadedData={onPlaying} onLoadedData={onPlaying}
/> />
); );

View File

@ -1,59 +1,13 @@
import { useFrigateReviews } from "@/api/ws"; import useOverlayState from "@/hooks/use-overlay-state";
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 { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review"; import LiveCameraView from "@/views/live/LiveCameraView";
import { useCallback, useEffect, useMemo, useState } from "react"; import LiveDashboardView from "@/views/live/LiveDashboardView";
import { isDesktop, isMobile, isSafari } from "react-device-detect"; import { useMemo } from "react";
import { CiGrid2H, CiGrid31 } from "react-icons/ci";
import useSWR from "swr"; import useSWR from "swr";
function Live() { function Live() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [selectedCameraName, setSelectedCameraName] = useOverlayState("camera");
// 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 cameras = useMemo(() => { const cameras = useMemo(() => {
if (!config) { if (!config) {
@ -65,84 +19,20 @@ function Live() {
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]); }, [config]);
const [windowVisible, setWindowVisible] = useState(true); const selectedCamera = useMemo(
const visibilityListener = useCallback(() => { () => cameras.find((cam) => cam.name == selectedCameraName),
setWindowVisible(document.visibilityState == "visible"); [cameras, selectedCameraName],
}, []);
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"}
/>
); );
})}
</div> if (selectedCamera) {
</div> return <LiveCameraView camera={selectedCamera} />;
}
return (
<LiveDashboardView
cameras={cameras}
onSelectCamera={setSelectedCameraName}
/>
); );
} }

7
web/src/types/ptz.ts Normal file
View File

@ -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[];
};

View File

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

View File

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