mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-12 22:25:24 +03:00
Move recordings view and open recordings when search is selected
This commit is contained in:
parent
0ce4f62753
commit
8d65e56f0e
@ -17,13 +17,13 @@ import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
|||||||
import { VideoPreview } from "../preview/ScrubbablePreview";
|
import { VideoPreview } from "../preview/ScrubbablePreview";
|
||||||
import { Preview } from "@/types/preview";
|
import { Preview } from "@/types/preview";
|
||||||
import { SearchResult } from "@/types/search";
|
import { SearchResult } from "@/types/search";
|
||||||
|
import { LuInfo } from "react-icons/lu";
|
||||||
|
|
||||||
type SearchPlayerProps = {
|
type SearchPlayerProps = {
|
||||||
searchResult: SearchResult;
|
searchResult: SearchResult;
|
||||||
allPreviews?: Preview[];
|
allPreviews?: Preview[];
|
||||||
scrollLock?: boolean;
|
scrollLock?: boolean;
|
||||||
onTimeUpdate?: (time: number | undefined) => void;
|
onClick: (searchResult: SearchResult, detail: boolean) => void;
|
||||||
onClick: (searchResult: SearchResult, ctrl: boolean) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SearchThumbnailPlayer({
|
export default function SearchThumbnailPlayer({
|
||||||
@ -31,7 +31,6 @@ export default function SearchThumbnailPlayer({
|
|||||||
allPreviews,
|
allPreviews,
|
||||||
scrollLock = false,
|
scrollLock = false,
|
||||||
onClick,
|
onClick,
|
||||||
onTimeUpdate,
|
|
||||||
}: SearchPlayerProps) {
|
}: SearchPlayerProps) {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
@ -136,10 +135,6 @@ export default function SearchThumbnailPlayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setPlayback(false);
|
setPlayback(false);
|
||||||
|
|
||||||
if (onTimeUpdate) {
|
|
||||||
onTimeUpdate(undefined);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// we know that these deps are correct
|
// we know that these deps are correct
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -167,7 +162,6 @@ export default function SearchThumbnailPlayer({
|
|||||||
relevantPreview={relevantPreview}
|
relevantPreview={relevantPreview}
|
||||||
setIgnoreClick={setIgnoreClick}
|
setIgnoreClick={setIgnoreClick}
|
||||||
isPlayingBack={setPlayback}
|
isPlayingBack={setPlayback}
|
||||||
onTimeUpdate={onTimeUpdate}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -237,6 +231,32 @@ export default function SearchThumbnailPlayer({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="absolute right-0 top-2 z-40">
|
||||||
|
<Tooltip>
|
||||||
|
<div
|
||||||
|
className="flex"
|
||||||
|
onMouseEnter={() => setTooltipHovering(true)}
|
||||||
|
onMouseLeave={() => setTooltipHovering(false)}
|
||||||
|
>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="mx-3 pb-1 text-sm text-white">
|
||||||
|
{
|
||||||
|
<>
|
||||||
|
<Chip
|
||||||
|
className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} "bg-gray-500 z-0 bg-gradient-to-br from-gray-400 to-gray-500`}
|
||||||
|
>
|
||||||
|
<LuInfo className="size-3" />
|
||||||
|
</Chip>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
</div>
|
||||||
|
<TooltipContent className="capitalize">
|
||||||
|
View Detection Details
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
{!playingBack && (
|
{!playingBack && (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-t-l pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/60 to-transparent"></div>
|
<div className="rounded-t-l pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/60 to-transparent"></div>
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import {
|
|||||||
SegmentedReviewData,
|
SegmentedReviewData,
|
||||||
} from "@/types/review";
|
} from "@/types/review";
|
||||||
import EventView from "@/views/events/EventView";
|
import EventView from "@/views/events/EventView";
|
||||||
import { RecordingView } from "@/views/events/RecordingView";
|
import { RecordingView } from "@/views/recording/RecordingView";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|||||||
@ -1,24 +1,29 @@
|
|||||||
import SearchFilterGroup from "@/components/filter/SearchFilterGroup";
|
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
||||||
import SearchThumbnailPlayer from "@/components/player/SearchThumbnailPlayer";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
|
||||||
import useApiFilter from "@/hooks/use-api-filter";
|
import useApiFilter from "@/hooks/use-api-filter";
|
||||||
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
||||||
import { cn } from "@/lib/utils";
|
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { RecordingStartingPoint } from "@/types/record";
|
||||||
import { SearchFilter, SearchResult } from "@/types/search";
|
import { SearchFilter, SearchResult } from "@/types/search";
|
||||||
import { TimeRange } from "@/types/timeline";
|
import { TimeRange } from "@/types/timeline";
|
||||||
|
import { RecordingView } from "@/views/recording/RecordingView";
|
||||||
|
import SearchView from "@/views/search/SearchView";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { LuSearchCheck, LuSearchX } from "react-icons/lu";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
export default function Search() {
|
export default function Search() {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
// search field handler
|
// search field handler
|
||||||
|
|
||||||
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout>();
|
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout>();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
const [recording, setRecording] =
|
||||||
|
useOverlayState<RecordingStartingPoint>("recording");
|
||||||
|
|
||||||
// search filter
|
// search filter
|
||||||
|
|
||||||
const [searchFilter, setSearchFilter, searchSearchParams] =
|
const [searchFilter, setSearchFilter, searchSearchParams] =
|
||||||
@ -82,72 +87,90 @@ export default function Search() {
|
|||||||
autoRefresh: false,
|
autoRefresh: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
// selection
|
||||||
<div className="flex size-full flex-col pt-2 md:py-2">
|
|
||||||
<Toaster closeButton={true} />
|
|
||||||
|
|
||||||
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:pl-3">
|
const onSelectSearch = useCallback(
|
||||||
<Input
|
(item: SearchResult, detail: boolean) => {
|
||||||
className={cn("mr-2 w-full bg-muted md:mr-0 md:w-1/3")}
|
if (detail) {
|
||||||
placeholder="Search for a specific detection..."
|
// TODO open detail
|
||||||
value={search}
|
} else {
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
setRecording({
|
||||||
/>
|
camera: item.camera,
|
||||||
|
startTime: item.start_time,
|
||||||
<SearchFilterGroup
|
severity: "alert",
|
||||||
filter={searchFilter}
|
});
|
||||||
onUpdateFilter={onUpdateFilter}
|
}
|
||||||
/>
|
},
|
||||||
</div>
|
[setRecording],
|
||||||
|
|
||||||
<div className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
|
|
||||||
{searchTerm.length == 0 && (
|
|
||||||
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
|
||||||
<LuSearchCheck className="size-16" />
|
|
||||||
Search For Detections
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{searchTerm.length > 0 && searchResults?.length == 0 && (
|
|
||||||
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
|
||||||
<LuSearchX className="size-16" />
|
|
||||||
No Detections Found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-3 md:gap-4 3xl:grid-cols-4">
|
|
||||||
{searchResults &&
|
|
||||||
searchResults.map((value) => {
|
|
||||||
const selected = false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={value.id}
|
|
||||||
data-start={value.start_time}
|
|
||||||
className="review-item relative rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="aspect-video overflow-hidden rounded-lg">
|
|
||||||
<SearchThumbnailPlayer
|
|
||||||
searchResult={value}
|
|
||||||
allPreviews={allPreviews}
|
|
||||||
scrollLock={false}
|
|
||||||
onClick={() => {}}
|
|
||||||
//onTimeUpdate={onPreviewTimeUpdate}
|
|
||||||
//onClick={onSelectReview}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-severity_alert outline-severity_alert` : "outline-transparent duration-500"}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectedReviewData = useMemo(() => {
|
||||||
|
if (!recording) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!searchResults) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCameras = searchFilter?.cameras ?? Object.keys(config.cameras);
|
||||||
|
|
||||||
|
return {
|
||||||
|
camera: recording.camera,
|
||||||
|
start_time: recording.startTime,
|
||||||
|
allCameras: allCameras,
|
||||||
|
};
|
||||||
|
|
||||||
|
// previews will not update after item is selected
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [recording, searchResults]);
|
||||||
|
|
||||||
|
const selectedTimeRange = useMemo(() => {
|
||||||
|
if (!recording) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = new Date(recording.startTime * 1000);
|
||||||
|
time.setUTCMinutes(0, 0, 0);
|
||||||
|
const start = time.getTime() / 1000;
|
||||||
|
time.setHours(time.getHours() + 2);
|
||||||
|
const end = time.getTime() / 1000;
|
||||||
|
return {
|
||||||
|
after: start,
|
||||||
|
before: end,
|
||||||
|
};
|
||||||
|
}, [recording]);
|
||||||
|
|
||||||
|
if (recording) {
|
||||||
|
if (selectedReviewData && selectedTimeRange) {
|
||||||
|
return (
|
||||||
|
<RecordingView
|
||||||
|
startCamera={selectedReviewData.camera}
|
||||||
|
startTime={selectedReviewData.start_time}
|
||||||
|
allCameras={selectedReviewData.allCameras}
|
||||||
|
allPreviews={allPreviews}
|
||||||
|
timeRange={selectedTimeRange}
|
||||||
|
updateFilter={onUpdateFilter}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<SearchView
|
||||||
|
search={search}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
searchFilter={searchFilter}
|
||||||
|
searchResults={searchResults}
|
||||||
|
allPreviews={allPreviews}
|
||||||
|
isLoading={isLoading}
|
||||||
|
setSearch={setSearch}
|
||||||
|
onUpdateFilter={onUpdateFilter}
|
||||||
|
onSelectItem={onSelectSearch}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
99
web/src/views/search/SearchView.tsx
Normal file
99
web/src/views/search/SearchView.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import SearchFilterGroup from "@/components/filter/SearchFilterGroup";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import SearchThumbnailPlayer from "@/components/player/SearchThumbnailPlayer";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Preview } from "@/types/preview";
|
||||||
|
import { SearchFilter, SearchResult } from "@/types/search";
|
||||||
|
import { LuSearchCheck, LuSearchX } from "react-icons/lu";
|
||||||
|
|
||||||
|
type SearchViewProps = {
|
||||||
|
search: string;
|
||||||
|
searchTerm: string;
|
||||||
|
searchFilter?: SearchFilter;
|
||||||
|
searchResults?: SearchResult[];
|
||||||
|
allPreviews?: Preview[];
|
||||||
|
isLoading: boolean;
|
||||||
|
setSearch: (search: string) => void;
|
||||||
|
onUpdateFilter: (filter: SearchFilter) => void;
|
||||||
|
onSelectItem: (item: SearchResult, detail: boolean) => void;
|
||||||
|
};
|
||||||
|
export default function SearchView({
|
||||||
|
search,
|
||||||
|
searchTerm,
|
||||||
|
searchFilter,
|
||||||
|
searchResults,
|
||||||
|
allPreviews,
|
||||||
|
isLoading,
|
||||||
|
setSearch,
|
||||||
|
onUpdateFilter,
|
||||||
|
onSelectItem,
|
||||||
|
}: SearchViewProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col pt-2 md:py-2">
|
||||||
|
<Toaster closeButton={true} />
|
||||||
|
|
||||||
|
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:pl-3">
|
||||||
|
<Input
|
||||||
|
className={cn("mr-2 w-full bg-muted md:mr-0 md:w-1/3")}
|
||||||
|
placeholder="Search for a specific detection..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SearchFilterGroup
|
||||||
|
filter={searchFilter}
|
||||||
|
onUpdateFilter={onUpdateFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
|
||||||
|
{searchTerm.length == 0 && (
|
||||||
|
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
||||||
|
<LuSearchCheck className="size-16" />
|
||||||
|
Search For Detections
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchTerm.length > 0 && searchResults?.length == 0 && (
|
||||||
|
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
||||||
|
<LuSearchX className="size-16" />
|
||||||
|
No Detections Found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-3 md:gap-4 3xl:grid-cols-4">
|
||||||
|
{searchResults &&
|
||||||
|
searchResults.map((value) => {
|
||||||
|
const selected = false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={value.id}
|
||||||
|
data-start={value.start_time}
|
||||||
|
className="review-item relative rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="aspect-video overflow-hidden rounded-lg">
|
||||||
|
<SearchThumbnailPlayer
|
||||||
|
searchResult={value}
|
||||||
|
allPreviews={allPreviews}
|
||||||
|
scrollLock={false}
|
||||||
|
onClick={(item, detail) => onSelectItem(item, detail)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-severity_alert outline-severity_alert` : "outline-transparent duration-500"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user