mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-25 02:10:23 -06:00
Improve review grid (#8953)
* Use constant aspect ratio for review grid * Add infinite scrolling * Don't have horizontal scrolling * Handle autoplay for mobile * Load more efficiently
This commit is contained in:
parent
1961a0afc1
commit
fbe58652d5
@ -21,12 +21,14 @@ import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
|||||||
|
|
||||||
type HistoryCardProps = {
|
type HistoryCardProps = {
|
||||||
timeline: Card;
|
timeline: Card;
|
||||||
allPreviews?: Preview[];
|
relevantPreview?: Preview;
|
||||||
|
shouldAutoPlay: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function HistoryCard({
|
export default function HistoryCard({
|
||||||
allPreviews,
|
relevantPreview,
|
||||||
timeline,
|
timeline,
|
||||||
|
shouldAutoPlay,
|
||||||
}: HistoryCardProps) {
|
}: HistoryCardProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
@ -35,11 +37,13 @@ export default function HistoryCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="my-2 mr-2 bg-secondary">
|
<Card className="my-2 xs:mr-2 bg-secondary w-full xs:w-[48%] sm:w-[284px]">
|
||||||
<PreviewThumbnailPlayer
|
<PreviewThumbnailPlayer
|
||||||
camera={timeline.camera}
|
camera={timeline.camera}
|
||||||
allPreviews={allPreviews || []}
|
relevantPreview={relevantPreview}
|
||||||
startTs={Object.values(timeline.entries)[0].timestamp}
|
startTs={Object.values(timeline.entries)[0].timestamp}
|
||||||
|
eventId={Object.values(timeline.entries)[0].source_id}
|
||||||
|
shouldAutoPlay={shouldAutoPlay}
|
||||||
/>
|
/>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<div className="text-sm flex">
|
<div className="text-sm flex">
|
||||||
|
@ -1,65 +1,122 @@
|
|||||||
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, useMemo, useRef } from "react";
|
import { useCallback, useRef } 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";
|
||||||
|
|
||||||
type PreviewPlayerProps = {
|
type PreviewPlayerProps = {
|
||||||
camera: string,
|
camera: string;
|
||||||
allPreviews: Preview[],
|
relevantPreview?: Preview;
|
||||||
startTs: number,
|
startTs: number;
|
||||||
}
|
eventId: string;
|
||||||
|
shouldAutoPlay: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type Preview = {
|
type Preview = {
|
||||||
camera: string,
|
camera: string;
|
||||||
src: string,
|
src: string;
|
||||||
type: string,
|
type: string;
|
||||||
start: number,
|
start: number;
|
||||||
end: number,
|
end: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function PreviewThumbnailPlayer({ camera, allPreviews, startTs }: PreviewPlayerProps) {
|
export default function PreviewThumbnailPlayer({
|
||||||
const { data: config } = useSWR('config');
|
camera,
|
||||||
const playerRef = useRef<Player | null>(null);
|
relevantPreview,
|
||||||
const apiHost = useApiHost();
|
startTs,
|
||||||
|
eventId,
|
||||||
|
shouldAutoPlay,
|
||||||
|
}: PreviewPlayerProps) {
|
||||||
|
const { data: config } = useSWR("config");
|
||||||
|
const playerRef = useRef<Player | null>(null);
|
||||||
|
const apiHost = useApiHost();
|
||||||
|
|
||||||
const relevantPreview = useMemo(() => {
|
const onPlayback = useCallback(
|
||||||
return Object.values(allPreviews || []).find(
|
(isHovered: Boolean) => {
|
||||||
(preview) => preview.camera == camera && preview.start < startTs && preview.end > startTs
|
if (!relevantPreview || !playerRef.current) {
|
||||||
);
|
return;
|
||||||
}, [allPreviews, camera, startTs]);
|
}
|
||||||
|
|
||||||
const onHover = useCallback((isHovered: Boolean) => {
|
if (isHovered) {
|
||||||
if (!relevantPreview || !playerRef.current) {
|
playerRef.current.play();
|
||||||
return;
|
} else {
|
||||||
}
|
playerRef.current.pause();
|
||||||
|
playerRef.current.currentTime(startTs - relevantPreview.start);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[relevantPreview, startTs]
|
||||||
|
);
|
||||||
|
|
||||||
if (isHovered) {
|
const observer = useRef<IntersectionObserver | null>();
|
||||||
playerRef.current.play();
|
const inViewRef = useCallback(
|
||||||
} else {
|
(node: HTMLElement | null) => {
|
||||||
playerRef.current.pause();
|
if (!shouldAutoPlay || observer.current) {
|
||||||
playerRef.current.currentTime(startTs - relevantPreview.start);
|
return;
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[relevantPreview, startTs]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!relevantPreview) {
|
try {
|
||||||
|
observer.current = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
|
onPlayback(true);
|
||||||
|
} else {
|
||||||
|
onPlayback(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 1.0 }
|
||||||
|
);
|
||||||
|
if (node) observer.current.observe(node);
|
||||||
|
} catch (e) {
|
||||||
|
// no op
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[observer, onPlayback]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relevantPreview) {
|
||||||
|
if (isCurrentHour(startTs)) {
|
||||||
return (
|
return (
|
||||||
<img className={getThumbWidth(camera, config)} src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`} />
|
<AspectRatio
|
||||||
|
ratio={16 / 9}
|
||||||
|
className="bg-black flex justify-center items-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className={`${getPreviewWidth(camera, config)}`}
|
||||||
|
loading="lazy"
|
||||||
|
src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`}
|
||||||
|
/>
|
||||||
|
</AspectRatio>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<AspectRatio
|
||||||
className={getThumbWidth(camera, config)}
|
ratio={16 / 9}
|
||||||
onMouseEnter={() => onHover(true)}
|
className="bg-black flex justify-center items-center"
|
||||||
onMouseLeave={() => onHover(false)}
|
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
className="w-[160px]"
|
||||||
|
loading="lazy"
|
||||||
|
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
|
||||||
|
/>
|
||||||
|
</AspectRatio>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}`}>
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
options={{
|
options={{
|
||||||
preload: 'auto',
|
preload: "auto",
|
||||||
autoplay: false,
|
autoplay: false,
|
||||||
controls: false,
|
controls: false,
|
||||||
muted: true,
|
muted: true,
|
||||||
@ -67,7 +124,7 @@ export default function PreviewThumbnailPlayer({ camera, allPreviews, startTs }:
|
|||||||
sources: [
|
sources: [
|
||||||
{
|
{
|
||||||
src: `${relevantPreview.src}`,
|
src: `${relevantPreview.src}`,
|
||||||
type: 'video/mp4',
|
type: "video/mp4",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
@ -82,18 +139,22 @@ export default function PreviewThumbnailPlayer({ camera, allPreviews, startTs }:
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
</AspectRatio>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentHour(timestamp: number) {
|
||||||
|
const now = new Date();
|
||||||
|
now.setMinutes(0, 0, 0);
|
||||||
|
return timestamp > now.getTime() / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewWidth(camera: string, config: FrigateConfig) {
|
||||||
|
const detect = config.cameras[camera].detect;
|
||||||
|
|
||||||
|
if (detect.width / detect.height < 1.4) {
|
||||||
|
return "w-[208px]";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getThumbWidth(camera: string, config: FrigateConfig) {
|
return "w-full";
|
||||||
const detect = config.cameras[camera].detect;
|
|
||||||
if (detect.width / detect.height > 2) {
|
|
||||||
return 'w-[320px]';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detect.width / detect.height < 1.4) {
|
|
||||||
return 'w-[200px]';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'w-[240px]';
|
|
||||||
}
|
|
@ -44,7 +44,7 @@ export default function VideoPlayer({ children, options, seekOptions = {forward:
|
|||||||
videoElement.classList.add('small-player');
|
videoElement.classList.add('small-player');
|
||||||
videoElement.classList.add('video-js');
|
videoElement.classList.add('video-js');
|
||||||
videoElement.classList.add('vjs-default-skin');
|
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);
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import useSWRInfinite from "swr/infinite";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import ActivityIndicator from "@/components/ui/activity-indicator";
|
import ActivityIndicator from "@/components/ui/activity-indicator";
|
||||||
import HistoryCard from "@/components/card/HistoryCard";
|
import HistoryCard from "@/components/card/HistoryCard";
|
||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const API_LIMIT = 100;
|
||||||
|
|
||||||
function History() {
|
function History() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
@ -14,13 +17,35 @@ function History() {
|
|||||||
config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
[config]
|
[config]
|
||||||
);
|
);
|
||||||
const { data: hourlyTimeline } = useSWR<HourlyTimeline>([
|
const timelineFetcher = useCallback((key: any) => {
|
||||||
"timeline/hourly",
|
const [path, params] = Array.isArray(key) ? key : [key, undefined];
|
||||||
{ timezone },
|
return axios.get(path, { params }).then((res) => res.data);
|
||||||
]);
|
}, []);
|
||||||
|
|
||||||
|
const getKey = useCallback((index: number, prevData: HourlyTimeline) => {
|
||||||
|
if (index > 0) {
|
||||||
|
const lastDate = prevData.end;
|
||||||
|
const pagedParams = { before: lastDate, timezone, limit: API_LIMIT };
|
||||||
|
return ["timeline/hourly", pagedParams];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["timeline/hourly", { timezone, limit: API_LIMIT }];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const shouldAutoPlay = useMemo(() => {
|
||||||
|
return window.innerWidth < 480;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: timelinePages,
|
||||||
|
mutate,
|
||||||
|
size,
|
||||||
|
setSize,
|
||||||
|
isValidating,
|
||||||
|
} = useSWRInfinite<HourlyTimeline>(getKey, timelineFetcher);
|
||||||
const { data: allPreviews } = useSWR<Preview[]>(
|
const { data: allPreviews } = useSWR<Preview[]>(
|
||||||
`preview/all/start/${hourlyTimeline?.start || 0}/end/${
|
`preview/all/start/${(timelinePages ?? [])?.at(0)?.start ?? 0}/end/${
|
||||||
hourlyTimeline?.end || 0
|
(timelinePages ?? [])?.at(-1)?.end ?? 0
|
||||||
}`,
|
}`,
|
||||||
{ revalidateOnFocus: false }
|
{ revalidateOnFocus: false }
|
||||||
);
|
);
|
||||||
@ -30,84 +55,113 @@ function History() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const timelineCards: CardsData | never[] = useMemo(() => {
|
const timelineCards: CardsData | never[] = useMemo(() => {
|
||||||
if (!hourlyTimeline) {
|
if (!timelinePages) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const cards: CardsData = {};
|
const cards: CardsData = {};
|
||||||
Object.keys(hourlyTimeline["hours"])
|
timelinePages.forEach((hourlyTimeline) => {
|
||||||
.reverse()
|
Object.keys(hourlyTimeline["hours"])
|
||||||
.forEach((hour) => {
|
.reverse()
|
||||||
const day = new Date(parseInt(hour) * 1000);
|
.forEach((hour) => {
|
||||||
day.setHours(0, 0, 0, 0);
|
const day = new Date(parseInt(hour) * 1000);
|
||||||
const dayKey = (day.getTime() / 1000).toString();
|
day.setHours(0, 0, 0, 0);
|
||||||
const source_to_types: { [key: string]: string[] } = {};
|
const dayKey = (day.getTime() / 1000).toString();
|
||||||
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
|
const source_to_types: { [key: string]: string[] } = {};
|
||||||
const time = new Date(i.timestamp * 1000);
|
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
|
||||||
time.setSeconds(0);
|
const time = new Date(i.timestamp * 1000);
|
||||||
time.setMilliseconds(0);
|
time.setSeconds(0);
|
||||||
const key = `${i.source_id}-${time.getMinutes()}`;
|
time.setMilliseconds(0);
|
||||||
if (key in source_to_types) {
|
const key = `${i.source_id}-${time.getMinutes()}`;
|
||||||
source_to_types[key].push(i.class_type);
|
if (key in source_to_types) {
|
||||||
} else {
|
source_to_types[key].push(i.class_type);
|
||||||
source_to_types[key] = [i.class_type];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!Object.keys(cards).includes(dayKey)) {
|
|
||||||
cards[dayKey] = {};
|
|
||||||
}
|
|
||||||
cards[dayKey][hour] = {};
|
|
||||||
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
|
|
||||||
const time = new Date(i.timestamp * 1000);
|
|
||||||
const key = `${i.camera}-${time.getMinutes()}`;
|
|
||||||
|
|
||||||
// detail level for saving items
|
|
||||||
// detail level determines which timeline items for each moment is returned
|
|
||||||
// values can be normal, extra, or full
|
|
||||||
// normal: return all items except active / attribute / gone / stationary / visible unless that is the only item.
|
|
||||||
// extra: return all items except attribute / gone / visible unless that is the only item
|
|
||||||
// full: return all items
|
|
||||||
|
|
||||||
let add = true;
|
|
||||||
if (detailLevel == "normal") {
|
|
||||||
if (
|
|
||||||
source_to_types[`${i.source_id}-${time.getMinutes()}`].length >
|
|
||||||
1 &&
|
|
||||||
["active", "attribute", "gone", "stationary", "visible"].includes(
|
|
||||||
i.class_type
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
add = false;
|
|
||||||
}
|
|
||||||
} else if (detailLevel == "extra") {
|
|
||||||
if (
|
|
||||||
source_to_types[`${i.source_id}-${time.getMinutes()}`].length >
|
|
||||||
1 &&
|
|
||||||
i.class_type in ["attribute", "gone", "visible"]
|
|
||||||
) {
|
|
||||||
add = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (add) {
|
|
||||||
if (key in cards[dayKey][hour]) {
|
|
||||||
cards[dayKey][hour][key].entries.push(i);
|
|
||||||
} else {
|
} else {
|
||||||
cards[dayKey][hour][key] = {
|
source_to_types[key] = [i.class_type];
|
||||||
camera: i.camera,
|
|
||||||
time: time.getTime() / 1000,
|
|
||||||
entries: [i],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!Object.keys(cards).includes(dayKey)) {
|
||||||
|
cards[dayKey] = {};
|
||||||
}
|
}
|
||||||
|
cards[dayKey][hour] = {};
|
||||||
|
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
|
||||||
|
const time = new Date(i.timestamp * 1000);
|
||||||
|
const key = `${i.camera}-${time.getMinutes()}`;
|
||||||
|
|
||||||
|
// detail level for saving items
|
||||||
|
// detail level determines which timeline items for each moment is returned
|
||||||
|
// values can be normal, extra, or full
|
||||||
|
// normal: return all items except active / attribute / gone / stationary / visible unless that is the only item.
|
||||||
|
// extra: return all items except attribute / gone / visible unless that is the only item
|
||||||
|
// full: return all items
|
||||||
|
|
||||||
|
let add = true;
|
||||||
|
if (detailLevel == "normal") {
|
||||||
|
if (
|
||||||
|
source_to_types[`${i.source_id}-${time.getMinutes()}`].length >
|
||||||
|
1 &&
|
||||||
|
[
|
||||||
|
"active",
|
||||||
|
"attribute",
|
||||||
|
"gone",
|
||||||
|
"stationary",
|
||||||
|
"visible",
|
||||||
|
].includes(i.class_type)
|
||||||
|
) {
|
||||||
|
add = false;
|
||||||
|
}
|
||||||
|
} else if (detailLevel == "extra") {
|
||||||
|
if (
|
||||||
|
source_to_types[`${i.source_id}-${time.getMinutes()}`].length >
|
||||||
|
1 &&
|
||||||
|
i.class_type in ["attribute", "gone", "visible"]
|
||||||
|
) {
|
||||||
|
add = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (add) {
|
||||||
|
if (key in cards[dayKey][hour]) {
|
||||||
|
cards[dayKey][hour][key].entries.push(i);
|
||||||
|
} else {
|
||||||
|
cards[dayKey][hour][key] = {
|
||||||
|
camera: i.camera,
|
||||||
|
time: time.getTime() / 1000,
|
||||||
|
entries: [i],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return cards;
|
return cards;
|
||||||
}, [detailLevel, hourlyTimeline]);
|
}, [detailLevel, timelinePages]);
|
||||||
|
|
||||||
if (!config || !timelineCards) {
|
const isDone =
|
||||||
|
(timelinePages?.[timelinePages.length - 1]?.count ?? 0) < API_LIMIT;
|
||||||
|
|
||||||
|
// hooks for infinite scroll
|
||||||
|
const observer = useRef<IntersectionObserver | null>();
|
||||||
|
const lastTimelineRef = useCallback(
|
||||||
|
(node: HTMLElement | null) => {
|
||||||
|
if (isValidating) return;
|
||||||
|
if (observer.current) observer.current.disconnect();
|
||||||
|
try {
|
||||||
|
observer.current = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting && !isDone) {
|
||||||
|
setSize(size + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (node) observer.current.observe(node);
|
||||||
|
} catch (e) {
|
||||||
|
// no op
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[size, setSize, isValidating, isDone]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!config || !timelineCards ||timelineCards.length == 0) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,7 +175,7 @@ function History() {
|
|||||||
<div>
|
<div>
|
||||||
{Object.entries(timelineCards)
|
{Object.entries(timelineCards)
|
||||||
.reverse()
|
.reverse()
|
||||||
.map(([day, timelineDay]) => {
|
.map(([day, timelineDay], dayIdx) => {
|
||||||
return (
|
return (
|
||||||
<div key={day}>
|
<div key={day}>
|
||||||
<Heading as="h3">
|
<Heading as="h3">
|
||||||
@ -129,37 +183,58 @@ function History() {
|
|||||||
strftime_fmt: "%A %b %d",
|
strftime_fmt: "%A %b %d",
|
||||||
})}
|
})}
|
||||||
</Heading>
|
</Heading>
|
||||||
{Object.entries(timelineDay).map(([hour, timelineHour]) => {
|
{Object.entries(timelineDay).map(
|
||||||
if (Object.values(timelineHour).length == 0) {
|
([hour, timelineHour], hourIdx) => {
|
||||||
return <></>;
|
if (Object.values(timelineHour).length == 0) {
|
||||||
}
|
return <div key={hour}></div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const lastRow =
|
||||||
<div key={hour}>
|
dayIdx == Object.values(timelineCards).length - 1 &&
|
||||||
<Heading as="h4">
|
hourIdx == Object.values(timelineDay).length - 1;
|
||||||
{formatUnixTimestampToDateTime(parseInt(hour), {
|
const previewMap: { [key: string]: Preview | undefined } =
|
||||||
strftime_fmt: "%I:00",
|
{};
|
||||||
})}
|
|
||||||
</Heading>
|
return (
|
||||||
<ScrollArea>
|
<div key={hour} ref={lastRow ? lastTimelineRef : null}>
|
||||||
<div className="flex">
|
<Heading as="h4">
|
||||||
|
{formatUnixTimestampToDateTime(parseInt(hour), {
|
||||||
|
strftime_fmt: "%I:00",
|
||||||
|
})}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap">
|
||||||
{Object.entries(timelineHour).map(
|
{Object.entries(timelineHour).map(
|
||||||
([key, timeline]) => {
|
([key, timeline]) => {
|
||||||
|
const startTs = Object.values(timeline.entries)[0]
|
||||||
|
.timestamp;
|
||||||
|
let relevantPreview = previewMap[timeline.camera];
|
||||||
|
|
||||||
|
if (relevantPreview == undefined) {
|
||||||
|
relevantPreview = previewMap[timeline.camera] =
|
||||||
|
Object.values(allPreviews || []).find(
|
||||||
|
(preview) =>
|
||||||
|
preview.camera == timeline.camera &&
|
||||||
|
preview.start < startTs &&
|
||||||
|
preview.end > startTs
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<HistoryCard
|
<HistoryCard
|
||||||
key={key}
|
key={key}
|
||||||
timeline={timeline}
|
timeline={timeline}
|
||||||
allPreviews={allPreviews}
|
shouldAutoPlay={shouldAutoPlay}
|
||||||
|
relevantPreview={relevantPreview}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ScrollBar className="m-2" orientation="horizontal" />
|
{lastRow && <ActivityIndicator />}
|
||||||
</ScrollArea>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
})}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -105,7 +105,7 @@ const getResolvedTimeZone = () => {
|
|||||||
*/
|
*/
|
||||||
export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiConfig): string => {
|
export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiConfig): string => {
|
||||||
const { timezone, time_format, date_style, time_style, strftime_fmt } = config;
|
const { timezone, time_format, date_style, time_style, strftime_fmt } = config;
|
||||||
const locale = window.navigator?.language || 'en-us';
|
const locale = window.navigator?.language || 'en-US';
|
||||||
if (isNaN(unixTimestamp)) {
|
if (isNaN(unixTimestamp)) {
|
||||||
return 'Invalid time';
|
return 'Invalid time';
|
||||||
}
|
}
|
||||||
@ -117,7 +117,7 @@ export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiC
|
|||||||
// use strftime_fmt if defined in config
|
// use strftime_fmt if defined in config
|
||||||
if (strftime_fmt) {
|
if (strftime_fmt) {
|
||||||
const offset = getUTCOffset(date, timezone || resolvedTimeZone);
|
const offset = getUTCOffset(date, timezone || resolvedTimeZone);
|
||||||
const strftime_locale = strftime.timezone(offset).localizeByIdentifier(locale);
|
const strftime_locale = strftime.timezone(offset);
|
||||||
return strftime_locale(strftime_fmt, date);
|
return strftime_locale(strftime_fmt, date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,6 +70,10 @@ module.exports = {
|
|||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
},
|
},
|
||||||
|
screens: {
|
||||||
|
"xs": "480px",
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
@ -12,24 +12,24 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:5000',
|
target: 'http://192.168.50.106:5000',
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
'/vod': {
|
'/vod': {
|
||||||
target: 'http://localhost:5000'
|
target: 'http://192.168.50.106:5000'
|
||||||
},
|
},
|
||||||
'/clips': {
|
'/clips': {
|
||||||
target: 'http://localhost:5000'
|
target: 'http://192.168.50.106:5000'
|
||||||
},
|
},
|
||||||
'/exports': {
|
'/exports': {
|
||||||
target: 'http://localhost:5000'
|
target: 'http://192.168.50.106:5000'
|
||||||
},
|
},
|
||||||
'/ws': {
|
'/ws': {
|
||||||
target: 'ws://localhost:5000',
|
target: 'ws://192.168.50.106:5000',
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
'/live': {
|
'/live': {
|
||||||
target: 'ws://localhost:5000',
|
target: 'ws://192.168.50.106:5000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user