mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-12-19 19:06:16 +01:00
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:
parent
f6a4c2a7b3
commit
64988c9be0
@ -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
38
web/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
BIN
web/public/fonts/Inter-VariableFont_slnt,wght.ttf
Normal file
BIN
web/public/fonts/Inter-VariableFont_slnt,wght.ttf
Normal file
Binary file not shown.
@ -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 />} />
|
||||
|
@ -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`, "");
|
||||
|
38
web/src/components/Chip.tsx
Normal file
38
web/src/components/Chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
117
web/src/components/camera/ResizingCameraImage.tsx
Normal file
117
web/src/components/camera/ResizingCameraImage.tsx
Normal 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>
|
||||
);
|
||||
}
|
44
web/src/components/image/EventThumbnail.tsx
Normal file
44
web/src/components/image/EventThumbnail.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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`}
|
||||
|
@ -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 = {
|
||||
|
@ -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%" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
310
web/src/components/settings/SettingsNavItems.tsx
Normal file
310
web/src/components/settings/SettingsNavItems.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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: {
|
||||
|
@ -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>
|
||||
|
68
web/src/hooks/use-camera-activity.ts
Normal file
68
web/src/hooks/use-camera-activity.ts
Normal 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,
|
||||
};
|
||||
}
|
49
web/src/hooks/use-camera-live-mode.ts
Normal file
49
web/src/hooks/use-camera-live-mode.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -22,3 +22,8 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
src: url("../fonts/Inter-VariableFont_slnt,wght.ttf");
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
34
web/src/pages/site-navigation.ts
Normal file
34
web/src/pages/site-navigation.ts
Normal 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,
|
||||
},
|
||||
];
|
@ -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
1
web/src/types/live.ts
Normal file
@ -0,0 +1 @@
|
||||
export type LivePlayerMode = "webrtc" | "mse" | "jsmpeg" | "debug";
|
@ -32,3 +32,5 @@ export interface FrigateEvent {
|
||||
before: FrigateObjectState;
|
||||
after: FrigateObjectState;
|
||||
}
|
||||
|
||||
export type ToggleableSetting = "ON" | "OFF"
|
||||
|
@ -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")],
|
||||
}
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user