Recordings viewer (#9985)

* Reduce redundant code and don't pull new items when marking as reviewed

* Chunk recording times and run playback

* fix overwriting existing data

* Implement scrubbing

* Show refresh button

* Remove old history

* Fix race condition

* Cleanup handling

* Remove console
This commit is contained in:
Nicolas Mowen
2024-02-22 17:03:34 -07:00
committed by GitHub
parent fa57a3db28
commit f84d2db406
18 changed files with 486 additions and 1680 deletions

View File

@@ -1,11 +1,213 @@
import useOverlayState from "@/hooks/use-overlay-state";
import { ReviewSegment } from "@/types/review";
import DesktopEventView from "@/views/events/DesktopEventView";
import DesktopRecordingView from "@/views/events/DesktopRecordingView";
import MobileEventView from "@/views/events/MobileEventView";
import { isMobile } from 'react-device-detect';
import axios from "axios";
import { useCallback, useMemo } from "react";
import { isMobile } from "react-device-detect";
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
const API_LIMIT = 250;
export default function Events() {
if (isMobile) {
return <MobileEventView />;
}
// recordings viewer
const [selectedReviewId, setSelectedReviewId] = useOverlayState("review");
return <DesktopEventView />;
// review paging
const timeRange = useMemo(() => {
return { before: Date.now() / 1000, after: getHoursAgo(24) };
}, []);
const reviewSegmentFetcher = useCallback((key: any) => {
const [path, params] = Array.isArray(key) ? key : [key, undefined];
return axios.get(path, { params }).then((res) => res.data);
}, []);
const reviewSearchParams = {};
const getKey = useCallback(
(index: number, prevData: ReviewSegment[]) => {
if (index > 0) {
const lastDate = prevData[prevData.length - 1].start_time;
const pagedParams = reviewSearchParams
? { before: lastDate, after: timeRange.after, limit: API_LIMIT }
: {
...reviewSearchParams,
before: lastDate,
after: timeRange.after,
limit: API_LIMIT,
};
return ["review", pagedParams];
}
const params = reviewSearchParams
? { limit: API_LIMIT, before: timeRange.before, after: timeRange.after }
: {
...reviewSearchParams,
limit: API_LIMIT,
before: timeRange.before,
after: timeRange.after,
};
return ["review", params];
},
[reviewSearchParams]
);
const {
data: reviewPages,
mutate: updateSegments,
size,
setSize,
isValidating,
} = useSWRInfinite<ReviewSegment[]>(getKey, reviewSegmentFetcher, {
revalidateOnFocus: false,
persistSize: true,
});
const isDone = useMemo(
() => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT,
[reviewPages]
);
// preview videos
const previewTimes = useMemo(() => {
if (
!reviewPages ||
reviewPages.length == 0 ||
reviewPages.at(-1)!!.length == 0
) {
return undefined;
}
const startDate = new Date();
startDate.setMinutes(0, 0, 0);
const endDate = new Date(reviewPages.at(-1)!!.at(-1)!!.end_time);
endDate.setHours(0, 0, 0, 0);
return {
start: startDate.getTime() / 1000,
end: endDate.getTime() / 1000,
};
}, [reviewPages]);
const { data: allPreviews } = useSWR<Preview[]>(
previewTimes
? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}`
: null,
{ revalidateOnFocus: false }
);
// review status
const markItemAsReviewed = useCallback(
async (reviewId: string) => {
const resp = await axios.post(`review/${reviewId}/viewed`);
if (resp.status == 200) {
updateSegments(
(data: ReviewSegment[][] | undefined) => {
if (!data) {
return data;
}
const newData: ReviewSegment[][] = [];
data.forEach((page) => {
const reviewIndex = page.findIndex((item) => item.id == reviewId);
if (reviewIndex == -1) {
newData.push([...page]);
} else {
newData.push([
...page.slice(0, reviewIndex),
{ ...page[reviewIndex], has_been_reviewed: true },
...page.slice(reviewIndex + 1),
]);
}
});
return newData;
},
{ revalidate: false }
);
}
},
[updateSegments]
);
// selected items
const selectedData = useMemo(() => {
if (!selectedReviewId) {
return undefined;
}
if (!reviewPages) {
return undefined;
}
const allReviews = reviewPages.flat();
const selectedReview = allReviews.find(
(item) => item.id == selectedReviewId
);
if (!selectedReview) {
return undefined;
}
return {
selected: selectedReview,
cameraSegments: allReviews.filter(
(seg) => seg.camera == selectedReview.camera
),
cameraPreviews: allPreviews?.filter(
(seg) => seg.camera == selectedReview.camera
),
};
}, [selectedReviewId, reviewPages]);
if (selectedData) {
return (
<DesktopRecordingView
reviewItems={selectedData.cameraSegments}
selectedReview={selectedData.selected}
relevantPreviews={selectedData.cameraPreviews}
/>
);
} else {
if (isMobile) {
return (
<MobileEventView
reviewPages={reviewPages}
relevantPreviews={allPreviews}
reachedEnd={isDone}
isValidating={isValidating}
loadNextPage={() => setSize(size + 1)}
markItemAsReviewed={markItemAsReviewed}
/>
);
}
return (
<DesktopEventView
reviewPages={reviewPages}
relevantPreviews={allPreviews}
timeRange={timeRange}
reachedEnd={isDone}
isValidating={isValidating}
loadNextPage={() => setSize(size + 1)}
markItemAsReviewed={markItemAsReviewed}
onSelectReview={setSelectedReviewId}
pullLatestData={updateSegments}
/>
);
}
}
function getHoursAgo(hours: number): number {
const now = new Date();
now.setHours(now.getHours() - hours);
return now.getTime() / 1000;
}

View File

@@ -1,284 +0,0 @@
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 axios from "axios";
import { getHourlyTimelineData } from "@/utils/historyUtil";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} 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 { 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";
import MobileTimelineView from "@/views/history/MobileTimelineView";
import DesktopTimelineView from "@/views/history/DesktopTimelineView";
const API_LIMIT = 200;
function History() {
const { data: config } = useSWR<FrigateConfig>("config");
const timezone = useMemo(
() =>
config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
[config]
);
const [historyFilter, setHistoryFilter, historySearchParams] =
useApiFilter<HistoryFilter>();
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 =
historySearchParams == undefined
? { before: lastDate, timezone, limit: API_LIMIT }
: {
...historySearchParams,
before: lastDate,
timezone,
limit: API_LIMIT,
};
return ["timeline/hourly", pagedParams];
}
const params =
historySearchParams == undefined
? { timezone, limit: API_LIMIT }
: { ...historySearchParams, timezone, limit: API_LIMIT };
return ["timeline/hourly", params];
},
[historySearchParams]
);
const {
data: timelinePages,
mutate: updateHistory,
size,
setSize,
isValidating,
} = useSWRInfinite<HourlyTimeline>(getKey, timelineFetcher);
const previewTimes = useMemo(() => {
if (!timelinePages) {
return undefined;
}
const startDate = new Date();
startDate.setMinutes(0, 0, 0);
const endDate = new Date(timelinePages.at(-1)!!.end);
endDate.setHours(0, 0, 0, 0);
return {
start: startDate.getTime() / 1000,
end: endDate.getTime() / 1000,
};
}, [timelinePages]);
const { data: allPreviews } = useSWR<Preview[]>(
previewTimes
? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}`
: null,
{ revalidateOnFocus: false }
);
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 isMobile = useMemo(() => {
return window.innerWidth < 768;
}, [playback]);
const timelineCards: CardsData = useMemo(() => {
if (!timelinePages) {
return {};
}
return getHourlyTimelineData(
timelinePages,
historyFilter?.detailLevel ?? "normal"
);
}, [historyFilter, timelinePages]);
const isDone =
(timelinePages?.[timelinePages.length - 1]?.count ?? 0) < API_LIMIT;
const [itemsToDelete, setItemsToDelete] = useState<string[] | null>(null);
const onDelete = useCallback(
async (timeline: Card) => {
if (timeline.entries.length > 1) {
const uniqueEvents = new Set(
timeline.entries.map((entry) => entry.source_id)
);
setItemsToDelete(new Array(...uniqueEvents));
} else {
const response = await axios.delete(
`events/${timeline.entries[0].source_id}`
);
if (response.status === 200) {
updateHistory();
}
}
},
[updateHistory]
);
const onDeleteMulti = useCallback(async () => {
if (!itemsToDelete) {
return;
}
const responses = itemsToDelete.map(async (id) => {
return axios.delete(`events/${id}`);
});
if ((await responses[0]).status == 200) {
updateHistory();
setItemsToDelete(null);
}
}, [itemsToDelete, updateHistory]);
if (!config || !timelineCards) {
return <ActivityIndicator />;
}
return (
<>
<div className="flex justify-between">
<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
open={itemsToDelete != null}
onOpenChange={(_) => setItemsToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{`Delete ${itemsToDelete?.length} events?`}</AlertDialogTitle>
<AlertDialogDescription>
This will delete all events associated with these objects.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setItemsToDelete(null)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-danger"
onClick={() => onDeleteMulti()}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<HistoryCardView
timelineCards={timelineCards}
allPreviews={allPreviews}
isMobile={isMobile}
isValidating={isValidating}
isDone={isDone}
onNextPage={() => {
setSize(size + 1);
}}
onDelete={onDelete}
onItemSelected={(item) => setPlaybackState(item)}
/>
<TimelineViewer
timelineData={timelineCards}
allPreviews={allPreviews || []}
playback={viewingPlayback ? playback : undefined}
isMobile={isMobile}
onClose={() => setPlaybackState(undefined)}
/>
</>
);
}
type TimelineViewerProps = {
timelineData: CardsData | undefined;
allPreviews: Preview[];
playback: TimelinePlayback | undefined;
isMobile: boolean;
onClose: () => void;
};
function TimelineViewer({
timelineData,
allPreviews,
playback,
isMobile,
onClose,
}: TimelineViewerProps) {
if (isMobile) {
return playback != undefined ? (
<div className="w-screen absolute left-0 top-20 bottom-0 bg-background z-50">
{timelineData && <MobileTimelineView playback={playback} />}
</div>
) : null;
}
return (
<Dialog open={playback != undefined} onOpenChange={(_) => onClose()}>
<DialogContent className="w-[70%] max-w-[1920px] h-[90%]">
{timelineData && playback && (
<DesktopTimelineView
timelineData={timelineData}
allPreviews={allPreviews}
initialPlayback={playback}
/>
)}
</DialogContent>
</Dialog>
);
}
export default History;