Refactor history viewer to show player / timeline for full hour and use preview while scrubbing timeline (#9051)

* Move history card view to separate view and create timeline view

* Get custom time scrubber working

* Add back nav

* Show timeline bounding boxes

* Implement seeking limiter

* Use browser history to allow back button to close timeline viewer

* Fix mobile timeline and add more icons for detections

* Play when item is initially visible
This commit is contained in:
Nicolas Mowen
2023-12-31 07:35:15 -06:00
committed by Blake Blackshear
parent 9a0dfa723a
commit a946a8f099
14 changed files with 892 additions and 210 deletions

View File

@@ -1,13 +1,10 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, 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 { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import axios from "axios";
import TimelinePlayerCard from "@/components/card/TimelinePlayerCard";
import { getHourlyTimelineData } from "@/utils/historyUtil";
import {
AlertDialog,
@@ -21,6 +18,13 @@ import {
} from "@/components/ui/alert-dialog";
import HistoryFilterPopover from "@/components/filter/HistoryFilterPopover";
import useApiFilter from "@/hooks/use-api-filter";
import HistoryCardView from "@/views/history/HistoryCardView";
import HistoryTimelineView from "@/views/history/HistoryTimelineView";
import { Button } from "@/components/ui/button";
import { IoMdArrowBack } from "react-icons/io";
import useOverlayState from "@/hooks/use-overlay-state";
import { useNavigate } from "react-router-dom";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const API_LIMIT = 200;
@@ -80,10 +84,24 @@ function History() {
{ revalidateOnFocus: false }
);
const [playback, setPlayback] = useState<Card | undefined>();
const navigate = useNavigate();
const [playback, setPlayback] = useState<TimelinePlayback | undefined>();
const [viewingPlayback, setViewingPlayback] = useOverlayState("timeline");
const setPlaybackState = useCallback(
(playback: TimelinePlayback | undefined) => {
if (playback == undefined) {
setPlayback(undefined);
navigate(-1);
} else {
setPlayback(playback);
setViewingPlayback(true);
}
},
[navigate]
);
const shouldAutoPlay = useMemo(() => {
return playback == undefined && window.innerWidth < 480;
const isMobile = useMemo(() => {
return window.innerWidth < 768;
}, [playback]);
const timelineCards: CardsData | never[] = useMemo(() => {
@@ -100,26 +118,6 @@ function History() {
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]
);
const [itemsToDelete, setItemsToDelete] = useState<string[] | null>(null);
const onDelete = useCallback(
async (timeline: Card) => {
@@ -161,11 +159,25 @@ function History() {
return (
<>
<div className="flex justify-between">
<Heading as="h2">History</Heading>
<HistoryFilterPopover
filter={historyFilter}
onUpdateFilter={(filter) => setHistoryFilter(filter)}
/>
<div className="flex justify-start">
{viewingPlayback && (
<Button
className="mt-2"
size="xs"
variant="ghost"
onClick={() => setPlaybackState(undefined)}
>
<IoMdArrowBack className="w-6 h-6" />
</Button>
)}
<Heading as="h2">History</Heading>
</div>
{!playback && (
<HistoryFilterPopover
filter={historyFilter}
onUpdateFilter={(filter) => setHistoryFilter(filter)}
/>
)}
</div>
<AlertDialog
@@ -192,96 +204,51 @@ function History() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TimelinePlayerCard
timeline={playback}
onDismiss={() => setPlayback(undefined)}
<HistoryCardView
timelineCards={timelineCards}
allPreviews={allPreviews}
isMobile={isMobile}
isValidating={isValidating}
isDone={isDone}
onNextPage={() => {
setSize(size + 1);
}}
onDelete={onDelete}
onItemSelected={(item) => setPlaybackState(item)}
/>
<TimelineViewer
playback={viewingPlayback ? playback : undefined}
isMobile={isMobile}
onClose={() => setPlaybackState(undefined)}
/>
<div>
{Object.entries(timelineCards)
.reverse()
.map(([day, timelineDay], dayIdx) => {
return (
<div key={day}>
<Heading
className="sticky py-2 -top-4 left-0 bg-background w-full z-20"
as="h3"
>
{formatUnixTimestampToDateTime(parseInt(day), {
strftime_fmt: "%A %b %d",
time_style: "medium",
date_style: "medium",
})}
</Heading>
{Object.entries(timelineDay).map(
([hour, timelineHour], hourIdx) => {
if (Object.values(timelineHour).length == 0) {
return <div key={hour}></div>;
}
const lastRow =
dayIdx == Object.values(timelineCards).length - 1 &&
hourIdx == Object.values(timelineDay).length - 1;
const previewMap: { [key: string]: Preview | undefined } =
{};
return (
<div key={hour} ref={lastRow ? lastTimelineRef : null}>
<Heading as="h4">
{formatUnixTimestampToDateTime(parseInt(hour), {
strftime_fmt:
config.ui.time_format == "24hour"
? "%H:00"
: "%I:00 %p",
time_style: "medium",
date_style: "medium",
})}
</Heading>
<div className="flex flex-wrap">
{Object.entries(timelineHour)
.reverse()
.map(([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 (
<HistoryCard
key={key}
timeline={timeline}
shouldAutoPlay={shouldAutoPlay}
relevantPreview={relevantPreview}
onClick={() => {
setPlayback(timeline);
}}
onDelete={() => onDelete(timeline)}
/>
);
})}
</div>
{lastRow && !isDone && <ActivityIndicator />}
</div>
);
}
)}
</div>
);
})}
</div>
</>
);
}
type TimelineViewerProps = {
playback: TimelinePlayback | undefined;
isMobile: boolean;
onClose: () => void;
};
function TimelineViewer({ playback, isMobile, onClose }: TimelineViewerProps) {
if (isMobile) {
return playback != undefined ? (
<div className="w-screen absolute left-0 top-20 bottom-0 bg-background z-50">
<HistoryTimelineView playback={playback} isMobile={isMobile} />
</div>
) : null;
}
return (
<Dialog open={playback != undefined} onOpenChange={(_) => onClose()}>
<DialogContent className="w-3/5 max-w-full">
{playback && (
<HistoryTimelineView playback={playback} isMobile={isMobile} />
)}
</DialogContent>
</Dialog>
);
}
export default History;