Review segment UI (#9945)

* Add ui for events

* Display data for review items

* Use preview thumbnails

* Implement paging

* Show icons for what was detected

* Show progress bar on preview thumbnail

* Hide the overlays on hover and update reviewed status

* Dim previews that have been reviewed

* Show audio icons

* Cleanup preview thumb player

* initial implementation of review timeline

* Use timeout for hover playback

* Break apart mobile and desktop views

* Show icons for sub labels

* autoplay first video on mobile

* Only show the last 24 hours by default

* Rework scrolling to fix nested scrolling

* Final scroll cleanups

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
Nicolas Mowen 2024-02-21 13:07:32 -07:00 committed by GitHub
parent be4b570346
commit 509e46adc8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1262 additions and 277 deletions

View File

@ -2420,6 +2420,40 @@ def review():
return jsonify([r for r in review])
@bp.route("/review/<id>/viewed", methods=("POST",))
def set_reviewed(id):
try:
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Review " + id + " not found"}), 404
)
review.has_been_reviewed = True
review.save()
return make_response(
jsonify({"success": True, "message": "Reviewed " + id + " viewed"}), 200
)
@bp.route("/review/<id>/viewed", methods=("DELETE",))
def set_not_reviewed(id):
try:
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Review " + id + " not found"}), 404
)
review.has_been_reviewed = False
review.save()
return make_response(
jsonify({"success": True, "message": "Reviewed " + id + " not viewed"}), 200
)
@bp.route(
"/export/<camera_name>/start/<int:start_time>/end/<int:end_time>", methods=["POST"]
)

91
web/package-lock.json generated
View File

@ -23,6 +23,8 @@
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"apexcharts": "^3.45.1",
"axios": "^1.6.2",
@ -38,6 +40,7 @@
"react": "^18.2.0",
"react-apexcharts": "^1.4.1",
"react-day-picker": "^8.9.1",
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-icons": "^4.12.0",
@ -1800,6 +1803,60 @@
}
}
},
"node_modules/@radix-ui/react-toggle": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.0.3.tgz",
"integrity": "sha512-Pkqg3+Bc98ftZGsl60CLANXQBBQ4W3mTFS9EJvNxKMZ7magklKV69/id1mlAlOFDDfHvlCms0fx8fA4CMKDJHg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-controllable-state": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.0.4.tgz",
"integrity": "sha512-Uaj/M/cMyiyT9Bx6fOZO0SAG4Cls0GptBWiBmBxofmDbNVnYYoyRWj/2M/6VCi/7qcXFWnHhRUfdfZFvvkuu8A==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-direction": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-roving-focus": "1.0.4",
"@radix-ui/react-toggle": "1.0.3",
"@radix-ui/react-use-controllable-state": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz",
@ -6748,6 +6805,18 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-device-detect": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-device-detect/-/react-device-detect-2.2.3.tgz",
"integrity": "sha512-buYY3qrCnQVlIFHrC5UcUoAj7iANs/+srdkwsnNjI7anr3Tt7UY6MqNxtMLlr0tMBied0O49UZVK8XKs3ZIiPw==",
"dependencies": {
"ua-parser-js": "^1.0.33"
},
"peerDependencies": {
"react": ">= 0.14.0",
"react-dom": ">= 0.14.0"
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
@ -8062,6 +8131,28 @@
"node": ">=14.17"
}
},
"node_modules/ua-parser-js": {
"version": "1.0.37",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz",
"integrity": "sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"engines": {
"node": "*"
}
},
"node_modules/ufo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz",

View File

@ -28,6 +28,8 @@
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"apexcharts": "^3.45.1",
"axios": "^1.6.2",
@ -43,6 +45,7 @@
"react": "^18.2.0",
"react-apexcharts": "^1.4.1",
"react-day-picker": "^8.9.1",
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-icons": "^4.12.0",

View File

