From 38718bcfc2e3d4c05ac78a802da8930b68af41cd Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 14 Aug 2024 08:02:21 -0600 Subject: [PATCH] Add support for review information side panel (#13063) --- frigate/api/event.py | 20 +++ .../overlay/detail/ReviewDetailDialog.tsx | 154 ++++++++++++++++++ .../{ => detail}/SearchDetailDialog.tsx | 8 +- .../player/PreviewThumbnailPlayer.tsx | 8 +- .../player/SearchThumbnailPlayer.tsx | 32 +--- web/src/views/events/EventView.tsx | 19 ++- web/src/views/search/SearchView.tsx | 2 +- 7 files changed, 205 insertions(+), 38 deletions(-) create mode 100644 web/src/components/overlay/detail/ReviewDetailDialog.tsx rename web/src/components/overlay/{ => detail}/SearchDetailDialog.tsx (96%) diff --git a/frigate/api/event.py b/frigate/api/event.py index 0ecb9ddbd4..525fb95159 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -251,6 +251,26 @@ def events(): return jsonify(list(events)) +@EventBp.route("/event_ids") +def event_ids(): + idString = request.args.get("ids") + ids = idString.split(",") + + if not ids: + return make_response( + jsonify({"success": False, "message": "Valid list of ids must be sent"}), + 400, + ) + + try: + events = Event.select().where(Event.id << ids).dicts().iterator() + return jsonify(list(events)) + except Exception: + return make_response( + jsonify({"success": False, "message": "Events not found"}), 400 + ) + + @EventBp.route("/events/search") def events_search(): query = request.args.get("query", type=str) diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx new file mode 100644 index 0000000000..1cdc428891 --- /dev/null +++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx @@ -0,0 +1,154 @@ +import { isDesktop, isIOS } from "react-device-detect"; +import { Sheet, SheetContent } from "../../ui/sheet"; +import { Drawer, DrawerContent } from "../../ui/drawer"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { useFormattedTimestamp } from "@/hooks/use-date-utils"; +import { getIconForLabel } from "@/utils/iconUtil"; +import { useApiHost } from "@/api"; +import { ReviewSegment } from "@/types/review"; +import { Event } from "@/types/event"; +import { useMemo } from "react"; + +type ReviewDetailDialogProps = { + review?: ReviewSegment; + setReview: (review: ReviewSegment | undefined) => void; +}; +export default function ReviewDetailDialog({ + review, + setReview, +}: ReviewDetailDialogProps) { + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + + const apiHost = useApiHost(); + + // data + + const { data: events } = useSWR( + review ? ["event_ids", { ids: review.data.detections.join(",") }] : null, + ); + + const hasMismatch = useMemo(() => { + if (!review || !events) { + return false; + } + + return events.length != review?.data.detections.length; + }, [review, events]); + + const formattedDate = useFormattedTimestamp( + review?.start_time ?? 0, + config?.ui.time_format == "24hour" + ? "%b %-d %Y, %H:%M" + : "%b %-d %Y, %I:%M %p", + ); + + // content + + const Overlay = isDesktop ? Sheet : Drawer; + const Content = isDesktop ? SheetContent : DrawerContent; + + return ( + { + if (!open) { + setReview(undefined); + } + }} + > + + {review && ( +
+
+
+
+
Camera
+
+ {review.camera.replaceAll("_", " ")} +
+
+
+
Timestamp
+
{formattedDate}
+
+
+
+
+
Objects
+
+ {events?.map((event) => { + return ( +
+ {getIconForLabel(event.label, "size-3 text-white")} + {event.sub_label ?? event.label} ( + {Math.round(event.data.top_score * 100)}%) +
+ ); + })} +
+
+ {review.data.zones.length > 0 && ( +
+
Zones
+
+ {review.data.zones.map((zone) => { + return ( +
+ {zone.replaceAll("_", " ")} +
+ ); + })} +
+
+ )} +
+
+ {hasMismatch && ( +
+ Some objects that were detected are not included in this list + because the object does not have a snapshot +
+ )} +
+ {events?.map((event) => { + return ( + + ); + })} +
+
+ )} +
+
+ ); +} diff --git a/web/src/components/overlay/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx similarity index 96% rename from web/src/components/overlay/SearchDetailDialog.tsx rename to web/src/components/overlay/detail/SearchDetailDialog.tsx index 1e9dd8c2ac..4a726a246b 100644 --- a/web/src/components/overlay/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -1,17 +1,17 @@ import { isDesktop, isIOS } from "react-device-detect"; -import { Sheet, SheetContent } from "../ui/sheet"; -import { Drawer, DrawerContent } from "../ui/drawer"; +import { Sheet, SheetContent } from "../../ui/sheet"; +import { Drawer, DrawerContent } from "../../ui/drawer"; import { SearchResult } from "@/types/search"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { getIconForLabel } from "@/utils/iconUtil"; import { useApiHost } from "@/api"; -import { Button } from "../ui/button"; +import { Button } from "../../ui/button"; import { useCallback, useEffect, useState } from "react"; import axios from "axios"; import { toast } from "sonner"; -import { Textarea } from "../ui/textarea"; +import { Textarea } from "../../ui/textarea"; type SearchDetailDialogProps = { search?: SearchResult; diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 99f2e9413a..f91b6c82ab 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -28,7 +28,7 @@ type PreviewPlayerProps = { timeRange: TimeRange; onTimeUpdate?: (time: number | undefined) => void; setReviewed: (review: ReviewSegment) => void; - onClick: (review: ReviewSegment, ctrl: boolean) => void; + onClick: (review: ReviewSegment, ctrl: boolean, detail: boolean) => void; }; export default function PreviewThumbnailPlayer({ @@ -50,7 +50,7 @@ export default function PreviewThumbnailPlayer({ const handleOnClick = useCallback( (e: React.MouseEvent) => { if (!ignoreClick) { - onClick(review, e.metaKey); + onClick(review, e.metaKey, false); } }, [ignoreClick, review, onClick], @@ -73,7 +73,7 @@ export default function PreviewThumbnailPlayer({ }); useContextMenu(imgRef, () => { - onClick(review, true); + onClick(review, true, false); }); // playback @@ -237,6 +237,7 @@ export default function PreviewThumbnailPlayer({ <> onClick(review, false, true)} > {review.data.objects.sort().map((object) => { return getIconForLabel(object, "size-3 text-white"); @@ -265,6 +266,7 @@ export default function PreviewThumbnailPlayer({ .sort() .join(", ") .replaceAll("-verified", "")} + {` Click To View Detection Details`} diff --git a/web/src/components/player/SearchThumbnailPlayer.tsx b/web/src/components/player/SearchThumbnailPlayer.tsx index 12e7cfd4f6..0f3a497c76 100644 --- a/web/src/components/player/SearchThumbnailPlayer.tsx +++ b/web/src/components/player/SearchThumbnailPlayer.tsx @@ -17,7 +17,6 @@ import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { VideoPreview } from "../preview/ScrubbablePreview"; import { Preview } from "@/types/preview"; import { SearchResult } from "@/types/search"; -import { LuInfo } from "react-icons/lu"; import useContextMenu from "@/hooks/use-contextmenu"; import { cn } from "@/lib/utils"; @@ -212,6 +211,7 @@ export default function SearchThumbnailPlayer({ <> onClick(searchResult, true)} > {getIconForLabel( searchResult.label, @@ -231,34 +231,8 @@ export default function SearchThumbnailPlayer({ .map((text) => capitalizeFirstLetter(text)) .sort() .join(", ") - .replaceAll("-verified", "")} - - - -
- -
setTooltipHovering(true)} - onMouseLeave={() => setTooltipHovering(false)} - > - -
- { - <> - onClick(searchResult, true)} - > - - - - } -
-
-
- - View Detection Details + .replaceAll("-verified", "")}{" "} + {` Click To View Detection Details`}
diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 3ca187a38b..a6af2e3319 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -52,6 +52,7 @@ import { cn } from "@/lib/utils"; import { FilterList, LAST_24_HOURS_KEY } from "@/types/filter"; import { GiSoundWaves } from "react-icons/gi"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; +import ReviewDetailDialog from "@/components/overlay/detail/ReviewDetailDialog"; type EventViewProps = { reviewItems?: SegmentedReviewData; @@ -464,6 +465,10 @@ function DetectionReview({ const segmentDuration = 60; + // detail + + const [reviewDetail, setReviewDetail] = useState(); + // preview const [previewTime, setPreviewTime] = useState(); @@ -628,6 +633,8 @@ function DetectionReview({ return ( <> + +
{ + if (detail) { + setReviewDetail(review); + } else { + onSelectReview(review, ctrl); + } + }} />