Move recordings view and open recordings when search is selected

This commit is contained in:
Nicolas Mowen 2024-06-22 14:35:18 -06:00
parent 0ce4f62753
commit 8d65e56f0e
5 changed files with 224 additions and 82 deletions

View File

@ -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>

View File

@ -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";

View File

@ -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}
/>
);
}
} }

View 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>
);
}