mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-07 03:35:26 +03:00
Add infinite scrolling
This commit is contained in:
parent
8278d9abd8
commit
38fbe84159
@ -21,11 +21,11 @@ import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
|
||||
type HistoryCardProps = {
|
||||
timeline: Card;
|
||||
allPreviews?: Preview[];
|
||||
relevantPreview?: Preview;
|
||||
};
|
||||
|
||||
export default function HistoryCard({
|
||||
allPreviews,
|
||||
relevantPreview,
|
||||
timeline,
|
||||
}: HistoryCardProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@ -38,7 +38,7 @@ export default function HistoryCard({
|
||||
<Card className="my-2 mr-2 bg-secondary w-[284px]">
|
||||
<PreviewThumbnailPlayer
|
||||
camera={timeline.camera}
|
||||
allPreviews={allPreviews || []}
|
||||
relevantPreview={relevantPreview}
|
||||
startTs={Object.values(timeline.entries)[0].timestamp}
|
||||
/>
|
||||
<div className="p-2">
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import VideoPlayer from "./VideoPlayer";
|
||||
import useSWR from "swr";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useApiHost } from "@/api";
|
||||
import Player from "video.js/dist/types/player";
|
||||
import { AspectRatio } from "../ui/aspect-ratio";
|
||||
|
||||
type PreviewPlayerProps = {
|
||||
camera: string;
|
||||
allPreviews: Preview[];
|
||||
relevantPreview?: Preview;
|
||||
startTs: number;
|
||||
};
|
||||
|
||||
@ -22,22 +22,13 @@ type Preview = {
|
||||
|
||||
export default function PreviewThumbnailPlayer({
|
||||
camera,
|
||||
allPreviews,
|
||||
relevantPreview,
|
||||
startTs,
|
||||
}: PreviewPlayerProps) {
|
||||
const { data: config } = useSWR("config");
|
||||
const playerRef = useRef<Player | null>(null);
|
||||
const apiHost = useApiHost();
|
||||
|
||||
const relevantPreview = useMemo(() => {
|
||||
return Object.values(allPreviews || []).find(
|
||||
(preview) =>
|
||||
preview.camera == camera &&
|
||||
preview.start < startTs &&
|
||||
preview.end > startTs
|
||||
);
|
||||
}, [allPreviews, camera, startTs]);
|
||||
|
||||
const onHover = useCallback(
|
||||
(isHovered: Boolean) => {
|
||||
if (!relevantPreview || !playerRef.current) {
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import ActivityIndicator from "@/components/ui/activity-indicator";
|
||||
import HistoryCard from "@/components/card/HistoryCard";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import axios from "axios";
|
||||
|
||||
const API_LIMIT = 200;
|
||||
|
||||
function History() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@ -14,13 +18,31 @@ function History() {
|
||||
config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
[config]
|
||||
);
|
||||
const { data: hourlyTimeline } = useSWR<HourlyTimeline>([
|
||||
"timeline/hourly",
|
||||
{ timezone },
|
||||
]);
|
||||
const timelineFetcher = useCallback((key: any) => {
|
||||
const [path, params] = Array.isArray(key) ? key : [key, undefined];
|
||||
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 };
|
||||
return ["timeline/hourly", pagedParams];
|
||||
}
|
||||
|
||||
return ["timeline/hourly", { timezone }];
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: timelinePages,
|
||||
mutate,
|
||||
size,
|
||||
setSize,
|
||||
isValidating,
|
||||
} = useSWRInfinite<HourlyTimeline>(getKey, timelineFetcher);
|
||||
const { data: allPreviews } = useSWR<Preview[]>(
|
||||
`preview/all/start/${hourlyTimeline?.start || 0}/end/${
|
||||
hourlyTimeline?.end || 0
|
||||
`preview/all/start/${(timelinePages ?? [])?.at(0)?.start ?? 0}/end/${
|
||||
(timelinePages ?? [])?.at(-1)?.end ?? 0
|
||||
}`,
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
@ -30,82 +52,111 @@ function History() {
|
||||
);
|
||||
|
||||
const timelineCards: CardsData | never[] = useMemo(() => {
|
||||
if (!hourlyTimeline) {
|
||||
if (!timelinePages) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cards: CardsData = {};
|
||||
Object.keys(hourlyTimeline["hours"])
|
||||
.reverse()
|
||||
.forEach((hour) => {
|
||||
const day = new Date(parseInt(hour) * 1000);
|
||||
day.setHours(0, 0, 0, 0);
|
||||
const dayKey = (day.getTime() / 1000).toString();
|
||||
const source_to_types: { [key: string]: string[] } = {};
|
||||
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
|
||||
const time = new Date(i.timestamp * 1000);
|
||||
time.setSeconds(0);
|
||||
time.setMilliseconds(0);
|
||||
const key = `${i.source_id}-${time.getMinutes()}`;
|
||||
if (key in source_to_types) {
|
||||
source_to_types[key].push(i.class_type);
|
||||
} else {
|
||||
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);
|
||||
timelinePages.forEach((hourlyTimeline) => {
|
||||
Object.keys(hourlyTimeline["hours"])
|
||||
.reverse()
|
||||
.forEach((hour) => {
|
||||
const day = new Date(parseInt(hour) * 1000);
|
||||
day.setHours(0, 0, 0, 0);
|
||||
const dayKey = (day.getTime() / 1000).toString();
|
||||
const source_to_types: { [key: string]: string[] } = {};
|
||||
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
|
||||
const time = new Date(i.timestamp * 1000);
|
||||
time.setSeconds(0);
|
||||
time.setMilliseconds(0);
|
||||
const key = `${i.source_id}-${time.getMinutes()}`;
|
||||
if (key in source_to_types) {
|
||||
source_to_types[key].push(i.class_type);
|
||||
} else {
|
||||
cards[dayKey][hour][key] = {
|
||||
camera: i.camera,
|
||||
time: time.getTime() / 1000,
|
||||
entries: [i],
|
||||
};
|
||||
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 {
|
||||
cards[dayKey][hour][key] = {
|
||||
camera: i.camera,
|
||||
time: time.getTime() / 1000,
|
||||
entries: [i],
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return cards;
|
||||
}, [detailLevel, hourlyTimeline]);
|
||||
}, [detailLevel, timelinePages]);
|
||||
|
||||
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) {
|
||||
return <ActivityIndicator />;
|
||||
@ -121,7 +172,7 @@ function History() {
|
||||
<div>
|
||||
{Object.entries(timelineCards)
|
||||
.reverse()
|
||||
.map(([day, timelineDay]) => {
|
||||
.map(([day, timelineDay], dayIdx) => {
|
||||
return (
|
||||
<div key={day}>
|
||||
<Heading as="h3">
|
||||
@ -129,37 +180,54 @@ function History() {
|
||||
strftime_fmt: "%A %b %d",
|
||||
})}
|
||||
</Heading>
|
||||
{Object.entries(timelineDay).map(([hour, timelineHour]) => {
|
||||
if (Object.values(timelineHour).length == 0) {
|
||||
return <></>;
|
||||
}
|
||||
{Object.entries(timelineDay).map(
|
||||
([hour, timelineHour], hourIdx) => {
|
||||
if (Object.values(timelineHour).length == 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={hour}>
|
||||
<Heading as="h4">
|
||||
{formatUnixTimestampToDateTime(parseInt(hour), {
|
||||
strftime_fmt: "%I:00",
|
||||
})}
|
||||
</Heading>
|
||||
<ScrollArea>
|
||||
<div className="flex">
|
||||
{Object.entries(timelineHour).map(
|
||||
([key, timeline]) => {
|
||||
return (
|
||||
<HistoryCard
|
||||
key={key}
|
||||
timeline={timeline}
|
||||
allPreviews={allPreviews}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<ScrollBar className="m-2" orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
const lastRow =
|
||||
dayIdx == Object.values(timelineCards).length - 1 &&
|
||||
hourIdx == Object.values(timelineDay).length - 1;
|
||||
|
||||
return (
|
||||
<div key={hour} ref={lastRow ? lastTimelineRef : null}>
|
||||
<Heading as="h4">
|
||||
{formatUnixTimestampToDateTime(parseInt(hour), {
|
||||
strftime_fmt: "%I:00",
|
||||
})}
|
||||
</Heading>
|
||||
<ScrollArea>
|
||||
<div className="flex">
|
||||
{Object.entries(timelineHour).map(
|
||||
([key, timeline]) => {
|
||||
const startTs = Object.values(
|
||||
timeline.entries
|
||||
)[0].timestamp;
|
||||
return (
|
||||
<HistoryCard
|
||||
key={key}
|
||||
timeline={timeline}
|
||||
relevantPreview={Object.values(
|
||||
allPreviews || []
|
||||
).find(
|
||||
(preview) =>
|
||||
preview.camera == timeline.camera &&
|
||||
preview.start < startTs &&
|
||||
preview.end > startTs
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<ScrollBar className="m-2" orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
{lastRow && <ActivityIndicator />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user