forked from Github/frigate
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
import { Calendar } from "../ui/calendar";
|
||||
|
||||
type HistoryFilterPopoverProps = {
|
||||
// @ts-ignore
|
||||
filter: HistoryFilter | undefined;
|
||||
// @ts-ignore
|
||||
onUpdateFilter: (filter: HistoryFilter) => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user