forked from Github/frigate
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:
committed by
Blake Blackshear
parent
9a0dfa723a
commit
a946a8f099
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user