import { useApiHost } from "@/api"; import TimelineEventOverlay from "@/components/overlay/TimelineDataOverlay"; import VideoPlayer from "@/components/player/VideoPlayer"; import ActivityScrubber from "@/components/scrubber/ActivityScrubber"; import ActivityIndicator from "@/components/ui/activity-indicator"; import { FrigateConfig } from "@/types/frigateConfig"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useSWR from "swr"; import Player from "video.js/dist/types/player"; import TimelineItemCard from "@/components/card/TimelineItemCard"; import { getTimelineHoursForDay } from "@/utils/historyUtil"; import { GraphDataPoint } from "@/types/graph"; import TimelineGraph from "@/components/graph/TimelineGraph"; type DesktopTimelineViewProps = { timelineData: CardsData; allPreviews: Preview[]; initialPlayback: TimelinePlayback; }; export default function DesktopTimelineView({ timelineData, allPreviews, initialPlayback, }: DesktopTimelineViewProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); const timezone = useMemo( () => config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config] ); const [selectedPlayback, setSelectedPlayback] = useState(initialPlayback); const playerRef = useRef(undefined); const previewRef = useRef(undefined); const initialScrollRef = useRef(null); const [scrubbing, setScrubbing] = useState(false); const [focusedItem, setFocusedItem] = useState( undefined ); const [seeking, setSeeking] = useState(false); const [timeToSeek, setTimeToSeek] = useState(undefined); const [timelineTime, setTimelineTime] = useState( initialPlayback.timelineItems.length > 0 ? initialPlayback.timelineItems[0].timestamp - initialPlayback.range.start : 0 ); const annotationOffset = useMemo(() => { if (!config) { return 0; } return ( (config.cameras[initialPlayback.camera]?.detect?.annotation_offset || 0) / 1000 ); }, [config]); const recordingParams = useMemo(() => { return { before: selectedPlayback.range.end, after: selectedPlayback.range.start, }; }, [selectedPlayback]); const { data: recordings } = useSWR( selectedPlayback ? [`${selectedPlayback.camera}/recordings`, recordingParams] : null, { revalidateOnFocus: false } ); const playbackUri = useMemo(() => { if (!selectedPlayback) { return ""; } const date = new Date(selectedPlayback.range.start * 1000); return `${apiHost}vod/${date.getFullYear()}-${ date.getMonth() + 1 }/${date.getDate()}/${date.getHours()}/${ selectedPlayback.camera }/${timezone.replaceAll("/", ",")}/master.m3u8`; }, [selectedPlayback]); const onSelectItem = useCallback( (timeline: Timeline | undefined) => { if (timeline) { setFocusedItem(timeline); const selected = timeline.timestamp; playerRef.current?.pause(); let seekSeconds = 0; (recordings || []).every((segment) => { // if the next segment is past the desired time, stop calculating if (segment.start_time > selected) { return false; } if (segment.end_time < selected) { seekSeconds += segment.end_time - segment.start_time; return true; } seekSeconds += segment.end_time - segment.start_time - (segment.end_time - selected); return true; }); playerRef.current?.currentTime(seekSeconds); } else { setFocusedItem(undefined); } }, [annotationOffset, recordings, playerRef] ); // handle scrolling to initial timeline item useEffect(() => { if (initialScrollRef.current != null) { initialScrollRef.current.scrollIntoView(); } }, [initialScrollRef]); // handle seeking to next frame when seek is finished useEffect(() => { if (seeking) { return; } if (timeToSeek && !scrubbing) { setScrubbing(true); playerRef.current?.pause(); } if (timeToSeek && timeToSeek != previewRef.current?.currentTime()) { setSeeking(true); previewRef.current?.currentTime(timeToSeek); } }, [timeToSeek, seeking]); // handle loading main / preview playback when selected hour changes useEffect(() => { if (!playerRef.current) { return; } setTimelineTime( selectedPlayback.timelineItems.length > 0 ? selectedPlayback.timelineItems[0].timestamp : selectedPlayback.range.start ); playerRef.current.src({ src: playbackUri, type: "application/vnd.apple.mpegurl", }); if (selectedPlayback.relevantPreview && previewRef.current) { previewRef.current.src({ src: selectedPlayback.relevantPreview.src, type: selectedPlayback.relevantPreview.type, }); } }, [playerRef, previewRef, playbackUri]); const timelineStack = useMemo( () => getTimelineHoursForDay( selectedPlayback.camera, timelineData, allPreviews, selectedPlayback.range.start + 60 ), [] ); const { data: activity } = useSWR( [ `${initialPlayback.camera}/recording/hourly/activity`, { after: timelineStack.start, before: timelineStack.end, timezone, }, ], { revalidateOnFocus: false } ); const timelineGraphData = useMemo(() => { if (!activity) { return {}; } const graphData: { [hour: string]: { objects: number[]; motion: GraphDataPoint[] }; } = {}; Object.entries(activity).forEach(([hour, data]) => { const objects: number[] = []; const motion: GraphDataPoint[] = []; data.forEach((seg, idx) => { if (seg.hasObjects) { objects.push(idx); } motion.push({ x: new Date(seg.date * 1000), y: seg.count, }); }); graphData[hour] = { objects, motion }; }); return graphData; }, [activity]); if (!config) { return ; } return (
<>
{ playerRef.current = player; if (selectedPlayback.timelineItems.length > 0) { player.currentTime( selectedPlayback.timelineItems[0].timestamp - selectedPlayback.range.start ); } else { player.currentTime(0); } player.on("playing", () => onSelectItem(undefined)); player.on("timeupdate", () => { setTimelineTime(Math.floor(player.currentTime() || 0)); }); }} onDispose={() => { playerRef.current = undefined; }} > {focusedItem && ( )}
{selectedPlayback.relevantPreview && (
{ previewRef.current = player; player.on("seeked", () => setSeeking(false)); }} onDispose={() => { previewRef.current = undefined; }} />
)}
{selectedPlayback.timelineItems.map((timeline) => { return ( onSelectItem(timeline)} /> ); })}
{timelineStack.playbackItems.map((timeline) => { const isInitiallySelected = initialPlayback.range.start == timeline.range.start; const isSelected = timeline.range.start == selectedPlayback.range.start; const graphData = timelineGraphData[timeline.range.start]; return (
{ if (!timeline.relevantPreview) { return; } const seekTimestamp = data.time.getTime() / 1000; const seekTime = seekTimestamp - timeline.relevantPreview.start; setTimelineTime(seekTimestamp - timeline.range.start); setTimeToSeek(Math.round(seekTime)); }} timechangedHandler={(data) => { const playbackTime = data.time.getTime() / 1000; playerRef.current?.currentTime( playbackTime - timeline.range.start ); setScrubbing(false); playerRef.current?.play(); }} selectHandler={(data) => { if (data.items.length > 0) { const selected = data.items[0]; onSelectItem( selectedPlayback.timelineItems.find( (timeline) => timeline.timestamp == selected ) ); } }} doubleClickHandler={() => { setScrubbing(false); setSelectedPlayback(timeline); }} /> {isSelected && graphData && (
)}
); })}
); }