Add infinite scrolling

This commit is contained in:
Nick Mowen 2023-12-13 09:54:06 -07:00
parent 8278d9abd8
commit 38fbe84159
3 changed files with 178 additions and 119 deletions

View File

@ -21,11 +21,11 @@ import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
type HistoryCardProps = { type HistoryCardProps = {
timeline: Card; timeline: Card;
allPreviews?: Preview[]; relevantPreview?: Preview;
}; };
export default function HistoryCard({ export default function HistoryCard({
allPreviews, relevantPreview,
timeline, timeline,
}: HistoryCardProps) { }: HistoryCardProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -38,7 +38,7 @@ export default function HistoryCard({
<Card className="my-2 mr-2 bg-secondary w-[284px]"> <Card className="my-2 mr-2 bg-secondary 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}
/> />
<div className="p-2"> <div className="p-2">

View File

@ -1,14 +1,14 @@
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"; import { AspectRatio } from "../ui/aspect-ratio";
type PreviewPlayerProps = { type PreviewPlayerProps = {
camera: string; camera: string;
allPreviews: Preview[]; relevantPreview?: Preview;
startTs: number; startTs: number;
}; };
@ -22,22 +22,13 @@ type Preview = {
export default function PreviewThumbnailPlayer({ export default function PreviewThumbnailPlayer({
camera, camera,
allPreviews, relevantPreview,
startTs, startTs,
}: PreviewPlayerProps) { }: PreviewPlayerProps) {
const { data: config } = useSWR("config"); const { data: config } = useSWR("config");
const playerRef = useRef<Player | null>(null); const playerRef = useRef<Player | null>(null);
const apiHost = useApiHost(); 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( const onHover = useCallback(
(isHovered: Boolean) => { (isHovered: Boolean) => {
if (!relevantPreview || !playerRef.current) { if (!relevantPreview || !playerRef.current) {

View File

@ -1,11 +1,15 @@
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 { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import axios from "axios";
const API_LIMIT = 200;
function History() { function History() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -14,13 +18,31 @@ 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 };
return ["timeline/hourly", pagedParams];
}
return ["timeline/hourly", { timezone }];
}, []);
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,11 +52,12 @@ function History() {
); );
const timelineCards: CardsData | never[] = useMemo(() => { const timelineCards: CardsData | never[] = useMemo(() => {
if (!hourlyTimeline) { if (!timelinePages) {
return []; return [];
} }
const cards: CardsData = {}; const cards: CardsData = {};
timelinePages.forEach((hourlyTimeline) => {
Object.keys(hourlyTimeline["hours"]) Object.keys(hourlyTimeline["hours"])
.reverse() .reverse()
.forEach((hour) => { .forEach((hour) => {
@ -74,9 +97,13 @@ function History() {
if ( if (
source_to_types[`${i.source_id}-${time.getMinutes()}`].length > source_to_types[`${i.source_id}-${time.getMinutes()}`].length >
1 && 1 &&
["active", "attribute", "gone", "stationary", "visible"].includes( [
i.class_type "active",
) "attribute",
"gone",
"stationary",
"visible",
].includes(i.class_type)
) { ) {
add = false; add = false;
} }
@ -103,9 +130,33 @@ function History() {
} }
}); });
}); });
});
return cards; 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) { if (!config || !timelineCards) {
return <ActivityIndicator />; return <ActivityIndicator />;
@ -121,7 +172,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,13 +180,18 @@ function History() {
strftime_fmt: "%A %b %d", strftime_fmt: "%A %b %d",
})} })}
</Heading> </Heading>
{Object.entries(timelineDay).map(([hour, timelineHour]) => { {Object.entries(timelineDay).map(
([hour, timelineHour], hourIdx) => {
if (Object.values(timelineHour).length == 0) { if (Object.values(timelineHour).length == 0) {
return <></>; return <></>;
} }
const lastRow =
dayIdx == Object.values(timelineCards).length - 1 &&
hourIdx == Object.values(timelineDay).length - 1;
return ( return (
<div key={hour}> <div key={hour} ref={lastRow ? lastTimelineRef : null}>
<Heading as="h4"> <Heading as="h4">
{formatUnixTimestampToDateTime(parseInt(hour), { {formatUnixTimestampToDateTime(parseInt(hour), {
strftime_fmt: "%I:00", strftime_fmt: "%I:00",
@ -145,11 +201,21 @@ function History() {
<div className="flex"> <div className="flex">
{Object.entries(timelineHour).map( {Object.entries(timelineHour).map(
([key, timeline]) => { ([key, timeline]) => {
const startTs = Object.values(
timeline.entries
)[0].timestamp;
return ( return (
<HistoryCard <HistoryCard
key={key} key={key}
timeline={timeline} timeline={timeline}
allPreviews={allPreviews} relevantPreview={Object.values(
allPreviews || []
).find(
(preview) =>
preview.camera == timeline.camera &&
preview.start < startTs &&
preview.end > startTs
)}
/> />
); );
} }
@ -157,9 +223,11 @@ function History() {
</div> </div>
<ScrollBar className="m-2" orientation="horizontal" /> <ScrollBar className="m-2" orientation="horizontal" />
</ScrollArea> </ScrollArea>
{lastRow && <ActivityIndicator />}
</div> </div>
); );
})} }
)}
</div> </div>
); );
})} })}