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-09-11 17:41:16 +03:00
import { useCallback , useEffect , useMemo , useRef , 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 HlsVideoPlayer from "@/components/player/HlsVideoPlayer" ;
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 ,
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-07 16:19:22 +03:00
import { useResizeObserver } from "@/hooks/resize-observer" ;
import { VideoResolutionType } from "@/types/live" ;
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-09-11 17:41:16 +03:00
type SearchTab = ( typeof SEARCH_TABS ) [ number ] ;
2024-06-23 23:58:00 +03:00
type SearchDetailDialogProps = {
search? : SearchResult ;
setSearch : ( search : SearchResult | undefined ) = > void ;
setSimilarity ? : ( ) = > void ;
} ;
export default function SearchDetailDialog ( {
search ,
setSearch ,
setSimilarity ,
} : SearchDetailDialogProps ) {
const { data : config } = useSWR < FrigateConfig > ( "config" , {
revalidateOnFocus : false ,
} ) ;
2024-09-11 17:41:16 +03:00
// tabs
const [ page , setPage ] = useState < SearchTab > ( "details" ) ;
const [ pageToggle , setPageToggle ] = useOptimisticState ( page , setPage , 100 ) ;
2024-09-12 22:39:35 +03:00
// dialog and mobile page
const [ isOpen , setIsOpen ] = useState ( search != undefined ) ;
useEffect ( ( ) = > {
setIsOpen ( search != undefined ) ;
} , [ 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-09-26 23:30:56 +03:00
if ( search . data . type != "object" ) {
const index = views . indexOf ( "object lifecycle" ) ;
views . splice ( index , 1 ) ;
}
2024-09-11 17:41:16 +03:00
// TODO implement
//if (!config.semantic_search.enabled) {
// const index = views.indexOf("similar-calendar");
// views.splice(index, 1);
// }
return views ;
} , [ config , search ] ) ;
2024-09-12 17:46:29 +03:00
useEffect ( ( ) = > {
if ( searchTabs . length == 0 ) {
return ;
}
if ( ! searchTabs . includes ( pageToggle ) ) {
setPage ( "details" ) ;
}
} , [ pageToggle , searchTabs ] ) ;
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 (
< Overlay
2024-09-12 22:39:35 +03:00
open = { isOpen }
2024-09-11 17:41:16 +03:00
onOpenChange = { ( open ) = > {
if ( ! open ) {
setSearch ( undefined ) ;
}
} }
>
< 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-09-12 22:39:35 +03:00
< Header onClose = { ( ) = > setIsOpen ( false ) } >
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 ;
}
const value = search . score ? ? search . data . top_score ;
return Math . round ( value * 100 ) ;
} , [ search ] ) ;
const subLabelScore = useMemo ( ( ) = > {
if ( ! search ) {
return undefined ;
}
if ( search . sub_label ) {
return Math . round ( ( search . data ? . top_score ? ? 0 ) * 100 ) ;
} 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-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 ,
} ,
) ;
}
} )
. catch ( ( ) = > {
toast . error (
2024-10-02 01:05:16 +03:00
` Failed to call ${ capitalizeAll ( config ? . genai . provider . replaceAll ( "_" , " " ) ? ? "Generative AI" ) } for a new description ` ,
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" >
< div className = "text-sm text-primary/40" > Score < / div >
< 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 ` }
/ >
< Button
onClick = { ( ) = > {
setSearch ( undefined ) ;
if ( setSimilarity ) {
setSimilarity ( ) ;
}
} }
>
Find Similar
< / Button >
< / div >
< / div >
< div className = "flex flex-col gap-1.5" >
< div className = "text-sm text-primary/40" > Description < / div >
< Textarea
2024-09-14 23:08:46 +03:00
className = "h-64"
2024-09-14 16:42:56 +03:00
placeholder = "Description of the tracked object"
2024-09-11 17:41:16 +03:00
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" >
{ config ? . genai . enabled && (
2024-10-01 00:54:53 +03:00
< div className = "flex items-center" >
< Button
className = "rounded-r-none border-r-0"
onClick = { ( ) = > regenerateDescription ( "thumbnails" ) }
>
Regenerate
< / Button >
{ search . has_snapshot && (
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button className = "rounded-l-none border-l-0 px-2" >
< FaChevronDown className = "size-3" / >
< / Button >
< / DropdownMenuTrigger >
< DropdownMenuContent >
< DropdownMenuItem
className = "cursor-pointer"
onClick = { ( ) = > regenerateDescription ( "snapshot" ) }
>
Regenerate from Snapshot
< / DropdownMenuItem >
< DropdownMenuItem
className = "cursor-pointer"
onClick = { ( ) = > regenerateDescription ( "thumbnails" ) }
>
Regenerate from Thumbnails
< / DropdownMenuItem >
< / DropdownMenuContent >
< / DropdownMenu >
) }
< / div >
2024-09-24 17:14:51 +03:00
) }
2024-09-11 17:41:16 +03:00
< Button variant = "select" onClick = { updateDescription } >
Save
< / Button >
< / div >
< / div >
< / div >
) ;
}
2024-10-06 20:43:36 +03:00
type ObjectSnapshotTabProps = {
search : Event ;
onEventUploaded : ( ) = > void ;
} ;
function ObjectSnapshotTab ( {
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 && (
< 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 ( ) ;
} }
/ >
) }
< / TransformComponent >
2024-10-13 20:34:39 +03:00
{ search . plus_id !== "not_enabled" && (
< 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-06 20:43:36 +03:00
< / div >
2024-10-13 20:34:39 +03:00
< div className = "flex flex-row justify-center gap-2 md:justify-end" >
{ state == "reviewing" && search . end_time && (
< >
< Button
className = "bg-success"
onClick = { ( ) = > {
setState ( "uploading" ) ;
onSubmitToPlus ( false ) ;
} }
>
This is a { search ? . label }
< / Button >
< Button
className = "text-white"
variant = "destructive"
onClick = { ( ) = > {
setState ( "uploading" ) ;
onSubmitToPlus ( true ) ;
} }
>
This is not 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 >
) }
2024-10-06 20:43:36 +03:00
< / div >
2024-10-13 20:34:39 +03:00
< / 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-07 16:19:22 +03:00
function VideoTab ( { search } : VideoTabProps ) {
2024-09-11 20:32:45 +03:00
const [ isLoading , setIsLoading ] = useState ( true ) ;
2024-09-11 17:41:16 +03:00
const videoRef = useRef < HTMLVideoElement | null > ( null ) ;
const endTime = useMemo ( ( ) = > search . end_time ? ? Date . now ( ) / 1000 , [ search ] ) ;
2024-09-13 23:44:31 +03:00
const navigate = useNavigate ( ) ;
const { data : reviewItem } = useSWR < ReviewSegment > ( [
` review/event/ ${ search . id } ` ,
] ) ;
2024-10-07 16:19:22 +03:00
const containerRef = useRef < HTMLDivElement | null > ( null ) ;
2024-09-11 22:20:41 +03:00
2024-10-07 16:19:22 +03:00
const [ { width : containerWidth , height : containerHeight } ] =
useResizeObserver ( containerRef ) ;
const [ videoResolution , setVideoResolution ] = useState < VideoResolutionType > ( {
width : 0 ,
height : 0 ,
} ) ;
2024-09-11 22:20:41 +03:00
2024-10-07 16:19:22 +03:00
const videoAspectRatio = useMemo ( ( ) = > {
return videoResolution . width / videoResolution . height || 16 / 9 ;
} , [ videoResolution ] ) ;
2024-09-11 22:20:41 +03:00
2024-10-07 16:19:22 +03:00
const containerAspectRatio = useMemo ( ( ) = > {
return containerWidth / containerHeight || 16 / 9 ;
} , [ containerWidth , containerHeight ] ) ;
2024-09-11 22:20:41 +03:00
2024-10-07 16:19:22 +03:00
const videoDimensions = useMemo ( ( ) = > {
if ( ! containerWidth || ! containerHeight )
return { width : "100%" , height : "100%" } ;
if ( containerAspectRatio > videoAspectRatio ) {
const height = containerHeight ;
const width = height * videoAspectRatio ;
return { width : ` ${ width } px ` , height : ` ${ height } px ` } ;
2024-09-11 22:20:41 +03:00
} else {
2024-10-07 16:19:22 +03:00
const width = containerWidth ;
const height = width / videoAspectRatio ;
return { width : ` ${ width } px ` , height : ` ${ height } px ` } ;
2024-09-11 22:20:41 +03:00
}
2024-10-07 16:19:22 +03:00
} , [ containerWidth , containerHeight , videoAspectRatio , containerAspectRatio ] ) ;
2024-09-11 22:20:41 +03:00
2024-09-11 17:41:16 +03:00
return (
2024-10-07 16:19:22 +03:00
< div ref = { containerRef } className = "relative flex h-full w-full flex-col" >
< div className = "relative flex flex-grow items-center justify-center" >
2024-09-13 23:44:31 +03:00
{ ( isLoading || ! reviewItem ) && (
2024-10-07 16:19:22 +03:00
< ActivityIndicator className = "absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2" / >
2024-09-13 23:44:31 +03:00
) }
2024-10-07 16:19:22 +03:00
< div
className = "relative flex items-center justify-center"
style = { videoDimensions }
>
2024-09-13 23:44:31 +03:00
< HlsVideoPlayer
videoRef = { videoRef }
currentSource = { ` ${ baseUrl } vod/ ${ search . camera } /start/ ${ search . start_time } /end/ ${ endTime } /index.m3u8 ` }
hotKeys
visible
frigateControls = { false }
fullscreen = { false }
supportsFullscreen = { false }
onPlaying = { ( ) = > setIsLoading ( false ) }
2024-10-07 16:19:22 +03:00
setFullResolution = { setVideoResolution }
2024-09-13 23:44:31 +03:00
/ >
2024-10-07 16:19:22 +03:00
{ ! isLoading && reviewItem && (
< div
className = { cn (
"absolute top-2 z-10 flex items-center" ,
isIOS ? "right-8" : "right-2" ,
) }
>
< 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 >
< TooltipContent side = "left" > View in History < / TooltipContent >
< / Tooltip >
< / div >
2024-09-14 23:08:46 +03:00
) }
2024-09-13 23:44:31 +03:00
< / div >
2024-10-07 16:19:22 +03:00
< / div >
2024-09-11 22:20:41 +03:00
< / div >
2024-06-23 23:58:00 +03:00
) ;
}