mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-09 04:35:25 +03:00
Implement camera groups in config and live view
This commit is contained in:
parent
0d6ebcdba8
commit
6188a622d3
@ -1010,6 +1010,7 @@ class CameraGroupConfig(FrigateBaseModel):
|
|||||||
default_factory=list, title="List of cameras in this group."
|
default_factory=list, title="List of cameras in this group."
|
||||||
)
|
)
|
||||||
icon: str = Field(default="generic", title="Icon that represents camera group.")
|
icon: str = Field(default="generic", title="Icon that represents camera group.")
|
||||||
|
order: int = Field(default=0, title="Sort order for group.")
|
||||||
|
|
||||||
|
|
||||||
def verify_config_roles(camera_config: CameraConfig) -> None:
|
def verify_config_roles(camera_config: CameraConfig) -> None:
|
||||||
|
|||||||
@ -1,42 +1,54 @@
|
|||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { MdHome } from "react-icons/md";
|
import { MdHome } from "react-icons/md";
|
||||||
import { FaCar, FaCircle, FaLeaf } from "react-icons/fa";
|
import { FaCar, FaCat, FaCircle, FaDog, FaLeaf } from "react-icons/fa";
|
||||||
import useOverlayState from "@/hooks/use-overlay-state";
|
import useOverlayState from "@/hooks/use-overlay-state";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
export function CameraGroupSelector() {
|
export function CameraGroupSelector() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [group, setGroup] = useOverlayState("cameraGroup");
|
const [group, setGroup] = useOverlayState("cameraGroup");
|
||||||
|
|
||||||
if (isMobile) {
|
const groups = useMemo(() => {
|
||||||
|
if (!config) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(config.camera_groups).sort(
|
||||||
|
(a, b) => a[1].order - b[1].order,
|
||||||
|
);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-start gap-2">
|
<div
|
||||||
|
className={`flex items-center justify-start gap-2 ${isDesktop ? "flex-col mb-4" : ""}`}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
className={
|
className={
|
||||||
group == undefined
|
group == undefined
|
||||||
? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
|
? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
|
||||||
: "text-muted-foreground bg-muted"
|
: "text-muted-foreground bg-secondary"
|
||||||
}
|
}
|
||||||
size="xs"
|
size="xs"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
>
|
>
|
||||||
<MdHome className="size-4" />
|
<MdHome className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{Object.entries(config?.camera_groups ?? {}).map(([name, config]) => {
|
{groups.map(([name, config]) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={name}
|
key={name}
|
||||||
className={
|
className={
|
||||||
group == name
|
group == name
|
||||||
? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
|
? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
|
||||||
: "text-muted-foreground bg-muted"
|
: "text-muted-foreground bg-secondary"
|
||||||
}
|
}
|
||||||
size="xs"
|
size="xs"
|
||||||
onClick={() => setGroup(name)}
|
onClick={() => setGroup(name, group != undefined)}
|
||||||
>
|
>
|
||||||
{getGroupIcon(config.icon)}
|
{getGroupIcon(config.icon)}
|
||||||
</Button>
|
</Button>
|
||||||
@ -44,15 +56,16 @@ export function CameraGroupSelector() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return <div></div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGroupIcon(icon: string) {
|
function getGroupIcon(icon: string) {
|
||||||
switch (icon) {
|
switch (icon) {
|
||||||
case "car":
|
case "car":
|
||||||
return <FaCar className="size-4" />;
|
return <FaCar className="size-4" />;
|
||||||
|
case "cat":
|
||||||
|
return <FaCat className="size-4" />;
|
||||||
|
case "dog":
|
||||||
|
return <FaDog className="size-4" />;
|
||||||
case "leaf":
|
case "leaf":
|
||||||
return <FaLeaf className="size-4" />;
|
return <FaLeaf className="size-4" />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import Logo from "../Logo";
|
|||||||
import { navbarLinks } from "@/pages/site-navigation";
|
import { navbarLinks } from "@/pages/site-navigation";
|
||||||
import SettingsNavItems from "../settings/SettingsNavItems";
|
import SettingsNavItems from "../settings/SettingsNavItems";
|
||||||
import NavItem from "./NavItem";
|
import NavItem from "./NavItem";
|
||||||
|
import { CameraGroupSelector } from "../filter/CameraGroupSelector";
|
||||||
|
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
return (
|
return (
|
||||||
@ -10,14 +11,17 @@ function Sidebar() {
|
|||||||
<div className="w-full flex flex-col gap-0 items-center">
|
<div className="w-full flex flex-col gap-0 items-center">
|
||||||
<Logo className="w-8 h-8 mb-6" />
|
<Logo className="w-8 h-8 mb-6" />
|
||||||
{navbarLinks.map((item) => (
|
{navbarLinks.map((item) => (
|
||||||
|
<>
|
||||||
<NavItem
|
<NavItem
|
||||||
className="mx-[10px] mb-6"
|
className={`mx-[10px] ${item.id == 1 ? "mb-2" : "mb-6"}`}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
Icon={item.icon}
|
Icon={item.icon}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
url={item.url}
|
url={item.url}
|
||||||
dev={item.dev}
|
dev={item.dev}
|
||||||
/>
|
/>
|
||||||
|
{item.id == 1 && <CameraGroupSelector />}
|
||||||
|
</>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<SettingsNavItems className="hidden md:flex flex-col items-center mb-8" />
|
<SettingsNavItems className="hidden md:flex flex-col items-center mb-8" />
|
||||||
|
|||||||
@ -3,16 +3,16 @@ import { useLocation, useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
export default function useOverlayState(
|
export default function useOverlayState(
|
||||||
key: string,
|
key: string,
|
||||||
): [string | undefined, (value: string) => void] {
|
): [string | undefined, (value: string, replace?: boolean) => void] {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const currentLocationState = location.state;
|
const currentLocationState = location.state;
|
||||||
|
|
||||||
const setOverlayStateValue = useCallback(
|
const setOverlayStateValue = useCallback(
|
||||||
(value: string) => {
|
(value: string, replace: boolean = false) => {
|
||||||
const newLocationState = { ...currentLocationState };
|
const newLocationState = { ...currentLocationState };
|
||||||
newLocationState[key] = value;
|
newLocationState[key] = value;
|
||||||
navigate(location.pathname, { state: newLocationState });
|
navigate(location.pathname, { state: newLocationState, replace });
|
||||||
},
|
},
|
||||||
// we know that these deps are correct
|
// we know that these deps are correct
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@ -7,17 +7,26 @@ import useSWR from "swr";
|
|||||||
|
|
||||||
function Live() {
|
function Live() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
const [selectedCameraName, setSelectedCameraName] = useOverlayState("camera");
|
const [selectedCameraName, setSelectedCameraName] = useOverlayState("camera");
|
||||||
|
const [cameraGroup] = useOverlayState("cameraGroup");
|
||||||
|
|
||||||
const cameras = useMemo(() => {
|
const cameras = useMemo(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cameraGroup) {
|
||||||
|
const group = config.camera_groups[cameraGroup];
|
||||||
|
return Object.values(config.cameras)
|
||||||
|
.filter((conf) => conf.enabled && group.cameras.includes(conf.name))
|
||||||
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||||
|
}
|
||||||
|
|
||||||
return Object.values(config.cameras)
|
return Object.values(config.cameras)
|
||||||
.filter((conf) => conf.ui.dashboard && conf.enabled)
|
.filter((conf) => conf.ui.dashboard && conf.enabled)
|
||||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||||
}, [config]);
|
}, [config, cameraGroup]);
|
||||||
|
|
||||||
const selectedCamera = useMemo(
|
const selectedCamera = useMemo(
|
||||||
() => cameras.find((cam) => cam.name == selectedCameraName),
|
() => cameras.find((cam) => cam.name == selectedCameraName),
|
||||||
|
|||||||
@ -204,6 +204,12 @@ export interface CameraConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CameraGroupConfig = {
|
||||||
|
cameras: string[];
|
||||||
|
icon: string;
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
export interface FrigateConfig {
|
export interface FrigateConfig {
|
||||||
audio: {
|
audio: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -276,6 +282,8 @@ export interface FrigateConfig {
|
|||||||
|
|
||||||
go2rtc: Record<string, unknown>;
|
go2rtc: Record<string, unknown>;
|
||||||
|
|
||||||
|
camera_groups: { [groupName: string]: CameraGroupConfig };
|
||||||
|
|
||||||
live: {
|
live: {
|
||||||
height: number;
|
height: number;
|
||||||
quality: number;
|
quality: number;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useFrigateReviews } from "@/api/ws";
|
import { useFrigateReviews } from "@/api/ws";
|
||||||
import Logo from "@/components/Logo";
|
import Logo from "@/components/Logo";
|
||||||
|
import { CameraGroupSelector } from "@/components/filter/CameraGroupSelector";
|
||||||
import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail";
|
import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail";
|
||||||
import LivePlayer from "@/components/player/LivePlayer";
|
import LivePlayer from "@/components/player/LivePlayer";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -78,7 +79,7 @@ export default function LiveDashboardView({
|
|||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="relative h-9 flex items-center justify-between">
|
<div className="relative h-9 flex items-center justify-between">
|
||||||
<Logo className="absolute inset-y-0 inset-x-1/2 -translate-x-1/2 h-8" />
|
<Logo className="absolute inset-y-0 inset-x-1/2 -translate-x-1/2 h-8" />
|
||||||
<div />
|
<CameraGroupSelector />
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
className={
|
className={
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user