mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-30 13:48:07 +02:00
Review grid playback (#8971)
* Playback recording when clicking on review item * Add timeline overlay selector * Lint fixes * Lint fixes * Set video plays inline * Stop autoplay on open * Reverse order * Improve autoplay performance * Remove chromecast icon by default * Improve margin and remove lazy image loading to reduce jumping * Fix root margin for mobile * Fix scrolling behavior * Fix width
This commit is contained in:
parent
e3387de48f
commit
3a33090984
@ -29,7 +29,7 @@ function App() {
|
|||||||
<Header onToggleNavbar={toggleNavbar} />
|
<Header onToggleNavbar={toggleNavbar} />
|
||||||
<div className="grid grid-cols-[auto,1fr] flex-grow-1 overflow-auto">
|
<div className="grid grid-cols-[auto,1fr] flex-grow-1 overflow-auto">
|
||||||
<Sidebar sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} />
|
<Sidebar sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} />
|
||||||
<div className="overflow-x-hidden px-4 py-2 w-screen md:w-full">
|
<div id="pageRoot" className="overflow-x-hidden px-4 py-2 w-screen md:w-full">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/live" element={<Live />} />
|
<Route path="/live" element={<Live />} />
|
||||||
|
@ -3,32 +3,26 @@ import PreviewThumbnailPlayer from "../player/PreviewThumbnailPlayer";
|
|||||||
import { Card } from "../ui/card";
|
import { Card } from "../ui/card";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import ActivityIndicator from "../ui/activity-indicator";
|
import ActivityIndicator from "../ui/activity-indicator";
|
||||||
import {
|
import { LuClock } from "react-icons/lu";
|
||||||
LuCircle,
|
|
||||||
LuClock,
|
|
||||||
LuPlay,
|
|
||||||
LuPlayCircle,
|
|
||||||
LuTruck,
|
|
||||||
} from "react-icons/lu";
|
|
||||||
import { IoMdExit } from "react-icons/io";
|
|
||||||
import {
|
|
||||||
MdFaceUnlock,
|
|
||||||
MdOutlineLocationOn,
|
|
||||||
MdOutlinePictureInPictureAlt,
|
|
||||||
} from "react-icons/md";
|
|
||||||
import { HiOutlineVideoCamera } from "react-icons/hi";
|
import { HiOutlineVideoCamera } from "react-icons/hi";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
|
import {
|
||||||
|
getTimelineIcon,
|
||||||
|
getTimelineItemDescription,
|
||||||
|
} from "@/utils/timelineUtil";
|
||||||
|
|
||||||
type HistoryCardProps = {
|
type HistoryCardProps = {
|
||||||
timeline: Card;
|
timeline: Card;
|
||||||
relevantPreview?: Preview;
|
relevantPreview?: Preview;
|
||||||
shouldAutoPlay: boolean;
|
shouldAutoPlay: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function HistoryCard({
|
export default function HistoryCard({
|
||||||
relevantPreview,
|
relevantPreview,
|
||||||
timeline,
|
timeline,
|
||||||
shouldAutoPlay,
|
shouldAutoPlay,
|
||||||
|
onClick,
|
||||||
}: HistoryCardProps) {
|
}: HistoryCardProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
@ -37,7 +31,10 @@ export default function HistoryCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="my-2 xs:mr-2 bg-secondary w-full xs:w-[48%] sm:w-[284px]">
|
<Card
|
||||||
|
className="cursor-pointer my-2 xs:mr-2 bg-secondary w-full xs:w-[48%] sm:w-[284px]"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
<PreviewThumbnailPlayer
|
<PreviewThumbnailPlayer
|
||||||
camera={timeline.camera}
|
camera={timeline.camera}
|
||||||
relevantPreview={relevantPreview}
|
relevantPreview={relevantPreview}
|
||||||
@ -73,76 +70,3 @@ export default function HistoryCard({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTimelineIcon(timelineItem: Timeline) {
|
|
||||||
switch (timelineItem.class_type) {
|
|
||||||
case "visible":
|
|
||||||
return <LuPlay className="w-4 mr-1" />;
|
|
||||||
case "gone":
|
|
||||||
return <IoMdExit className="w-4 mr-1" />;
|
|
||||||
case "active":
|
|
||||||
return <LuPlayCircle className="w-4 mr-1" />;
|
|
||||||
case "stationary":
|
|
||||||
return <LuCircle className="w-4 mr-1" />;
|
|
||||||
case "entered_zone":
|
|
||||||
return <MdOutlineLocationOn className="w-4 mr-1" />;
|
|
||||||
case "attribute":
|
|
||||||
switch (timelineItem.data.attribute) {
|
|
||||||
case "face":
|
|
||||||
return <MdFaceUnlock className="w-4 mr-1" />;
|
|
||||||
case "license_plate":
|
|
||||||
return <MdOutlinePictureInPictureAlt className="w-4 mr-1" />;
|
|
||||||
default:
|
|
||||||
return <LuTruck className="w-4 mr-1" />;
|
|
||||||
}
|
|
||||||
case "sub_label":
|
|
||||||
switch (timelineItem.data.label) {
|
|
||||||
case "person":
|
|
||||||
return <MdFaceUnlock className="w-4 mr-1" />;
|
|
||||||
case "car":
|
|
||||||
return <MdOutlinePictureInPictureAlt className="w-4 mr-1" />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTimelineItemDescription(timelineItem: Timeline) {
|
|
||||||
const label = (
|
|
||||||
(Array.isArray(timelineItem.data.sub_label)
|
|
||||||
? timelineItem.data.sub_label[0]
|
|
||||||
: timelineItem.data.sub_label) || timelineItem.data.label
|
|
||||||
).replaceAll("_", " ");
|
|
||||||
|
|
||||||
switch (timelineItem.class_type) {
|
|
||||||
case "visible":
|
|
||||||
return `${label} detected`;
|
|
||||||
case "entered_zone":
|
|
||||||
return `${label} entered ${timelineItem.data.zones
|
|
||||||
.join(" and ")
|
|
||||||
.replaceAll("_", " ")}`;
|
|
||||||
case "active":
|
|
||||||
return `${label} became active`;
|
|
||||||
case "stationary":
|
|
||||||
return `${label} became stationary`;
|
|
||||||
case "attribute": {
|
|
||||||
let title = "";
|
|
||||||
if (
|
|
||||||
timelineItem.data.attribute == "face" ||
|
|
||||||
timelineItem.data.attribute == "license_plate"
|
|
||||||
) {
|
|
||||||
title = `${timelineItem.data.attribute.replaceAll(
|
|
||||||
"_",
|
|
||||||
" "
|
|
||||||
)} detected for ${label}`;
|
|
||||||
} else {
|
|
||||||
title = `${
|
|
||||||
timelineItem.data.sub_label
|
|
||||||
} recognized as ${timelineItem.data.attribute.replaceAll("_", " ")}`;
|
|
||||||
}
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
case "sub_label":
|
|
||||||
return `${timelineItem.data.label} recognized as ${timelineItem.data.sub_label}`;
|
|
||||||
case "gone":
|
|
||||||
return `${label} left`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
262
web-new/src/components/card/TimelineCardPlayer.tsx
Normal file
262
web-new/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>
|
||||||
|
);
|
||||||
|
}
|
84
web-new/src/components/overlay/TimelineDataOverlay.tsx
Normal file
84
web-new/src/components/overlay/TimelineDataOverlay.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
type TimelineEventOverlayProps = {
|
||||||
|
timeline: Timeline;
|
||||||
|
cameraConfig: {
|
||||||
|
detect: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TimelineEventOverlay({
|
||||||
|
timeline,
|
||||||
|
cameraConfig,
|
||||||
|
}: TimelineEventOverlayProps) {
|
||||||
|
const boxLeftEdge = Math.round(timeline.data.box[0] * 100);
|
||||||
|
const boxTopEdge = Math.round(timeline.data.box[1] * 100);
|
||||||
|
const boxRightEdge = Math.round(
|
||||||
|
(1 - timeline.data.box[2] - timeline.data.box[0]) * 100
|
||||||
|
);
|
||||||
|
const boxBottomEdge = Math.round(
|
||||||
|
(1 - timeline.data.box[3] - timeline.data.box[1]) * 100
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isHovering, setIsHovering] = useState<boolean>(false);
|
||||||
|
const getHoverStyle = () => {
|
||||||
|
if (boxLeftEdge < 15) {
|
||||||
|
// show object stats on right side
|
||||||
|
return {
|
||||||
|
left: `${boxLeftEdge + timeline.data.box[2] * 100 + 1}%`,
|
||||||
|
top: `${boxTopEdge}%`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
right: `${boxRightEdge + timeline.data.box[2] * 100 + 1}%`,
|
||||||
|
top: `${boxTopEdge}%`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getObjectArea = () => {
|
||||||
|
const width = timeline.data.box[2] * cameraConfig.detect.width;
|
||||||
|
const height = timeline.data.box[3] * cameraConfig.detect.height;
|
||||||
|
return Math.round(width * height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getObjectRatio = () => {
|
||||||
|
const width = timeline.data.box[2] * cameraConfig.detect.width;
|
||||||
|
const height = timeline.data.box[3] * cameraConfig.detect.height;
|
||||||
|
return Math.round(100 * (width / height)) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="absolute border-4 border-red-600"
|
||||||
|
onMouseEnter={() => setIsHovering(true)}
|
||||||
|
onMouseLeave={() => setIsHovering(false)}
|
||||||
|
onTouchStart={() => setIsHovering(true)}
|
||||||
|
onTouchEnd={() => setIsHovering(false)}
|
||||||
|
style={{
|
||||||
|
left: `${boxLeftEdge}%`,
|
||||||
|
top: `${boxTopEdge}%`,
|
||||||
|
right: `${boxRightEdge}%`,
|
||||||
|
bottom: `${boxBottomEdge}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timeline.class_type == "entered_zone" ? (
|
||||||
|
<div className="absolute w-2 h-2 bg-yellow-500 left-[50%] -translate-x-1/2 translate-y-3/4 bottom-0" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{isHovering && (
|
||||||
|
<div
|
||||||
|
className="absolute bg-white dark:bg-slate-800 p-4 block text-black dark:text-white text-lg"
|
||||||
|
style={getHoverStyle()}
|
||||||
|
>
|
||||||
|
<div>{`Area: ${getObjectArea()} px`}</div>
|
||||||
|
<div>{`Ratio: ${getObjectRatio()}`}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import VideoPlayer from "./VideoPlayer";
|
import VideoPlayer from "./VideoPlayer";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import Player from "video.js/dist/types/player";
|
import Player from "video.js/dist/types/player";
|
||||||
import { AspectRatio } from "../ui/aspect-ratio";
|
import { AspectRatio } from "../ui/aspect-ratio";
|
||||||
@ -33,6 +33,8 @@ export default function PreviewThumbnailPlayer({
|
|||||||
const playerRef = useRef<Player | null>(null);
|
const playerRef = useRef<Player | null>(null);
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
|
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
const onPlayback = useCallback(
|
const onPlayback = useCallback(
|
||||||
(isHovered: Boolean) => {
|
(isHovered: Boolean) => {
|
||||||
if (!relevantPreview || !playerRef.current) {
|
if (!relevantPreview || !playerRef.current) {
|
||||||
@ -46,73 +48,73 @@ export default function PreviewThumbnailPlayer({
|
|||||||
playerRef.current.currentTime(startTs - relevantPreview.start);
|
playerRef.current.currentTime(startTs - relevantPreview.start);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[relevantPreview, startTs]
|
[relevantPreview, startTs, playerRef]
|
||||||
);
|
);
|
||||||
|
|
||||||
const observer = useRef<IntersectionObserver | null>();
|
const autoPlayObserver = useRef<IntersectionObserver | null>();
|
||||||
|
const preloadObserver = useRef<IntersectionObserver | null>();
|
||||||
const inViewRef = useCallback(
|
const inViewRef = useCallback(
|
||||||
(node: HTMLElement | null) => {
|
(node: HTMLElement | null) => {
|
||||||
if (!shouldAutoPlay || observer.current) {
|
if (!preloadObserver.current) {
|
||||||
return;
|
try {
|
||||||
|
preloadObserver.current = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const [{ isIntersecting }] = entries;
|
||||||
|
setVisible(isIntersecting);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
threshold: 0,
|
||||||
|
root: document.getElementById("pageRoot"),
|
||||||
|
rootMargin: "10% 0px 25% 0px",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (node) preloadObserver.current.observe(node);
|
||||||
|
} catch (e) {
|
||||||
|
// no op
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (shouldAutoPlay && !autoPlayObserver.current) {
|
||||||
observer.current = new IntersectionObserver(
|
try {
|
||||||
(entries) => {
|
autoPlayObserver.current = new IntersectionObserver(
|
||||||
if (entries[0].isIntersecting) {
|
(entries) => {
|
||||||
onPlayback(true);
|
const [{ isIntersecting }] = entries;
|
||||||
} else {
|
if (isIntersecting) {
|
||||||
onPlayback(false);
|
onPlayback(true);
|
||||||
}
|
} else {
|
||||||
},
|
onPlayback(false);
|
||||||
{ threshold: 1.0 }
|
}
|
||||||
);
|
},
|
||||||
if (node) observer.current.observe(node);
|
{ threshold: 1.0 }
|
||||||
} catch (e) {
|
);
|
||||||
// no op
|
if (node) autoPlayObserver.current.observe(node);
|
||||||
|
} catch (e) {
|
||||||
|
// no op
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[observer, onPlayback]
|
[preloadObserver, autoPlayObserver, onPlayback]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!relevantPreview) {
|
let content;
|
||||||
|
if (!relevantPreview || !visible) {
|
||||||
if (isCurrentHour(startTs)) {
|
if (isCurrentHour(startTs)) {
|
||||||
return (
|
content = (
|
||||||
<AspectRatio
|
<img
|
||||||
ratio={16 / 9}
|
className={`${getPreviewWidth(camera, config)}`}
|
||||||
className="bg-black flex justify-center items-center"
|
src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`}
|
||||||
>
|
/>
|
||||||
<img
|
|
||||||
className={`${getPreviewWidth(camera, config)}`}
|
|
||||||
loading="lazy"
|
|
||||||
src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`}
|
|
||||||
/>
|
|
||||||
</AspectRatio>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
content = (
|
||||||
<AspectRatio
|
<img
|
||||||
ratio={16 / 9}
|
className="w-[160px]"
|
||||||
className="bg-black flex justify-center items-center"
|
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
|
||||||
>
|
/>
|
||||||
<img
|
|
||||||
className="w-[160px]"
|
|
||||||
loading="lazy"
|
|
||||||
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
|
|
||||||
/>
|
|
||||||
</AspectRatio>
|
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
|
content = (
|
||||||
return (
|
|
||||||
<AspectRatio
|
|
||||||
ref={shouldAutoPlay ? inViewRef : null}
|
|
||||||
ratio={16 / 9}
|
|
||||||
className="bg-black flex justify-center items-center"
|
|
||||||
onMouseEnter={() => onPlayback(true)}
|
|
||||||
onMouseLeave={() => onPlayback(false)}
|
|
||||||
>
|
|
||||||
<div className={`${getPreviewWidth(camera, config)}`}>
|
<div className={`${getPreviewWidth(camera, config)}`}>
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
options={{
|
options={{
|
||||||
@ -139,6 +141,18 @@ export default function PreviewThumbnailPlayer({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AspectRatio
|
||||||
|
ref={relevantPreview ? inViewRef : null}
|
||||||
|
ratio={16 / 9}
|
||||||
|
className="bg-black flex justify-center items-center"
|
||||||
|
onMouseEnter={() => onPlayback(true)}
|
||||||
|
onMouseLeave={() => onPlayback(false)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -152,6 +166,10 @@ function isCurrentHour(timestamp: number) {
|
|||||||
function getPreviewWidth(camera: string, config: FrigateConfig) {
|
function getPreviewWidth(camera: string, config: FrigateConfig) {
|
||||||
const detect = config.cameras[camera].detect;
|
const detect = config.cameras[camera].detect;
|
||||||
|
|
||||||
|
if (detect.width / detect.height < 1.0) {
|
||||||
|
return "w-[120px]";
|
||||||
|
}
|
||||||
|
|
||||||
if (detect.width / detect.height < 1.4) {
|
if (detect.width / detect.height < 1.4) {
|
||||||
return "w-[208px]";
|
return "w-[208px]";
|
||||||
}
|
}
|
||||||
|
@ -1,74 +1,90 @@
|
|||||||
import { useEffect, useRef, ReactElement } from "react";
|
import { useEffect, useRef, ReactElement } from "react";
|
||||||
import videojs from 'video.js';
|
import videojs from "video.js";
|
||||||
import 'videojs-playlist';
|
import "videojs-playlist";
|
||||||
import 'video.js/dist/video-js.css';
|
import "video.js/dist/video-js.css";
|
||||||
import Player from "video.js/dist/types/player";
|
import Player from "video.js/dist/types/player";
|
||||||
|
|
||||||
type VideoPlayerProps = {
|
type VideoPlayerProps = {
|
||||||
children?: ReactElement | ReactElement[],
|
children?: ReactElement | ReactElement[];
|
||||||
options?: {
|
options?: {
|
||||||
[key: string]: any
|
[key: string]: any;
|
||||||
},
|
};
|
||||||
seekOptions?: {
|
seekOptions?: {
|
||||||
forward?: number,
|
forward?: number;
|
||||||
backward?: number,
|
backward?: number;
|
||||||
},
|
};
|
||||||
onReady?: (player: Player) => void,
|
remotePlayback?: boolean;
|
||||||
onDispose?: () => void,
|
onReady?: (player: Player) => void;
|
||||||
}
|
onDispose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
export default function VideoPlayer({ children, options, seekOptions = {forward:30, backward: 10}, onReady = (_) => {}, onDispose = () => {} }: VideoPlayerProps) {
|
export default function VideoPlayer({
|
||||||
const videoRef = useRef<HTMLDivElement | null>(null);
|
children,
|
||||||
const playerRef = useRef<Player | null>(null);
|
options,
|
||||||
|
seekOptions = { forward: 30, backward: 10 },
|
||||||
|
remotePlayback = false,
|
||||||
|
onReady = (_) => {},
|
||||||
|
onDispose = () => {},
|
||||||
|
}: VideoPlayerProps) {
|
||||||
|
const videoRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const playerRef = useRef<Player | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
controls: true,
|
controls: true,
|
||||||
controlBar: {
|
controlBar: {
|
||||||
skipButtons: seekOptions,
|
skipButtons: seekOptions,
|
||||||
},
|
},
|
||||||
playbackRates: [0.5, 1, 2, 4, 8],
|
playbackRates: [0.5, 1, 2, 4, 8],
|
||||||
fluid: true,
|
fluid: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!videojs.browser.IS_FIREFOX) {
|
||||||
|
defaultOptions.playbackRates.push(16);
|
||||||
|
}
|
||||||
|
|
||||||
if (!videojs.browser.IS_FIREFOX) {
|
// Make sure Video.js player is only initialized once
|
||||||
defaultOptions.playbackRates.push(16);
|
if (!playerRef.current) {
|
||||||
}
|
// The Video.js player needs to be _inside_ the component el for React 18 Strict Mode.
|
||||||
|
const videoElement = document.createElement("video-js");
|
||||||
|
// @ts-ignore we know this is a video element
|
||||||
|
videoElement.controls = true;
|
||||||
|
// @ts-ignore
|
||||||
|
videoElement.playsInline = true;
|
||||||
|
// @ts-ignore
|
||||||
|
videoElement.disableRemotePlayback = remotePlayback;
|
||||||
|
videoElement.classList.add("small-player");
|
||||||
|
videoElement.classList.add("video-js");
|
||||||
|
videoElement.classList.add("vjs-default-skin");
|
||||||
|
videoRef.current?.appendChild(videoElement);
|
||||||
|
|
||||||
// Make sure Video.js player is only initialized once
|
const player = (playerRef.current = videojs(
|
||||||
if (!playerRef.current) {
|
videoElement,
|
||||||
// The Video.js player needs to be _inside_ the component el for React 18 Strict Mode.
|
{ ...defaultOptions, ...options },
|
||||||
const videoElement = document.createElement("video-js");
|
() => {
|
||||||
|
|
||||||
videoElement.classList.add('small-player');
|
|
||||||
videoElement.classList.add('video-js');
|
|
||||||
videoElement.classList.add('vjs-default-skin');
|
|
||||||
videoRef.current?.appendChild(videoElement);
|
|
||||||
|
|
||||||
const player = playerRef.current = videojs(videoElement, { ...defaultOptions, ...options }, () => {
|
|
||||||
onReady && onReady(player);
|
onReady && onReady(player);
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [options, videoRef]);
|
|
||||||
|
|
||||||
// Dispose the Video.js player when the functional component unmounts
|
|
||||||
useEffect(() => {
|
|
||||||
const player = playerRef.current;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (player && !player.isDisposed()) {
|
|
||||||
player.dispose();
|
|
||||||
playerRef.current = null;
|
|
||||||
onDispose();
|
|
||||||
}
|
}
|
||||||
};
|
));
|
||||||
}, [playerRef]);
|
}
|
||||||
|
}, [options, videoRef]);
|
||||||
|
|
||||||
return (
|
// Dispose the Video.js player when the functional component unmounts
|
||||||
<div data-vjs-player>
|
useEffect(() => {
|
||||||
<div ref={videoRef} />
|
const player = playerRef.current;
|
||||||
{children}
|
|
||||||
</div>
|
return () => {
|
||||||
);
|
if (player && !player.isDisposed()) {
|
||||||
}
|
player.dispose();
|
||||||
|
playerRef.current = null;
|
||||||
|
onDispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [playerRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-vjs-player>
|
||||||
|
<div ref={videoRef} />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { DayPicker } from "react-day-picker"
|
import { DayPicker } from "react-day-picker";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||||
|
|
||||||
function Calendar({
|
function Calendar({
|
||||||
className,
|
className,
|
||||||
@ -52,13 +52,15 @@ function Calendar({
|
|||||||
...classNames,
|
...classNames,
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
|
// @ts-ignore
|
||||||
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||||
|
// @ts-ignore
|
||||||
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
Calendar.displayName = "Calendar"
|
Calendar.displayName = "Calendar";
|
||||||
|
|
||||||
export { Calendar }
|
export { Calendar };
|
||||||
|
@ -7,8 +7,9 @@ import ActivityIndicator from "@/components/ui/activity-indicator";
|
|||||||
import HistoryCard from "@/components/card/HistoryCard";
|
import HistoryCard from "@/components/card/HistoryCard";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import TimelinePlayerCard from "@/components/card/TimelineCardPlayer";
|
||||||
|
|
||||||
const API_LIMIT = 100;
|
const API_LIMIT = 120;
|
||||||
|
|
||||||
function History() {
|
function History() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
@ -32,27 +33,26 @@ function History() {
|
|||||||
return ["timeline/hourly", { timezone, limit: API_LIMIT }];
|
return ["timeline/hourly", { timezone, limit: API_LIMIT }];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const shouldAutoPlay = useMemo(() => {
|
|
||||||
return window.innerWidth < 480;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: timelinePages,
|
data: timelinePages,
|
||||||
mutate,
|
|
||||||
size,
|
size,
|
||||||
setSize,
|
setSize,
|
||||||
isValidating,
|
isValidating,
|
||||||
} = useSWRInfinite<HourlyTimeline>(getKey, timelineFetcher);
|
} = useSWRInfinite<HourlyTimeline>(getKey, timelineFetcher);
|
||||||
const { data: allPreviews } = useSWR<Preview[]>(
|
const { data: allPreviews } = useSWR<Preview[]>(
|
||||||
`preview/all/start/${(timelinePages ?? [])?.at(0)?.start ?? 0}/end/${
|
timelinePages
|
||||||
(timelinePages ?? [])?.at(-1)?.end ?? 0
|
? `preview/all/start/${timelinePages?.at(0)
|
||||||
}`,
|
?.start}/end/${timelinePages?.at(-1)?.end}`
|
||||||
|
: null,
|
||||||
{ revalidateOnFocus: false }
|
{ revalidateOnFocus: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
const [detailLevel, setDetailLevel] = useState<"normal" | "extra" | "full">(
|
const [detailLevel, _] = useState<"normal" | "extra" | "full">("normal");
|
||||||
"normal"
|
const [playback, setPlayback] = useState<Card | undefined>();
|
||||||
);
|
|
||||||
|
const shouldAutoPlay = useMemo(() => {
|
||||||
|
return playback == undefined && window.innerWidth < 480;
|
||||||
|
}, [playback]);
|
||||||
|
|
||||||
const timelineCards: CardsData | never[] = useMemo(() => {
|
const timelineCards: CardsData | never[] = useMemo(() => {
|
||||||
if (!timelinePages) {
|
if (!timelinePages) {
|
||||||
@ -161,7 +161,7 @@ function History() {
|
|||||||
[size, setSize, isValidating, isDone]
|
[size, setSize, isValidating, isDone]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!config || !timelineCards ||timelineCards.length == 0) {
|
if (!config || !timelineCards || timelineCards.length == 0) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,6 +172,11 @@ function History() {
|
|||||||
Dates and times are based on the timezone {timezone}
|
Dates and times are based on the timezone {timezone}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TimelinePlayerCard
|
||||||
|
timeline={playback}
|
||||||
|
onDismiss={() => setPlayback(undefined)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{Object.entries(timelineCards)
|
{Object.entries(timelineCards)
|
||||||
.reverse()
|
.reverse()
|
||||||
@ -204,7 +209,7 @@ function History() {
|
|||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<div className="flex flex-wrap">
|
<div className="flex flex-wrap">
|
||||||
{Object.entries(timelineHour).map(
|
{Object.entries(timelineHour).reverse().map(
|
||||||
([key, timeline]) => {
|
([key, timeline]) => {
|
||||||
const startTs = Object.values(timeline.entries)[0]
|
const startTs = Object.values(timeline.entries)[0]
|
||||||
.timestamp;
|
.timestamp;
|
||||||
@ -225,6 +230,9 @@ function History() {
|
|||||||
timeline={timeline}
|
timeline={timeline}
|
||||||
shouldAutoPlay={shouldAutoPlay}
|
shouldAutoPlay={shouldAutoPlay}
|
||||||
relevantPreview={relevantPreview}
|
relevantPreview={relevantPreview}
|
||||||
|
onClick={() => {
|
||||||
|
setPlayback(timeline);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -71,7 +71,7 @@ function Storage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex bg-center snap-center text-center items-center">
|
<div className="flex items-center">
|
||||||
<CardTitle>Data</CardTitle>
|
<CardTitle>Data</CardTitle>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@ -137,7 +137,7 @@ function Storage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex bg-center snap-center text-center items-center">
|
<div className="flex items-center">
|
||||||
<CardTitle>Memory</CardTitle>
|
<CardTitle>Memory</CardTitle>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@ -187,7 +187,7 @@ function Storage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex bg-center snap-center text-center items-center my-4">
|
<div className="flex items-center my-4">
|
||||||
<Heading as="h4">Cameras</Heading>
|
<Heading as="h4">Cameras</Heading>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
export interface UiConfig {
|
export interface UiConfig {
|
||||||
timezone: string;
|
timezone?: string;
|
||||||
time_format: "browser" | "12hour" | "24hour";
|
time_format?: 'browser' | '12hour' | '24hour';
|
||||||
date_style: "full" | "long" | "medium" | "short";
|
date_style?: 'full' | 'long' | 'medium' | 'short';
|
||||||
time_style: "full" | "long" | "medium" | "short";
|
time_style?: 'full' | 'long' | 'medium' | 'short';
|
||||||
strftime_fmt: string;
|
strftime_fmt?: string;
|
||||||
live_mode: string;
|
live_mode?: string;
|
||||||
use_experimental: boolean;
|
use_experimental?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CameraConfig {
|
export interface CameraConfig {
|
||||||
|
11
web-new/src/types/record.ts
Normal file
11
web-new/src/types/record.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
type Recording = {
|
||||||
|
id: string,
|
||||||
|
camera: string,
|
||||||
|
start_time: number,
|
||||||
|
end_time: number,
|
||||||
|
path: string,
|
||||||
|
segment_size: number,
|
||||||
|
motion: number,
|
||||||
|
objects: number,
|
||||||
|
dBFS: number,
|
||||||
|
}
|
@ -143,8 +143,8 @@ export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiC
|
|||||||
// fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
|
// fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
|
||||||
// This works even tough the timezone is undefined, it will use the runtime's default time zone
|
// This works even tough the timezone is undefined, it will use the runtime's default time zone
|
||||||
if (!containsTime) {
|
if (!containsTime) {
|
||||||
const dateOptions = { ...formatMap[date_style]?.date, timeZone: options.timeZone, hour12: options.hour12 };
|
const dateOptions = { ...formatMap[date_style ?? ""]?.date, timeZone: options.timeZone, hour12: options.hour12 };
|
||||||
const timeOptions = { ...formatMap[time_style]?.time, timeZone: options.timeZone, hour12: options.hour12 };
|
const timeOptions = { ...formatMap[time_style ?? ""]?.time, timeZone: options.timeZone, hour12: options.hour12 };
|
||||||
|
|
||||||
return `${date.toLocaleDateString(locale, dateOptions)} ${date.toLocaleTimeString(locale, timeOptions)}`;
|
return `${date.toLocaleDateString(locale, dateOptions)} ${date.toLocaleTimeString(locale, timeOptions)}`;
|
||||||
}
|
}
|
||||||
|
80
web-new/src/utils/timelineUtil.tsx
Normal file
80
web-new/src/utils/timelineUtil.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { LuCircle, LuPlay, LuPlayCircle, LuTruck } from "react-icons/lu";
|
||||||
|
import { IoMdExit } from "react-icons/io";
|
||||||
|
import {
|
||||||
|
MdFaceUnlock,
|
||||||
|
MdOutlineLocationOn,
|
||||||
|
MdOutlinePictureInPictureAlt,
|
||||||
|
} from "react-icons/md";
|
||||||
|
|
||||||
|
export function getTimelineIcon(timelineItem: Timeline) {
|
||||||
|
switch (timelineItem.class_type) {
|
||||||
|
case "visible":
|
||||||
|
return <LuPlay className="w-4 mr-1" />;
|
||||||
|
case "gone":
|
||||||
|
return <IoMdExit className="w-4 mr-1" />;
|
||||||
|
case "active":
|
||||||
|
return <LuPlayCircle className="w-4 mr-1" />;
|
||||||
|
case "stationary":
|
||||||
|
return <LuCircle className="w-4 mr-1" />;
|
||||||
|
case "entered_zone":
|
||||||
|
return <MdOutlineLocationOn className="w-4 mr-1" />;
|
||||||
|
case "attribute":
|
||||||
|
switch (timelineItem.data.attribute) {
|
||||||
|
case "face":
|
||||||
|
return <MdFaceUnlock className="w-4 mr-1" />;
|
||||||
|
case "license_plate":
|
||||||
|
return <MdOutlinePictureInPictureAlt className="w-4 mr-1" />;
|
||||||
|
default:
|
||||||
|
return <LuTruck className="w-4 mr-1" />;
|
||||||
|
}
|
||||||
|
case "sub_label":
|
||||||
|
switch (timelineItem.data.label) {
|
||||||
|
case "person":
|
||||||
|
return <MdFaceUnlock className="w-4 mr-1" />;
|
||||||
|
case "car":
|
||||||
|
return <MdOutlinePictureInPictureAlt className="w-4 mr-1" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimelineItemDescription(timelineItem: Timeline) {
|
||||||
|
const label = (
|
||||||
|
(Array.isArray(timelineItem.data.sub_label)
|
||||||
|
? timelineItem.data.sub_label[0]
|
||||||
|
: timelineItem.data.sub_label) || timelineItem.data.label
|
||||||
|
).replaceAll("_", " ");
|
||||||
|
|
||||||
|
switch (timelineItem.class_type) {
|
||||||
|
case "visible":
|
||||||
|
return `${label} detected`;
|
||||||
|
case "entered_zone":
|
||||||
|
return `${label} entered ${timelineItem.data.zones
|
||||||
|
.join(" and ")
|
||||||
|
.replaceAll("_", " ")}`;
|
||||||
|
case "active":
|
||||||
|
return `${label} became active`;
|
||||||
|
case "stationary":
|
||||||
|
return `${label} became stationary`;
|
||||||
|
case "attribute": {
|
||||||
|
let title = "";
|
||||||
|
if (
|
||||||
|
timelineItem.data.attribute == "face" ||
|
||||||
|
timelineItem.data.attribute == "license_plate"
|
||||||
|
) {
|
||||||
|
title = `${timelineItem.data.attribute.replaceAll(
|
||||||
|
"_",
|
||||||
|
" "
|
||||||
|
)} detected for ${label}`;
|
||||||
|
} else {
|
||||||
|
title = `${
|
||||||
|
timelineItem.data.sub_label
|
||||||
|
} recognized as ${timelineItem.data.attribute.replaceAll("_", " ")}`;
|
||||||
|
}
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
case "sub_label":
|
||||||
|
return `${timelineItem.data.label} recognized as ${timelineItem.data.sub_label}`;
|
||||||
|
case "gone":
|
||||||
|
return `${label} left`;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user