Recordings viewer (#9985)

* Reduce redundant code and don't pull new items when marking as reviewed

* Chunk recording times and run playback

* fix overwriting existing data

* Implement scrubbing

* Show refresh button

* Remove old history

* Fix race condition

* Cleanup handling

* Remove console
This commit is contained in:
Nicolas Mowen
2024-02-22 17:03:34 -07:00
committed by GitHub
parent fa57a3db28
commit f84d2db406
18 changed files with 486 additions and 1680 deletions

View File

@@ -1,87 +0,0 @@
import useSWR from "swr";
import { Card } from "../ui/card";
import { FrigateConfig } from "@/types/frigateConfig";
import ActivityIndicator from "../ui/activity-indicator";
import { LuClock, LuTrash } from "react-icons/lu";
import { HiOutlineVideoCamera } from "react-icons/hi";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import {
getTimelineIcon,
getTimelineItemDescription,
} from "@/utils/timelineUtil";
import { Button } from "../ui/button";
type HistoryCardProps = {
timeline: Card;
relevantPreview?: Preview;
isMobile: boolean;
onClick?: () => void;
onDelete?: () => void;
};
export default function HistoryCard({
// @ts-ignore
relevantPreview,
timeline,
// @ts-ignore
isMobile,
onClick,
onDelete,
}: HistoryCardProps) {
const { data: config } = useSWR<FrigateConfig>("config");
if (!config) {
return <ActivityIndicator />;
}
return (
<Card
className="cursor-pointer my-2 xs:mr-2 w-full xs:w-[48%] sm:w-[284px]"
onClick={onClick}
>
<>
<div className="text-sm flex justify-between items-center">
<div className="pl-1 pt-1">
<LuClock className="h-5 w-5 mr-2 inline" />
{formatUnixTimestampToDateTime(timeline.time, {
strftime_fmt:
config.ui.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S %p",
time_style: "medium",
date_style: "medium",
})}
</div>
<Button className="px-2 py-2" variant="ghost" size="xs">
<LuTrash
className="w-5 h-5 stroke-danger"
onClick={(e: Event) => {
e.stopPropagation();
if (onDelete) {
onDelete();
}
}}
/>
</Button>
</div>
<div className="pl-1 capitalize text-sm flex items-center mt-1">
<HiOutlineVideoCamera className="h-5 w-5 mr-2 inline" />
{timeline.camera.replaceAll("_", " ")}
</div>
<div className="pl-1 my-2">
<div className="text-sm font-medium">Activity:</div>
{Object.entries(timeline.entries).map(([_, entry], idx) => {
return (
<div
key={idx}
className="flex text-xs capitalize my-1 items-center"
>
{getTimelineIcon(entry)}
{getTimelineItemDescription(entry)}
</div>
);
})}
</div>
</>
</Card>
);
}

View File

@@ -1,262 +0,0 @@
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<FrigateConfig>("config");
const apiHost = useApiHost();
const playerRef = useRef<Player | undefined>();
const annotationOffset = useMemo(() => {
if (!config || !timeline) {
return 0;
}
return (
(config.cameras[timeline.camera]?.detect?.annotation_offset || 0) / 1000
);
}, [config, timeline]);
const [selectedItem, setSelectedItem] = useState<Timeline | undefined>();
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<Recording[]>(
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 (
<>
<Dialog
open={timeline != null}
onOpenChange={(_) => {
setSelectedItem(undefined);
onDismiss();
}}
>
<DialogContent
className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-5xl"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="capitalize">
{`${timeline?.camera?.replaceAll(
"_",
" "
)} @ ${formatUnixTimestampToDateTime(timeline?.time ?? 0, {
strftime_fmt:
config?.ui?.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S",
})}`}
</DialogTitle>
</DialogHeader>
{config && timeline && recordings && recordings.length > 0 && (
<>
<TimelineSummary
timeline={timeline}
annotationOffset={annotationOffset}
recordings={recordings}
onFrameSelected={(selected, seekTime) => {
setSelectedItem(selected);
playerRef.current?.pause();
playerRef.current?.currentTime(seekTime);
}}
/>
<div className="relative">
<VideoPlayer
options={{
preload: "auto",
autoplay: true,
sources: [
{
src: playbackUri,
type: "application/vnd.apple.mpegurl",
},
],
}}
seekOptions={{ forward: 10, backward: 5 }}
onReady={(player) => {
playerRef.current = player;
player.on("playing", () => {
setSelectedItem(undefined);
});
}}
onDispose={() => {
playerRef.current = undefined;
}}
>
{selectedItem ? (
<TimelineEventOverlay
timeline={selectedItem}
cameraConfig={config.cameras[timeline.camera]}
/>
) : undefined}
</VideoPlayer>
</div>
</>
)}
</DialogContent>
</Dialog>
</>
);
}
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<number>(-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 <ActivityIndicator />;
}
return (
<div className="flex flex-col">
<div className="h-12 flex justify-center">
<div className="flex flex-row flex-nowrap justify-between overflow-auto">
{timeline.entries.map((item, index) => (
<TooltipProvider key={item.timestamp}>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={`m-1 blue ${
index == timeIndex ? "text-blue-500" : "text-gray-500"
}`}
variant="secondary"
autoFocus={false}
onClick={() => onSelectMoment(index)}
>
{getTimelineIcon(item)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{getTimelineItemDescription(item)}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
</div>
{timeIndex >= 0 ? (
<div className="max-w-md self-center">
<div className="flex justify-start">
<div className="text-sm flex justify-between py-1 items-center">
Bounding boxes may not align
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost">
<LuAlertCircle />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
Disclaimer: This data comes from the detect feed but is
shown on the recordings.
</p>
<p>
It is unlikely that the streams are perfectly in sync so the
bounding box and the footage will not line up perfectly.
</p>
<p>The annotation_offset field can be used to adjust this.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -16,7 +16,9 @@ import {
import { Calendar } from "../ui/calendar";
type HistoryFilterPopoverProps = {
// @ts-ignore
filter: HistoryFilter | undefined;
// @ts-ignore
onUpdateFilter: (filter: HistoryFilter) => void;
};

View File

@@ -23,6 +23,7 @@ type PreviewPlayerProps = {
relevantPreview?: Preview;
autoPlayback?: boolean;
setReviewed?: () => void;
onClick?: () => void;
};
type Preview = {
@@ -38,6 +39,7 @@ export default function PreviewThumbnailPlayer({
relevantPreview,
autoPlayback = false,
setReviewed,
onClick,
}: PreviewPlayerProps) {
const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
@@ -109,6 +111,7 @@ export default function PreviewThumbnailPlayer({
className="relative w-full h-full cursor-pointer"
onMouseEnter={isMobile ? undefined : () => onPlayback(true)}
onMouseLeave={isMobile ? undefined : () => onPlayback(false)}
onClick={onClick}
>
{playingBack ? (
<PreviewContent
@@ -185,6 +188,15 @@ function PreviewContent({
setProgress,
setReviewed,
}: PreviewContentProps) {
const playerStartTime = useMemo(() => {
if (!relevantPreview) {
return 0;
}
// start with a bit of padding
return Math.max(0, review.start_time - relevantPreview.start - 8);
}, []);
// manual playback
// safari is incapable of playing at a speed > 2x
// so manual seeking is required on iOS
@@ -195,9 +207,11 @@ function PreviewContent({
return;
}
let counter = 0;
const intervalId: NodeJS.Timeout = setInterval(() => {
if (playerRef.current) {
playerRef.current.currentTime(playerRef.current.currentTime()!! + 1);
playerRef.current.currentTime(playerStartTime + counter);
counter += 1;
}
}, 125);
return () => clearInterval(intervalId);
@@ -233,20 +247,15 @@ function PreviewContent({
return;
}
// start with a bit of padding
const playerStartTime = Math.max(
0,
review.start_time - relevantPreview.start - 8
);
if (isSafari) {
player.pause();
setManualPlayback(true);
} else {
player.currentTime(playerStartTime);
player.playbackRate(8);
}
player.currentTime(playerStartTime);
let lastPercent = 0;
player.on("timeupdate", () => {
if (!setProgress) {
return;
@@ -262,11 +271,14 @@ function PreviewContent({
if (
setReviewed &&
!review.has_been_reviewed &&
lastPercent < 50 &&
playerPercent > 50
) {
setReviewed();
}
lastPercent = playerPercent;
if (playerPercent > 100) {
playerRef.current?.pause();
setManualPlayback(false);

View File

@@ -25,6 +25,7 @@ export type EventReviewTimelineProps = {
events: ReviewSegment[];
severityType: ReviewSeverity;
contentRef: RefObject<HTMLDivElement>;
onHandlebarDraggingChange?: (isDragging: boolean) => void;
};
export function EventReviewTimeline({
@@ -41,6 +42,7 @@ export function EventReviewTimeline({
events,
severityType,
contentRef,
onHandlebarDraggingChange,
}: EventReviewTimelineProps) {
const [isDragging, setIsDragging] = useState(false);
const [currentTimeSegment, setCurrentTimeSegment] = useState<number>(0);
@@ -152,6 +154,12 @@ export function EventReviewTimeline({
}
}, [currentTimeSegment, showHandlebar]);
useEffect(() => {
if (onHandlebarDraggingChange) {
onHandlebarDraggingChange(isDragging);
}
}, [isDragging, onHandlebarDraggingChange]);
useEffect(() => {
if (timelineRef.current && handlebarTime && showHandlebar) {
const { scrollHeight: timelineHeight } = timelineRef.current;