From 63bc1b15823977e9e90ca0478f173c9eb580be5d Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 12 Feb 2024 18:28:36 -0700 Subject: [PATCH] Improve thumbnails and live player (#9828) * Don't show gif until event is over and fix aspect * Be more efficient about updating events * ensure previews are sorted * Don't show live view when window is not visible * Move debug camera to separate view * Improve jpg loading * Ensure still is updated on player live finish * Don't reload when window not visible * Only disconnect instead of full remove * Use start time instead of event over to determine gif --- frigate/http.py | 4 +- .../camera/AutoUpdatingCameraImage.tsx | 34 +++- .../components/camera/DebugCameraImage.tsx | 150 +++++++++++++++ .../image/AnimatedEventThumbnail.tsx | 36 +++- web/src/components/player/LivePlayer.tsx | 171 +++--------------- web/src/components/player/MsePlayer.tsx | 20 +- web/src/pages/Live.tsx | 48 ++++- web/tailwind.config.js | 2 +- 8 files changed, 298 insertions(+), 167 deletions(-) create mode 100644 web/src/components/camera/DebugCameraImage.tsx diff --git a/frigate/http.py b/frigate/http.py index fe3398741..447cf7cf7 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -2294,9 +2294,9 @@ def preview_thumbnail(camera_name, frame_time): file_check = f"{file_start}-{frame_time}.jpg" selected_preview = None - for file in os.listdir(preview_dir): + for file in sorted(os.listdir(preview_dir)): if file.startswith(file_start): - if file < file_check: + if file > file_check: selected_preview = file break diff --git a/web/src/components/camera/AutoUpdatingCameraImage.tsx b/web/src/components/camera/AutoUpdatingCameraImage.tsx index 2bfe4ca1b..d51768be7 100644 --- a/web/src/components/camera/AutoUpdatingCameraImage.tsx +++ b/web/src/components/camera/AutoUpdatingCameraImage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import CameraImage from "./CameraImage"; type AutoUpdatingCameraImageProps = { @@ -20,19 +20,41 @@ export default function AutoUpdatingCameraImage({ }: AutoUpdatingCameraImageProps) { const [key, setKey] = useState(Date.now()); const [fps, setFps] = useState("0"); + const [timeoutId, setTimeoutId] = useState(); + + useEffect(() => { + if (reloadInterval == -1) { + return; + } + + setKey(Date.now()); + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + setTimeoutId(undefined); + } + }; + }, [reloadInterval]); const handleLoad = useCallback(() => { + if (reloadInterval == -1) { + return; + } + const loadTime = Date.now() - key; if (showFps) { setFps((1000 / Math.max(loadTime, reloadInterval)).toFixed(1)); } - setTimeout( - () => { - setKey(Date.now()); - }, - loadTime > reloadInterval ? 1 : reloadInterval + setTimeoutId( + setTimeout( + () => { + setKey(Date.now()); + }, + loadTime > reloadInterval ? 1 : reloadInterval + ) ); }, [key, setFps]); diff --git a/web/src/components/camera/DebugCameraImage.tsx b/web/src/components/camera/DebugCameraImage.tsx new file mode 100644 index 000000000..e7352c17a --- /dev/null +++ b/web/src/components/camera/DebugCameraImage.tsx @@ -0,0 +1,150 @@ +import { Switch } from "../ui/switch"; +import { Label } from "../ui/label"; +import { CameraConfig } from "@/types/frigateConfig"; +import { Button } from "../ui/button"; +import { LuSettings } from "react-icons/lu"; +import { useCallback, useMemo, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { usePersistence } from "@/hooks/use-persistence"; +import AutoUpdatingCameraImage from "./AutoUpdatingCameraImage"; + +type Options = { [key: string]: boolean }; + +const emptyObject = Object.freeze({}); + +type DebugCameraImageProps = { + className?: string; + cameraConfig: CameraConfig; +}; + +export default function DebugCameraImage({ + className, + cameraConfig, +}: DebugCameraImageProps) { + const [showSettings, setShowSettings] = useState(false); + const [options, setOptions] = usePersistence( + `${cameraConfig?.name}-feed`, + emptyObject + ); + const handleSetOption = useCallback( + (id: string, value: boolean) => { + const newOptions = { ...options, [id]: value }; + setOptions(newOptions); + }, + [options] + ); + const searchParams = useMemo( + () => + new URLSearchParams( + Object.keys(options).reduce((memo, key) => { + //@ts-ignore we know this is correct + memo.push([key, options[key] === true ? "1" : "0"]); + return memo; + }, []) + ), + [options] + ); + const handleToggleSettings = useCallback(() => { + setShowSettings(!showSettings); + }, [showSettings]); + + return ( +
+ + + {showSettings ? ( + + + Options + + + + + + ) : null} +
+ ); +} + +type DebugSettingsProps = { + handleSetOption: (id: string, value: boolean) => void; + options: Options; +}; + +function DebugSettings({ handleSetOption, options }: DebugSettingsProps) { + return ( +
+
+ { + handleSetOption("bbox", isChecked); + }} + /> + +
+
+ { + handleSetOption("timestamp", isChecked); + }} + /> + +
+
+ { + handleSetOption("zones", isChecked); + }} + /> + +
+
+ { + handleSetOption("mask", isChecked); + }} + /> + +
+
+ { + handleSetOption("motion", isChecked); + }} + /> + +
+
+ { + handleSetOption("regions", isChecked); + }} + /> + +
+
+ ); +} diff --git a/web/src/components/image/AnimatedEventThumbnail.tsx b/web/src/components/image/AnimatedEventThumbnail.tsx index 0584944bb..7fa01a978 100644 --- a/web/src/components/image/AnimatedEventThumbnail.tsx +++ b/web/src/components/image/AnimatedEventThumbnail.tsx @@ -2,18 +2,50 @@ import { baseUrl } from "@/api/baseUrl"; import { Event as FrigateEvent } from "@/types/event"; import TimeAgo from "../dynamic/TimeAgo"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import { useMemo } from "react"; +import { useApiHost } from "@/api"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; type AnimatedEventThumbnailProps = { event: FrigateEvent; }; export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) { + const apiHost = useApiHost(); + const { data: config } = useSWR("config"); + + const imageUrl = useMemo(() => { + if (Date.now() / 1000 < event.start_time + 20) { + return `${apiHost}api/preview/${event.camera}/${event.start_time}/thumbnail.jpg`; + } + + return `${baseUrl}api/events/${event.id}/preview.gif`; + }, [event]); + + const aspect = useMemo(() => { + if (!config) { + return ""; + } + + const detect = config.cameras[event.camera].detect; + const aspect = detect.width / detect.height; + + if (aspect > 2) { + return "aspect-wide"; + } else if (aspect < 1) { + return "aspect-tall"; + } else { + return "aspect-video"; + } + }, [config, event]); + return (
diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 09ed3c027..f6f9c535f 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -2,13 +2,7 @@ import WebRtcPlayer from "./WebRTCPlayer"; import { CameraConfig } from "@/types/frigateConfig"; import AutoUpdatingCameraImage from "../camera/AutoUpdatingCameraImage"; import ActivityIndicator from "../ui/activity-indicator"; -import { Button } from "../ui/button"; -import { LuSettings } from "react-icons/lu"; -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 { useEffect, useMemo, useState } from "react"; import MSEPlayer from "./MsePlayer"; import JSMpegPlayer from "./JSMpegPlayer"; import { MdCircle, MdLeakAdd } from "react-icons/md"; @@ -19,31 +13,33 @@ 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; preferredLiveMode?: LivePlayerMode; showStillWithoutActivity?: boolean; + windowVisible?: boolean; }; -type Options = { [key: string]: boolean }; - export default function LivePlayer({ className, cameraConfig, preferredLiveMode, showStillWithoutActivity = true, + windowVisible = true, }: LivePlayerProps) { // camera activity + const { activeMotion, activeAudio, activeTracking } = useCameraActivity(cameraConfig); const cameraActive = useMemo( - () => activeMotion || activeTracking, - [activeMotion, activeTracking] + () => windowVisible && (activeMotion || activeTracking), + [activeMotion, activeTracking, windowVisible] ); + + // camera live state + const liveMode = useCameraLiveMode(cameraConfig, preferredLiveMode); const [liveReady, setLiveReady] = useState(false); @@ -63,34 +59,23 @@ export default function LivePlayer({ const { payload: recording } = useRecordingsState(cameraConfig.name); - // debug view settings + // camera still state - const [showSettings, setShowSettings] = useState(false); - const [options, setOptions] = usePersistence( - `${cameraConfig?.name}-feed`, - emptyObject - ); - const handleSetOption = useCallback( - (id: string, value: boolean) => { - const newOptions = { ...options, [id]: value }; - setOptions(newOptions); - }, - [options] - ); - const searchParams = useMemo( - () => - new URLSearchParams( - Object.keys(options).reduce((memo, key) => { - //@ts-ignore we know this is correct - memo.push([key, options[key] === true ? "1" : "0"]); - return memo; - }, []) - ), - [options] - ); - const handleToggleSettings = useCallback(() => { - setShowSettings(!showSettings); - }, [showSettings]); + const stillReloadInterval = useMemo(() => { + if (!windowVisible) { + return -1; // no reason to update the image when the window is not visible + } + + if (liveReady) { + return 60000; + } + + if (cameraActive) { + return 200; + } + + return 30000; + }, []); if (!cameraConfig) { return ; @@ -111,6 +96,7 @@ export default function LivePlayer({ setLiveReady(true)} /> ); @@ -131,34 +117,6 @@ export default function LivePlayer({ height={cameraConfig.detect.height} /> ); - } else if (liveMode == "debug") { - player = ( - <> - - - {showSettings ? ( - - - Options - - - - - - ) : null} - - ); } else { player = ; } @@ -173,8 +131,7 @@ export default function LivePlayer({ >
- - {(showStillWithoutActivity == false || cameraActive) && player} + {player}
@@ -220,75 +177,3 @@ export default function LivePlayer({
); } - -type DebugSettingsProps = { - handleSetOption: (id: string, value: boolean) => void; - options: Options; -}; - -function DebugSettings({ handleSetOption, options }: DebugSettingsProps) { - return ( -
-
- { - handleSetOption("bbox", isChecked); - }} - /> - -
-
- { - handleSetOption("timestamp", isChecked); - }} - /> - -
-
- { - handleSetOption("zones", isChecked); - }} - /> - -
-
- { - handleSetOption("mask", isChecked); - }} - /> - -
-
- { - handleSetOption("motion", isChecked); - }} - /> - -
-
- { - handleSetOption("regions", isChecked); - }} - /> - -
-
- ); -} diff --git a/web/src/components/player/MsePlayer.tsx b/web/src/components/player/MsePlayer.tsx index ebdf09b6f..c4fad17f2 100644 --- a/web/src/components/player/MsePlayer.tsx +++ b/web/src/components/player/MsePlayer.tsx @@ -4,10 +4,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; type MSEPlayerProps = { camera: string; className?: string; + playbackEnabled?: boolean; onPlaying?: () => void; }; -function MSEPlayer({ camera, className, onPlaying }: MSEPlayerProps) { +function MSEPlayer({ + camera, + className, + playbackEnabled = true, + onPlaying, +}: MSEPlayerProps) { let connectTS: number = 0; const RECONNECT_TIMEOUT: number = 30000; @@ -203,6 +209,10 @@ function MSEPlayer({ camera, className, onPlaying }: MSEPlayerProps) { }; useEffect(() => { + if (!playbackEnabled) { + return; + } + // iOS 17.1+ uses ManagedMediaSource const MediaSourceConstructor = "ManagedMediaSource" in window ? window.ManagedMediaSource : MediaSource; @@ -236,18 +246,12 @@ function MSEPlayer({ camera, className, onPlaying }: MSEPlayerProps) { observer.observe(videoRef.current!); } - return () => { - onDisconnect(); - }; - }, [onDisconnect, onConnect]); - - useEffect(() => { onConnect(); return () => { onDisconnect(); }; - }, [wsURL]); + }, [playbackEnabled, onDisconnect, onConnect]); return (