Streamline live view (#9772)

* Break out live page

* Improving layouts and add chip component

* Improve default camera player sizing

* Improve live updating

* Cleanup and fit figma

* Use fixed height

* Masonry layout

* Fix stuff

* Don't force heights

* Adjust scaling

* Cleanup

* remove sidebar (#9731)

* remove sidebar

* keep sidebar on mobile for now and add icons

* Fix revalidation

* Cleanup

* Cleanup width

* Add chips for activity on cameras

* Remove dashboard from header

* Use Inter font (#9735)

* Show still image when no activity is occurring

* remove unused search params

* add playing check for webrtc

* Don't use grid at all for single column

* Fix height on mobile

* a few style updates to better match figma (#9745)

* Remove active objects when they become stationary

* Move to sidebar only and make settings separate component

* Fix layout

* Animate visibility of chips

* Sidebar is full screen

* Fix tall aspect ratio cameras

* Fix complicated aspect logic

* remove

* Adjust thumbnail aspect and add text

* margin on single column layout

* Smaller event thumb text

* Simplify basic image view

* Only show the red dot when camera is recording

* Improve typing for camera toggles

* animate chips with react-transition-group (#9763)

* don't flash when going to still image

* revalidate

* tooltips and active tracking outline (#9766)

* tooltips

* fix tooltip provider and add active tracking outline

* remove unused icon

* remove figma comment

* Get live mode working for jsmpeg

* add small gradient below timeago on event thumbnails (#9767)

* Create live mode hook and make sure jsmpeg can be used

* Enforce env var

* Use print

* Remove unstable

* Add tooltips to thumbnails

* Put back vite

* Format

* Update web/src/components/player/JSMpegPlayer.tsx

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Co-authored-by: Blake Blackshear <blake@frigate.video>
This commit is contained in:
Nicolas Mowen 2024-02-10 05:30:53 -07:00 committed by GitHub
parent f6a4c2a7b3
commit 64988c9be0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1111 additions and 972 deletions

View File

@ -647,6 +647,13 @@ class FrigateApp:
self.init_logger()
logger.info(f"Starting Frigate ({VERSION})")
if not os.environ.get("I_PROMISE_I_WONT_MAKE_AN_ISSUE_ON_GITHUB"):
print(
"Frigate 0.14 UNSTABLE - not for public use at this time. Please use Frigate stable"
)
sys.exit(1)
try:
self.ensure_dirs()
try:

38
web/package-lock.json generated
View File

@ -42,6 +42,7 @@
"react-hook-form": "^7.48.2",
"react-icons": "^4.12.0",
"react-router-dom": "^6.20.1",
"react-transition-group": "^4.4.5",
"react-use-websocket": "^4.5.0",
"recoil": "^0.7.7",
"sonner": "^1.4.0",
@ -64,6 +65,7 @@
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
@ -2498,6 +2500,15 @@
"react-icons": "*"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.10",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz",
"integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/scheduler": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
@ -3741,8 +3752,7 @@
"node_modules/csstype": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==",
"devOptional": true
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
},
"node_modules/data-urls": {
"version": "5.0.0",
@ -3992,6 +4002,15 @@
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
@ -6867,6 +6886,21 @@
}
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/react-use-websocket": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.5.0.tgz",

View File

@ -47,6 +47,7 @@
"react-hook-form": "^7.48.2",
"react-icons": "^4.12.0",
"react-router-dom": "^6.20.1",
"react-transition-group": "^4.4.5",
"react-use-websocket": "^4.5.0",
"recoil": "^0.7.7",
"sonner": "^1.4.0",
@ -69,6 +70,7 @@
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",

Binary file not shown.

View File

@ -4,7 +4,6 @@ import { useState } from "react";
import Wrapper from "@/components/Wrapper";
import Sidebar from "@/components/Sidebar";
import Header from "@/components/Header";
import Dashboard from "@/pages/Dashboard";
import Live from "@/pages/Live";
import History from "@/pages/History";
import Export from "@/pages/Export";
@ -35,8 +34,7 @@ function App() {
className="overflow-x-hidden px-4 py-2 w-screen md:w-full"
>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/live/:camera?" element={<Live />} />
<Route path="/" element={<Live />} />
<Route path="/history" element={<History />} />
<Route path="/export" element={<Export />} />
<Route path="/storage" element={<Storage />} />

View File

@ -10,7 +10,7 @@ import {
import { produce, Draft } from "immer";
import useWebSocket, { ReadyState } from "react-use-websocket";
import { FrigateConfig } from "@/types/frigateConfig";
import { FrigateEvent } from "@/types/ws";
import { FrigateEvent, ToggleableSetting } from "@/types/ws";
type ReducerState = {
[topic: string]: {
@ -149,8 +149,8 @@ export function useWs(watchTopic: string, publishTopic: string) {
}
export function useDetectState(camera: string): {
payload: string;
send: (payload: string, retain?: boolean) => void;
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
@ -160,8 +160,8 @@ export function useDetectState(camera: string): {
}
export function useRecordingsState(camera: string): {
payload: string;
send: (payload: string, retain?: boolean) => void;
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
@ -171,8 +171,8 @@ export function useRecordingsState(camera: string): {
}
export function useSnapshotsState(camera: string): {
payload: string;
send: (payload: string, retain?: boolean) => void;
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
@ -182,8 +182,8 @@ export function useSnapshotsState(camera: string): {
}
export function useAudioState(camera: string): {
payload: string;
send: (payload: string, retain?: boolean) => void;
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
@ -228,7 +228,7 @@ export function useMotionActivity(camera: string): { payload: string } {
return { payload };
}
export function useAudioActivity(camera: string): { payload: string } {
export function useAudioActivity(camera: string): { payload: number } {
const {
value: { payload },
} = useWs(`${camera}/audio/rms`, "");

View File

@ -0,0 +1,38 @@
import { ReactNode, useRef } from "react";
import { CSSTransition } from "react-transition-group";
type ChipProps = {
className?: string;
children?: ReactNode[];
in?: boolean;
};
export default function Chip({
className,
children,
in: inProp = true,
}: ChipProps) {
const nodeRef = useRef(null);
return (
<CSSTransition
in={inProp}
nodeRef={nodeRef}
timeout={500}
classNames={{
enter: "opacity-0",
enterActive: "opacity-100 transition-opacity duration-500 ease-in-out",
exit: "opacity-100",
exitActive: "opacity-0 transition-opacity duration-500 ease-in-out",
}}
unmountOnExit
>
<div
ref={nodeRef}
className={`flex px-2 py-1.5 rounded-2xl items-center z-10 ${className}`}
>
{children}
</div>
</CSSTransition>
);
}

View File

@ -1,102 +1,45 @@
import { Link } from "react-router-dom";
import Logo from "@/components/Logo";
import {
LuActivity,
LuGithub,
LuHardDrive,
LuLifeBuoy,
LuList,
LuMenu,
LuMoon,
LuMoreVertical,
LuPenSquare,
LuRotateCw,
LuSettings,
LuSun,
LuSunMoon,
} from "react-icons/lu";
import { IoColorPalette } from "react-icons/io5";
import { CgDarkMode } from "react-icons/cg";
import { LuMenu } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import Heading from "./ui/heading";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
colorSchemes,
friendlyColorSchemeName,
useTheme,
} from "@/context/theme-provider";
import { useEffect, useState } from "react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "./ui/sheet";
import ActivityIndicator from "./ui/activity-indicator";
import { useRestart } from "@/api/ws";
import { ENV } from "@/env";
import { NavLink } from "react-router-dom";
import { navbarLinks } from "@/pages/site-navigation";
import SettingsNavItems from "./settings/SettingsNavItems";
type HeaderProps = {
onToggleNavbar: () => void;
};
function Header({ onToggleNavbar }: HeaderProps) {
const { theme, colorScheme, setTheme, setColorScheme } = useTheme();
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
const [countdown, setCountdown] = useState(60);
const { send: sendRestart } = useRestart();
useEffect(() => {
let countdownInterval: NodeJS.Timeout;
if (restartingSheetOpen) {
countdownInterval = setInterval(() => {
setCountdown((prevCountdown) => prevCountdown - 1);
}, 1000);
}
return () => {
clearInterval(countdownInterval);
};
}, [restartingSheetOpen]);
useEffect(() => {
if (countdown === 0) {
window.location.href = "/";
}
}, [countdown]);
const handleForceReload = () => {
window.location.href = "/";
};
function HeaderNavigation() {
return (
<div className="flex gap-10 lg:gap-20 justify-between pt-2 mb-2 border-b-[1px] px-4 items-center">
<div className="flex gap-4 items-center flex-shrink-0 m-5">
<div className="hidden md:flex">
{navbarLinks.map((item) => {
let shouldRender = item.dev ? ENV !== "production" : true;
return (
shouldRender && (
<NavLink
key={item.id}
to={item.url}
className={({ isActive }) =>
`my-2 py-3 px-4 text-muted-foreground flex flex-row items-center text-center rounded-lg gap-2 hover:bg-border ${
isActive ? "font-bold bg-popover text-popover-foreground" : ""
}`
}
>
<div className="text-sm">{item.title}</div>
</NavLink>
)
);
})}
</div>
);
}
function Header({ onToggleNavbar }: HeaderProps) {
return (
<div className="flex gap-10 lg:gap-20 justify-between pt-2 mb-2 border-b-[1px] px-4 items-center md:hidden">
<div className="flex gap-4 items-center flex-shrink-0 m-1">
<Button
variant="ghost"
size="icon"
@ -110,207 +53,11 @@ function Header({ onToggleNavbar }: HeaderProps) {
<div className="w-10 mr-5">
<Logo />
</div>
<Heading as="h1">Frigate</Heading>
</div>
{ENV == "production" && (
<div className="text-red-500 text-sm items-center text-right">
0.14 unstable
</div>
)}
</Link>
<HeaderNavigation />
</div>
<div className="flex flex-shrink-0 md:gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost">
<LuMoreVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="md:w-72 mr-5">
<DropdownMenuLabel>System</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<Link to="/storage">
<DropdownMenuItem>
<LuHardDrive className="mr-2 h-4 w-4" />
<span>Storage</span>
</DropdownMenuItem>
</Link>
<Link to="/system">
<DropdownMenuItem>
<LuActivity className="mr-2 h-4 w-4" />
<span>System metrics</span>
</DropdownMenuItem>
</Link>
<Link to="/logs">
<DropdownMenuItem>
<LuList className="mr-2 h-4 w-4" />
<span>System logs</span>
</DropdownMenuItem>
</Link>
</DropdownMenuGroup>
<DropdownMenuLabel className="mt-3">
Configuration
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<Link to="/settings">
<DropdownMenuItem>
<LuSettings className="mr-2 h-4 w-4" />
<span>Settings</span>
</DropdownMenuItem>
</Link>
<Link to="/config">
<DropdownMenuItem>
<LuPenSquare className="mr-2 h-4 w-4" />
<span>Configuration editor</span>
</DropdownMenuItem>
</Link>
<DropdownMenuLabel className="mt-3">Appearance</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<LuSunMoon className="mr-2 h-4 w-4" />
<span>Dark Mode</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => setTheme("light")}>
{theme === "light" ? (
<>
<LuSun className="mr-2 h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
Light
</>
) : (
<span className="mr-2 ml-6">Light</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
{theme === "dark" ? (
<>
<LuMoon className="mr-2 h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
Dark
</>
) : (
<span className="mr-2 ml-6">Dark</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
{theme === "system" ? (
<>
<CgDarkMode className="mr-2 h-4 w-4 scale-100 transition-all" />
System
</>
) : (
<span className="mr-2 ml-6">System</span>
)}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<LuSunMoon className="mr-2 h-4 w-4" />
<span>Theme</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{colorSchemes.map((scheme) => (
<DropdownMenuItem
key={scheme}
onClick={() => setColorScheme(scheme)}
>
{scheme === colorScheme ? (
<>
<IoColorPalette className="mr-2 h-4 w-4 rotate-0 scale-100 transition-all" />
{friendlyColorSchemeName(scheme)}
</>
) : (
<span className="mr-2 ml-6">
{friendlyColorSchemeName(scheme)}
</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuLabel className="mt-3">Help</DropdownMenuLabel>
<DropdownMenuSeparator />
<a href="https://docs.frigate.video">
<DropdownMenuItem>
<LuLifeBuoy className="mr-2 h-4 w-4" />
<span>Documentation</span>
</DropdownMenuItem>
</a>
<a href="https://github.com/blakeblackshear/frigate">
<DropdownMenuItem>
<LuGithub className="mr-2 h-4 w-4" />
<span>GitHub</span>
</DropdownMenuItem>
</a>
<DropdownMenuSeparator className="mt-3" />
<DropdownMenuItem onClick={() => setRestartDialogOpen(true)}>
<LuRotateCw className="mr-2 h-4 w-4" />
<span>Restart Frigate</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{restartDialogOpen && (
<AlertDialog
open={restartDialogOpen}
onOpenChange={() => setRestartDialogOpen(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to restart Frigate?
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setRestartingSheetOpen(true);
sendRestart("restart");
}}
>
Restart
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{restartingSheetOpen && (
<>
<Sheet
open={restartingSheetOpen}
onOpenChange={() => setRestartingSheetOpen(false)}
>
<SheetContent
side="top"
onInteractOutside={(e) => e.preventDefault()}
>
<div className="flex flex-col items-center">
<ActivityIndicator />
<SheetHeader className="mt-5 text-center">
<SheetTitle className="text-center">
Frigate is Restarting
</SheetTitle>
<SheetDescription className="text-center">
<p>This page will reload in {countdown} seconds.</p>
</SheetDescription>
</SheetHeader>
<Button size="lg" className="mt-5" onClick={handleForceReload}>
Force Reload Now
</Button>
</div>
</SheetContent>
</Sheet>
</>
)}
<SettingsNavItems className="flex flex-shrink-0 md:gap-2" />
</div>
);
}

View File

@ -1,6 +1,9 @@
export default function Logo() {
type LogoProps = {
className?: string;
};
export default function Logo({ className }: LogoProps) {
return (
<svg viewBox="0 0 512 512" className="fill-current">
<svg viewBox="0 0 512 512" className={`fill-current ${className}`}>
<path d="M130 446.5C131.6 459.3 145 468 137 470C129 472 94 406.5 86 378.5C78 350.5 73.5 319 75.5 301C77.4999 283 181 255 181 247.5C181 240 147.5 247 146 241C144.5 235 171.3 238.6 178.5 229C189.75 214 204 216.5 213 208.5C222 200.5 233 170 235 157C237 144 215 129 209 119C203 109 222 102 268 83C314 64 460 22 462 27C464 32 414 53 379 66C344 79 287 104 287 111C287 118 290 123.5 288 139.5C286 155.5 285.76 162.971 282 173.5C279.5 180.5 277 197 282 212C286 224 299 233 305 235C310 235.333 323.8 235.8 339 235C358 234 385 236 385 241C385 246 344 243 344 250C344 257 386 249 385 256C384 263 350 260 332 260C317.6 260 296.333 259.333 287 256L285 263C281.667 263 274.7 265 267.5 265C258.5 265 258 268 241.5 268C225 268 230 267 215 266C200 265 144 308 134 322C124 336 130 370 130 385.5C130 399.428 128 430.5 130 446.5Z" />
</svg>
);

View File

@ -1,49 +1,15 @@
import { IconType } from "react-icons";
import {
LuConstruction,
LuFileUp,
LuFilm,
LuLayoutDashboard,
LuVideo,
} from "react-icons/lu";
import { NavLink } from "react-router-dom";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import Logo from "./Logo";
import { ENV } from "@/env";
const navbarLinks = [
{
id: 1,
icon: LuLayoutDashboard,
title: "Dashboard",
url: "/",
},
{
id: 2,
icon: LuVideo,
title: "Live",
url: "/live",
},
{
id: 3,
icon: LuFilm,
title: "History",
url: "/history",
},
{
id: 4,
icon: LuFileUp,
title: "Export",
url: "/export",
},
{
id: 5,
icon: LuConstruction,
title: "UI Playground",
url: "/playground",
dev: true,
},
];
import { navbarLinks } from "@/pages/site-navigation";
import SettingsNavItems from "./settings/SettingsNavItems";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
function Sidebar({
sheetOpen,
@ -53,35 +19,34 @@ function Sidebar({
setSheetOpen: (open: boolean) => void;
}) {
const sidebar = (
<aside className="sticky top-0 overflow-y-auto scrollbar-hidden py-4 lg:pt-0 flex flex-col ml-1 lg:w-56 gap-0">
{navbarLinks.map((item) => (
<SidebarItem
key={item.id}
Icon={item.icon}
title={item.title}
url={item.url}
dev={item.dev}
onClick={() => setSheetOpen(false)}
/>
))}
<aside className="w-[52px] z-10 h-screen sticky top-0 overflow-y-auto scrollbar-hidden py-4 flex flex-col justify-between">
<div className="w-full flex flex-col gap-0 items-center">
<Logo className="w-8 h-8 mb-6" />
{navbarLinks.map((item) => (
<SidebarItem
key={item.id}
Icon={item.icon}
title={item.title}
url={item.url}
dev={item.dev}
onClick={() => setSheetOpen(false)}
/>
))}
</div>
<SettingsNavItems className="hidden md:flex flex-col items-center" />
</aside>
);
return (
<>
<div className="hidden md:block">{sidebar}</div>
<Sheet
open={sheetOpen}
modal={false}
onOpenChange={() => setSheetOpen(false)}
>
<SheetContent side="left" className="w-[120px]">
<div className="w-full flex flex-row justify-center">
<div className="w-10">
<Logo />
</div>
</div>
<SheetContent side="left" className="w-[90px]">
<div className="w-full flex flex-row justify-center"></div>
{sidebar}
</SheetContent>
</Sheet>
@ -102,18 +67,26 @@ function SidebarItem({ Icon, title, url, dev, onClick }: SidebarItemProps) {
return (
shouldRender && (
<NavLink
to={url}
onClick={onClick}
className={({ isActive }) =>
`py-4 px-2 flex flex-col lg:flex-row items-center rounded-lg gap-2 lg:w-full hover:bg-border ${
isActive ? "font-bold bg-popover text-popover-foreground" : ""
}`
}
>
<Icon className="w-6 h-6 mr-1" />
<div className="text-sm text-center">{title}</div>
</NavLink>
<Tooltip>
<NavLink
to={url}
onClick={onClick}
className={({ isActive }) =>
`mx-[10px] mb-6 flex flex-col justify-center items-center rounded-lg ${
isActive
? "font-bold text-white bg-primary"
: "text-muted-foreground bg-secondary"
}`
}
>
<TooltipTrigger>
<Icon className="w-5 h-5 m-[6px]" />
</TooltipTrigger>
</NavLink>
<TooltipContent side="right">
<p>{title}</p>
</TooltipContent>
</Tooltip>
)
);
}

View File

@ -6,6 +6,7 @@ type AutoUpdatingCameraImageProps = {
searchParams?: {};
showFps?: boolean;
className?: string;
reloadInterval?: number;
};
const MIN_LOAD_TIMEOUT_MS = 200;
@ -15,6 +16,7 @@ export default function AutoUpdatingCameraImage({
searchParams = "",
showFps = true,
className,
reloadInterval = MIN_LOAD_TIMEOUT_MS,
}: AutoUpdatingCameraImageProps) {
const [key, setKey] = useState(Date.now());
const [fps, setFps] = useState<string>("0");
@ -23,14 +25,14 @@ export default function AutoUpdatingCameraImage({
const loadTime = Date.now() - key;
if (showFps) {
setFps((1000 / Math.max(loadTime, MIN_LOAD_TIMEOUT_MS)).toFixed(1));
setFps((1000 / Math.max(loadTime, reloadInterval)).toFixed(1));
}
setTimeout(
() => {
setKey(Date.now());
},
loadTime > MIN_LOAD_TIMEOUT_MS ? 1 : MIN_LOAD_TIMEOUT_MS
loadTime > reloadInterval ? 1 : reloadInterval
);
}, [key, setFps]);

View File

@ -1,102 +1,56 @@
import { useApiHost } from "@/api";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import useSWR from "swr";
import ActivityIndicator from "../ui/activity-indicator";
import { useResizeObserver } from "@/hooks/resize-observer";
type CameraImageProps = {
className?: string;
camera: string;
onload?: (event: Event) => void;
onload?: () => void;
searchParams?: {};
stretch?: boolean; // stretch to fit width
fitAspect?: number; // shrink to fit height
};
export default function CameraImage({
className,
camera,
onload,
searchParams = "",
stretch = false,
fitAspect,
}: CameraImageProps) {
const { data: config } = useSWR("config");
const apiHost = useApiHost();
const [hasLoaded, setHasLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
// Add scrollbar width (when visible) to the available observer width to eliminate screen juddering.
// https://github.com/blakeblackshear/frigate/issues/1657
let scrollBarWidth = 0;
if (window.innerWidth && document.body.offsetWidth) {
scrollBarWidth = window.innerWidth - document.body.offsetWidth;
}
const availableWidth = scrollBarWidth
? containerWidth + scrollBarWidth
: containerWidth;
const imgRef = useRef<HTMLImageElement | null>(null);
const { name } = config ? config.cameras[camera] : "";
const enabled = config ? config.cameras[camera].enabled : "True";
const { width, height } = config
? config.cameras[camera].detect
: { width: 1, height: 1 };
const aspectRatio = width / height;
const scaledHeight = useMemo(() => {
const scaledHeight =
aspectRatio < (fitAspect ?? 0)
? Math.floor(containerHeight)
: Math.floor(availableWidth / aspectRatio);
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height);
if (finalHeight > 0) {
return finalHeight;
}
return 100;
}, [availableWidth, aspectRatio, height, stretch]);
const scaledWidth = useMemo(
() => Math.ceil(scaledHeight * aspectRatio - scrollBarWidth),
[scaledHeight, aspectRatio, scrollBarWidth]
);
const img = useMemo(() => new Image(), []);
img.onload = useCallback(
(event: Event) => {
setHasLoaded(true);
if (canvasRef.current) {
const ctx = canvasRef.current.getContext("2d");
ctx?.drawImage(img, 0, 0, scaledWidth, scaledHeight);
}
onload && onload(event);
},
[img, scaledHeight, scaledWidth, setHasLoaded, onload, canvasRef]
);
useEffect(() => {
if (!config || scaledHeight === 0 || !canvasRef.current) {
if (!config || !imgRef.current) {
return;
}
img.src = `${apiHost}api/${name}/latest.jpg?h=${scaledHeight}${
searchParams ? `&${searchParams}` : ""
imgRef.current.src = `${apiHost}api/${name}/latest.jpg${
searchParams ? `?${searchParams}` : ""
}`;
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
}, [apiHost, name, imgRef, searchParams, config]);
return (
<div
className={`relative w-full ${
fitAspect && aspectRatio < fitAspect ? "h-full flex justify-center" : ""
}`}
className={`relative w-full h-full flex justify-center ${className}`}
ref={containerRef}
>
{enabled ? (
<canvas
data-testid="cameraimage-canvas"
height={scaledHeight}
ref={canvasRef}
width={scaledWidth}
<img
ref={imgRef}
className="object-contain rounded-2xl"
onLoad={() => {
setHasLoaded(true);
if (onload) {
onload();
}
}}
/>
) : (
<div className="text-center pt-6">
@ -104,10 +58,7 @@ export default function CameraImage({
</div>
)}
{!hasLoaded && enabled ? (
<div
className="absolute inset-0 flex justify-center"
style={{ height: `${scaledHeight}px` }}
>
<div className="absolute left-0 right-0 top-0 bottom-0 flex justify-center items-center">
<ActivityIndicator />
</div>
) : null}

View File

@ -1,120 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { AspectRatio } from "../ui/aspect-ratio";
import CameraImage from "./CameraImage";
import { LuEar } from "react-icons/lu";
import { CameraConfig } from "@/types/frigateConfig";
import { TbUserScan } from "react-icons/tb";
import { MdLeakAdd } from "react-icons/md";
import {
useAudioActivity,
useFrigateEvents,
useMotionActivity,
} from "@/api/ws";
type DynamicCameraImageProps = {
camera: CameraConfig;
aspect: number;
};
const INTERVAL_INACTIVE_MS = 60000; // refresh once a minute
const INTERVAL_ACTIVE_MS = 1000; // refresh once a second
export default function DynamicCameraImage({
camera,
aspect,
}: DynamicCameraImageProps) {
const [key, setKey] = useState(Date.now());
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | undefined>(
undefined
);
const [activeObjects, setActiveObjects] = useState<string[]>([]);
const hasActiveObjects = useMemo(
() => activeObjects.length > 0,
[activeObjects]
);
const { payload: detectingMotion } = useMotionActivity(camera.name);
const { payload: event } = useFrigateEvents();
const { payload: audioRms } = useAudioActivity(camera.name);
useEffect(() => {
if (!event) {
return;
}
if (event.after.camera != camera.name) {
return;
}
if (event.type == "end") {
const eventIndex = activeObjects.indexOf(event.after.id);
if (eventIndex != -1) {
const newActiveObjects = [...activeObjects];
newActiveObjects.splice(eventIndex, 1);
setActiveObjects(newActiveObjects);
}
} else {
if (!event.after.stationary) {
const eventIndex = activeObjects.indexOf(event.after.id);
if (eventIndex == -1) {
const newActiveObjects = [...activeObjects, event.after.id];
setActiveObjects(newActiveObjects);
clearTimeout(timeoutId);
setKey(Date.now());
}
}
}
}, [event, activeObjects]);
const handleLoad = useCallback(() => {
const loadTime = Date.now() - key;
const loadInterval = hasActiveObjects
? INTERVAL_ACTIVE_MS
: INTERVAL_INACTIVE_MS;
const tId = setTimeout(
() => {
setKey(Date.now());
},
loadTime > loadInterval ? 1 : loadInterval
);
setTimeoutId(tId);
}, [key]);
return (
<AspectRatio
ratio={aspect}
className="bg-black flex justify-center items-center relative"
>
<CameraImage
camera={camera.name}
fitAspect={aspect}
searchParams={`cache=${key}`}
onload={handleLoad}
/>
<div className="flex absolute right-0 bottom-0 bg-black bg-opacity-20 rounded p-1">
<MdLeakAdd
className={`${
detectingMotion == "ON" ? "text-motion" : "text-gray-600"
}`}
/>
<TbUserScan
className={`${
activeObjects.length > 0 ? "text-object" : "text-gray-600"
}`}
/>
{camera.audio.enabled_in_config && (
<LuEar
className={`${
parseInt(audioRms) >= camera.audio.min_volume
? "text-audio"
: "text-gray-600"
}`}
/>
)}
</div>
</AspectRatio>
);
}

View File

@ -0,0 +1,117 @@
import { useApiHost } from "@/api";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import ActivityIndicator from "../ui/activity-indicator";
import { useResizeObserver } from "@/hooks/resize-observer";
type CameraImageProps = {
className?: string;
camera: string;
onload?: (event: Event) => void;
searchParams?: {};
stretch?: boolean; // stretch to fit width
fitAspect?: number; // shrink to fit height
};
export default function CameraImage({
className,
camera,
onload,
searchParams = "",
stretch = false,
fitAspect,
}: CameraImageProps) {
const { data: config } = useSWR("config");
const apiHost = useApiHost();
const [hasLoaded, setHasLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
// Add scrollbar width (when visible) to the available observer width to eliminate screen juddering.
// https://github.com/blakeblackshear/frigate/issues/1657
let scrollBarWidth = 0;
if (window.innerWidth && document.body.offsetWidth) {
scrollBarWidth = window.innerWidth - document.body.offsetWidth;
}
const availableWidth = scrollBarWidth
? containerWidth + scrollBarWidth
: containerWidth;
const { name } = config ? config.cameras[camera] : "";
const enabled = config ? config.cameras[camera].enabled : "True";
const { width, height } = config
? config.cameras[camera].detect
: { width: 1, height: 1 };
const aspectRatio = width / height;
const scaledHeight = useMemo(() => {
const scaledHeight =
aspectRatio < (fitAspect ?? 0)
? Math.floor(containerHeight)
: Math.floor(availableWidth / aspectRatio);
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height);
if (finalHeight > 0) {
return finalHeight;
}
return 100;
}, [availableWidth, aspectRatio, height, stretch]);
const scaledWidth = useMemo(
() => Math.ceil(scaledHeight * aspectRatio - scrollBarWidth),
[scaledHeight, aspectRatio, scrollBarWidth]
);
const img = useMemo(() => new Image(), []);
img.onload = useCallback(
(event: Event) => {
setHasLoaded(true);
if (canvasRef.current) {
const ctx = canvasRef.current.getContext("2d");
ctx?.drawImage(img, 0, 0, scaledWidth, scaledHeight);
}
onload && onload(event);
},
[img, scaledHeight, scaledWidth, setHasLoaded, onload, canvasRef]
);
useEffect(() => {
if (!config || scaledHeight === 0 || !canvasRef.current) {
return;
}
img.src = `${apiHost}api/${name}/latest.jpg?h=${scaledHeight}${
searchParams ? `&${searchParams}` : ""
}`;
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
return (
<div
className={`relative w-full h-full flex justify-center ${className}`}
ref={containerRef}
>
{enabled ? (
<canvas
className="rounded-2xl"
data-testid="cameraimage-canvas"
height={scaledHeight}
ref={canvasRef}
width={scaledWidth}
/>
) : (
<div className="text-center pt-6">
Camera is disabled in config, no stream or snapshot available!
</div>
)}
{!hasLoaded && enabled ? (
<div
className="absolute inset-0 flex justify-center"
style={{ height: `${scaledHeight}px` }}
>
<ActivityIndicator />
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,44 @@
import { baseUrl } from "@/api/baseUrl";
import { Event as FrigateEvent } from "@/types/event";
import { LuStar } from "react-icons/lu";
import TimeAgo from "../dynamic/TimeAgo";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
type EventThumbnailProps = {
event: FrigateEvent;
onFavorite?: (e: Event, event: FrigateEvent) => void;
};
export function EventThumbnail({ event, onFavorite }: EventThumbnailProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className="relative rounded bg-cover aspect-square h-24 bg-no-repeat bg-center mr-4"
style={{
backgroundImage: `url(${baseUrl}api/events/${event.id}/thumbnail.jpg)`,
}}
>
<LuStar
className="absolute h-6 w-6 text-yellow-300 top-1 right-1 cursor-pointer"
onClick={(e: Event) => (onFavorite ? onFavorite(e, event) : null)}
fill={event.retain_indefinitely ? "currentColor" : "none"}
/>
<div className="absolute bottom-0 w-full h-6 bg-gradient-to-t from-slate-900/50 to-transparent">
<div className="absolute left-1 bottom-0 text-xs text-white w-full">
<TimeAgo time={event.start_time * 1000} dense />
</div>
</div>
</div>
</TooltipTrigger>
<TooltipContent>
{`${event.label} ${
event.sub_label ? `(${event.sub_label})` : ""
} detected with score of ${(event.data.score * 100).toFixed(0)}% ${
event.data.sub_label_score
? `(${event.data.sub_label_score * 100}%)`
: ""
}`}
</TooltipContent>
</Tooltip>
);
}

View File

@ -5,6 +5,7 @@ import JSMpeg from "@cycjimmy/jsmpeg-player";
import { useEffect, useMemo, useRef } from "react";
type JSMpegPlayerProps = {
className?: string;
camera: string;
width: number;
height: number;
@ -14,11 +15,13 @@ export default function JSMpegPlayer({
camera,
width,
height,
className,
}: JSMpegPlayerProps) {
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
const playerRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | 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.
// https://github.com/blakeblackshear/frigate/issues/1657
@ -35,6 +38,10 @@ export default function JSMpegPlayer({
const scaledHeight = Math.floor(availableWidth / aspectRatio);
const finalHeight = Math.min(scaledHeight, height);
if (containerHeight < finalHeight) {
return containerHeight;
}
if (finalHeight > 0) {
return finalHeight;
}
@ -79,7 +86,7 @@ export default function JSMpegPlayer({
}, [url]);
return (
<div ref={containerRef}>
<div className={className} ref={containerRef}>
<div
ref={playerRef}
className={`jsmpeg`}

View File

@ -4,42 +4,75 @@ import AutoUpdatingCameraImage from "../camera/AutoUpdatingCameraImage";
import ActivityIndicator from "../ui/activity-indicator";
import { Button } from "../ui/button";
import { LuSettings } from "react-icons/lu";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
import { usePersistence } from "@/hooks/use-persistence";
import MSEPlayer from "./MsePlayer";
import JSMpegPlayer from "./JSMpegPlayer";
import { MdCircle, MdLeakAdd } from "react-icons/md";
import { BsSoundwave } from "react-icons/bs";
import Chip from "../Chip";
import useCameraActivity from "@/hooks/use-camera-activity";
import { useRecordingsState } from "@/api/ws";
import { LivePlayerMode } from "@/types/live";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
const emptyObject = Object.freeze({});
type LivePlayerProps = {
className?: string;
cameraConfig: CameraConfig;
liveMode: string;
preferredLiveMode?: LivePlayerMode;
showStillWithoutActivity?: boolean;
};
type Options = { [key: string]: boolean };
export default function LivePlayer({
className,
cameraConfig,
liveMode,
preferredLiveMode,
showStillWithoutActivity = true,
}: LivePlayerProps) {
const [showSettings, setShowSettings] = useState(false);
// camera activity
const { activeMotion, activeAudio, activeTracking } =
useCameraActivity(cameraConfig);
const liveMode = useCameraLiveMode(cameraConfig, preferredLiveMode);
const [liveReady, setLiveReady] = useState(false);
useEffect(() => {
if (!liveReady) {
if (activeMotion && liveMode == "jsmpeg") {
setLiveReady(true);
}
return;
}
if (!activeMotion && !activeTracking) {
setLiveReady(false);
}
}, [activeMotion, activeTracking, liveReady]);
const { payload: recording } = useRecordingsState(cameraConfig.name);
// debug view settings
const [showSettings, setShowSettings] = useState(false);
const [options, setOptions] = usePersistence(
`${cameraConfig.name}-feed`,
`${cameraConfig?.name}-feed`,
emptyObject
);
const handleSetOption = useCallback(
(id: string, value: boolean) => {
const newOptions = { ...options, [id]: value };
setOptions(newOptions);
},
[options, setOptions]
[options]
);
const searchParams = useMemo(
() =>
new URLSearchParams(
@ -51,26 +84,34 @@ export default function LivePlayer({
),
[options]
);
const handleToggleSettings = useCallback(() => {
setShowSettings(!showSettings);
}, [showSettings, setShowSettings]);
}, [showSettings]);
if (!cameraConfig) {
return <ActivityIndicator />;
}
let player;
if (liveMode == "webrtc") {
return (
<div className="max-w-5xl">
<WebRtcPlayer camera={cameraConfig.live.stream_name} />
</div>
player = (
<WebRtcPlayer
className={`rounded-2xl h-full ${liveReady ? "" : "hidden"}`}
camera={cameraConfig.live.stream_name}
onPlaying={() => setLiveReady(true)}
/>
);
} else if (liveMode == "mse") {
if ("MediaSource" in window || "ManagedMediaSource" in window) {
return (
<div className="max-w-5xl">
<MSEPlayer camera={cameraConfig.live.stream_name} />
</div>
player = (
<MSEPlayer
className={`rounded-2xl h-full ${liveReady ? "" : "hidden"}`}
camera={cameraConfig.name}
onPlaying={() => setLiveReady(true)}
/>
);
} else {
return (
player = (
<div className="w-5xl text-center text-sm">
MSE is only supported on iOS 17.1+. You'll need to update if available
or use jsmpeg / webRTC streams. See the docs for more info.
@ -78,17 +119,16 @@ export default function LivePlayer({
);
}
} else if (liveMode == "jsmpeg") {
return (
<div className={`max-w-[${cameraConfig.detect.width}px]`}>
<JSMpegPlayer
camera={cameraConfig.name}
width={cameraConfig.detect.width}
height={cameraConfig.detect.height}
/>
</div>
player = (
<JSMpegPlayer
className="w-full flex justify-center"
camera={cameraConfig.name}
width={cameraConfig.detect.width}
height={cameraConfig.detect.height}
/>
);
} else if (liveMode == "debug") {
return (
player = (
<>
<AutoUpdatingCameraImage
camera={cameraConfig.name}
@ -116,8 +156,63 @@ export default function LivePlayer({
</>
);
} else {
<ActivityIndicator />;
player = <ActivityIndicator />;
}
return (
<div
className={`relative flex justify-center w-full outline ${
activeTracking
? "outline-destructive outline-1 rounded-2xl shadow-[0_0_6px_1px] shadow-destructive"
: "outline-0"
} transition-all duration-500 ${className}`}
>
{(showStillWithoutActivity == false || activeMotion || activeTracking) &&
player}
<div
className={`absolute left-0 top-0 right-0 bottom-0 w-full ${
showStillWithoutActivity && !liveReady ? "visible" : "invisible"
}`}
>
<AutoUpdatingCameraImage
className="w-full h-full"
camera={cameraConfig.name}
showFps={false}
reloadInterval={30000}
/>
</div>
<div className="absolute flex left-2 top-2 gap-2">
<Chip
in={activeMotion}
className={`bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500/90`}
>
<MdLeakAdd className="w-4 h-4 text-motion" />
<div className="ml-1 text-white text-xs">Motion</div>
</Chip>
{cameraConfig.audio.enabled_in_config && (
<Chip
in={activeAudio}
className={`bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500/90`}
>
<BsSoundwave className="w-4 h-4 text-audio" />
<div className="ml-1 text-white text-xs">Sound</div>
</Chip>
)}
</div>
<Chip className="absolute right-2 top-2 bg-gradient-to-br from-gray-300/50 to-gray-500/90">
{recording == "ON" && (
<MdCircle className="w-2 h-2 drop-shadow-md shadow-danger text-danger" />
)}
<div className="ml-1 capitalize text-white text-xs">
{cameraConfig.name.replaceAll("_", " ")}
</div>
</Chip>
</div>
);
}
type DebugSettingsProps = {

View File

@ -3,9 +3,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type MSEPlayerProps = {
camera: string;
className?: string;
onPlaying?: () => void;
};
function MSEPlayer({ camera }: MSEPlayerProps) {
function MSEPlayer({ camera, className, onPlaying }: MSEPlayerProps) {
let connectTS: number = 0;
const RECONNECT_TIMEOUT: number = 30000;
@ -246,11 +248,11 @@ function MSEPlayer({ camera }: MSEPlayerProps) {
return (
<video
ref={videoRef}
controls
className={className}
playsInline
preload="auto"
onLoadedData={onPlaying}
muted
style={{ display: "block", width: "100%", height: "100%" }}
/>
);
}

View File

@ -2,15 +2,15 @@ import { baseUrl } from "@/api/baseUrl";
import { useCallback, useEffect, useRef } from "react";
type WebRtcPlayerProps = {
className?: string;
camera: string;
width?: number;
height?: number;
onPlaying?: () => void;
};
export default function WebRtcPlayer({
className,
camera,
width,
height,
onPlaying,
}: WebRtcPlayerProps) {
const pcRef = useRef<RTCPeerConnection | undefined>();
const videoRef = useRef<HTMLVideoElement | null>(null);
@ -149,12 +149,11 @@ export default function WebRtcPlayer({
<div>
<video
ref={videoRef}
className={className}
autoPlay
playsInline
controls
muted
width={width}
height={height}
onLoadedData={onPlaying}
/>
</div>
);

View File

@ -0,0 +1,310 @@
import {
LuActivity,
LuGithub,
LuHardDrive,
LuLifeBuoy,
LuList,
LuMoon,
LuPenSquare,
LuRotateCw,
LuSettings,
LuSun,
LuSunMoon,
} from "react-icons/lu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { Button } from "../ui/button";
import { Link } from "react-router-dom";
import { CgDarkMode } from "react-icons/cg";
import { VscAccount } from "react-icons/vsc";
import {
colorSchemes,
friendlyColorSchemeName,
useTheme,
} from "@/context/theme-provider";
import { IoColorPalette } from "react-icons/io5";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import { useEffect, useState } from "react";
import { useRestart } from "@/api/ws";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "../ui/sheet";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import ActivityIndicator from "../ui/activity-indicator";
type SettingsNavItemsProps = {
className?: string;
};
export default function SettingsNavItems({ className }: SettingsNavItemsProps) {
const { theme, colorScheme, setTheme, setColorScheme } = useTheme();
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
const [countdown, setCountdown] = useState(60);
const { send: sendRestart } = useRestart();
useEffect(() => {
let countdownInterval: NodeJS.Timeout;
if (restartingSheetOpen) {
countdownInterval = setInterval(() => {
setCountdown((prevCountdown) => prevCountdown - 1);
}, 1000);
}
return () => {
clearInterval(countdownInterval);
};
}, [restartingSheetOpen]);
useEffect(() => {
if (countdown === 0) {
window.location.href = "/";
}
}, [countdown]);
const handleForceReload = () => {
window.location.href = "/";
};
return (
<>
<div className={className}>
<DropdownMenu>
<DropdownMenuTrigger>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost">
<LuSettings />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Settings</p>
</TooltipContent>
</Tooltip>
</DropdownMenuTrigger>
<DropdownMenuContent className="md:w-72 mr-5">
<DropdownMenuLabel>System</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<Link to="/storage">
<DropdownMenuItem>
<LuHardDrive className="mr-2 h-4 w-4" />
<span>Storage</span>
</DropdownMenuItem>
</Link>
<Link to="/system">
<DropdownMenuItem>
<LuActivity className="mr-2 h-4 w-4" />
<span>System metrics</span>
</DropdownMenuItem>
</Link>
<Link to="/logs">
<DropdownMenuItem>
<LuList className="mr-2 h-4 w-4" />
<span>System logs</span>
</DropdownMenuItem>
</Link>
</DropdownMenuGroup>
<DropdownMenuLabel className="mt-3">
Configuration
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<Link to="/settings">
<DropdownMenuItem>
<LuSettings className="mr-2 h-4 w-4" />
<span>Settings</span>
</DropdownMenuItem>
</Link>
<Link to="/config">
<DropdownMenuItem>
<LuPenSquare className="mr-2 h-4 w-4" />
<span>Configuration editor</span>
</DropdownMenuItem>
</Link>
<DropdownMenuLabel className="mt-3">Appearance</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<LuSunMoon className="mr-2 h-4 w-4" />
<span>Dark Mode</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => setTheme("light")}>
{theme === "light" ? (
<>
<LuSun className="mr-2 h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
Light
</>
) : (
<span className="mr-2 ml-6">Light</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
{theme === "dark" ? (
<>
<LuMoon className="mr-2 h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
Dark
</>
) : (
<span className="mr-2 ml-6">Dark</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
{theme === "system" ? (
<>
<CgDarkMode className="mr-2 h-4 w-4 scale-100 transition-all" />
System
</>
) : (
<span className="mr-2 ml-6">System</span>
)}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<LuSunMoon className="mr-2 h-4 w-4" />
<span>Theme</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{colorSchemes.map((scheme) => (
<DropdownMenuItem
key={scheme}
onClick={() => setColorScheme(scheme)}
>
{scheme === colorScheme ? (
<>
<IoColorPalette className="mr-2 h-4 w-4 rotate-0 scale-100 transition-all" />
{friendlyColorSchemeName(scheme)}
</>
) : (
<span className="mr-2 ml-6">
{friendlyColorSchemeName(scheme)}
</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuLabel className="mt-3">Help</DropdownMenuLabel>
<DropdownMenuSeparator />
<a href="https://docs.frigate.video">
<DropdownMenuItem>
<LuLifeBuoy className="mr-2 h-4 w-4" />
<span>Documentation</span>
</DropdownMenuItem>
</a>
<a href="https://github.com/blakeblackshear/frigate">
<DropdownMenuItem>
<LuGithub className="mr-2 h-4 w-4" />
<span>GitHub</span>
</DropdownMenuItem>
</a>
<DropdownMenuSeparator className="mt-3" />
<DropdownMenuItem onClick={() => setRestartDialogOpen(true)}>
<LuRotateCw className="mr-2 h-4 w-4" />
<span>Restart Frigate</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger>
<Button size="icon" variant="ghost">
<VscAccount />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Account</p>
</TooltipContent>
</Tooltip>
</div>
{restartDialogOpen && (
<AlertDialog
open={restartDialogOpen}
onOpenChange={() => setRestartDialogOpen(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to restart Frigate?
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setRestartingSheetOpen(true);
sendRestart("restart");
}}
>
Restart
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{restartingSheetOpen && (
<>
<Sheet
open={restartingSheetOpen}
onOpenChange={() => setRestartingSheetOpen(false)}
>
<SheetContent
side="top"
onInteractOutside={(e) => e.preventDefault()}
>
<div className="flex flex-col items-center">
<ActivityIndicator />
<SheetHeader className="mt-5 text-center">
<SheetTitle className="text-center">
Frigate is Restarting
</SheetTitle>
<SheetDescription className="text-center">
<p>This page will reload in {countdown} seconds.</p>
</SheetDescription>
</SheetHeader>
<Button size="lg" className="mt-5" onClick={handleForceReload}>
Force Reload Now
</Button>
</div>
</SheetContent>
</Sheet>
</>
)}
</>
);
}

View File

@ -16,7 +16,8 @@ const buttonVariants = cva(
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
ghost:
"text-muted-foreground hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {

View File

@ -3,6 +3,7 @@ import { ThemeProvider } from "@/context/theme-provider";
import { RecoilRoot } from "recoil";
import { ApiProvider } from "@/api";
import { IconContext } from "react-icons";
import { TooltipProvider } from "@/components/ui/tooltip";
type TProvidersProps = {
children: ReactNode;
@ -13,9 +14,11 @@ function providers({ children }: TProvidersProps) {
<RecoilRoot>
<ApiProvider>
<ThemeProvider defaultTheme="light" storageKey="frigate-ui-theme">
<IconContext.Provider value={{ size: "20" }}>
{children}
</IconContext.Provider>
<TooltipProvider>
<IconContext.Provider value={{ size: "20" }}>
{children}
</IconContext.Provider>
</TooltipProvider>
</ThemeProvider>
</ApiProvider>
</RecoilRoot>

View File

@ -0,0 +1,68 @@
import {
useAudioActivity,
useFrigateEvents,
useMotionActivity,
} from "@/api/ws";
import { CameraConfig } from "@/types/frigateConfig";
import { useEffect, useMemo, useState } from "react";
type useCameraActivityReturn = {
activeTracking: boolean;
activeMotion: boolean;
activeAudio: boolean;
};
export default function useCameraActivity(
camera: CameraConfig
): useCameraActivityReturn {
const [activeObjects, setActiveObjects] = useState<string[]>([]);
const hasActiveObjects = useMemo(
() => activeObjects.length > 0,
[activeObjects]
);
const { payload: detectingMotion } = useMotionActivity(camera.name);
const { payload: event } = useFrigateEvents();
const { payload: audioRms } = useAudioActivity(camera.name);
useEffect(() => {
if (!event) {
return;
}
if (event.after.camera != camera.name) {
return;
}
const eventIndex = activeObjects.indexOf(event.after.id);
if (event.type == "end") {
if (eventIndex != -1) {
const newActiveObjects = [...activeObjects];
newActiveObjects.splice(eventIndex, 1);
setActiveObjects(newActiveObjects);
}
} else {
if (eventIndex == -1) {
// add unknown event to list if not stationary
if (!event.after.stationary) {
const newActiveObjects = [...activeObjects, event.after.id];
setActiveObjects(newActiveObjects);
}
} else {
// remove known event from list if it has become stationary
if (event.after.stationary) {
activeObjects.splice(eventIndex, 1);
}
}
}
}, [event, activeObjects]);
return {
activeTracking: hasActiveObjects,
activeMotion: detectingMotion == "ON",
activeAudio: camera.audio.enabled_in_config
? audioRms >= camera.audio.min_volume
: false,
};
}

View File

@ -0,0 +1,49 @@
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { useMemo } from "react";
import useSWR from "swr";
import { usePersistence } from "./use-persistence";
import { LivePlayerMode } from "@/types/live";
export default function useCameraLiveMode(
cameraConfig: CameraConfig,
preferredMode?: string
): LivePlayerMode {
const { data: config } = useSWR<FrigateConfig>("config");
const restreamEnabled = useMemo(() => {
if (!config) {
return false;
}
return (
cameraConfig &&
Object.keys(config.go2rtc.streams || {}).includes(
cameraConfig.live.stream_name
)
);
}, [config, cameraConfig]);
const defaultLiveMode = useMemo(() => {
if (config && cameraConfig) {
if (restreamEnabled) {
return cameraConfig.ui.live_mode || config?.ui.live_mode;
}
return "jsmpeg";
}
return undefined;
}, [cameraConfig, restreamEnabled]);
const [viewSource] = usePersistence(
`${cameraConfig.name}-source`,
defaultLiveMode
);
if (
restreamEnabled &&
(preferredMode == "mse" || preferredMode == "webrtc")
) {
return preferredMode;
} else {
return viewSource;
}
}

View File

@ -22,3 +22,8 @@
@apply bg-background text-foreground;
}
}
@font-face {
font-family: "Inter";
src: url("../fonts/Inter-VariableFont_slnt,wght.ttf");
}

View File

@ -123,7 +123,7 @@ function ConfigEditor() {
}
return (
<div className="absolute top-28 bottom-16 right-0 left-0 md:left-24 lg:left-60">
<div className="absolute top-2 bottom-16 right-0 left-0 md:left-12">
<div className="lg:flex justify-between mr-1">
<Heading as="h2">Config</Heading>
<div>

View File

@ -1,176 +0,0 @@
import { useMemo } from "react";
import ActivityIndicator from "@/components/ui/activity-indicator";
import {
useAudioState,
useDetectState,
useRecordingsState,
useSnapshotsState,
} from "@/api/ws";
import useSWR from "swr";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import Heading from "@/components/ui/heading";
import { Card } from "@/components/ui/card";
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 as FrigateEvent } from "@/types/event";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import DynamicCameraImage from "@/components/camera/DynamicCameraImage";
export function Dashboard() {
const { data: config } = useSWR<FrigateConfig>("config");
const now = new Date();
now.setMinutes(now.getMinutes() - 30, 0, 0);
const recentTimestamp = now.getTime() / 1000;
const { data: events, mutate: updateEvents } = useSWR<FrigateEvent[]>([
"events",
{ 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 (
<>
<Heading as="h2">Dashboard</Heading>
{!config && <ActivityIndicator />}
{config && (
<div>
{events && events.length > 0 && (
<>
<Heading as="h4">Recent Events</Heading>
<ScrollArea>
<div className="flex">
{events.map((event) => {
return (
<MiniEventCard
key={event.id}
event={event}
onUpdate={() => updateEvents()}
/>
);
})}
</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({ camera }: { camera: CameraConfig }) {
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 (
<>
<Card>
<a href={`/live/${camera.name}`}>
<DynamicCameraImage aspect={16 / 9} camera={camera} />
<div className="flex justify-between items-center">
<div className="text-lg capitalize p-2">
{camera.name.replaceAll("_", " ")}
</div>
<div>
<Button
variant="ghost"
size="sm"
className={`${
detectValue == "ON" ? "text-primary" : "text-gray-400"
}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
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-danger"
}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
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={(e) => {
e.stopPropagation();
e.preventDefault();
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={(e) => {
e.stopPropagation();
e.preventDefault();
sendAudio(audioValue == "ON" ? "OFF" : "ON");
}}
>
<LuEar />
</Button>
)}
</div>
</div>
</a>
</Card>
</>
);
}
export default Dashboard;

View File

@ -1,161 +1,101 @@
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
import { EventThumbnail } from "@/components/image/EventThumbnail";
import LivePlayer from "@/components/player/LivePlayer";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import Heading from "@/components/ui/heading";
import { usePersistence } from "@/hooks/use-persistence";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Event as FrigateEvent } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig";
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import axios from "axios";
import { useCallback, useMemo } from "react";
import useSWR from "swr";
function Live() {
const { data: config } = useSWR<FrigateConfig>("config");
const { camera: openedCamera } = useParams();
const [camera, setCamera] = useState<string>(
openedCamera ?? (config?.birdseye.enabled ? "birdseye" : "Select A Camera")
// recent events
const { data: allEvents, mutate: updateEvents } = useSWR<FrigateEvent[]>(
["events", { limit: 10 }],
{ refreshInterval: 60000 }
);
const cameraConfig = useMemo(() => {
return camera == "birdseye" ? undefined : config?.cameras[camera];
}, [camera, config]);
const sortedCameras = useMemo(() => {
const events = useMemo(() => {
if (!allEvents) {
return [];
}
const date = new Date();
date.setHours(date.getHours() - 1);
const cutoff = date.getTime() / 1000;
return allEvents.filter((event) => event.start_time > cutoff);
}, [allEvents]);
const onFavorite = useCallback(async (e: Event, event: FrigateEvent) => {
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) {
updateEvents();
}
}, []);
// camera live views
const cameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras).sort(
(aConf, bConf) => aConf.ui.order - bConf.ui.order
);
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const restreamEnabled = useMemo(() => {
if (!config) {
return false;
}
if (camera == "birdseye") {
return config.birdseye.restream;
}
return (
cameraConfig &&
Object.keys(config.go2rtc.streams || {}).includes(
cameraConfig.live.stream_name
)
);
}, [config, cameraConfig]);
const defaultLiveMode = useMemo(() => {
if (cameraConfig) {
if (restreamEnabled) {
return cameraConfig.ui.live_mode || config?.ui.live_mode;
}
return "jsmpeg";
}
return undefined;
}, [cameraConfig, restreamEnabled]);
const [viewSource, setViewSource, sourceIsLoaded] = usePersistence(
`${camera}-source`,
camera == "birdseye" ? "jsmpeg" : defaultLiveMode
);
return (
<div className=" w-full">
<div className="flex justify-between">
<Heading as="h2">Live</Heading>
<div className="flex">
<div className="mx-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="capitalize" variant="outline">
{camera?.replaceAll("_", " ") || "Select A Camera"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select A Camera</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={camera}
onValueChange={setCamera}
>
{config?.birdseye.enabled && (
<DropdownMenuRadioItem value="birdseye">
Birdseye
</DropdownMenuRadioItem>
)}
{sortedCameras.map((item) => (
<DropdownMenuRadioItem
className="capitalize"
key={item.name}
value={item.name}
>
{item.name.replaceAll("_", " ")}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mx-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="capitalize" variant="outline">
{viewSource || defaultLiveMode || "Select A Live Mode"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select A Live Mode</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={`${viewSource}`}
onValueChange={setViewSource}
>
{restreamEnabled && (
<DropdownMenuRadioItem value="webrtc">
Webrtc
</DropdownMenuRadioItem>
)}
{restreamEnabled && (
<DropdownMenuRadioItem value="mse">
MSE
</DropdownMenuRadioItem>
)}
<DropdownMenuRadioItem value="jsmpeg">
Jsmpeg
</DropdownMenuRadioItem>
{camera != "birdseye" && (
<DropdownMenuRadioItem value="debug">
Debug
</DropdownMenuRadioItem>
)}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<>
{events && events.length > 0 && (
<ScrollArea>
<TooltipProvider>
<div className="flex">
{events.map((event) => {
return (
<EventThumbnail
key={event.id}
event={event}
onFavorite={onFavorite}
/>
);
})}
</div>
</TooltipProvider>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)}
<div className="mt-4 md:grid md:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4 gap-4">
{cameras.map((camera) => {
let grow;
if (camera.detect.width / camera.detect.height > 2) {
grow = "aspect-wide md:col-span-2";
} else if (camera.detect.width / camera.detect.height < 1) {
grow = "aspect-tall md:aspect-auto md:row-span-2";
} else {
grow = "aspect-video";
}
return (
<LivePlayer
key={camera.name}
className={`mb-2 md:mb-0 rounded-2xl bg-black ${grow}`}
cameraConfig={camera}
preferredLiveMode="mse"
/>
);
})}
</div>
{config && camera == "birdseye" && sourceIsLoaded && (
<BirdseyeLivePlayer
birdseyeConfig={config?.birdseye}
liveMode={`${viewSource ?? defaultLiveMode}`}
/>
)}
{cameraConfig && sourceIsLoaded && (
<LivePlayer
liveMode={`${viewSource ?? defaultLiveMode}`}
cameraConfig={cameraConfig}
/>
)}
</div>
</>
);
}

View File

@ -0,0 +1,34 @@
import {
LuConstruction,
LuFileUp,
LuFilm,
LuVideo,
} from "react-icons/lu";
export const navbarLinks = [
{
id: 1,
icon: LuVideo,
title: "Live",
url: "/",
},
{
id: 2,
icon: LuFilm,
title: "History",
url: "/history",
},
{
id: 3,
icon: LuFileUp,
title: "Export",
url: "/export",
},
{
id: 4,
icon: LuConstruction,
title: "UI Playground",
url: "/playground",
dev: true,
},
];

View File

@ -1,25 +1,26 @@
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";
}
}
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;
sub_label_score?: number;
region: number[];
box: number[];
area: number;
ratio: number;
type: "object" | "audio" | "manual";
};
}

1
web/src/types/live.ts Normal file
View File

@ -0,0 +1 @@
export type LivePlayerMode = "webrtc" | "mse" | "jsmpeg" | "debug";

View File

@ -32,3 +32,5 @@ export interface FrigateEvent {
before: FrigateObjectState;
after: FrigateObjectState;
}
export type ToggleableSetting = "ON" | "OFF"

View File

@ -2,11 +2,11 @@
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
theme: {
container: {
center: true,
@ -15,7 +15,18 @@ module.exports = {
"2xl": "1400px",
},
},
fontFamily: {
sans: ['"Inter"', "sans-serif"],
},
extend: {
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
aspectRatio: {
wide: "32 / 9",
tall: "9 / 16",
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
@ -57,11 +68,6 @@ module.exports = {
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
@ -72,12 +78,8 @@ module.exports = {
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
screens: {
"xs": "480px",
xs: "480px",
"2xl": "1440px",
"3xl": "1920px",
"4xl": "2560px",
@ -85,4 +87,4 @@ module.exports = {
},
},
plugins: [require("tailwindcss-animate")],
}
};