diff --git a/frigate/app.py b/frigate/app.py index 5aa738d93..f288f3a2d 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -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: diff --git a/web/package-lock.json b/web/package-lock.json index 4073ae3a6..27793517d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index b7734e3ab..bfc508ff7 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/public/fonts/Inter-VariableFont_slnt,wght.ttf b/web/public/fonts/Inter-VariableFont_slnt,wght.ttf new file mode 100644 index 000000000..e72470871 Binary files /dev/null and b/web/public/fonts/Inter-VariableFont_slnt,wght.ttf differ diff --git a/web/src/App.tsx b/web/src/App.tsx index 46fcc5b64..8a22b0842 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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" > - } /> - } /> + } /> } /> } /> } /> diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index eab7206d1..827ca4a13 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -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`, ""); diff --git a/web/src/components/Chip.tsx b/web/src/components/Chip.tsx new file mode 100644 index 000000000..8d1b6555d --- /dev/null +++ b/web/src/components/Chip.tsx @@ -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 ( + +
+ {children} +
+
+ ); +} diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index 6fff81e44..9eb84fe2f 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -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 ( -
-
+
+ {navbarLinks.map((item) => { + let shouldRender = item.dev ? ENV !== "production" : true; + return ( + shouldRender && ( + + `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" : "" + }` + } + > +
{item.title}
+
+ ) + ); + })} +
+ ); +} + +function Header({ onToggleNavbar }: HeaderProps) { + return ( +
+
- Frigate
- {ENV == "production" && ( -
- 0.14 unstable -
- )} +
-
- - - - - - System - - - - - - Storage - - - - - - System metrics - - - - - - System logs - - - - - Configuration - - - - - - - Settings - - - - - - Configuration editor - - - Appearance - - - - - Dark Mode - - - - setTheme("light")}> - {theme === "light" ? ( - <> - - Light - - ) : ( - Light - )} - - setTheme("dark")}> - {theme === "dark" ? ( - <> - - Dark - - ) : ( - Dark - )} - - setTheme("system")}> - {theme === "system" ? ( - <> - - System - - ) : ( - System - )} - - - - - - - - Theme - - - - {colorSchemes.map((scheme) => ( - setColorScheme(scheme)} - > - {scheme === colorScheme ? ( - <> - - {friendlyColorSchemeName(scheme)} - - ) : ( - - {friendlyColorSchemeName(scheme)} - - )} - - ))} - - - - - Help - - - - - Documentation - - - - - - GitHub - - - - setRestartDialogOpen(true)}> - - Restart Frigate - - - -
- {restartDialogOpen && ( - setRestartDialogOpen(false)} - > - - - - Are you sure you want to restart Frigate? - - - - Cancel - { - setRestartingSheetOpen(true); - sendRestart("restart"); - }} - > - Restart - - - - - )} - {restartingSheetOpen && ( - <> - setRestartingSheetOpen(false)} - > - e.preventDefault()} - > -
- - - - Frigate is Restarting - - -

This page will reload in {countdown} seconds.

