Skip to content

Commit fbb0f01

Browse files
authored
feat: exclude camera entities from stale tracking and remove stale display (#139)
1 parent c8bc464 commit fbb0f01

35 files changed

Lines changed: 1359 additions & 276 deletions

src/components/BinarySensorCard.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,11 @@ function BinarySensorCardComponent({
118118
onDelete={onDelete}
119119
onConfigure={isEditMode && item ? () => setConfigOpen(true) : undefined}
120120
hasConfiguration={!!item}
121-
title={isStale ? 'Entity data may be outdated' : undefined}
121+
title={undefined}
122122
style={{
123123
backgroundColor: isOn && !isSelected ? 'var(--amber-3)' : undefined,
124-
borderColor: isOn && !isSelected && !isStale ? 'var(--amber-6)' : undefined,
125-
borderWidth: isSelected || isOn || isStale ? '2px' : '1px',
124+
borderColor: isOn && !isSelected ? 'var(--amber-6)' : undefined,
125+
borderWidth: isSelected || isOn ? '2px' : '1px',
126126
}}
127127
>
128128
<Flex direction="column" align="center" justify="center" gap="2">

src/components/ButtonCard.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,18 +85,18 @@ function ButtonCardComponent({
8585
onSelect={() => onSelect?.(!isSelected)}
8686
onDelete={onDelete}
8787
onClick={handleClick}
88-
title={error || (isStale ? 'Entity data may be outdated' : undefined)}
88+
title={error || undefined}
8989
style={{
9090
backgroundColor: isOn && !isSelected && !error ? 'var(--amber-3)' : undefined,
91-
borderColor: isOn && !isSelected && !error && !isStale ? 'var(--amber-6)' : undefined,
92-
borderWidth: isSelected || error || isOn || isStale ? '2px' : '1px',
91+
borderColor: isOn && !isSelected && !error ? 'var(--amber-6)' : undefined,
92+
borderWidth: isSelected || error || isOn ? '2px' : '1px',
9393
}}
9494
>
9595
<Flex direction="column" align="center" justify="center" gap="2">
9696
<GridCard.Icon>
9797
<span
9898
style={{
99-
color: isStale ? 'var(--orange-9)' : isOn ? 'var(--amber-9)' : 'var(--gray-9)',
99+
color: isOn ? 'var(--amber-9)' : 'var(--gray-9)',
100100
transform: `scale(${iconScale})`,
101101
display: 'flex',
102102
alignItems: 'center',

src/components/CameraCard.css

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
@keyframes recording-pulse {
2+
0% {
3+
opacity: 1;
4+
transform: scale(1);
5+
}
6+
50% {
7+
opacity: 0.6;
8+
transform: scale(1.1);
9+
}
10+
100% {
11+
opacity: 1;
12+
transform: scale(1);
13+
}
14+
}
15+
16+
.recording-dot {
17+
width: 0.625em;
18+
height: 0.625em;
19+
background-color: #dc2626;
20+
border-radius: 50%;
21+
display: inline-block;
22+
animation: recording-pulse 1.5s ease-in-out infinite;
23+
}

src/components/CameraCard.tsx

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
1-
import { Flex, Text, Button } from '@radix-ui/themes'
1+
import { Flex, Text, Button, Spinner } from '@radix-ui/themes'
22
import {
33
VideoIcon,
44
ReloadIcon,
55
EnterFullScreenIcon,
66
SpeakerLoudIcon,
77
SpeakerOffIcon,
8+
ExclamationTriangleIcon,
89
} from '@radix-ui/react-icons'
9-
import { useEntity, useWebRTC } from '~/hooks'
10+
import { useEntity, useWebRTC, useIsConnecting } from '~/hooks'
1011
import { memo, useMemo, useState, useRef, useCallback } from 'react'
1112
import { SkeletonCard, ErrorDisplay, FullscreenModal } from './ui'
1213
import { GridCardWithComponents as GridCard } from './GridCard'
1314
import { useDashboardStore } from '~/store'
1415
import { KeepAlive } from './KeepAlive'
16+
import './CameraCard.css'
1517

1618
interface CameraCardProps {
1719
entityId: string
@@ -43,6 +45,8 @@ function CameraControls({
4345
supportsStream,
4446
isEditMode,
4547
isMuted,
48+
isReconnecting,
49+
hasFrameWarning,
4650
handleToggleMute,
4751
handleVideoFullscreen,
4852
size,
@@ -57,6 +61,8 @@ function CameraControls({
5761
supportsStream: boolean
5862
isEditMode: boolean
5963
isMuted: boolean
64+
isReconnecting: boolean
65+
hasFrameWarning: boolean
6066
handleToggleMute: (e: React.MouseEvent) => void
6167
handleVideoFullscreen: (e: React.MouseEvent) => void
6268
size: 'small' | 'medium' | 'large'
@@ -100,17 +106,31 @@ function CameraControls({
100106
lineHeight: 1.2,
101107
textTransform: 'uppercase',
102108
fontWeight: 500,
109+
display: 'flex',
110+
alignItems: 'center',
111+
gap: `${0.4 * scaleFactor}em`,
103112
}}
104113
>
114+
{isReconnecting || (supportsStream && !isStreaming && !streamError) ? (
115+
<Spinner size="1" />
116+
) : hasFrameWarning && !streamError ? (
117+
<ExclamationTriangleIcon style={{ color: '#f59e0b', width: '1em', height: '1em' }} />
118+
) : (isRecording || isStreaming) && !streamError ? (
119+
<span className="recording-dot" />
120+
) : null}
105121
{streamError
106122
? 'ERROR'
107-
: isRecording
108-
? 'RECORDING'
109-
: isStreaming
110-
? 'STREAMING'
111-
: isIdle
112-
? 'IDLE'
113-
: entity.state.toUpperCase()}
123+
: isReconnecting || (supportsStream && !isStreaming && !streamError)
124+
? 'CONNECTING'
125+
: hasFrameWarning
126+
? 'NO SIGNAL'
127+
: supportsStream && isStreaming && (isRecording || entity.state === 'streaming')
128+
? 'RECORDING'
129+
: supportsStream && isStreaming
130+
? 'STREAMING'
131+
: isIdle
132+
? 'IDLE'
133+
: entity.state.toUpperCase()}
114134
</div>
115135
</div>
116136

@@ -191,6 +211,7 @@ function CameraCardComponent({
191211
const { entity, isConnected, isStale, isLoading: isEntityLoading } = useEntity(entityId)
192212
const { mode } = useDashboardStore()
193213
const isEditMode = mode === 'edit'
214+
const isReconnecting = useIsConnecting()
194215
const [isFullscreen, setIsFullscreen] = useState(false)
195216
const [isMuted, setIsMuted] = useState(true) // Start muted by default
196217
const normalContainerRef = useRef<HTMLDivElement>(null)
@@ -221,6 +242,7 @@ function CameraCardComponent({
221242
isStreaming,
222243
error: streamError,
223244
retry: retryStream,
245+
hasFrameWarning,
224246
} = useWebRTC({
225247
entityId,
226248
enabled: webRTCEnabled,
@@ -297,19 +319,18 @@ function CameraCardComponent({
297319
isUnavailable={isUnavailable}
298320
onSelect={() => onSelect?.(!isSelected)}
299321
onDelete={onDelete}
300-
title={streamError || (isStale ? 'Entity data may be outdated' : undefined)}
322+
title={streamError || undefined}
301323
className="camera-card"
302324
style={{
303325
backgroundColor:
304326
(isRecording || isStreaming_) && !isSelected && !streamError
305327
? 'var(--blue-3)'
306328
: undefined,
307329
borderColor:
308-
(isRecording || isStreaming_) && !isSelected && !streamError && !isStale
330+
(isRecording || isStreaming_) && !isSelected && !streamError
309331
? 'var(--blue-6)'
310332
: undefined,
311-
borderWidth:
312-
isSelected || streamError || isRecording || isStreaming_ || isStale ? '2px' : '1px',
333+
borderWidth: isSelected || streamError || isRecording || isStreaming_ ? '2px' : '1px',
313334
}}
314335
>
315336
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
@@ -402,13 +423,24 @@ function CameraCardComponent({
402423
}}
403424
/>
404425
</KeepAlive>
426+
{!isStreaming && (
427+
<Flex
428+
align="center"
429+
justify="center"
430+
style={{
431+
position: 'absolute',
432+
top: 0,
433+
left: 0,
434+
width: '100%',
435+
height: '100%',
436+
backgroundColor: 'var(--gray-3)',
437+
}}
438+
>
439+
<Spinner size="3" />
440+
</Flex>
441+
)}
405442
</>
406443
)}
407-
{!isStreaming && !streamError && (
408-
<Text size="2" color="gray">
409-
Connecting...
410-
</Text>
411-
)}
412444
</div>
413445
) : (
414446
<Flex
@@ -420,12 +452,8 @@ function CameraCardComponent({
420452
<GridCard.Icon>
421453
<VideoIcon
422454
style={{
423-
color: isStale
424-
? 'var(--orange-9)'
425-
: isRecording || isStreaming_
426-
? 'var(--blue-9)'
427-
: 'var(--gray-9)',
428-
opacity: isStale ? 0.6 : 1,
455+
color: isRecording || isStreaming_ ? 'var(--blue-9)' : 'var(--gray-9)',
456+
opacity: 1,
429457
transition: 'opacity 0.2s ease',
430458
width: 20,
431459
height: 20,
@@ -454,6 +482,8 @@ function CameraCardComponent({
454482
supportsStream={supportsStream}
455483
isEditMode={isEditMode}
456484
isMuted={isMuted}
485+
isReconnecting={isReconnecting}
486+
hasFrameWarning={hasFrameWarning}
457487
handleToggleMute={handleToggleMute}
458488
handleVideoFullscreen={handleVideoFullscreen}
459489
size={size}
@@ -508,6 +538,8 @@ function CameraCardComponent({
508538
supportsStream={supportsStream}
509539
isEditMode={isEditMode}
510540
isMuted={isMuted}
541+
isReconnecting={isReconnecting}
542+
hasFrameWarning={hasFrameWarning}
511543
handleToggleMute={handleToggleMute}
512544
handleVideoFullscreen={handleVideoFullscreen}
513545
size="large"

src/components/ClimateCard.test.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ describe('ClimateCard', () => {
456456
expect(card).toHaveAttribute('title', 'Service call failed')
457457
})
458458

459-
it('shows stale state with dashed border', () => {
459+
it('does not show stale state visually (stale display removed)', () => {
460460
const entity = createMockClimateEntity()
461461
;(useEntity as any).mockReturnValue({
462462
entity,
@@ -467,9 +467,8 @@ describe('ClimateCard', () => {
467467
renderWithTheme(<ClimateCard entityId="climate.test_thermostat" />)
468468

469469
const card = screen.getByText('Test Thermostat').closest('.climate-card')
470-
expect(card).toHaveStyle({
471-
borderColor: 'var(--orange-7)',
472-
borderWidth: '2px',
470+
// Stale state no longer shows visual indication
471+
expect(card).not.toHaveStyle({
473472
borderStyle: 'dashed',
474473
})
475474
})

src/components/ClimateCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ function ClimateCardComponent({
472472
isSelected={isSelected}
473473
onSelect={() => onSelect?.(!isSelected)}
474474
onDelete={onDelete}
475-
title={error || (isStale ? 'Entity data may be outdated' : undefined)}
475+
title={error || undefined}
476476
className="climate-card"
477477
>
478478
<Flex

0 commit comments

Comments
 (0)