Skip to content

Commit e5ff0a4

Browse files
authored
feat(camera): add configuration options for fit, matting, and debug stats (#141)
1 parent d27383c commit e5ff0a4

4 files changed

Lines changed: 309 additions & 7 deletions

File tree

src/components/CameraCard.tsx

Lines changed: 271 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Flex, Text, Button, Spinner } from '@radix-ui/themes'
1+
import { Flex, Text, Button, Spinner, Card, Badge, Separator, Grid } from '@radix-ui/themes'
22
import {
33
VideoIcon,
44
ReloadIcon,
@@ -8,11 +8,13 @@ import {
88
ExclamationTriangleIcon,
99
} from '@radix-ui/react-icons'
1010
import { useEntity, useWebRTC, useIsConnecting } from '~/hooks'
11-
import { memo, useMemo, useState, useRef, useCallback } from 'react'
11+
import { memo, useMemo, useState, useRef, useCallback, useEffect } from 'react'
1212
import { SkeletonCard, ErrorDisplay, FullscreenModal } from './ui'
1313
import { GridCardWithComponents as GridCard } from './GridCard'
14-
import { useDashboardStore } from '~/store'
14+
import { useDashboardStore, dashboardActions } from '~/store'
1515
import { KeepAlive } from './KeepAlive'
16+
import { CardConfig } from './CardConfig'
17+
import type { GridItem } from '~/store/types'
1618
import './CameraCard.css'
1719

1820
interface CameraCardProps {
@@ -21,6 +23,7 @@ interface CameraCardProps {
2123
onDelete?: () => void
2224
isSelected?: boolean
2325
onSelect?: (selected: boolean) => void
26+
item?: GridItem
2427
}
2528

2629
interface CameraAttributes {
@@ -34,6 +37,198 @@ interface CameraAttributes {
3437
// Camera supported features bit flags from Home Assistant
3538
const 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
38233
function 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

Comments
 (0)