mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-30 18:47:40 +03:00
* i18n translated label fixes * Fix frame cache race bug Objects that were marked as false positives (that would later become true positives) would sometimes have their saved frame prematurely removed from the frame cache.
522 lines
18 KiB
TypeScript
522 lines
18 KiB
TypeScript
import { isDesktop, isIOS, isMobile } from "react-device-detect";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "../../ui/sheet";
|
|
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 { ReviewDetailPaneType, ReviewSegment } from "@/types/review";
|
|
import { Event } from "@/types/event";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
|
|
import ObjectLifecycle from "./ObjectLifecycle";
|
|
import Chip from "@/components/indicators/Chip";
|
|
import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa";
|
|
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
|
import { FaArrowsRotate } from "react-icons/fa6";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { Button } from "@/components/ui/button";
|
|
import { baseUrl } from "@/api/baseUrl";
|
|
import { shareOrCopy } from "@/utils/browserUtil";
|
|
import {
|
|
MobilePage,
|
|
MobilePageContent,
|
|
MobilePageDescription,
|
|
MobilePageHeader,
|
|
MobilePageTitle,
|
|
} from "@/components/mobile/MobilePage";
|
|
import { DownloadVideoButton } from "@/components/button/DownloadVideoButton";
|
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
|
import { LuSearch } from "react-icons/lu";
|
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
|
import { Trans, useTranslation } from "react-i18next";
|
|
import { getTranslatedLabel } from "@/utils/i18n";
|
|
|
|
type ReviewDetailDialogProps = {
|
|
review?: ReviewSegment;
|
|
setReview: (review: ReviewSegment | undefined) => void;
|
|
};
|
|
export default function ReviewDetailDialog({
|
|
review,
|
|
setReview,
|
|
}: ReviewDetailDialogProps) {
|
|
const { t } = useTranslation(["views/explore"]);
|
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
|
revalidateOnFocus: false,
|
|
});
|
|
|
|
const navigate = useNavigate();
|
|
|
|
// upload
|
|
|
|
const [upload, setUpload] = useState<Event>();
|
|
|
|
// data
|
|
|
|
const { data: events } = useSWR<Event[]>(
|
|
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 missingObjects = useMemo(() => {
|
|
if (!review || !events) {
|
|
return [];
|
|
}
|
|
|
|
const detectedIds = review.data.detections;
|
|
const missing = Array.from(
|
|
new Set(
|
|
events
|
|
.filter((event) => !detectedIds.includes(event.id))
|
|
.map((event) => event.label),
|
|
),
|
|
);
|
|
|
|
return missing;
|
|
}, [review, events]);
|
|
|
|
const formattedDate = useFormattedTimestamp(
|
|
review?.start_time ?? 0,
|
|
config?.ui.time_format == "24hour"
|
|
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
|
|
ns: "common",
|
|
})
|
|
: t("time.formattedTimestampMonthDayYearHourMinute.12hour", {
|
|
ns: "common",
|
|
}),
|
|
config?.ui.timezone,
|
|
);
|
|
|
|
// content
|
|
|
|
const [selectedEvent, setSelectedEvent] = useState<Event>();
|
|
const [pane, setPane] = useState<ReviewDetailPaneType>("overview");
|
|
|
|
// dialog and mobile page
|
|
|
|
const [isOpen, setIsOpen] = useState(review != undefined);
|
|
|
|
const handleOpenChange = useCallback(
|
|
(open: boolean) => {
|
|
setIsOpen(open);
|
|
if (!open) {
|
|
// short timeout to allow the mobile page animation
|
|
// to complete before updating the state
|
|
setTimeout(() => {
|
|
setReview(undefined);
|
|
setSelectedEvent(undefined);
|
|
setPane("overview");
|
|
}, 300);
|
|
}
|
|
},
|
|
[setReview, setIsOpen],
|
|
);
|
|
|
|
useEffect(() => {
|
|
setIsOpen(review != undefined);
|
|
// we know that these deps are correct
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [review]);
|
|
|
|
// keyboard listener
|
|
|
|
useKeyboardListener(["Esc"], (key, modifiers) => {
|
|
if (key == "Esc" && modifiers.down && !modifiers.repeat) {
|
|
setIsOpen(false);
|
|
}
|
|
});
|
|
|
|
const Overlay = isDesktop ? Sheet : MobilePage;
|
|
const Content = isDesktop ? SheetContent : MobilePageContent;
|
|
const Header = isDesktop ? SheetHeader : MobilePageHeader;
|
|
const Title = isDesktop ? SheetTitle : MobilePageTitle;
|
|
const Description = isDesktop ? SheetDescription : MobilePageDescription;
|
|
|
|
if (!review) {
|
|
return;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Overlay
|
|
open={isOpen ?? false}
|
|
onOpenChange={handleOpenChange}
|
|
enableHistoryBack={true}
|
|
>
|
|
<FrigatePlusDialog
|
|
upload={upload}
|
|
onClose={() => setUpload(undefined)}
|
|
onEventUploaded={() => {
|
|
if (upload) {
|
|
upload.plus_id = "new_upload";
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<Content
|
|
className={cn(
|
|
"scrollbar-container overflow-y-auto",
|
|
isDesktop && pane == "overview"
|
|
? "sm:max-w-xl"
|
|
: "pt-2 sm:max-w-4xl",
|
|
isMobile && "px-4",
|
|
)}
|
|
>
|
|
<span tabIndex={0} className="sr-only" />
|
|
{pane == "overview" && (
|
|
<Header className="justify-center">
|
|
<Title>{t("details.item.title")}</Title>
|
|
<Description className="sr-only">
|
|
{t("details.item.desc")}
|
|
</Description>
|
|
<div
|
|
className={cn(
|
|
"absolute flex gap-2 lg:flex-col",
|
|
isDesktop && "right-1 top-8",
|
|
isMobile && "right-0 top-3",
|
|
)}
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
aria-label={t("details.item.button.share")}
|
|
size="sm"
|
|
onClick={() =>
|
|
shareOrCopy(`${baseUrl}review?id=${review.id}`)
|
|
}
|
|
>
|
|
<FaShareAlt className="size-4 text-secondary-foreground" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipPortal>
|
|
<TooltipContent>
|
|
{t("details.item.button.share")}
|
|
</TooltipContent>
|
|
</TooltipPortal>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<DownloadVideoButton
|
|
source={`${baseUrl}api/${review.camera}/start/${review.start_time}/end/${review.end_time || Date.now() / 1000}/clip.mp4`}
|
|
camera={review.camera}
|
|
startTime={review.start_time}
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipPortal>
|
|
<TooltipContent>
|
|
{t("button.download", { ns: "common" })}
|
|
</TooltipContent>
|
|
</TooltipPortal>
|
|
</Tooltip>
|
|
</div>
|
|
</Header>
|
|
)}
|
|
{pane == "overview" && (
|
|
<div className="flex flex-col gap-5 md:mt-3">
|
|
<div className="flex w-full flex-row">
|
|
<div className="flex w-full flex-col gap-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="text-sm text-primary/40">
|
|
{t("details.camera")}
|
|
</div>
|
|
<div className="text-sm smart-capitalize">
|
|
{review.camera.replaceAll("_", " ")}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="text-sm text-primary/40">
|
|
{t("details.timestamp")}
|
|
</div>
|
|
<div className="text-sm">{formattedDate}</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex w-full flex-col items-center gap-2">
|
|
<div className="flex w-full flex-col gap-1.5 lg:pr-8">
|
|
<div className="text-sm text-primary/40">
|
|
{t("details.objects")}
|
|
</div>
|
|
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm smart-capitalize">
|
|
{events?.map((event) => {
|
|
return (
|
|
<div
|
|
key={event.id}
|
|
className="flex flex-row items-center gap-2 smart-capitalize"
|
|
>
|
|
{getIconForLabel(
|
|
event.label,
|
|
"size-3 text-primary",
|
|
)}
|
|
{event.sub_label ??
|
|
event.label.replaceAll("_", " ")}{" "}
|
|
({Math.round(event.data.top_score * 100)}%)
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<div
|
|
className="cursor-pointer"
|
|
onClick={() => {
|
|
navigate(`/explore?event_id=${event.id}`);
|
|
}}
|
|
>
|
|
<LuSearch className="size-4 text-muted-foreground" />
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipPortal>
|
|
<TooltipContent>
|
|
{t("details.item.button.viewInExplore")}
|
|
</TooltipContent>
|
|
</TooltipPortal>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
{review.data.zones.length > 0 && (
|
|
<div className="scrollbar-container flex max-h-32 w-full flex-col gap-1.5">
|
|
<div className="text-sm text-primary/40">
|
|
{t("details.zones")}
|
|
</div>
|
|
<div className="flex flex-col items-start gap-2 text-sm smart-capitalize">
|
|
{review.data.zones.map((zone) => {
|
|
return (
|
|
<div
|
|
key={zone}
|
|
className="flex flex-row items-center gap-2 smart-capitalize"
|
|
>
|
|
{zone.replaceAll("_", " ")}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{hasMismatch && (
|
|
<div className="p-4 text-center text-sm">
|
|
{(() => {
|
|
const detectedCount = Math.abs(
|
|
(events?.length ?? 0) -
|
|
(review?.data.detections.length ?? 0),
|
|
);
|
|
|
|
return t("details.item.tips.mismatch", {
|
|
count: detectedCount,
|
|
});
|
|
})()}
|
|
{missingObjects.length > 0 && (
|
|
<div className="mt-2">
|
|
<Trans
|
|
ns="views/explore"
|
|
values={{
|
|
objects: missingObjects
|
|
.map((x) => getTranslatedLabel(x))
|
|
.join(", "),
|
|
}}
|
|
>
|
|
details.item.tips.hasMissingObjects
|
|
</Trans>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="relative flex size-full flex-col gap-2">
|
|
{events?.map((event) => (
|
|
<EventItem
|
|
key={event.id}
|
|
event={event}
|
|
setPane={setPane}
|
|
setSelectedEvent={setSelectedEvent}
|
|
setUpload={setUpload}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{pane == "details" && selectedEvent && (
|
|
<div className="mt-0 flex size-full flex-col gap-2">
|
|
<ObjectLifecycle event={selectedEvent} setPane={setPane} />
|
|
</div>
|
|
)}
|
|
</Content>
|
|
</Overlay>
|
|
</>
|
|
);
|
|
}
|
|
|
|
type EventItemProps = {
|
|
event: Event;
|
|
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
|
|
setSelectedEvent: React.Dispatch<React.SetStateAction<Event | undefined>>;
|
|
setUpload?: React.Dispatch<React.SetStateAction<Event | undefined>>;
|
|
};
|
|
|
|
function EventItem({
|
|
event,
|
|
setPane,
|
|
setSelectedEvent,
|
|
setUpload,
|
|
}: EventItemProps) {
|
|
const { t } = useTranslation(["views/explore"]);
|
|
|
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
|
revalidateOnFocus: false,
|
|
});
|
|
|
|
const apiHost = useApiHost();
|
|
|
|
const imgRef = useRef(null);
|
|
|
|
const [hovered, setHovered] = useState(isMobile);
|
|
|
|
const navigate = useNavigate();
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={cn(
|
|
"relative mr-auto",
|
|
!event.has_snapshot && "flex flex-row items-center justify-center",
|
|
)}
|
|
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
|
|
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
|
|
key={event.id}
|
|
>
|
|
{event.has_snapshot && (
|
|
<>
|
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
|
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
|
|
</>
|
|
)}
|
|
<img
|
|
ref={imgRef}
|
|
className={cn(
|
|
"select-none rounded-lg object-contain transition-opacity",
|
|
)}
|
|
style={
|
|
isIOS
|
|
? {
|
|
WebkitUserSelect: "none",
|
|
WebkitTouchCallout: "none",
|
|
}
|
|
: undefined
|
|
}
|
|
draggable={false}
|
|
src={
|
|
event.has_snapshot
|
|
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
|
: `${apiHost}api/events/${event.id}/thumbnail.webp`
|
|
}
|
|
/>
|
|
{hovered && (
|
|
<div>
|
|
<div
|
|
className={cn("absolute right-1 top-1 flex items-center gap-2")}
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<a
|
|
download
|
|
href={
|
|
event.has_snapshot
|
|
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
|
: `${apiHost}api/events/${event.id}/thumbnail.webp`
|
|
}
|
|
>
|
|
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
|
|
<FaDownload className="size-4 text-white" />
|
|
</Chip>
|
|
</a>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{t("button.download", { ns: "common" })}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{event.has_snapshot &&
|
|
event.plus_id == undefined &&
|
|
event.data.type == "object" &&
|
|
config?.plus.enabled && (
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<Chip
|
|
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
|
|
onClick={() => {
|
|
setUpload?.(event);
|
|
}}
|
|
>
|
|
<FrigatePlusIcon className="size-4 text-white" />
|
|
</Chip>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{t("itemMenu.submitToPlus.label")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{event.has_clip && (
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<Chip
|
|
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
|
|
onClick={() => {
|
|
setPane("details");
|
|
setSelectedEvent(event);
|
|
}}
|
|
>
|
|
<FaArrowsRotate className="size-4 text-white" />
|
|
</Chip>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{t("itemMenu.viewObjectLifecycle.label")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{event.has_snapshot && config?.semantic_search.enabled && (
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<Chip
|
|
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
|
|
onClick={() => {
|
|
navigate(
|
|
`/explore?search_type=similarity&event_id=${event.id}`,
|
|
);
|
|
}}
|
|
>
|
|
<FaImages className="size-4 text-white" />
|
|
</Chip>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{t("itemMenu.findSimilar.label")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|