diff --git a/web-new/src/components/card/HistoryCard.tsx b/web-new/src/components/card/HistoryCard.tsx index 0684f3667..30ddd7caa 100644 --- a/web-new/src/components/card/HistoryCard.tsx +++ b/web-new/src/components/card/HistoryCard.tsx @@ -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("config"); @@ -38,7 +38,7 @@ export default function HistoryCard({
diff --git a/web-new/src/components/player/PreviewThumbnailPlayer.tsx b/web-new/src/components/player/PreviewThumbnailPlayer.tsx index ca51ddfc1..2e05f601e 100644 --- a/web-new/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web-new/src/components/player/PreviewThumbnailPlayer.tsx @@ -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(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) { diff --git a/web-new/src/pages/History.tsx b/web-new/src/pages/History.tsx index 9b98d8b52..c238df4ee 100644 --- a/web-new/src/pages/History.tsx +++ b/web-new/src/pages/History.tsx @@ -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("config"); @@ -14,13 +18,31 @@ function History() { config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config] ); - const { data: hourlyTimeline } = useSWR([ - "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(getKey, timelineFetcher); const { data: allPreviews } = useSWR( - `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(); + 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 ; @@ -121,7 +172,7 @@ function History() {
{Object.entries(timelineCards) .reverse() - .map(([day, timelineDay]) => { + .map(([day, timelineDay], dayIdx) => { return (
@@ -129,37 +180,54 @@ function History() { strftime_fmt: "%A %b %d", })} - {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 ( -
- - {formatUnixTimestampToDateTime(parseInt(hour), { - strftime_fmt: "%I:00", - })} - - -
- {Object.entries(timelineHour).map( - ([key, timeline]) => { - return ( - - ); - } - )} -
- -
-
- ); - })} + const lastRow = + dayIdx == Object.values(timelineCards).length - 1 && + hourIdx == Object.values(timelineDay).length - 1; + + return ( +
+ + {formatUnixTimestampToDateTime(parseInt(hour), { + strftime_fmt: "%I:00", + })} + + +
+ {Object.entries(timelineHour).map( + ([key, timeline]) => { + const startTs = Object.values( + timeline.entries + )[0].timestamp; + return ( + + preview.camera == timeline.camera && + preview.start < startTs && + preview.end > startTs + )} + /> + ); + } + )} +
+ +
+ {lastRow && } +
+ ); + } + )}
); })}