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() self.init_logger()
logger.info(f"Starting Frigate ({VERSION})") 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: try:
self.ensure_dirs() self.ensure_dirs()
try: try:

38
web/package-lock.json generated
View File

@ -42,6 +42,7 @@
"react-hook-form": "^7.48.2", "react-hook-form": "^7.48.2",
"react-icons": "^4.12.0", "react-icons": "^4.12.0",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"react-transition-group": "^4.4.5",
"react-use-websocket": "^4.5.0", "react-use-websocket": "^4.5.0",
"recoil": "^0.7.7", "recoil": "^0.7.7",
"sonner": "^1.4.0", "sonner": "^1.4.0",
@ -64,6 +65,7 @@
"@types/react": "^18.2.37", "@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15", "@types/react-dom": "^18.2.15",
"@types/react-icons": "^3.0.0", "@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8", "@types/strftime": "^0.9.8",
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0", "@typescript-eslint/parser": "^6.10.0",
@ -2498,6 +2500,15 @@
"react-icons": "*" "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": { "node_modules/@types/scheduler": {
"version": "0.16.8", "version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
@ -3741,8 +3752,7 @@
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
"devOptional": true
}, },
"node_modules/data-urls": { "node_modules/data-urls": {
"version": "5.0.0", "version": "5.0.0",
@ -3992,6 +4002,15 @@
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true "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": { "node_modules/dom-walk": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", "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": { "node_modules/react-use-websocket": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.5.0.tgz", "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-hook-form": "^7.48.2",
"react-icons": "^4.12.0", "react-icons": "^4.12.0",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"react-transition-group": "^4.4.5",
"react-use-websocket": "^4.5.0", "react-use-websocket": "^4.5.0",
"recoil": "^0.7.7", "recoil": "^0.7.7",
"sonner": "^1.4.0", "sonner": "^1.4.0",
@ -69,6 +70,7 @@
"@types/react": "^18.2.37", "@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15", "@types/react-dom": "^18.2.15",
"@types/react-icons": "^3.0.0", "@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8", "@types/strftime": "^0.9.8",
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^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 Wrapper from "@/components/Wrapper";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import Header from "@/components/Header"; import Header from "@/components/Header";
import Dashboard from "@/pages/Dashboard";
import Live from "@/pages/Live"; import Live from "@/pages/Live";
import History from "@/pages/History"; import History from "@/pages/History";
import Export from "@/pages/Export"; import Export from "@/pages/Export";
@ -35,8 +34,7 @@ function App() {
className="overflow-x-hidden px-4 py-2 w-screen md:w-full" className="overflow-x-hidden px-4 py-2 w-screen md:w-full"
> >
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Live />} />
<Route path="/live/:camera?" element={<Live />} />
<Route path="/history" element={<History />} /> <Route path="/history" element={<History />} />
<Route path="/export" element={<Export />} /> <Route path="/export" element={<Export />} />
<Route path="/storage" element={<Storage />} /> <Route path="/storage" element={<Storage />} />

View File

@ -10,7 +10,7 @@ import {
import { produce, Draft } from "immer"; import { produce, Draft } from "immer";
import useWebSocket, { ReadyState } from "react-use-websocket"; import useWebSocket, { ReadyState } from "react-use-websocket";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { FrigateEvent } from "@/types/ws"; import { FrigateEvent, ToggleableSetting } from "@/types/ws";
type ReducerState = { type ReducerState = {
[topic: string]: { [topic: string]: {
@ -149,8 +149,8 @@ export function useWs(watchTopic: string, publishTopic: string) {
} }
export function useDetectState(camera: string): { export function useDetectState(camera: string): {
payload: string; payload: ToggleableSetting;
send: (payload: string, retain?: boolean) => void; send: (payload: ToggleableSetting, retain?: boolean) => void;
} { } {
const { const {
value: { payload }, value: { payload },
@ -160,8 +160,8 @@ export function useDetectState(camera: string): {
} }
export function useRecordingsState(camera: string): { export function useRecordingsState(camera: string): {
payload: string; payload: ToggleableSetting;
send: (payload: string, retain?: boolean) => void; send: (payload: ToggleableSetting, retain?: boolean) => void;
} { } {
const { const {
value: { payload }, value: { payload },
@ -171,8 +171,8 @@ export function useRecordingsState(camera: string): {
} }
export function useSnapshotsState(camera: string): { export function useSnapshotsState(camera: string): {
payload: string; payload: ToggleableSetting;
send: (payload: string, retain?: boolean) => void; send: (payload: ToggleableSetting, retain?: boolean) => void;
} { } {
const { const {
value: { payload }, value: { payload },
@ -182,8 +182,8 @@ export function useSnapshotsState(camera: string): {
} }
export function useAudioState(camera: string): { export function useAudioState(camera: string): {
payload: string; payload: ToggleableSetting;
send: (payload: string, retain?: boolean) => void; send: (payload: ToggleableSetting, retain?: boolean) => void;
} { } {
const { const {
value: { payload }, value: { payload },
@ -228,7 +228,7 @@ export function useMotionActivity(camera: string): { payload: string } {
return { payload }; return { payload };
} }
export function useAudioActivity(camera: string): { payload: string } { export function useAudioActivity(camera: string): { payload: number } {
const { const {
value: { payload }, value: { payload },
} = useWs(`${camera}/audio/rms`, ""); } = 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 { Link } from "react-router-dom";
import Logo from "@/components/Logo"; import Logo from "@/components/Logo";
import { import { LuMenu } from "react-icons/lu";
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 { Button } from "@/components/ui/button"; 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 { ENV } from "@/env";
import { NavLink } from "react-router-dom";
import { navbarLinks } from "@/pages/site-navigation";
import SettingsNavItems from "./settings/SettingsNavItems";
type HeaderProps = { type HeaderProps = {
onToggleNavbar: () => void; onToggleNavbar: () => void;
}; };
function Header({ onToggleNavbar }: HeaderProps) { function HeaderNavigation() {
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 ( return (
<div className="flex gap-10 lg:gap-20 justify-between pt-2 mb-2 border-b-[1px] px-4 items-center"> <div className="hidden md:flex">
<div className="flex gap-4 items-center flex-shrink-0 m-5"> {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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -110,207 +53,11 @@ function Header({ onToggleNavbar }: HeaderProps) {
<div className="w-10 mr-5"> <div className="w-10 mr-5">
<Logo /> <Logo />
</div> </div>
<Heading as="h1">Frigate</Heading>
</div> </div>
{ENV == "production" && (
<div className="text-red-500 text-sm items-center text-right">
0.14 unstable
</div>
)}
</Link> </Link>
<HeaderNavigation />
</div> </div>
<div className="flex flex-shrink-0 md:gap-2"> <SettingsNavItems 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>
</>
)}
</div> </div>
); );
} }

View File

@ -1,6 +1,9 @@
export default function Logo() { type LogoProps = {
className?: string;
};
export default function Logo({ className }: LogoProps) {
return ( 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" /> <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> </svg>
); );

View File

@ -1,49 +1,15 @@
import { IconType } from "react-icons"; import { IconType } from "react-icons";
import {
LuConstruction,
LuFileUp,
LuFilm,
LuLayoutDashboard,
LuVideo,
} from "react-icons/lu";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { Sheet, SheetContent } from "@/components/ui/sheet"; import { Sheet, SheetContent } from "@/components/ui/sheet";
import Logo from "./Logo"; import Logo from "./Logo";
import { ENV } from "@/env"; import { ENV } from "@/env";
import { navbarLinks } from "@/pages/site-navigation";
const navbarLinks = [ import SettingsNavItems from "./settings/SettingsNavItems";
{ import {
id: 1, Tooltip,
icon: LuLayoutDashboard, TooltipContent,
title: "Dashboard", TooltipTrigger,
url: "/", } from "@/components/ui/tooltip";
},
{
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,
},
];
function Sidebar({ function Sidebar({
sheetOpen, sheetOpen,
@ -53,35 +19,34 @@ function Sidebar({
setSheetOpen: (open: boolean) => void; setSheetOpen: (open: boolean) => void;
}) { }) {
const sidebar = ( 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"> <aside className="w-[52px] z-10 h-screen sticky top-0 overflow-y-auto scrollbar-hidden py-4 flex flex-col justify-between">
{navbarLinks.map((item) => ( <div className="w-full flex flex-col gap-0 items-center">
<SidebarItem <Logo className="w-8 h-8 mb-6" />
key={item.id} {navbarLinks.map((item) => (
Icon={item.icon} <SidebarItem
title={item.title} key={item.id}
url={item.url} Icon={item.icon}
dev={item.dev} title={item.title}
onClick={() => setSheetOpen(false)} url={item.url}
/> dev={item.dev}
))} onClick={() => setSheetOpen(false)}
/>
))}
</div>
<SettingsNavItems className="hidden md:flex flex-col items-center" />
</aside> </aside>
); );
return ( return (
<> <>
<div className="hidden md:block">{sidebar}</div> <div className="hidden md:block">{sidebar}</div>
<Sheet <Sheet
open={sheetOpen} open={sheetOpen}
modal={false} modal={false}
onOpenChange={() => setSheetOpen(false)} onOpenChange={() => setSheetOpen(false)}
> >
<SheetContent side="left" className="w-[120px]"> <SheetContent side="left" className="w-[90px]">
<div className="w-full flex flex-row justify-center"> <div className="w-full flex flex-row justify-center"></div>
<div className="w-10">
<Logo />
</div>
</div>
{sidebar} {sidebar}
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@ -102,18 +67,26 @@ function SidebarItem({ Icon, title, url, dev, onClick }: SidebarItemProps) {
return ( return (
shouldRender && ( shouldRender && (
<NavLink <Tooltip>
to={url} <NavLink
onClick={onClick} to={url}
className={({ isActive }) => onClick={onClick}
`py-4 px-2 flex flex-col lg:flex-row items-center rounded-lg gap-2 lg:w-full hover:bg-border ${ className={({ isActive }) =>
isActive ? "font-bold bg-popover text-popover-foreground" : "" `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"
<Icon className="w-6 h-6 mr-1" /> }`
<div className="text-sm text-center">{title}</div> }
</NavLink> >
<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?: {}; searchParams?: {};
showFps?: boolean; showFps?: boolean;
className?: string; className?: string;
reloadInterval?: number;
}; };
const MIN_LOAD_TIMEOUT_MS = 200; const MIN_LOAD_TIMEOUT_MS = 200;
@ -15,6 +16,7 @@ export default function AutoUpdatingCameraImage({
searchParams = "", searchParams = "",
showFps = true, showFps = true,
className, className,
reloadInterval = MIN_LOAD_TIMEOUT_MS,
}: AutoUpdatingCameraImageProps) { }: AutoUpdatingCameraImageProps) {
const [key, setKey] = useState(Date.now()); const [key, setKey] = useState(Date.now());
const [fps, setFps] = useState<string>("0"); const [fps, setFps] = useState<string>("0");
@ -23,14 +25,14 @@ export default function AutoUpdatingCameraImage({
const loadTime = Date.now() - key; const loadTime = Date.now() - key;
if (showFps) { if (showFps) {
setFps((1000 / Math.max(loadTime, MIN_LOAD_TIMEOUT_MS)).toFixed(1)); setFps((1000 / Math.max(loadTime, reloadInterval)).toFixed(1));
} }
setTimeout( setTimeout(
() => { () => {
setKey(Date.now()); setKey(Date.now());
}, },
loadTime > MIN_LOAD_TIMEOUT_MS ? 1 : MIN_LOAD_TIMEOUT_MS loadTime > reloadInterval ? 1 : reloadInterval
); );
}, [key, setFps]); }, [key, setFps]);

View File

@ -1,102 +1,56 @@
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import ActivityIndicator from "../ui/activity-indicator"; import ActivityIndicator from "../ui/activity-indicator";
import { useResizeObserver } from "@/hooks/resize-observer";
type CameraImageProps = { type CameraImageProps = {
className?: string;
camera: string; camera: string;
onload?: (event: Event) => void; onload?: () => void;
searchParams?: {}; searchParams?: {};
stretch?: boolean; // stretch to fit width
fitAspect?: number; // shrink to fit height
}; };
export default function CameraImage({ export default function CameraImage({
className,
camera, camera,
onload, onload,
searchParams = "", searchParams = "",
stretch = false,
fitAspect,
}: CameraImageProps) { }: CameraImageProps) {
const { data: config } = useSWR("config"); const { data: config } = useSWR("config");
const apiHost = useApiHost(); const apiHost = useApiHost();
const [hasLoaded, setHasLoaded] = useState(false); const [hasLoaded, setHasLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null); const imgRef = useRef<HTMLImageElement | 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 { name } = config ? config.cameras[camera] : "";
const enabled = config ? config.cameras[camera].enabled : "True"; 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(() => { useEffect(() => {
if (!config || scaledHeight === 0 || !canvasRef.current) { if (!config || !imgRef.current) {
return; 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 ( return (
<div <div
className={`relative w-full ${ className={`relative w-full h-full flex justify-center ${className}`}
fitAspect && aspectRatio < fitAspect ? "h-full flex justify-center" : ""
}`}
ref={containerRef} ref={containerRef}
> >
{enabled ? ( {enabled ? (
<canvas <img
data-testid="cameraimage-canvas" ref={imgRef}
height={scaledHeight} className="object-contain rounded-2xl"
ref={canvasRef} onLoad={() => {
width={scaledWidth} setHasLoaded(true);
if (onload) {
onload();
}
}}
/> />
) : ( ) : (
<div className="text-center pt-6"> <div className="text-center pt-6">
@ -104,10 +58,7 @@ export default function CameraImage({
</div> </div>
)} )}
{!hasLoaded && enabled ? ( {!hasLoaded && enabled ? (
<div <div className="absolute left-0 right-0 top-0 bottom-0 flex justify-center items-center">
className="absolute inset-0 flex justify-center"
style={{ height: `${scaledHeight}px` }}
>
<ActivityIndicator /> <ActivityIndicator />
</div> </div>
) : null} ) : 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"; import { useEffect, useMemo, useRef } from "react";
type JSMpegPlayerProps = { type JSMpegPlayerProps = {
className?: string;
camera: string; camera: string;
width: number; width: number;
height: number; height: number;
@ -14,11 +15,13 @@ export default function JSMpegPlayer({
camera, camera,
width, width,
height, height,
className,
}: JSMpegPlayerProps) { }: JSMpegPlayerProps) {
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`; const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
const playerRef = useRef<HTMLDivElement | null>(null); const playerRef = useRef<HTMLDivElement | null>(null);
const containerRef = 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. // Add scrollbar width (when visible) to the available observer width to eliminate screen juddering.
// https://github.com/blakeblackshear/frigate/issues/1657 // https://github.com/blakeblackshear/frigate/issues/1657
@ -35,6 +38,10 @@ export default function JSMpegPlayer({
const scaledHeight = Math.floor(availableWidth / aspectRatio); const scaledHeight = Math.floor(availableWidth / aspectRatio);
const finalHeight = Math.min(scaledHeight, height); const finalHeight = Math.min(scaledHeight, height);
if (containerHeight < finalHeight) {
return containerHeight;
}
if (finalHeight > 0) { if (finalHeight > 0) {
return finalHeight; return finalHeight;
} }
@ -79,7 +86,7 @@ export default function JSMpegPlayer({
}, [url]); }, [url]);
return ( return (
<div ref={containerRef}> <div className={className} ref={containerRef}>
<div <div
ref={playerRef} ref={playerRef}
className={`jsmpeg`} className={`jsmpeg`}

View File

@ -4,42 +4,75 @@ import AutoUpdatingCameraImage from "../camera/AutoUpdatingCameraImage";
import ActivityIndicator from "../ui/activity-indicator"; import ActivityIndicator from "../ui/activity-indicator";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { LuSettings } from "react-icons/lu"; 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 { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import MSEPlayer from "./MsePlayer"; import MSEPlayer from "./MsePlayer";
import JSMpegPlayer from "./JSMpegPlayer"; 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({}); const emptyObject = Object.freeze({});
type LivePlayerProps = { type LivePlayerProps = {
className?: string;
cameraConfig: CameraConfig; cameraConfig: CameraConfig;
liveMode: string; preferredLiveMode?: LivePlayerMode;
showStillWithoutActivity?: boolean;
}; };
type Options = { [key: string]: boolean }; type Options = { [key: string]: boolean };
export default function LivePlayer({ export default function LivePlayer({
className,
cameraConfig, cameraConfig,
liveMode, preferredLiveMode,
showStillWithoutActivity = true,
}: LivePlayerProps) { }: 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( const [options, setOptions] = usePersistence(
`${cameraConfig.name}-feed`, `${cameraConfig?.name}-feed`,
emptyObject emptyObject
); );
const handleSetOption = useCallback( const handleSetOption = useCallback(
(id: string, value: boolean) => { (id: string, value: boolean) => {
const newOptions = { ...options, [id]: value }; const newOptions = { ...options, [id]: value };
setOptions(newOptions); setOptions(newOptions);
}, },
[options, setOptions] [options]
); );
const searchParams = useMemo( const searchParams = useMemo(
() => () =>
new URLSearchParams( new URLSearchParams(
@ -51,26 +84,34 @@ export default function LivePlayer({
), ),
[options] [options]
); );
const handleToggleSettings = useCallback(() => { const handleToggleSettings = useCallback(() => {
setShowSettings(!showSettings); setShowSettings(!showSettings);
}, [showSettings, setShowSettings]); }, [showSettings]);
if (!cameraConfig) {
return <ActivityIndicator />;
}
let player;
if (liveMode == "webrtc") { if (liveMode == "webrtc") {
return ( player = (
<div className="max-w-5xl"> <WebRtcPlayer
<WebRtcPlayer camera={cameraConfig.live.stream_name} /> className={`rounded-2xl h-full ${liveReady ? "" : "hidden"}`}
</div> camera={cameraConfig.live.stream_name}
onPlaying={() => setLiveReady(true)}
/>
); );
} else if (liveMode == "mse") { } else if (liveMode == "mse") {
if ("MediaSource" in window || "ManagedMediaSource" in window) { if ("MediaSource" in window || "ManagedMediaSource" in window) {
return ( player = (
<div className="max-w-5xl"> <MSEPlayer
<MSEPlayer camera={cameraConfig.live.stream_name} /> className={`rounded-2xl h-full ${liveReady ? "" : "hidden"}`}
</div> camera={cameraConfig.name}
onPlaying={() => setLiveReady(true)}
/>
); );
} else { } else {
return ( player = (
<div className="w-5xl text-center text-sm"> <div className="w-5xl text-center text-sm">
MSE is only supported on iOS 17.1+. You'll need to update if available 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. or use jsmpeg / webRTC streams. See the docs for more info.
@ -78,17 +119,16 @@ export default function LivePlayer({
); );
} }
} else if (liveMode == "jsmpeg") { } else if (liveMode == "jsmpeg") {
return ( player = (
<div className={`max-w-[${cameraConfig.detect.width}px]`}> <JSMpegPlayer
<JSMpegPlayer className="w-full flex justify-center"
camera={cameraConfig.name} camera={cameraConfig.name}
width={cameraConfig.detect.width} width={cameraConfig.detect.width}
height={cameraConfig.detect.height} height={cameraConfig.detect.height}
/> />
</div>
); );
} else if (liveMode == "debug") { } else if (liveMode == "debug") {
return ( player = (
<> <>
<AutoUpdatingCameraImage <AutoUpdatingCameraImage
camera={cameraConfig.name} camera={cameraConfig.name}
@ -116,8 +156,63 @@ export default function LivePlayer({
</> </>
); );
} else { } 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 = { type DebugSettingsProps = {

View File

@ -3,9 +3,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type MSEPlayerProps = { type MSEPlayerProps = {
camera: string; camera: string;
className?: string;
onPlaying?: () => void;
}; };
function MSEPlayer({ camera }: MSEPlayerProps) { function MSEPlayer({ camera, className, onPlaying }: MSEPlayerProps) {
let connectTS: number = 0; let connectTS: number = 0;
const RECONNECT_TIMEOUT: number = 30000; const RECONNECT_TIMEOUT: number = 30000;
@ -246,11 +248,11 @@ function MSEPlayer({ camera }: MSEPlayerProps) {
return ( return (
<video <video
ref={videoRef} ref={videoRef}
controls className={className}
playsInline playsInline
preload="auto" preload="auto"
onLoadedData={onPlaying}
muted 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"; import { useCallback, useEffect, useRef } from "react";
type WebRtcPlayerProps = { type WebRtcPlayerProps = {
className?: string;
camera: string; camera: string;
width?: number; onPlaying?: () => void;
height?: number;
}; };
export default function WebRtcPlayer({ export default function WebRtcPlayer({
className,
camera, camera,
width, onPlaying,
height,
}: WebRtcPlayerProps) { }: WebRtcPlayerProps) {
const pcRef = useRef<RTCPeerConnection | undefined>(); const pcRef = useRef<RTCPeerConnection | undefined>();
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
@ -149,12 +149,11 @@ export default function WebRtcPlayer({
<div> <div>
<video <video
ref={videoRef} ref={videoRef}
className={className}
autoPlay autoPlay
playsInline playsInline
controls
muted muted
width={width} onLoadedData={onPlaying}
height={height}
/> />
</div> </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", "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "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", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {

View File

@ -3,6 +3,7 @@ import { ThemeProvider } from "@/context/theme-provider";
import { RecoilRoot } from "recoil"; import { RecoilRoot } from "recoil";
import { ApiProvider } from "@/api"; import { ApiProvider } from "@/api";
import { IconContext } from "react-icons"; import { IconContext } from "react-icons";
import { TooltipProvider } from "@/components/ui/tooltip";
type TProvidersProps = { type TProvidersProps = {
children: ReactNode; children: ReactNode;
@ -13,9 +14,11 @@ function providers({ children }: TProvidersProps) {
<RecoilRoot> <RecoilRoot>
<ApiProvider> <ApiProvider>
<ThemeProvider defaultTheme="light" storageKey="frigate-ui-theme"> <ThemeProvider defaultTheme="light" storageKey="frigate-ui-theme">
<IconContext.Provider value={{ size: "20" }}> <TooltipProvider>
{children} <IconContext.Provider value={{ size: "20" }}>
</IconContext.Provider> {children}
</IconContext.Provider>
</TooltipProvider>
</ThemeProvider> </ThemeProvider>
</ApiProvider> </ApiProvider>
</RecoilRoot> </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; @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 ( 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"> <div className="lg:flex justify-between mr-1">
<Heading as="h2">Config</Heading> <Heading as="h2">Config</Heading>
<div> <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 LivePlayer from "@/components/player/LivePlayer";
import { Button } from "@/components/ui/button"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { import { TooltipProvider } from "@/components/ui/tooltip";
DropdownMenu, import { Event as FrigateEvent } from "@/types/event";
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import Heading from "@/components/ui/heading";
import { usePersistence } from "@/hooks/use-persistence";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useMemo, useState } from "react"; import axios from "axios";
import { useParams } from "react-router-dom"; import { useCallback, useMemo } from "react";
import useSWR from "swr"; import useSWR from "swr";
function Live() { function Live() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const { camera: openedCamera } = useParams();
const [camera, setCamera] = useState<string>( // recent events
openedCamera ?? (config?.birdseye.enabled ? "birdseye" : "Select A Camera")
const { data: allEvents, mutate: updateEvents } = useSWR<FrigateEvent[]>(
["events", { limit: 10 }],
{ refreshInterval: 60000 }
); );
const cameraConfig = useMemo(() => {
return camera == "birdseye" ? undefined : config?.cameras[camera]; const events = useMemo(() => {
}, [camera, config]); if (!allEvents) {
const sortedCameras = useMemo(() => { 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) { if (!config) {
return []; return [];
} }
return Object.values(config.cameras).sort( return Object.values(config.cameras)
(aConf, bConf) => aConf.ui.order - bConf.ui.order .filter((conf) => conf.ui.dashboard && conf.enabled)
); .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]); }, [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 ( return (
<div className=" w-full"> <>
<div className="flex justify-between"> {events && events.length > 0 && (
<Heading as="h2">Live</Heading> <ScrollArea>
<div className="flex"> <TooltipProvider>
<div className="mx-1"> <div className="flex">
<DropdownMenu> {events.map((event) => {
<DropdownMenuTrigger asChild> return (
<Button className="capitalize" variant="outline"> <EventThumbnail
{camera?.replaceAll("_", " ") || "Select A Camera"} key={event.id}
</Button> event={event}
</DropdownMenuTrigger> onFavorite={onFavorite}
<DropdownMenuContent> />
<DropdownMenuLabel>Select A Camera</DropdownMenuLabel> );
<DropdownMenuSeparator /> })}
<DropdownMenuRadioGroup </div>
value={camera} </TooltipProvider>
onValueChange={setCamera} <ScrollBar orientation="horizontal" />
> </ScrollArea>
{config?.birdseye.enabled && ( )}
<DropdownMenuRadioItem value="birdseye">
Birdseye <div className="mt-4 md:grid md:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4 gap-4">
</DropdownMenuRadioItem> {cameras.map((camera) => {
)} let grow;
{sortedCameras.map((item) => ( if (camera.detect.width / camera.detect.height > 2) {
<DropdownMenuRadioItem grow = "aspect-wide md:col-span-2";
className="capitalize" } else if (camera.detect.width / camera.detect.height < 1) {
key={item.name} grow = "aspect-tall md:aspect-auto md:row-span-2";
value={item.name} } else {
> grow = "aspect-video";
{item.name.replaceAll("_", " ")} }
</DropdownMenuRadioItem> return (
))} <LivePlayer
</DropdownMenuRadioGroup> key={camera.name}
</DropdownMenuContent> className={`mb-2 md:mb-0 rounded-2xl bg-black ${grow}`}
</DropdownMenu> cameraConfig={camera}
</div> preferredLiveMode="mse"
<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>
</div> </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 { export interface Event {
id: string; id: string;
label: string; label: string;
sub_label?: string; sub_label?: string;
camera: string; camera: string;
start_time: number; start_time: number;
end_time?: number; end_time?: number;
false_positive: boolean; false_positive: boolean;
zones: string[]; zones: string[];
thumbnail: string; thumbnail: string;
has_clip: boolean; has_clip: boolean;
has_snapshot: boolean; has_snapshot: boolean;
retain_indefinitely: boolean; retain_indefinitely: boolean;
plus_id?: string; plus_id?: string;
model_hash?: string; model_hash?: string;
data: { data: {
top_score: number; top_score: number;
score: number; score: number;
region: number[]; sub_label_score?: number;
box: number[]; region: number[];
area: number; box: number[];
ratio: number; area: number;
type: "object" | "audio" | "manual"; 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; before: FrigateObjectState;
after: FrigateObjectState; after: FrigateObjectState;
} }
export type ToggleableSetting = "ON" | "OFF"

View File

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