forked from Github/frigate
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user