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:
Nicolas Mowen 2023-12-16 04:06:02 -07:00 committed by Blake Blackshear
parent e3387de48f
commit 3a33090984
13 changed files with 643 additions and 238 deletions

View File

@ -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 />} />

View File

@ -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`;
}
}

View 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>
);
}

View 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>
)}
</>
);
}

View File

@ -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,20 +48,38 @@ 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
}
} }
if (shouldAutoPlay && !autoPlayObserver.current) {
try { try {
observer.current = new IntersectionObserver( autoPlayObserver.current = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].isIntersecting) { const [{ isIntersecting }] = entries;
if (isIntersecting) {
onPlayback(true); onPlayback(true);
} else { } else {
onPlayback(false); onPlayback(false);
@ -67,52 +87,34 @@ export default function PreviewThumbnailPlayer({
}, },
{ threshold: 1.0 } { threshold: 1.0 }
); );
if (node) observer.current.observe(node); if (node) autoPlayObserver.current.observe(node);
} catch (e) { } catch (e) {
// no op // no op
} }
}
}, },
[observer, onPlayback] [preloadObserver, autoPlayObserver, onPlayback]
); );
if (!relevantPreview) { let content;
if (!relevantPreview || !visible) {
if (isCurrentHour(startTs)) { if (isCurrentHour(startTs)) {
return ( content = (
<AspectRatio
ratio={16 / 9}
className="bg-black flex justify-center items-center"
>
<img <img
className={`${getPreviewWidth(camera, config)}`} className={`${getPreviewWidth(camera, config)}`}
loading="lazy"
src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`} src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`}
/> />
</AspectRatio>
); );
} }
return ( content = (
<AspectRatio
ratio={16 / 9}
className="bg-black flex justify-center items-center"
>
<img <img
className="w-[160px]" className="w-[160px]"
loading="lazy"
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`} 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]";
} }

View File

@ -1,23 +1,31 @@
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({
children,
options,
seekOptions = { forward: 30, backward: 10 },
remotePlayback = false,
onReady = (_) => {},
onDispose = () => {},
}: VideoPlayerProps) {
const videoRef = useRef<HTMLDivElement | null>(null); const videoRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<Player | null>(null); const playerRef = useRef<Player | null>(null);
@ -31,7 +39,6 @@ export default function VideoPlayer({ children, options, seekOptions = {forward:
fluid: true, fluid: true,
}; };
if (!videojs.browser.IS_FIREFOX) { if (!videojs.browser.IS_FIREFOX) {
defaultOptions.playbackRates.push(16); defaultOptions.playbackRates.push(16);
} }
@ -40,15 +47,24 @@ export default function VideoPlayer({ children, options, seekOptions = {forward:
if (!playerRef.current) { if (!playerRef.current) {
// The Video.js player needs to be _inside_ the component el for React 18 Strict Mode. // The Video.js player needs to be _inside_ the component el for React 18 Strict Mode.
const videoElement = document.createElement("video-js"); const videoElement = document.createElement("video-js");
// @ts-ignore we know this is a video element
videoElement.classList.add('small-player'); videoElement.controls = true;
videoElement.classList.add('video-js'); // @ts-ignore
videoElement.classList.add('vjs-default-skin'); 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); videoRef.current?.appendChild(videoElement);
const player = playerRef.current = videojs(videoElement, { ...defaultOptions, ...options }, () => { const player = (playerRef.current = videojs(
videoElement,
{ ...defaultOptions, ...options },
() => {
onReady && onReady(player); onReady && onReady(player);
}); }
));
} }
}, [options, videoRef]); }, [options, videoRef]);
@ -71,4 +87,4 @@ export default function VideoPlayer({ children, options, seekOptions = {forward:
{children} {children}
</div> </div>
); );
} }

View File

@ -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 };

View File

@ -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);
}}
/> />
); );
} }

View File

@ -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>

View File

@ -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 {

View 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,
}

View File

@ -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)}`;
} }

View 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`;
}
}