import ReviewCard from "@/components/card/ReviewCard"; import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; import ExportDialog from "@/components/overlay/ExportDialog"; import PreviewPlayer, { PreviewController, } from "@/components/player/PreviewPlayer"; import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController"; import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useOverlayState } from "@/hooks/use-overlay-state"; import { ExportMode } from "@/types/filter"; import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { MotionData, REVIEW_PADDING, ReviewFilter, ReviewSegment, ReviewSummary, } from "@/types/review"; import { getChunkedTimeDay } from "@/utils/timelineUtil"; import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { isDesktop, isMobile } from "react-device-detect"; import { IoMdArrowRoundBack } from "react-icons/io"; import { useNavigate } from "react-router-dom"; import { Toaster } from "@/components/ui/sonner"; import useSWR from "swr"; import { TimeRange, TimelineType } from "@/types/timeline"; import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer"; import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer"; import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer"; import Logo from "@/components/Logo"; import { Skeleton } from "@/components/ui/skeleton"; import { FaVideo } from "react-icons/fa"; import { VideoResolutionType } from "@/types/live"; import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; import { useResizeObserver } from "@/hooks/resize-observer"; import { cn } from "@/lib/utils"; const SEGMENT_DURATION = 30; type RecordingViewProps = { startCamera: string; startTime: number; reviewItems?: ReviewSegment[]; reviewSummary?: ReviewSummary; timeRange: TimeRange; allCameras: string[]; allPreviews?: Preview[]; filter?: ReviewFilter; updateFilter: (newFilter: ReviewFilter) => void; }; export function RecordingView({ startCamera, startTime, reviewItems, reviewSummary, timeRange, allCameras, allPreviews, filter, updateFilter, }: RecordingViewProps) { const { data: config } = useSWR("config"); const navigate = useNavigate(); const contentRef = useRef(null); // controller state const [mainCamera, setMainCamera] = useState(startCamera); const mainControllerRef = useRef(null); const mainLayoutRef = useRef(null); const cameraLayoutRef = useRef(null); const previewRowRef = useRef(null); const previewRefs = useRef<{ [camera: string]: PreviewController }>({}); const [playbackStart, setPlaybackStart] = useState(startTime); const mainCameraReviewItems = useMemo( () => reviewItems?.filter((cam) => cam.camera == mainCamera) ?? [], [reviewItems, mainCamera], ); // timeline const [timelineType, setTimelineType] = useOverlayState( "timelineType", "timeline", ); const chunkedTimeRange = useMemo( () => getChunkedTimeDay(timeRange), [timeRange], ); const [selectedRangeIdx, setSelectedRangeIdx] = useState( chunkedTimeRange.findIndex((chunk) => { return chunk.after <= startTime && chunk.before >= startTime; }), ); const currentTimeRange = useMemo( () => chunkedTimeRange[selectedRangeIdx], [selectedRangeIdx, chunkedTimeRange], ); // export const [exportMode, setExportMode] = useState("none"); const [exportRange, setExportRange] = useState(); // move to next clip const onClipEnded = useCallback(() => { if (!mainControllerRef.current) { return; } if (selectedRangeIdx < chunkedTimeRange.length - 1) { setSelectedRangeIdx(selectedRangeIdx + 1); } }, [selectedRangeIdx, chunkedTimeRange]); // scrubbing and timeline state const [scrubbing, setScrubbing] = useState(false); const [currentTime, setCurrentTime] = useState(startTime); const [playerTime, setPlayerTime] = useState(startTime); const updateSelectedSegment = useCallback( (currentTime: number, updateStartTime: boolean) => { const index = chunkedTimeRange.findIndex( (seg) => seg.after <= currentTime && seg.before >= currentTime, ); if (index != -1) { if (updateStartTime) { setPlaybackStart(currentTime); } setSelectedRangeIdx(index); } }, [chunkedTimeRange], ); useEffect(() => { if (scrubbing || exportRange) { if ( currentTime > currentTimeRange.before + 60 || currentTime < currentTimeRange.after - 60 ) { updateSelectedSegment(currentTime, false); return; } mainControllerRef.current?.scrubToTimestamp(currentTime); Object.values(previewRefs.current).forEach((controller) => { controller.scrubToTimestamp(currentTime); }); } // we only want to seek when current time updates // eslint-disable-next-line react-hooks/exhaustive-deps }, [ currentTime, scrubbing, timeRange, currentTimeRange, updateSelectedSegment, ]); useEffect(() => { if (!scrubbing) { if (Math.abs(currentTime - playerTime) > 10) { if ( currentTimeRange.after <= currentTime && currentTimeRange.before >= currentTime ) { mainControllerRef.current?.seekToTimestamp(currentTime, true); } else { updateSelectedSegment(currentTime, true); } } else if (playerTime != currentTime) { mainControllerRef.current?.play(); } } // we only want to seek when current time doesn't match the player update time // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentTime, scrubbing]); const [fullResolution, setFullResolution] = useState({ width: 0, height: 0, }); const onSelectCamera = useCallback( (newCam: string) => { setMainCamera(newCam); setFullResolution({ width: 0, height: 0, }); setPlaybackStart(currentTime); }, [currentTime], ); // fullscreen const [fullscreen, setFullscreen] = useState(false); const onToggleFullscreen = useCallback( (full: boolean) => { if (full) { mainLayoutRef.current?.requestFullscreen(); } else { document.exitFullscreen(); } }, [mainLayoutRef], ); useEffect(() => { if (mainLayoutRef.current == null) { return; } const fsListener = () => { setFullscreen(document.fullscreenElement != null); }; document.addEventListener("fullscreenchange", fsListener); return () => { document.removeEventListener("fullscreenchange", fsListener); }; }, [mainLayoutRef]); // layout const getCameraAspect = useCallback( (cam: string) => { if (!config) { return undefined; } if (cam == mainCamera && fullResolution.width && fullResolution.height) { return fullResolution.width / fullResolution.height; } const camera = config.cameras[cam]; if (!camera) { return undefined; } return camera.detect.width / camera.detect.height; }, [config, fullResolution, mainCamera], ); const mainCameraAspect = useMemo(() => { const aspectRatio = getCameraAspect(mainCamera); if (!aspectRatio) { return "normal"; } else if (aspectRatio > ASPECT_WIDE_LAYOUT) { return "wide"; } else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) { return "tall"; } else { return "normal"; } }, [getCameraAspect, mainCamera]); const grow = useMemo(() => { if (mainCameraAspect == "wide") { return "w-full aspect-wide"; } else if (mainCameraAspect == "tall") { if (isDesktop) { return "size-full aspect-tall flex flex-col justify-center"; } else { return "size-full"; } } else { return "w-full aspect-video"; } }, [mainCameraAspect]); const [{ width: mainWidth, height: mainHeight }] = useResizeObserver(cameraLayoutRef); const mainCameraStyle = useMemo(() => { if (isMobile || mainCameraAspect != "normal" || !config) { return undefined; } const camera = config.cameras[mainCamera]; if (!camera) { return undefined; } const aspect = camera.detect.width / camera.detect.height; if (!aspect) { return undefined; } const availableHeight = mainHeight - 112; let percent; if (mainWidth / availableHeight < aspect) { percent = 100; } else { const availableWidth = aspect * availableHeight; percent = (mainWidth < availableWidth ? mainWidth / availableWidth : availableWidth / mainWidth) * 100; } return { width: `${Math.round(percent)}%`, }; }, [config, mainCameraAspect, mainWidth, mainHeight, mainCamera]); const previewRowOverflows = useMemo(() => { if (!previewRowRef.current) { return false; } return ( previewRowRef.current.scrollWidth > previewRowRef.current.clientWidth || previewRowRef.current.scrollHeight > previewRowRef.current.clientHeight ); // we only want to update when the scroll size changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [previewRowRef.current?.scrollWidth, previewRowRef.current?.scrollHeight]); return (
{isMobile && ( )}
{ setPlaybackStart(currentTime); setMainCamera(cam); }} /> {isDesktop && ( { setExportRange(range); if (range != undefined) { mainControllerRef.current?.pause(); } }} setMode={setExportMode} /> )} {isDesktop && ( {}} /> )} {isDesktop ? ( value ? setTimelineType(value, true) : null } // don't allow the severity to be unselected >
Timeline
Events
) : ( )}
{ setPlayerTime(timestamp); setCurrentTime(timestamp); Object.values(previewRefs.current ?? {}).forEach((prev) => prev.scrubToTimestamp(Math.floor(timestamp)), ); }} onClipEnded={onClipEnded} onControllerReady={(controller) => { mainControllerRef.current = controller; }} isScrubbing={scrubbing || exportMode == "timeline"} setFullResolution={setFullResolution} setFullscreen={onToggleFullscreen} />
{isDesktop && (
{allCameras.map((cam) => { if (cam == mainCamera) { return; } return (
{ previewRefs.current[cam] = controller; controller.scrubToTimestamp(startTime); }} onClick={() => onSelectCamera(cam)} />
); })}
)}
); } type TimelineProps = { contentRef: MutableRefObject; mainCamera: string; timelineType: TimelineType; timeRange: TimeRange; mainCameraReviewItems: ReviewSegment[]; currentTime: number; exportRange?: TimeRange; setCurrentTime: React.Dispatch>; setScrubbing: React.Dispatch>; setExportRange: (range: TimeRange) => void; }; function Timeline({ contentRef, mainCamera, timelineType, timeRange, mainCameraReviewItems, currentTime, exportRange, setCurrentTime, setScrubbing, setExportRange, }: TimelineProps) { const { data: motionData } = useSWR([ "review/activity/motion", { before: timeRange.before, after: timeRange.after, scale: SEGMENT_DURATION / 2, cameras: mainCamera, }, ]); const [exportStart, setExportStartTime] = useState(0); const [exportEnd, setExportEndTime] = useState(0); useEffect(() => { if (exportRange && exportStart != 0 && exportEnd != 0) { if (exportRange.after != exportStart) { setCurrentTime(exportStart); } else if (exportRange?.before != exportEnd) { setCurrentTime(exportEnd); } setExportRange({ after: exportStart, before: exportEnd }); } // we only want to update when the export parts change // eslint-disable-next-line react-hooks/exhaustive-deps }, [exportStart, exportEnd, setExportRange, setCurrentTime]); return (
{timelineType == "timeline" ? ( motionData ? ( setScrubbing(scrubbing)} /> ) : ( ) ) : (
{mainCameraReviewItems.map((review) => { if (review.severity == "significant_motion") { return; } return ( { setScrubbing(true); setCurrentTime(review.start_time - REVIEW_PADDING); setScrubbing(false); }} /> ); })}
)}
); }