2024-10-06 20:43:36 +03:00
import { isDesktop , isIOS , isMobile , isSafari } from "react-device-detect" ;
2024-06-23 23:58:00 +03: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 17:02:21 +03:00
import { Button } from "../../ui/button" ;
2024-10-13 20:46:40 +03:00
import { useCallback , useEffect , useMemo , useState } from "react" ;
2024-06-23 23:58:00 +03:00
import axios from "axios" ;
import { toast } from "sonner" ;
2024-08-14 17:02:21 +03:00
import { Textarea } from "../../ui/textarea" ;
2024-09-11 17:41:16 +03: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 20:32:45 +03:00
import ActivityIndicator from "@/components/indicators/activity-indicator" ;
2024-10-01 00:54:53 +03:00
import {
2024-10-06 20:43:36 +03:00
FaCheckCircle ,
2024-10-01 00:54:53 +03:00
FaChevronDown ,
2024-10-23 06:09:57 +03:00
FaDownload ,
2024-10-01 00:54:53 +03:00
FaHistory ,
FaImage ,
FaRegListAlt ,
FaVideo ,
} from "react-icons/fa" ;
2024-09-12 17:46:29 +03:00
import { FaRotate } from "react-icons/fa6" ;
import ObjectLifecycle from "./ObjectLifecycle" ;
2024-09-12 22:39:35 +03:00
import {
MobilePage ,
MobilePageContent ,
MobilePageDescription ,
MobilePageHeader ,
MobilePageTitle ,
} from "@/components/mobile/MobilePage" ;
2024-09-13 23:44:31 +03: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-02 01:05:16 +03:00
import { capitalizeAll } from "@/utils/stringUtil" ;
2024-09-24 17:14:51 +03:00
import useGlobalMutation from "@/hooks/use-global-mutate" ;
2024-10-01 00:54:53 +03:00
import {
DropdownMenu ,
DropdownMenuContent ,
DropdownMenuItem ,
DropdownMenuTrigger ,
} from "@/components/ui/dropdown-menu" ;
2024-10-06 20:43:36 +03: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 20:46:40 +03:00
import { GenericVideoPlayer } from "@/components/player/GenericVideoPlayer" ;
2024-10-17 14:30:52 +03:00
import {
Popover ,
PopoverContent ,
PopoverTrigger ,
} from "@/components/ui/popover" ;
import { LuInfo } from "react-icons/lu" ;
2024-10-23 06:09:57 +03:00
import { TooltipPortal } from "@radix-ui/react-tooltip" ;
2024-09-11 17:41:16 +03:00
2024-09-12 17:46:29 +03:00
const SEARCH_TABS = [
"details" ,
"snapshot" ,
"video" ,
"object lifecycle" ,
] as const ;
2024-10-15 16:24:47 +03:00
export type SearchTab = ( typeof SEARCH_TABS ) [ number ] ;
2024-06-23 23:58:00 +03:00
type SearchDetailDialogProps = {
search? : SearchResult ;
2024-10-15 16:24:47 +03:00
page : SearchTab ;
2024-06-23 23:58:00 +03:00
setSearch : ( search : SearchResult | undefined ) = > void ;
2024-10-15 16:24:47 +03:00
setSearchPage : ( page : SearchTab ) = > void ;
2024-06-23 23:58:00 +03:00
setSimilarity ? : ( ) = > void ;
} ;
export default function SearchDetailDialog ( {
search ,
2024-10-15 16:24:47 +03:00
page ,
2024-06-23 23:58:00 +03:00
setSearch ,
2024-10-15 16:24:47 +03:00
setSearchPage ,
2024-06-23 23:58:00 +03:00
setSimilarity ,
} : SearchDetailDialogProps ) {
const { data : config } = useSWR < FrigateConfig > ( "config" , {
revalidateOnFocus : false ,
} ) ;
2024-09-11 17:41:16 +03:00
// tabs
2024-10-15 16:24:47 +03:00
const [ pageToggle , setPageToggle ] = useOptimisticState (
page ,
setSearchPage ,
100 ,
) ;
2024-09-11 17:41:16 +03:00
2024-09-12 22:39:35 +03:00
// dialog and mobile page
const [ isOpen , setIsOpen ] = useState ( search != undefined ) ;
2024-10-25 15:24:04 +03: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 22:39:35 +03:00
useEffect ( ( ) = > {
2024-10-16 15:15:25 +03:00
if ( search ) {
setIsOpen ( search != undefined ) ;
}
2024-09-12 22:39:35 +03:00
} , [ search ] ) ;
2024-09-11 17:41:16 +03:00
const searchTabs = useMemo ( ( ) = > {
if ( ! config || ! search ) {
return [ ] ;
}
const views = [ . . . SEARCH_TABS ] ;
2024-09-12 17:46:29 +03:00
if ( ! search . has_snapshot ) {
const index = views . indexOf ( "snapshot" ) ;
2024-09-11 17:41:16 +03:00
views . splice ( index , 1 ) ;
}
2024-10-30 14:54:06 +03: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 23:30:56 +03:00
const index = views . indexOf ( "object lifecycle" ) ;
views . splice ( index , 1 ) ;
}
2024-09-11 17:41:16 +03:00
return views ;
} , [ config , search ] ) ;
2024-09-12 17:46:29 +03:00
useEffect ( ( ) = > {
if ( searchTabs . length == 0 ) {
return ;
}
if ( ! searchTabs . includes ( pageToggle ) ) {
2024-10-15 16:24:47 +03:00
setSearchPage ( "details" ) ;
2024-09-12 17:46:29 +03:00
}
2024-10-15 16:24:47 +03:00
} , [ pageToggle , searchTabs , setSearchPage ] ) ;
2024-09-12 17:46:29 +03:00
2024-09-11 17:41:16 +03:00
if ( ! search ) {
return ;
}
// content
2024-09-12 22:39:35 +03: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 17:41:16 +03:00
return (
2024-10-25 15:24:04 +03:00
< Overlay open = { isOpen } onOpenChange = { handleOpenChange } >
2024-09-11 17:41:16 +03:00
< Content
2024-09-12 22:39:35 +03: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 17:41:16 +03:00
>
2024-10-25 15:24:04 +03:00
< Header >
2024-09-11 17:41:16 +03:00
< Title > Tracked Object Details < / Title >
2024-09-12 22:39:35 +03:00
< Description className = "sr-only" > Tracked object details < / Description >
2024-09-11 17:41:16 +03: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 22:20:41 +03:00
{ item == "details" && < FaRegListAlt className = "size-4" / > }
2024-09-12 17:46:29 +03:00
{ item == "snapshot" && < FaImage className = "size-4" / > }
2024-09-11 22:20:41 +03:00
{ item == "video" && < FaVideo className = "size-4" / > }
2024-09-12 17:46:29 +03:00
{ item == "object lifecycle" && (
< FaRotate className = "size-4" / >
) }
2024-09-11 17:41:16 +03: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 }
/ >
) }
2024-09-12 17:46:29 +03:00
{ page == "snapshot" && (
2024-10-06 20:43:36 +03:00
< ObjectSnapshotTab
search = {
2024-09-12 17:46:29 +03:00
{
. . . search ,
plus_id : config?.plus?.enabled ? search . plus_id : "not_enabled" ,
} as unknown as Event
}
2024-09-11 17:41:16 +03:00
onEventUploaded = { ( ) = > {
search . plus_id = "new_upload" ;
} }
/ >
) }
2024-10-07 16:19:22 +03:00
{ page == "video" && < VideoTab search = { search } / > }
2024-09-12 17:46:29 +03:00
{ page == "object lifecycle" && (
< ObjectLifecycle
2024-09-13 15:07:11 +03:00
className = "w-full overflow-x-hidden"
2024-09-12 17:46:29 +03:00
event = { search as unknown as Event }
fullscreen = { true }
setPane = { ( ) = > { } }
/ >
) }
2024-09-11 17:41:16 +03:00
< / Content >
< / Overlay >
) ;
}
type ObjectDetailsTabProps = {
search : SearchResult ;
config? : FrigateConfig ;
setSearch : ( search : SearchResult | undefined ) = > void ;
setSimilarity ? : ( ) = > void ;
} ;
function ObjectDetailsTab ( {
search ,
config ,
setSearch ,
setSimilarity ,
} : ObjectDetailsTabProps ) {
2024-06-23 23:58:00 +03:00
const apiHost = useApiHost ( ) ;
2024-09-24 17:14:51 +03:00
// mutation / revalidation
const mutate = useGlobalMutation ( ) ;
2024-06-23 23:58:00 +03:00
// data
2024-09-13 23:44:31 +03:00
const [ desc , setDesc ] = useState ( search ? . data . description ) ;
2024-06-23 23:58:00 +03:00
// we have to make sure the current selected search item stays in sync
2024-09-14 16:42:56 +03:00
useEffect ( ( ) = > setDesc ( search ? . data . description ? ? "" ) , [ search ] ) ;
2024-06-23 23:58:00 +03: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 22:28:15 +03:00
config ? . ui . timezone ,
2024-06-23 23:58:00 +03:00
) ;
2024-09-10 21:38:58 +03:00
const score = useMemo ( ( ) = > {
if ( ! search ) {
return 0 ;
}
2024-10-23 18:31:20 +03:00
const value = search . data . top_score ? ? search . top_score ? ? 0 ;
2024-09-10 21:38:58 +03:00
return Math . round ( value * 100 ) ;
} , [ search ] ) ;
const subLabelScore = useMemo ( ( ) = > {
if ( ! search ) {
return undefined ;
}
2024-11-18 21:26:44 +03:00
if ( search . sub_label && search . data ? . sub_label_score ) {
2024-10-23 01:07:42 +03:00
return Math . round ( ( search . data ? . sub_label_score ? ? 0 ) * 100 ) ;
2024-09-10 21:38:58 +03:00
} else {
return undefined ;
}
} , [ search ] ) ;
2024-06-23 23:58:00 +03: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 17:14:51 +03:00
mutate (
( key ) = >
typeof key === "string" &&
( key . includes ( "events" ) ||
key . includes ( "events/search" ) ||
2024-09-24 21:34:29 +03:00
key . includes ( "events/explore" ) ) ,
2024-10-23 00:11:05 +03:00
( currentData : SearchResult [ ] [ ] | SearchResult [ ] | undefined ) = > {
2024-10-22 00:14:57 +03:00
if ( ! currentData ) return currentData ;
// optimistic update
2024-10-23 00:11:05 +03:00
return currentData
. flat ( )
. map ( ( event ) = >
2024-10-22 00:14:57 +03:00
event . id === search . id
2024-10-23 00:11:05 +03:00
? { . . . event , data : { . . . event . data , description : desc } }
2024-10-22 00:14:57 +03:00
: event ,
2024-10-23 00:11:05 +03:00
) ;
2024-10-22 00:14:57 +03:00
} ,
{
optimisticData : true ,
rollbackOnError : true ,
revalidate : false ,
} ,
2024-09-24 17:14:51 +03:00
) ;
2024-06-23 23:58:00 +03:00
} )
. catch ( ( ) = > {
toast . error ( "Failed to update the description" , {
position : "top-center" ,
} ) ;
2024-09-13 23:44:31 +03:00
setDesc ( search . data . description ) ;
2024-06-23 23:58:00 +03:00
} ) ;
2024-09-24 17:14:51 +03:00
} , [ desc , search , mutate ] ) ;
2024-10-01 00:54:53 +03:00
const regenerateDescription = useCallback (
( source : "snapshot" | "thumbnails" ) = > {
if ( ! search ) {
return ;
}
axios
. put ( ` events/ ${ search . id } /description/regenerate?source= ${ source } ` )
. then ( ( resp ) = > {
if ( resp . status == 200 ) {
toast . success (
2024-10-02 01:05:16 +03: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-10-01 00:54:53 +03:00
{
position : "top-center" ,
duration : 7000 ,
} ,
) ;
}
} )
2024-10-23 15:14:50 +03:00
. catch ( ( error ) = > {
2024-10-01 00:54:53 +03:00
toast . error (
2024-10-23 15:14:50 +03:00
` Failed to call ${ capitalizeAll ( config ? . genai . provider . replaceAll ( "_" , " " ) ? ? "Generative AI" ) } for a new description: ${ error . response . data . message } ` ,
2024-09-24 17:14:51 +03:00
{
position : "top-center" ,
} ,
) ;
2024-10-01 00:54:53 +03:00
} ) ;
} ,
[ search , config ] ,
) ;
2024-06-23 23:58:00 +03:00
return (
2024-09-12 22:39:35 +03:00
< div className = "flex flex-col gap-5" >
2024-09-11 17:41:16 +03: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 23:58:00 +03:00
< / div >
2024-09-11 17:41:16 +03:00
< / div >
< div className = "flex flex-col gap-1.5" >
2024-10-17 14:30:52 +03: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 17:41:16 +03:00
< div className = "text-sm" >
{ score } % { subLabelScore && ` ( ${ subLabelScore } %) ` }
2024-06-23 23:58:00 +03:00
< / div >
< / div >
2024-09-11 17:41:16 +03:00
< 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 22:39:35 +03:00
< div className = "flex w-full flex-col gap-2 pl-6" >
2024-09-11 17:41:16 +03: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 21:08:03 +03:00
{ config ? . semantic_search . enabled && search . data . type == "object" && (
2024-10-14 04:36:49 +03:00
< Button
2024-10-23 01:07:42 +03:00
aria - label = "Find similar tracked objects"
2024-10-14 04:36:49 +03:00
onClick = { ( ) = > {
setSearch ( undefined ) ;
2024-09-11 17:41:16 +03:00
2024-10-14 04:36:49 +03:00
if ( setSimilarity ) {
setSimilarity ( ) ;
}
} }
>
Find Similar
< / Button >
) }
2024-09-11 17:41:16 +03:00
< / div >
< / div >
< div className = "flex flex-col gap-1.5" >
2024-12-18 02:33:04 +03: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 >
< / div >
< / >
) : (
< >
< 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 ) }
/ >
< / >
) }
2024-09-24 17:14:51 +03:00
< div className = "flex w-full flex-row justify-end gap-2" >
2024-12-18 02:33:04 +03:00
{ config ? . cameras [ search . camera ] . genai . enabled && search . end_time && (
< >
< 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 >
2024-10-01 00:54:53 +03:00
< Button
2024-12-18 02:33:04 +03:00
variant = "select"
aria - label = "Save"
onClick = { updateDescription }
2024-10-01 00:54:53 +03:00
>
2024-12-18 02:33:04 +03:00
Save
2024-10-01 00:54:53 +03:00
< / Button >
2024-12-18 02:33:04 +03:00
< / >
2024-09-24 17:14:51 +03:00
) }
2024-09-11 17:41:16 +03:00
< / div >
< / div >
< / div >
) ;
}
2024-10-06 20:43:36 +03:00
type ObjectSnapshotTabProps = {
search : Event ;
onEventUploaded : ( ) = > void ;
} ;
2024-11-12 15:37:25 +03:00
export function ObjectSnapshotTab ( {
2024-10-06 20:43:36 +03: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-23 06:09:57 +03: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 20:43:36 +03:00
) }
< / TransformComponent >
2024-12-01 21:08:03 +03: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 +
< / div >
< 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 >
2024-10-13 20:34:39 +03:00
< / div >
2024-12-01 21:08:03 +03:00
< 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 20:43:36 +03:00
< / div >
< / TransformWrapper >
< / div >
< / div >
) ;
}
2024-09-11 17:41:16 +03:00
type VideoTabProps = {
search : SearchResult ;
} ;
2024-10-13 20:46:40 +03:00
export function VideoTab ( { search } : VideoTabProps ) {
2024-09-13 23:44:31 +03:00
const navigate = useNavigate ( ) ;
const { data : reviewItem } = useSWR < ReviewSegment > ( [
` review/event/ ${ search . id } ` ,
] ) ;
2024-10-13 20:46:40 +03:00
const endTime = useMemo ( ( ) = > search . end_time ? ? Date . now ( ) / 1000 , [ search ] ) ;
2024-09-13 23:44:31 +03:00
2024-10-13 20:46:40 +03:00
const source = ` ${ baseUrl } vod/ ${ search . camera } /start/ ${ search . start_time } /end/ ${ endTime } /index.m3u8 ` ;
2024-09-11 22:20:41 +03:00
2024-09-11 17:41:16 +03:00
return (
2024-10-13 20:46:40 +03:00
< GenericVideoPlayer source = { source } >
{ reviewItem && (
2024-10-07 16:19:22 +03:00
< div
2024-10-13 20:46:40 +03:00
className = { cn (
2024-10-23 06:09:57 +03:00
"absolute top-2 z-10 flex items-center gap-2" ,
2024-10-13 20:46:40 +03:00
isIOS ? "right-8" : "right-2" ,
2024-09-14 23:08:46 +03:00
) }
2024-10-13 20:46:40 +03: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-23 06:09:57 +03: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 20:46:40 +03:00
< / Tooltip >
2024-09-13 23:44:31 +03:00
< / div >
2024-10-13 20:46:40 +03:00
) }
< / GenericVideoPlayer >
2024-06-23 23:58:00 +03:00
) ;
}