import ReviewCard from "@/components/card/ReviewCard"; import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; import DebugReplayDialog from "@/components/overlay/DebugReplayDialog"; import ExportDialog from "@/components/overlay/ExportDialog"; import ActionsDropdown from "@/components/overlay/ActionsDropdown"; 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 DetailStream from "@/components/timeline/DetailStream"; import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useOverlayState } from "@/hooks/use-overlay-state"; import { useResizeObserver } from "@/hooks/resize-observer"; import { ExportMode } from "@/types/filter"; import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { MotionData, RecordingsSummary, REVIEW_PADDING, ReviewFilter, ReviewSegment, ReviewSummary, ZoomLevel, } from "@/types/review"; import { findChunkIndex, getChunkedTimeDay } from "@/utils/timelineUtil"; import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { isDesktop, isMobile, isMobileOnly, isTablet, } from "react-device-detect"; import { IoMdArrowRoundBack } from "react-icons/io"; import { useLocation, useNavigate } from "react-router-dom"; import { Toaster } from "@/components/ui/sonner"; import { useIsAdmin } from "@/hooks/use-is-admin"; 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, RecordingSegment, RecordingStartingPoint, } from "@/types/record"; import { cn } from "@/lib/utils"; import { useFullscreen } from "@/hooks/use-fullscreen"; import { useTimezone } from "@/hooks/use-date-utils"; import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; import { useTranslation } from "react-i18next"; import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { DetailStreamProvider } from "@/context/detail-stream-context"; import { GenAISummaryDialog, GenAISummaryChip, } from "@/components/overlay/chip/GenAISummaryChip"; import ShareTimestampDialog from "@/components/overlay/ShareTimestampDialog"; import { shareOrCopy } from "@/utils/browserUtil"; import { createRecordingReviewUrl } from "@/utils/recordingReviewUrl"; const DATA_REFRESH_TIME = 600000; // 10 minutes type RecordingViewProps = { startCamera: string; startTime: number; reviewItems?: ReviewSegment[]; reviewSummary?: ReviewSummary; timeRange: TimeRange; allCameras: string[]; allPreviews?: Preview[]; filter?: ReviewFilter; updateFilter: (newFilter: ReviewFilter) => void; refreshData?: () => void; onMotionSearch?: (camera: string) => void; }; export function RecordingView({ startCamera, startTime, reviewItems, reviewSummary, timeRange, allCameras, allPreviews, filter, updateFilter, refreshData, onMotionSearch, }: RecordingViewProps) { const { t } = useTranslation(["views/events", "components/dialog"]); const { data: config } = useSWR("config"); const isAdmin = useIsAdmin(); const navigate = useNavigate(); const location = useLocation(); const contentRef = useRef(null); // recordings summary const timezone = useTimezone(config); const allowedCameras = useAllowedCameras(); const effectiveCameras = useMemo( () => allCameras.filter( (camera) => allowedCameras.includes(camera) && config?.cameras[camera]?.ui?.review !== false, ), [allCameras, allowedCameras, config?.cameras], ); const [mainCamera, setMainCamera] = useState(startCamera); const { data: recordingsSummary } = useSWR([ "recordings/summary", { timezone: timezone, cameras: mainCamera ?? null, }, ]); // controller state 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 >= timeRange.after && startTime <= timeRange.before ? startTime : timeRange.before - 60, ); const mainCameraReviewItems = useMemo( () => reviewItems?.filter((cam) => cam.camera == mainCamera) ?? [], [reviewItems, mainCamera], ); // timeline const [recording] = useOverlayState( "recording", undefined, false, ); const [timelineType, setTimelineType] = useOverlayState( "timelineType", recording?.timelineType ?? "timeline", ); const chunkedTimeRange = useMemo( () => getChunkedTimeDay(timeRange), [timeRange], ); const [selectedRangeIdx, setSelectedRangeIdx] = useState( findChunkIndex(chunkedTimeRange, startTime), ); const currentTimeRange = useMemo( () => chunkedTimeRange[selectedRangeIdx] ?? chunkedTimeRange[chunkedTimeRange.length - 1], [selectedRangeIdx, chunkedTimeRange], ); const reviewFilterList = useMemo(() => { const uniqueLabels = new Set(); reviewItems?.forEach((rev) => { rev.data.objects.forEach((obj) => uniqueLabels.add(obj.replace("-verified", "")), ); rev.data.audio.forEach((aud) => uniqueLabels.add(aud)); }); const uniqueZones = new Set(); reviewItems?.forEach((rev) => { rev.data.zones.forEach((zone) => uniqueZones.add(zone)); }); return { labels: [...uniqueLabels], zones: [...uniqueZones] }; }, [reviewItems]); // export const [exportMode, setExportMode] = useState("none"); const [exportRange, setExportRange] = useState(); const [showExportPreview, setShowExportPreview] = useState(false); // debug replay const [debugReplayMode, setDebugReplayMode] = useState("none"); const [debugReplayRange, setDebugReplayRange] = useState(); const [shareTimestampOpen, setShareTimestampOpen] = useState(false); const [shareTimestampAtOpen, setShareTimestampAtOpen] = useState( Math.floor(startTime), ); const [shareTimestampOption, setShareTimestampOption] = useState< "current" | "custom" >("current"); const [customShareTimestamp, setCustomShareTimestamp] = useState( Math.floor(startTime), ); // move to next clip const onClipEnded = useCallback(() => { if (!mainControllerRef.current) { return; } if (selectedRangeIdx < chunkedTimeRange.length - 1) { setSelectedRangeIdx(selectedRangeIdx + 1); } }, [selectedRangeIdx, chunkedTimeRange]); // visibility tracking for refreshing stale data const lastVisibilityTime = useRef(Date.now()); useEffect(() => { const handleVisibilityChange = () => { if (document.visibilityState === "visible") { const now = Date.now(); const timeSinceLastVisible = now - lastVisibilityTime.current; // Only refresh if user was away for a while // and the video is not currently playing if ( timeSinceLastVisible >= DATA_REFRESH_TIME && refreshData && mainControllerRef.current && !mainControllerRef.current.isPlaying() ) { refreshData(); } lastVisibilityTime.current = now; } else { lastVisibilityTime.current = Date.now(); } }; document.addEventListener("visibilitychange", handleVisibilityChange); return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, [refreshData]); // 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 = findChunkIndex(chunkedTimeRange, currentTime); if (index != -1) { if (updateStartTime) { setPlaybackStart(currentTime); } setSelectedRangeIdx(index); } }, [chunkedTimeRange], ); useEffect(() => { if (scrubbing || exportRange || debugReplayRange) { 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, ]); const manuallySetCurrentTime = useCallback( (time: number, play: boolean = false) => { if (!currentTimeRange) { return; } setCurrentTime(time); if (currentTimeRange.after <= time && currentTimeRange.before >= time) { mainControllerRef.current?.seekToTimestamp(time, play); } else { updateSelectedSegment(time, true); } }, [currentTimeRange, updateSelectedSegment], ); const onClipPrevious = useCallback( (diff: number) => { manuallySetCurrentTime( currentTime + diff, mainControllerRef.current?.isPlaying() ?? false, ); }, [currentTime, manuallySetCurrentTime], ); const onShareReviewLink = useCallback( (timestamp: number) => { const reviewUrl = createRecordingReviewUrl(location.pathname, { camera: mainCamera, timestamp: Math.floor(timestamp), }); shareOrCopy( reviewUrl, t("recording.shareTimestamp.shareTitle", { ns: "components/dialog", camera: mainCamera, }), ); }, [location.pathname, mainCamera, t], ); const handleBack = useCallback(() => { // if we came from a direct share link, there is no history to go back to, so navigate to the homepage instead if (recording?.navigationSource === "shared-link") { navigate("/"); return; } navigate(-1); }, [navigate, recording?.navigationSource]); useEffect(() => { if (!scrubbing) { if (Math.abs(currentTime - playerTime) > 10) { if ( currentTimeRange.after <= currentTime && currentTimeRange.before >= currentTime ) { if (mainControllerRef.current != undefined) { let shouldPlayback = true; if (timelineType == "detail") { shouldPlayback = mainControllerRef.current.isPlaying(); } mainControllerRef.current.seekToTimestamp( currentTime, shouldPlayback, ); } } else { updateSelectedSegment(currentTime, true); } } else if (playerTime != currentTime && timelineType != "detail") { 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) => { if (allowedCameras.includes(newCam)) { setMainCamera(newCam); setFullResolution({ width: 0, height: 0, }); setPlaybackStart(currentTime); } }, [currentTime, allowedCameras], ); // fullscreen const { fullscreen, toggleFullscreen, supportsFullScreen } = useFullscreen(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]); // use a resize observer to determine whether to use w-full or h-full based on container aspect ratio const [{ width: containerWidth, height: containerHeight }] = useResizeObserver(cameraLayoutRef); const [{ width: previewRowWidth, height: previewRowHeight }] = useResizeObserver(previewRowRef); const useHeightBased = useMemo(() => { if (!containerWidth || !containerHeight) { return false; } const cameraAspectRatio = getCameraAspect(mainCamera); if (!cameraAspectRatio) { return false; } // Calculate available space for camera after accounting for preview row // For tall cameras: preview row is side-by-side (takes width) // For wide/normal cameras: preview row is stacked (takes height) const availableWidth = mainCameraAspect == "tall" && previewRowWidth ? containerWidth - previewRowWidth : containerWidth; const availableHeight = mainCameraAspect != "tall" && previewRowHeight ? containerHeight - previewRowHeight : containerHeight; const availableAspectRatio = availableWidth / availableHeight; // If available space is wider than camera aspect, constrain by height (h-full) // If available space is taller than camera aspect, constrain by width (w-full) return availableAspectRatio >= cameraAspectRatio; }, [ containerWidth, containerHeight, previewRowWidth, previewRowHeight, getCameraAspect, mainCamera, mainCameraAspect, ]); 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]); // visibility listener for lazy loading const [visiblePreviews, setVisiblePreviews] = useState([]); const visiblePreviewObserver = useRef(null); useEffect(() => { const visibleCameras = new Set(); visiblePreviewObserver.current = new IntersectionObserver( (entries) => { entries.forEach((entry) => { const camera = (entry.target as HTMLElement).dataset.camera; if (!camera) { return; } if (entry.isIntersecting) { visibleCameras.add(camera); } else { visibleCameras.delete(camera); } setVisiblePreviews([...visibleCameras]); }); }, { threshold: 0.1 }, ); return () => { visiblePreviewObserver.current?.disconnect(); }; }, []); const previewRef = useCallback( (node: HTMLElement | null) => { if (!visiblePreviewObserver.current) { return; } try { if (node) visiblePreviewObserver.current.observe(node); } catch (e) { // no op } }, // we need to listen on the value of the ref // eslint-disable-next-line react-hooks/exhaustive-deps [visiblePreviewObserver.current], ); const activeReviewItem = useMemo(() => { if (!config?.cameras?.[mainCamera].review.genai?.enabled_in_config) { return undefined; } return mainCameraReviewItems.find( (rev) => rev.start_time - REVIEW_PADDING < currentTime && rev.end_time && currentTime < rev.end_time + REVIEW_PADDING, ); }, [config, currentTime, mainCameraReviewItems, mainCamera]); const onAnalysisOpen = useCallback( (open: boolean) => { if (open) { mainControllerRef.current?.pause(); } else { mainControllerRef.current?.play(); } }, [mainControllerRef], ); return (
{isMobile && ( )}
{isDesktop && ( { setDebugReplayRange(range); if (range != undefined) { mainControllerRef.current?.pause(); } }} setMode={setDebugReplayMode} /> )} {isDesktop && ( { setExportRange(range); if (range != undefined) { mainControllerRef.current?.pause(); } }} setMode={setExportMode} setShowPreview={setShowExportPreview} /> )} {isDesktop && ( {}} mainCamera={mainCamera} onUpdateFilter={(newFilter: ReviewFilter) => { const updatedCameras = newFilter.cameras === undefined ? undefined // Respect undefined as "all cameras" : newFilter.cameras ? Array.from( new Set([mainCamera, ...(newFilter.cameras || [])]), ) // Include mainCamera if specific cameras are selected : [mainCamera]; const adjustedFilter: ReviewFilter = { ...newFilter, cameras: updatedCameras, }; updateFilter(adjustedFilter); }} setMotionOnly={() => {}} /> )} {isDesktop && ( )} {isDesktop && ( { const initialTimestamp = Math.floor(currentTime); setShareTimestampAtOpen(initialTimestamp); setShareTimestampOption("current"); setCustomShareTimestamp(initialTimestamp); setShareTimestampOpen(true); }} onMotionSearchClick={ onMotionSearch ? () => onMotionSearch(mainCamera) : undefined } onDebugReplayClick={ isAdmin ? () => { setDebugReplayRange({ after: timeRange.before - 60, before: timeRange.before, }); setDebugReplayMode("select"); } : undefined } onExportClick={() => { const now = new Date(timeRange.before * 1000); now.setHours(now.getHours() - 1); setExportRange({ before: timeRange.before, after: now.getTime() / 1000, }); setExportMode("select"); }} /> )} {isDesktop ? ( value ? setTimelineType(value, true) : null } // don't allow the severity to be unselected >
{t("timeline.label")}
{t("events.label")}
{t("detail.label")}
) : ( )} { setDebugReplayRange(range); if (range != undefined) { mainControllerRef.current?.pause(); } }} onShareTimestamp={onShareReviewLink} onMotionSearch={ onMotionSearch ? () => onMotionSearch(mainCamera) : undefined } onUpdateFilter={updateFilter} setRange={setExportRange} setMode={setExportMode} setShowExportPreview={setShowExportPreview} />
{(isDesktop || isTablet) && ( )} { setPlayerTime(timestamp); setCurrentTime(timestamp); Object.values(previewRefs.current ?? {}).forEach((prev) => prev.scrubToTimestamp(Math.floor(timestamp)), ); }} onClipEnded={onClipEnded} onClipPrevious={onClipPrevious} onSeekToTime={manuallySetCurrentTime} onControllerReady={(controller) => { mainControllerRef.current = controller; }} isScrubbing={ scrubbing || exportMode == "timeline" || exportMode == "timeline_multi" || debugReplayMode == "timeline" } supportsFullscreen={supportsFullScreen} setFullResolution={setFullResolution} toggleFullscreen={toggleFullscreen} containerRef={mainLayoutRef} />
{isDesktop && effectiveCameras.length > 1 && (
{effectiveCameras.map((cam) => { if (cam == mainCamera || cam == "birdseye") { return; } return (
{ previewRefs.current[cam] = controller; controller.scrubToTimestamp(startTime); }} onClick={() => onSelectCamera(cam)} />
); })}
)}
); } type TimelineProps = { contentRef: MutableRefObject; timelineRef?: MutableRefObject; mainCamera: string; timelineType: TimelineType; timeRange: TimeRange; mainCameraReviewItems: ReviewSegment[]; activeReviewItem?: ReviewSegment; currentTime: number; exportRange?: TimeRange; isPlaying?: boolean; setCurrentTime: React.Dispatch>; manuallySetCurrentTime: (time: number, force: boolean) => void; setScrubbing: React.Dispatch>; setExportRange: (range: TimeRange) => void; onAnalysisOpen: (open: boolean) => void; }; function Timeline({ contentRef, timelineRef, mainCamera, timelineType, timeRange, mainCameraReviewItems, activeReviewItem, currentTime, exportRange, isPlaying, setCurrentTime, manuallySetCurrentTime, setScrubbing, setExportRange, onAnalysisOpen, }: TimelineProps) { const { t } = useTranslation(["views/events"]); const internalTimelineRef = useRef(null); const selectedTimelineRef = timelineRef || internalTimelineRef; // timeline interaction const [zoomSettings, setZoomSettings] = useState({ segmentDuration: 30, timestampSpread: 15, }); const possibleZoomLevels: ZoomLevel[] = useMemo( () => [ { segmentDuration: 30, timestampSpread: 15 }, { segmentDuration: 15, timestampSpread: 5 }, { segmentDuration: 5, timestampSpread: 1 }, ], [], ); const handleZoomChange = useCallback( (newZoomLevel: number) => { setZoomSettings(possibleZoomLevels[newZoomLevel]); }, [possibleZoomLevels], ); const currentZoomLevel = useMemo( () => possibleZoomLevels.findIndex( (level) => level.segmentDuration === zoomSettings.segmentDuration, ), [possibleZoomLevels, zoomSettings.segmentDuration], ); const { isZooming, zoomDirection } = useTimelineZoom({ zoomSettings, zoomLevels: possibleZoomLevels, onZoomChange: handleZoomChange, timelineRef: selectedTimelineRef, timelineDuration: timeRange.after - timeRange.before, }); // motion data const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils( { segmentDuration: zoomSettings.segmentDuration, }, ); const alignedAfter = alignStartDateToTimeline(timeRange.after); const alignedBefore = alignEndDateToTimeline(timeRange.before); const { data: motionData, isLoading } = useSWR([ "review/activity/motion", { before: alignedBefore, after: alignedAfter, scale: Math.round(zoomSettings.segmentDuration / 2), cameras: mainCamera, }, ]); const { data: noRecordings } = useSWR([ "recordings/unavailable", { before: alignedBefore, after: alignedAfter, scale: Math.round(zoomSettings.segmentDuration), 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 (
{isMobileOnly && timelineType == "timeline" && ( )} {timelineType != "detail" && ( <>
)} {timelineType == "timeline" ? ( !isLoading ? ( ) : ( ) ) : timelineType == "detail" ? ( manuallySetCurrentTime(timestamp, play ?? true) } reviewItems={mainCameraReviewItems} isPlaying={isPlaying} /> ) : (
{mainCameraReviewItems.length === 0 ? (
{t("events.noFoundForTimePeriod")}
) : ( mainCameraReviewItems.map((review) => { if (review.severity === "significant_motion") { return; } return ( { manuallySetCurrentTime( review.start_time - REVIEW_PADDING, true, ); }} /> ); }) )}
)}
); }