mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-11 05:35:25 +03:00
merge dev
This commit is contained in:
parent
8d0385c991
commit
bd64e6f873
29
web/package-lock.json
generated
29
web/package-lock.json
generated
@ -21,6 +21,7 @@
|
|||||||
"@radix-ui/react-radio-group": "^1.1.3",
|
"@radix-ui/react-radio-group": "^1.1.3",
|
||||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
"@radix-ui/react-separator": "^1.0.3",
|
||||||
"@radix-ui/react-slider": "^1.1.2",
|
"@radix-ui/react-slider": "^1.1.2",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
@ -1650,6 +1651,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-separator": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/react-primitive": "1.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-slider": {
|
"node_modules/@radix-ui/react-slider": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.2.tgz",
|
||||||
@ -2568,11 +2592,6 @@
|
|||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/scheduler": {
|
|
||||||
"version": "0.16.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
|
|
||||||
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/semver": {
|
"node_modules/@types/semver": {
|
||||||
"version": "7.5.6",
|
"version": "7.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
|
||||||
|
|||||||
@ -26,6 +26,7 @@
|
|||||||
"@radix-ui/react-radio-group": "^1.1.3",
|
"@radix-ui/react-radio-group": "^1.1.3",
|
||||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
"@radix-ui/react-separator": "^1.0.3",
|
||||||
"@radix-ui/react-slider": "^1.1.2",
|
"@radix-ui/react-slider": "^1.1.2",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import { FrigateStats } from "@/types/stats";
|
|||||||
import { useFrigateStats } from "@/api/ws";
|
import { useFrigateStats } from "@/api/ws";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import useStats from "@/hooks/use-stats";
|
import useStats from "@/hooks/use-stats";
|
||||||
import GeneralSettings from "../settings/GeneralSettings";
|
import GeneralSettings from "../menu/GeneralSettings";
|
||||||
import AccountSettings from "../settings/AccountSettings";
|
import AccountSettings from "../menu/AccountSettings";
|
||||||
import useNavigation from "@/hooks/use-navigation";
|
import useNavigation from "@/hooks/use-navigation";
|
||||||
|
|
||||||
function Bottombar() {
|
function Bottombar() {
|
||||||
|
|||||||
@ -2,8 +2,8 @@ import Logo from "../Logo";
|
|||||||
import NavItem from "./NavItem";
|
import NavItem from "./NavItem";
|
||||||
import { CameraGroupSelector } from "../filter/CameraGroupSelector";
|
import { CameraGroupSelector } from "../filter/CameraGroupSelector";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import GeneralSettings from "../settings/GeneralSettings";
|
import GeneralSettings from "../menu/GeneralSettings";
|
||||||
import AccountSettings from "../settings/AccountSettings";
|
import AccountSettings from "../menu/AccountSettings";
|
||||||
import useNavigation from "@/hooks/use-navigation";
|
import useNavigation from "@/hooks/use-navigation";
|
||||||
|
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
|
|||||||
40
web/src/components/settings/General.tsx
Normal file
40
web/src/components/settings/General.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import Heading from "@/components/ui/heading";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
|
export default function General() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Heading as="h2">Settings</Heading>
|
||||||
|
<div className="flex items-center space-x-2 mt-5">
|
||||||
|
<Switch id="lowdata" checked={false} onCheckedChange={() => {}} />
|
||||||
|
<Label htmlFor="lowdata">Low Data Mode (this device only)</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 mt-5">
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Another General Option" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Live Mode</SelectLabel>
|
||||||
|
<SelectItem value="jsmpeg">JSMpeg</SelectItem>
|
||||||
|
<SelectItem value="mse">MSE</SelectItem>
|
||||||
|
<SelectItem value="webrtc">WebRTC</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,13 +1,4 @@
|
|||||||
import Heading from "@/components/ui/heading";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -23,12 +14,16 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { PolygonCanvas } from "./PolygonCanvas";
|
import { PolygonCanvas } from "./PolygonCanvas";
|
||||||
import { Polygon } from "@/types/canvas";
|
import { Polygon } from "@/types/canvas";
|
||||||
import { interpolatePoints } from "@/utils/canvasUtil";
|
import { interpolatePoints, toRGBColorString } from "@/utils/canvasUtil";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import ZoneControls, { ZoneObjectSelector } from "./ZoneControls";
|
import ZoneControls, {
|
||||||
|
NewZoneButton,
|
||||||
|
ZoneObjectSelector,
|
||||||
|
} from "./NewZoneButton";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from "../ui/skeleton";
|
||||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
import { LuPencil } from "react-icons/lu";
|
import { LuCopy, LuPencil, LuPlusSquare, LuTrash } from "react-icons/lu";
|
||||||
|
import { FaDrawPolygon } from "react-icons/fa";
|
||||||
|
|
||||||
const parseCoordinates = (coordinatesString: string) => {
|
const parseCoordinates = (coordinatesString: string) => {
|
||||||
const coordinates = coordinatesString.split(",");
|
const coordinates = coordinatesString.split(",");
|
||||||
@ -49,7 +44,15 @@ export type ZoneObjects = {
|
|||||||
objects: string[];
|
objects: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SettingsZones() {
|
type MasksAndZoneProps = {
|
||||||
|
selectedCamera: string;
|
||||||
|
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MasksAndZones({
|
||||||
|
selectedCamera,
|
||||||
|
setSelectedCamera,
|
||||||
|
}: MasksAndZoneProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const [zonePolygons, setZonePolygons] = useState<Polygon[]>([]);
|
const [zonePolygons, setZonePolygons] = useState<Polygon[]>([]);
|
||||||
const [zoneObjects, setZoneObjects] = useState<ZoneObjects[]>([]);
|
const [zoneObjects, setZoneObjects] = useState<ZoneObjects[]>([]);
|
||||||
@ -68,8 +71,6 @@ export default function SettingsZones() {
|
|||||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const [selectedCamera, setSelectedCamera] = useState(cameras[0].name);
|
|
||||||
|
|
||||||
const cameraConfig = useMemo(() => {
|
const cameraConfig = useMemo(() => {
|
||||||
if (config && selectedCamera) {
|
if (config && selectedCamera) {
|
||||||
return config.cameras[selectedCamera];
|
return config.cameras[selectedCamera];
|
||||||
@ -137,7 +138,7 @@ export default function SettingsZones() {
|
|||||||
[setZoneObjects],
|
[setZoneObjects],
|
||||||
);
|
);
|
||||||
|
|
||||||
const grow = useMemo(() => {
|
const growe = useMemo(() => {
|
||||||
if (!cameraConfig) {
|
if (!cameraConfig) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -157,14 +158,51 @@ export default function SettingsZones() {
|
|||||||
}
|
}
|
||||||
}, [cameraConfig]);
|
}, [cameraConfig]);
|
||||||
|
|
||||||
const handleCameraChange = useCallback(
|
const getCameraAspect = useCallback(
|
||||||
(camera: string) => {
|
(cam: string) => {
|
||||||
setSelectedCamera(camera);
|
if (!config) {
|
||||||
setActivePolygonIndex(null);
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = config.cameras[cam];
|
||||||
|
|
||||||
|
if (!camera) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return camera.detect.width / camera.detect.height;
|
||||||
},
|
},
|
||||||
[setSelectedCamera, setActivePolygonIndex],
|
[config],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mainCameraAspect = useMemo(() => {
|
||||||
|
const aspectRatio = getCameraAspect(selectedCamera);
|
||||||
|
|
||||||
|
if (!aspectRatio) {
|
||||||
|
return "normal";
|
||||||
|
} else if (aspectRatio > 2) {
|
||||||
|
return "wide";
|
||||||
|
} else if (aspectRatio < 16 / 9) {
|
||||||
|
return "tall";
|
||||||
|
} else {
|
||||||
|
return "normal";
|
||||||
|
}
|
||||||
|
}, [getCameraAspect, selectedCamera]);
|
||||||
|
|
||||||
|
const grow = useMemo(() => {
|
||||||
|
if (mainCameraAspect == "wide") {
|
||||||
|
return "w-full aspect-wide";
|
||||||
|
} else if (mainCameraAspect == "tall") {
|
||||||
|
if (isDesktop) {
|
||||||
|
return "size-full aspect-tall flex flex-col justify-center";
|
||||||
|
} else {
|
||||||
|
return "size-full";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "w-full aspect-video";
|
||||||
|
}
|
||||||
|
}, [mainCameraAspect]);
|
||||||
|
|
||||||
const [{ width: containerWidth, height: containerHeight }] =
|
const [{ width: containerWidth, height: containerHeight }] =
|
||||||
useResizeObserver(containerRef);
|
useResizeObserver(containerRef);
|
||||||
|
|
||||||
@ -173,13 +211,16 @@ export default function SettingsZones() {
|
|||||||
: { width: 1, height: 1 };
|
: { width: 1, height: 1 };
|
||||||
const aspectRatio = width / height;
|
const aspectRatio = width / height;
|
||||||
|
|
||||||
const stretch = false;
|
const stretch = true;
|
||||||
const fitAspect = 0.75;
|
const fitAspect = 16 / 9;
|
||||||
|
// console.log(containerRef.current?.clientHeight);
|
||||||
|
|
||||||
const scaledHeight = useMemo(() => {
|
const scaledHeight = useMemo(() => {
|
||||||
const scaledHeight =
|
const scaledHeight =
|
||||||
aspectRatio < (fitAspect ?? 0)
|
aspectRatio < (fitAspect ?? 0)
|
||||||
? Math.floor(containerHeight)
|
? Math.floor(
|
||||||
|
Math.min(containerHeight, containerRef.current?.clientHeight),
|
||||||
|
)
|
||||||
: Math.floor(containerWidth / aspectRatio);
|
: Math.floor(containerWidth / aspectRatio);
|
||||||
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height);
|
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height);
|
||||||
|
|
||||||
@ -244,57 +285,86 @@ export default function SettingsZones() {
|
|||||||
console.log("component zone objects", zoneObjects);
|
console.log("component zone objects", zoneObjects);
|
||||||
}, [zoneObjects]);
|
}, [zoneObjects]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCamera) {
|
||||||
|
setActivePolygonIndex(null);
|
||||||
|
}
|
||||||
|
}, [selectedCamera]);
|
||||||
|
|
||||||
if (!cameraConfig && !selectedCamera) {
|
if (!cameraConfig && !selectedCamera) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<>
|
||||||
<Heading as="h2">Zones</Heading>
|
|
||||||
<div className="flex items-center space-x-2 mt-5">
|
|
||||||
<Select value={selectedCamera} onValueChange={handleCameraChange}>
|
|
||||||
<SelectTrigger className="w-[180px]">
|
|
||||||
<SelectValue placeholder="Camera" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel>Choose a camera</SelectLabel>
|
|
||||||
{cameras.map((camera) => (
|
|
||||||
<SelectItem
|
|
||||||
key={camera.name}
|
|
||||||
value={`${camera.name}`}
|
|
||||||
className="capitalize"
|
|
||||||
>
|
|
||||||
{camera.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{cameraConfig && (
|
{cameraConfig && (
|
||||||
<div className="flex flex-col justify-evenly">
|
<div className="flex flex-col md:flex-row size-full">
|
||||||
<div
|
<div className="flex flex-col order-last w-full md:w-3/12 md:order-none md:mr-2">
|
||||||
className={`flex flex-col justify-center items-center w-full md:w-[60%] ${grow}`}
|
<div className="flex mb-3">
|
||||||
>
|
<Separator />
|
||||||
<div ref={containerRef} className="size-full">
|
</div>
|
||||||
{cameraConfig ? (
|
<div className="flex flex-row justify-between items-center mb-3">
|
||||||
<PolygonCanvas
|
<div className="text-md">Zones</div>
|
||||||
|
<NewZoneButton
|
||||||
camera={cameraConfig.name}
|
camera={cameraConfig.name}
|
||||||
width={scaledWidth}
|
|
||||||
height={scaledHeight}
|
|
||||||
polygons={zonePolygons}
|
polygons={zonePolygons}
|
||||||
setPolygons={setZonePolygons}
|
setPolygons={setZonePolygons}
|
||||||
activePolygonIndex={activePolygonIndex}
|
activePolygonIndex={activePolygonIndex}
|
||||||
|
setActivePolygonIndex={setActivePolygonIndex}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{zonePolygons.map((polygon, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex p-1 rounded-lg flex-row items-center justify-between mx-2 mb-1"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
activePolygonIndex === index
|
||||||
|
? toRGBColorString(polygon.color, false)
|
||||||
|
: "",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center ${activePolygonIndex === index ? "text-primary" : "text-secondary-foreground"}`}
|
||||||
|
>
|
||||||
|
<FaDrawPolygon
|
||||||
|
className="size-4 mr-2"
|
||||||
|
style={{
|
||||||
|
fill: toRGBColorString(polygon.color, true),
|
||||||
|
color: toRGBColorString(polygon.color, true),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{polygon.name}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<div
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setActivePolygonIndex(index)}
|
||||||
|
>
|
||||||
|
<LuPencil
|
||||||
|
className={`size-4 ${activePolygonIndex === index ? "text-primary" : "text-secondary-foreground"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<LuCopy
|
||||||
|
className={`size-4 ${activePolygonIndex === index ? "text-primary" : "text-secondary-foreground"}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setZonePolygons((oldPolygons) => {
|
||||||
|
return oldPolygons.filter((_, i) => i !== index);
|
||||||
|
});
|
||||||
|
setActivePolygonIndex(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuTrash
|
||||||
|
className={`size-4 ${activePolygonIndex === index ? "text-primary fill-primary" : "text-secondary-foreground fill-secondary-foreground"}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<Skeleton className="w-full h-full" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full md:w-[30%]">
|
</div>
|
||||||
<Table>
|
))}
|
||||||
|
{/* <Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[100px]">Name</TableHead>
|
<TableHead className="w-[100px]">Name</TableHead>
|
||||||
@ -372,10 +442,29 @@ export default function SettingsZones() {
|
|||||||
0,
|
0,
|
||||||
)}
|
)}
|
||||||
</pre>
|
</pre>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex md:w-7/12 md:grow md:h-dvh md:max-h-[90%]"
|
||||||
|
>
|
||||||
|
<div className="size-full">
|
||||||
|
{cameraConfig ? (
|
||||||
|
<PolygonCanvas
|
||||||
|
camera={cameraConfig.name}
|
||||||
|
width={scaledWidth}
|
||||||
|
height={scaledHeight}
|
||||||
|
polygons={zonePolygons}
|
||||||
|
setPolygons={setZonePolygons}
|
||||||
|
activePolygonIndex={activePolygonIndex}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="w-full h-full" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -16,6 +16,7 @@ import { Button } from "../ui/button";
|
|||||||
import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig";
|
import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
|
import { LuPlusSquare } from "react-icons/lu";
|
||||||
|
|
||||||
type ZoneObjectSelectorProps = {
|
type ZoneObjectSelectorProps = {
|
||||||
camera: string;
|
camera: string;
|
||||||
@ -126,7 +127,7 @@ export function ZoneObjectSelector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ZoneControlsProps = {
|
type NewZoneButtonProps = {
|
||||||
camera: string;
|
camera: string;
|
||||||
polygons: Polygon[];
|
polygons: Polygon[];
|
||||||
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
|
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
|
||||||
@ -134,13 +135,13 @@ type ZoneControlsProps = {
|
|||||||
setActivePolygonIndex: React.Dispatch<React.SetStateAction<number | null>>;
|
setActivePolygonIndex: React.Dispatch<React.SetStateAction<number | null>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ZoneControls({
|
export function NewZoneButton({
|
||||||
camera,
|
camera,
|
||||||
polygons,
|
polygons,
|
||||||
setPolygons,
|
setPolygons,
|
||||||
activePolygonIndex,
|
activePolygonIndex,
|
||||||
setActivePolygonIndex,
|
setActivePolygonIndex,
|
||||||
}: ZoneControlsProps) {
|
}: NewZoneButtonProps) {
|
||||||
const { data: config } = useSWR("config");
|
const { data: config } = useSWR("config");
|
||||||
const [zoneName, setZoneName] = useState<string | null>();
|
const [zoneName, setZoneName] = useState<string | null>();
|
||||||
const [invalidName, setInvalidName] = useState<boolean>();
|
const [invalidName, setInvalidName] = useState<boolean>();
|
||||||
@ -190,16 +191,20 @@ export function ZoneControls({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex">
|
||||||
<div className="flex justify-between items-center my-5">
|
{/* <Button className="mr-5" variant="secondary" onClick={undo}>
|
||||||
<Button className="mr-5" variant="secondary" onClick={undo}>
|
|
||||||
Undo
|
Undo
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={reset}>
|
<Button variant="secondary" onClick={reset}>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button> */}
|
||||||
<Button variant="secondary" onClick={() => setDialogOpen(true)}>
|
|
||||||
New Zone
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 px-0"
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<LuPlusSquare />
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
@ -214,8 +219,8 @@ export function ZoneControls({
|
|||||||
{isMobile && <span tabIndex={0} className="sr-only" />}
|
{isMobile && <span tabIndex={0} className="sr-only" />}
|
||||||
<DialogTitle>New Zone</DialogTitle>
|
<DialogTitle>New Zone</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Enter a unique label for your zone. Do not include spaces, and
|
Enter a unique label for your zone. Do not include spaces, and don't
|
||||||
don't use the name of a camera.
|
use the name of a camera.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
@ -226,9 +231,7 @@ export function ZoneControls({
|
|||||||
setInvalidName(
|
setInvalidName(
|
||||||
Object.keys(config.cameras).includes(e.target.value) ||
|
Object.keys(config.cameras).includes(e.target.value) ||
|
||||||
e.target.value.includes(" ") ||
|
e.target.value.includes(" ") ||
|
||||||
polygons
|
polygons.map((item) => item.name).includes(e.target.value),
|
||||||
.map((item) => item.name)
|
|
||||||
.includes(e.target.value),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setZoneName(e.target.value);
|
setZoneName(e.target.value);
|
||||||
@ -257,8 +260,7 @@ export function ZoneControls({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ZoneControls;
|
export default NewZoneButton;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Line, Circle, Group } from "react-konva";
|
import { Line, Circle, Group } from "react-konva";
|
||||||
import { minMax, dragBoundFunc } from "@/utils/canvasUtil";
|
import { minMax, toRGBColorString, dragBoundFunc } from "@/utils/canvasUtil";
|
||||||
import type { KonvaEventObject } from "konva/lib/Node";
|
import type { KonvaEventObject } from "konva/lib/Node";
|
||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import { Vector2d } from "konva/lib/types";
|
import { Vector2d } from "konva/lib/types";
|
||||||
@ -78,11 +78,7 @@ export default function PolygonDrawer({
|
|||||||
|
|
||||||
const colorString = useCallback(
|
const colorString = useCallback(
|
||||||
(darkened: boolean) => {
|
(darkened: boolean) => {
|
||||||
if (color.length !== 3) {
|
return toRGBColorString(color, darkened);
|
||||||
return "rgb(220,0,0,0.5)";
|
|
||||||
}
|
|
||||||
|
|
||||||
return `rgba(${color[2]},${color[1]},${color[0]},${darkened ? "0.8" : "0.5"})`;
|
|
||||||
},
|
},
|
||||||
[color],
|
[color],
|
||||||
);
|
);
|
||||||
|
|||||||
29
web/src/components/ui/separator.tsx
Normal file
29
web/src/components/ui/separator.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@ -1,75 +1,191 @@
|
|||||||
import Heading from "@/components/ui/heading";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
DropdownMenu,
|
||||||
SelectContent,
|
DropdownMenuContent,
|
||||||
SelectGroup,
|
DropdownMenuLabel,
|
||||||
SelectItem,
|
DropdownMenuSeparator,
|
||||||
SelectLabel,
|
DropdownMenuTrigger,
|
||||||
SelectTrigger,
|
} from "@/components/ui/dropdown-menu";
|
||||||
SelectValue,
|
// import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
} from "@/components/ui/select";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import MotionTuner from "@/components/settings/MotionTuner";
|
import MotionTuner from "@/components/settings/MotionTuner";
|
||||||
import SettingsZones from "@/components/settings/Zones";
|
import MasksAndZones from "@/components/settings/MasksAndZones";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||||
|
import Logo from "@/components/Logo";
|
||||||
|
import { isMobile } from "react-device-detect";
|
||||||
|
import { FaVideo } from "react-icons/fa";
|
||||||
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import General from "@/components/settings/General";
|
||||||
|
import FilterCheckBox from "@/components/filter/FilterCheckBox";
|
||||||
|
|
||||||
function General() {
|
type CameraSelectButtonProps = {
|
||||||
return (
|
allCameras: CameraConfig[];
|
||||||
<>
|
selectedCamera: string;
|
||||||
<Heading as="h2">Settings</Heading>
|
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
|
||||||
<div className="flex items-center space-x-2 mt-5">
|
};
|
||||||
<Switch id="detect" checked={false} onCheckedChange={() => {}} />
|
|
||||||
<Label htmlFor="detect">
|
function CameraSelectButton({
|
||||||
Always show PTZ controls for ONVIF cameras
|
allCameras,
|
||||||
</Label>
|
selectedCamera,
|
||||||
|
setSelectedCamera,
|
||||||
|
}: CameraSelectButtonProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2 capitalize bg-selected hover:bg-selected"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<FaVideo className="text-background dark:text-primary" />
|
||||||
|
<div className="hidden md:block text-background dark:text-primary">
|
||||||
|
{selectedCamera == undefined ? "No Camera" : selectedCamera}
|
||||||
</div>
|
</div>
|
||||||
|
</Button>
|
||||||
<div className="flex items-center space-x-2 mt-5">
|
);
|
||||||
<Select>
|
const content = (
|
||||||
<SelectTrigger className="w-[180px]">
|
<>
|
||||||
<SelectValue placeholder="Default Live Mode" />
|
{isMobile && (
|
||||||
</SelectTrigger>
|
<>
|
||||||
<SelectContent>
|
<DropdownMenuLabel className="flex justify-center">
|
||||||
<SelectGroup>
|
Camera
|
||||||
<SelectLabel>Live Mode</SelectLabel>
|
</DropdownMenuLabel>
|
||||||
<SelectItem value="jsmpeg">JSMpeg</SelectItem>
|
<DropdownMenuSeparator />
|
||||||
<SelectItem value="mse">MSE</SelectItem>
|
</>
|
||||||
<SelectItem value="webrtc">WebRTC</SelectItem>
|
)}
|
||||||
</SelectGroup>
|
<div className="h-auto overflow-y-auto overflow-x-hidden pb-4 md:pb-0">
|
||||||
</SelectContent>
|
{allCameras.map((item) => (
|
||||||
</Select>
|
<FilterCheckBox
|
||||||
|
key={item.name}
|
||||||
|
isChecked={item.name === selectedCamera}
|
||||||
|
label={item.name}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
if (isChecked) {
|
||||||
|
setSelectedCamera(item.name);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedCamera(selectedCamera);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||||
|
<DrawerContent className="max-h-[75dvh] overflow-hidden">
|
||||||
|
{content}
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedCamera(selectedCamera);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>{content}</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
|
const settingsViews = [
|
||||||
|
"general",
|
||||||
|
"objects",
|
||||||
|
"masks / zones",
|
||||||
|
"motion tuner",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SettingsType = (typeof settingsViews)[number];
|
||||||
|
const [page, setPage] = useState<SettingsType>("general");
|
||||||
|
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
||||||
|
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
const cameras = useMemo(() => {
|
||||||
|
if (!config) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(config.cameras)
|
||||||
|
.filter((conf) => conf.ui.dashboard && conf.enabled)
|
||||||
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const [selectedCamera, setSelectedCamera] = useState(cameras[0].name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="size-full p-2 flex flex-col">
|
||||||
<div className="flex h-full">
|
<div className="w-full h-11 relative flex justify-between items-center">
|
||||||
<div className="flex-1 content-start gap-2 overflow-y-auto no-scrollbar mt-4 mr-5">
|
{isMobile && (
|
||||||
<Tabs defaultValue="general" className="w-auto">
|
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
|
||||||
<TabsList>
|
)}
|
||||||
<TabsTrigger value="general">General</TabsTrigger>
|
<ToggleGroup
|
||||||
<TabsTrigger value="objects">Objects</TabsTrigger>
|
className="*:px-3 *:py-4 *:rounded-md"
|
||||||
<TabsTrigger value="zones">Zones</TabsTrigger>
|
type="single"
|
||||||
<TabsTrigger value="masks">Masks</TabsTrigger>
|
size="sm"
|
||||||
<TabsTrigger value="motion">Motion</TabsTrigger>
|
value={pageToggle}
|
||||||
</TabsList>
|
onValueChange={(value: SettingsType) => {
|
||||||
<TabsContent value="general">
|
if (value) {
|
||||||
<General />
|
setPageToggle(value);
|
||||||
</TabsContent>
|
}
|
||||||
<TabsContent value="objects">Objects</TabsContent>
|
}}
|
||||||
<TabsContent value="zones">
|
>
|
||||||
<SettingsZones />
|
{Object.values(settingsViews).map((item) => (
|
||||||
</TabsContent>
|
<ToggleGroupItem
|
||||||
<TabsContent value="masks">Masks</TabsContent>
|
key={item}
|
||||||
<TabsContent value="motion">
|
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-gray-500"}`}
|
||||||
<MotionTuner />
|
value={item}
|
||||||
</TabsContent>
|
aria-label={`Select ${item}`}
|
||||||
</Tabs>
|
>
|
||||||
|
<div className="capitalize">{item}</div>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
{(page == "objects" ||
|
||||||
|
page == "masks / zones" ||
|
||||||
|
page == "motion tuner") && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CameraSelectButton
|
||||||
|
allCameras={cameras}
|
||||||
|
selectedCamera={selectedCamera}
|
||||||
|
setSelectedCamera={setSelectedCamera}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-col items-start">
|
||||||
|
{page == "general" && <General />}
|
||||||
|
{page == "objects" && <></>}
|
||||||
|
{page == "masks / zones" && (
|
||||||
|
<MasksAndZones
|
||||||
|
selectedCamera={selectedCamera}
|
||||||
|
setSelectedCamera={setSelectedCamera}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{page == "motion tuner" && <MotionTuner />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -63,3 +63,11 @@ export const interpolatePoints = (
|
|||||||
|
|
||||||
return newPoints;
|
return newPoints;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const toRGBColorString = (color: number[], darkened: boolean) => {
|
||||||
|
if (color.length !== 3) {
|
||||||
|
return "rgb(220,0,0,0.5)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `rgba(${color[2]},${color[1]},${color[0]},${darkened ? "0.9" : "0.5"})`;
|
||||||
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user