Show camera dashboard

This commit is contained in:
Nick Mowen 2023-12-15 13:05:14 -07:00
parent 573c08c46c
commit 2241c85a85
6 changed files with 119 additions and 65 deletions

View File

@ -32,7 +32,7 @@ function App() {
<div id="pageRoot" className="overflow-x-hidden px-4 py-2 w-screen md:w-full"> <div id="pageRoot" className="overflow-x-hidden px-4 py-2 w-screen md:w-full">
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/live" element={<Live />} /> <Route path="/live/:camera?" element={<Live />} />
<Route path="/history" element={<History />} /> <Route path="/history" element={<History />} />
<Route path="/export" element={<Export />} /> <Route path="/export" element={<Export />} />
<Route path="/storage" element={<Storage />} /> <Route path="/storage" element={<Storage />} />

View File

@ -7,8 +7,9 @@ import { useResizeObserver } from "@/hooks/resize-observer";
type CameraImageProps = { type CameraImageProps = {
camera: string; camera: string;
onload?: (event: Event) => void; onload?: (event: Event) => void;
searchParams: {}; searchParams?: {};
stretch?: boolean; stretch?: boolean; // stretch to fit width
fitAspect?: number; // shrink to fit height
}; };
export default function CameraImage({ export default function CameraImage({
@ -16,13 +17,15 @@ export default function CameraImage({
onload, onload,
searchParams = "", searchParams = "",
stretch = false, stretch = false,
fitAspect,
}: CameraImageProps) { }: CameraImageProps) {
const { data: config } = useSWR("config"); const { data: config } = useSWR("config");
const apiHost = useApiHost(); const apiHost = useApiHost();
const [hasLoaded, setHasLoaded] = useState(false); const [hasLoaded, setHasLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [{ width: containerWidth }] = useResizeObserver(containerRef); const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
// Add scrollbar width (when visible) to the available observer width to eliminate screen juddering. // Add scrollbar width (when visible) to the available observer width to eliminate screen juddering.
// https://github.com/blakeblackshear/frigate/issues/1657 // https://github.com/blakeblackshear/frigate/issues/1657
@ -42,8 +45,9 @@ export default function CameraImage({
const aspectRatio = width / height; const aspectRatio = width / height;
const scaledHeight = useMemo(() => { const scaledHeight = useMemo(() => {
const scaledHeight = Math.floor(availableWidth / aspectRatio); const scaledHeight = (aspectRatio < (fitAspect ?? 0)) ? Math.floor(containerHeight) : Math.floor(availableWidth / aspectRatio);
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height); const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height);
console.log("returning " + containerHeight + " for " + camera)
if (finalHeight > 0) { if (finalHeight > 0) {
return finalHeight; return finalHeight;
@ -79,7 +83,7 @@ export default function CameraImage({
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]); }, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
return ( return (
<div className="relative w-full" ref={containerRef}> <div className={`relative w-full ${fitAspect && aspectRatio < fitAspect ? 'h-full flex justify-center' : ''}`} ref={containerRef}>
{enabled ? ( {enabled ? (
<canvas <canvas
data-testid="cameraimage-canvas" data-testid="cameraimage-canvas"

View File

@ -32,7 +32,7 @@ export default function HistoryCard({
return ( return (
<Card <Card
className="cursor-pointer my-2 xs:mr-2 bg-secondary w-full xs:w-[48%] sm:w-[284px]" className="cursor-pointer my-2 xs:mr-2 w-full xs:w-[48%] sm:w-[284px]"
onClick={onClick} onClick={onClick}
> >
<PreviewThumbnailPlayer <PreviewThumbnailPlayer

View File

@ -1,79 +1,125 @@
import { useState } from "react"; import { useMemo } from "react";
import ActivityIndicator from "@/components/ui/activity-indicator"; import ActivityIndicator from "@/components/ui/activity-indicator";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { import {
Select, useAudioState,
SelectContent, useDetectState,
SelectGroup, useRecordingsState,
SelectItem, useSnapshotsState,
SelectTrigger, } from "@/api/ws";
SelectValue,
} from "@/components/ui/select";
import { useDetectState } from "@/api/ws";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { Card } from "@/components/ui/card";
import CameraImage from "@/components/camera/CameraImage";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { AiOutlinePicture } from "react-icons/ai";
import { FaWalking } from "react-icons/fa";
import { LuEar } from "react-icons/lu";
import { TbMovie } from "react-icons/tb";
export function Dashboard() { export function Dashboard() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [selectedCamera, setSelectedCamera] = useState<string | undefined>(
undefined
);
let cameras; const sortedCameras = useMemo(() => {
if (config?.cameras) { if (!config) {
cameras = Object.keys(config.cameras).map((name) => ( return [];
<div key={name}> }
<SelectItem value={name} onClick={() => setSelectedCamera(name)}>
{name} return Object.values(config.cameras)
</SelectItem> .filter((conf) => conf.ui.dashboard)
</div> .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
)); }, [config]);
}
return ( return (
<> <>
<Heading as="h2">Dashboard</Heading>
{!config && <ActivityIndicator />} {!config && <ActivityIndicator />}
<Heading as="h2">Components testing</Heading> {config && (
<div>
<div className="flex items-center space-x-2 mt-5"> <div className="grid gap-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<Select {sortedCameras.map((camera) => {
value={selectedCamera} return <Camera key={camera.name} camera={camera} />;
onValueChange={(val) => setSelectedCamera(val as string)} })}
> </div>
<SelectTrigger className="w-[180px]"> </div>
<SelectValue placeholder="Choose camera" /> )}
</SelectTrigger>
<SelectContent>
<SelectGroup>{cameras}</SelectGroup>
</SelectContent>
</Select>
</div>
{selectedCamera && <Camera cameraName={selectedCamera} />}
</> </>
); );
} }
function Camera({ cameraName }: { cameraName: string }) { function Camera({ camera }: { camera: CameraConfig }) {
const { payload: detectValue, send: sendDetect } = useDetectState(cameraName); const { payload: detectValue, send: sendDetect } = useDetectState(
camera.name
);
const { payload: recordValue, send: sendRecord } = useRecordingsState(
camera.name
);
const { payload: snapshotValue, send: sendSnapshot } = useSnapshotsState(
camera.name
);
const { payload: audioValue, send: sendAudio } = useAudioState(camera.name);
return ( return (
<> <>
<Heading as="h3" className="mt-5"> <Card className="">
{cameraName} <a href={`/live/${camera.name}`}>
</Heading> <AspectRatio
<div className="flex items-center space-x-2 mt-5"> ratio={16 / 9}
<Switch className="bg-black flex justify-center items-center"
id={`detect-${cameraName}`} >
checked={detectValue === "ON"} <CameraImage camera={camera.name} fitAspect={16 / 9} />
onCheckedChange={() => </AspectRatio>
sendDetect(detectValue === "ON" ? "OFF" : "ON", true) <div className="flex justify-between items-center">
} <Heading className="capitalize p-2" as="h4">
/> {camera.name.replaceAll("_", " ")}
<Label htmlFor={`detect-${cameraName}`}>Detect</Label> </Heading>
</div> <div>
<Button
variant="ghost"
className={`${
detectValue == "ON" ? "text-primary" : "text-gray-400"
}`}
onClick={() => sendDetect(detectValue == "ON" ? "OFF" : "ON")}
>
<FaWalking />
</Button>
<Button
variant="ghost"
className={
camera.record.enabled_in_config
? recordValue == "ON"
? "text-primary"
: "text-gray-400"
: "text-red-500"
}
>
<TbMovie />
</Button>
<Button
variant="ghost"
className={`${
snapshotValue == "ON" ? "text-primary" : "text-gray-400"
}`}
>
<AiOutlinePicture />
</Button>
{camera.audio.enabled_in_config && (
<Button
variant="ghost"
className={`${
audioValue == "ON" ? "text-primary" : "text-gray-400"
}`}
>
<LuEar />
</Button>
)}
</div>
</div>
</a>
</Card>
</> </>
); );
} }

View File

@ -13,12 +13,16 @@ import Heading from "@/components/ui/heading";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
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 { camera: openedCamera } = useParams();
const [camera, setCamera] = useState<string>("Select A Camera"); const [camera, setCamera] = useState<string>(
openedCamera ?? "Select A Camera"
);
const cameraConfig = useMemo(() => { const cameraConfig = useMemo(() => {
return config?.cameras[camera]; return config?.cameras[camera];
}, [camera, config]); }, [camera, config]);

View File

@ -25,7 +25,7 @@
.theme-blue.dark { .theme-blue.dark {
--background: 222.2 84% 4.9%; --background: 222.2 84% 4.9%;
--foreground: 210 40% 98%; --foreground: 210 40% 98%;
--card: 222.2 84% 4.9%; --card: 217.2 32.6% 17.5%;
--card-foreground: 210 40% 98%; --card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%; --popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%; --popover-foreground: 210 40% 98%;