import SearchThumbnail from "@/components/card/SearchThumbnail"; import SearchFilterGroup from "@/components/filter/SearchFilterGroup"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import Chip from "@/components/indicators/Chip"; import SearchDetailDialog from "@/components/overlay/detail/SearchDetailDialog"; import { Toaster } from "@/components/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { FrigateConfig } from "@/types/frigateConfig"; import { SearchFilter, SearchResult, SearchSource } from "@/types/search"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isMobileOnly } from "react-device-detect"; import { LuImage, LuSearchX, LuText } from "react-icons/lu"; import useSWR from "swr"; import ExploreView from "../explore/ExploreView"; import useKeyboardListener, { KeyModifiers, } from "@/hooks/use-keyboard-listener"; import scrollIntoView from "scroll-into-view-if-needed"; import InputWithTags from "@/components/input/InputWithTags"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { isEqual } from "lodash"; type SearchViewProps = { search: string; searchTerm: string; searchFilter?: SearchFilter; searchResults?: SearchResult[]; isLoading: boolean; setSearch: (search: string) => void; setSimilaritySearch: (search: SearchResult) => void; setSearchFilter: (filter: SearchFilter) => void; onUpdateFilter: (filter: SearchFilter) => void; loadMore: () => void; hasMore: boolean; }; export default function SearchView({ search, searchTerm, searchFilter, searchResults, isLoading, setSearch, setSimilaritySearch, setSearchFilter, onUpdateFilter, loadMore, hasMore, }: SearchViewProps) { const { data: config } = useSWR("config", { revalidateOnFocus: false, }); // suggestions values const allLabels = useMemo(() => { if (!config) { return []; } const labels = new Set(); const cameras = searchFilter?.cameras || Object.keys(config.cameras); cameras.forEach((camera) => { if (camera == "birdseye") { return; } const cameraConfig = config.cameras[camera]; cameraConfig.objects.track.forEach((label) => { labels.add(label); }); if (cameraConfig.audio.enabled_in_config) { cameraConfig.audio.listen.forEach((label) => { labels.add(label); }); } }); return [...labels].sort(); }, [config, searchFilter]); const { data: allSubLabels } = useSWR("sub_labels"); const allZones = useMemo(() => { if (!config) { return []; } const zones = new Set(); const cameras = searchFilter?.cameras || Object.keys(config.cameras); cameras.forEach((camera) => { if (camera == "birdseye") { return; } const cameraConfig = config.cameras[camera]; Object.entries(cameraConfig.zones).map(([name, _]) => { zones.add(name); }); }); return [...zones].sort(); }, [config, searchFilter]); const suggestionsValues = useMemo( () => ({ cameras: Object.keys(config?.cameras || {}), labels: Object.values(allLabels || {}), zones: Object.values(allZones || {}), sub_labels: allSubLabels, search_type: ["thumbnail", "description"] as SearchSource[], }), [config, allLabels, allZones, allSubLabels], ); // remove duplicate event ids const uniqueResults = useMemo(() => { return searchResults?.filter( (value, index, self) => index === self.findIndex((v) => v.id === value.id), ); }, [searchResults]); // detail const [searchDetail, setSearchDetail] = useState(); // search interaction const [selectedIndex, setSelectedIndex] = useState(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const onSelectSearch = useCallback((item: SearchResult, index: number) => { setSearchDetail(item); setSelectedIndex(index); }, []); // update search detail when results change useEffect(() => { if (searchDetail && searchResults) { const flattenedResults = searchResults.flat(); const updatedSearchDetail = flattenedResults.find( (result) => result.id === searchDetail.id, ); if (updatedSearchDetail && !isEqual(updatedSearchDetail, searchDetail)) { setSearchDetail(updatedSearchDetail); } } }, [searchResults, searchDetail]); // confidence score - probably needs tweaking const zScoreToConfidence = (score: number, source: string) => { let midpoint, scale; if (source === "thumbnail") { midpoint = 2; scale = 0.5; } else { midpoint = 0.5; scale = 1.5; } // Sigmoid function: 1 / (1 + e^x) const confidence = 1 / (1 + Math.exp((score - midpoint) * scale)); return Math.round(confidence * 100); }; const hasExistingSearch = useMemo( () => searchResults != undefined || searchFilter != undefined, [searchResults, searchFilter], ); // keyboard listener const onKeyboardShortcut = useCallback( (key: string | null, modifiers: KeyModifiers) => { if (!modifiers.down || !uniqueResults) { return; } switch (key) { case "ArrowLeft": setSelectedIndex((prevIndex) => { const newIndex = prevIndex === null ? uniqueResults.length - 1 : (prevIndex - 1 + uniqueResults.length) % uniqueResults.length; setSearchDetail(uniqueResults[newIndex]); return newIndex; }); break; case "ArrowRight": setSelectedIndex((prevIndex) => { const newIndex = prevIndex === null ? 0 : (prevIndex + 1) % uniqueResults.length; setSearchDetail(uniqueResults[newIndex]); return newIndex; }); break; } }, [uniqueResults], ); useKeyboardListener(["ArrowLeft", "ArrowRight"], onKeyboardShortcut); // scroll into view useEffect(() => { if ( selectedIndex !== null && uniqueResults && itemRefs.current?.[selectedIndex] ) { scrollIntoView(itemRefs.current[selectedIndex], { block: "center", behavior: "smooth", scrollMode: "if-needed", }); } // we only want to scroll when the index changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedIndex]); // observer for loading more const observerTarget = useRef(null); const observerRef = useRef(null); useEffect(() => { const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasMore && !isLoading) { loadMore(); } }, { threshold: 1.0 }, ); if (observerTarget.current) { observer.observe(observerTarget.current); } observerRef.current = observer; return () => { if (observerRef.current) { observerRef.current.disconnect(); } }; }, [hasMore, isLoading, loadMore]); return (
setSimilaritySearch(searchDetail)) } />
{config?.semantic_search?.enabled && (
)} {hasExistingSearch && (
)}
{uniqueResults?.length == 0 && !isLoading && (
No Tracked Objects Found
)} {uniqueResults?.length == 0 && isLoading && (searchTerm || (searchFilter && Object.keys(searchFilter).length !== 0)) && ( )} {uniqueResults && (
{uniqueResults && uniqueResults.map((value, index) => { const selected = selectedIndex === index; return (
(itemRefs.current[index] = item)} data-start={value.start_time} className="review-item relative rounded-lg" >
setSimilaritySearch(value)} onClick={() => onSelectSearch(value, index)} /> {searchTerm && (
{value.search_source == "thumbnail" ? ( ) : ( )} {zScoreToConfidence( value.search_distance, value.search_source, )} % Matched {value.search_source} at{" "} {zScoreToConfidence( value.search_distance, value.search_source, )} %
)}
); })}
)} {uniqueResults && uniqueResults.length > 0 && ( <>
{hasMore && isLoading && }
)}
{searchFilter && Object.keys(searchFilter).length === 0 && !searchTerm && (
)}
); }