import { isDesktop, isIOS, isMobile, isSafari } from "react-device-detect"; 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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import axios from "axios"; import { toast } from "sonner"; import { Textarea } from "../../ui/textarea"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import useOptimisticState from "@/hooks/use-optimistic-state"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Event } from "@/types/event"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { FaArrowRight, FaCheckCircle, FaChevronLeft, FaChevronRight, FaMicrophone, FaCheck, FaTimes, } from "react-icons/fa"; import { TrackingDetails } from "./TrackingDetails"; import { AnnotationSettingsPane } from "./AnnotationSettingsPane"; import { DetailStreamProvider } from "@/context/detail-stream-context"; import { MobilePage, MobilePageContent, MobilePageDescription, MobilePageHeader, MobilePageTitle, } from "@/components/mobile/MobilePage"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { REVIEW_PADDING } from "@/types/review"; import { capitalizeAll } from "@/utils/stringUtil"; import useGlobalMutation from "@/hooks/use-global-mutate"; import DetailActionsMenu from "./DetailActionsMenu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import useImageLoaded from "@/hooks/use-image-loaded"; import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator"; import { GenericVideoPlayer } from "@/components/player/GenericVideoPlayer"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Drawer, DrawerContent, DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer"; import { LuInfo } from "react-icons/lu"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { FaPencilAlt } from "react-icons/fa"; import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; import { Trans, useTranslation } from "react-i18next"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { getTranslatedLabel } from "@/utils/i18n"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { DialogPortal } from "@radix-ui/react-dialog"; import { useDetailStream } from "@/context/detail-stream-context"; import { PiSlidersHorizontalBold } from "react-icons/pi"; import { HiSparkles } from "react-icons/hi"; import { useAudioTranscriptionProcessState } from "@/api/ws"; const SEARCH_TABS = ["snapshot", "tracking_details"] as const; export type SearchTab = (typeof SEARCH_TABS)[number]; type TabsWithActionsProps = { search: SearchResult; searchTabs: SearchTab[]; pageToggle: SearchTab; setPageToggle: (v: SearchTab) => void; config?: FrigateConfig; setSearch: (s: SearchResult | undefined) => void; setSimilarity?: () => void; isPopoverOpen: boolean; setIsPopoverOpen: (open: boolean) => void; dialogContainer: HTMLDivElement | null; }; function TabsWithActions({ search, searchTabs, pageToggle, setPageToggle, config, setSearch, setSimilarity, isPopoverOpen, setIsPopoverOpen, dialogContainer, }: TabsWithActionsProps) { const { t } = useTranslation(["views/explore", "views/faceLibrary"]); useEffect(() => { if (pageToggle !== "tracking_details" && isPopoverOpen) { setIsPopoverOpen(false); } }, [pageToggle, isPopoverOpen, setIsPopoverOpen]); if (!search) return null; return (
{ if (value) { setPageToggle(value); } }} > {Object.values(searchTabs).map((item) => (
{item === "snapshot" ? search?.has_snapshot ? t("type.snapshot") : t("type.thumbnail") : t(`type.${item}`)}
))}
{pageToggle === "tracking_details" && ( )}
); } type AnnotationSettingsProps = { search: SearchResult; open: boolean; setIsOpen: (open: boolean) => void; container?: HTMLElement | null; }; function AnnotationSettings({ search, open, setIsOpen, container, }: AnnotationSettingsProps) { const { t } = useTranslation(["views/explore"]); const { annotationOffset, setAnnotationOffset } = useDetailStream(); const ignoreNextOpenRef = useRef(false); useEffect(() => { setIsOpen(false); ignoreNextOpenRef.current = false; }, [search, setIsOpen]); const handleOpenChange = useCallback( (nextOpen: boolean) => { if (nextOpen) { if (ignoreNextOpenRef.current) { ignoreNextOpenRef.current = false; return; } setIsOpen(true); } else { setIsOpen(false); } }, [setIsOpen], ); const registerTriggerCloseIntent = useCallback(() => { if (open) { ignoreNextOpenRef.current = true; } }, [open]); const Overlay = isDesktop ? Popover : Drawer; const Trigger = isDesktop ? PopoverTrigger : DrawerTrigger; const Content = isDesktop ? PopoverContent : DrawerContent; const Title = isDesktop ? "div" : DrawerTitle; const contentProps = isDesktop ? { align: "end" as const, container: container ?? undefined } : {}; return (
{t("trackingDetails.adjustAnnotationSettings")}
); } type DialogContentComponentProps = { page: SearchTab; search: SearchResult; isDesktop: boolean; apiHost: string; config?: FrigateConfig; searchTabs: SearchTab[]; pageToggle: SearchTab; setPageToggle: (v: SearchTab) => void; setSearch: (s: SearchResult | undefined) => void; setInputFocused: React.Dispatch>; setSimilarity?: () => void; isPopoverOpen: boolean; setIsPopoverOpen: (open: boolean) => void; dialogContainer: HTMLDivElement | null; }; function DialogContentComponent({ page, search, isDesktop, apiHost, config, searchTabs, pageToggle, setPageToggle, setSearch, setInputFocused, setSimilarity, isPopoverOpen, setIsPopoverOpen, dialogContainer, }: DialogContentComponentProps) { if (page === "tracking_details") { return ( ) : undefined } /> ); } // Snapshot page content const snapshotElement = search.has_snapshot ? ( ) : (
); if (isDesktop) { return (
{snapshotElement}
); } // mobile return ( <> {snapshotElement} ); } type SearchDetailDialogProps = { search?: SearchResult; page: SearchTab; setSearch: (search: SearchResult | undefined) => void; setSearchPage: (page: SearchTab) => void; setSimilarity?: () => void; setInputFocused: React.Dispatch>; onPrevious?: () => void; onNext?: () => void; }; export default function SearchDetailDialog({ search, page, setSearch, setSearchPage, setSimilarity, setInputFocused, onPrevious, onNext, }: SearchDetailDialogProps) { const { t } = useTranslation(["views/explore", "views/faceLibrary"]); const { data: config } = useSWR("config", { revalidateOnFocus: false, }); const apiHost = useApiHost(); // tabs const [pageToggle, setPageToggle] = useOptimisticState( page, setSearchPage, 100, ); // dialog and mobile page const [isOpen, setIsOpen] = useState(search != undefined); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const dialogContentRef = useRef(null); const [dialogContainer, setDialogContainer] = useState( null, ); const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); if (!open) { setIsPopoverOpen(false); // short timeout to allow the mobile page animation // to complete before updating the state setTimeout(() => { setSearch(undefined); }, 300); } }, [setSearch], ); useLayoutEffect(() => { setDialogContainer(dialogContentRef.current); }, [isOpen, search?.id]); useEffect(() => { if (search) { setIsOpen(search != undefined); } }, [search]); // show/hide annotation settings is handled inside TabsWithActions const searchTabs = useMemo(() => { if (!config || !search) { return []; } const views = [...SEARCH_TABS]; if (!search.has_clip) { const index = views.indexOf("tracking_details"); views.splice(index, 1); } return views; }, [config, search]); useEffect(() => { if (searchTabs.length == 0) { return; } if (!searchTabs.includes(pageToggle)) { setSearchPage("snapshot"); } }, [pageToggle, searchTabs, setSearchPage]); if (!search) { return; } // content const Overlay = isDesktop ? Dialog : MobilePage; const Content = isDesktop ? DialogContent : MobilePageContent; const Header = isDesktop ? DialogHeader : MobilePageHeader; const Title = isDesktop ? DialogTitle : MobilePageTitle; const Description = isDesktop ? DialogDescription : MobilePageDescription; return ( {isDesktop && onPrevious && onNext && (
{t("searchResult.previousTrackedObject")} {t("searchResult.nextTrackedObject")}
)} { if (isPopoverOpen) { event.preventDefault(); } }} onInteractOutside={(e) => { if (isPopoverOpen) { e.preventDefault(); } const target = e.target as HTMLElement; if (target.closest(".nav-button")) { e.preventDefault(); } }} >
{t("trackedObjectDetails")} {t("trackedObjectDetails")}
{!isDesktop && (
)}
); } type ObjectDetailsTabProps = { search: SearchResult; config?: FrigateConfig; setSearch: (search: SearchResult | undefined) => void; setInputFocused: React.Dispatch>; }; function ObjectDetailsTab({ search, config, setSearch, setInputFocused, }: ObjectDetailsTabProps) { const { t, i18n } = useTranslation([ "views/explore", "views/faceLibrary", "components/dialog", ]); const apiHost = useApiHost(); // mutation / revalidation const mutate = useGlobalMutation(); // Helper to map over SWR cached search results while preserving // either paginated format (SearchResult[][]) or flat format (SearchResult[]) const mapSearchResults = useCallback( ( currentData: SearchResult[][] | SearchResult[] | undefined, fn: (event: SearchResult) => SearchResult, ) => { if (!currentData) return currentData; if (Array.isArray(currentData[0])) { return (currentData as SearchResult[][]).map((page) => page.map(fn)); } return (currentData as SearchResult[]).map(fn); }, [], ); // users const isAdmin = useIsAdmin(); // data const [desc, setDesc] = useState(search?.data.description); const [isSubLabelDialogOpen, setIsSubLabelDialogOpen] = useState(false); const [isLPRDialogOpen, setIsLPRDialogOpen] = useState(false); const [isEditingDesc, setIsEditingDesc] = useState(false); const originalDescRef = useRef(null); const handleDescriptionFocus = useCallback(() => { setInputFocused(true); }, [setInputFocused]); const handleDescriptionBlur = useCallback(() => { setInputFocused(false); }, [setInputFocused]); // we have to make sure the current selected search item stays in sync useEffect(() => setDesc(search?.data.description ?? ""), [search]); const formattedDate = useFormattedTimestamp( search?.start_time ?? 0, config?.ui.time_format == "24hour" ? t("time.formattedTimestampMonthDayYearHourMinute.24hour", { ns: "common", }) : t("time.formattedTimestampMonthDayYearHourMinute.12hour", { ns: "common", }), config?.ui.timezone, ); const topScore = useMemo(() => { if (!search) { return 0; } const value = search.data.top_score ?? search.top_score ?? 0; return Math.round(value * 100); }, [search]); const subLabelScore = useMemo(() => { if (!search) { return undefined; } if (search.sub_label && search.data?.sub_label_score) { return Math.round((search.data?.sub_label_score ?? 0) * 100); } else { return undefined; } }, [search]); const recognizedLicensePlateScore = useMemo(() => { if (!search) { return undefined; } if ( search.data.recognized_license_plate && search.data?.recognized_license_plate_score ) { return Math.round( (search.data?.recognized_license_plate_score ?? 0) * 100, ); } else { return undefined; } }, [search]); const snapScore = useMemo(() => { if (!search?.has_snapshot) { return undefined; } const value = search.data.score ?? search.score ?? 0; return Math.floor(value * 100); }, [search]); const averageEstimatedSpeed = useMemo(() => { if (!search || !search.data?.average_estimated_speed) { return undefined; } if (search.data?.average_estimated_speed != 0) { return search.data?.average_estimated_speed.toFixed(1); } else { return undefined; } }, [search]); const velocityAngle = useMemo(() => { if (!search || !search.data?.velocity_angle) { return undefined; } if (search.data?.velocity_angle != 0) { return search.data?.velocity_angle.toFixed(1); } else { return undefined; } }, [search]); const isEventsKey = useCallback((key: unknown): boolean => { const candidate = Array.isArray(key) ? key[0] : key; const EVENTS_KEY_PATTERNS = ["events", "events/search", "events/explore"]; return ( typeof candidate === "string" && EVENTS_KEY_PATTERNS.some((p) => candidate.includes(p)) ); }, []); const updateDescription = useCallback(() => { if (!search) { return; } axios .post(`events/${search.id}/description`, { description: desc }) .then((resp) => { if (resp.status == 200) { toast.success(t("details.tips.descriptionSaved"), { position: "top-center", }); } mutate( (key) => isEventsKey(key), (currentData: SearchResult[][] | SearchResult[] | undefined) => mapSearchResults(currentData, (event) => event.id === search.id ? { ...event, data: { ...event.data, description: desc } } : event, ), { optimisticData: true, rollbackOnError: true, revalidate: false, }, ); setSearch({ ...search, data: { ...search.data, description: desc } }); }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("details.tips.saveDescriptionFailed", { errorMessage, }), { position: "top-center", }, ); setDesc(search.data.description); }); }, [desc, search, mutate, t, mapSearchResults, isEventsKey, setSearch]); const regenerateDescription = useCallback( (source: "snapshot" | "thumbnails") => { if (!search) { return; } axios .put(`events/${search.id}/description/regenerate?source=${source}`) .then((resp) => { if (resp.status == 200) { toast.success( t("details.item.toast.success.regenerate", { provider: capitalizeAll( config?.genai.provider.replaceAll("_", " ") ?? t("generativeAI"), ), }), { position: "top-center", duration: 7000, }, ); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("details.item.toast.error.regenerate", { provider: capitalizeAll( config?.genai.provider.replaceAll("_", " ") ?? t("generativeAI"), ), errorMessage, }), { position: "top-center" }, ); }); }, [search, config, t], ); const handleSubLabelSave = useCallback( (text: string) => { if (!search) return; // set score to 1.0 if we're manually entering a sub label const subLabelScore = text === "" ? undefined : search.data?.sub_label_score || 1.0; axios .post(`${apiHost}api/events/${search.id}/sub_label`, { camera: search.camera, subLabel: text, subLabelScore: subLabelScore, }) .then((response) => { if (response.status === 200) { toast.success(t("details.item.toast.success.updatedSublabel"), { position: "top-center", }); mutate( (key) => isEventsKey(key), (currentData: SearchResult[][] | SearchResult[] | undefined) => mapSearchResults(currentData, (event) => event.id === search.id ? { ...event, sub_label: text, data: { ...event.data, sub_label_score: subLabelScore, }, } : event, ), { optimisticData: true, rollbackOnError: true, revalidate: false, }, ); setSearch({ ...search, sub_label: text, data: { ...search.data, sub_label_score: subLabelScore, }, }); setIsSubLabelDialogOpen(false); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("details.item.toast.error.updatedSublabelFailed", { errorMessage, }), { position: "top-center", }, ); }); }, [search, apiHost, mutate, setSearch, t, mapSearchResults, isEventsKey], ); // recognized plate const handleLPRSave = useCallback( (text: string) => { if (!search) return; // set score to 1.0 if we're manually entering a new plate const plateScore = text === "" ? undefined : 1.0; axios .post(`${apiHost}api/events/${search.id}/recognized_license_plate`, { recognizedLicensePlate: text, recognizedLicensePlateScore: plateScore, }) .then((response) => { if (response.status === 200) { toast.success(t("details.item.toast.success.updatedLPR"), { position: "top-center", }); mutate( (key) => isEventsKey(key), (currentData: SearchResult[][] | SearchResult[] | undefined) => mapSearchResults(currentData, (event) => event.id === search.id ? { ...event, data: { ...event.data, recognized_license_plate: text, recognized_license_plate_score: plateScore, }, } : event, ), { optimisticData: true, rollbackOnError: true, revalidate: false, }, ); setSearch({ ...search, data: { ...search.data, recognized_license_plate: text, recognized_license_plate_score: plateScore, }, }); setIsLPRDialogOpen(false); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("details.item.toast.error.updatedLPRFailed", { errorMessage, }), { position: "top-center", }, ); }); }, [search, apiHost, mutate, setSearch, t, mapSearchResults, isEventsKey], ); // speech transcription const onTranscribe = useCallback(() => { axios .put(`/audio/transcribe`, { event_id: search.id }) .then((resp) => { if (resp.status == 202) { toast.success(t("details.item.toast.success.audioTranscription"), { position: "top-center", }); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("details.item.toast.error.audioTranscription", { errorMessage, }), { position: "top-center", }, ); }); }, [search, t]); // audio transcription processing state const { payload: audioTranscriptionProcessState } = useAudioTranscriptionProcessState(); // frigate+ submission type SubmissionState = "reviewing" | "uploading" | "submitted"; const [state, setState] = useState( search?.plus_id ? "submitted" : "reviewing", ); useEffect( () => setState(search?.plus_id ? "submitted" : "reviewing"), [search], ); const onSubmitToPlus = useCallback( async (falsePositive: boolean) => { if (!search) { return; } falsePositive ? axios.put(`events/${search.id}/false_positive`) : axios.post(`events/${search.id}/plus`, { include_annotation: 1, }); setState("submitted"); setSearch({ ...search, plus_id: "new_upload" }); mutate( (key) => isEventsKey(key), (currentData: SearchResult[][] | SearchResult[] | undefined) => mapSearchResults(currentData, (event) => event.id === search.id ? { ...event, plus_id: "new_upload" } : event, ), { optimisticData: true, rollbackOnError: true, revalidate: false, }, ); }, [search, mutate, mapSearchResults, setSearch, isEventsKey], ); const popoverContainerRef = useRef(null); const canRegenerate = !!( config?.cameras[search.camera].objects.genai.enabled && search.end_time ); const showGenAIPlaceholder = !!( config?.cameras[search.camera].objects.genai.enabled && !search.end_time && (config.cameras[search.camera].objects.genai.required_zones.length === 0 || search.zones.some((zone) => config.cameras[search.camera].objects.genai.required_zones.includes( zone, ), )) && (config.cameras[search.camera].objects.genai.objects.length === 0 || config.cameras[search.camera].objects.genai.objects.includes( search.label, )) ); return (
{t("details.label")}
{getIconForLabel(search.label, "size-4 text-primary")} {getTranslatedLabel(search.label, search.data.type)} {search.sub_label && ` (${search.sub_label})`} {isAdmin && search.end_time && ( setIsSubLabelDialogOpen(true)} /> {t("details.editSubLabel.title")} )}
{t("details.topScore.label")}
Info
{t("details.topScore.info")}
{topScore}%{subLabelScore && ` (${subLabelScore}%)`}
{t("details.camera")}
{snapScore != undefined && (
{t("details.snapshotScore.label")}
{snapScore}%
)} {averageEstimatedSpeed && (
{t("details.estimatedSpeed")}
{averageEstimatedSpeed}{" "} {config?.ui.unit_system == "imperial" ? t("unit.speed.mph", { ns: "common" }) : t("unit.speed.kph", { ns: "common" })} {velocityAngle != undefined && ( )}
)}
{t("details.timestamp")}
{formattedDate}
{search?.data.recognized_license_plate && (
{t("details.recognizedLicensePlate")}
{search.data.recognized_license_plate}{" "} {recognizedLicensePlateScore && ` (${recognizedLicensePlateScore}%)`} {isAdmin && ( setIsLPRDialogOpen(true)} /> {t("details.editLPR.title")} )}
)}
{isAdmin && search.data.type === "object" && config?.plus?.enabled && search.end_time != undefined && search.has_snapshot && (
{t("explore.plus.submitToPlus.label", { ns: "components/dialog", })}
Info
{t("explore.plus.submitToPlus.desc", { ns: "components/dialog", })}
{state == "reviewing" && ( <>
{i18n.language === "en" ? ( // English with a/an logic plus label <> {/^[aeiou]/i.test(search?.label || "") ? ( explore.plus.review.question.ask_an ) : ( explore.plus.review.question.ask_a )} ) : ( // For other languages explore.plus.review.question.ask_full )}
)} {state == "uploading" && } {state == "submitted" && (
{t("explore.plus.review.state.submitted", { ns: "components/dialog", })}
)}
)}
{t("details.description.label")}
{t("button.edit", { ns: "common" })} {config?.cameras[search?.camera].audio_transcription.enabled && search?.label == "speech" && search?.end_time && ( {t("itemMenu.audioTranscription.label")} )} {canRegenerate && (
{t("details.button.regenerate.title")} {search.has_snapshot && ( regenerateDescription("snapshot")} > {t("details.regenerateFromSnapshot")} )} regenerateDescription("thumbnails")} > {t("details.regenerateFromThumbnails")}
)}
{!isEditingDesc ? ( showGenAIPlaceholder ? (
{t("details.description.aiTips")}
) : (
{desc || t("label.none", { ns: "common" })}
) ) : (