import DynamicVideoPlayer, { DynamicVideoController, } from "@/components/player/DynamicVideoPlayer"; import PreviewPlayer, { PreviewController, } from "@/components/player/PreviewPlayer"; import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review"; import { getChunkedTimeDay } from "@/utils/timelineUtil"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { IoMdArrowRoundBack } from "react-icons/io"; import { useNavigate } from "react-router-dom"; import useSWR from "swr"; const SEGMENT_DURATION = 30; type DesktopRecordingViewProps = { startCamera: string; startTime: number; severity: ReviewSeverity; reviewItems: ReviewSegment[]; allCameras: string[]; allPreviews?: Preview[]; }; export function DesktopRecordingView({ startCamera, startTime, severity, reviewItems, allCameras, allPreviews, }: DesktopRecordingViewProps) { const { data: config } = useSWR("config"); const navigate = useNavigate(); const contentRef = useRef(null); // controller state const [mainCamera, setMainCamera] = useState(startCamera); const mainControllerRef = 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 time const timeRange = useMemo(() => getChunkedTimeDay(startTime), [startTime]); const [selectedRangeIdx, setSelectedRangeIdx] = useState( timeRange.ranges.findIndex((chunk) => { return chunk.start <= startTime && chunk.end >= startTime; }), ); const currentTimeRange = useMemo( () => timeRange.ranges[selectedRangeIdx], [selectedRangeIdx, timeRange], ); // move to next clip useEffect(() => { if (!mainControllerRef.current) { return; } mainControllerRef.current.onClipChangedEvent((dir) => { if (dir == "forward") { if (selectedRangeIdx < timeRange.ranges.length - 1) { setSelectedRangeIdx(selectedRangeIdx + 1); } } }); // we only want to fire once when players are ready // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedRangeIdx, timeRange, mainControllerRef.current, mainCamera]); // scrubbing and timeline state const [scrubbing, setScrubbing] = useState(false); const [currentTime, setCurrentTime] = useState(startTime); useEffect(() => { if (scrubbing) { if ( currentTime > currentTimeRange.end + 60 || currentTime < currentTimeRange.start - 60 ) { const index = timeRange.ranges.findIndex( (seg) => seg.start <= currentTime && seg.end >= currentTime, ); if (index != -1) { setSelectedRangeIdx(index); } return; } mainControllerRef.current?.scrubToTimestamp(currentTime); Object.values(previewRefs.current).forEach((controller) => { controller.scrubToTimestamp(currentTime); }); } }, [currentTime, scrubbing, timeRange, currentTimeRange]); useEffect(() => { if (!scrubbing) { mainControllerRef.current?.seekToTimestamp(currentTime, true); } // we only want to seek when user stops scrubbing // eslint-disable-next-line react-hooks/exhaustive-deps }, [scrubbing]); const onSelectCamera = useCallback( (newCam: string) => { setMainCamera(newCam); setPlaybackStart(currentTime); }, [currentTime], ); // motion timeline data const { data: motionData } = useSWR( severity == "significant_motion" ? [ "review/activity/motion", { before: timeRange.end, after: timeRange.start, scale: SEGMENT_DURATION / 2, cameras: mainCamera, }, ] : null, ); const grow = useMemo(() => { if (!config) { return "aspect-video"; } const aspectRatio = config.cameras[mainCamera].detect.width / config.cameras[mainCamera].detect.height; if (aspectRatio > 2) { return "aspect-wide"; } else { return "aspect-video"; } }, [config, mainCamera]); return (
{ mainControllerRef.current = controller; controller.onPlayerTimeUpdate((timestamp: number) => { setCurrentTime(timestamp); Object.values(previewRefs.current ?? {}).forEach((prev) => prev.scrubToTimestamp(Math.floor(timestamp)), ); }); }} />
{allCameras.map((cam) => { if (cam !== mainCamera) { return (
{ previewRefs.current[cam] = controller; controller.scrubToTimestamp(startTime); }} onClick={() => onSelectCamera(cam)} />
); } return null; })}
{severity != "significant_motion" ? ( setScrubbing(scrubbing)} /> ) : ( setScrubbing(scrubbing)} /> )}
); } type MobileRecordingViewProps = { startCamera: string; startTime: number; severity: ReviewSeverity; reviewItems: ReviewSegment[]; relevantPreviews?: Preview[]; allCameras: string[]; }; export function MobileRecordingView({ startCamera, startTime, severity, reviewItems, relevantPreviews, allCameras, }: MobileRecordingViewProps) { const navigate = useNavigate(); const contentRef = useRef(null); // controller state const [playerReady, setPlayerReady] = useState(false); const controllerRef = useRef(undefined); const [playbackCamera, setPlaybackCamera] = useState(startCamera); const [playbackStart, setPlaybackStart] = useState(startTime); // timeline time const timeRange = useMemo(() => getChunkedTimeDay(startTime), [startTime]); const [selectedRangeIdx, setSelectedRangeIdx] = useState( timeRange.ranges.findIndex((chunk) => { return chunk.start <= startTime && chunk.end >= startTime; }), ); const currentTimeRange = useMemo( () => timeRange.ranges[selectedRangeIdx], [selectedRangeIdx, timeRange], ); const mainCameraReviewItems = useMemo( () => reviewItems.filter((cam) => cam.camera == playbackCamera), [reviewItems, playbackCamera], ); // move to next clip useEffect(() => { if (!controllerRef.current) { return; } controllerRef.current.onClipChangedEvent((dir) => { if (dir == "forward") { if (selectedRangeIdx < timeRange.ranges.length - 1) { setSelectedRangeIdx(selectedRangeIdx + 1); } } }); }, [playerReady, selectedRangeIdx, timeRange]); // scrubbing and timeline state const [scrubbing, setScrubbing] = useState(false); const [currentTime, setCurrentTime] = useState( startTime || Date.now() / 1000, ); useEffect(() => { if (scrubbing) { if ( currentTime > currentTimeRange.end + 60 || currentTime < currentTimeRange.start - 60 ) { const index = timeRange.ranges.findIndex( (seg) => seg.start <= currentTime && seg.end >= currentTime, ); if (index != -1) { setSelectedRangeIdx(index); } return; } controllerRef.current?.scrubToTimestamp(currentTime); } }, [currentTime, scrubbing, currentTimeRange, timeRange]); useEffect(() => { 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]); // motion timeline data const { data: motionData } = useSWR( severity == "significant_motion" ? [ "review/activity/motion", { before: timeRange.end, after: timeRange.start, scale: SEGMENT_DURATION / 2, cameras: playbackCamera, }, ] : null, ); return (
{ setPlaybackStart(currentTime); setPlaybackCamera(cam); }} > {allCameras.map((cam) => ( {cam.replaceAll("_", " ")} ))}
{ controllerRef.current = controller; setPlayerReady(true); controllerRef.current.onPlayerTimeUpdate((timestamp: number) => { setCurrentTime(timestamp); }); controllerRef.current?.seekToTimestamp(startTime, true); }} />
{severity != "significant_motion" ? ( setScrubbing(scrubbing)} /> ) : ( setScrubbing(scrubbing)} /> )}
); }