mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Fix linter and fix lint issues (#10141)
This commit is contained in:
		
							parent
							
								
									b6ef1e4330
								
							
						
					
					
						commit
						3bf2a496e1
					
				| @ -1,12 +1,13 @@ | ||||
| module.exports = { | ||||
|   root: true, | ||||
|   env: { browser: true, es2021: true, "vitest-globals/env": true }, | ||||
|   extends: [ | ||||
|     "eslint:recommended", | ||||
|     "plugin:@typescript-eslint/recommended", | ||||
|     "plugin:react-hooks/recommended", | ||||
|     "plugin:prettier", | ||||
|     "plugin:vitest-globals/recommended", | ||||
|     "plugin:prettier/recommended", | ||||
|   ], | ||||
|   env: { browser: true, es2021: true, "vitest-globals/env": true }, | ||||
|   ignorePatterns: ["dist", ".eslintrc.cjs"], | ||||
|   parser: "@typescript-eslint/parser", | ||||
|   parserOptions: { | ||||
| @ -21,9 +22,11 @@ module.exports = { | ||||
|       version: 27, | ||||
|     }, | ||||
|   }, | ||||
|   ignorePatterns: ["*.d.ts"], | ||||
|   plugins: ["react-refresh"], | ||||
|   ignorePatterns: ["*.d.ts", "/src/components/ui/*"], | ||||
|   plugins: ["react-hooks", "react-refresh"], | ||||
|   rules: { | ||||
|     "react-hooks/rules-of-hooks": "error", | ||||
|     "react-hooks/exhaustive-deps": "error", | ||||
|     "react-refresh/only-export-components": [ | ||||
|       "warn", | ||||
|       { allowConstantExport: true }, | ||||
|  | ||||
							
								
								
									
										2
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -78,7 +78,7 @@ | ||||
|         "@vitejs/plugin-react-swc": "^3.6.0", | ||||
|         "@vitest/coverage-v8": "^1.0.0", | ||||
|         "autoprefixer": "^10.4.16", | ||||
|         "eslint": "^8.53.0", | ||||
|         "eslint": "^8.55.0", | ||||
|         "eslint-config-prettier": "^9.1.0", | ||||
|         "eslint-plugin-jest": "^27.6.0", | ||||
|         "eslint-plugin-prettier": "^5.0.1", | ||||
|  | ||||
| @ -83,7 +83,7 @@ | ||||
|     "@vitejs/plugin-react-swc": "^3.6.0", | ||||
|     "@vitest/coverage-v8": "^1.0.0", | ||||
|     "autoprefixer": "^10.4.16", | ||||
|     "eslint": "^8.53.0", | ||||
|     "eslint": "^8.55.0", | ||||
|     "eslint-config-prettier": "^9.1.0", | ||||
|     "eslint-plugin-jest": "^27.6.0", | ||||
|     "eslint-plugin-prettier": "^5.0.1", | ||||
|  | ||||
| @ -3,4 +3,4 @@ export default { | ||||
|     tailwindcss: {}, | ||||
|     autoprefixer: {}, | ||||
|   }, | ||||
| } | ||||
| }; | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| declare global { | ||||
|     interface Window { | ||||
|       baseUrl?: any; | ||||
|     } | ||||
|   interface Window { | ||||
|     baseUrl?: string; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export const baseUrl = `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`; | ||||
| export const baseUrl = `${window.location.protocol}//${window.location.host}${window.baseUrl || "/"}`; | ||||
|  | ||||
| @ -9,12 +9,12 @@ import { createContainer } from "react-tracked"; | ||||
| 
 | ||||
| type Update = { | ||||
|   topic: string; | ||||
|   payload: any; | ||||
|   payload: unknown; | ||||
|   retain: boolean; | ||||
| }; | ||||
| 
 | ||||
| type WsState = { | ||||
|   [topic: string]: any; | ||||
|   [topic: string]: unknown; | ||||
| }; | ||||
| 
 | ||||
| type useValueReturn = [WsState, (update: Update) => void]; | ||||
| @ -47,6 +47,8 @@ function useValue(): useValueReturn { | ||||
|     }); | ||||
| 
 | ||||
|     setWsState({ ...wsState, ...cameraStates }); | ||||
|     // we only want this to run initially when the config is loaded
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [config]); | ||||
| 
 | ||||
|   // ws handler
 | ||||
| @ -72,7 +74,7 @@ function useValue(): useValueReturn { | ||||
|         }); | ||||
|       } | ||||
|     }, | ||||
|     [readyState, sendJsonMessage] | ||||
|     [readyState, sendJsonMessage], | ||||
|   ); | ||||
| 
 | ||||
