mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
Camera dashboard (#8981)
* Show camera dashboard * Cleanup * Cleanup * Cleanup * Cleanup * fix * Fix input color * Show recent events on dashboard
This commit is contained in:
parent
3a33090984
commit
a2c6f45454
@ -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,7 +45,10 @@ 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);
|
||||||
|
|
||||||
if (finalHeight > 0) {
|
if (finalHeight > 0) {
|
||||||
@ -79,7 +85,12 @@ 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
|
||||||
|
83
web-new/src/components/card/MiniEventCard.tsx
Normal file
83
web-new/src/components/card/MiniEventCard.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { useApiHost } from "@/api";
|
||||||
|
import { Card } from "../ui/card";
|
||||||
|
import { Event as FrigateEvent } from "@/types/event";
|
||||||
|
import { LuClock, LuStar } from "react-icons/lu";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import TimeAgo from "../dynamic/TimeAgo";
|
||||||
|
import { HiOutlineVideoCamera } from "react-icons/hi";
|
||||||
|
import { MdOutlineLocationOn } from "react-icons/md";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
type MiniEventCardProps = {
|
||||||
|
event: FrigateEvent;
|
||||||
|
onUpdate?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MiniEventCard({ event, onUpdate }: MiniEventCardProps) {
|
||||||
|
const baseUrl = useApiHost();
|
||||||
|
const onSave = useCallback(
|
||||||
|
async (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
let response;
|
||||||
|
if (!event.retain_indefinitely) {
|
||||||
|
response = await axios.post(`events/${event.id}/retain`);
|
||||||
|
} else {
|
||||||
|
response = await axios.delete(`events/${event.id}/retain`);
|
||||||
|
}
|
||||||
|
if (response.status === 200 && onUpdate) {
|
||||||
|
onUpdate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[event]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mr-2 min-w-[260px] max-w-[320px]">
|
||||||
|
<div className="flex">
|
||||||
|
<div
|
||||||
|
className="relative rounded-l min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${baseUrl}api/events/${event.id}/thumbnail.jpg)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuStar
|
||||||
|
className="h-6 w-6 text-yellow-300 absolute top-1 right-1 cursor-pointer"
|
||||||
|
onClick={(e: Event) => onSave(e)}
|
||||||
|
fill={event.retain_indefinitely ? "currentColor" : "none"}
|
||||||
|
/>
|
||||||
|
{event.end_time ? null : (
|
||||||
|
<div className="bg-slate-300 dark:bg-slate-700 absolute bottom-0 text-center w-full uppercase text-sm rounded-bl">
|
||||||
|
In progress
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-1 flex flex-col justify-between">
|
||||||
|
<div className="capitalize text-lg font-bold">
|
||||||
|
{event.label.replaceAll("_", " ")}
|
||||||
|
{event.sub_label
|
||||||
|
? `: ${event.sub_label.replaceAll("_", " ")}`
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm flex">
|
||||||
|
<LuClock className="h-4 w-4 mr-2 inline" />
|
||||||
|
<div className="hidden sm:inline">
|
||||||
|
<TimeAgo time={event.start_time * 1000} dense />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="capitalize text-sm flex align-center mt-1 whitespace-nowrap">
|
||||||
|
<HiOutlineVideoCamera className="h-4 w-4 mr-2 inline" />
|
||||||
|
{event.camera.replaceAll("_", " ")}
|
||||||
|
</div>
|
||||||
|
{event.zones.length ? (
|
||||||
|
<div className="capitalize text-sm flex align-center">
|
||||||
|
<MdOutlineLocationOn className="w-4 h-4 mr-2 inline" />
|
||||||
|
{event.zones.join(", ").replaceAll("_", " ")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
83
web-new/src/components/dynamic/TimeAgo.tsx
Normal file
83
web-new/src/components/dynamic/TimeAgo.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { FunctionComponent, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
interface IProp {
|
||||||
|
/** The time to calculate time-ago from */
|
||||||
|
time: number;
|
||||||
|
/** OPTIONAL: overwrite current time */
|
||||||
|
currentTime?: Date;
|
||||||
|
/** OPTIONAL: boolean that determines whether to show the time-ago text in dense format */
|
||||||
|
dense?: boolean;
|
||||||
|
/** OPTIONAL: set custom refresh interval in milliseconds, default 1000 (1 sec) */
|
||||||
|
refreshInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeUnit = {
|
||||||
|
unit: string;
|
||||||
|
full: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeAgo = ({ time, currentTime = new Date(), dense = false }: IProp): string => {
|
||||||
|
if (typeof time !== 'number' || time < 0) return 'Invalid Time Provided';
|
||||||
|
|
||||||
|
const pastTime: Date = new Date(time);
|
||||||
|
const elapsedTime: number = currentTime.getTime() - pastTime.getTime();
|
||||||
|
|
||||||
|
const timeUnits: TimeUnit[] = [
|
||||||
|
{ unit: 'yr', full: 'year', value: 31536000 },
|
||||||
|
{ unit: 'mo', full: 'month', value: 0 },
|
||||||
|
{ unit: 'd', full: 'day', value: 86400 },
|
||||||
|
{ unit: 'h', full: 'hour', value: 3600 },
|
||||||
|
{ unit: 'm', full: 'minute', value: 60 },
|
||||||
|
{ unit: 's', full: 'second', value: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const elapsed: number = elapsedTime / 1000;
|
||||||
|
if (elapsed < 10) {
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < timeUnits.length; i++) {
|
||||||
|
// if months
|
||||||
|
if (i === 1) {
|
||||||
|
// Get the month and year for the time provided
|
||||||
|
const pastMonth = pastTime.getUTCMonth();
|
||||||
|
const pastYear = pastTime.getUTCFullYear();
|
||||||
|
|
||||||
|
// get current month and year
|
||||||
|
const currentMonth = currentTime.getUTCMonth();
|
||||||
|
const currentYear = currentTime.getUTCFullYear();
|
||||||
|
|
||||||
|
let monthDiff = (currentYear - pastYear) * 12 + (currentMonth - pastMonth);
|
||||||
|
|
||||||
|
// check if the time provided is the previous month but not exceeded 1 month ago.
|
||||||
|
if (currentTime.getUTCDate() < pastTime.getUTCDate()) {
|
||||||
|
monthDiff--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monthDiff > 0) {
|
||||||
|
const unitAmount = monthDiff;
|
||||||
|
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`;
|
||||||
|
}
|
||||||
|
} else if (elapsed >= timeUnits[i].value) {
|
||||||
|
const unitAmount: number = Math.floor(elapsed / timeUnits[i].value);
|
||||||
|
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Invalid Time';
|
||||||
|
};
|
||||||
|
|
||||||
|
const TimeAgo: FunctionComponent<IProp> = ({ refreshInterval = 1000, ...rest }): JSX.Element => {
|
||||||
|
const [currentTime, setCurrentTime] = useState<Date>(new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId: NodeJS.Timeout = setInterval(() => {
|
||||||
|
setCurrentTime(new Date());
|
||||||
|
}, refreshInterval);
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [refreshInterval]);
|
||||||
|
|
||||||
|
const timeAgoValue = useMemo(() => timeAgo({ currentTime, ...rest }), [currentTime, rest]);
|
||||||
|
|
||||||
|
return <span>{timeAgoValue}</span>;
|
||||||
|
};
|
||||||
|
export default TimeAgo;
|
@ -8,7 +8,7 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||||
import { Switch } from "../ui/switch";
|
import { Switch } from "../ui/switch";
|
||||||
import { Label } from "../ui/label";
|
import { Label } from "../ui/label";
|
||||||
import { usePersistence } from "@/context/use-persistence";
|
import { usePersistence } from "@/hooks/use-persistence";
|
||||||
|
|
||||||
const emptyObject = Object.freeze({});
|
const emptyObject = Object.freeze({});
|
||||||
|
|
||||||
|
@ -1,79 +1,169 @@
|
|||||||
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";
|
||||||
|
import MiniEventCard from "@/components/card/MiniEventCard";
|
||||||
|
import { Event } from "@/types/event";
|
||||||
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
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 recentTimestamp = useMemo(() => {
|
||||||
if (config?.cameras) {
|
const now = new Date();
|
||||||
cameras = Object.keys(config.cameras).map((name) => (
|
now.setMinutes(now.getMinutes() - 30);
|
||||||
<div key={name}>
|
return now.getTime() / 1000;
|
||||||
<SelectItem value={name} onClick={() => setSelectedCamera(name)}>
|
}, []);
|
||||||
{name}
|
const { data: events, mutate: updateEvents } = useSWR<Event[]>([
|
||||||
</SelectItem>
|
"events",
|
||||||
</div>
|
{ limit: 10, after: recentTimestamp },
|
||||||
));
|
]);
|
||||||
}
|
|
||||||
|
const sortedCameras = useMemo(() => {
|
||||||
|
if (!config) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(config.cameras)
|
||||||
|
.filter((conf) => conf.ui.dashboard)
|
||||||
|
.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">
|
{events && events.length > 0 && (
|
||||||
<Select
|
<>
|
||||||
value={selectedCamera}
|
<Heading as="h4">Recent Events</Heading>
|
||||||
onValueChange={(val) => setSelectedCamera(val as string)}
|
<ScrollArea>
|
||||||
>
|
<div className="flex">
|
||||||
<SelectTrigger className="w-[180px]">
|
{events.map((event) => {
|
||||||
<SelectValue placeholder="Choose camera" />
|
return (
|
||||||
</SelectTrigger>
|
<MiniEventCard
|
||||||
<SelectContent>
|
key={event.id}
|
||||||
<SelectGroup>{cameras}</SelectGroup>
|
event={event}
|
||||||
</SelectContent>
|
onUpdate={() => updateEvents()}
|
||||||
</Select>
|
/>
|
||||||
</div>
|
);
|
||||||
{selectedCamera && <Camera cameraName={selectedCamera} />}
|
})}
|
||||||
|
</div>
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Heading as="h4">Cameras</Heading>
|
||||||
|
<div className="mt-2 grid gap-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||||
|
{sortedCameras.map((camera) => {
|
||||||
|
return <Camera key={camera.name} camera={camera} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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">
|
||||||
}
|
<div className="text-lg capitalize p-2">
|
||||||
/>
|
{camera.name.replaceAll("_", " ")}
|
||||||
<Label htmlFor={`detect-${cameraName}`}>Detect</Label>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={`${
|
||||||
|
detectValue == "ON" ? "text-primary" : "text-gray-400"
|
||||||
|
}`}
|
||||||
|
onClick={() => sendDetect(detectValue == "ON" ? "OFF" : "ON")}
|
||||||
|
>
|
||||||
|
<FaWalking />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={
|
||||||
|
camera.record.enabled_in_config
|
||||||
|
? recordValue == "ON"
|
||||||
|
? "text-primary"
|
||||||
|
: "text-gray-400"
|
||||||
|
: "text-red-500"
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
camera.record.enabled_in_config
|
||||||
|
? sendRecord(recordValue == "ON" ? "OFF" : "ON")
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TbMovie />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={`${
|
||||||
|
snapshotValue == "ON" ? "text-primary" : "text-gray-400"
|
||||||
|
}`}
|
||||||
|
onClick={() => sendSnapshot(detectValue == "ON" ? "OFF" : "ON")}
|
||||||
|
>
|
||||||
|
<AiOutlinePicture />
|
||||||
|
</Button>
|
||||||
|
{camera.audio.enabled_in_config && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={`${
|
||||||
|
audioValue == "ON" ? "text-primary" : "text-gray-400"
|
||||||
|
}`}
|
||||||
|
onClick={() => sendAudio(detectValue == "ON" ? "OFF" : "ON")}
|
||||||
|
>
|
||||||
|
<LuEar />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -13,15 +13,27 @@ 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]);
|
||||||
|
const sortedCameras = useMemo(() => {
|
||||||
|
if (!config) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(config.cameras)
|
||||||
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||||
|
}, [config]);
|
||||||
const restreamEnabled = useMemo(() => {
|
const restreamEnabled = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
config &&
|
config &&
|
||||||
@ -62,13 +74,13 @@ function Live() {
|
|||||||
<DropdownMenuLabel>Select A Camera</DropdownMenuLabel>
|
<DropdownMenuLabel>Select A Camera</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuRadioGroup value={camera} onValueChange={setCamera}>
|
<DropdownMenuRadioGroup value={camera} onValueChange={setCamera}>
|
||||||
{Object.keys(config?.cameras || {}).map((item) => (
|
{(sortedCameras).map((item) => (
|
||||||
<DropdownMenuRadioItem
|
<DropdownMenuRadioItem
|
||||||
className="capitalize"
|
className="capitalize"
|
||||||
key={item}
|
key={item.name}
|
||||||
value={item}
|
value={item.name}
|
||||||
>
|
>
|
||||||
{item.replaceAll("_", " ")}
|
{item.name.replaceAll("_", " ")}
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuRadioGroup>
|
</DropdownMenuRadioGroup>
|
||||||
|
25
web-new/src/types/event.ts
Normal file
25
web-new/src/types/event.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
export interface Event {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
sub_label?: string;
|
||||||
|
camera: string;
|
||||||
|
start_time: number;
|
||||||
|
end_time?: number;
|
||||||
|
false_positive: boolean;
|
||||||
|
zones: string[];
|
||||||
|
thumbnail: string;
|
||||||
|
has_clip: boolean;
|
||||||
|
has_snapshot: boolean;
|
||||||
|
retain_indefinitely: boolean;
|
||||||
|
plus_id?: string;
|
||||||
|
model_hash?: string;
|
||||||
|
data: {
|
||||||
|
top_score: number;
|
||||||
|
score: number;
|
||||||
|
region: number[];
|
||||||
|
box: number[];
|
||||||
|
area: number;
|
||||||
|
ratio: number;
|
||||||
|
type: "object" | "audio" | "manual";
|
||||||
|
}
|
||||||
|
}
|
@ -6,6 +6,8 @@ export interface UiConfig {
|
|||||||
strftime_fmt?: string;
|
strftime_fmt?: string;
|
||||||
live_mode?: string;
|
live_mode?: string;
|
||||||
use_experimental?: boolean;
|
use_experimental?: boolean;
|
||||||
|
dashboard: boolean;
|
||||||
|
order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CameraConfig {
|
export interface CameraConfig {
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import strftime from 'strftime';
|
import strftime from "strftime";
|
||||||
import { fromUnixTime, intervalToDuration, formatDuration } from 'date-fns';
|
import { fromUnixTime, intervalToDuration, formatDuration } from "date-fns";
|
||||||
import { UiConfig } from "@/types/frigateConfig";
|
|
||||||
export const longToDate = (long: number): Date => new Date(long * 1000);
|
export const longToDate = (long: number): Date => new Date(long * 1000);
|
||||||
export const epochToLong = (date: number): number => date / 1000;
|
export const epochToLong = (date: number): number => date / 1000;
|
||||||
export const dateToLong = (date: Date): number => epochToLong(date.getTime());
|
export const dateToLong = (date: Date): number => epochToLong(date.getTime());
|
||||||
@ -40,23 +39,45 @@ export const getNowYesterdayInLong = (): number => {
|
|||||||
// only used as a fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
|
// only used as a fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
|
||||||
const formatMap: {
|
const formatMap: {
|
||||||
[k: string]: {
|
[k: string]: {
|
||||||
date: { year: 'numeric' | '2-digit'; month: 'long' | 'short' | '2-digit'; day: 'numeric' | '2-digit' };
|
date: {
|
||||||
time: { hour: 'numeric'; minute: 'numeric'; second?: 'numeric'; timeZoneName?: 'short' | 'long' };
|
year: "numeric" | "2-digit";
|
||||||
|
month: "long" | "short" | "2-digit";
|
||||||
|
day: "numeric" | "2-digit";
|
||||||
|
};
|
||||||
|
time: {
|
||||||
|
hour: "numeric";
|
||||||
|
minute: "numeric";
|
||||||
|
second?: "numeric";
|
||||||
|
timeZoneName?: "short" | "long";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
} = {
|
} = {
|
||||||
full: {
|
full: {
|
||||||
date: { year: 'numeric', month: 'long', day: 'numeric' },
|
date: { year: "numeric", month: "long", day: "numeric" },
|
||||||
time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' },
|
time: {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
second: "numeric",
|
||||||
|
timeZoneName: "long",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
long: {
|
long: {
|
||||||
date: { year: 'numeric', month: 'long', day: 'numeric' },
|
date: { year: "numeric", month: "long", day: "numeric" },
|
||||||
time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' },
|
time: {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
second: "numeric",
|
||||||
|
timeZoneName: "long",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
medium: {
|
medium: {
|
||||||
date: { year: 'numeric', month: 'short', day: 'numeric' },
|
date: { year: "numeric", month: "short", day: "numeric" },
|
||||||
time: { hour: 'numeric', minute: 'numeric', second: 'numeric' },
|
time: { hour: "numeric", minute: "numeric", second: "numeric" },
|
||||||
|
},
|
||||||
|
short: {
|
||||||
|
date: { year: "2-digit", month: "2-digit", day: "2-digit" },
|
||||||
|
time: { hour: "numeric", minute: "numeric" },
|
||||||
},
|
},
|
||||||
short: { date: { year: '2-digit', month: '2-digit', day: '2-digit' }, time: { hour: 'numeric', minute: 'numeric' } },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -79,11 +100,11 @@ const getResolvedTimeZone = () => {
|
|||||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const offsetMinutes = new Date().getTimezoneOffset();
|
const offsetMinutes = new Date().getTimezoneOffset();
|
||||||
return `UTC${offsetMinutes < 0 ? '+' : '-'}${Math.abs(offsetMinutes / 60)
|
return `UTC${offsetMinutes < 0 ? "+" : "-"}${Math.abs(offsetMinutes / 60)
|
||||||
.toString()
|
.toString()
|
||||||
.padStart(2, '0')}:${Math.abs(offsetMinutes % 60)
|
.padStart(2, "0")}:${Math.abs(offsetMinutes % 60)
|
||||||
.toString()
|
.toString()
|
||||||
.padStart(2, '0')}`;
|
.padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -103,11 +124,21 @@ const getResolvedTimeZone = () => {
|
|||||||
*
|
*
|
||||||
* @throws {Error} If the given unixTimestamp is not a valid number, the function will return 'Invalid time'.
|
* @throws {Error} If the given unixTimestamp is not a valid number, the function will return 'Invalid time'.
|
||||||
*/
|
*/
|
||||||
export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiConfig): string => {
|
export const formatUnixTimestampToDateTime = (
|
||||||
const { timezone, time_format, date_style, time_style, strftime_fmt } = config;
|
unixTimestamp: number,
|
||||||
const locale = window.navigator?.language || 'en-US';
|
config: {
|
||||||
|
timezone?: string;
|
||||||
|
time_format?: "browser" | "12hour" | "24hour";
|
||||||
|
date_style?: "full" | "long" | "medium" | "short";
|
||||||
|
time_style?: "full" | "long" | "medium" | "short";
|
||||||
|
strftime_fmt?: string;
|
||||||
|
}
|
||||||
|
): string => {
|
||||||
|
const { timezone, time_format, date_style, time_style, strftime_fmt } =
|
||||||
|
config;
|
||||||
|
const locale = window.navigator?.language || "en-US";
|
||||||
if (isNaN(unixTimestamp)) {
|
if (isNaN(unixTimestamp)) {
|
||||||
return 'Invalid time';
|
return "Invalid time";
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -125,7 +156,7 @@ export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiC
|
|||||||
const options: Intl.DateTimeFormatOptions = {
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
dateStyle: date_style,
|
dateStyle: date_style,
|
||||||
timeStyle: time_style,
|
timeStyle: time_style,
|
||||||
hour12: time_format !== 'browser' ? time_format == '12hour' : undefined,
|
hour12: time_format !== "browser" ? time_format == "12hour" : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only set timeZone option when resolvedTimeZone does not match UTC±HH:MM format, or when timezone is set in config
|
// Only set timeZone option when resolvedTimeZone does not match UTC±HH:MM format, or when timezone is set in config
|
||||||
@ -143,15 +174,26 @@ export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiC
|
|||||||
// fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
|
// fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
|
||||||
// This works even tough the timezone is undefined, it will use the runtime's default time zone
|
// This works even tough the timezone is undefined, it will use the runtime's default time zone
|
||||||
if (!containsTime) {
|
if (!containsTime) {
|
||||||
const dateOptions = { ...formatMap[date_style ?? ""]?.date, timeZone: options.timeZone, hour12: options.hour12 };
|
const dateOptions = {
|
||||||
const timeOptions = { ...formatMap[time_style ?? ""]?.time, timeZone: options.timeZone, hour12: options.hour12 };
|
...formatMap[date_style ?? ""]?.date,
|
||||||
|
timeZone: options.timeZone,
|
||||||
|
hour12: options.hour12,
|
||||||
|
};
|
||||||
|
const timeOptions = {
|
||||||
|
...formatMap[time_style ?? ""]?.time,
|
||||||
|
timeZone: options.timeZone,
|
||||||
|
hour12: options.hour12,
|
||||||
|
};
|
||||||
|
|
||||||
return `${date.toLocaleDateString(locale, dateOptions)} ${date.toLocaleTimeString(locale, timeOptions)}`;
|
return `${date.toLocaleDateString(
|
||||||
|
locale,
|
||||||
|
dateOptions
|
||||||
|
)} ${date.toLocaleTimeString(locale, timeOptions)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return formattedDateTime;
|
return formattedDateTime;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return 'Invalid time';
|
return "Invalid time";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -169,28 +211,31 @@ interface DurationToken {
|
|||||||
* @param end_time: number|null - Unix timestamp for end time
|
* @param end_time: number|null - Unix timestamp for end time
|
||||||
* @returns string - duration or 'In Progress' if end time is not provided
|
* @returns string - duration or 'In Progress' if end time is not provided
|
||||||
*/
|
*/
|
||||||
export const getDurationFromTimestamps = (start_time: number, end_time: number | null): string => {
|
export const getDurationFromTimestamps = (
|
||||||
|
start_time: number,
|
||||||
|
end_time: number | null
|
||||||
|
): string => {
|
||||||
if (isNaN(start_time)) {
|
if (isNaN(start_time)) {
|
||||||
return 'Invalid start time';
|
return "Invalid start time";
|
||||||
}
|
}
|
||||||
let duration = 'In Progress';
|
let duration = "In Progress";
|
||||||
if (end_time !== null) {
|
if (end_time !== null) {
|
||||||
if (isNaN(end_time)) {
|
if (isNaN(end_time)) {
|
||||||
return 'Invalid end time';
|
return "Invalid end time";
|
||||||
}
|
}
|
||||||
const start = fromUnixTime(start_time);
|
const start = fromUnixTime(start_time);
|
||||||
const end = fromUnixTime(end_time);
|
const end = fromUnixTime(end_time);
|
||||||
const formatDistanceLocale: DurationToken = {
|
const formatDistanceLocale: DurationToken = {
|
||||||
xSeconds: '{{count}}s',
|
xSeconds: "{{count}}s",
|
||||||
xMinutes: '{{count}}m',
|
xMinutes: "{{count}}m",
|
||||||
xHours: '{{count}}h',
|
xHours: "{{count}}h",
|
||||||
};
|
};
|
||||||
const shortEnLocale = {
|
const shortEnLocale = {
|
||||||
formatDistance: (token: keyof DurationToken, count: number) =>
|
formatDistance: (token: keyof DurationToken, count: number) =>
|
||||||
formatDistanceLocale[token].replace('{{count}}', count.toString()),
|
formatDistanceLocale[token].replace("{{count}}", count.toString()),
|
||||||
};
|
};
|
||||||
duration = formatDuration(intervalToDuration({ start, end }), {
|
duration = formatDuration(intervalToDuration({ start, end }), {
|
||||||
format: ['hours', 'minutes', 'seconds'],
|
format: ["hours", "minutes", "seconds"],
|
||||||
locale: shortEnLocale,
|
locale: shortEnLocale,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -209,14 +254,18 @@ const getUTCOffset = (date: Date, timezone: string): number => {
|
|||||||
if (utcOffsetMatch) {
|
if (utcOffsetMatch) {
|
||||||
const hours = parseInt(utcOffsetMatch[2], 10);
|
const hours = parseInt(utcOffsetMatch[2], 10);
|
||||||
const minutes = parseInt(utcOffsetMatch[3], 10);
|
const minutes = parseInt(utcOffsetMatch[3], 10);
|
||||||
return (utcOffsetMatch[1] === '+' ? 1 : -1) * (hours * 60 + minutes);
|
return (utcOffsetMatch[1] === "+" ? 1 : -1) * (hours * 60 + minutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, calculate offset using provided timezone
|
// Otherwise, calculate offset using provided timezone
|
||||||
const utcDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
|
const utcDate = new Date(
|
||||||
|
date.getTime() - date.getTimezoneOffset() * 60 * 1000
|
||||||
|
);
|
||||||
// locale of en-CA is required for proper locale format
|
// locale of en-CA is required for proper locale format
|
||||||
let iso = utcDate.toLocaleString('en-CA', { timeZone: timezone, hour12: false }).replace(', ', 'T');
|
let iso = utcDate
|
||||||
iso += `.${utcDate.getMilliseconds().toString().padStart(3, '0')}`;
|
.toLocaleString("en-CA", { timeZone: timezone, hour12: false })
|
||||||
|
.replace(", ", "T");
|
||||||
|
iso += `.${utcDate.getMilliseconds().toString().padStart(3, "0")}`;
|
||||||
let target = new Date(`${iso}Z`);
|
let target = new Date(`${iso}Z`);
|
||||||
|
|
||||||
// safari doesn't like the default format
|
// safari doesn't like the default format
|
||||||
|
@ -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%;
|
||||||
@ -40,7 +40,7 @@
|
|||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 217.2 32.6% 17.5%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 217.2 38.6% 29.5%;
|
||||||
--ring: 224.3 76.3% 48%;
|
--ring: 224.3 76.3% 48%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user