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";
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 (
);
}
// 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.data.type != "object" || !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 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) =>
typeof key === "string" &&
(key.includes("events") ||
key.includes("events/search") ||
key.includes("events/explore")),
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
mapSearchResults(currentData, (event) =>
event.id === search.id
? { ...event, data: { ...event.data, description: desc } }
: event,
),
{
optimisticData: true,
rollbackOnError: true,
revalidate: false,
},
);
})
.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]);
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) =>
typeof key === "string" &&
(key.includes("events") ||
key.includes("events/search") ||
key.includes("events/explore")),
(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],
);
// 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) =>
typeof key === "string" &&
(key.includes("events") ||
key.includes("events/search") ||
key.includes("events/explore")),
(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],
);
// 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]);
// 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");
mutate(
(key) =>
typeof key === "string" &&
(key.includes("events") ||
key.includes("events/search") ||
key.includes("events/explore")),
(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],
);
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}%)`}
{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")}
)}
)}
{search.data.type === "object" &&
config?.plus?.enabled &&
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" })}
)
) : (
)}
);
}
type ObjectSnapshotTabProps = {
search: Event;
className?: string;
onEventUploaded?: () => void;
};
export function ObjectSnapshotTab({
search,
className,
}: ObjectSnapshotTabProps) {
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
return (
{search?.id && (

{
onImgLoad();
}}
/>
)}
);
}
type VideoTabProps = {
search: SearchResult;
};
export function VideoTab({ search }: VideoTabProps) {
const clipTimeRange = useMemo(() => {
const startTime = search.start_time - REVIEW_PADDING;
const endTime = (search.end_time ?? Date.now() / 1000) + REVIEW_PADDING;
return `start/${startTime}/end/${endTime}`;
}, [search]);
const source = `${baseUrl}vod/${search.camera}/${clipTimeRange}/index.m3u8`;
return (
<>
>
);
}