1- import { Flex , Text , Button , Spinner } from '@radix-ui/themes'
1+ import { Flex , Text , Button , Spinner , Card , Badge , Separator , Grid } from '@radix-ui/themes'
22import {
33 VideoIcon ,
44 ReloadIcon ,
@@ -8,11 +8,13 @@ import {
88 ExclamationTriangleIcon ,
99} from '@radix-ui/react-icons'
1010import { useEntity , useWebRTC , useIsConnecting } from '~/hooks'
11- import { memo , useMemo , useState , useRef , useCallback } from 'react'
11+ import { memo , useMemo , useState , useRef , useCallback , useEffect } from 'react'
1212import { SkeletonCard , ErrorDisplay , FullscreenModal } from './ui'
1313import { GridCardWithComponents as GridCard } from './GridCard'
14- import { useDashboardStore } from '~/store'
14+ import { useDashboardStore , dashboardActions } from '~/store'
1515import { KeepAlive } from './KeepAlive'
16+ import { CardConfig } from './CardConfig'
17+ import type { GridItem } from '~/store/types'
1618import './CameraCard.css'
1719
1820interface CameraCardProps {
@@ -21,6 +23,7 @@ interface CameraCardProps {
2123 onDelete ?: ( ) => void
2224 isSelected ?: boolean
2325 onSelect ?: ( selected : boolean ) => void
26+ item ?: GridItem
2427}
2528
2629interface CameraAttributes {
@@ -34,6 +37,198 @@ interface CameraAttributes {
3437// Camera supported features bit flags from Home Assistant
3538const SUPPORT_STREAM = 2
3639
40+ // Stats display component
41+ function CameraStats ( {
42+ size,
43+ hasFrameWarning,
44+ isStreaming,
45+ videoElement,
46+ peerConnection,
47+ } : {
48+ size : 'small' | 'medium' | 'large'
49+ hasFrameWarning : boolean
50+ isStreaming : boolean
51+ videoElement : HTMLVideoElement | null
52+ peerConnection : RTCPeerConnection | null
53+ } ) {
54+ const scaleFactor = size === 'small' ? 0.64 : size === 'large' ? 0.96 : 0.8
55+ const [ stats , setStats ] = useState ( {
56+ timestamp : new Date ( ) . toLocaleTimeString ( ) ,
57+ fps : 0 ,
58+ decodedFrames : 0 ,
59+ droppedFrames : 0 ,
60+ bitrate : 0 ,
61+ resolution : '' ,
62+ } )
63+
64+ // Update stats every second
65+ useEffect ( ( ) => {
66+ if ( ! videoElement ) return
67+
68+ let lastTime = Date . now ( )
69+ let lastDecodedFrames = 0
70+ let lastBytesReceived = 0
71+
72+ const updateStats = async ( ) => {
73+ const now = Date . now ( )
74+ const deltaTime = ( now - lastTime ) / 1000 // Convert to seconds
75+
76+ // Get video quality stats
77+ const videoElem = videoElement as HTMLVideoElement & {
78+ getVideoPlaybackQuality ?: ( ) => { totalVideoFrames ?: number ; droppedVideoFrames ?: number }
79+ webkitDecodedFrameCount ?: number
80+ webkitDroppedFrameCount ?: number
81+ mozDecodedFrames ?: number
82+ mozParsedFrames ?: number
83+ }
84+ const videoQuality = videoElem . getVideoPlaybackQuality ?.( )
85+ const currentDecodedFrames =
86+ videoQuality ?. totalVideoFrames ||
87+ videoElem . webkitDecodedFrameCount ||
88+ videoElem . mozDecodedFrames ||
89+ 0
90+ const currentDroppedFrames =
91+ videoQuality ?. droppedVideoFrames ||
92+ videoElem . webkitDroppedFrameCount ||
93+ ( videoElem . mozParsedFrames && videoElem . mozDecodedFrames
94+ ? videoElem . mozParsedFrames - videoElem . mozDecodedFrames
95+ : 0 ) ||
96+ 0
97+
98+ // Calculate FPS based on decoded frames
99+ const framesDelta = currentDecodedFrames - lastDecodedFrames
100+ const fps = deltaTime > 0 ? Math . round ( framesDelta / deltaTime ) : 0
101+
102+ // Get connection stats if available
103+ let bitrate = 0
104+ try {
105+ if ( peerConnection && peerConnection . getStats ) {
106+ const stats = await peerConnection . getStats ( )
107+ stats . forEach ( ( report ) => {
108+ if ( report . type === 'inbound-rtp' && report . kind === 'video' ) {
109+ const rtpReport = report as RTCInboundRtpStreamStats
110+ const bytesReceived = rtpReport . bytesReceived || 0
111+ const bytesDelta = bytesReceived - lastBytesReceived
112+ bitrate = deltaTime > 0 ? Math . round ( ( bytesDelta * 8 ) / deltaTime / 1000 ) : 0 // kbps
113+ lastBytesReceived = bytesReceived
114+ }
115+ } )
116+ }
117+ } catch {
118+ // Ignore errors getting stats
119+ }
120+
121+ // Get video resolution
122+ const resolution =
123+ videoElement . videoWidth && videoElement . videoHeight
124+ ? `${ videoElement . videoWidth } x${ videoElement . videoHeight } `
125+ : ''
126+
127+ setStats ( {
128+ timestamp : new Date ( ) . toLocaleTimeString ( ) ,
129+ fps,
130+ decodedFrames : currentDecodedFrames ,
131+ droppedFrames : currentDroppedFrames ,
132+ bitrate,
133+ resolution,
134+ } )
135+
136+ lastTime = now
137+ lastDecodedFrames = currentDecodedFrames
138+ }
139+
140+ // Initial update
141+ updateStats ( )
142+
143+ // Update every second
144+ const interval = setInterval ( updateStats , 1000 )
145+ return ( ) => clearInterval ( interval )
146+ } , [ videoElement , peerConnection ] )
147+
148+ return (
149+ < Card
150+ size = "1"
151+ style = { {
152+ position : 'absolute' ,
153+ bottom : size === 'small' ? '4px' : '6px' ,
154+ right : size === 'small' ? '4px' : '6px' ,
155+ backgroundColor : 'var(--color-panel-translucent)' ,
156+ backdropFilter : 'blur(16px)' ,
157+ border : '1px solid var(--gray-a5)' ,
158+ padding : 'var(--space-2)' ,
159+ fontSize : '11px' ,
160+ } }
161+ >
162+ { size === 'small' ? (
163+ // Compact single line for small size
164+ < Text size = "1" style = { { fontFamily : 'var(--code-font-family)' } } >
165+ { stats . fps } FPS • { stats . bitrate } kb/s
166+ </ Text >
167+ ) : (
168+ // Flex layout for medium/large
169+ < Flex gap = "3" align = "center" >
170+ < Flex direction = "column" gap = "0" >
171+ < Text
172+ size = "1"
173+ color = "gray"
174+ style = { { fontFamily : 'var(--code-font-family)' , fontSize : '10px' } }
175+ >
176+ FPS
177+ </ Text >
178+ < Text size = "1" weight = "medium" style = { { fontFamily : 'var(--code-font-family)' } } >
179+ { stats . fps }
180+ </ Text >
181+ </ Flex >
182+
183+ < Flex direction = "column" gap = "0" >
184+ < Text
185+ size = "1"
186+ color = "gray"
187+ style = { { fontFamily : 'var(--code-font-family)' , fontSize : '10px' } }
188+ >
189+ Bitrate
190+ </ Text >
191+ < Text size = "1" weight = "medium" style = { { fontFamily : 'var(--code-font-family)' } } >
192+ { stats . bitrate }
193+ </ Text >
194+ </ Flex >
195+
196+ < Flex direction = "column" gap = "0" >
197+ < Text
198+ size = "1"
199+ color = "gray"
200+ style = { { fontFamily : 'var(--code-font-family)' , fontSize : '10px' } }
201+ >
202+ Frames
203+ </ Text >
204+ < Text size = "1" weight = "medium" style = { { fontFamily : 'var(--code-font-family)' } } >
205+ { stats . decodedFrames }
206+ </ Text >
207+ </ Flex >
208+
209+ < Flex direction = "column" gap = "0" >
210+ < Text
211+ size = "1"
212+ color = "gray"
213+ style = { { fontFamily : 'var(--code-font-family)' , fontSize : '10px' } }
214+ >
215+ Dropped
216+ </ Text >
217+ < Text
218+ size = "1"
219+ weight = "medium"
220+ color = { stats . droppedFrames > 0 ? 'red' : undefined }
221+ style = { { fontFamily : 'var(--code-font-family)' } }
222+ >
223+ { stats . droppedFrames }
224+ </ Text >
225+ </ Flex >
226+ </ Flex >
227+ ) }
228+ </ Card >
229+ )
230+ }
231+
37232// Custom-styled camera controls for both regular and fullscreen views
38233function CameraControls ( {
39234 friendlyName,
@@ -207,17 +402,25 @@ function CameraCardComponent({
207402 onDelete,
208403 isSelected = false ,
209404 onSelect,
405+ item,
210406} : CameraCardProps ) {
211407 const { entity, isConnected, isStale, isLoading : isEntityLoading } = useEntity ( entityId )
212- const { mode } = useDashboardStore ( )
408+ const { mode, currentScreenId } = useDashboardStore ( )
213409 const isEditMode = mode === 'edit'
214410 const isReconnecting = useIsConnecting ( )
215411 const [ isFullscreen , setIsFullscreen ] = useState ( false )
216412 const [ isMuted , setIsMuted ] = useState ( true ) // Start muted by default
413+ const [ configOpen , setConfigOpen ] = useState ( false )
217414 const normalContainerRef = useRef < HTMLDivElement > ( null )
218415 const fullscreenContainerRef = useRef < HTMLDivElement > ( null )
219416 const videoElementRef = useRef < HTMLVideoElement | null > ( null )
220417
418+ // Get configuration values
419+ const config = item ?. config || { }
420+ const fit = ( config . fit as string ) || 'cover'
421+ const matting = ( config . matting as string ) || 'small'
422+ const showStats = config . showStats === true
423+
221424 // Memoize camera attributes and stream support to prevent re-renders
222425 const cameraAttributes = useMemo (
223426 ( ) => entity ?. attributes as CameraAttributes | undefined ,
@@ -243,6 +446,7 @@ function CameraCardComponent({
243446 error : streamError ,
244447 retry : retryStream ,
245448 hasFrameWarning,
449+ peerConnection,
246450 } = useWebRTC ( {
247451 entityId,
248452 enabled : webRTCEnabled ,
@@ -275,6 +479,12 @@ function CameraCardComponent({
275479 setIsMuted ( ( prev ) => ! prev )
276480 } , [ ] )
277481
482+ const handleConfigSave = ( updates : Partial < GridItem > ) => {
483+ if ( item && currentScreenId ) {
484+ dashboardActions . updateGridItem ( currentScreenId , item . id , updates )
485+ }
486+ }
487+
278488 // Combined ref callback for both WebRTC and local ref
279489 const combinedVideoRef = useCallback (
280490 ( element : HTMLVideoElement | null ) => {
@@ -307,6 +517,17 @@ function CameraCardComponent({
307517 const isIdle = entity . state === 'idle'
308518 const isStreaming_ = entity . state === 'streaming'
309519
520+ // Calculate matting/padding based on configuration
521+ // Map matting values to Radix UI space tokens
522+ // Small matches the default padding for the current card size
523+ const defaultPadding = size === 'small' ? '2' : size === 'large' ? '4' : '3'
524+ const mattingPadding =
525+ matting === 'none'
526+ ? '0'
527+ : matting === 'large'
528+ ? 'var(--space-5)'
529+ : `var(--space-${ defaultPadding } )`
530+
310531 return (
311532 < >
312533 < GridCard
@@ -319,8 +540,11 @@ function CameraCardComponent({
319540 isUnavailable = { isUnavailable }
320541 onSelect = { ( ) => onSelect ?.( ! isSelected ) }
321542 onDelete = { onDelete }
543+ onConfigure = { ( ) => setConfigOpen ( true ) }
544+ hasConfiguration = { true }
322545 title = { streamError || undefined }
323546 className = "camera-card"
547+ customPadding = { mattingPadding }
324548 style = { {
325549 backgroundColor :
326550 ( isRecording || isStreaming_ ) && ! isSelected && ! streamError
@@ -418,7 +642,7 @@ function CameraCardComponent({
418642 style = { {
419643 width : '100%' ,
420644 height : '100%' ,
421- objectFit : isFullscreen ? 'contain' : 'cover' ,
645+ objectFit : isFullscreen ? 'contain' : ( fit as 'cover' | 'contain' ) ,
422646 display : isStreaming ? 'block' : 'none' ,
423647 } }
424648 />
@@ -463,6 +687,17 @@ function CameraCardComponent({
463687 </ Flex >
464688 ) }
465689
690+ { /* Stats display (when enabled) */ }
691+ { showStats && supportsStream && ! streamError && (
692+ < CameraStats
693+ size = { size }
694+ hasFrameWarning = { hasFrameWarning }
695+ isStreaming = { isStreaming }
696+ videoElement = { videoElementRef . current }
697+ peerConnection = { peerConnection }
698+ />
699+ ) }
700+
466701 { /* Controls and info container positioned absolutely at bottom left */ }
467702 < div
468703 style = { {
@@ -518,6 +753,17 @@ function CameraCardComponent({
518753 } }
519754 />
520755
756+ { /* Fullscreen stats display (when enabled) */ }
757+ { showStats && (
758+ < CameraStats
759+ size = "large"
760+ hasFrameWarning = { hasFrameWarning }
761+ isStreaming = { isStreaming }
762+ videoElement = { videoElementRef . current }
763+ peerConnection = { peerConnection }
764+ />
765+ ) }
766+
521767 { /* Fullscreen controls and info container */ }
522768 < div
523769 style = { {
@@ -566,6 +812,24 @@ function CameraCardComponent({
566812 Click or press ESC to exit
567813 </ div >
568814 </ FullscreenModal >
815+
816+ { /* Configuration modal */ }
817+ < CardConfig . Modal
818+ open = { configOpen }
819+ onOpenChange = { setConfigOpen }
820+ item = {
821+ item || {
822+ id : '' ,
823+ entityId,
824+ type : 'entity' ,
825+ x : 0 ,
826+ y : 0 ,
827+ width : CameraCard . defaultDimensions . width ,
828+ height : CameraCard . defaultDimensions . height ,
829+ }
830+ }
831+ onSave = { handleConfigSave }
832+ />
569833 </ >
570834 )
571835}
@@ -578,7 +842,8 @@ const MemoizedCameraCard = memo(CameraCardComponent, (prevProps, nextProps) => {
578842 prevProps . size === nextProps . size &&
579843 prevProps . onDelete === nextProps . onDelete &&
580844 prevProps . isSelected === nextProps . isSelected &&
581- prevProps . onSelect === nextProps . onSelect
845+ prevProps . onSelect === nextProps . onSelect &&
846+ prevProps . item === nextProps . item
582847 )
583848} )
584849
0 commit comments