forked from Github/frigate
50
web/src/components/card/ExportCard.tsx
Normal file
50
web/src/components/card/ExportCard.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import ActivityIndicator from "../ui/activity-indicator";
|
||||
import { Card } from "../ui/card";
|
||||
import { LuPlay, LuTrash } from "react-icons/lu";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
type ExportProps = {
|
||||
file: {
|
||||
name: string;
|
||||
};
|
||||
onSelect: (file: string) => void;
|
||||
onDelete: (file: string) => void;
|
||||
};
|
||||
|
||||
export default function ExportCard({ file, onSelect, onDelete }: ExportProps) {
|
||||
return (
|
||||
<Card className="my-4 p-4 bg-secondary flex justify-start text-center items-center">
|
||||
{file.name.startsWith("in_progress") ? (
|
||||
<>
|
||||
<div className="p-2">
|
||||
<ActivityIndicator size={16} />
|
||||
</div>
|
||||
<div className="px-2">
|
||||
{file.name.substring(12, file.name.length - 4)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => onSelect(file.name)}>
|
||||
<LuPlay className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
<a
|
||||
className="text-blue-500 hover:underline overflow-hidden"
|
||||
href={`${baseUrl}exports/${file.name}`}
|
||||
download
|
||||
>
|
||||
{file.name.substring(0, file.name.length - 4)}
|
||||
</a>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
variant="secondary"
|
||||
onClick={() => onDelete(file.name)}
|
||||
>
|
||||
<LuTrash className="h-4 w-4" stroke="#f87171" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
74
web/src/components/card/HistoryCard.tsx
Normal file
74
web/src/components/card/HistoryCard.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import useSWR from "swr";
|
||||
import PreviewThumbnailPlayer from "../player/PreviewThumbnailPlayer";
|
||||
import { Card } from "../ui/card";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import ActivityIndicator from "../ui/activity-indicator";
|
||||
import { LuClock } from "react-icons/lu";
|
||||
import { HiOutlineVideoCamera } from "react-icons/hi";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import {
|
||||
getTimelineIcon,
|
||||
getTimelineItemDescription,
|
||||
} from "@/utils/timelineUtil";
|
||||
|
||||
type HistoryCardProps = {
|
||||
timeline: Card;
|
||||
relevantPreview?: Preview;
|
||||
shouldAutoPlay: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export default function HistoryCard({
|
||||
relevantPreview,
|
||||
timeline,
|
||||
shouldAutoPlay,
|
||||
onClick,
|
||||
}: 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}
|
||||
>
|
||||
<PreviewThumbnailPlayer
|
||||
camera={timeline.camera}
|
||||
relevantPreview={relevantPreview}
|
||||
startTs={Object.values(timeline.entries)[0].timestamp}
|
||||
eventId={Object.values(timeline.entries)[0].source_id}
|
||||
shouldAutoPlay={shouldAutoPlay}
|
||||
/>
|
||||
<div className="p-2">
|
||||
<div className="text-sm flex">
|
||||
<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",
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
})}
|
||||
</div>
|
||||
<div className="capitalize text-sm flex align-center mt-1">
|
||||
<HiOutlineVideoCamera className="h-5 w-5 mr-2 inline" />
|
||||
{timeline.camera.replaceAll("_", " ")}
|
||||
</div>
|
||||
<div className="my-2 text-sm font-medium">Activity:</div>
|
||||
{Object.entries(timeline.entries).map(([_, entry]) => {
|
||||
return (
|
||||
<div
|
||||
key={entry.timestamp}
|
||||
className="flex text-xs capitalize my-1 items-center"
|
||||
>
|
||||
{getTimelineIcon(entry)}
|
||||
{getTimelineItemDescription(entry)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
83
web/src/components/card/MiniEventCard.tsx
Normal file
83
web/src/components/card/MiniEventCard.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useApiHost } from "@/api";
|
||||
import { Card } from "../ui/card";
|
||||
import { Event as FrigateEvent } from "@/types/event";
|
||||
import { LuClock, LuStar } from "react-icons/lu";
|
||||
import { useCallback } from "react";
|
||||
import TimeAgo from "../dynamic/TimeAgo";
|
||||
import { HiOutlineVideoCamera } from "react-icons/hi";
|
||||
import { MdOutlineLocationOn } from "react-icons/md";
|
||||
import axios from "axios";
|
||||
|
||||
type MiniEventCardProps = {
|
||||
event: FrigateEvent;
|
||||
onUpdate?: () => void;
|
||||
};
|
||||
|
||||
export default function MiniEventCard({ event, onUpdate }: MiniEventCardProps) {
|
||||
const baseUrl = useApiHost();
|
||||
const onSave = useCallback(
|
||||
async (e: Event) => {
|
||||
e.stopPropagation();
|
||||
let response;
|
||||
if (!event.retain_indefinitely) {
|
||||
response = await axios.post(`events/${event.id}/retain`);
|
||||
} else {
|
||||
response = await axios.delete(`events/${event.id}/retain`);
|
||||
}
|
||||
if (response.status === 200 && onUpdate) {
|
||||
onUpdate();
|
||||
}
|
||||
},
|
||||
[event]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="mr-2 min-w-[260px] max-w-[320px]">
|
||||
<div className="flex">
|
||||
<div
|
||||
className="relative rounded-l min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center"
|
||||
style={{
|
||||
backgroundImage: `url(${baseUrl}api/events/${event.id}/thumbnail.jpg)`,
|
||||
}}
|
||||
>
|
||||
<LuStar
|
||||
className="h-6 w-6 text-yellow-300 absolute top-1 right-1 cursor-pointer"
|
||||
onClick={(e: Event) => onSave(e)}
|
||||
fill={event.retain_indefinitely ? "currentColor" : "none"}
|
||||
/>
|
||||
{event.end_time ? null : (
|
||||
<div className="bg-slate-300 dark:bg-slate-700 absolute bottom-0 text-center w-full uppercase text-sm rounded-bl">
|
||||
In progress
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-1 flex flex-col justify-between">
|
||||
<div className="capitalize text-lg font-bold">
|
||||
{event.label.replaceAll("_", " ")}
|
||||
{event.sub_label
|
||||
? `: ${event.sub_label.replaceAll("_", " ")}`
|
||||
: null}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm flex">
|
||||
<LuClock className="h-4 w-4 mr-2 inline" />
|
||||
<div className="hidden sm:inline">
|
||||
<TimeAgo time={event.start_time * 1000} dense />
|
||||
</div>
|
||||
</div>
|
||||
<div className="capitalize text-sm flex align-center mt-1 whitespace-nowrap">
|
||||
<HiOutlineVideoCamera className="h-4 w-4 mr-2 inline" />
|
||||
{event.camera.replaceAll("_", " ")}
|
||||
</div>
|
||||
{event.zones.length ? (
|
||||
<div className="capitalize text-sm flex align-center">
|
||||
<MdOutlineLocationOn className="w-4 h-4 mr-2 inline" />
|
||||
{event.zones.join(", ").replaceAll("_", " ")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
262
web/src/components/card/TimelineCardPlayer.tsx
Normal file
262
web/src/components/card/TimelineCardPlayer.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user