-
-
- -
-
-
- - )} +
); } diff --git a/web/src/components/Logo.tsx b/web/src/components/Logo.tsx index f5a220ce9..32cd52eda 100644 --- a/web/src/components/Logo.tsx +++ b/web/src/components/Logo.tsx @@ -1,6 +1,9 @@ -export default function Logo() { +type LogoProps = { + className?: string; +}; +export default function Logo({ className }: LogoProps) { return ( - + ); diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index be9edb6c0..a855dbdc0 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -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 = ( -
+ + {navbarLinks.map((item) => ( + setSheetOpen(false)} + /> + ))} +
+ ); return ( <>
{sidebar}
- setSheetOpen(false)} > - -
-
- -
-
+ +
{sidebar}
@@ -102,18 +67,26 @@ function SidebarItem({ Icon, title, url, dev, onClick }: SidebarItemProps) { return ( shouldRender && ( - - `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" : "" - }` - } - > - -
{title}
-
+ + + `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" + }` + } + > + + + + + +

{title}

+
+
) ); } diff --git a/web/src/components/camera/AutoUpdatingCameraImage.tsx b/web/src/components/camera/AutoUpdatingCameraImage.tsx index 61faffdaf..2bfe4ca1b 100644 --- a/web/src/components/camera/AutoUpdatingCameraImage.tsx +++ b/web/src/components/camera/AutoUpdatingCameraImage.tsx @@ -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("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]); diff --git a/web/src/components/camera/CameraImage.tsx b/web/src/components/camera/CameraImage.tsx index 853c2da09..0c6fb61de 100644 --- a/web/src/components/camera/CameraImage.tsx +++ b/web/src/components/camera/CameraImage.tsx @@ -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(null); - const canvasRef = useRef(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(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 (
{enabled ? ( - { + setHasLoaded(true); + + if (onload) { + onload(); + } + }} /> ) : (
@@ -104,10 +58,7 @@ export default function CameraImage({
)} {!hasLoaded && enabled ? ( -
+
) : null} diff --git a/web/src/components/camera/DynamicCameraImage.tsx b/web/src/components/camera/DynamicCameraImage.tsx deleted file mode 100644 index 0ef249964..000000000 --- a/web/src/components/camera/DynamicCameraImage.tsx +++ /dev/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( - undefined - ); - const [activeObjects, setActiveObjects] = useState([]); - 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 ( - - -
- - 0 ? "text-object" : "text-gray-600" - }`} - /> - {camera.audio.enabled_in_config && ( - = camera.audio.min_volume - ? "text-audio" - : "text-gray-600" - }`} - /> - )} -
-
- ); -} diff --git a/web/src/components/camera/ResizingCameraImage.tsx b/web/src/components/camera/ResizingCameraImage.tsx new file mode 100644 index 000000000..18ffabcfb --- /dev/null +++ b/web/src/components/camera/ResizingCameraImage.tsx @@ -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(null); + const canvasRef = useRef(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 ( +
+ {enabled ? ( + + ) : ( +
+ Camera is disabled in config, no stream or snapshot available! +
+ )} + {!hasLoaded && enabled ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/web/src/components/image/EventThumbnail.tsx b/web/src/components/image/EventThumbnail.tsx new file mode 100644 index 000000000..7c187230c --- /dev/null +++ b/web/src/components/image/EventThumbnail.tsx @@ -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 ( + + +
+ (onFavorite ? onFavorite(e, event) : null)} + fill={event.retain_indefinitely ? "currentColor" : "none"} + /> +
+
+ +
+
+
+
+ + {`${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}%)` + : "" + }`} + +
+ ); +} diff --git a/web/src/components/player/JSMpegPlayer.tsx b/web/src/components/player/JSMpegPlayer.tsx index 567f94a91..13a898fa2 100644 --- a/web/src/components/player/JSMpegPlayer.tsx +++ b/web/src/components/player/JSMpegPlayer.tsx @@ -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(null); const containerRef = useRef(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 ( -
+
{ + 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 ; + } + + let player; if (liveMode == "webrtc") { - return ( -
- -
+ player = ( + setLiveReady(true)} + /> ); } else if (liveMode == "mse") { if ("MediaSource" in window || "ManagedMediaSource" in window) { - return ( -
- -
+ player = ( + setLiveReady(true)} + /> ); } else { - return ( + player = (
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 ( -
- -
+ player = ( + ); } else if (liveMode == "debug") { - return ( + player = ( <> ); } else { - ; + player = ; } + + return ( +
+ {(showStillWithoutActivity == false || activeMotion || activeTracking) && + player} + +
+ +
+ +
+ + +
Motion
+
+ + {cameraConfig.audio.enabled_in_config && ( + + +
Sound
+
+ )} +
+ + + {recording == "ON" && ( + + )} +
+ {cameraConfig.name.replaceAll("_", " ")} +
+
+
+ ); } type DebugSettingsProps = { diff --git a/web/src/components/player/MsePlayer.tsx b/web/src/components/player/MsePlayer.tsx index d1630ca75..14ac3363a 100644 --- a/web/src/components/player/MsePlayer.tsx +++ b/web/src/components/player/MsePlayer.tsx @@ -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 (