@ -14,6 +14,7 @@ import Logs from "@/pages/Logs";
import NoMatch from "@/pages/NoMatch";
import Settings from "@/pages/Settings";
import UIPlayground from "./pages/UIPlayground";
import Events from "./pages/Events";
function App() {
const [sheetOpen, setSheetOpen] = useState(false);
@ -27,14 +28,15 @@ function App() {
<BrowserRouter>
<Wrapper>
<Header onToggleNavbar={toggleNavbar} />
<div className="grid grid-cols-[auto,1fr] flex-grow-1 overflow-auto">
<div className="w-full h-full pt-2 overflow-hidden">
<Sidebar sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} />
<div
id="pageRoot"
className="overflow-x-hidden px-4 py-2 w-screen md:w-full"
className="absolute left-0 md:left-16 top-16 md:top-2 right-0 bottom-0 overflow-hidden"
>
<Routes>
<Route path="/" element={<Live />} />
<Route path="/events" element={<Events />} />
<Route path="/history" element={<History />} />
<Route path="/export" element={<Export />} />
<Route path="/storage" element={<Storage />} />

View File

@ -5,7 +5,7 @@ type TWrapperProps = {
};
const Wrapper = ({ children }: TWrapperProps) => {
return <main className="flex flex-col h-screen">{children}</main>;
return <main className="w-screen h-screen overflow-hidden">{children}</main>;
};
export default Wrapper;

View File

@ -1,5 +1,4 @@
import useSWR from "swr";
import PreviewThumbnailPlayer from "../player/PreviewThumbnailPlayer";
import { Card } from "../ui/card";
import { FrigateConfig } from "@/types/frigateConfig";
import ActivityIndicator from "../ui/activity-indicator";
@ -21,8 +20,10 @@ type HistoryCardProps = {
};
export default function HistoryCard({
// @ts-ignore
relevantPreview,
timeline,
// @ts-ignore
isMobile,
onClick,
onDelete,
@ -38,14 +39,6 @@ export default function HistoryCard({
className="cursor-pointer my-2 xs:mr-2 w-full xs:w-[48%] sm:w-[284px]"
onClick={onClick}
>
<PreviewThumbnailPlayer
camera={timeline.camera}
relevantPreview={relevantPreview}
startTs={Object.values(timeline.entries)[0].timestamp}
eventId={Object.values(timeline.entries)[0].source_id}
isMobile={isMobile}
onClick={onClick}
/>
<>
<div className="text-sm flex justify-between items-center">
<div className="pl-1 pt-1">

View File

@ -2,23 +2,26 @@ import VideoPlayer from "./VideoPlayer";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useApiHost } from "@/api";
import Player from "video.js/dist/types/player";
import { AspectRatio } from "../ui/aspect-ratio";
import { LuPlayCircle } from "react-icons/lu";
import { isCurrentHour } from "@/utils/dateUtil";
import { isSafari } from "@/utils/browserUtil";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { ReviewSegment } from "@/types/review";
import { Slider } from "../ui/slider";
import { getIconForLabel, getIconForSubLabel } from "@/utils/iconUtil";
import TimeAgo from "../dynamic/TimeAgo";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { isMobile, isSafari } from "react-device-detect";
type PreviewPlayerProps = {
camera: string;
review: ReviewSegment;
relevantPreview?: Preview;
startTs: number;
eventId: string;
isMobile: boolean;
onClick?: () => void;
autoPlayback?: boolean;
setReviewed?: () => void;
};
type Preview = {
@ -30,17 +33,43 @@ type Preview = {
};
export default function PreviewThumbnailPlayer({
camera,
review,
relevantPreview,
startTs,
eventId,
isMobile,
onClick,
autoPlayback = false,
setReviewed,
}: PreviewPlayerProps) {
const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
const playerRef = useRef<Player | null>(null);
const [visible, setVisible] = useState(false);
const [isInitiallyVisible, setIsInitiallyVisible] = useState(false);
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout | null>();
const [playback, setPlayback] = useState(false);
const [progress, setProgress] = useState(0);
const playingBack = useMemo(
() => relevantPreview && playback,
[playback, autoPlayback, relevantPreview]
);
useEffect(() => {
if (!autoPlayback) {
setPlayback(false);
if (hoverTimeout) {
clearTimeout(hoverTimeout);
}
return;
}
const timeout = setTimeout(() => {
setPlayback(true);
setHoverTimeout(null);
}, 500);
return () => {
clearTimeout(timeout);
};
}, [autoPlayback]);
const onPlayback = useCallback(
(isHovered: Boolean) => {
@ -48,205 +77,181 @@ export default function PreviewThumbnailPlayer({
return;
}
if (!playerRef.current) {
if (isHovered) {
setIsInitiallyVisible(true);
}
return;
}
if (isHovered) {
playerRef.current.play();
setHoverTimeout(
setTimeout(() => {
setPlayback(true);
setHoverTimeout(null);
}, 500)
);
} else {
playerRef.current.pause();
playerRef.current.currentTime(startTs - relevantPreview.start);
}
},
[relevantPreview, startTs, playerRef]
);
const autoPlayObserver = useRef<IntersectionObserver | null>();
const preloadObserver = useRef<IntersectionObserver | null>();
const inViewRef = useCallback(
(node: HTMLElement | null) => {
if (!preloadObserver.current) {
try {
preloadObserver.current = new IntersectionObserver(
(entries) => {
const [{ isIntersecting }] = entries;
setVisible(isIntersecting);
},
{
threshold: 0,
root: document.getElementById("pageRoot"),
rootMargin: "10% 0px 25% 0px",
}
);
if (node) preloadObserver.current.observe(node);
} catch (e) {
// no op
if (hoverTimeout) {
clearTimeout(hoverTimeout);
}
}
if (isMobile && !autoPlayObserver.current) {
try {
autoPlayObserver.current = new IntersectionObserver(
(entries) => {
const [{ isIntersecting }] = entries;
if (isIntersecting) {
onPlayback(true);
} else {
onPlayback(false);
}
},
{
threshold: 1.0,
root: document.getElementById("pageRoot"),
rootMargin: "-10% 0px -25% 0px",
}
setPlayback(false);
setProgress(0);
if (playerRef.current) {
playerRef.current.pause();
playerRef.current.currentTime(
review.start_time - relevantPreview.start
);
if (node) autoPlayObserver.current.observe(node);
} catch (e) {
// no op
}
}
},
[preloadObserver, autoPlayObserver, onPlayback]
[hoverTimeout, relevantPreview, review, playerRef]
);
return (
<AspectRatio
ref={relevantPreview ? inViewRef : null}
ratio={16 / 9}
className="bg-black flex justify-center items-center"
onMouseEnter={() => onPlayback(true)}
onMouseLeave={() => onPlayback(false)}
<div
className="relative w-full h-full cursor-pointer"
onMouseEnter={isMobile ? undefined : () => onPlayback(true)}
onMouseLeave={isMobile ? undefined : () => onPlayback(false)}
>
<PreviewContent
playerRef={playerRef}
relevantPreview={relevantPreview}
isVisible={visible}
isInitiallyVisible={isInitiallyVisible}
startTs={startTs}
camera={camera}
eventId={eventId}
isMobile={isMobile}
onClick={onClick}
/>
</AspectRatio>
{playingBack ? (
<PreviewContent
playerRef={playerRef}
review={review}
relevantPreview={relevantPreview}
playback={playingBack}
setProgress={setProgress}
setReviewed={setReviewed}
/>
) : (
<img
className="h-full w-full"
src={`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`}
/>
)}
{!playingBack &&
(review.severity == "alert" || review.severity == "detection") && (
<div className="absolute top-1 left-[6px] flex gap-1">
{review.data.objects.map((object) => {
return getIconForLabel(object, "w-3 h-3 text-white");
})}
{review.data.audio.map((audio) => {
return getIconForLabel(audio, "w-3 h-3 text-white");
})}
{review.data.sub_labels?.map((sub) => {
return getIconForSubLabel(sub, "w-3 h-3 text-white");
})}
</div>
)}
{!playingBack && (
<div className="absolute left-[6px] right-[6px] bottom-1 flex justify-between text-white">
<TimeAgo time={review.start_time * 1000} />
{config &&
formatUnixTimestampToDateTime(review.start_time, {
strftime_fmt:
config.ui.time_format == "24hour"
? "%b %-d, %H:%M"
: "%b %-d, %I:%M %p",
})}
</div>
)}
<div className="absolute top-0 left-0 right-0 rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none" />
<div className="absolute bottom-0 left-0 right-0 rounded-2xl z-10 w-full h-[10%] bg-gradient-to-t from-black/20 to-transparent pointer-events-none" />
{playingBack && (
<Slider
className="absolute left-0 right-0 bottom-0 z-10"
value={[progress]}
min={0}
step={1}
max={100}
/>
)}
{!playingBack && review.has_been_reviewed && (
<div className="absolute left-0 top-0 bottom-0 right-0 bg-black bg-opacity-60" />
)}
</div>
);
}
type PreviewContentProps = {
playerRef: React.MutableRefObject<Player | null>;
camera: string;
review: ReviewSegment;
relevantPreview: Preview | undefined;
eventId: string;
isVisible: boolean;
isInitiallyVisible: boolean;
startTs: number;
isMobile: boolean;
onClick?: () => void;
playback: boolean;
setProgress?: (progress: number) => void;
setReviewed?: () => void;
};
function PreviewContent({
playerRef,
camera,
review,
relevantPreview,
eventId,
isVisible,
isInitiallyVisible,
startTs,
isMobile,
onClick,
playback,
setProgress,
setReviewed,
}: PreviewContentProps) {
const apiHost = useApiHost();
const slowPlayack = isSafari();
// handle touchstart -> touchend as click
const [touchStart, setTouchStart] = useState(0);
const handleTouchStart = useCallback(() => {
setTouchStart(new Date().getTime());
}, []);
useEffect(() => {
if (!isMobile || !playerRef.current || !onClick) {
return;
}
playerRef.current.on("touchend", () => {
if (!onClick) {
return;
}
const touchEnd = new Date().getTime();
// consider tap less than 100 ms
if (touchEnd - touchStart < 100) {
onClick();
}
});
}, [playerRef, touchStart]);
if (relevantPreview && !isVisible) {
return <div />;
} else if (!relevantPreview && !isCurrentHour(startTs)) {
if (relevantPreview && playback) {
return (
<img
className="w-[160px]"
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
<VideoPlayer
options={{
preload: "auto",
autoplay: true,
controls: false,
muted: true,
fluid: true,
aspectRatio: "16:9",
loadingSpinner: false,
sources: relevantPreview
? [
{
src: `${relevantPreview.src}`,
type: "video/mp4",
},
]
: [],
}}
seekOptions={{}}
onReady={(player) => {
playerRef.current = player;
if (!relevantPreview) {
return;
}
// start with a bit of padding
const playerStartTime = Math.max(
0,
review.start_time - relevantPreview.start - 8
);
player.playbackRate(isSafari ? 2 : 8);
player.currentTime(playerStartTime);
player.on("timeupdate", () => {
if (!setProgress || playerRef.current?.paused()) {
return;
}
const playerProgress =
(player.currentTime() || 0) - playerStartTime;
// end with a bit of padding
const playerDuration = review.end_time - review.start_time + 8;
const playerPercent = (playerProgress / playerDuration) * 100;
if (
setReviewed &&
!review.has_been_reviewed &&
playerPercent > 50
) {
setReviewed();
}
if (playerPercent > 100) {
playerRef.current?.pause();
setProgress(100.0);
} else {
setProgress(playerPercent);
}
});
}}
onDispose={() => {
playerRef.current = null;
}}
/>
);
} else {
return (
<>
<div className="w-full">
<VideoPlayer
options={{
preload: "auto",
aspectRatio: "16:9",
autoplay: true,
controls: false,
muted: true,
loadingSpinner: false,
poster: relevantPreview
? ""
: `${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`,
sources: relevantPreview
? [
{
src: `${relevantPreview.src}`,
type: "video/mp4",
},
]
: [],
}}
seekOptions={{}}
onReady={(player) => {
playerRef.current = player;
if (!relevantPreview) {
return;
}
if (!isInitiallyVisible) {
player.pause(); // autoplay + pause is required for iOS
}
player.playbackRate(slowPlayack ? 2 : 8);
player.currentTime(startTs - relevantPreview.start);
if (isMobile && onClick) {
player.on("touchstart", handleTouchStart);
}
}}
onDispose={() => {
playerRef.current = null;
}}
/>
</div>
{relevantPreview && (
<LuPlayCircle className="absolute z-10 left-1 bottom-1 w-4 h-4 text-white text-opacity-60" />
)}
</>
);
}
}

View File

@ -47,7 +47,7 @@ export function EventReviewTimeline({
const currentTimeRef = useRef<HTMLDivElement>(null);
const observer = useRef<ResizeObserver | null>(null);
const timelineDuration = useMemo(
() => timelineEnd - timelineStart,
() => timelineStart - timelineEnd,
[timelineEnd, timelineStart]
);
@ -208,7 +208,7 @@ export function EventReviewTimeline({
return (
<div
ref={timelineRef}
className={`relative w-[120px] md:w-[100px] h-[100dvh] overflow-y-scroll no-scrollbar bg-secondary ${
className={`relative w-[120px] md:w-[100px] h-full overflow-y-scroll no-scrollbar bg-secondary ${
isDragging && showHandlebar ? "cursor-grabbing" : "cursor-auto"
}`}
>

View File

@ -1,7 +1,7 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
@ -15,12 +15,11 @@ const Slider = React.forwardRef<
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="absolute h-full bg-blue-500" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider }
export { Slider };

View File

@ -0,0 +1,59 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@ -0,0 +1,43 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@ -164,4 +164,4 @@ export const useSegmentUtils = (
getReviewed,
shouldShowRoundedCorners,
};
};
};

11
web/src/pages/Events.tsx Normal file
View File

@ -0,0 +1,11 @@
import DesktopEventView from "@/views/events/DesktopEventView";
import MobileEventView from "@/views/events/MobileEventView";
import { isMobile } from 'react-device-detect';
export default function Events() {
if (isMobile) {
return <MobileEventView />;
}
return <DesktopEventView />;
}

View File

@ -5,8 +5,8 @@ import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Event as FrigateEvent } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig";
import { isSafari } from "@/utils/browserUtil";
import { useCallback, useEffect, useMemo, useState } from "react";
import { isSafari } from "react-device-detect";
import useSWR from "swr";
function Live() {
@ -65,7 +65,6 @@ function Live() {
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const safari = isSafari();
const [windowVisible, setWindowVisible] = useState(true);
const visibilityListener = useCallback(() => {
setWindowVisible(document.visibilityState == "visible");
@ -80,7 +79,7 @@ function Live() {
}, []);
return (
<>
<div className="w-full h-full overflow-scroll">
{events && events.length > 0 && (
<ScrollArea>
<TooltipProvider>
@ -111,12 +110,12 @@ function Live() {
className={`mb-2 md:mb-0 rounded-2xl bg-black ${grow}`}
windowVisible={windowVisible}
cameraConfig={camera}
preferredLiveMode={safari ? "webrtc" : "mse"}
preferredLiveMode={isSafari ? "webrtc" : "mse"}
/>
);
})}
</div>
</>
</div>
);
}

View File

@ -41,7 +41,7 @@ function Logs() {
}, [logs]);
return (
<>
<div className="relative w-full h-full overflow-hidden">
<div className="flex justify-between items-center">
<Heading className="first:mt-2" as="h2">
Logs
@ -76,10 +76,10 @@ function Logs() {
</div>
</div>
<div className="overflow-auto font-mono text-sm bg-secondary rounded my-2 p-2 whitespace-pre-wrap">
<div className="absolute left-0 top-16 bottom-2 right-2 overflow-auto font-mono text-sm bg-secondary rounded p-2 whitespace-pre-wrap">
{logs}
</div>
</>
</div>
);
}

View File

@ -1,34 +1,34 @@
import {
LuConstruction,
LuFileUp,
LuFilm,
LuVideo,
} from "react-icons/lu";
LuConstruction,
LuFileUp,
LuFlag,
LuVideo,
} from "react-icons/lu";
export const navbarLinks = [
{
id: 1,
icon: LuVideo,
title: "Live",
url: "/",
},
{
id: 2,
icon: LuFilm,
title: "History",
url: "/history",
},
{
id: 3,
icon: LuFileUp,
title: "Export",
url: "/export",
},
{
id: 4,
icon: LuConstruction,
title: "UI Playground",
url: "/playground",
dev: true,
},
];
{
id: 1,
icon: LuVideo,
title: "Live",
url: "/",
},
{
id: 2,
icon: LuFlag,
title: "Events",
url: "/events",
},
{
id: 3,
icon: LuFileUp,
title: "Export",
url: "/export",
},
{
id: 4,
icon: LuConstruction,
title: "UI Playground",
url: "/playground",
dev: true,
},
];

View File

@ -1,20 +1,21 @@
export interface ReviewSegment {
id: string;
camera: string;
severity: ReviewSeverity;
start_time: number;
end_time: number;
thumb_path: string;
has_been_reviewed: boolean;
data: ReviewData;
}
id: string;
camera: string;
severity: ReviewSeverity;
start_time: number;
end_time: number;
thumb_path: string;
has_been_reviewed: boolean;
data: ReviewData;
}
export type ReviewSeverity = "alert" | "detection" | "significant_motion";
export type ReviewSeverity = "alert" | "detection" | "significant_motion";
export type ReviewData = {
audio: string[];
detections: string[];
objects: string[];
significant_motion_areas: number[];
zones: string[];
};
export type ReviewData = {
audio: string[];
detections: string[];
objects: string[];
sub_labels?: string[];
significant_motion_areas: number[];
zones: string[];
};

View File

@ -1,7 +0,0 @@
import { useMemo } from "react";
export function isSafari() {
return useMemo(() => {
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
}, []);
}

View File

@ -0,0 +1,49 @@
import { BsPersonWalking } from "react-icons/bs";
import {
FaAmazon,
FaCarSide,
FaCat,
FaDog,
FaFedex,
FaFire,
FaUps,
} from "react-icons/fa";
import { LuBox, LuLassoSelect } from "react-icons/lu";
import { MdRecordVoiceOver } from "react-icons/md";
export function getIconForLabel(label: string, className?: string) {
switch (label) {
case "car":
return <FaCarSide key={label} className={className} />;
case "cat":
return <FaCat key={label} className={className} />;
case "bark":
case "dog":
return <FaDog key={label} className={className} />;
case "fire_alarm":
return <FaFire key={label} className={className} />;
case "package":
return <LuBox key={label} className={className} />;
case "person":
return <BsPersonWalking key={label} className={className} />;
case "crying":
case "laughter":
case "scream":
case "speech":
case "yell":
return <MdRecordVoiceOver key={label} className={className} />;
default:
return <LuLassoSelect key={label} className={className} />;
}
}
export function getIconForSubLabel(label: string, className?: string) {
switch (label) {
case "amazon":
return <FaAmazon key={label} className={className} />;
case "fedex":
return <FaFedex key={label} className={className} />;
case "ups":
return <FaUps key={label} className={className} />;
}
}

View File

@ -0,0 +1,392 @@
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import ActivityIndicator from "@/components/ui/activity-indicator";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment, ReviewSeverity } from "@/types/review";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LuCalendar, LuFilter, LuVideo } from "react-icons/lu";
import { MdCircle } from "react-icons/md";
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
const API_LIMIT = 250;
export default function DesktopEventView() {
const { data: config } = useSWR<FrigateConfig>("config");
const [severity, setSeverity] = useState<ReviewSeverity>("alert");
const contentRef = useRef<HTMLDivElement | null>(null);
// review paging
const [after, setAfter] = useState(0);
useEffect(() => {
const now = new Date();
now.setHours(now.getHours() - 24);
setAfter(now.getTime() / 1000);
const intervalId: NodeJS.Timeout = setInterval(() => {
const now = new Date();
now.setHours(now.getHours() - 24);
setAfter(now.getTime() / 1000);
}, 60000);
return () => clearInterval(intervalId);
}, [60000]);
const reviewSearchParams = {};
const reviewSegmentFetcher = 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: ReviewSegment[]) => {
if (index > 0) {
const lastDate = prevData[prevData.length - 1].start_time;
const pagedParams = reviewSearchParams
? { before: lastDate, after: after, limit: API_LIMIT }
: {
...reviewSearchParams,
before: lastDate,
after: after,
limit: API_LIMIT,
};
return ["review", pagedParams];
}
const params = reviewSearchParams
? { limit: API_LIMIT, after: after }
: { ...reviewSearchParams, limit: API_LIMIT, after: after };
return ["review", params];
},
[reviewSearchParams]
);
const {
data: reviewPages,
mutate: updateSegments,
size,
setSize,
isValidating,
} = useSWRInfinite<ReviewSegment[]>(getKey, reviewSegmentFetcher);
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 isDone = useMemo(
() => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT,
[reviewPages]
);
// 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 && !isDone) {
setSize(size + 1);
}
});
if (node) pagingObserver.current.observe(node);
} catch (e) {
// no op
}
},
[isValidating, isDone]
);
const [minimap, setMinimap] = useState<string[]>([]);
const minimapObserver = useRef<IntersectionObserver | null>();
useEffect(() => {
if (!contentRef.current) {
return;
}
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]);
});
},
{ root: contentRef.current, threshold: 0.5 }
);
return () => {
minimapObserver.current?.disconnect();
};
}, [contentRef]);
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: Math.floor(Date.now() / 1000) - 35 * 60,
end: Math.floor(Date.now() / 1000) - 21 * 60,
};
const list = minimap.sort();
if (list.length > 0) {
data.end = parseFloat(list.at(-1)!!);
data.start = parseFloat(list[0]);
}
return data;
}, [minimap]);
// review status
const setReviewed = useCallback(
async (id: string) => {
const resp = await axios.post(`review/${id}/viewed`);
if (resp.status == 200) {
updateSegments();
}
},
[updateSegments]
);
// 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 }
);
if (!config) {
return <ActivityIndicator />;
}
console.log("end of the timeline is " + after + " vs " + (Math.floor(Date.now() / 1000) + 2 * 60 * 60))
return (
<div className="relative w-full h-full">
<div className="absolute flex justify-between left-0 top-0 right-0">
<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>
<div>
<Button className="mx-1" variant="secondary">
<LuVideo className=" mr-[10px]" />
All Cameras
</Button>
<ReviewCalendarButton />
<Button className="mx-1" variant="secondary">
<LuFilter className=" mr-[10px]" />
Filter
</Button>
</div>
</div>
<div
ref={contentRef}
className="absolute left-0 top-12 bottom-0 right-28 flex flex-wrap content-start gap-2 overflow-y-auto no-scrollbar"
>
{reviewItems[severity]?.map((value, segIdx) => {
const lastRow = segIdx == reviewItems[severity].length - 1;
const relevantPreview = Object.values(allPreviews || []).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="h-[234px] aspect-video rounded-lg overflow-hidden">
<PreviewThumbnailPlayer
review={value}
relevantPreview={relevantPreview}
setReviewed={() => setReviewed(value.id)}
/>
</div>
{lastRow && !isDone && <ActivityIndicator />}
</div>
);
})}
</div>
<div className="absolute top-12 right-0 bottom-0">
{after != 0 && (<EventReviewTimeline
segmentDuration={60}
timestampSpread={15}
timelineStart={Math.floor(Date.now() / 1000)} // start of the timeline - all times are numeric, not Date objects
timelineEnd={after} // end of timeline - timestamp
showMinimap
minimapStartTime={minimapBounds.start}
minimapEndTime={minimapBounds.end}
events={reviewItems.all}
severityType={severity}
contentRef={contentRef}
/>)}
</div>
</div>
);
}
/**
* <EventReviewTimeline
segmentDuration={60} // seconds per segment
timestampSpread={15} // minutes between each major timestamp
timelineStart={Math.floor(Date.now() / 1000)} // start of the timeline - all times are numeric, not Date objects
timelineEnd={Math.floor(Date.now() / 1000) + 2 * 60 * 60} // end of timeline - timestamp
showHandlebar // show / hide the handlebar
handlebarTime={Math.floor(Date.now() / 1000) - 27 * 60} // set the time of the handlebar
showMinimap // show / hide the minimap
minimapStartTime={Math.floor(Date.now() / 1000) - 35 * 60} // start time of the minimap - the earlier time (eg 1:00pm)
minimapEndTime={Math.floor(Date.now() / 1000) - 21 * 60} // end of the minimap - the later time (eg 3:00pm)
events={mockEvents} // events, including new has_been_reviewed and severity properties
severityType={"alert"} // choose the severity type for the middle line - all other severity types are to the right
contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later
/>
*/
function ReviewCalendarButton() {
const disabledDates = useMemo(() => {
const tomorrow = new Date();
tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0);
const future = new Date();
future.setFullYear(tomorrow.getFullYear() + 10);
return { from: tomorrow, to: future };
}, []);
return (
<Popover>
<PopoverTrigger asChild>
<Button className="mx-1" variant="secondary">
<LuCalendar className=" mr-[10px]" />
{formatUnixTimestampToDateTime(Date.now() / 1000, {
strftime_fmt: "%b %-d",
})}
</Button>
</PopoverTrigger>
<PopoverContent>
<Calendar mode="single" disabled={disabledDates} />
</PopoverContent>
</Popover>
);
}

View File

@ -0,0 +1,311 @@
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 axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MdCircle } from "react-icons/md";
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
const API_LIMIT = 250;
export default function MobileEventView() {
const { data: config } = useSWR<FrigateConfig>("config");
const [severity, setSeverity] = useState<ReviewSeverity>("alert");
const contentRef = useRef<HTMLDivElement | null>(null);
// review paging
const [after, setAfter] = useState(0);
useEffect(() => {
const now = new Date();
now.setHours(now.getHours() - 24);
setAfter(now.getTime() / 1000);
const intervalId: NodeJS.Timeout = setInterval(() => {
const now = new Date();
now.setHours(now.getHours() - 24);
setAfter(now.getTime() / 1000);
}, 60000);
return () => clearInterval(intervalId);
}, [60000]);
const reviewSearchParams = {};
const reviewSegmentFetcher = 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: ReviewSegment[]) => {
if (index > 0) {
const lastDate = prevData[prevData.length - 1].start_time;
const pagedParams = reviewSearchParams
? { before: lastDate, after: after, limit: API_LIMIT }
: {
...reviewSearchParams,
before: lastDate,
after: after,
limit: API_LIMIT,
};
return ["review", pagedParams];
}
const params = reviewSearchParams
? { limit: API_LIMIT, after: after }
: { ...reviewSearchParams, limit: API_LIMIT, after: after };
return ["review", params];
},
[reviewSearchParams]
);
const {
data: reviewPages,
mutate: updateSegments,
size,
setSize,
isValidating,
} = useSWRInfinite<ReviewSegment[]>(getKey, reviewSegmentFetcher);
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 isDone = useMemo(
() => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT,
[reviewPages]
);
// 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 && !isDone) {
setSize(size + 1);
}
});
if (node) pagingObserver.current.observe(node);
} catch (e) {
// no op
}
},
[isValidating, isDone]
);
const [minimap, setMinimap] = useState<string[]>([]);
const minimapObserver = useRef<IntersectionObserver | null>();
useEffect(() => {
if (!contentRef.current) {
return;
}
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();
};
}, [contentRef]);
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: Math.floor(Date.now() / 1000) - 35 * 60,
end: Math.floor(Date.now() / 1000) - 21 * 60,
};
const list = minimap.sort();
if (list.length > 0) {
data.end = parseFloat(list.at(-1)!!);
data.start = parseFloat(list[0]);
}
return data;
}, [minimap]);
// review status
const setReviewed = useCallback(
async (id: string) => {
const resp = await axios.post(`review/${id}/viewed`);
if (resp.status == 200) {
updateSegments();
}
},
[updateSegments]
);
// 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 }
);
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>
<div
ref={contentRef}
className="w-full h-full grid grid-cols-1 sm:grid-cols-2 mt-2 gap-2 overflow-y-auto"
>
{reviewItems[severity]?.map((value, segIdx) => {
const lastRow = segIdx == reviewItems[severity].length - 1;
const relevantPreview = Object.values(allPreviews || []).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={() => setReviewed(value.id)}
/>
</div>
{lastRow && !isDone && <ActivityIndicator />}
</div>
);
})}
</div>
</>
);
}