diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx index 8fabafa69..c66ab9438 100644 --- a/web/src/components/filter/SearchFilterGroup.tsx +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -6,25 +6,40 @@ import { useCallback, useMemo, useState } from "react"; import { DropdownMenuSeparator } from "../ui/dropdown-menu"; import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { FaFilter } from "react-icons/fa"; -import { isDesktop, isMobile } from "react-device-detect"; +import { isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; -import MobileReviewSettingsDrawer, { - DrawerFeatures, -} from "../overlay/MobileReviewSettingsDrawer"; import FilterSwitch from "./FilterSwitch"; import { FilterList } from "@/types/filter"; import { CalendarRangeFilterButton } from "./CalendarFilterButton"; import { CamerasFilterButton } from "./CamerasFilterButton"; import { SearchFilter, SearchSource } from "@/types/search"; import { DateRange } from "react-day-picker"; +import { cn } from "@/lib/utils"; +import SubFilterIcon from "../icons/SubFilterIcon"; +import { FaLocationDot } from "react-icons/fa6"; -const SEARCH_FILTERS = ["cameras", "date", "general"] as const; +const SEARCH_FILTERS = [ + "cameras", + "date", + "general", + "zone", + "sub", + "source", +] as const; type SearchFilters = (typeof SEARCH_FILTERS)[number]; -const DEFAULT_REVIEW_FILTERS: SearchFilters[] = ["cameras", "date", "general"]; +const DEFAULT_REVIEW_FILTERS: SearchFilters[] = [ + "cameras", + "date", + "general", + "zone", + "sub", + "source", +]; type SearchFilterGroupProps = { + className: string; filters?: SearchFilters[]; filter?: SearchFilter; filterList?: FilterList; @@ -32,12 +47,15 @@ type SearchFilterGroupProps = { }; export default function SearchFilterGroup({ + className, filters = DEFAULT_REVIEW_FILTERS, filter, filterList, onUpdateFilter, }: SearchFilterGroupProps) { - const { data: config } = useSWR("config"); + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); const allLabels = useMemo(() => { if (filterList?.labels) { @@ -70,6 +88,8 @@ export default function SearchFilterGroup({ return [...labels].sort(); }, [config, filterList, filter]); + const { data: allSubLabels } = useSWR("sub_labels"); + const allZones = useMemo(() => { if (filterList?.zones) { return filterList.zones; @@ -118,20 +138,6 @@ export default function SearchFilterGroup({ ); }, [config]); - const mobileSettingsFeatures = useMemo(() => { - const features: DrawerFeatures[] = []; - - if (filters.includes("date")) { - features.push("calendar"); - } - - if (filters.includes("general")) { - features.push("filter"); - } - - return features; - }, [filters]); - // handle updating filters const onUpdateSelectedRange = useCallback( @@ -148,7 +154,7 @@ export default function SearchFilterGroup({ ); return ( -
+
{filters.includes("cameras") && ( )} - {isDesktop && filters.includes("date") && ( + {filters.includes("date") && ( )} - {isDesktop && filters.includes("general") && ( + {filters.includes("general") && ( { onUpdateFilter({ ...filter, labels: newLabels }); }} + /> + )} + {filters.includes("zone") && allZones.length > 0 && ( + onUpdateFilter({ ...filter, zones: newZones }) } + /> + )} + {filters.includes("sub") && ( + + onUpdateFilter({ ...filter, subLabels: newSubLabels }) + } + /> + )} + {config?.semantic_search?.enabled && filters.includes("source") && ( + onUpdateFilter({ ...filter, search_type: newSearchSource }) } /> )} - {isMobile && mobileSettingsFeatures.length > 0 && ( - {}} - setRange={() => {}} - /> - )}
); } @@ -216,47 +223,29 @@ export default function SearchFilterGroup({ type GeneralFilterButtonProps = { allLabels: string[]; selectedLabels: string[] | undefined; - allZones: string[]; - selectedZones?: string[]; - selectedSearchSources: SearchSource[]; updateLabelFilter: (labels: string[] | undefined) => void; - updateZoneFilter: (zones: string[] | undefined) => void; - updateSearchSourceFilter: (sources: SearchSource[]) => void; }; function GeneralFilterButton({ allLabels, selectedLabels, - allZones, - selectedZones, - selectedSearchSources, updateLabelFilter, - updateZoneFilter, - updateSearchSourceFilter, }: GeneralFilterButtonProps) { const [open, setOpen] = useState(false); const [currentLabels, setCurrentLabels] = useState( selectedLabels, ); - const [currentZones, setCurrentZones] = useState( - selectedZones, - ); - const [currentSearchSources, setCurrentSearchSources] = useState< - SearchSource[] - >(selectedSearchSources); const trigger = ( + +
+ + ); +} + +type ZoneFilterButtonProps = { + allZones: string[]; + selectedZones?: string[]; + updateZoneFilter: (zones: string[] | undefined) => void; +}; +function ZoneFilterButton({ + allZones, + selectedZones, + updateZoneFilter, +}: ZoneFilterButtonProps) { + const [open, setOpen] = useState(false); + + const [currentZones, setCurrentZones] = useState( + selectedZones, + ); + + const trigger = ( + + ); + const content = ( + setOpen(false)} + /> + ); + + if (isMobile) { + return ( + { + if (!open) { + setCurrentZones(selectedZones); + } + + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) { + setCurrentZones(selectedZones); + } + + setOpen(open); + }} + > + {trigger} + {content} + + ); +} + +type ZoneFilterContentProps = { + allZones?: string[]; + selectedZones?: string[]; + currentZones?: string[]; + updateZoneFilter?: (zones: string[] | undefined) => void; + setCurrentZones?: (zones: string[] | undefined) => void; + onClose: () => void; +}; +export function ZoneFilterContent({ + allZones, + selectedZones, + currentZones, + updateZoneFilter, + setCurrentZones, + onClose, +}: ZoneFilterContentProps) { + return ( + <> +
{allZones && setCurrentZones && ( <> @@ -501,18 +548,10 @@ export function GeneralFilterContent({ + ); + const content = ( + setOpen(false)} + /> + ); + + if (isMobile) { + return ( + { + if (!open) { + setCurrentSubLabels(selectedSubLabels); + } + + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) { + setCurrentSubLabels(selectedSubLabels); + } + + setOpen(open); + }} + > + {trigger} + {content} + + ); +} + +type SubFilterContentProps = { + allSubLabels: string[]; + selectedSubLabels: string[] | undefined; + currentSubLabels: string[] | undefined; + updateSubLabelFilter: (labels: string[] | undefined) => void; + setCurrentSubLabels: (labels: string[] | undefined) => void; + onClose: () => void; +}; +export function SubFilterContent({ + allSubLabels, + selectedSubLabels, + currentSubLabels, + updateSubLabelFilter, + setCurrentSubLabels, + onClose, +}: SubFilterContentProps) { + return ( + <> +
+
+ + { + if (isChecked) { + setCurrentSubLabels(undefined); + } + }} + /> +
+
+ {allSubLabels.map((item) => ( + { + if (isChecked) { + const updatedLabels = currentSubLabels + ? [...currentSubLabels] + : []; + + updatedLabels.push(item); + setCurrentSubLabels(updatedLabels); + } else { + const updatedLabels = currentSubLabels + ? [...currentSubLabels] + : []; + + // can not deselect the last item + if (updatedLabels.length > 1) { + updatedLabels.splice(updatedLabels.indexOf(item), 1); + setCurrentSubLabels(updatedLabels); + } + } + }} + /> + ))} +
+
+ +
+ + +
+ + ); +} + +type SearchTypeButtonProps = { + selectedSearchSources: SearchSource[]; + updateSearchSourceFilter: (sources: SearchSource[]) => void; +}; +function SearchTypeButton({ + selectedSearchSources, + updateSearchSourceFilter, +}: SearchTypeButtonProps) { + const [open, setOpen] = useState(false); + const [currentSearchSources, setCurrentSearchSources] = useState< + SearchSource[] + >(selectedSearchSources); + + const trigger = ( + + ); + const content = ( + setOpen(false)} + /> + ); + + if (isMobile) { + return ( + { + if (!open) { + setCurrentSearchSources(selectedSearchSources); + } + + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) { + setCurrentSearchSources(selectedSearchSources); + } + + setOpen(open); + }} + > + {trigger} + {content} + + ); +} + +type SearchTypeContentProps = { + selectedSearchSources: SearchSource[]; + currentSearchSources: SearchSource[]; + setCurrentSearchSources: (sources: SearchSource[]) => void; + updateSearchSourceFilter: (sources: SearchSource[]) => void; + onClose: () => void; +}; +export function SearchTypeContent({ + selectedSearchSources, + currentSearchSources, + setCurrentSearchSources, + updateSearchSourceFilter, + onClose, +}: SearchTypeContentProps) { + return ( + <> +
+
+ { + const updatedSources = currentSearchSources + ? [...currentSearchSources] + : []; + + if (isChecked) { + updatedSources.push("thumbnail"); + setCurrentSearchSources(updatedSources); + } else { + if (updatedSources.length > 1) { + const index = updatedSources.indexOf("thumbnail"); + if (index !== -1) updatedSources.splice(index, 1); + setCurrentSearchSources(updatedSources); + } + } + }} + /> + { + const updatedSources = currentSearchSources + ? [...currentSearchSources] + : []; + + if (isChecked) { + updatedSources.push("description"); + setCurrentSearchSources(updatedSources); + } else { + if (updatedSources.length > 1) { + const index = updatedSources.indexOf("description"); + if (index !== -1) updatedSources.splice(index, 1); + setCurrentSearchSources(updatedSources); + } + } + }} + /> +
+ +
+ + +
+
+ + ); +} diff --git a/web/src/components/icons/SubFilterIcon.tsx b/web/src/components/icons/SubFilterIcon.tsx new file mode 100644 index 000000000..1bcb4b64e --- /dev/null +++ b/web/src/components/icons/SubFilterIcon.tsx @@ -0,0 +1,25 @@ +import { forwardRef } from "react"; +import { cn } from "@/lib/utils"; +import { FaCog, FaFilter } from "react-icons/fa"; + +type SubFilterIconProps = { + className?: string; + onClick?: () => void; +}; + +const SubFilterIcon = forwardRef( + ({ className, onClick }, ref) => { + return ( +
+ + +
+ ); + }, +); + +export default SubFilterIcon; diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 368d88dea..00e612248 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -114,12 +114,15 @@ export default function SearchDetailDialog({
{getIconForLabel(search.label, "size-4 text-primary")} {search.label} + {search.sub_label && ` (${search.sub_label})`}
Score
- {Math.round(search.score * 100)}% + {Math.round(search.data.top_score * 100)}% + {search.sub_label && + ` (${Math.round((search.data.sub_label_score ?? 0) * 100)}%)`}
diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 2d91c5e3d..409618b86 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -329,7 +329,9 @@ function PreviewContent({ } else if (isCurrentHour(review.start_time)) { return ( Date.now() / 1000, []); if (relevantPreview) { return ( @@ -287,6 +288,21 @@ function PreviewContent({ /> ); } else if (isCurrentHour(searchResult.start_time)) { - return
; + return ( + {}} + /> + ); } } diff --git a/web/src/components/preview/ScrubbablePreview.tsx b/web/src/components/preview/ScrubbablePreview.tsx index eb9a962a8..f3aa4f158 100644 --- a/web/src/components/preview/ScrubbablePreview.tsx +++ b/web/src/components/preview/ScrubbablePreview.tsx @@ -6,7 +6,6 @@ import React, { useState, } from "react"; import { useApiHost } from "@/api"; -import { ReviewSegment } from "@/types/review"; import useSWR from "swr"; import { isFirefox, isMobile, isSafari } from "react-device-detect"; import { TimelineScrubMode, TimeRange } from "@/types/timeline"; @@ -286,21 +285,27 @@ export function VideoPreview({ const MIN_LOAD_TIMEOUT_MS = 200; type InProgressPreviewProps = { - review: ReviewSegment; + camera: string; + startTime: number; + endTime?: number; timeRange: TimeRange; showProgress?: boolean; loop?: boolean; - setReviewed: (reviewId: string) => void; + defaultImageUrl?: string; + setReviewed: () => void; setIgnoreClick: (ignore: boolean) => void; isPlayingBack: (ended: boolean) => void; onTimeUpdate?: (time: number | undefined) => void; windowVisible: boolean; }; export function InProgressPreview({ - review, + camera, + startTime, + endTime, timeRange, showProgress = true, loop = false, + defaultImageUrl, setReviewed, setIgnoreClick, isPlayingBack, @@ -310,8 +315,8 @@ export function InProgressPreview({ const apiHost = useApiHost(); const sliderRef = useRef(null); const { data: previewFrames } = useSWR( - `preview/${review.camera}/start/${Math.floor(review.start_time) - PREVIEW_PADDING}/end/${ - Math.ceil(review.end_time ?? timeRange.before) + PREVIEW_PADDING + `preview/${camera}/start/${Math.floor(startTime) - PREVIEW_PADDING}/end/${ + Math.ceil(endTime ?? timeRange.before) + PREVIEW_PADDING }/frames`, { revalidateOnFocus: false }, ); @@ -326,7 +331,7 @@ export function InProgressPreview({ } if (onTimeUpdate) { - onTimeUpdate(review.start_time - PREVIEW_PADDING + key); + onTimeUpdate(startTime - PREVIEW_PADDING + key); } if (playbackMode != "auto") { @@ -334,9 +339,7 @@ export function InProgressPreview({ } if (key == previewFrames.length - 1) { - if (!review.has_been_reviewed) { - setReviewed(review.id); - } + setReviewed(); if (loop) { setKey(0); @@ -356,7 +359,7 @@ export function InProgressPreview({ setTimeout(() => { if (setReviewed && key == Math.floor(previewFrames.length / 2)) { - setReviewed(review.id); + setReviewed(); } if (previewFrames[key + 1]) { @@ -377,11 +380,7 @@ export function InProgressPreview({ const onManualSeek = useCallback( (values: number[]) => { const value = values[0]; - - if (!review.has_been_reviewed) { - setReviewed(review.id); - } - + setReviewed(); setKey(value); }, @@ -424,7 +423,7 @@ export function InProgressPreview({ return ( ); } diff --git a/web/src/hooks/use-api-filter.ts b/web/src/hooks/use-api-filter.ts index 13fc12ef8..79269baee 100644 --- a/web/src/hooks/use-api-filter.ts +++ b/web/src/hooks/use-api-filter.ts @@ -1,5 +1,26 @@ import { FilterType } from "@/types/filter"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; + +function getStringifiedArgs(filter: FilterType) { + const search: { [key: string]: string } = {}; + + Object.entries(filter).forEach(([key, value]) => { + if (Array.isArray(value)) { + if (value.length == 0) { + // empty array means all so ignore + } else { + search[key] = value.join(","); + } + } else { + if (value != undefined) { + search[key] = `${value}`; + } + } + }); + + return search; +} type useApiFilterReturn = [ filter: F | undefined, @@ -20,23 +41,48 @@ export default function useApiFilter< return {}; } - const search: { [key: string]: string } = {}; - - Object.entries(filter).forEach(([key, value]) => { - if (Array.isArray(value)) { - if (value.length == 0) { - // empty array means all so ignore - } else { - search[key] = value.join(","); - } - } else { - if (value != undefined) { - search[key] = `${value}`; - } - } - }); - - return search; + return getStringifiedArgs(filter); + }, [filter]); + + return [filter, setFilter, searchParams]; +} + +export function useApiFilterArgs< + F extends FilterType, +>(): useApiFilterReturn { + const [rawParams, setRawParams] = useSearchParams(); + + const setFilter = useCallback( + (newFilter: F) => setRawParams(getStringifiedArgs(newFilter)), + [setRawParams], + ); + + const filter = useMemo(() => { + if (rawParams.size == 0) { + return {} as F; + } + + const filter: { [key: string]: unknown } = {}; + + rawParams.forEach((value, key) => { + if (isNaN(parseFloat(value))) { + filter[key] = value.includes(",") ? value.split(",") : [value]; + } else { + if (value != undefined) { + filter[key] = `${value}`; + } + } + }); + + return filter as F; + }, [rawParams]); + + const searchParams = useMemo(() => { + if (filter == undefined || Object.keys(filter).length == 0) { + return {}; + } + + return getStringifiedArgs(filter); }, [filter]); return [filter, setFilter, searchParams]; diff --git a/web/src/hooks/use-navigation.ts b/web/src/hooks/use-navigation.ts index fde200839..86ca7b1b7 100644 --- a/web/src/hooks/use-navigation.ts +++ b/web/src/hooks/use-navigation.ts @@ -19,7 +19,9 @@ export const ID_PLAYGROUND = 6; export default function useNavigation( variant: "primary" | "secondary" = "primary", ) { - const { data: config } = useSWR("config"); + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); return useMemo( () => @@ -44,7 +46,6 @@ export default function useNavigation( icon: IoSearch, title: "Search", url: "/search", - enabled: config?.semantic_search?.enabled, }, { id: ID_EXPORT, @@ -70,6 +71,6 @@ export default function useNavigation( enabled: ENV !== "production", }, ] as NavData[], - [config?.plus.enabled, config?.semantic_search.enabled, variant], + [config?.plus.enabled, variant], ); } diff --git a/web/src/pages/Search.tsx b/web/src/pages/Search.tsx index 644167173..80e9846cb 100644 --- a/web/src/pages/Search.tsx +++ b/web/src/pages/Search.tsx @@ -1,4 +1,4 @@ -import useApiFilter from "@/hooks/use-api-filter"; +import { useApiFilterArgs } from "@/hooks/use-api-filter"; import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { useOverlayState } from "@/hooks/use-overlay-state"; import { FrigateConfig } from "@/types/frigateConfig"; @@ -27,7 +27,7 @@ export default function Search() { // search filter const [searchFilter, setSearchFilter, searchSearchParams] = - useApiFilter(); + useApiFilterArgs(); const onUpdateFilter = useCallback( (newFilter: SearchFilter) => { @@ -60,10 +60,6 @@ export default function Search() { }, [search]); const searchQuery = useMemo(() => { - if (searchTerm.length == 0) { - return null; - } - if (similaritySearch) { return [ "events/search", @@ -71,6 +67,7 @@ export default function Search() { query: similaritySearch.id, cameras: searchSearchParams["cameras"], labels: searchSearchParams["labels"], + sub_labels: searchSearchParams["subLabels"], zones: searchSearchParams["zones"], before: searchSearchParams["before"], after: searchSearchParams["after"], @@ -80,16 +77,35 @@ export default function Search() { ]; } + if (searchTerm) { + return [ + "events/search", + { + query: searchTerm, + cameras: searchSearchParams["cameras"], + labels: searchSearchParams["labels"], + sub_labels: searchSearchParams["subLabels"], + zones: searchSearchParams["zones"], + before: searchSearchParams["before"], + after: searchSearchParams["after"], + search_type: searchSearchParams["search_type"], + include_thumbnails: 0, + }, + ]; + } + return [ - "events/search", + "events", { - query: searchTerm, cameras: searchSearchParams["cameras"], labels: searchSearchParams["labels"], + sub_labels: searchSearchParams["subLabels"], zones: searchSearchParams["zones"], before: searchSearchParams["before"], after: searchSearchParams["after"], search_type: searchSearchParams["search_type"], + limit: Object.keys(searchSearchParams).length == 0 ? 20 : null, + in_progress: 0, include_thumbnails: 0, }, ]; diff --git a/web/src/types/search.ts b/web/src/types/search.ts index 1bb12731c..673644e1b 100644 --- a/web/src/types/search.ts +++ b/web/src/types/search.ts @@ -13,11 +13,22 @@ export type SearchResult = { zones: string[]; search_source: SearchSource; search_distance: number; + data: { + top_score: number; + score: number; + sub_label_score?: number; + region: number[]; + box: number[]; + area: number; + ratio: number; + type: "object" | "audio" | "manual"; + }; }; export type SearchFilter = { cameras?: string[]; labels?: string[]; + subLabels?: string[]; zones?: string[]; before?: number; after?: number; diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index 57a6beeb4..06bac13e0 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -11,19 +11,13 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { SearchFilter, SearchResult } from "@/types/search"; import { useCallback, useMemo, useState } from "react"; import { isMobileOnly } from "react-device-detect"; -import { - LuExternalLink, - LuImage, - LuSearchCheck, - LuSearchX, - LuText, - LuXCircle, -} from "react-icons/lu"; -import { Link } from "react-router-dom"; +import { LuImage, LuSearchX, LuText, LuXCircle } from "react-icons/lu"; +import useSWR from "swr"; type SearchViewProps = { search: string; @@ -51,6 +45,10 @@ export default function SearchView({ onUpdateFilter, onOpenSearch, }: SearchViewProps) { + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + // remove duplicate event ids const uniqueResults = useMemo(() => { @@ -96,6 +94,11 @@ export default function SearchView({ return Math.round(confidence * 100); }; + const hasExistingSearch = useMemo( + () => searchResults != undefined || searchFilter != undefined, + [searchResults, searchFilter], + ); + return (
@@ -107,52 +110,49 @@ export default function SearchView({ } /> -
-
- setSearch(e.target.value)} - /> - {search && ( - setSearch("")} +
+ {config?.semantic_search?.enabled && ( +
+ setSearch(e.target.value)} /> - )} -
- - -
- -
- {searchTerm.length == 0 && ( -
- - Search -
- Frigate can find detected objects in your review items. -
-
- - Read the Documentation{" "} - - -
+ {search && ( + setSearch("")} + /> + )}
)} + {hasExistingSearch && ( + + )} +
+ +
{searchTerm.length > 0 && searchResults?.length == 0 && (
@@ -186,34 +186,36 @@ export default function SearchView({ scrollLock={false} onClick={onSelectSearch} /> -
- - - - {value.search_source == "thumbnail" ? ( - - ) : ( - - )} + {searchTerm && ( +
+ + + + {value.search_source == "thumbnail" ? ( + + ) : ( + + )} + {zScoreToConfidence( + value.search_distance, + value.search_source, + )} + % + + + + Matched {value.search_source} at{" "} {zScoreToConfidence( value.search_distance, value.search_source, )} % - - - - Matched {value.search_source} at{" "} - {zScoreToConfidence( - value.search_distance, - value.search_source, - )} - % - - -
+ +
+
+ )}