|   return [wsState, setState]; | ||||
| @ -91,14 +93,14 @@ export function useWs(watchTopic: string, publishTopic: string) { | ||||
|   const value = { payload: state[watchTopic] || null }; | ||||
| 
 | ||||
|   const send = useCallback( | ||||
|     (payload: any, retain = false) => { | ||||
|     (payload: unknown, retain = false) => { | ||||
|       sendJsonMessage({ | ||||
|         topic: publishTopic || watchTopic, | ||||
|         payload, | ||||
|         retain, | ||||
|       }); | ||||
|     }, | ||||
|     [sendJsonMessage, watchTopic, publishTopic] | ||||
|     [sendJsonMessage, watchTopic, publishTopic], | ||||
|   ); | ||||
| 
 | ||||
|   return { value, send }; | ||||
| @ -112,7 +114,7 @@ export function useDetectState(camera: string): { | ||||
|     value: { payload }, | ||||
|     send, | ||||
|   } = useWs(`${camera}/detect/state`, `${camera}/detect/set`); | ||||
|   return { payload, send }; | ||||
|   return { payload: payload as ToggleableSetting, send }; | ||||
| } | ||||
| 
 | ||||
| export function useRecordingsState(camera: string): { | ||||
| @ -123,7 +125,7 @@ export function useRecordingsState(camera: string): { | ||||
|     value: { payload }, | ||||
|     send, | ||||
|   } = useWs(`${camera}/recordings/state`, `${camera}/recordings/set`); | ||||
|   return { payload, send }; | ||||
|   return { payload: payload as ToggleableSetting, send }; | ||||
| } | ||||
| 
 | ||||
| export function useSnapshotsState(camera: string): { | ||||
| @ -134,7 +136,7 @@ export function useSnapshotsState(camera: string): { | ||||
|     value: { payload }, | ||||
|     send, | ||||
|   } = useWs(`${camera}/snapshots/state`, `${camera}/snapshots/set`); | ||||
|   return { payload, send }; | ||||
|   return { payload: payload as ToggleableSetting, send }; | ||||
| } | ||||
| 
 | ||||
| export function useAudioState(camera: string): { | ||||
| @ -145,7 +147,7 @@ export function useAudioState(camera: string): { | ||||
|     value: { payload }, | ||||
|     send, | ||||
|   } = useWs(`${camera}/audio/state`, `${camera}/audio/set`); | ||||
|   return { payload, send }; | ||||
|   return { payload: payload as ToggleableSetting, send }; | ||||
| } | ||||
| 
 | ||||
| export function usePtzCommand(camera: string): { | ||||
| @ -156,7 +158,7 @@ export function usePtzCommand(camera: string): { | ||||
|     value: { payload }, | ||||
|     send, | ||||
|   } = useWs(`${camera}/ptz`, `${camera}/ptz`); | ||||
|   return { payload, send }; | ||||
|   return { payload: payload as string, send }; | ||||
| } | ||||
| 
 | ||||
| export function useRestart(): { | ||||
| @ -167,40 +169,40 @@ export function useRestart(): { | ||||
|     value: { payload }, | ||||
|     send, | ||||
|   } = useWs("restart", "restart"); | ||||
|   return { payload, send }; | ||||
|   return { payload: payload as string, send }; | ||||
| } | ||||
| 
 | ||||
| export function useFrigateEvents(): { payload: FrigateEvent } { | ||||
|   const { | ||||
|     value: { payload }, | ||||
|   } = useWs("events", ""); | ||||
|   return { payload: JSON.parse(payload) }; | ||||
|   return { payload: JSON.parse(payload as string) }; | ||||
| } | ||||
| 
 | ||||
| export function useFrigateReviews(): { payload: FrigateReview } { | ||||
|   const { | ||||
|     value: { payload }, | ||||
|   } = useWs("reviews", ""); | ||||
|   return { payload: JSON.parse(payload) }; | ||||
|   return { payload: JSON.parse(payload as string) }; | ||||
| } | ||||
| 
 | ||||
| export function useFrigateStats(): { payload: FrigateStats } { | ||||
|   const { | ||||
|     value: { payload }, | ||||
|   } = useWs("stats", ""); | ||||
|   return { payload: JSON.parse(payload) }; | ||||
|   return { payload: JSON.parse(payload as string) }; | ||||
| } | ||||
| 
 | ||||
| export function useMotionActivity(camera: string): { payload: string } { | ||||
|   const { | ||||
|     value: { payload }, | ||||
|   } = useWs(`${camera}/motion`, ""); | ||||
|   return { payload }; | ||||
|   return { payload: payload as string }; | ||||
| } | ||||
| 
 | ||||
| export function useAudioActivity(camera: string): { payload: number } { | ||||
|   const { | ||||
|     value: { payload }, | ||||
|   } = useWs(`${camera}/audio/rms`, ""); | ||||
|   return { payload }; | ||||
|   return { payload: payload as number }; | ||||
| } | ||||
|  | ||||
| @ -4,7 +4,7 @@ import { useMemo } from "react"; | ||||
| import { MdCircle } from "react-icons/md"; | ||||
| import useSWR from "swr"; | ||||
| 
 | ||||
| export default function Statusbar({}) { | ||||
| export default function Statusbar() { | ||||
|   const { data: initialStats } = useSWR<FrigateStats>("stats", { | ||||
|     revalidateOnFocus: false, | ||||
|   }); | ||||
|  | ||||
| @ -3,7 +3,7 @@ import CameraImage from "./CameraImage"; | ||||
| 
 | ||||
| type AutoUpdatingCameraImageProps = { | ||||
|   camera: string; | ||||
|   searchParams?: {}; | ||||
|   searchParams?: URLSearchParams; | ||||
|   showFps?: boolean; | ||||
|   className?: string; | ||||
|   reloadInterval?: number; | ||||
| @ -13,7 +13,7 @@ const MIN_LOAD_TIMEOUT_MS = 200; | ||||
| 
 | ||||
| export default function AutoUpdatingCameraImage({ | ||||
|   camera, | ||||
|   searchParams = "", | ||||
|   searchParams = undefined, | ||||
|   showFps = true, | ||||
|   className, | ||||
|   reloadInterval = MIN_LOAD_TIMEOUT_MS, | ||||
| @ -35,6 +35,8 @@ export default function AutoUpdatingCameraImage({ | ||||
|         setTimeoutId(undefined); | ||||
|       } | ||||
|     }; | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [reloadInterval]); | ||||
| 
 | ||||
|   const handleLoad = useCallback(() => { | ||||
| @ -53,9 +55,11 @@ export default function AutoUpdatingCameraImage({ | ||||
|         () => { | ||||
|           setKey(Date.now()); | ||||
|         }, | ||||
|         loadTime > reloadInterval ? 1 : reloadInterval | ||||
|       ) | ||||
|         loadTime > reloadInterval ? 1 : reloadInterval, | ||||
|       ), | ||||
|     ); | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [key, setFps]); | ||||
| 
 | ||||
|   return ( | ||||
|  | ||||
| @ -7,7 +7,7 @@ type CameraImageProps = { | ||||
|   className?: string; | ||||
|   camera: string; | ||||
|   onload?: () => void; | ||||
|   searchParams?: {}; | ||||
|   searchParams?: string; | ||||
| }; | ||||
| 
 | ||||
| export default function CameraImage({ | ||||
|  | ||||
| @ -24,25 +24,25 @@ export default function DebugCameraImage({ | ||||
|   const [showSettings, setShowSettings] = useState(false); | ||||
|   const [options, setOptions] = usePersistence<Options>( | ||||
|     `${cameraConfig?.name}-feed`, | ||||
|     emptyObject | ||||
|     emptyObject, | ||||
|   ); | ||||
|   const handleSetOption = useCallback( | ||||
|     (id: string, value: boolean) => { | ||||
|       const newOptions = { ...options, [id]: value }; | ||||
|       setOptions(newOptions); | ||||
|     }, | ||||
|     [options] | ||||
|     [options, setOptions], | ||||
|   ); | ||||
|   const searchParams = useMemo( | ||||
|     () => | ||||
|       new URLSearchParams( | ||||
|         Object.keys(options || {}).reduce((memo, key) => { | ||||
|           //@ts-ignore we know this is correct
 | ||||
|           //@ts-expect-error we know this is correct
 | ||||
|           memo.push([key, options[key] === true ? "1" : "0"]); | ||||
|           return memo; | ||||
|         }, []) | ||||
|         }, []), | ||||
|       ), | ||||
|     [options] | ||||
|     [options], | ||||
|   ); | ||||
|   const handleToggleSettings = useCallback(() => { | ||||
|     setShowSettings(!showSettings); | ||||
|  | ||||
| @ -8,7 +8,7 @@ type CameraImageProps = { | ||||
|   className?: string; | ||||
|   camera: string; | ||||
|   onload?: (event: Event) => void; | ||||
|   searchParams?: {}; | ||||
|   searchParams?: string; | ||||
|   stretch?: boolean; // stretch to fit width
 | ||||
|   fitAspect?: number; // shrink to fit height
 | ||||
| }; | ||||
| @ -58,10 +58,17 @@ export default function CameraImage({ | ||||
|     } | ||||
| 
 | ||||
|     return 100; | ||||
|   }, [availableWidth, aspectRatio, height, stretch]); | ||||
|   }, [ | ||||
|     availableWidth, | ||||
|     aspectRatio, | ||||
|     containerHeight, | ||||
|     fitAspect, | ||||
|     height, | ||||
|     stretch, | ||||
|   ]); | ||||
|   const scaledWidth = useMemo( | ||||
|     () => Math.ceil(scaledHeight * aspectRatio - scrollBarWidth), | ||||
|     [scaledHeight, aspectRatio, scrollBarWidth] | ||||
|     [scaledHeight, aspectRatio, scrollBarWidth], | ||||
|   ); | ||||
| 
 | ||||
|   const img = useMemo(() => new Image(), []); | ||||
| @ -74,7 +81,7 @@ export default function CameraImage({ | ||||
|       } | ||||
|       onload && onload(event); | ||||
|     }, | ||||
|     [img, scaledHeight, scaledWidth, setHasLoaded, onload, canvasRef] | ||||
|     [img, scaledHeight, scaledWidth, setHasLoaded, onload, canvasRef], | ||||
|   ); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|  | ||||
| @ -2,7 +2,7 @@ import { useFrigateReviews } from "@/api/ws"; | ||||
| import { ReviewSeverity } from "@/types/review"; | ||||
| import { Button } from "../ui/button"; | ||||
| import { LuRefreshCcw } from "react-icons/lu"; | ||||
| import { MutableRefObject, useEffect, useState } from "react"; | ||||
| import { MutableRefObject, useEffect, useMemo, useState } from "react"; | ||||
| 
 | ||||
| type NewReviewDataProps = { | ||||
|   className: string; | ||||
| @ -18,7 +18,8 @@ export default function NewReviewData({ | ||||
| }: NewReviewDataProps) { | ||||
|   const { payload: review } = useFrigateReviews(); | ||||
| 
 | ||||
|   const [reviewId, setReviewId] = useState(""); | ||||
|   const startCheckTs = useMemo(() => Date.now() / 1000, []); | ||||
|   const [reviewTs, setReviewTs] = useState(startCheckTs); | ||||
|   const [hasUpdate, setHasUpdate] = useState(false); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
| @ -27,15 +28,15 @@ export default function NewReviewData({ | ||||
|     } | ||||
| 
 | ||||
|     if (review.type == "end" && review.review.severity == severity) { | ||||
|       setReviewId(review.review.id); | ||||
|       setReviewTs(review.review.start_time); | ||||
|     } | ||||
|   }, [review]); | ||||
|   }, [review, severity]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (reviewId != "") { | ||||
|     if (reviewTs > startCheckTs) { | ||||
|       setHasUpdate(true); | ||||
|     } | ||||
|   }, [reviewId]); | ||||
|   }, [startCheckTs, reviewTs]); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={className}> | ||||
|  | ||||
| @ -91,7 +91,7 @@ const TimeAgo: FunctionComponent<IProp> = ({ | ||||
|     } else { | ||||
|       return 3600000; // refresh every hour
 | ||||
|     } | ||||
|   }, [currentTime, manualRefreshInterval]); | ||||
|   }, [currentTime, manualRefreshInterval, time]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     const intervalId: NodeJS.Timeout = setInterval(() => { | ||||
| @ -102,7 +102,7 @@ const TimeAgo: FunctionComponent<IProp> = ({ | ||||
| 
 | ||||
|   const timeAgoValue = useMemo( | ||||
|     () => timeAgo({ time, currentTime, ...rest }), | ||||
|     [currentTime, rest] | ||||
|     [currentTime, rest, time], | ||||
|   ); | ||||
| 
 | ||||
|   return <span>{timeAgoValue}</span>; | ||||
|  | ||||
| @ -54,7 +54,7 @@ export default function ReviewFilterGroup({ | ||||
|       cameras: Object.keys(config?.cameras || {}), | ||||
|       labels: Object.values(allLabels || {}), | ||||
|     }), | ||||
|     [config, allLabels] | ||||
|     [config, allLabels], | ||||
|   ); | ||||
| 
 | ||||
|   // handle updating filters
 | ||||
| @ -67,7 +67,7 @@ export default function ReviewFilterGroup({ | ||||
|         before: day == undefined ? undefined : getEndOfDayTimestamp(day), | ||||
|       }); | ||||
|     }, | ||||
|     [onUpdateFilter] | ||||
|     [filter, onUpdateFilter], | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
| @ -111,7 +111,7 @@ function CamerasFilterButton({ | ||||
|   updateCameraFilter, | ||||
| }: CameraFilterButtonProps) { | ||||
|   const [currentCameras, setCurrentCameras] = useState<string[] | undefined>( | ||||
|     selectedCameras | ||||
|     selectedCameras, | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
| @ -200,7 +200,7 @@ function CalendarFilterButton({ | ||||
|   }, []); | ||||
|   const selectedDate = useFormattedTimestamp( | ||||
|     day == undefined ? 0 : day?.getTime() / 1000, | ||||
|     "%b %-d" | ||||
|     "%b %-d", | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
| @ -273,7 +273,7 @@ function GeneralFilterButton({ | ||||
|           <Button | ||||
|             className="capitalize flex justify-between items-center cursor-pointer w-full" | ||||
|             variant="secondary" | ||||
|             onClick={(_) => setShowReviewed(showReviewed == 0 ? 1 : 0)} | ||||
|             onClick={() => setShowReviewed(showReviewed == 0 ? 1 : 0)} | ||||
|           > | ||||
|             {showReviewed ? ( | ||||
|               <LuCheck className="w-6 h-6" /> | ||||
| @ -299,7 +299,7 @@ function LabelsFilterButton({ | ||||
|   updateLabelFilter, | ||||
| }: LabelFilterButtonProps) { | ||||
|   const [currentLabels, setCurrentLabels] = useState<string[] | undefined>( | ||||
|     selectedLabels | ||||
|     selectedLabels, | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
| @ -375,7 +375,7 @@ function FilterCheckBox({ | ||||
|     <Button | ||||
|       className="capitalize flex justify-between items-center cursor-pointer w-full" | ||||
|       variant="ghost" | ||||
|       onClick={(_) => onCheckedChange(!isChecked)} | ||||
|       onClick={() => onCheckedChange(!isChecked)} | ||||
|     > | ||||
|       {isChecked ? ( | ||||
|         <LuCheck className="w-6 h-6" /> | ||||
|  | ||||
| @ -30,7 +30,7 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) { | ||||
|     } | ||||
| 
 | ||||
|     return `${baseUrl}api/review/${event.id}/preview.gif`; | ||||
|   }, [event]); | ||||
|   }, [apiHost, event]); | ||||
| 
 | ||||
|   const aspectRatio = useMemo(() => { | ||||
|     if (!config) { | ||||
| @ -39,7 +39,7 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) { | ||||
| 
 | ||||
|     const detect = config.cameras[event.camera].detect; | ||||
|     return detect.width / detect.height; | ||||
|   }, [config]); | ||||
|   }, [config, event]); | ||||
| 
 | ||||
|   return ( | ||||
|     <Tooltip> | ||||
|  | ||||
| @ -4,20 +4,20 @@ import SettingsNavItems from "../settings/SettingsNavItems"; | ||||
| 
 | ||||
| function Bottombar() { | ||||
|   return ( | ||||
|       <div className="absolute h-16 inset-x-4 bottom-0 flex flex-row items-center justify-between"> | ||||
|         {navbarLinks.map((item) => ( | ||||
|           <NavItem | ||||
|             className="" | ||||
|             variant="secondary" | ||||
|             key={item.id} | ||||
|             Icon={item.icon} | ||||
|             title={item.title} | ||||
|             url={item.url} | ||||
|             dev={item.dev} | ||||
|           /> | ||||
|         ))} | ||||
|     <div className="absolute h-16 inset-x-4 bottom-0 flex flex-row items-center justify-between"> | ||||
|       {navbarLinks.map((item) => ( | ||||
|         <NavItem | ||||
|           className="" | ||||
|           variant="secondary" | ||||
|           key={item.id} | ||||
|           Icon={item.icon} | ||||
|           title={item.title} | ||||
|           url={item.url} | ||||
|           dev={item.dev} | ||||
|         /> | ||||
|       ))} | ||||
|       <SettingsNavItems className="flex flex-shrink-0 justify-between gap-4" /> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -14,19 +14,6 @@ export default function TimelineEventOverlay({ | ||||
|   timeline, | ||||
|   cameraConfig, | ||||
| }: TimelineEventOverlayProps) { | ||||
|   if (!timeline.data.box) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   const boxLeftEdge = Math.round(timeline.data.box[0] * 100); | ||||
|   const boxTopEdge = Math.round(timeline.data.box[1] * 100); | ||||
|   const boxRightEdge = Math.round( | ||||
|     (1 - timeline.data.box[2] - timeline.data.box[0]) * 100 | ||||
|   ); | ||||
|   const boxBottomEdge = Math.round( | ||||
|     (1 - timeline.data.box[3] - timeline.data.box[1]) * 100 | ||||
|   ); | ||||
| 
 | ||||
|   const [isHovering, setIsHovering] = useState<boolean>(false); | ||||
|   const getHoverStyle = () => { | ||||
|     if (!timeline.data.box) { | ||||
| @ -67,6 +54,19 @@ export default function TimelineEventOverlay({ | ||||
|     return Math.round(100 * (width / height)) / 100; | ||||
|   }; | ||||
| 
 | ||||
|   if (!timeline.data.box) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   const boxLeftEdge = Math.round(timeline.data.box[0] * 100); | ||||
|   const boxTopEdge = Math.round(timeline.data.box[1] * 100); | ||||
|   const boxRightEdge = Math.round( | ||||
|     (1 - timeline.data.box[2] - timeline.data.box[0]) * 100, | ||||
|   ); | ||||
|   const boxBottomEdge = Math.round( | ||||
|     (1 - timeline.data.box[3] - timeline.data.box[1]) * 100, | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <div | ||||
|  | ||||
| @ -14,6 +14,9 @@ import useSWR from "swr"; | ||||
| import { FrigateConfig } from "@/types/frigateConfig"; | ||||
| import ActivityIndicator from "../ui/activity-indicator"; | ||||
| import useKeyboardListener from "@/hooks/use-keyboard-listener"; | ||||
| import { Recording } from "@/types/record"; | ||||
| import { Preview } from "@/types/preview"; | ||||
| import { DynamicPlayback } from "@/types/playback"; | ||||
| 
 | ||||
| /** | ||||
|  * Dynamically switches between video playback and scrubbing preview player. | ||||
| @ -37,7 +40,7 @@ export default function DynamicVideoPlayer({ | ||||
|   const timezone = useMemo( | ||||
|     () => | ||||
|       config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, | ||||
|     [config] | ||||
|     [config], | ||||
|   ); | ||||
| 
 | ||||
|   // playback behavior
 | ||||
| @ -51,7 +54,7 @@ export default function DynamicVideoPlayer({ | ||||
|         config.cameras[camera].detect.height < | ||||
|       1.7 | ||||
|     ); | ||||
|   }, [config]); | ||||
|   }, [camera, config]); | ||||
| 
 | ||||
|   // controlling playback
 | ||||
| 
 | ||||
| @ -60,7 +63,7 @@ export default function DynamicVideoPlayer({ | ||||
|   const [isScrubbing, setIsScrubbing] = useState(false); | ||||
|   const [hasPreview, setHasPreview] = useState(false); | ||||
|   const [focusedItem, setFocusedItem] = useState<Timeline | undefined>( | ||||
|     undefined | ||||
|     undefined, | ||||
|   ); | ||||
|   const controller = useMemo(() => { | ||||
|     if (!config) { | ||||
| @ -72,9 +75,9 @@ export default function DynamicVideoPlayer({ | ||||
|       previewRef, | ||||
|       (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000, | ||||
|       setIsScrubbing, | ||||
|       setFocusedItem | ||||
|       setFocusedItem, | ||||
|     ); | ||||
|   }, [config]); | ||||
|   }, [camera, config]); | ||||
| 
 | ||||
|   // keyboard control
 | ||||
| 
 | ||||
| @ -115,11 +118,11 @@ export default function DynamicVideoPlayer({ | ||||
|           break; | ||||
|       } | ||||
|     }, | ||||
|     [playerRef] | ||||
|     [playerRef], | ||||
|   ); | ||||
|   useKeyboardListener( | ||||
|     ["ArrowLeft", "ArrowRight", "m", " "], | ||||
|     onKeyboardShortcut | ||||
|     onKeyboardShortcut, | ||||
|   ); | ||||
| 
 | ||||
|   // initial state
 | ||||
| @ -131,16 +134,18 @@ export default function DynamicVideoPlayer({ | ||||
|         date.getMonth() + 1 | ||||
|       }/${date.getDate()}/${date.getHours()}/${camera}/${timezone.replaceAll( | ||||
|         "/", | ||||
|         "," | ||||
|         ",", | ||||
|       )}/master.m3u8`,
 | ||||
|       type: "application/vnd.apple.mpegurl", | ||||
|     }; | ||||
|     // we only want to calculate this once
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, []); | ||||
|   const initialPreviewSource = useMemo(() => { | ||||
|     const preview = cameraPreviews.find( | ||||
|       (preview) => | ||||
|         Math.round(preview.start) >= timeRange.start && | ||||
|         Math.floor(preview.end) <= timeRange.end | ||||
|         Math.floor(preview.end) <= timeRange.end, | ||||
|     ); | ||||
| 
 | ||||
|     if (preview) { | ||||
| @ -153,6 +158,9 @@ export default function DynamicVideoPlayer({ | ||||
|       setHasPreview(false); | ||||
|       return undefined; | ||||
|     } | ||||
| 
 | ||||
|     // we only want to calculate this once
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, []); | ||||
| 
 | ||||
|   // state of playback player
 | ||||
| @ -165,7 +173,7 @@ export default function DynamicVideoPlayer({ | ||||
|   }, [timeRange]); | ||||
|   const { data: recordings } = useSWR<Recording[]>( | ||||
|     [`${camera}/recordings`, recordingParams], | ||||
|     { revalidateOnFocus: false } | ||||
|     { revalidateOnFocus: false }, | ||||
|   ); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
| @ -178,13 +186,13 @@ export default function DynamicVideoPlayer({ | ||||
|       date.getMonth() + 1 | ||||
|     }/${date.getDate()}/${date.getHours()}/${camera}/${timezone.replaceAll( | ||||
|       "/", | ||||
|       "," | ||||
|       ",", | ||||
|     )}/master.m3u8`;
 | ||||
| 
 | ||||
|     const preview = cameraPreviews.find( | ||||
|       (preview) => | ||||
|         Math.round(preview.start) >= timeRange.start && | ||||
|         Math.floor(preview.end) <= timeRange.end | ||||
|         Math.floor(preview.end) <= timeRange.end, | ||||
|     ); | ||||
|     setHasPreview(preview != undefined); | ||||
| 
 | ||||
| @ -193,6 +201,9 @@ export default function DynamicVideoPlayer({ | ||||
|       playbackUri, | ||||
|       preview, | ||||
|     }); | ||||
| 
 | ||||
|     // we only want this to change when recordings update
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [controller, recordings]); | ||||
| 
 | ||||
|   if (!controller) { | ||||
| @ -300,7 +311,7 @@ export class DynamicVideoController { | ||||
|     previewRef: MutableRefObject<Player | undefined>, | ||||
|     annotationOffset: number, | ||||
|     setScrubbing: (isScrubbing: boolean) => void, | ||||
|     setFocusedItem: (timeline: Timeline) => void | ||||
|     setFocusedItem: (timeline: Timeline) => void, | ||||
|   ) { | ||||
|     this.playerRef = playerRef; | ||||
|     this.previewRef = previewRef; | ||||
| @ -437,7 +448,7 @@ export class DynamicVideoController { | ||||
|       this.timeToSeek = time; | ||||
|     } else { | ||||
|       this.previewRef.current?.currentTime( | ||||
|         Math.max(0, time - this.preview.start) | ||||
|         Math.max(0, time - this.preview.start), | ||||
|       ); | ||||
|       this.seeking = true; | ||||
|     } | ||||
| @ -453,7 +464,7 @@ export class DynamicVideoController { | ||||
|       this.timeToSeek != this.previewRef.current?.currentTime() | ||||
|     ) { | ||||
|       this.previewRef.current?.currentTime( | ||||
|         this.timeToSeek - this.preview.start | ||||
|         this.timeToSeek - this.preview.start, | ||||
|       ); | ||||
|     } else { | ||||
|       this.seeking = false; | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import { baseUrl } from "@/api/baseUrl"; | ||||
| import { useResizeObserver } from "@/hooks/resize-observer"; | ||||
| // @ts-ignore we know this doesn't have types
 | ||||
| // @ts-expect-error we know this doesn't have types
 | ||||
| import JSMpeg from "@cycjimmy/jsmpeg-player"; | ||||
| import { useEffect, useMemo, useRef } from "react"; | ||||
| 
 | ||||
| @ -47,10 +47,10 @@ export default function JSMpegPlayer({ | ||||
|     } | ||||
| 
 | ||||
|     return 100; | ||||
|   }, [availableWidth, aspectRatio, height]); | ||||
|   }, [availableWidth, aspectRatio, containerHeight, height]); | ||||
|   const scaledWidth = useMemo( | ||||
|     () => Math.ceil(scaledHeight * aspectRatio - scrollBarWidth), | ||||
|     [scaledHeight, aspectRatio, scrollBarWidth] | ||||
|     [scaledHeight, aspectRatio, scrollBarWidth], | ||||
|   ); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
| @ -62,7 +62,7 @@ export default function JSMpegPlayer({ | ||||
|       playerRef.current, | ||||
|       url, | ||||
|       {}, | ||||
|       { protocols: [], audio: false, videoBufferSize: 1024 * 1024 * 4 } | ||||
|       { protocols: [], audio: false, videoBufferSize: 1024 * 1024 * 4 }, | ||||
|     ); | ||||
| 
 | ||||
|     const fullscreen = () => { | ||||
| @ -79,6 +79,7 @@ export default function JSMpegPlayer({ | ||||
|       if (playerRef.current) { | ||||
|         try { | ||||
|           video.destroy(); | ||||
|           // eslint-disable-next-line no-empty
 | ||||
|         } catch (e) {} | ||||
|         playerRef.current = null; | ||||
|       } | ||||
|  | ||||
| @ -36,7 +36,7 @@ export default function LivePlayer({ | ||||
| 
 | ||||
|   const cameraActive = useMemo( | ||||
|     () => windowVisible && (activeMotion || activeTracking), | ||||
|     [activeMotion, activeTracking, windowVisible] | ||||
|     [activeMotion, activeTracking, windowVisible], | ||||
|   ); | ||||
| 
 | ||||
|   // camera live state
 | ||||
| @ -56,6 +56,8 @@ export default function LivePlayer({ | ||||
|     if (!cameraActive) { | ||||
|       setLiveReady(false); | ||||
|     } | ||||
|     // live mode won't change
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [cameraActive, liveReady]); | ||||
| 
 | ||||
|   const { payload: recording } = useRecordingsState(cameraConfig.name); | ||||
| @ -76,7 +78,7 @@ export default function LivePlayer({ | ||||
|     } | ||||
| 
 | ||||
|     return 30000; | ||||
|   }, []); | ||||
|   }, [liveReady, cameraActive, windowVisible]); | ||||
| 
 | ||||
|   if (!cameraConfig) { | ||||
|     return <ActivityIndicator />; | ||||
|  | ||||
| @ -37,8 +37,10 @@ function MSEPlayer({ | ||||
|   const videoRef = useRef<HTMLVideoElement>(null); | ||||
|   const wsRef = useRef<WebSocket | null>(null); | ||||
|   const reconnectTIDRef = useRef<number | null>(null); | ||||
|   const ondataRef = useRef<((data: any) => void) | null>(null); | ||||
|   const onmessageRef = useRef<{ [key: string]: (msg: any) => void }>({}); | ||||
|   const ondataRef = useRef<((data: ArrayBufferLike) => void) | null>(null); | ||||
|   const onmessageRef = useRef<{ | ||||
|     [key: string]: (msg: { value: string; type: string }) => void; | ||||
|   }>({}); | ||||
|   const msRef = useRef<MediaSource | null>(null); | ||||
| 
 | ||||
|   const wsURL = useMemo(() => { | ||||
| @ -49,7 +51,7 @@ function MSEPlayer({ | ||||
|     const currentVideo = videoRef.current; | ||||
| 
 | ||||
|     if (currentVideo) { | ||||
|       currentVideo.play().catch((er: any) => { | ||||
|       currentVideo.play().catch((er: { name: string }) => { | ||||
|         if (er.name === "NotAllowedError" && !currentVideo.muted) { | ||||
|           currentVideo.muted = true; | ||||
|           currentVideo.play().catch(() => {}); | ||||
| @ -59,16 +61,19 @@ function MSEPlayer({ | ||||
|   }; | ||||
| 
 | ||||
|   const send = useCallback( | ||||
|     (value: any) => { | ||||
|     (value: object) => { | ||||
|       if (wsRef.current) wsRef.current.send(JSON.stringify(value)); | ||||
|     }, | ||||
|     [wsRef] | ||||
|     [wsRef], | ||||
|   ); | ||||
| 
 | ||||
|   const codecs = useCallback((isSupported: (type: string) => boolean) => { | ||||
|     return CODECS.filter((codec) => | ||||
|       isSupported(`video/mp4; codecs="${codec}"`) | ||||
|       isSupported(`video/mp4; codecs="${codec}"`), | ||||
|     ).join(); | ||||
| 
 | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, []); | ||||
| 
 | ||||
|   const onConnect = useCallback(() => { | ||||
| @ -76,6 +81,8 @@ function MSEPlayer({ | ||||
| 
 | ||||
|     setWsState(WebSocket.CONNECTING); | ||||
| 
 | ||||
|     // TODO may need to check this later
 | ||||
|     // eslint-disable-next-line
 | ||||
|     connectTS = Date.now(); | ||||
| 
 | ||||
|     wsRef.current = new WebSocket(wsURL); | ||||
| @ -110,6 +117,8 @@ function MSEPlayer({ | ||||
|     onmessageRef.current = {}; | ||||
| 
 | ||||
|     onMse(); | ||||
|     // only run once
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, []); | ||||
| 
 | ||||
|   const onClose = useCallback(() => { | ||||
| @ -135,11 +144,11 @@ function MSEPlayer({ | ||||
|         () => { | ||||
|           send({ | ||||
|             type: "mse", | ||||
|             // @ts-ignore
 | ||||
|             // @ts-expect-error for typing
 | ||||
|             value: codecs(MediaSource.isTypeSupported), | ||||
|           }); | ||||
|         }, | ||||
|         { once: true } | ||||
|         { once: true }, | ||||
|       ); | ||||
| 
 | ||||
|       if (videoRef.current) { | ||||
| @ -156,7 +165,7 @@ function MSEPlayer({ | ||||
|             value: codecs(MediaSource.isTypeSupported), | ||||
|           }); | ||||
|         }, | ||||
|         { once: true } | ||||
|         { once: true }, | ||||
|       ); | ||||
|       videoRef.current!.src = URL.createObjectURL(msRef.current!); | ||||
|       videoRef.current!.srcObject = null; | ||||
| @ -184,6 +193,7 @@ function MSEPlayer({ | ||||
|             } | ||||
|           } | ||||
|         } catch (e) { | ||||
|           // eslint-disable-next-line no-console
 | ||||
|           console.debug(e); | ||||
|         } | ||||
|       }); | ||||
| @ -201,6 +211,7 @@ function MSEPlayer({ | ||||
|           try { | ||||
|             sb?.appendBuffer(data); | ||||
|           } catch (e) { | ||||
|             // eslint-disable-next-line no-console
 | ||||
|             console.debug(e); | ||||
|           } | ||||
|         } | ||||
| @ -217,7 +228,7 @@ function MSEPlayer({ | ||||
|     const MediaSourceConstructor = | ||||
|       "ManagedMediaSource" in window ? window.ManagedMediaSource : MediaSource; | ||||
| 
 | ||||
|     // @ts-ignore
 | ||||
|     // @ts-expect-error for typing
 | ||||
|     msRef.current = new MediaSourceConstructor(); | ||||
| 
 | ||||
|     if ("hidden" in document && visibilityCheck) { | ||||
| @ -241,7 +252,7 @@ function MSEPlayer({ | ||||
|             } | ||||
|           }); | ||||
|         }, | ||||
|         { threshold: visibilityThreshold } | ||||
|         { threshold: visibilityThreshold }, | ||||
|       ); | ||||
|       observer.observe(videoRef.current!); | ||||
|     } | ||||
| @ -251,6 +262,8 @@ function MSEPlayer({ | ||||
|     return () => { | ||||
|       onDisconnect(); | ||||
|     }; | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [playbackEnabled, onDisconnect, onConnect]); | ||||
| 
 | ||||
|   return ( | ||||
|  | ||||
| @ -78,13 +78,13 @@ export default function PreviewThumbnailPlayer({ | ||||
|   const playingBack = useMemo(() => playback, [playback]); | ||||
| 
 | ||||
|   const onPlayback = useCallback( | ||||
|     (isHovered: Boolean) => { | ||||
|     (isHovered: boolean) => { | ||||
|       if (isHovered) { | ||||
|         setHoverTimeout( | ||||
|           setTimeout(() => { | ||||
|             setPlayback(true); | ||||
|             setHoverTimeout(null); | ||||
|           }, 500) | ||||
|           }, 500), | ||||
|         ); | ||||
|       } else { | ||||
|         if (hoverTimeout) { | ||||
| @ -95,14 +95,17 @@ export default function PreviewThumbnailPlayer({ | ||||
|         setProgress(0); | ||||
|       } | ||||
|     }, | ||||
|     [hoverTimeout, review] | ||||
| 
 | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|     [hoverTimeout, review], | ||||
|   ); | ||||
| 
 | ||||
|   // date
 | ||||
| 
 | ||||
|   const formattedDate = useFormattedTimestamp( | ||||
|     review.start_time, | ||||
|     config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p" | ||||
|     config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
| @ -134,7 +137,7 @@ export default function PreviewThumbnailPlayer({ | ||||
|               }`}
 | ||||
|               src={`${apiHost}${review.thumb_path.replace( | ||||
|                 "/media/frigate/", | ||||
|                 "" | ||||
|                 "", | ||||
|               )}`}
 | ||||
|               loading={isSafari ? "eager" : "lazy"} | ||||
|               onLoad={() => { | ||||
| @ -215,8 +218,11 @@ function PreviewContent({ | ||||
|     // start with a bit of padding
 | ||||
|     return Math.max( | ||||
|       0, | ||||
|       review.start_time - relevantPreview.start - PREVIEW_PADDING | ||||
|       review.start_time - relevantPreview.start - PREVIEW_PADDING, | ||||
|     ); | ||||
| 
 | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, []); | ||||
|   const [lastPercent, setLastPercent] = useState(0.0); | ||||
| 
 | ||||
| @ -234,6 +240,9 @@ function PreviewContent({ | ||||
|       playerRef.current.currentTime = playerStartTime; | ||||
|       playerRef.current.playbackRate = 8; | ||||
|     } | ||||
| 
 | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [playerRef]); | ||||
| 
 | ||||
|   // time progress update
 | ||||
| @ -269,6 +278,9 @@ function PreviewContent({ | ||||
|     } else { | ||||
|       setProgress(playerPercent); | ||||
|     } | ||||
| 
 | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [setProgress, lastPercent]); | ||||
| 
 | ||||
|   // manual playback
 | ||||
| @ -289,6 +301,9 @@ function PreviewContent({ | ||||
|       } | ||||
|     }, 125); | ||||
|     return () => clearInterval(intervalId); | ||||
| 
 | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [manualPlayback, playerRef]); | ||||
| 
 | ||||
|   // preview
 | ||||
| @ -333,7 +348,7 @@ function InProgressPreview({ | ||||
|   const { data: previewFrames } = useSWR<string[]>( | ||||
|     `preview/${review.camera}/start/${Math.floor(review.start_time) - 4}/end/${ | ||||
|       Math.ceil(review.end_time) + 4 | ||||
|     }/frames` | ||||
|     }/frames`,
 | ||||
|   ); | ||||
|   const [key, setKey] = useState(0); | ||||
| 
 | ||||
| @ -361,6 +376,9 @@ function InProgressPreview({ | ||||
| 
 | ||||
|       setKey(key + 1); | ||||
|     }, MIN_LOAD_TIMEOUT_MS); | ||||
| 
 | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [key, previewFrames]); | ||||
| 
 | ||||
|   if (!previewFrames || previewFrames.length == 0) { | ||||
| @ -394,7 +412,7 @@ function PreviewContextItems({ | ||||
|   const exportReview = useCallback(() => { | ||||
|     axios.post( | ||||
|       `export/${review.camera}/start/${review.start_time}/end/${review.end_time}`, | ||||
|       { playback: "realtime" } | ||||
|       { playback: "realtime" }, | ||||
|     ); | ||||
|   }, [review]); | ||||
| 
 | ||||
|  | ||||
| @ -7,7 +7,7 @@ import Player from "video.js/dist/types/player"; | ||||
| type VideoPlayerProps = { | ||||
|   children?: ReactElement | ReactElement[]; | ||||
|   options?: { | ||||
|     [key: string]: any; | ||||
|     [key: string]: unknown; | ||||
|   }; | ||||
|   seekOptions?: { | ||||
|     forward?: number; | ||||
| @ -23,7 +23,7 @@ export default function VideoPlayer({ | ||||
|   options, | ||||
|   seekOptions = { forward: 30, backward: 10 }, | ||||
|   remotePlayback = false, | ||||
|   onReady = (_) => {}, | ||||
|   onReady = () => {}, | ||||
|   onDispose = () => {}, | ||||
| }: VideoPlayerProps) { | ||||
|   const videoRef = useRef<HTMLDivElement | null>(null); | ||||
| @ -47,7 +47,7 @@ export default function VideoPlayer({ | ||||
|     if (!playerRef.current) { | ||||
|       // The Video.js player needs to be _inside_ the component el for React 18 Strict Mode.
 | ||||
|       const videoElement = document.createElement( | ||||
|         "video-js" | ||||
|         "video-js", | ||||
|       ) as HTMLVideoElement; | ||||
|       videoElement.controls = true; | ||||
|       videoElement.playsInline = true; | ||||
| @ -62,9 +62,12 @@ export default function VideoPlayer({ | ||||
|         { ...defaultOptions, ...options }, | ||||
|         () => { | ||||
|           onReady && onReady(player); | ||||
|         } | ||||
|         }, | ||||
|       )); | ||||
|     } | ||||
| 
 | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [options, videoRef]); | ||||
| 
 | ||||
|   // Dispose the Video.js player when the functional component unmounts
 | ||||
| @ -78,6 +81,9 @@ export default function VideoPlayer({ | ||||
|         onDispose(); | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [playerRef]); | ||||
| 
 | ||||
|   return ( | ||||
|  | ||||
| @ -58,7 +58,7 @@ export default function WebRtcPlayer({ | ||||
|           .filter((kind) => media.indexOf(kind) >= 0) | ||||
|           .map( | ||||
|             (kind) => | ||||
|               pc.addTransceiver(kind, { direction: "recvonly" }).receiver.track | ||||
|               pc.addTransceiver(kind, { direction: "recvonly" }).receiver.track, | ||||
|           ); | ||||
|         localTracks.push(...tracks); | ||||
|       } | ||||
| @ -66,12 +66,12 @@ export default function WebRtcPlayer({ | ||||
|       videoRef.current.srcObject = new MediaStream(localTracks); | ||||
|       return pc; | ||||
|     }, | ||||
|     [videoRef] | ||||
|     [videoRef], | ||||
|   ); | ||||
| 
 | ||||
|   async function getMediaTracks( | ||||
|     media: string, | ||||
|     constraints: MediaStreamConstraints | ||||
|     constraints: MediaStreamConstraints, | ||||
|   ) { | ||||
|     try { | ||||
|       const stream = | ||||
| @ -126,7 +126,7 @@ export default function WebRtcPlayer({ | ||||
|         } | ||||
|       }); | ||||
|     }, | ||||
|     [] | ||||
|     [], | ||||
|   ); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
| @ -140,7 +140,7 @@ export default function WebRtcPlayer({ | ||||
| 
 | ||||
|     const url = `${baseUrl.replace( | ||||
|       /^http/, | ||||
|       "ws" | ||||
|       "ws", | ||||
|     )}live/webrtc/api/ws?src=${camera}`;
 | ||||
|     const ws = new WebSocket(url); | ||||
|     const aPc = PeerConnection("video+audio"); | ||||
|  | ||||
| @ -52,12 +52,12 @@ export function EventReviewTimeline({ | ||||
|   const observer = useRef<ResizeObserver | null>(null); | ||||
|   const timelineDuration = useMemo( | ||||
|     () => timelineStart - timelineEnd, | ||||
|     [timelineEnd, timelineStart] | ||||
|     [timelineEnd, timelineStart], | ||||
|   ); | ||||
| 
 | ||||
|   const { alignStartDateToTimeline, alignEndDateToTimeline } = useEventUtils( | ||||
|     events, | ||||
|     segmentDuration | ||||
|     segmentDuration, | ||||
|   ); | ||||
| 
 | ||||
|   const { handleMouseDown, handleMouseUp, handleMouseMove } = | ||||
| @ -79,6 +79,7 @@ export function EventReviewTimeline({ | ||||
| 
 | ||||
|   function handleResize() { | ||||
|     // TODO: handle screen resize for mobile
 | ||||
|     // eslint-disable-next-line no-empty
 | ||||
|     if (timelineRef.current && contentRef.current) { | ||||
|     } | ||||
|   } | ||||
| @ -94,6 +95,8 @@ export function EventReviewTimeline({ | ||||
|         observer.current?.unobserve(content); | ||||
|       }; | ||||
|     } | ||||
|     // should only be calculated at beginning
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, []); | ||||
| 
 | ||||
|   // Generate segments for the timeline
 | ||||
| @ -119,6 +122,8 @@ export function EventReviewTimeline({ | ||||
|         /> | ||||
|       ); | ||||
|     }); | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [ | ||||
|     segmentDuration, | ||||
|     timestampSpread, | ||||
| @ -132,6 +137,8 @@ export function EventReviewTimeline({ | ||||
| 
 | ||||
|   const segments = useMemo( | ||||
|     () => generateSegments(), | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|     [ | ||||
|       segmentDuration, | ||||
|       timestampSpread, | ||||
| @ -141,7 +148,7 @@ export function EventReviewTimeline({ | ||||
|       minimapStartTime, | ||||
|       minimapEndTime, | ||||
|       events, | ||||
|     ] | ||||
|     ], | ||||
|   ); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
| @ -149,7 +156,7 @@ export function EventReviewTimeline({ | ||||
|       requestAnimationFrame(() => { | ||||
|         if (currentTimeRef.current && currentTimeSegment) { | ||||
|           currentTimeRef.current.textContent = new Date( | ||||
|             currentTimeSegment * 1000 | ||||
|             currentTimeSegment * 1000, | ||||
|           ).toLocaleTimeString([], { | ||||
|             hour: "2-digit", | ||||
|             minute: "2-digit", | ||||
| @ -158,6 +165,8 @@ export function EventReviewTimeline({ | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [currentTimeSegment, showHandlebar]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
| @ -177,7 +186,7 @@ export function EventReviewTimeline({ | ||||
|       // Calculate the segment index corresponding to the target time
 | ||||
|       const alignedHandlebarTime = alignStartDateToTimeline(handlebarTime); | ||||
|       const segmentIndex = Math.ceil( | ||||
|         (timelineStart - alignedHandlebarTime) / segmentDuration | ||||
|         (timelineStart - alignedHandlebarTime) / segmentDuration, | ||||
|       ); | ||||
| 
 | ||||
|       // Calculate the top position based on the segment index
 | ||||
| @ -193,6 +202,8 @@ export function EventReviewTimeline({ | ||||
| 
 | ||||
|       setCurrentTimeSegment(alignedHandlebarTime); | ||||
|     } | ||||
|     // should only be run once
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, []); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
| @ -207,6 +218,8 @@ export function EventReviewTimeline({ | ||||
|       document.removeEventListener("mousemove", handleMouseMove); | ||||
|       document.removeEventListener("mouseup", handleMouseUp); | ||||
|     }; | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [ | ||||
|     currentTimeSegment, | ||||
|     generateSegments, | ||||
|  | ||||
| @ -150,17 +150,19 @@ export function EventSegment({ | ||||
| 
 | ||||
|   const { alignStartDateToTimeline, alignEndDateToTimeline } = useEventUtils( | ||||
|     events, | ||||
|     segmentDuration | ||||
|     segmentDuration, | ||||
|   ); | ||||
| 
 | ||||
|   const severity = useMemo( | ||||
|     () => getSeverity(segmentTime, displaySeverityType), | ||||
|     [getSeverity, segmentTime] | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|     [getSeverity, segmentTime], | ||||
|   ); | ||||
| 
 | ||||
|   const reviewed = useMemo( | ||||
|     () => getReviewed(segmentTime), | ||||
|     [getReviewed, segmentTime] | ||||
|     [getReviewed, segmentTime], | ||||
|   ); | ||||
| 
 | ||||
|   const { | ||||
| @ -170,7 +172,7 @@ export function EventSegment({ | ||||
|     roundBottomSecondary, | ||||
|   } = useMemo( | ||||
|     () => shouldShowRoundedCorners(segmentTime), | ||||
|     [shouldShowRoundedCorners, segmentTime] | ||||
|     [shouldShowRoundedCorners, segmentTime], | ||||
|   ); | ||||
| 
 | ||||
|   const startTimestamp = useMemo(() => { | ||||
| @ -178,6 +180,8 @@ export function EventSegment({ | ||||
|     if (eventStart) { | ||||
|       return alignStartDateToTimeline(eventStart); | ||||
|     } | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [getEventStart, segmentTime]); | ||||
| 
 | ||||
|   const apiHost = useApiHost(); | ||||
| @ -191,11 +195,11 @@ export function EventSegment({ | ||||
| 
 | ||||
|   const alignedMinimapStartTime = useMemo( | ||||
|     () => alignStartDateToTimeline(minimapStartTime ?? 0), | ||||
|     [minimapStartTime, alignStartDateToTimeline] | ||||
|     [minimapStartTime, alignStartDateToTimeline], | ||||
|   ); | ||||
|   const alignedMinimapEndTime = useMemo( | ||||
|     () => alignEndDateToTimeline(minimapEndTime ?? 0), | ||||
|     [minimapEndTime, alignEndDateToTimeline] | ||||
|     [minimapEndTime, alignEndDateToTimeline], | ||||
|   ); | ||||
| 
 | ||||
|   const isInMinimapRange = useMemo(() => { | ||||
| @ -236,6 +240,8 @@ export function EventSegment({ | ||||
|     if (firstSegment && showMinimap && isFirstSegmentInMinimap) { | ||||
|       debounceScrollIntoView(firstSegment); | ||||
|     } | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [showMinimap, isFirstSegmentInMinimap, events, segmentDuration]); | ||||
| 
 | ||||
|   const segmentClasses = `h-2 relative w-full ${ | ||||
| @ -267,13 +273,13 @@ export function EventSegment({ | ||||
|   const segmentClick = useCallback(() => { | ||||
|     if (contentRef.current && startTimestamp) { | ||||
|       const element = contentRef.current.querySelector( | ||||
|         `[data-segment-start="${startTimestamp - segmentDuration}"]` | ||||
|         `[data-segment-start="${startTimestamp - segmentDuration}"]`, | ||||
|       ); | ||||
|       if (element instanceof HTMLElement) { | ||||
|         debounceScrollIntoView(element); | ||||
|         element.classList.add( | ||||
|           `outline-severity_${severityType}`, | ||||
|           `shadow-severity_${severityType}` | ||||
|           `shadow-severity_${severityType}`, | ||||
|         ); | ||||
|         element.classList.add("outline-4", "shadow-[0_0_6px_1px]"); | ||||
|         element.classList.remove("outline-0", "shadow-none"); | ||||
| @ -285,6 +291,8 @@ export function EventSegment({ | ||||
|         }, 3000); | ||||
|       } | ||||
|     } | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [startTimestamp]); | ||||
| 
 | ||||
|   return ( | ||||
|  | ||||
| @ -67,6 +67,7 @@ export function ThemeProvider({ | ||||
|       const storedData = JSON.parse(localStorage.getItem(storageKey) || "{}"); | ||||
|       return storedData.theme || defaultTheme; | ||||
|     } catch (error) { | ||||
|       // eslint-disable-next-line no-console
 | ||||
|       console.error("Error parsing theme data from storage:", error); | ||||
|       return defaultTheme; | ||||
|     } | ||||
| @ -79,6 +80,7 @@ export function ThemeProvider({ | ||||
|         ? defaultColorScheme | ||||
|         : storedData.colorScheme || defaultColorScheme; | ||||
|     } catch (error) { | ||||
|       // eslint-disable-next-line no-console
 | ||||
|       console.error("Error parsing color scheme data from storage:", error); | ||||
|       return defaultColorScheme; | ||||
|     } | ||||
|  | ||||
| @ -1 +1 @@ | ||||
| export const ENV = import.meta.env.MODE; | ||||
| export const ENV = import.meta.env.MODE; | ||||
|  | ||||
| @ -7,7 +7,7 @@ export function useResizeObserver(...refs: MutableRefObject<Element | null>[]) { | ||||
|       height: 0, | ||||
|       x: -Infinity, | ||||
|       y: -Infinity, | ||||
|     }) | ||||
|     }), | ||||
|   ); | ||||
|   const resizeObserver = useMemo( | ||||
|     () => | ||||
| @ -16,7 +16,7 @@ export function useResizeObserver(...refs: MutableRefObject<Element | null>[]) { | ||||
|           setDimensions(entries.map((entry) => entry.contentRect)); | ||||
|         }); | ||||
|       }), | ||||
|     [] | ||||
|     [], | ||||
|   ); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|  | ||||
| @ -1,9 +1,12 @@ | ||||
| import { FilterType } from "@/types/filter"; | ||||
| import { useMemo, useState } from "react"; | ||||
| 
 | ||||
| type useApiFilterReturn<F extends FilterType> = [ | ||||
|   filter: F | undefined, | ||||
|   setFilter: (filter: F) => void, | ||||
|   searchParams: { | ||||
|     // accept any type for a filter
 | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     [key: string]: any; | ||||
|   }, | ||||
| ]; | ||||
|  | ||||
| @ -13,12 +13,12 @@ type useCameraActivityReturn = { | ||||
| }; | ||||
| 
 | ||||
| export default function useCameraActivity( | ||||
|   camera: CameraConfig | ||||
|   camera: CameraConfig, | ||||
| ): useCameraActivityReturn { | ||||
|   const [activeObjects, setActiveObjects] = useState<string[]>([]); | ||||
|   const hasActiveObjects = useMemo( | ||||
|     () => activeObjects.length > 0, | ||||
|     [activeObjects] | ||||
|     [activeObjects], | ||||
|   ); | ||||
| 
 | ||||
|   const { payload: detectingMotion } = useMotionActivity(camera.name); | ||||
| @ -56,7 +56,7 @@ export default function useCameraActivity( | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, [event, activeObjects]); | ||||
|   }, [camera, event, activeObjects]); | ||||
| 
 | ||||
|   return { | ||||
|     activeTracking: hasActiveObjects, | ||||
|  | ||||
| @ -6,7 +6,7 @@ import { LivePlayerMode } from "@/types/live"; | ||||
| 
 | ||||
| export default function useCameraLiveMode( | ||||
|   cameraConfig: CameraConfig, | ||||
|   preferredMode?: string | ||||
|   preferredMode?: string, | ||||
| ): LivePlayerMode | undefined { | ||||
|   const { data: config } = useSWR<FrigateConfig>("config"); | ||||
| 
 | ||||
| @ -18,7 +18,7 @@ export default function useCameraLiveMode( | ||||
|     return ( | ||||
|       cameraConfig && | ||||
|       Object.keys(config.go2rtc.streams || {}).includes( | ||||
|         cameraConfig.live.stream_name | ||||
|         cameraConfig.live.stream_name, | ||||
|       ) | ||||
|     ); | ||||
|   }, [config, cameraConfig]); | ||||
| @ -32,10 +32,12 @@ export default function useCameraLiveMode( | ||||
|     } | ||||
| 
 | ||||
|     return undefined; | ||||
|     // config will be updated if camera config is updated
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [cameraConfig, restreamEnabled]); | ||||
|   const [viewSource] = usePersistence<LivePlayerMode>( | ||||
|     `${cameraConfig.name}-source`, | ||||
|     defaultLiveMode | ||||
|     defaultLiveMode, | ||||
|   ); | ||||
| 
 | ||||
|   if ( | ||||
|  | ||||
| @ -3,7 +3,7 @@ import { ReviewSegment } from "@/types/review"; | ||||
| 
 | ||||
| export const useEventUtils = ( | ||||
|   events: ReviewSegment[], | ||||
|   segmentDuration: number | ||||
|   segmentDuration: number, | ||||
| ) => { | ||||
|   const isStartOfEvent = useCallback( | ||||
|     (time: number): boolean => { | ||||
| @ -12,7 +12,9 @@ export const useEventUtils = ( | ||||
|         return time >= segmentStart && time < segmentStart + segmentDuration; | ||||
|       }); | ||||
|     }, | ||||
|     [events, segmentDuration] | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|     [events, segmentDuration], | ||||
|   ); | ||||
| 
 | ||||
|   const isEndOfEvent = useCallback( | ||||
| @ -25,21 +27,23 @@ export const useEventUtils = ( | ||||
|         return false; | ||||
|       }); | ||||
|     }, | ||||
|     [events, segmentDuration] | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|     [events, segmentDuration], | ||||
|   ); | ||||
| 
 | ||||
|   const getSegmentStart = useCallback( | ||||
|     (time: number): number => { | ||||
|       return Math.floor(time / segmentDuration) * segmentDuration; | ||||
|     }, | ||||
|     [segmentDuration] | ||||
|     [segmentDuration], | ||||
|   ); | ||||
| 
 | ||||
|   const getSegmentEnd = useCallback( | ||||
|     (time: number): number => { | ||||
|       return Math.ceil(time / segmentDuration) * segmentDuration; | ||||
|     }, | ||||
|     [segmentDuration] | ||||
|     [segmentDuration], | ||||
|   ); | ||||
| 
 | ||||
|   const alignEndDateToTimeline = useCallback( | ||||
| @ -48,16 +52,16 @@ export const useEventUtils = ( | ||||
|       const adjustment = remainder !== 0 ? segmentDuration - remainder : 0; | ||||
|       return time + adjustment; | ||||
|     }, | ||||
|     [segmentDuration] | ||||
|     [segmentDuration], | ||||
|   ); | ||||
| 
 | ||||
|   const alignStartDateToTimeline = useCallback( | ||||
|     (time: number): number => { | ||||
|       const remainder = time % segmentDuration; | ||||
|       const adjustment = remainder === 0 ? 0 : -(remainder); | ||||
|       const adjustment = remainder === 0 ? 0 : -remainder; | ||||
|       return time + adjustment; | ||||
|     }, | ||||
|     [segmentDuration] | ||||
|     [segmentDuration], | ||||
|   ); | ||||
| 
 | ||||
|   return { | ||||
|  | ||||
| @ -37,7 +37,7 @@ function useDraggableHandler({ | ||||
|       e.stopPropagation(); | ||||
|       setIsDragging(true); | ||||
|     }, | ||||
|     [setIsDragging] | ||||
|     [setIsDragging], | ||||
|   ); | ||||
| 
 | ||||
|   const handleMouseUp = useCallback( | ||||
| @ -48,7 +48,7 @@ function useDraggableHandler({ | ||||
|         setIsDragging(false); | ||||
|       } | ||||
|     }, | ||||
|     [isDragging, setIsDragging] | ||||
|     [isDragging, setIsDragging], | ||||
|   ); | ||||
| 
 | ||||
|   const handleMouseMove = useCallback( | ||||
| @ -90,13 +90,13 @@ function useDraggableHandler({ | ||||
|           visibleTimelineHeight - timelineTop + parentScrollTop, | ||||
|           Math.max( | ||||
|             segmentHeight + scrolled, | ||||
|             e.clientY - timelineTop + parentScrollTop | ||||
|           ) | ||||
|             e.clientY - timelineTop + parentScrollTop, | ||||
|           ), | ||||
|         ); | ||||
| 
 | ||||
|         const segmentIndex = Math.floor(newHandlePosition / segmentHeight); | ||||
|         const segmentStartTime = alignStartDateToTimeline( | ||||
|           timelineStart - segmentIndex * segmentDuration | ||||
|           timelineStart - segmentIndex * segmentDuration, | ||||
|         ); | ||||
| 
 | ||||
|         if (showHandlebar) { | ||||
| @ -105,7 +105,7 @@ function useDraggableHandler({ | ||||
|             thumb.style.top = `${newHandlePosition - segmentHeight}px`; | ||||
|             if (currentTimeRef.current) { | ||||
|               currentTimeRef.current.textContent = new Date( | ||||
|                 segmentStartTime * 1000 | ||||
|                 segmentStartTime * 1000, | ||||
|               ).toLocaleTimeString([], { | ||||
|                 hour: "2-digit", | ||||
|                 minute: "2-digit", | ||||
| @ -116,12 +116,14 @@ function useDraggableHandler({ | ||||
|           if (setHandlebarTime) { | ||||
|             setHandlebarTime( | ||||
|               timelineStart - | ||||
|                 (newHandlePosition / segmentHeight) * segmentDuration | ||||
|                 (newHandlePosition / segmentHeight) * segmentDuration, | ||||
|             ); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|     [ | ||||
|       isDragging, | ||||
|       contentRef, | ||||
| @ -129,7 +131,7 @@ function useDraggableHandler({ | ||||
|       showHandlebar, | ||||
|       timelineDuration, | ||||
|       timelineStart, | ||||
|     ] | ||||
|     ], | ||||
|   ); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
| @ -141,6 +143,8 @@ function useDraggableHandler({ | ||||
|         block: "center", | ||||
|       }); | ||||
|     } | ||||
|     // temporary until behavior is decided
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, []); | ||||
| 
 | ||||
|   return { handleMouseDown, handleMouseUp, handleMouseMove }; | ||||
|  | ||||
| @ -2,7 +2,7 @@ import { useCallback, useEffect } from "react"; | ||||
| 
 | ||||
| export default function useKeyboardListener( | ||||
|   keys: string[], | ||||
|   listener: (key: string, down: boolean, repeat: boolean) => void | ||||
|   listener: (key: string, down: boolean, repeat: boolean) => void, | ||||
| ) { | ||||
|   const keyDownListener = useCallback( | ||||
|     (e: KeyboardEvent) => { | ||||
| @ -15,7 +15,7 @@ export default function useKeyboardListener( | ||||
|         listener(e.key, true, e.repeat); | ||||
|       } | ||||
|     }, | ||||
|     [listener] | ||||
|     [keys, listener], | ||||
|   ); | ||||
| 
 | ||||
|   const keyUpListener = useCallback( | ||||
| @ -29,7 +29,7 @@ export default function useKeyboardListener( | ||||
|         listener(e.key, false, false); | ||||
|       } | ||||
|     }, | ||||
|     [listener] | ||||
|     [keys, listener], | ||||
|   ); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
| @ -39,5 +39,5 @@ export default function useKeyboardListener( | ||||
|       document.removeEventListener("keydown", keyDownListener); | ||||
|       document.removeEventListener("keyup", keyUpListener); | ||||
|     }; | ||||
|   }, [listener]); | ||||
|   }, [listener, keyDownListener, keyUpListener]); | ||||
| } | ||||
|  | ||||
| @ -12,7 +12,9 @@ export default function useOverlayState(key: string) { | ||||
|       newLocationState[key] = value; | ||||
|       navigate(location.pathname, { state: newLocationState }); | ||||
|     }, | ||||
|     [navigate] | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|     [key, navigate], | ||||
|   ); | ||||
| 
 | ||||
|   const overlayStateValue = location.state && location.state[key]; | ||||
|  | ||||
| @ -9,7 +9,7 @@ type usePersistenceReturn<S> = [ | ||||
| 
 | ||||
| export function usePersistence<S>( | ||||
|   key: string, | ||||
|   defaultValue: S | undefined = undefined | ||||
|   defaultValue: S | undefined = undefined, | ||||
| ): usePersistenceReturn<S> { | ||||
|   const [value, setInternalValue] = useState<S | undefined>(defaultValue); | ||||
|   const [loaded, setLoaded] = useState<boolean>(false); | ||||
| @ -23,7 +23,7 @@ export function usePersistence<S>( | ||||
| 
 | ||||
|       update(); | ||||
|     }, | ||||
|     [key] | ||||
|     [key], | ||||
|   ); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|  | ||||
| @ -4,13 +4,13 @@ import { ReviewSegment } from "@/types/review"; | ||||
| export const useSegmentUtils = ( | ||||
|   segmentDuration: number, | ||||
|   events: ReviewSegment[], | ||||
|   severityType: string | ||||
|   severityType: string, | ||||
| ) => { | ||||
|   const getSegmentStart = useCallback( | ||||
|     (time: number): number => { | ||||
|       return Math.floor(time / segmentDuration) * segmentDuration; | ||||
|     }, | ||||
|     [segmentDuration] | ||||
|     [segmentDuration], | ||||
|   ); | ||||
| 
 | ||||
|   const getSegmentEnd = useCallback( | ||||
| @ -23,7 +23,7 @@ export const useSegmentUtils = ( | ||||
|         return Date.now() / 1000 + segmentDuration; | ||||
|       } | ||||
|     }, | ||||
|     [segmentDuration] | ||||
|     [segmentDuration], | ||||
|   ); | ||||
| 
 | ||||
|   const mapSeverityToNumber = useCallback((severity: string): number => { | ||||
| @ -41,7 +41,7 @@ export const useSegmentUtils = ( | ||||
| 
 | ||||
|   const displaySeverityType = useMemo( | ||||
|     () => mapSeverityToNumber(severityType ?? ""), | ||||
|     [severityType] | ||||
|     [mapSeverityToNumber, severityType], | ||||
|   ); | ||||
| 
 | ||||
|   const getSeverity = useCallback( | ||||
| @ -54,7 +54,7 @@ export const useSegmentUtils = ( | ||||
| 
 | ||||
|       if (activeEvents?.length === 0) return [0]; | ||||
|       const severityValues = activeEvents.map((event) => | ||||
|         mapSeverityToNumber(event.severity) | ||||
|         mapSeverityToNumber(event.severity), | ||||
|       ); | ||||
|       const highestSeverityValue = Math.max(...severityValues); | ||||
| 
 | ||||
| @ -67,7 +67,7 @@ export const useSegmentUtils = ( | ||||
|         return [highestSeverityValue]; | ||||
|       } | ||||
|     }, | ||||
|     [events, getSegmentStart, getSegmentEnd, mapSeverityToNumber] | ||||
|     [events, getSegmentStart, getSegmentEnd, mapSeverityToNumber], | ||||
|   ); | ||||
| 
 | ||||
|   const getReviewed = useCallback( | ||||
| @ -80,12 +80,12 @@ export const useSegmentUtils = ( | ||||
|         ); | ||||
|       }); | ||||
|     }, | ||||
|     [events, getSegmentStart, getSegmentEnd] | ||||
|     [events, getSegmentStart, getSegmentEnd], | ||||
|   ); | ||||
| 
 | ||||
|   const shouldShowRoundedCorners = useCallback( | ||||
|     ( | ||||
|       segmentTime: number | ||||
|       segmentTime: number, | ||||
|     ): { | ||||
|       roundTopPrimary: boolean; | ||||
|       roundBottomPrimary: boolean; | ||||
| @ -163,7 +163,7 @@ export const useSegmentUtils = ( | ||||
|         roundBottomSecondary, | ||||
|       }; | ||||
|     }, | ||||
|     [events, getSegmentStart, getSegmentEnd, segmentDuration, severityType] | ||||
|     [events, getSegmentStart, getSegmentEnd, segmentDuration, severityType], | ||||
|   ); | ||||
| 
 | ||||
|   const getEventStart = useCallback( | ||||
| @ -178,7 +178,7 @@ export const useSegmentUtils = ( | ||||
| 
 | ||||
|       return matchingEvent?.start_time ?? 0; | ||||
|     }, | ||||
|     [events, getSegmentStart, getSegmentEnd, severityType] | ||||
|     [events, getSegmentStart, getSegmentEnd, severityType], | ||||
|   ); | ||||
| 
 | ||||
|   const getEventThumbnail = useCallback( | ||||
| @ -193,7 +193,7 @@ export const useSegmentUtils = ( | ||||
| 
 | ||||
|       return matchingEvent?.thumb_path ?? ""; | ||||
|     }, | ||||
|     [events, getSegmentStart, getSegmentEnd, severityType] | ||||
|     [events, getSegmentStart, getSegmentEnd, severityType], | ||||
|   ); | ||||
| 
 | ||||
|   return { | ||||
| @ -204,6 +204,6 @@ export const useSegmentUtils = ( | ||||
|     getReviewed, | ||||
|     shouldShowRoundedCorners, | ||||
|     getEventStart, | ||||
|     getEventThumbnail | ||||
|     getEventThumbnail, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| @ -1,25 +1,25 @@ | ||||
| const formatter = new Intl.RelativeTimeFormat(undefined, { | ||||
|     numeric: "always", | ||||
|   }) | ||||
|   numeric: "always", | ||||
| }); | ||||
| 
 | ||||
|   const DIVISIONS: { amount: number; name: Intl.RelativeTimeFormatUnit }[] = [ | ||||
|     { amount: 60, name: "seconds" }, | ||||
|     { amount: 60, name: "minutes" }, | ||||
|     { amount: 24, name: "hours" }, | ||||
|     { amount: 7, name: "days" }, | ||||
|     { amount: 4.34524, name: "weeks" }, | ||||
|     { amount: 12, name: "months" }, | ||||
|     { amount: Number.POSITIVE_INFINITY, name: "years" }, | ||||
|   ] | ||||
| const DIVISIONS: { amount: number; name: Intl.RelativeTimeFormatUnit }[] = [ | ||||
|   { amount: 60, name: "seconds" }, | ||||
|   { amount: 60, name: "minutes" }, | ||||
|   { amount: 24, name: "hours" }, | ||||
|   { amount: 7, name: "days" }, | ||||
|   { amount: 4.34524, name: "weeks" }, | ||||
|   { amount: 12, name: "months" }, | ||||
|   { amount: Number.POSITIVE_INFINITY, name: "years" }, | ||||
| ]; | ||||
| 
 | ||||
|   export function formatTimeAgo(date: Date) { | ||||
|     let duration = (date.getTime() - new Date().getTime()) / 1000 | ||||
| export function formatTimeAgo(date: Date) { | ||||
|   let duration = (date.getTime() - new Date().getTime()) / 1000; | ||||
| 
 | ||||
|     for (let i = 0; i < DIVISIONS.length; i++) { | ||||
|       const division = DIVISIONS[i] | ||||
|       if (Math.abs(duration) < division.amount) { | ||||
|         return formatter.format(Math.round(duration), division.name) | ||||
|       } | ||||
|       duration /= division.amount | ||||
|   for (let i = 0; i < DIVISIONS.length; i++) { | ||||
|     const division = DIVISIONS[i]; | ||||
|     if (Math.abs(duration) < division.amount) { | ||||
|       return formatter.format(Math.round(duration), division.name); | ||||
|     } | ||||
|   } | ||||
|     duration /= division.amount; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import { type ClassValue, clsx } from "clsx" | ||||
| import { twMerge } from "tailwind-merge" | ||||
| import { type ClassValue, clsx } from "clsx"; | ||||
| import { twMerge } from "tailwind-merge"; | ||||
| 
 | ||||
| export function cn(...inputs: ClassValue[]) { | ||||
|   return twMerge(clsx(inputs)) | ||||
|   return twMerge(clsx(inputs)); | ||||
| } | ||||
|  | ||||
| @ -1,10 +1,10 @@ | ||||
| import React from 'react' | ||||
| import ReactDOM from 'react-dom/client' | ||||
| import App from './App.tsx' | ||||
| import './index.css' | ||||
| import React from "react"; | ||||
| import ReactDOM from "react-dom/client"; | ||||
| import App from "./App.tsx"; | ||||
| import "./index.css"; | ||||
| 
 | ||||
| ReactDOM.createRoot(document.getElementById('root')!).render( | ||||
| ReactDOM.createRoot(document.getElementById("root")!).render( | ||||
|   <React.StrictMode> | ||||
|     <App /> | ||||
|   </React.StrictMode>, | ||||
| ) | ||||
| ); | ||||
|  | ||||
| @ -38,7 +38,7 @@ function ConfigEditor() { | ||||
|           editorRef.current.getValue(), | ||||
|           { | ||||
|             headers: { "Content-Type": "text/plain" }, | ||||
|           } | ||||
|           }, | ||||
|         ) | ||||
|         .then((response) => { | ||||
|           if (response.status === 200) { | ||||
| @ -56,7 +56,7 @@ function ConfigEditor() { | ||||
|           } | ||||
|         }); | ||||
|     }, | ||||
|     [editorRef] | ||||
|     [editorRef], | ||||
|   ); | ||||
| 
 | ||||
|   const handleCopyConfig = useCallback(async () => { | ||||
| @ -127,24 +127,20 @@ function ConfigEditor() { | ||||
|       <div className="lg:flex justify-between mr-1"> | ||||
|         <Heading as="h2">Config</Heading> | ||||
|         <div> | ||||
|           <Button | ||||
|             size="sm" | ||||
|             className="mx-1" | ||||
|             onClick={(_) => handleCopyConfig()} | ||||
|           > | ||||
|           <Button size="sm" className="mx-1" onClick={() => handleCopyConfig()}> | ||||
|             Copy Config | ||||
|           </Button> | ||||
|           <Button | ||||
|             size="sm" | ||||
|             className="mx-1" | ||||
|             onClick={(_) => onHandleSaveConfig("restart")} | ||||
|             onClick={() => onHandleSaveConfig("restart")} | ||||
|           > | ||||
|             Save & Restart | ||||
|           </Button> | ||||
|           <Button | ||||
|             size="sm" | ||||
|             className="mx-1" | ||||
|             onClick={(_) => onHandleSaveConfig("saveonly")} | ||||
|             onClick={() => onHandleSaveConfig("saveonly")} | ||||
|           > | ||||
|             Save Only | ||||
|           </Button> | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| import useApiFilter from "@/hooks/use-api-filter"; | ||||
| import useOverlayState from "@/hooks/use-overlay-state"; | ||||
| import { Preview } from "@/types/preview"; | ||||
| import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; | ||||
| import DesktopRecordingView from "@/views/events/DesktopRecordingView"; | ||||
| import EventView from "@/views/events/EventView"; | ||||
| @ -24,6 +25,8 @@ export default function Events() { | ||||
|   const onUpdateFilter = useCallback((newFilter: ReviewFilter) => { | ||||
|     setSize(1); | ||||
|     setReviewFilter(newFilter); | ||||
|     // we don't want this updating
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, []); | ||||
| 
 | ||||
|   // review paging
 | ||||
| @ -41,9 +44,9 @@ export default function Events() { | ||||
|       before: Math.floor(reviewSearchParams["before"]), | ||||
|       after: Math.floor(reviewSearchParams["after"]), | ||||
|     }; | ||||
|   }, [reviewSearchParams]); | ||||
|   }, [last24Hours, reviewSearchParams]); | ||||
| 
 | ||||
|   const reviewSegmentFetcher = useCallback((key: any) => { | ||||
|   const reviewSegmentFetcher = useCallback((key: Array<string> | string) => { | ||||
|     const [path, params] = Array.isArray(key) ? key : [key, undefined]; | ||||
|     return axios.get(path, { params }).then((res) => res.data); | ||||
|   }, []); | ||||
| @ -74,7 +77,7 @@ export default function Events() { | ||||
|       }; | ||||
|       return ["review", params]; | ||||
|     }, | ||||
|     [reviewSearchParams, last24Hours] | ||||
|     [reviewSearchParams, last24Hours], | ||||
|   ); | ||||
| 
 | ||||
|   const { | ||||
| @ -90,7 +93,7 @@ export default function Events() { | ||||
| 
 | ||||
|   const isDone = useMemo( | ||||
|     () => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT, | ||||
|     [reviewPages] | ||||
|     [reviewPages], | ||||
|   ); | ||||
| 
 | ||||
|   const onLoadNextPage = useCallback(() => setSize(size + 1), [size, setSize]); | ||||
| @ -103,7 +106,7 @@ export default function Events() { | ||||
|     if ( | ||||
|       !reviewPages || | ||||
|       reviewPages.length == 0 || | ||||
|       reviewPages.at(-1)!!.length == 0 | ||||
|       reviewPages.at(-1)?.length == 0 | ||||
|     ) { | ||||
|       return undefined; | ||||
|     } | ||||
| @ -111,7 +114,7 @@ export default function Events() { | ||||
|     const startDate = new Date(); | ||||
|     startDate.setMinutes(0, 0, 0); | ||||
| 
 | ||||
|     const endDate = new Date(reviewPages.at(-1)!!.at(-1)!!.end_time); | ||||
|     const endDate = new Date(reviewPages.at(-1)?.at(-1)?.end_time || 0); | ||||
|     endDate.setHours(0, 0, 0, 0); | ||||
|     return { | ||||
|       start: startDate.getTime() / 1000, | ||||
| @ -122,7 +125,7 @@ export default function Events() { | ||||
|     previewTimes | ||||
|       ? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}` | ||||
|       : null, | ||||
|     { revalidateOnFocus: false } | ||||
|     { revalidateOnFocus: false }, | ||||
|   ); | ||||
| 
 | ||||
|   // review status
 | ||||
| @ -156,11 +159,11 @@ export default function Events() { | ||||
| 
 | ||||
|             return newData; | ||||
|           }, | ||||
|           { revalidate: false, populateCache: true } | ||||
|           { revalidate: false, populateCache: true }, | ||||
|         ); | ||||
|       } | ||||
|     }, | ||||
|     [updateSegments] | ||||
|     [updateSegments], | ||||
|   ); | ||||
| 
 | ||||
|   // selected items
 | ||||
| @ -176,7 +179,7 @@ export default function Events() { | ||||
| 
 | ||||
|     const allReviews = reviewPages.flat(); | ||||
|     const selectedReview = allReviews.find( | ||||
|       (item) => item.id == selectedReviewId | ||||
|       (item) => item.id == selectedReviewId, | ||||
|     ); | ||||
| 
 | ||||
|     if (!selectedReview) { | ||||
| @ -186,12 +189,15 @@ export default function Events() { | ||||
|     return { | ||||
|       selected: selectedReview, | ||||
|       cameraSegments: allReviews.filter( | ||||
|         (seg) => seg.camera == selectedReview.camera | ||||
|         (seg) => seg.camera == selectedReview.camera, | ||||
|       ), | ||||
|       cameraPreviews: allPreviews?.filter( | ||||
|         (seg) => seg.camera == selectedReview.camera | ||||
|         (seg) => seg.camera == selectedReview.camera, | ||||
|       ), | ||||
|     }; | ||||
| 
 | ||||
|     // previews will not update after item is selected
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [selectedReviewId, reviewPages]); | ||||
| 
 | ||||
|   if (selectedData) { | ||||
|  | ||||
| @ -51,7 +51,7 @@ function Export() { | ||||
|   const { data: config } = useSWR<FrigateConfig>("config"); | ||||
|   const { data: exports, mutate } = useSWR<ExportItem[]>( | ||||
|     "exports/", | ||||
|     (url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data) | ||||
|     (url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data), | ||||
|   ); | ||||
| 
 | ||||
|   // Export States
 | ||||
| @ -96,7 +96,7 @@ function Export() { | ||||
|       parseInt(startHour), | ||||
|       parseInt(startMin), | ||||
|       parseInt(startSec), | ||||
|       0 | ||||
|       0, | ||||
|     ); | ||||
|     const start = startDate.getTime() / 1000; | ||||
|     const endDate = new Date((date.to || date.from).getTime()); | ||||
| @ -117,7 +117,7 @@ function Export() { | ||||
|         if (response.status == 200) { | ||||
|           toast.success( | ||||
|             "Successfully started export. View the file in the /exports folder.", | ||||
|             { position: "top-center" } | ||||
|             { position: "top-center" }, | ||||
|           ); | ||||
|         } | ||||
| 
 | ||||
| @ -127,7 +127,7 @@ function Export() { | ||||
|         if (error.response?.data?.message) { | ||||
|           toast.error( | ||||
|             `Failed to start export: ${error.response.data.message}`, | ||||
|             { position: "top-center" } | ||||
|             { position: "top-center" }, | ||||
|           ); | ||||
|         } else { | ||||
|           toast.error(`Failed to start export: ${error.message}`, { | ||||
| @ -148,7 +148,7 @@ function Export() { | ||||
|         mutate(); | ||||
|       } | ||||
|     }); | ||||
|   }, [deleteClip]); | ||||
|   }, [deleteClip, mutate]); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="w-full h-full overflow-hidden"> | ||||
| @ -156,7 +156,7 @@ function Export() { | ||||
| 
 | ||||
|       <AlertDialog | ||||
|         open={deleteClip != undefined} | ||||
|         onOpenChange={(_) => setDeleteClip(undefined)} | ||||
|         onOpenChange={() => setDeleteClip(undefined)} | ||||
|       > | ||||
|         <AlertDialogContent> | ||||
|           <AlertDialogHeader> | ||||
| @ -176,7 +176,7 @@ function Export() { | ||||
| 
 | ||||
|       <Dialog | ||||
|         open={selectedClip != undefined} | ||||
|         onOpenChange={(_) => setSelectedClip(undefined)} | ||||
|         onOpenChange={() => setSelectedClip(undefined)} | ||||
|       > | ||||
|         <DialogContent> | ||||
|           <DialogHeader> | ||||
|  | ||||
| @ -20,7 +20,7 @@ function Live() { | ||||
| 
 | ||||
|   const [layout, setLayout] = usePersistence<"grid" | "list">( | ||||
|     "live-layout", | ||||
|     isDesktop ? "grid" : "list" | ||||
|     isDesktop ? "grid" : "list", | ||||
|   ); | ||||
| 
 | ||||
|   // recent events
 | ||||
| @ -40,7 +40,7 @@ function Live() { | ||||
|       updateEvents(); | ||||
|       return; | ||||
|     } | ||||
|   }, [eventUpdate]); | ||||
|   }, [eventUpdate, updateEvents]); | ||||
| 
 | ||||
|   const events = useMemo(() => { | ||||
|     if (!allEvents) { | ||||
| @ -76,7 +76,7 @@ function Live() { | ||||
|     return () => { | ||||
|       removeEventListener("visibilitychange", visibilityListener); | ||||
|     }; | ||||
|   }, []); | ||||
|   }, [visibilityListener]); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="size-full overflow-y-scroll px-2"> | ||||
| @ -123,7 +123,7 @@ function Live() { | ||||
|       > | ||||
|         {cameras.map((camera) => { | ||||
|           let grow; | ||||
|           let aspectRatio = camera.detect.width / camera.detect.height; | ||||
|           const aspectRatio = camera.detect.width / camera.detect.height; | ||||
|           if (aspectRatio > 2) { | ||||
|             grow = `${layout == "grid" ? "col-span-2" : ""} aspect-wide`; | ||||
|           } else if (aspectRatio < 1) { | ||||
|  | ||||
| @ -57,7 +57,7 @@ function Logs() { | ||||
|         // no op
 | ||||
|       } | ||||
|     }, | ||||
|     [setEndVisible] | ||||
|     [setEndVisible], | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|  | ||||
| @ -47,7 +47,7 @@ function Storage() { | ||||
|       service["storage"]["/media/frigate/recordings"]["total"] != | ||||
|         service["storage"]["/media/frigate/clips"]["total"] | ||||
|     ); | ||||
|   }, service); | ||||
|   }, [service]); | ||||
| 
 | ||||
|   const getUnitSize = (MB: number) => { | ||||
|     if (isNaN(MB) || MB < 0) return "Invalid number"; | ||||
| @ -106,12 +106,12 @@ function Storage() { | ||||
|                   </TableCell> | ||||
|                   <TableCell> | ||||
|                     {getUnitSize( | ||||
|                       service["storage"]["/media/frigate/recordings"]["used"] | ||||
|                       service["storage"]["/media/frigate/recordings"]["used"], | ||||
|                     )} | ||||
|                   </TableCell> | ||||
|                   <TableCell> | ||||
|                     {getUnitSize( | ||||
|                       service["storage"]["/media/frigate/recordings"]["total"] | ||||
|                       service["storage"]["/media/frigate/recordings"]["total"], | ||||
|                     )} | ||||
|                   </TableCell> | ||||
|                 </TableRow> | ||||
| @ -120,12 +120,12 @@ function Storage() { | ||||
|                     <TableCell>Snapshots</TableCell> | ||||
|                     <TableCell> | ||||
|                       {getUnitSize( | ||||
|                         service["storage"]["/media/frigate/clips"]["used"] | ||||
|                         service["storage"]["/media/frigate/clips"]["used"], | ||||
|                       )} | ||||
|                     </TableCell> | ||||
|                     <TableCell> | ||||
|                       {getUnitSize( | ||||
|                         service["storage"]["/media/frigate/clips"]["total"] | ||||
|                         service["storage"]["/media/frigate/clips"]["total"], | ||||
|                       )} | ||||
|                     </TableCell> | ||||
|                   </TableRow> | ||||
|  | ||||
| @ -2,7 +2,6 @@ import { useMemo, useRef, useState } from "react"; | ||||
| import Heading from "@/components/ui/heading"; | ||||
| import useSWR from "swr"; | ||||
| import { FrigateConfig } from "@/types/frigateConfig"; | ||||
| import { Event } from "@/types/event"; | ||||
| import ActivityIndicator from "@/components/ui/activity-indicator"; | ||||
| import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; | ||||
| import { ReviewData, ReviewSegment, ReviewSeverity } from "@/types/review"; | ||||
| @ -84,19 +83,9 @@ function UIPlayground() { | ||||
|   const contentRef = useRef<HTMLDivElement>(null); | ||||
|   const [mockEvents, setMockEvents] = useState<ReviewSegment[]>([]); | ||||
|   const [handlebarTime, setHandlebarTime] = useState( | ||||
|     Math.floor(Date.now() / 1000) - 15 * 60 | ||||
|     Math.floor(Date.now() / 1000) - 15 * 60, | ||||
|   ); | ||||
| 
 | ||||
|   const recentTimestamp = useMemo(() => { | ||||
|     const now = new Date(); | ||||
|     now.setMinutes(now.getMinutes() - 240); | ||||
|     return now.getTime() / 1000; | ||||
|   }, []); | ||||
|   const { data: events } = useSWR<Event[]>([ | ||||
|     "events", | ||||
|     { limit: 10, after: recentTimestamp }, | ||||
|   ]); | ||||
| 
 | ||||
|   useMemo(() => { | ||||
|     const initialEvents = Array.from({ length: 50 }, generateRandomEvent); | ||||
|     setMockEvents(initialEvents); | ||||
| @ -108,16 +97,16 @@ function UIPlayground() { | ||||
|       return Math.min(...mockEvents.map((event) => event.start_time)); | ||||
|     } | ||||
|     return Math.floor(Date.now() / 1000); // Default to current time if no events
 | ||||
|   }, [events]); | ||||
|   }, [mockEvents]); | ||||
| 
 | ||||
|   const minimapEndTime = useMemo(() => { | ||||
|     if (mockEvents && mockEvents.length > 0) { | ||||
|       return Math.max( | ||||
|         ...mockEvents.map((event) => event.end_time ?? event.start_time) | ||||
|         ...mockEvents.map((event) => event.end_time ?? event.start_time), | ||||
|       ); | ||||
|     } | ||||
|     return Math.floor(Date.now() / 1000); // Default to current time if no events
 | ||||
|   }, [events]); | ||||
|   }, [mockEvents]); | ||||
| 
 | ||||
|   const [zoomLevel, setZoomLevel] = useState(0); | ||||
|   const [zoomSettings, setZoomSettings] = useState({ | ||||
| @ -134,7 +123,7 @@ function UIPlayground() { | ||||
|   function handleZoomIn() { | ||||
|     const nextZoomLevel = Math.min( | ||||
|       possibleZoomLevels.length - 1, | ||||
|       zoomLevel + 1 | ||||
|       zoomLevel + 1, | ||||
|     ); | ||||
|     setZoomLevel(nextZoomLevel); | ||||
|     setZoomSettings(possibleZoomLevels[nextZoomLevel]); | ||||
|  | ||||
| @ -1,9 +1,4 @@ | ||||
| import { | ||||
|   LuConstruction, | ||||
|   LuFileUp, | ||||
|   LuFlag, | ||||
|   LuVideo, | ||||
| } from "react-icons/lu"; | ||||
| import { LuConstruction, LuFileUp, LuFlag, LuVideo } from "react-icons/lu"; | ||||
| 
 | ||||
| export const navbarLinks = [ | ||||
|   { | ||||
|  | ||||
| @ -1 +1,3 @@ | ||||
| type FilterType = { [searchKey: string]: any }; | ||||
| // allow any
 | ||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
| export type FilterType = { [searchKey: string]: any }; | ||||
|  | ||||
| @ -199,7 +199,7 @@ export interface CameraConfig { | ||||
|       coordinates: string; | ||||
|       filters: Record<string, unknown>; | ||||
|       inertia: number; | ||||
|       objects: any[]; | ||||
|       objects: string[]; | ||||
|     }; | ||||
|   }; | ||||
| } | ||||
| @ -383,7 +383,7 @@ export interface FrigateConfig { | ||||
|   }; | ||||
| 
 | ||||
|   telemetry: { | ||||
|     network_interfaces: any[]; | ||||
|     network_interfaces: string[]; | ||||
|     stats: { | ||||
|       amd_gpu_stats: boolean; | ||||
|       intel_gpu_stats: boolean; | ||||
|  | ||||
| @ -1 +1 @@ | ||||
| export type LivePlayerMode = "webrtc" | "mse" | "jsmpeg" | "debug"; | ||||
| export type LivePlayerMode = "webrtc" | "mse" | "jsmpeg" | "debug"; | ||||
|  | ||||
| @ -1,4 +1,7 @@ | ||||
| type DynamicPlayback = { | ||||
| import { Preview } from "./preview"; | ||||
| import { Recording } from "./record"; | ||||
| 
 | ||||
| export type DynamicPlayback = { | ||||
|   recordings: Recording[]; | ||||
|   playbackUri: string; | ||||
|   preview: Preview | undefined; | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| type Preview = { | ||||
|     camera: string; | ||||
|     src: string; | ||||
|     type: string; | ||||
|     start: number; | ||||
|     end: number; | ||||
|   }; | ||||
| export type Preview = { | ||||
|   camera: string; | ||||
|   src: string; | ||||
|   type: string; | ||||
|   start: number; | ||||
|   end: number; | ||||
| }; | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| type Recording = { | ||||
| export type Recording = { | ||||
|   id: string; | ||||
|   camera: string; | ||||
|   start_time: number; | ||||
| @ -11,7 +11,7 @@ type Recording = { | ||||
|   dBFS: number; | ||||
| }; | ||||
| 
 | ||||
| type RecordingSegment = { | ||||
| export type RecordingSegment = { | ||||
|   id: string; | ||||
|   start_time: number; | ||||
|   end_time: number; | ||||
| @ -21,7 +21,7 @@ type RecordingSegment = { | ||||
|   duration: number; | ||||
| }; | ||||
| 
 | ||||
| type RecordingActivity = { | ||||
| export type RecordingActivity = { | ||||
|   [hour: number]: RecordingSegmentActivity[]; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -1,60 +1,60 @@ | ||||
| export interface FrigateStats { | ||||
|     cameras: { [camera_name: string]: CameraStats }; | ||||
|     cpu_usages: { [pid: string]: CpuStats }; | ||||
|     detectors: { [detectorKey: string]: DetectorStats }; | ||||
|     gpu_usages?: { [gpuKey: string]: GpuStats }; | ||||
|     processes: { [processKey: string]: ExtraProcessStats }; | ||||
|     service: ServiceStats; | ||||
|     detection_fps: number; | ||||
|   } | ||||
|   cameras: { [camera_name: string]: CameraStats }; | ||||
|   cpu_usages: { [pid: string]: CpuStats }; | ||||
|   detectors: { [detectorKey: string]: DetectorStats }; | ||||
|   gpu_usages?: { [gpuKey: string]: GpuStats }; | ||||
|   processes: { [processKey: string]: ExtraProcessStats }; | ||||
|   service: ServiceStats; | ||||
|   detection_fps: number; | ||||
| } | ||||
| 
 | ||||
|   export type CameraStats = { | ||||
|     audio_dBFPS: number; | ||||
|     audio_rms: number; | ||||
|     camera_fps: number; | ||||
|     capture_pid: number; | ||||
|     detection_enabled: number; | ||||
|     detection_fps: number; | ||||
|     ffmpeg_pid: number; | ||||
|     pid: number; | ||||
|     process_fps: number; | ||||
|     skipped_fps: number; | ||||
|   }; | ||||
| export type CameraStats = { | ||||
|   audio_dBFPS: number; | ||||
|   audio_rms: number; | ||||
|   camera_fps: number; | ||||
|   capture_pid: number; | ||||
|   detection_enabled: number; | ||||
|   detection_fps: number; | ||||
|   ffmpeg_pid: number; | ||||
|   pid: number; | ||||
|   process_fps: number; | ||||
|   skipped_fps: number; | ||||
| }; | ||||
| 
 | ||||
|   export type CpuStats = { | ||||
|     cmdline: string; | ||||
|     cpu: string; | ||||
|     cpu_average: string; | ||||
|     mem: string; | ||||
|   }; | ||||
| export type CpuStats = { | ||||
|   cmdline: string; | ||||
|   cpu: string; | ||||
|   cpu_average: string; | ||||
|   mem: string; | ||||
| }; | ||||
| 
 | ||||
|   export type DetectorStats = { | ||||
|     detection_start: number; | ||||
|     inference_speed: number; | ||||
|     pid: number; | ||||
|   }; | ||||
| export type DetectorStats = { | ||||
|   detection_start: number; | ||||
|   inference_speed: number; | ||||
|   pid: number; | ||||
| }; | ||||
| 
 | ||||
|   export type ExtraProcessStats = { | ||||
|     pid: number; | ||||
|   }; | ||||
| export type ExtraProcessStats = { | ||||
|   pid: number; | ||||
| }; | ||||
| 
 | ||||
|   export type GpuStats = { | ||||
|     gpu: string; | ||||
|     mem: string; | ||||
|   }; | ||||
| export type GpuStats = { | ||||
|   gpu: string; | ||||
|   mem: string; | ||||
| }; | ||||
| 
 | ||||
|   export type ServiceStats = { | ||||
|     last_updated: number; | ||||
|     storage: { [path: string]: StorageStats }; | ||||
|     temperatures: { [apex: string]: number }; | ||||
|     update: number; | ||||
|     latest_version: string; | ||||
|     version: string; | ||||
|   }; | ||||
| export type ServiceStats = { | ||||
|   last_updated: number; | ||||
|   storage: { [path: string]: StorageStats }; | ||||
|   temperatures: { [apex: string]: number }; | ||||
|   update: number; | ||||
|   latest_version: string; | ||||
|   version: string; | ||||
| }; | ||||
| 
 | ||||
|   export type StorageStats = { | ||||
|     free: number; | ||||
|     total: number; | ||||
|     used: number; | ||||
|     mount_type: string; | ||||
|   }; | ||||
| export type StorageStats = { | ||||
|   free: number; | ||||
|   total: number; | ||||
|   used: number; | ||||
|   mount_type: string; | ||||
| }; | ||||
|  | ||||
| @ -1,31 +1,33 @@ | ||||
| type Timeline = { | ||||
|   camera: string; | ||||
|   timestamp: number; | ||||
|   data: { | ||||
|     camera: string; | ||||
|     timestamp: number; | ||||
|     data: { | ||||
|       camera: string; | ||||
|       label: string; | ||||
|       sub_label: string; | ||||
|       box?: [number, number, number, number]; | ||||
|       region: [number, number, number, number]; | ||||
|       attribute: string; | ||||
|       zones: string[]; | ||||
|     }; | ||||
|     class_type: | ||||
|       | "visible" | ||||
|       | "gone" | ||||
|       | "entered_zone" | ||||
|       | "attribute" | ||||
|       | "active" | ||||
|       | "stationary" | ||||
|       | "heard" | ||||
|       | "external"; | ||||
|     source_id: string; | ||||
|     source: string; | ||||
|     label: string; | ||||
|     sub_label: string; | ||||
|     box?: [number, number, number, number]; | ||||
|     region: [number, number, number, number]; | ||||
|     attribute: string; | ||||
|     zones: string[]; | ||||
|   }; | ||||
|   class_type: | ||||
|     | "visible" | ||||
|     | "gone" | ||||
|     | "entered_zone" | ||||
|     | "attribute" | ||||
|     | "active" | ||||
|     | "stationary" | ||||
|     | "heard" | ||||
|     | "external"; | ||||
|   source_id: string; | ||||
|   source: string; | ||||
| }; | ||||
| 
 | ||||
|   type HourlyTimeline = { | ||||
|     start: number; | ||||
|     end: number; | ||||
|     count: number; | ||||
|     hours: { [key: string]: Timeline[] }; | ||||
|   }; | ||||
| // may be used in the future, keep for now for reference
 | ||||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
| type HourlyTimeline = { | ||||
|   start: number; | ||||
|   end: number; | ||||
|   count: number; | ||||
|   hours: { [key: string]: Timeline[] }; | ||||
| }; | ||||
|  | ||||
| @ -40,4 +40,4 @@ export interface FrigateEvent { | ||||
|   after: FrigateObjectState; | ||||
| } | ||||
| 
 | ||||
| export type ToggleableSetting = "ON" | "OFF" | ||||
| export type ToggleableSetting = "ON" | "OFF"; | ||||
|  | ||||
| @ -132,7 +132,7 @@ export const formatUnixTimestampToDateTime = ( | ||||
|     date_style?: "full" | "long" | "medium" | "short"; | ||||
|     time_style?: "full" | "long" | "medium" | "short"; | ||||
|     strftime_fmt?: string; | ||||
|   } | ||||
|   }, | ||||
| ): string => { | ||||
|   const { timezone, time_format, date_style, time_style, strftime_fmt } = | ||||
|     config; | ||||
| @ -187,7 +187,7 @@ export const formatUnixTimestampToDateTime = ( | ||||
| 
 | ||||
|       return `${date.toLocaleDateString( | ||||
|         locale, | ||||
|         dateOptions | ||||
|         dateOptions, | ||||
|       )} ${date.toLocaleTimeString(locale, timeOptions)}`;
 | ||||
|     } | ||||
| 
 | ||||
| @ -213,7 +213,7 @@ interface DurationToken { | ||||
|  */ | ||||
| export const getDurationFromTimestamps = ( | ||||
|   start_time: number, | ||||
|   end_time: number | null | ||||
|   end_time: number | null, | ||||
| ): string => { | ||||
|   if (isNaN(start_time)) { | ||||
|     return "Invalid start time"; | ||||
| @ -259,7 +259,7 @@ const getUTCOffset = (date: Date, timezone: string): number => { | ||||
| 
 | ||||
|   // Otherwise, calculate offset using provided timezone
 | ||||
|   const utcDate = new Date( | ||||
|     date.getTime() - date.getTimezoneOffset() * 60 * 1000 | ||||
|     date.getTime() - date.getTimezoneOffset() * 60 * 1000, | ||||
|   ); | ||||
|   // locale of en-CA is required for proper locale format
 | ||||
|   let iso = utcDate | ||||
|  | ||||
| @ -102,7 +102,7 @@ export function getTimelineItemDescription(timelineItem: Timeline) { | ||||
|       ) { | ||||
|         title = `${timelineItem.data.attribute.replaceAll( | ||||
|           "_", | ||||
|           " " | ||||
|           " ", | ||||
|         )} detected for ${label}`;
 | ||||
|       } else { | ||||
|         title = `${ | ||||
|  | ||||
| @ -3,6 +3,7 @@ import DynamicVideoPlayer, { | ||||
| } from "@/components/player/DynamicVideoPlayer"; | ||||
| import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; | ||||
| import { Button } from "@/components/ui/button"; | ||||
| import { Preview } from "@/types/preview"; | ||||
| import { ReviewSegment } from "@/types/review"; | ||||
| import { getChunkedTimeRange } from "@/utils/timelineUtil"; | ||||
| import { useEffect, useMemo, useRef, useState } from "react"; | ||||
| @ -31,7 +32,7 @@ export default function DesktopRecordingView({ | ||||
| 
 | ||||
|   const timeRange = useMemo( | ||||
|     () => getChunkedTimeRange(selectedReview.start_time), | ||||
|     [] | ||||
|     [selectedReview], | ||||
|   ); | ||||
|   const [selectedRangeIdx, setSelectedRangeIdx] = useState( | ||||
|     timeRange.ranges.findIndex((chunk) => { | ||||
| @ -39,7 +40,7 @@ export default function DesktopRecordingView({ | ||||
|         chunk.start <= selectedReview.start_time && | ||||
|         chunk.end >= selectedReview.start_time | ||||
|       ); | ||||
|     }) | ||||
|     }), | ||||
|   ); | ||||
| 
 | ||||
|   // move to next clip
 | ||||
| @ -55,13 +56,13 @@ export default function DesktopRecordingView({ | ||||
|         setSelectedRangeIdx(selectedRangeIdx - 1); | ||||
|       } | ||||
|     }); | ||||
|   }, [playerReady, selectedRangeIdx]); | ||||
|   }, [playerReady, selectedRangeIdx, timeRange]); | ||||
| 
 | ||||
|   // scrubbing and timeline state
 | ||||
| 
 | ||||
|   const [scrubbing, setScrubbing] = useState(false); | ||||
|   const [currentTime, setCurrentTime] = useState<number>( | ||||
|     selectedReview?.start_time || Date.now() / 1000 | ||||
|     selectedReview?.start_time || Date.now() / 1000, | ||||
|   ); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
| @ -74,6 +75,9 @@ export default function DesktopRecordingView({ | ||||
|     if (!scrubbing) { | ||||
|       controllerRef.current?.seekToTimestamp(currentTime, true); | ||||
|     } | ||||
| 
 | ||||
|     // we only want to seek when user stops scrubbing
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [scrubbing]); | ||||
| 
 | ||||
|   return ( | ||||
| @ -100,7 +104,7 @@ export default function DesktopRecordingView({ | ||||
| 
 | ||||
|             controllerRef.current?.seekToTimestamp( | ||||
|               selectedReview.start_time, | ||||
|               true | ||||
|               true, | ||||
|             ); | ||||
|           }} | ||||
|         /> | ||||
|  | ||||
| @ -7,6 +7,7 @@ import ActivityIndicator from "@/components/ui/activity-indicator"; | ||||
| import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; | ||||
| import { useEventUtils } from "@/hooks/use-event-utils"; | ||||
| import { FrigateConfig } from "@/types/frigateConfig"; | ||||
| import { Preview } from "@/types/preview"; | ||||
| import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; | ||||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||||
| import { isDesktop, isMobile } from "react-device-detect"; | ||||
| @ -84,7 +85,7 @@ export default function EventView({ | ||||
| 
 | ||||
|   const { alignStartDateToTimeline } = useEventUtils( | ||||
|     reviewItems.all, | ||||
|     segmentDuration | ||||
|     segmentDuration, | ||||
|   ); | ||||
| 
 | ||||
|   const currentItems = useMemo(() => { | ||||
| @ -103,6 +104,8 @@ export default function EventView({ | ||||
|     } | ||||
| 
 | ||||
|     return contentRef.current.scrollHeight > contentRef.current.clientHeight; | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [contentRef.current?.scrollHeight, severity]); | ||||
| 
 | ||||
|   // review interaction
 | ||||
| @ -123,7 +126,7 @@ export default function EventView({ | ||||
|         // no op
 | ||||
|       } | ||||
|     }, | ||||
|     [isValidating, reachedEnd] | ||||
|     [isValidating, reachedEnd, loadNextPage], | ||||
|   ); | ||||
| 
 | ||||
|   const [minimap, setMinimap] = useState<string[]>([]); | ||||
| @ -148,7 +151,7 @@ export default function EventView({ | ||||
|           setMinimap([...visibleTimestamps]); | ||||
|         }); | ||||
|       }, | ||||
|       { root: contentRef.current, threshold: isDesktop ? 0.1 : 0.5 } | ||||
|       { root: contentRef.current, threshold: isDesktop ? 0.1 : 0.5 }, | ||||
|     ); | ||||
| 
 | ||||
|     return () => { | ||||
| @ -167,7 +170,7 @@ export default function EventView({ | ||||
|         // no op
 | ||||
|       } | ||||
|     }, | ||||
|     [minimapObserver] | ||||
|     [minimapObserver], | ||||
|   ); | ||||
|   const minimapBounds = useMemo(() => { | ||||
|     const data = { | ||||
| @ -177,7 +180,7 @@ export default function EventView({ | ||||
|     const list = minimap.sort(); | ||||
| 
 | ||||
|     if (list.length > 0) { | ||||
|       data.end = parseFloat(list.at(-1)!!); | ||||
|       data.end = parseFloat(list.at(-1) || "0"); | ||||
|       data.start = parseFloat(list[0]); | ||||
|     } | ||||
| 
 | ||||
| @ -260,12 +263,12 @@ export default function EventView({ | ||||
|               currentItems.map((value, segIdx) => { | ||||
|                 const lastRow = segIdx == reviewItems[severity].length - 1; | ||||
|                 const relevantPreview = Object.values( | ||||
|                   relevantPreviews || [] | ||||
|                   relevantPreviews || [], | ||||
|                 ).find( | ||||
|                   (preview) => | ||||
|                     preview.camera == value.camera && | ||||
|                     preview.start < value.start_time && | ||||
|                     preview.end > value.end_time | ||||
|                     preview.end > value.end_time, | ||||
|                 ); | ||||
| 
 | ||||
|                 return ( | ||||
|  | ||||
| @ -1,45 +1,45 @@ | ||||
| /// <reference types="vitest" />
 | ||||
| import path from "path" | ||||
| import { defineConfig } from 'vite' | ||||
| import react from '@vitejs/plugin-react-swc' | ||||
| import monacoEditorPlugin from 'vite-plugin-monaco-editor'; | ||||
| import path from "path"; | ||||
| import { defineConfig } from "vite"; | ||||
| import react from "@vitejs/plugin-react-swc"; | ||||
| import monacoEditorPlugin from "vite-plugin-monaco-editor"; | ||||
| 
 | ||||
| // https://vitejs.dev/config/
 | ||||
| export default defineConfig({ | ||||
|   define: { | ||||
|     'import.meta.vitest': 'undefined', | ||||
|     "import.meta.vitest": "undefined", | ||||
|   }, | ||||
|   server: { | ||||
|     proxy: { | ||||
|       '/api': { | ||||
|         target: 'http://localhost:5000', | ||||
|       "/api": { | ||||
|         target: "http://localhost:5000", | ||||
|         ws: true, | ||||
|       }, | ||||
|       '/vod': { | ||||
|         target: 'http://localhost:5000' | ||||
|       "/vod": { | ||||
|         target: "http://localhost:5000", | ||||
|       }, | ||||
|       '/clips': { | ||||
|         target: 'http://localhost:5000' | ||||
|       "/clips": { | ||||
|         target: "http://localhost:5000", | ||||
|       }, | ||||
|       '/exports': { | ||||
|         target: 'http://localhost:5000' | ||||
|       "/exports": { | ||||
|         target: "http://localhost:5000", | ||||
|       }, | ||||
|       '/ws': { | ||||
|         target: 'ws://localhost:5000', | ||||
|       "/ws": { | ||||
|         target: "ws://localhost:5000", | ||||
|         ws: true, | ||||
|       }, | ||||
|       '/live': { | ||||
|         target: 'ws://localhost:5000', | ||||
|       "/live": { | ||||
|         target: "ws://localhost:5000", | ||||
|         changeOrigin: true, | ||||
|         ws: true, | ||||
|       }, | ||||
|     } | ||||
|     }, | ||||
|   }, | ||||
|   plugins: [ | ||||
|     react(), | ||||
|     monacoEditorPlugin.default({ | ||||
|       customWorkers: [{ label: 'yaml', entry: 'monaco-yaml/yaml.worker' }], | ||||
|       languageWorkers: ['editorWorkerService'], // we don't use any of the default languages
 | ||||
|       customWorkers: [{ label: "yaml", entry: "monaco-yaml/yaml.worker" }], | ||||
|       languageWorkers: ["editorWorkerService"], // we don't use any of the default languages
 | ||||
|     }), | ||||
|   ], | ||||
|   resolve: { | ||||
| @ -48,17 +48,20 @@ export default defineConfig({ | ||||
|     }, | ||||
|   }, | ||||
|   test: { | ||||
|     environment: 'jsdom', | ||||
|     environment: "jsdom", | ||||
|     alias: { | ||||
|       'testing-library': path.resolve(__dirname, './__test__/testing-library.js'), | ||||
|       "testing-library": path.resolve( | ||||
|         __dirname, | ||||
|         "./__test__/testing-library.js", | ||||
|       ), | ||||
|     }, | ||||
|     setupFiles: ['./__test__/test-setup.ts'], | ||||
|     includeSource: ['src/**/*.{js,jsx,ts,tsx}'], | ||||
|     setupFiles: ["./__test__/test-setup.ts"], | ||||
|     includeSource: ["src/**/*.{js,jsx,ts,tsx}"], | ||||
|     coverage: { | ||||
|       reporter: ['text-summary', 'text'], | ||||
|       reporter: ["text-summary", "text"], | ||||
|     }, | ||||
|     mockReset: true, | ||||
|     restoreMocks: true, | ||||
|     globals: true, | ||||
|   }, | ||||
| }) | ||||
| }); | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user