import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import VideoPlayer from "../player/VideoPlayer"; import { useMemo, useRef, useState } from "react"; import { useApiHost } from "@/api"; import TimelineEventOverlay from "../overlay/TimelineDataOverlay"; import ActivityIndicator from "../ui/activity-indicator"; import { Button } from "../ui/button"; import { getTimelineIcon, getTimelineItemDescription, } from "@/utils/timelineUtil"; import { LuAlertCircle } from "react-icons/lu"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../ui/tooltip"; import Player from "video.js/dist/types/player"; type TimelinePlayerCardProps = { timeline?: Card; onDismiss: () => void; }; export default function TimelinePlayerCard({ timeline, onDismiss, }: TimelinePlayerCardProps) { const { data: config } = useSWR("config"); const apiHost = useApiHost(); const playerRef = useRef(); const annotationOffset = useMemo(() => { if (!config || !timeline) { return 0; } return ( (config.cameras[timeline.camera]?.detect?.annotation_offset || 0) / 1000 ); }, [config, timeline]); const [selectedItem, setSelectedItem] = useState(); const recordingParams = useMemo(() => { if (!timeline) { return {}; } return { before: timeline.entries.at(-1)!!.timestamp + 30, after: timeline.entries.at(0)!!.timestamp, }; }, [timeline]); const { data: recordings } = useSWR( timeline ? [`${timeline.camera}/recordings`, recordingParams] : null, { revalidateOnFocus: false } ); const playbackUri = useMemo(() => { if (!timeline) { return ""; } const end = timeline.entries.at(-1)!!.timestamp + 30; const start = timeline.entries.at(0)!!.timestamp; return `${apiHost}vod/${timeline?.camera}/start/${ Number.isInteger(start) ? start.toFixed(1) : start }/end/${Number.isInteger(end) ? end.toFixed(1) : end}/master.m3u8`; }, [timeline]); return ( <> { setSelectedItem(undefined); onDismiss(); }} > e.preventDefault()} > {`${timeline?.camera?.replaceAll( "_", " " )} @ ${formatUnixTimestampToDateTime(timeline?.time ?? 0, { strftime_fmt: config?.ui?.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S", })}`} {config && timeline && recordings && recordings.length > 0 && ( <> { setSelectedItem(selected); playerRef.current?.pause(); playerRef.current?.currentTime(seekTime); }} />
{ playerRef.current = player; player.on("playing", () => { setSelectedItem(undefined); }); }} onDispose={() => { playerRef.current = undefined; }} > {selectedItem ? ( ) : undefined}
)}
); } type TimelineSummaryProps = { timeline: Card; annotationOffset: number; recordings: Recording[]; onFrameSelected: (timeline: Timeline, frameTime: number) => void; }; function TimelineSummary({ timeline, annotationOffset, recordings, onFrameSelected, }: TimelineSummaryProps) { const [timeIndex, setTimeIndex] = useState(-1); // calculates the seek seconds by adding up all the seconds in the segments prior to the playback time const getSeekSeconds = (seekUnix: number) => { if (!recordings) { return 0; } let seekSeconds = 0; recordings.every((segment) => { // if the next segment is past the desired time, stop calculating if (segment.start_time > seekUnix) { return false; } if (segment.end_time < seekUnix) { seekSeconds += segment.end_time - segment.start_time; return true; } seekSeconds += segment.end_time - segment.start_time - (segment.end_time - seekUnix); return true; }); return seekSeconds; }; const onSelectMoment = async (index: number) => { setTimeIndex(index); onFrameSelected( timeline.entries[index], getSeekSeconds(timeline.entries[index].timestamp + annotationOffset) ); }; if (!timeline || !recordings) { return ; } return (
{timeline.entries.map((item, index) => (

{getTimelineItemDescription(item)}

))}
{timeIndex >= 0 ? (
Bounding boxes may not align

Disclaimer: This data comes from the detect feed but is shown on the recordings.

It is unlikely that the streams are perfectly in sync so the bounding box and the footage will not line up perfectly.

The annotation_offset field can be used to adjust this.

) : null}
); }