diff --git a/web/src/App.tsx b/web/src/App.tsx index 5123e3b0c..3bc2e7836 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,7 +15,6 @@ const Live = lazy(() => import("@/pages/Live")); const Events = lazy(() => import("@/pages/Events")); const Explore = lazy(() => import("@/pages/Explore")); const Exports = lazy(() => import("@/pages/Exports")); -const SubmitPlus = lazy(() => import("@/pages/SubmitPlus")); const ConfigEditor = lazy(() => import("@/pages/ConfigEditor")); const System = lazy(() => import("@/pages/System")); const Settings = lazy(() => import("@/pages/Settings")); @@ -47,7 +46,6 @@ function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx index 261738d82..958149923 100644 --- a/web/src/components/filter/SearchFilterGroup.tsx +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -43,14 +43,15 @@ type SearchFilterGroupProps = { className: string; filters?: SearchFilters[]; filter?: SearchFilter; + searchTerm: string; filterList?: FilterList; onUpdateFilter: (filter: SearchFilter) => void; }; - export default function SearchFilterGroup({ className, filters = DEFAULT_REVIEW_FILTERS, filter, + searchTerm, filterList, onUpdateFilter, }: SearchFilterGroupProps) { @@ -213,16 +214,18 @@ export default function SearchFilterGroup({ } /> )} - {config?.semantic_search?.enabled && filters.includes("source") && ( - - onUpdateFilter({ ...filter, search_type: newSearchSource }) - } - /> - )} + {config?.semantic_search?.enabled && + filters.includes("source") && + !searchTerm.includes("similarity:") && ( + + onUpdateFilter({ ...filter, search_type: newSearchSource }) + } + /> + )} ); } diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 89928c986..a67ec3b4a 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -33,8 +33,11 @@ import HlsVideoPlayer from "@/components/player/HlsVideoPlayer"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; +import { FaRegListAlt, FaVideo } from "react-icons/fa"; +import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; -const SEARCH_TABS = ["details", "Frigate+", "video"] as const; +const SEARCH_TABS = ["details", "frigate+", "video"] as const; type SearchTab = (typeof SEARCH_TABS)[number]; type SearchDetailDialogProps = { @@ -64,7 +67,7 @@ export default function SearchDetailDialog({ const views = [...SEARCH_TABS]; if (!config.plus.enabled || !search.has_snapshot) { - const index = views.indexOf("Frigate+"); + const index = views.indexOf("frigate+"); views.splice(index, 1); } @@ -132,6 +135,9 @@ export default function SearchDetailDialog({ data-nav-item={item} aria-label={`Select ${item}`} > + {item == "details" && } + {item == "frigate+" && } + {item == "video" && }
{item}
))} @@ -147,7 +153,7 @@ export default function SearchDetailDialog({ setSimilarity={setSimilarity} /> )} - {page == "Frigate+" && ( + {page == "frigate+" && ( )} - {page == "video" && } + {page == "video" && } ); @@ -311,28 +317,79 @@ function ObjectDetailsTab({ type VideoTabProps = { search: SearchResult; + config?: FrigateConfig; }; -function VideoTab({ search }: VideoTabProps) { +function VideoTab({ search, config }: VideoTabProps) { const [isLoading, setIsLoading] = useState(true); const videoRef = useRef(null); const endTime = useMemo(() => search.end_time ?? Date.now() / 1000, [search]); + const mainCameraAspect = useMemo(() => { + const camera = config?.cameras?.[search.camera]; + + if (!camera) { + return "normal"; + } + + const aspectRatio = camera.detect.width / camera.detect.height; + + if (!aspectRatio) { + return "normal"; + } else if (aspectRatio > ASPECT_WIDE_LAYOUT) { + return "wide"; + } else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) { + return "tall"; + } else { + return "normal"; + } + }, [config, search]); + + const containerClassName = useMemo(() => { + if (mainCameraAspect == "wide") { + return "flex justify-center items-center"; + } else if (mainCameraAspect == "tall") { + if (isDesktop) { + return "size-full flex flex-col justify-center items-center"; + } else { + return "size-full"; + } + } else { + return ""; + } + }, [mainCameraAspect]); + + const videoClassName = useMemo(() => { + if (mainCameraAspect == "wide") { + return "w-full aspect-wide"; + } else if (mainCameraAspect == "tall") { + if (isDesktop) { + return "w-[50%] aspect-tall flex justify-center"; + } else { + return "size-full"; + } + } else { + return "w-full aspect-video"; + } + }, [mainCameraAspect]); + return ( - <> +
{isLoading && ( )} - setIsLoading(false)} - /> - +
+ setIsLoading(false)} + /> +
+
); } diff --git a/web/src/hooks/use-navigation.ts b/web/src/hooks/use-navigation.ts index 6a43483f8..06ebd6c1d 100644 --- a/web/src/hooks/use-navigation.ts +++ b/web/src/hooks/use-navigation.ts @@ -1,28 +1,20 @@ -import Logo from "@/components/Logo"; import { ENV } from "@/env"; -import { FrigateConfig } from "@/types/frigateConfig"; import { NavData } from "@/types/navigation"; import { useMemo } from "react"; import { FaCompactDisc, FaVideo } from "react-icons/fa"; import { IoSearch } from "react-icons/io5"; import { LuConstruction } from "react-icons/lu"; import { MdVideoLibrary } from "react-icons/md"; -import useSWR from "swr"; export const ID_LIVE = 1; export const ID_REVIEW = 2; export const ID_EXPLORE = 3; export const ID_EXPORT = 4; -export const ID_PLUS = 5; -export const ID_PLAYGROUND = 6; +export const ID_PLAYGROUND = 5; export default function useNavigation( variant: "primary" | "secondary" = "primary", ) { - const { data: config } = useSWR("config", { - revalidateOnFocus: false, - }); - return useMemo( () => [ @@ -54,14 +46,6 @@ export default function useNavigation( title: "Export", url: "/export", }, - { - id: ID_PLUS, - variant, - icon: Logo, - title: "Frigate+", - url: "/plus", - enabled: config?.plus?.enabled == true, - }, { id: ID_PLAYGROUND, variant, @@ -71,6 +55,6 @@ export default function useNavigation( enabled: ENV !== "production", }, ] as NavData[], - [config?.plus.enabled, variant], + [variant], ); } diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index de13a9ee0..750c75fde 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -3,12 +3,7 @@ import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; import { FrigateConfig } from "@/types/frigateConfig"; import { RecordingStartingPoint } from "@/types/record"; -import { - PartialSearchResult, - SearchFilter, - SearchResult, - SearchSource, -} from "@/types/search"; +import { SearchFilter, SearchResult } from "@/types/search"; import { TimeRange } from "@/types/timeline"; import { RecordingView } from "@/views/recording/RecordingView"; import SearchView from "@/views/search/SearchView"; @@ -31,62 +26,26 @@ export default function Explore() { // search filter - const [similaritySearch, setSimilaritySearch] = - useState(); + const similaritySearch = useMemo(() => { + if (!searchTerm.includes("similarity:")) { + return undefined; + } + + return searchTerm.split(":")[1]; + }, [searchTerm]); const [searchFilter, setSearchFilter, searchSearchParams] = useApiFilterArgs(); - const onUpdateFilter = useCallback( - (newFilter: SearchFilter) => { - setSearchFilter(newFilter); - - if (similaritySearch && !newFilter.search_type?.includes("similarity")) { - setSimilaritySearch(undefined); - } - }, - [similaritySearch, setSearchFilter], - ); - // search api - const updateFilterWithSimilarity = useCallback( - (similaritySearch: PartialSearchResult) => { - let newFilter = searchFilter; - setSimilaritySearch(similaritySearch); - if (similaritySearch) { - newFilter = { - ...searchFilter, - // @ts-expect-error we want to set this - similarity_search_id: undefined, - search_type: ["similarity"] as SearchSource[], - }; - } else { - if (searchFilter?.search_type?.includes("similarity" as SearchSource)) { - newFilter = { - ...searchFilter, - // @ts-expect-error we want to set this - similarity_search_id: undefined, - search_type: undefined, - }; - } - } - if (newFilter) { - setSearchFilter(newFilter); - } - }, - [searchFilter, setSearchFilter], - ); - useSearchEffect("similarity_search_id", (similarityId) => { - updateFilterWithSimilarity({ id: similarityId }); + setSearch(`similarity:${similarityId}`); + // @ts-expect-error we want to clear this + setSearchFilter({ ...searchFilter, similarity_search_id: undefined }); }); useEffect(() => { - if (similaritySearch) { - setSimilaritySearch(undefined); - } - if (searchTimeout) { clearTimeout(searchTimeout); } @@ -106,7 +65,7 @@ export default function Explore() { return [ "events/search", { - query: similaritySearch.id, + query: similaritySearch, cameras: searchSearchParams["cameras"], labels: searchSearchParams["labels"], sub_labels: searchSearchParams["subLabels"], @@ -241,7 +200,7 @@ export default function Explore() { allCameras={selectedReviewData.allCameras} allPreviews={allPreviews} timeRange={selectedTimeRange} - updateFilter={onUpdateFilter} + updateFilter={setSearchFilter} /> ); } @@ -254,9 +213,8 @@ export default function Explore() { searchResults={searchResults} isLoading={isLoading} setSearch={setSearch} - similaritySearch={similaritySearch} - setSimilaritySearch={updateFilterWithSimilarity} - onUpdateFilter={onUpdateFilter} + setSimilaritySearch={(search) => setSearch(`similarity:${search.id}`)} + onUpdateFilter={setSearchFilter} onOpenSearch={onOpenSearch} /> ); diff --git a/web/src/pages/SubmitPlus.tsx b/web/src/pages/SubmitPlus.tsx deleted file mode 100644 index 96fc21787..000000000 --- a/web/src/pages/SubmitPlus.tsx +++ /dev/null @@ -1,636 +0,0 @@ -import { baseUrl } from "@/api/baseUrl"; -import { CamerasFilterButton } from "@/components/filter/CamerasFilterButton"; -import { GeneralFilterContent } from "@/components/filter/ReviewFilterGroup"; -import Chip from "@/components/indicators/Chip"; -import ActivityIndicator from "@/components/indicators/activity-indicator"; -import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog"; -import { Button } from "@/components/ui/button"; -import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { DualThumbSlider } from "@/components/ui/slider"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { Event } from "@/types/event"; -import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig"; -import { getIconForLabel } from "@/utils/iconUtil"; -import { capitalizeFirstLetter } from "@/utils/stringUtil"; -import axios from "axios"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { isMobile } from "react-device-detect"; -import { - FaList, - FaSort, - FaSortAmountDown, - FaSortAmountUp, -} from "react-icons/fa"; -import { LuFolderX } from "react-icons/lu"; -import { PiSlidersHorizontalFill } from "react-icons/pi"; -import useSWR from "swr"; -import useSWRInfinite from "swr/infinite"; - -const API_LIMIT = 100; - -export default function SubmitPlus() { - // title - - useEffect(() => { - document.title = "Plus - Frigate"; - }, []); - - // filters - - const [selectedCameras, setSelectedCameras] = useState(); - const [selectedLabels, setSelectedLabels] = useState(); - const [scoreRange, setScoreRange] = useState(); - - // sort - - const [sort, setSort] = useState(); - - // data - - const eventFetcher = useCallback((key: string) => { - const [path, params] = Array.isArray(key) ? key : [key, undefined]; - return axios.get(path, { params }).then((res) => res.data); - }, []); - - const getKey = useCallback( - (index: number, prevData: Event[]) => { - if (index > 0) { - const lastDate = prevData[prevData.length - 1].start_time; - return [ - "events", - { - limit: API_LIMIT, - in_progress: 0, - is_submitted: 0, - has_snapshot: 1, - cameras: selectedCameras ? selectedCameras.join(",") : null, - labels: selectedLabels ? selectedLabels.join(",") : null, - min_score: scoreRange ? scoreRange[0] : null, - max_score: scoreRange ? scoreRange[1] : null, - sort: sort ? sort : null, - before: lastDate, - }, - ]; - } - - return [ - "events", - { - limit: 100, - in_progress: 0, - is_submitted: 0, - has_snapshot: 1, - cameras: selectedCameras ? selectedCameras.join(",") : null, - labels: selectedLabels ? selectedLabels.join(",") : null, - min_score: scoreRange ? scoreRange[0] : null, - max_score: scoreRange ? scoreRange[1] : null, - sort: sort ? sort : null, - }, - ]; - }, - [scoreRange, selectedCameras, selectedLabels, sort], - ); - - const { - data: eventPages, - mutate: refresh, - size, - setSize, - isValidating, - } = useSWRInfinite(getKey, eventFetcher, { - revalidateOnFocus: false, - }); - - const events = useMemo( - () => (eventPages ? eventPages.flat() : []), - [eventPages], - ); - - const [upload, setUpload] = useState(); - - // paging - - const isDone = useMemo( - () => (eventPages?.at(-1)?.length ?? 0) < API_LIMIT, - [eventPages], - ); - - const pagingObserver = useRef(); - const lastEventRef = useCallback( - (node: HTMLElement | null) => { - if (isValidating) return; - if (pagingObserver.current) pagingObserver.current.disconnect(); - try { - pagingObserver.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && !isDone) { - setSize(size + 1); - } - }); - if (node) pagingObserver.current.observe(node); - } catch (e) { - // no op - } - }, - [isValidating, isDone, size, setSize], - ); - - return ( -
-
- - -
-
- {!events?.length ? ( - <> - {isValidating ? ( - - ) : ( -
- - No snapshots found -
- )} - - ) : ( - <> -
- setUpload(undefined)} - onEventUploaded={() => { - refresh( - (data: Event[][] | undefined) => { - if (!data || !upload) { - return data; - } - - let pageIndex = -1; - let index = -1; - - data.forEach((page, pIdx) => { - const search = page.findIndex((e) => e.id == upload.id); - - if (search != -1) { - pageIndex = pIdx; - index = search; - } - }); - - if (index == -1) { - return data; - } - - return [ - ...data.slice(0, pageIndex), - [ - ...data[pageIndex].slice(0, index), - { ...data[pageIndex][index], plus_id: "new_upload" }, - ...data[pageIndex].slice(index + 1), - ], - ...data.slice(pageIndex + 1), - ]; - }, - { revalidate: false, populateCache: true }, - ); - }} - /> - - {events?.map((event) => { - if (event.data.type != "object" || event.plus_id) { - return; - } - - return ( -
setUpload(event)} - > -
- -
- -
- - {[event.label].map((object) => { - return getIconForLabel( - object, - "size-3 text-white", - ); - })} -
- {Math.round(event.data.score * 100)}% -
-
-
-
-
- - {[event.label] - .map((text) => capitalizeFirstLetter(text)) - .sort() - .join(", ") - .replaceAll("-verified", "")} - -
-
- -
- ); - })} -
- {!isDone && isValidating ? ( -
- -
- ) : ( -
- )} - - )} -
-
- ); -} - -type PlusFilterGroupProps = { - selectedCameras: string[] | undefined; - selectedLabels: string[] | undefined; - selectedScoreRange: number[] | undefined; - setSelectedCameras: (cameras: string[] | undefined) => void; - setSelectedLabels: (cameras: string[] | undefined) => void; - setSelectedScoreRange: (range: number[] | undefined) => void; -}; -function PlusFilterGroup({ - selectedCameras, - selectedLabels, - selectedScoreRange, - setSelectedCameras, - setSelectedLabels, - setSelectedScoreRange, -}: PlusFilterGroupProps) { - const { data: config } = useSWR("config"); - - const allCameras = useMemo(() => { - if (!config) { - return []; - } - - return Object.keys(config.cameras); - }, [config]); - const allLabels = useMemo(() => { - if (!config) { - return []; - } - - const labels = new Set(); - const cameras = selectedCameras || Object.keys(config.cameras); - - cameras.forEach((camera) => { - const cameraConfig = config.cameras[camera]; - cameraConfig.objects.track.forEach((label) => { - if (!ATTRIBUTE_LABELS.includes(label)) { - labels.add(label); - } - }); - }); - - return [...labels].sort(); - }, [config, selectedCameras]); - - const [open, setOpen] = useState<"none" | "camera" | "label" | "score">( - "none", - ); - const [currentLabels, setCurrentLabels] = useState( - undefined, - ); - const [currentScoreRange, setCurrentScoreRange] = useState< - number[] | undefined - >(undefined); - - const Menu = isMobile ? Drawer : DropdownMenu; - const Trigger = isMobile ? DrawerTrigger : DropdownMenuTrigger; - const Content = isMobile ? DrawerContent : DropdownMenuContent; - - return ( -
- - { - if (!open) { - setCurrentLabels(selectedLabels); - } - setOpen(open ? "label" : "none"); - }} - > - - - - - setOpen("none")} - /> - - - { - setOpen(open ? "score" : "none"); - }} - > - - - - -
- { - const value = e.target.value; - - if (value) { - setCurrentScoreRange([ - parseInt(value) / 100.0, - currentScoreRange?.at(1) ?? 1.0, - ]); - } - }} - /> - - { - const value = e.target.value; - - if (value) { - setCurrentScoreRange([ - currentScoreRange?.at(0) ?? 0.5, - parseInt(value) / 100.0, - ]); - } - }} - /> -
- -
- - -
-
-
-
- ); -} - -type PlusSortSelectorProps = { - selectedSort?: string; - setSelectedSort: (sort: string | undefined) => void; -}; -function PlusSortSelector({ - selectedSort, - setSelectedSort, -}: PlusSortSelectorProps) { - // menu state - - const [open, setOpen] = useState(false); - - // sort - - const [currentSort, setCurrentSort] = useState(); - const [currentDir, setCurrentDir] = useState("desc"); - - // components - - const Sort = selectedSort - ? selectedSort.split("_")[1] == "desc" - ? FaSortAmountDown - : FaSortAmountUp - : FaSort; - const Menu = isMobile ? Drawer : DropdownMenu; - const Trigger = isMobile ? DrawerTrigger : DropdownMenuTrigger; - const Content = isMobile ? DrawerContent : DropdownMenuContent; - - return ( -
- { - setOpen(open); - - if (!open) { - const parts = selectedSort?.split("_"); - - if (parts?.length == 2) { - setCurrentSort(parts[0]); - setCurrentDir(parts[1]); - } - } - }} - > - - - - - setCurrentSort(value)} - > -
- - - {currentSort == "date" ? ( - currentDir == "desc" ? ( - setCurrentDir("asc")} - /> - ) : ( - setCurrentDir("desc")} - /> - ) - ) : ( -
- )} -
-
- - - {currentSort == "score" ? ( - currentDir == "desc" ? ( - setCurrentDir("asc")} - /> - ) : ( - setCurrentDir("desc")} - /> - ) - ) : ( -
- )} -
- - -
- - -
- -
-
- ); -} diff --git a/web/src/types/search.ts b/web/src/types/search.ts index 718c099ca..63b86229d 100644 --- a/web/src/types/search.ts +++ b/web/src/types/search.ts @@ -28,8 +28,6 @@ export type SearchResult = { }; }; -export type PartialSearchResult = Partial & { id: string }; - export type SearchFilter = { cameras?: string[]; labels?: string[]; diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index 366ea813e..009b85343 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -12,11 +12,7 @@ import { } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { FrigateConfig } from "@/types/frigateConfig"; -import { - PartialSearchResult, - SearchFilter, - SearchResult, -} from "@/types/search"; +import { SearchFilter, SearchResult } from "@/types/search"; import { useCallback, useMemo, useState } from "react"; import { isMobileOnly } from "react-device-detect"; import { LuImage, LuSearchX, LuText, LuXCircle } from "react-icons/lu"; @@ -29,7 +25,6 @@ type SearchViewProps = { searchFilter?: SearchFilter; searchResults?: SearchResult[]; isLoading: boolean; - similaritySearch?: PartialSearchResult; setSearch: (search: string) => void; setSimilaritySearch: (search: SearchResult) => void; onUpdateFilter: (filter: SearchFilter) => void; @@ -41,7 +36,6 @@ export default function SearchView({ searchFilter, searchResults, isLoading, - similaritySearch, setSearch, setSimilaritySearch, onUpdateFilter, @@ -123,7 +117,7 @@ export default function SearchView({ setSearch(e.target.value)} /> {search && ( @@ -141,6 +135,7 @@ export default function SearchView({ "w-full justify-between md:justify-start lg:justify-end", )} filter={searchFilter} + searchTerm={searchTerm} onUpdateFilter={onUpdateFilter} /> )} @@ -180,7 +175,7 @@ export default function SearchView({ findSimilar={() => setSimilaritySearch(value)} onClick={() => onSelectSearch(value)} /> - {(searchTerm || similaritySearch) && ( + {searchTerm && (