Combine event views

This commit is contained in:
Nicolas Mowen 2024-02-27 12:41:19 -07:00
parent 8d8494de6c
commit a6bb39f17b
4 changed files with 26 additions and 285 deletions

View File

@ -1,12 +1,10 @@
import useApiFilter from "@/hooks/use-api-filter"; import useApiFilter from "@/hooks/use-api-filter";
import useOverlayState from "@/hooks/use-overlay-state"; import useOverlayState from "@/hooks/use-overlay-state";
import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review";
import DesktopEventView from "@/views/events/DesktopEventView";
import DesktopRecordingView from "@/views/events/DesktopRecordingView"; import DesktopRecordingView from "@/views/events/DesktopRecordingView";
import MobileEventView from "@/views/events/MobileEventView"; import EventView from "@/views/events/EventView";
import axios from "axios"; import axios from "axios";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { isMobile } from "react-device-detect";
import useSWR from "swr"; import useSWR from "swr";
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
@ -209,24 +207,8 @@ export default function Events() {
/> />
); );
} else { } else {
if (isMobile) {
return ( return (
<MobileEventView <EventView
reviewPages={reviewPages}
relevantPreviews={allPreviews}
reachedEnd={isDone}
isValidating={isValidating}
severity={severity}
setSeverity={setSeverity}
loadNextPage={onLoadNextPage}
markItemAsReviewed={markItemAsReviewed}
pullLatestData={reloadData}
/>
);
}
return (
<DesktopEventView
reviewPages={reviewPages} reviewPages={reviewPages}
relevantPreviews={allPreviews} relevantPreviews={allPreviews}
timeRange={selectedTimeRange} timeRange={selectedTimeRange}

View File

@ -9,8 +9,7 @@ import { Event as FrigateEvent } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { isDesktop, isMobile, isSafari } from "react-device-detect"; import { isDesktop, isMobile, isSafari } from "react-device-detect";
import { LuGrid } from "react-icons/lu"; import { CiGrid2H, CiGrid31 } from "react-icons/ci";
import { CiGrid2V, CiGrid31 } from "react-icons/ci";
import useSWR from "swr"; import useSWR from "swr";
function Live() { function Live() {
@ -92,7 +91,7 @@ function Live() {
<div className="w-full h-full overflow-y-scroll px-2"> <div className="w-full h-full overflow-y-scroll px-2">
{isMobile && ( {isMobile && (
<div className="relative h-9 flex items-center justify-between"> <div className="relative h-9 flex items-center justify-between">
<Logo className="absolute w-full h-8" /> <Logo className="absolute inset-y-0 inset-x-1/2 -translate-x-1/2 h-8" />
<div /> <div />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
@ -109,7 +108,7 @@ function Live() {
variant="secondary" variant="secondary"
onClick={() => setLayout("list")} onClick={() => setLayout("list")}
> >
<CiGrid2V className="m-1" /> <CiGrid2H className="m-1" />
</Button> </Button>
</div> </div>
</div> </div>
@ -129,7 +128,7 @@ function Live() {
)} )}
<div <div
className={`mt-4 grid ${layout == "grid" ? "grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" : ""} gap-4`} className={`mt-4 grid ${layout == "grid" ? "grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" : ""} gap-2 md:gap-4`}
> >
{cameras.map((camera) => { {cameras.map((camera) => {
let grow; let grow;

View File

@ -1,3 +1,4 @@
import Logo from "@/components/Logo";
import NewReviewData from "@/components/dynamic/NewReviewData"; import NewReviewData from "@/components/dynamic/NewReviewData";
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
@ -8,11 +9,12 @@ import { useEventUtils } from "@/hooks/use-event-utils";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isDesktop, isMobile } from "react-device-detect";
import { LuFolderCheck } from "react-icons/lu"; import { LuFolderCheck } from "react-icons/lu";
import { MdCircle } from "react-icons/md"; import { MdCircle } from "react-icons/md";
import useSWR from "swr"; import useSWR from "swr";
type DesktopEventViewProps = { type EventViewProps = {
reviewPages?: ReviewSegment[][]; reviewPages?: ReviewSegment[][];
relevantPreviews?: Preview[]; relevantPreviews?: Preview[];
timeRange: { before: number; after: number }; timeRange: { before: number; after: number };
@ -27,7 +29,7 @@ type DesktopEventViewProps = {
pullLatestData: () => void; pullLatestData: () => void;
updateFilter: (filter: ReviewFilter) => void; updateFilter: (filter: ReviewFilter) => void;
}; };
export default function DesktopEventView({ export default function EventView({
reviewPages, reviewPages,
relevantPreviews, relevantPreviews,
timeRange, timeRange,
@ -41,7 +43,7 @@ export default function DesktopEventView({
onSelectReview, onSelectReview,
pullLatestData, pullLatestData,
updateFilter, updateFilter,
}: DesktopEventViewProps) { }: EventViewProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
const segmentDuration = 60; const segmentDuration = 60;
@ -192,7 +194,8 @@ export default function DesktopEventView({
return ( return (
<div className="flex flex-col w-full h-full"> <div className="flex flex-col w-full h-full">
<div className="flex justify-between mb-2"> <div className="relative flex justify-between mb-2">
<Logo className="absolute inset-y-0 inset-x-1/2 -translate-x-1/2 h-8" />
<ToggleGroup <ToggleGroup
type="single" type="single"
defaultValue="alert" defaultValue="alert"
@ -206,8 +209,8 @@ export default function DesktopEventView({
value="alert" value="alert"
aria-label="Select alerts" aria-label="Select alerts"
> >
<MdCircle className="w-2 h-2 mr-[10px] text-severity_alert" /> <MdCircle className="w-2 h-2 md:mr-[10px] text-severity_alert" />
Alerts <div className="hidden md:block">Alerts</div>
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem <ToggleGroupItem
className={`px-3 py-4 rounded-2xl ${ className={`px-3 py-4 rounded-2xl ${
@ -216,8 +219,8 @@ export default function DesktopEventView({
value="detection" value="detection"
aria-label="Select detections" aria-label="Select detections"
> >
<MdCircle className="w-2 h-2 mr-[10px] text-severity_detection" /> <MdCircle className="w-2 h-2 md:mr-[10px] text-severity_detection" />
Detections <div className="hidden md:block">Detections</div>
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem <ToggleGroupItem
className={`px-3 py-4 rounded-2xl ${ className={`px-3 py-4 rounded-2xl ${
@ -226,11 +229,13 @@ export default function DesktopEventView({
value="significant_motion" value="significant_motion"
aria-label="Select motion" aria-label="Select motion"
> >
<MdCircle className="w-2 h-2 mr-[10px] text-severity_motion" /> <MdCircle className="w-2 h-2 md:mr-[10px] text-severity_motion" />
Motion <div className="hidden md:block">Motion</div>
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
{isDesktop && (
<ReviewFilterGroup filter={filter} onUpdateFilter={updateFilter} /> <ReviewFilterGroup filter={filter} onUpdateFilter={updateFilter} />
)}
</div> </div>
<div className="flex h-full overflow-hidden"> <div className="flex h-full overflow-hidden">
@ -284,6 +289,9 @@ export default function DesktopEventView({
relevantPreview={relevantPreview} relevantPreview={relevantPreview}
setReviewed={markItemAsReviewed} setReviewed={markItemAsReviewed}
onClick={onSelectReview} onClick={onSelectReview}
autoPlayback={
isMobile && minimapBounds.end == value.start_time
}
/> />
</div> </div>
{lastRow && !reachedEnd && <ActivityIndicator />} {lastRow && !reachedEnd && <ActivityIndicator />}
@ -295,7 +303,7 @@ export default function DesktopEventView({
)} )}
</div> </div>
</div> </div>
<div className="md:w-[100px] mt-2 overflow-y-auto no-scrollbar"> <div className="w-[44px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar">
<EventReviewTimeline <EventReviewTimeline
segmentDuration={segmentDuration} segmentDuration={segmentDuration}
timestampSpread={15} timestampSpread={15}

View File

@ -1,248 +0,0 @@
import NewReviewData from "@/components/dynamic/NewReviewData";
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
import ActivityIndicator from "@/components/ui/activity-indicator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment, ReviewSeverity } from "@/types/review";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MdCircle } from "react-icons/md";
import useSWR from "swr";
type MobileEventViewProps = {
reviewPages?: ReviewSegment[][];
relevantPreviews?: Preview[];
reachedEnd: boolean;
isValidating: boolean;
severity: ReviewSeverity;
setSeverity: (severity: ReviewSeverity) => void;
loadNextPage: () => void;
markItemAsReviewed: (reviewId: string) => void;
pullLatestData: () => void;
};
export default function MobileEventView({
reviewPages,
relevantPreviews,
reachedEnd,
isValidating,
severity,
setSeverity,
loadNextPage,
markItemAsReviewed,
pullLatestData,
}: MobileEventViewProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const contentRef = useRef<HTMLDivElement | null>(null);
// review paging
const reviewItems = useMemo(() => {
const all: ReviewSegment[] = [];
const alerts: ReviewSegment[] = [];
const detections: ReviewSegment[] = [];
const motion: ReviewSegment[] = [];
reviewPages?.forEach((page) => {
page.forEach((segment) => {
all.push(segment);
switch (segment.severity) {
case "alert":
alerts.push(segment);
break;
case "detection":
detections.push(segment);
break;
default:
motion.push(segment);
break;
}
});
});
return {
all: all,
alert: alerts,
detection: detections,
significant_motion: motion,
};
}, [reviewPages]);
const currentItems = useMemo(() => {
const current = reviewItems[severity];
if (!current || current.length == 0) {
return null;
}
return current;
}, [reviewItems, severity]);
// review interaction
const pagingObserver = useRef<IntersectionObserver | null>();
const lastReviewRef = useCallback(
(node: HTMLElement | null) => {
if (isValidating) return;
if (pagingObserver.current) pagingObserver.current.disconnect();
try {
pagingObserver.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !reachedEnd) {
loadNextPage();
}
});
if (node) pagingObserver.current.observe(node);
} catch (e) {
// no op
}
},
[isValidating, reachedEnd]
);
const [minimap, setMinimap] = useState<string[]>([]);
const minimapObserver = useRef<IntersectionObserver | null>();
useEffect(() => {
const visibleTimestamps = new Set<string>();
minimapObserver.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const start = (entry.target as HTMLElement).dataset.start;
if (!start) {
return;
}
if (entry.isIntersecting) {
visibleTimestamps.add(start);
} else {
visibleTimestamps.delete(start);
}
setMinimap([...visibleTimestamps]);
});
},
{ threshold: 0.5 }
);
return () => {
minimapObserver.current?.disconnect();
};
}, []);
const minimapRef = useCallback(
(node: HTMLElement | null) => {
if (!minimapObserver.current) {
return;
}
try {
if (node) minimapObserver.current.observe(node);
} catch (e) {
// no op
}
},
[minimapObserver.current]
);
const minimapBounds = useMemo(() => {
const data = {
start: 0,
end: 0,
};
const list = minimap.sort();
if (list.length > 0) {
data.end = parseFloat(list.at(-1)!!);
data.start = parseFloat(list[0]);
}
return data;
}, [minimap]);
if (!config) {
return <ActivityIndicator />;
}
return (
<>
<ToggleGroup
type="single"
defaultValue="alert"
size="sm"
onValueChange={(value: ReviewSeverity) => setSeverity(value)}
>
<ToggleGroupItem
className={`px-3 py-4 rounded-2xl ${
severity == "alert" ? "" : "text-gray-500"
}`}
value="alert"
aria-label="Select alerts"
>
<MdCircle className="w-2 h-2 mr-[10px] text-severity_alert" />
Alerts
</ToggleGroupItem>
<ToggleGroupItem
className={`px-3 py-4 rounded-2xl ${
severity == "detection" ? "" : "text-gray-500"
}`}
value="detection"
aria-label="Select detections"
>
<MdCircle className="w-2 h-2 mr-[10px] text-severity_detection" />
Detections
</ToggleGroupItem>
<ToggleGroupItem
className={`px-3 py-4 rounded-2xl ${
severity == "significant_motion" ? "" : "text-gray-500"
}`}
value="significant_motion"
aria-label="Select motion"
>
<MdCircle className="w-2 h-2 mr-[10px] text-severity_motion" />
Motion
</ToggleGroupItem>
</ToggleGroup>
<NewReviewData
className="absolute w-full z-30"
contentRef={contentRef}
severity={severity}
pullLatestData={pullLatestData}
/>
<div
ref={contentRef}
className="w-full h-full grid grid-cols-1 sm:grid-cols-2 mt-2 gap-2 overflow-y-auto"
>
{currentItems ? (
currentItems.map((value, segIdx) => {
const lastRow = segIdx == currentItems.length - 1;
const relevantPreview = Object.values(relevantPreviews || []).find(
(preview) =>
preview.camera == value.camera &&
preview.start < value.start_time &&
preview.end > value.end_time
);
return (
<div
key={value.id}
ref={lastRow ? lastReviewRef : minimapRef}
data-start={value.start_time}
>
<div className="w-full aspect-video rounded-lg overflow-hidden">
<PreviewThumbnailPlayer
review={value}
relevantPreview={relevantPreview}
autoPlayback={minimapBounds.end == value.start_time}
setReviewed={markItemAsReviewed}
/>
</div>
{lastRow && !reachedEnd && <ActivityIndicator />}
</div>
);
})
) : (
<div ref={lastReviewRef} />
)}
</div>
</>
);
}