2024-10-06 12:43:36 -05:00
import { isDesktop , isIOS , isMobile , isSafari } from "react-device-detect" ;
2024-06-23 14:58:00 -06:00
import { SearchResult } from "@/types/search" ;
import useSWR from "swr" ;
import { FrigateConfig } from "@/types/frigateConfig" ;
import { useFormattedTimestamp } from "@/hooks/use-date-utils" ;
import { getIconForLabel } from "@/utils/iconUtil" ;
import { useApiHost } from "@/api" ;
2024-08-14 08:02:21 -06:00
import { Button } from "../../ui/button" ;
2024-10-13 12:46:40 -05:00
import { useCallback , useEffect , useMemo , useState } from "react" ;
2024-06-23 14:58:00 -06:00
import axios from "axios" ;
import { toast } from "sonner" ;
2024-08-14 08:02:21 -06:00
import { Textarea } from "../../ui/textarea" ;
2024-09-11 08:41:16 -06:00
import { ScrollArea , ScrollBar } from "@/components/ui/scroll-area" ;
import { ToggleGroup , ToggleGroupItem } from "@/components/ui/toggle-group" ;
import useOptimisticState from "@/hooks/use-optimistic-state" ;
import {
Dialog ,
DialogContent ,
DialogDescription ,
DialogHeader ,
DialogTitle ,
} from "@/components/ui/dialog" ;
import { Event } from "@/types/event" ;
import { baseUrl } from "@/api/baseUrl" ;
import { cn } from "@/lib/utils" ;
2024-09-11 12:32:45 -05:00
import ActivityIndicator from "@/components/indicators/activity-indicator" ;
2024-09-30 16:54:53 -05:00
import {
2024-10-06 12:43:36 -05:00
FaCheckCircle ,
2024-09-30 16:54:53 -05:00
FaChevronDown ,
2024-10-22 22:09:57 -05:00
FaDownload ,
2024-09-30 16:54:53 -05:00
FaHistory ,
FaImage ,
FaRegListAlt ,
FaVideo ,
} from "react-icons/fa" ;
2024-09-12 08:46:29 -06:00
import { FaRotate } from "react-icons/fa6" ;
import ObjectLifecycle from "./ObjectLifecycle" ;
2024-09-12 14:39:35 -05:00
import {
MobilePage ,
MobilePageContent ,
MobilePageDescription ,
MobilePageHeader ,
MobilePageTitle ,
} from "@/components/mobile/MobilePage" ;
2024-09-13 15:44:31 -05:00
import {
Tooltip ,
TooltipContent ,
TooltipTrigger ,
} from "@/components/ui/tooltip" ;
import { ReviewSegment } from "@/types/review" ;
import { useNavigate } from "react-router-dom" ;
import Chip from "@/components/indicators/Chip" ;
2024-10-01 17:05:16 -05:00
import { capitalizeAll } from "@/utils/stringUtil" ;
2024-09-24 09:14:51 -05:00
import useGlobalMutation from "@/hooks/use-global-mutate" ;
2024-09-30 16:54:53 -05:00
import {
DropdownMenu ,
DropdownMenuContent ,
DropdownMenuItem ,
DropdownMenuTrigger ,
} from "@/components/ui/dropdown-menu" ;
2024-10-06 12:43:36 -05:00
import { TransformComponent , TransformWrapper } from "react-zoom-pan-pinch" ;
import { Card , CardContent } from "@/components/ui/card" ;
import useImageLoaded from "@/hooks/use-image-loaded" ;
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator" ;
2024-10-13 12:46:40 -05:00
import { GenericVideoPlayer } from "@/components/player/GenericVideoPlayer" ;
2024-10-17 06:30:52 -05:00
import {
Popover ,
PopoverContent ,
PopoverTrigger ,
} from "@/components/ui/popover" ;
import { LuInfo } from "react-icons/lu" ;
2024-10-22 22:09:57 -05:00
import { TooltipPortal } from "@radix-ui/react-tooltip" ;
2024-09-11 08:41:16 -06:00
2024-09-12 08:46:29 -06:00
const SEARCH_TABS = [
"details" ,
"snapshot" ,
"video" ,
"object lifecycle" ,
] as const ;
2024-10-15 08:24:47 -05:00
export type SearchTab = ( typeof SEARCH_TABS )[ number ];
2024-06-23 14:58:00 -06:00
type SearchDetailDialogProps = {
search? : SearchResult ;
2024-10-15 08:24:47 -05:00
page : SearchTab ;
2024-06-23 14:58:00 -06:00
setSearch : ( search : SearchResult | undefined ) => void ;
2024-10-15 08:24:47 -05:00
setSearchPage : ( page : SearchTab ) => void ;
2024-06-23 14:58:00 -06:00
setSimilarity ?: () => void ;
2025-01-20 07:23:22 -06:00
setInputFocused : React.Dispatch < React.SetStateAction < boolean >>;
2024-06-23 14:58:00 -06:00
};
export default function SearchDetailDialog ({
search ,
2024-10-15 08:24:47 -05:00
page ,
2024-06-23 14:58:00 -06:00
setSearch ,
2024-10-15 08:24:47 -05:00
setSearchPage ,
2024-06-23 14:58:00 -06:00
setSimilarity ,
2025-01-20 07:23:22 -06:00
setInputFocused ,
2024-06-23 14:58:00 -06:00
} : SearchDetailDialogProps ) {
const { data : config } = useSWR < FrigateConfig >( "config" , {
revalidateOnFocus : false ,
});
2024-09-11 08:41:16 -06:00
// tabs
2024-10-15 08:24:47 -05:00
const [ pageToggle , setPageToggle ] = useOptimisticState (
page ,
setSearchPage ,
100 ,
);
2024-09-11 08:41:16 -06:00
2024-09-12 14:39:35 -05:00
// dialog and mobile page
const [ isOpen , setIsOpen ] = useState ( search != undefined );
2024-10-25 07:24:04 -05:00
const handleOpenChange = useCallback (
( open : boolean ) => {
setIsOpen ( open );
if ( ! open ) {
// short timeout to allow the mobile page animation
// to complete before updating the state
setTimeout (() => {
setSearch ( undefined );
}, 300 );
}
},
[ setSearch ],
);
2024-09-12 14:39:35 -05:00
useEffect (() => {
2024-10-16 07:15:25 -05:00
if ( search ) {
setIsOpen ( search != undefined );
}
2024-09-12 14:39:35 -05:00
}, [ search ]);
2024-09-11 08:41:16 -06:00
const searchTabs = useMemo (() => {
if ( ! config || ! search ) {
return [];
}
const views = [... SEARCH_TABS ];
2024-09-12 08:46:29 -06:00
if ( ! search . has_snapshot ) {
const index = views . indexOf ( "snapshot" );
2024-09-11 08:41:16 -06:00
views . splice ( index , 1 );
}
2024-10-30 06:54:06 -05:00
if ( ! search . has_clip ) {
const index = views . indexOf ( "video" );
views . splice ( index , 1 );
}
if ( search . data . type != "object" || ! search . has_clip ) {
2024-09-26 15:30:56 -05:00
const index = views . indexOf ( "object lifecycle" );
views . splice ( index , 1 );
}
2024-09-11 08:41:16 -06:00
return views ;
}, [ config , search ]);
2024-09-12 08:46:29 -06:00
useEffect (() => {
if ( searchTabs . length == 0 ) {
return ;
}
if ( ! searchTabs . includes ( pageToggle )) {
2024-10-15 08:24:47 -05:00
setSearchPage ( "details" );
2024-09-12 08:46:29 -06:00
}
2024-10-15 08:24:47 -05:00
}, [ pageToggle , searchTabs , setSearchPage ]);
2024-09-12 08:46:29 -06:00
2024-09-11 08:41:16 -06:00
if ( ! search ) {
return ;
}
// content
2024-09-12 14:39:35 -05:00
const Overlay = isDesktop ? Dialog : MobilePage ;
const Content = isDesktop ? DialogContent : MobilePageContent ;
const Header = isDesktop ? DialogHeader : MobilePageHeader ;
const Title = isDesktop ? DialogTitle : MobilePageTitle ;
const Description = isDesktop ? DialogDescription : MobilePageDescription ;
2024-09-11 08:41:16 -06:00
return (
2024-10-25 07:24:04 -05:00
< Overlay open = { isOpen } onOpenChange = { handleOpenChange }>
2024-09-11 08:41:16 -06:00
< Content
2024-09-12 14:39:35 -05:00
className = { cn (
"scrollbar-container overflow-y-auto" ,
isDesktop &&
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl" ,
isMobile && "px-4" ,
)}
2024-09-11 08:41:16 -06:00
>
2024-10-25 07:24:04 -05:00
< Header >
2024-09-11 08:41:16 -06:00
< Title > Tracked Object Details </ Title >
2024-09-12 14:39:35 -05:00
< Description className = "sr-only" > Tracked object details </ Description >
2024-09-11 08:41:16 -06:00
</ Header >
< ScrollArea
className = { cn ( "w-full whitespace-nowrap" , isMobile && "my-2" )}
>
< div className = "flex flex-row" >
< ToggleGroup
className = "*:rounded-md *:px-3 *:py-4"
type = "single"
size = "sm"
value = { pageToggle }
onValueChange = {( value : SearchTab ) => {
if ( value ) {
setPageToggle ( value );
}
}}
>
{ Object . values ( searchTabs ). map (( item ) => (
< ToggleGroupItem
key = { item }
className = { `flex scroll-mx-10 items-center justify-between gap-2 ${ page == "details" ? "last:mr-20" : "" } ${ pageToggle == item ? "" : "*:text-muted-foreground" } ` }
value = { item }
data-nav-item = { item }
aria-label = { `Select ${ item } ` }
>
2024-09-11 13:20:41 -06:00
{ item == "details" && < FaRegListAlt className = "size-4" />}
2024-09-12 08:46:29 -06:00
{ item == "snapshot" && < FaImage className = "size-4" />}
2024-09-11 13:20:41 -06:00
{ item == "video" && < FaVideo className = "size-4" />}
2024-09-12 08:46:29 -06:00
{ item == "object lifecycle" && (
< FaRotate className = "size-4" />
)}
2024-09-11 08:41:16 -06:00
< div className = "capitalize" >{ item }</ div >
</ ToggleGroupItem >
))}
</ ToggleGroup >
< ScrollBar orientation = "horizontal" className = "h-0" />
</ div >
</ ScrollArea >
{ page == "details" && (
< ObjectDetailsTab
search = { search }
config = { config }
setSearch = { setSearch }
setSimilarity = { setSimilarity }
2025-01-20 07:23:22 -06:00
setInputFocused = { setInputFocused }
2024-09-11 08:41:16 -06:00
/>
)}
2024-09-12 08:46:29 -06:00
{ page == "snapshot" && (
2024-10-06 12:43:36 -05:00
< ObjectSnapshotTab
search = {
2024-09-12 08:46:29 -06:00
{
... search ,
plus_id : config?.plus?.enabled ? search . plus_id : "not_enabled" ,
} as unknown as Event
}
2024-09-11 08:41:16 -06:00
onEventUploaded = {() => {
search . plus_id = "new_upload" ;
}}
/>
)}
2024-10-07 08:19:22 -05:00
{ page == "video" && < VideoTab search = { search } />}
2024-09-12 08:46:29 -06:00
{ page == "object lifecycle" && (
< ObjectLifecycle
2024-09-13 07:07:11 -05:00
className = "w-full overflow-x-hidden"
2024-09-12 08:46:29 -06:00
event = { search as unknown as Event }
fullscreen = { true }
setPane = {() => {}}
/>
)}
2024-09-11 08:41:16 -06:00
</ Content >
</ Overlay >
);
}
type ObjectDetailsTabProps = {
search : SearchResult ;
config? : FrigateConfig ;
setSearch : ( search : SearchResult | undefined ) => void ;
setSimilarity ?: () => void ;
2025-01-20 07:23:22 -06:00
setInputFocused : React.Dispatch < React.SetStateAction < boolean >>;
2024-09-11 08:41:16 -06:00
};
function ObjectDetailsTab ({
search ,
config ,
setSearch ,
setSimilarity ,
2025-01-20 07:23:22 -06:00
setInputFocused ,
2024-09-11 08:41:16 -06:00
} : ObjectDetailsTabProps ) {
2024-06-23 14:58:00 -06:00
const apiHost = useApiHost ();
2024-09-24 09:14:51 -05:00
// mutation / revalidation
const mutate = useGlobalMutation ();
2024-06-23 14:58:00 -06:00
// data
2024-09-13 15:44:31 -05:00
const [ desc , setDesc ] = useState ( search ? . data . description );
2024-06-23 14:58:00 -06:00
2025-01-20 07:23:22 -06:00
const handleDescriptionFocus = useCallback (() => {
setInputFocused ( true );
}, [ setInputFocused ]);
const handleDescriptionBlur = useCallback (() => {
setInputFocused ( false );
}, [ setInputFocused ]);
2024-06-23 14:58:00 -06:00
// we have to make sure the current selected search item stays in sync
2024-09-14 08:42:56 -05:00
useEffect (() => setDesc ( search ? . data . description ?? "" ), [ search ]);
2024-06-23 14:58:00 -06:00
const formattedDate = useFormattedTimestamp (
search ? . start_time ?? 0 ,
config ? . ui . time_format == "24hour"
? "%b %-d %Y, %H:%M"
: "%b %-d %Y, %I:%M %p" ,
2024-09-12 13:28:15 -06:00
config ? . ui . timezone ,
2024-06-23 14:58:00 -06:00
);
2024-09-10 12:38:58 -06:00
const score = useMemo (() => {
if ( ! search ) {
return 0 ;
}
2024-10-23 10:31:20 -05:00
const value = search . data . top_score ?? search . top_score ?? 0 ;
2024-09-10 12:38:58 -06:00
return Math . round ( value * 100 );
}, [ search ]);
const subLabelScore = useMemo (() => {
if ( ! search ) {
return undefined ;
}
2024-11-18 12:26:44 -06:00
if ( search . sub_label && search . data ? . sub_label_score ) {
2024-10-22 17:07:42 -05:00
return Math . round (( search . data ? . sub_label_score ?? 0 ) * 100 );
2024-09-10 12:38:58 -06:00
} else {
return undefined ;
}
}, [ search ]);
2024-06-23 14:58:00 -06:00
const updateDescription = useCallback (() => {
if ( ! search ) {
return ;
}
axios
. post ( `events/ ${ search . id } /description` , { description : desc })
. then (( resp ) => {
if ( resp . status == 200 ) {
toast . success ( "Successfully saved description" , {
position : "top-center" ,
});
}
2024-09-24 09:14:51 -05:00
mutate (
( key ) =>
typeof key === "string" &&
( key . includes ( "events" ) ||
key . includes ( "events/search" ) ||
2024-09-24 13:34:29 -05:00
key . includes ( "events/explore" )),
2024-10-22 16:11:05 -05:00
( currentData : SearchResult [][] | SearchResult [] | undefined ) => {
2024-10-21 16:14:57 -05:00
if ( ! currentData ) return currentData ;
// optimistic update
2024-10-22 16:11:05 -05:00
return currentData
. flat ()
. map (( event ) =>
2024-10-21 16:14:57 -05:00
event . id === search . id
2024-10-22 16:11:05 -05:00
? { ... event , data : { ... event . data , description : desc } }
2024-10-21 16:14:57 -05:00
: event ,
2024-10-22 16:11:05 -05:00
);
2024-10-21 16:14:57 -05:00
},
{
optimisticData : true ,
rollbackOnError : true ,
revalidate : false ,
},
2024-09-24 09:14:51 -05:00
);
2024-06-23 14:58:00 -06:00
})
. catch (() => {
toast . error ( "Failed to update the description" , {
position : "top-center" ,
});
2024-09-13 15:44:31 -05:00
setDesc ( search . data . description );
2024-06-23 14:58:00 -06:00
});
2024-09-24 09:14:51 -05:00
}, [ desc , search , mutate ]);
2024-09-30 16:54:53 -05:00
const regenerateDescription = useCallback (
( source : "snapshot" | "thumbnails" ) => {
if ( ! search ) {
return ;
}
2024-09-24 09:14:51 -05:00
2024-09-30 16:54:53 -05:00
axios
. put ( `events/ ${ search . id } /description/regenerate?source= ${ source } ` )
. then (( resp ) => {
if ( resp . status == 200 ) {
toast . success (
2024-10-01 17:05:16 -05:00
`A new description has been requested from ${ capitalizeAll ( config ? . genai . provider . replaceAll ( "_" , " " ) ?? "Generative AI" ) } . Depending on the speed of your provider, the new description may take some time to regenerate.` ,
2024-09-30 16:54:53 -05:00
{
position : "top-center" ,
duration : 7000 ,
},
);
}
})
2024-10-23 07:14:50 -05:00
. catch (( error ) => {
2024-09-30 16:54:53 -05:00
toast . error (
2024-10-23 07:14:50 -05:00
`Failed to call ${ capitalizeAll ( config ? . genai . provider . replaceAll ( "_" , " " ) ?? "Generative AI" ) } for a new description: ${ error . response . data . message } ` ,
2024-09-24 09:14:51 -05:00
{
position : "top-center" ,
},
);
2024-09-30 16:54:53 -05:00
});
},
[ search , config ],
);
2024-06-23 14:58:00 -06:00
return (
2024-09-12 14:39:35 -05:00
< div className = "flex flex-col gap-5" >
2024-09-11 08:41:16 -06:00
< div className = "flex w-full flex-row" >
< div className = "flex w-full flex-col gap-3" >
< div className = "flex flex-col gap-1.5" >
< div className = "text-sm text-primary/40" > Label </ div >
< div className = "flex flex-row items-center gap-2 text-sm capitalize" >
{ getIconForLabel ( search . label , "size-4 text-primary" )}
{ search . label }
{ search . sub_label && ` ( ${ search . sub_label } )` }
2024-06-23 14:58:00 -06:00
</ div >
</ div >
2024-09-11 08:41:16 -06:00
< div className = "flex flex-col gap-1.5" >
2024-10-17 06:30:52 -05:00
< div className = "text-sm text-primary/40" >
< div className = "flex flex-row items-center gap-1" >
Top Score
< Popover >
< PopoverTrigger asChild >
< div className = "cursor-pointer p-0" >
< LuInfo className = "size-4" />
< span className = "sr-only" > Info </ span >
</ div >
</ PopoverTrigger >
< PopoverContent className = "w-80" >
The top score is the highest median score for the tracked
object , so this may differ from the score shown on the
search result thumbnail .
</ PopoverContent >
</ Popover >
</ div >
</ div >
2024-09-11 08:41:16 -06:00
< div className = "text-sm" >
{ score } % { subLabelScore && ` ( ${ subLabelScore } %)` }
</ div >
</ div >
< div className = "flex flex-col gap-1.5" >
< div className = "text-sm text-primary/40" > Camera </ div >
< div className = "text-sm capitalize" >
{ search . camera . replaceAll ( "_" , " " )}
</ div >
</ div >
< div className = "flex flex-col gap-1.5" >
< div className = "text-sm text-primary/40" > Timestamp </ div >
< div className = "text-sm" >{ formattedDate }</ div >
</ div >
</ div >
2024-09-12 14:39:35 -05:00
< div className = "flex w-full flex-col gap-2 pl-6" >
2024-09-11 08:41:16 -06:00
< img
className = "aspect-video select-none rounded-lg object-contain transition-opacity"
style = {
isIOS
? {
WebkitUserSelect : "none" ,
WebkitTouchCallout : "none" ,
}
: undefined
}
draggable = { false }
src = { ` ${ apiHost } api/events/ ${ search . id } /thumbnail.jpg` }
/>
2024-12-01 12:08:03 -06:00
{ config ? . semantic_search . enabled && search . data . type == "object" && (
2024-10-13 20:36:49 -05:00
< Button
2024-10-22 17:07:42 -05:00
aria-label = "Find similar tracked objects"
2024-10-13 20:36:49 -05:00
onClick = {() => {
setSearch ( undefined );
2024-09-11 08:41:16 -06:00
2024-10-13 20:36:49 -05:00
if ( setSimilarity ) {
setSimilarity ();
}
}}
>
Find Similar
</ Button >
)}
2024-09-11 08:41:16 -06:00
</ div >
</ div >
< div className = "flex flex-col gap-1.5" >
2024-12-17 17:33:04 -06:00
{ config ? . cameras [ search . camera ]. genai . enabled &&
! search . end_time &&
( config . cameras [ search . camera ]. genai . required_zones . length === 0 ||
search . zones . some (( zone ) =>
config . cameras [ search . camera ]. genai . required_zones . includes ( zone ),
)) &&
( config . cameras [ search . camera ]. genai . objects . length === 0 ||
config . cameras [ search . camera ]. genai . objects . includes (
search . label ,
)) ? (
<>
< div className = "text-sm text-primary/40" > Description </ div >
< div className = "flex h-64 flex-col items-center justify-center gap-3 border p-4 text-sm text-primary/40" >
< div className = "flex" >
< ActivityIndicator />
</ div >
< div className = "flex" >
Frigate will not request a description from your Generative AI
provider until the tracked object ' s lifecycle has ended .
</ div >
2024-09-30 16:54:53 -05:00
</ div >
2024-12-17 17:33:04 -06:00
</>
) : (
<>
< div className = "text-sm text-primary/40" > Description </ div >
< Textarea
className = "h-64"
placeholder = "Description of the tracked object"
value = { desc }
onChange = {( e ) => setDesc ( e . target . value )}
2025-01-20 07:23:22 -06:00
onFocus = { handleDescriptionFocus }
onBlur = { handleDescriptionBlur }
2024-12-17 17:33:04 -06:00
/>
</>
)}
< div className = "flex w-full flex-row justify-end gap-2" >
{ config ? . cameras [ search . camera ]. genai . enabled && search . end_time && (
2024-12-30 08:23:25 -06:00
< div className = "flex items-start" >
< Button
className = "rounded-r-none border-r-0"
aria-label = "Regenerate tracked object description"
onClick = {() => regenerateDescription ( "thumbnails" )}
>
Regenerate
</ Button >
{ search . has_snapshot && (
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button
className = "rounded-l-none border-l-0 px-2"
aria-label = "Expand regeneration menu"
>
< FaChevronDown className = "size-3" />
</ Button >
</ DropdownMenuTrigger >
< DropdownMenuContent >
< DropdownMenuItem
className = "cursor-pointer"
aria-label = "Regenerate from snapshot"
onClick = {() => regenerateDescription ( "snapshot" )}
>
Regenerate from Snapshot
</ DropdownMenuItem >
< DropdownMenuItem
className = "cursor-pointer"
aria-label = "Regenerate from thumbnails"
onClick = {() => regenerateDescription ( "thumbnails" )}
>
Regenerate from Thumbnails
</ DropdownMenuItem >
</ DropdownMenuContent >
</ DropdownMenu >
)}
</ div >
)}
2025-01-11 15:23:52 -06:00
{(( config ? . cameras [ search . camera ]. genai . enabled && search . end_time ) ||
! config ? . cameras [ search . camera ]. genai . enabled ) && (
< Button
variant = "select"
aria-label = "Save"
onClick = { updateDescription }
>
Save
</ Button >
)}
2024-09-11 08:41:16 -06:00
</ div >
</ div >
</ div >
);
}
2024-10-06 12:43:36 -05:00
type ObjectSnapshotTabProps = {
search : Event ;
onEventUploaded : () => void ;
};
2024-11-12 06:37:25 -06:00
export function ObjectSnapshotTab ({
2024-10-06 12:43:36 -05:00
search ,
onEventUploaded ,
} : ObjectSnapshotTabProps ) {
type SubmissionState = "reviewing" | "uploading" | "submitted" ;
const [ imgRef , imgLoaded , onImgLoad ] = useImageLoaded ();
// upload
const [ state , setState ] = useState < SubmissionState >(
search ? . plus_id ? "submitted" : "reviewing" ,
);
useEffect (
() => setState ( search ? . plus_id ? "submitted" : "reviewing" ),
[ search ],
);
const onSubmitToPlus = useCallback (
async ( falsePositive : boolean ) => {
if ( ! search ) {
return ;
}
falsePositive
? axios . put ( `events/ ${ search . id } /false_positive` )
: axios . post ( `events/ ${ search . id } /plus` , {
include_annotation : 1 ,
});
setState ( "submitted" );
onEventUploaded ();
},
[ search , onEventUploaded ],
);
return (
< div className = "relative size-full" >
< ImageLoadingIndicator
className = "absolute inset-0 aspect-video min-h-[60dvh] w-full"
imgLoaded = { imgLoaded }
/>
< div className = { ` ${ imgLoaded ? "visible" : "invisible" } ` }>
< TransformWrapper minScale = { 1.0 } wheel = {{ smoothStep : 0.005 }}>
< div className = "flex flex-col space-y-3" >
< TransformComponent
wrapperStyle = {{
width : "100%" ,
height : "100%" ,
}}
contentStyle = {{
position : "relative" ,
width : "100%" ,
height : "100%" ,
}}
>
{ search ? . id && (
2024-10-22 22:09:57 -05:00
< div className = "relative mx-auto" >
< img
ref = { imgRef }
className = { `mx-auto max-h-[60dvh] bg-black object-contain` }
src = { ` ${ baseUrl } api/events/ ${ search ? . id } /snapshot.jpg` }
alt = { ` ${ search ? . label } ` }
loading = { isSafari ? "eager" : "lazy" }
onLoad = {() => {
onImgLoad ();
}}
/>
< div
className = { cn (
"absolute right-1 top-1 flex items-center gap-2" ,
)}
>
< Tooltip >
< TooltipTrigger asChild >
< a
download
href = { ` ${ baseUrl } api/events/ ${ search ? . id } /snapshot.jpg` }
>
< Chip className = "cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500" >
< FaDownload className = "size-4 text-white" />
</ Chip >
</ a >
</ TooltipTrigger >
< TooltipPortal >
< TooltipContent > Download </ TooltipContent >
</ TooltipPortal >
</ Tooltip >
</ div >
</ div >
2024-10-06 12:43:36 -05:00
)}
</ TransformComponent >
2024-12-01 12:08:03 -06:00
{ search . data . type == "object" &&
search . plus_id !== "not_enabled" &&
search . end_time && (
< Card className = "p-1 text-sm md:p-2" >
< CardContent className = "flex flex-col items-center justify-between gap-3 p-2 md:flex-row" >
< div className = { cn ( "flex flex-col space-y-3" )}>
< div
className = {
"text-lg font-semibold leading-none tracking-tight"
}
>
Submit To Frigate +
2024-10-13 12:34:39 -05:00
</ div >
2024-12-01 12:08:03 -06:00
< div className = "text-sm text-muted-foreground" >
Objects in locations you want to avoid are not false
positives . Submitting them as false positives will
confuse the model .
</ div >
</ div >
< div className = "flex flex-row justify-center gap-2 md:justify-end" >
{ state == "reviewing" && (
<>
< Button
className = "bg-success"
aria-label = "Confirm this label for Frigate Plus"
onClick = {() => {
setState ( "uploading" );
onSubmitToPlus ( false );
}}
>
This is { " " }
{ /^[aeiou]/i . test ( search ? . label || "" ) ? "an" : "a" }{ " " }
{ search ? . label }
</ Button >
< Button
className = "text-white"
aria-label = "Do not confirm this label for Frigate Plus"
variant = "destructive"
onClick = {() => {
setState ( "uploading" );
onSubmitToPlus ( true );
}}
>
This is not { " " }
{ /^[aeiou]/i . test ( search ? . label || "" ) ? "an" : "a" }{ " " }
{ search ? . label }
</ Button >
</>
)}
{ state == "uploading" && < ActivityIndicator />}
{ state == "submitted" && (
< div className = "flex flex-row items-center justify-center gap-2" >
< FaCheckCircle className = "text-success" />
Submitted
</ div >
)}
</ div >
</ CardContent >
</ Card >
)}
2024-10-06 12:43:36 -05:00
</ div >
</ TransformWrapper >
</ div >
</ div >
);
}
2024-09-11 08:41:16 -06:00
type VideoTabProps = {
search : SearchResult ;
};
2024-10-13 12:46:40 -05:00
export function VideoTab ({ search } : VideoTabProps ) {
2024-09-13 15:44:31 -05:00
const navigate = useNavigate ();
const { data : reviewItem } = useSWR < ReviewSegment >([
`review/event/ ${ search . id } ` ,
]);
2024-10-13 12:46:40 -05:00
const endTime = useMemo (() => search . end_time ?? Date . now () / 1000 , [ search ]);
2024-09-13 15:44:31 -05:00
2024-10-13 12:46:40 -05:00
const source = ` ${ baseUrl } vod/ ${ search . camera } /start/ ${ search . start_time } /end/ ${ endTime } /index.m3u8` ;
2024-09-11 13:20:41 -06:00
2024-09-11 08:41:16 -06:00
return (
2024-10-13 12:46:40 -05:00
< GenericVideoPlayer source = { source }>
{ reviewItem && (
2024-10-07 08:19:22 -05:00
< div
2024-10-13 12:46:40 -05:00
className = { cn (
2024-10-22 22:09:57 -05:00
"absolute top-2 z-10 flex items-center gap-2" ,
2024-10-13 12:46:40 -05:00
isIOS ? "right-8" : "right-2" ,
2024-10-07 08:19:22 -05:00
)}
2024-10-13 12:46:40 -05:00
>
< Tooltip >
< TooltipTrigger >
< Chip
className = "cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick = {() => {
if ( reviewItem ? . id ) {
const params = new URLSearchParams ({
id : reviewItem.id ,
}). toString ();
navigate ( `/review? ${ params } ` );
}
}}
>
< FaHistory className = "size-4 text-white" />
</ Chip >
</ TooltipTrigger >
2024-10-22 22:09:57 -05:00
< TooltipPortal >
< TooltipContent > View in History </ TooltipContent >
</ TooltipPortal >
</ Tooltip >
< Tooltip >
< TooltipTrigger asChild >
< a
download
href = { ` ${ baseUrl } api/ ${ search . camera } /start/ ${ search . start_time } /end/ ${ endTime } /clip.mp4` }
>
< Chip className = "cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500" >
< FaDownload className = "size-4 text-white" />
</ Chip >
</ a >
</ TooltipTrigger >
< TooltipPortal >
< TooltipContent > Download </ TooltipContent >
</ TooltipPortal >
2024-10-13 12:46:40 -05:00
</ Tooltip >
2024-09-13 15:44:31 -05:00
</ div >
2024-10-13 12:46:40 -05:00
)}
</ GenericVideoPlayer >
2024-06-23 14:58:00 -06:00
);
}