mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-07 19:55:26 +03:00
Show camera dashboard
This commit is contained in:
parent
573c08c46c
commit
2241c85a85
@ -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 />} />
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
@ -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%;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user