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, useMemo, 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, FaChevronDown, FaDownload, FaHistory, FaImage, FaRegListAlt, FaVideo, } from "react-icons/fa"; import { FaRotate } from "react-icons/fa6"; import ObjectLifecycle from "./ObjectLifecycle"; import { MobilePage, MobilePageContent, MobilePageDescription, MobilePageHeader, MobilePageTitle, } from "@/components/mobile/MobilePage"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { ReviewSegment } from "@/types/review"; import { useNavigate } from "react-router-dom"; import Chip from "@/components/indicators/Chip"; import { capitalizeAll } from "@/utils/stringUtil"; import useGlobalMutation from "@/hooks/use-global-mutate"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import { Card, CardContent } from "@/components/ui/card"; 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 { 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"; const SEARCH_TABS = [ "details", "snapshot", "video", "object lifecycle", ] as const; export type SearchTab = (typeof SEARCH_TABS)[number]; type SearchDetailDialogProps = { search?: SearchResult; page: SearchTab; setSearch: (search: SearchResult | undefined) => void; setSearchPage: (page: SearchTab) => void; setSimilarity?: () => void; setInputFocused: React.Dispatch>; }; export default function SearchDetailDialog({ search, page, setSearch, setSearchPage, setSimilarity, setInputFocused, }: SearchDetailDialogProps) { const { data: config } = useSWR("config", { revalidateOnFocus: false, }); // tabs const [pageToggle, setPageToggle] = useOptimisticState( page, setSearchPage, 100, ); // dialog and mobile page const [isOpen, setIsOpen] = useState(search != 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(() => { setSearch(undefined); }, 300); } }, [setSearch], ); useEffect(() => { if (search) { setIsOpen(search != undefined); } }, [search]); const searchTabs = useMemo(() => { if (!config || !search) { return []; } const views = [...SEARCH_TABS]; if (!search.has_snapshot) { const index = views.indexOf("snapshot"); views.splice(index, 1); } if (!search.has_clip) { const index = views.indexOf("video"); views.splice(index, 1); } if (search.data.type != "object" || !search.has_clip) { const index = views.indexOf("object lifecycle"); views.splice(index, 1); } return views; }, [config, search]); useEffect(() => { if (searchTabs.length == 0) { return; } if (!searchTabs.includes(pageToggle)) { setSearchPage("details"); } }, [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 (
Tracked Object Details Tracked object details
{ if (value) { setPageToggle(value); } }} > {Object.values(searchTabs).map((item) => ( {item == "details" && } {item == "snapshot" && } {item == "video" && } {item == "object lifecycle" && ( )}
{item}
))}
{page == "details" && ( )} {page == "snapshot" && ( { search.plus_id = "new_upload"; }} /> )} {page == "video" && } {page == "object lifecycle" && ( {}} /> )}
); } type ObjectDetailsTabProps = { search: SearchResult; config?: FrigateConfig; setSearch: (search: SearchResult | undefined) => void; setSimilarity?: () => void; setInputFocused: React.Dispatch>; }; function ObjectDetailsTab({ search, config, setSearch, setSimilarity, setInputFocused, }: ObjectDetailsTabProps) { const apiHost = useApiHost(); // mutation / revalidation const mutate = useGlobalMutation(); // data const [desc, setDesc] = useState(search?.data.description); const [isSubLabelDialogOpen, setIsSubLabelDialogOpen] = useState(false); 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" ? "%b %-d %Y, %H:%M" : "%b %-d %Y, %I:%M %p", config?.ui.timezone, ); const score = 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 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 updateDescription = useCallback(() => { if (!search) { return; } axios .post(`events/${search.id}/description`, { description: desc }) .then((resp) => { if (resp.status == 200) { toast.success("Successfully saved description", { position: "top-center", }); } mutate( (key) => typeof key === "string" && (key.includes("events") || key.includes("events/search") || key.includes("events/explore")), (currentData: SearchResult[][] | SearchResult[] | undefined) => { if (!currentData) return currentData; // optimistic update return currentData .flat() .map((event) => event.id === search.id ? { ...event, data: { ...event.data, description: desc } } : event, ); }, { optimisticData: true, rollbackOnError: true, revalidate: false, }, ); }) .catch(() => { toast.error("Failed to update the description", { position: "top-center", }); setDesc(search.data.description); }); }, [desc, search, mutate]); 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( `A new description has been requested from ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")}. Depending on the speed of your provider, the new description may take some time to regenerate.`, { position: "top-center", duration: 7000, }, ); } }) .catch((error) => { toast.error( `Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${error.response.data.message}`, { position: "top-center", }, ); }); }, [search, config], ); 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("Successfully updated sub label.", { position: "top-center", }); mutate( (key) => typeof key === "string" && (key.includes("events") || key.includes("events/search") || key.includes("events/explore")), (currentData: SearchResult[][] | SearchResult[] | undefined) => { if (!currentData) return currentData; return currentData.flat().map((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(() => { toast.error("Failed to update sub label.", { position: "top-center", }); }); }, [search, apiHost, mutate, setSearch], ); return (
Label
{getIconForLabel(search.label, "size-4 text-primary")} {search.label} {search.sub_label && ` (${search.sub_label})`} { setIsSubLabelDialogOpen(true); }} /> Edit sub label
Top Score
Info
The top score is the highest median score for the tracked object, so this may differ from the score shown on the search result thumbnail.
{score}%{subLabelScore && ` (${subLabelScore}%)`}
{averageEstimatedSpeed && (
Estimated Speed
{averageEstimatedSpeed && (
{averageEstimatedSpeed}{" "} {config?.ui.unit_system == "imperial" ? "mph" : "kph"}{" "} {velocityAngle != undefined && ( )}
)}
)}
Camera
{search.camera.replaceAll("_", " ")}
Timestamp
{formattedDate}
{config?.semantic_search.enabled && search.data.type == "object" && ( )}
{config?.cameras[search.camera].genai.enabled && !search.end_time && (config.cameras[search.camera].genai.required_zones.length === 0 || search.zones.some((zone) => config.cameras[search.camera].genai.required_zones.includes(zone), )) && (config.cameras[search.camera].genai.objects.length === 0 || config.cameras[search.camera].genai.objects.includes( search.label, )) ? ( <>
Description
Frigate will not request a description from your Generative AI provider until the tracked object's lifecycle has ended.
) : ( <>
Description