Fix camera feed freezing and person detection alerts

- Add WebRTC auto-reconnect with exponential backoff when streams
  disconnect or fail, preventing permanent freezes in grid view
- Replace hard-coded Front Porch person alert with generic system
  that monitors all configured personDetectionEntities
- Map Frigate person_occupancy entities to cameras dynamically
- Show correct camera name and feed in alert overlay
- Bump config version to refresh detection entity defaults
This commit is contained in:
root
2026-02-26 15:33:25 -06:00
parent 97a7912eae
commit 58ebd3e239
4 changed files with 151 additions and 64 deletions

View File

@@ -19,17 +19,26 @@ import { useUIStore, useCameraOverlay } from '@/stores/uiStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { env } from '@/config/environment';
// Front porch alert overlay - shows for 30 seconds when person detected
function FrontPorchAlert({ onClose }: { onClose: () => void }) {
// Map a Frigate person_occupancy entity to its camera name
// e.g. 'binary_sensor.fpe_person_occupancy' -> 'fpe' -> match camera by frigateCamera
function entityToCameraName(entityId: string): string | null {
const match = entityId.match(/^binary_sensor\.(.+)_person_occupancy$/);
return match ? match[1] : null;
}
// Person detection alert overlay - shows for 30 seconds when person detected on any configured camera
function PersonAlert({ cameraName, onClose }: { cameraName: string; onClose: () => void }) {
const cameras = useSettingsStore((state) => state.config.cameras);
const frontPorchCamera = cameras.find((c) => c.name === 'Front_Porch');
const camera = cameras.find(
(c) => c.frigateCamera?.toLowerCase() === cameraName || c.name.toLowerCase() === cameraName,
);
useEffect(() => {
const timer = setTimeout(onClose, 30000); // 30 seconds
const timer = setTimeout(onClose, 30000);
return () => clearTimeout(timer);
}, [onClose]);
if (!frontPorchCamera) return null;
if (!camera) return null;
return (
<div className="fixed inset-0 z-50 bg-black flex flex-col">
@@ -37,7 +46,7 @@ function FrontPorchAlert({ onClose }: { onClose: () => void }) {
<div className="flex items-center gap-3">
<div className="w-3 h-3 rounded-full bg-status-warning animate-pulse" />
<h2 className="text-lg font-semibold text-status-warning">
Person Detected - Front Porch
Person Detected - {camera.displayName}
</h2>
</div>
<button
@@ -51,7 +60,7 @@ function FrontPorchAlert({ onClose }: { onClose: () => void }) {
</div>
<div className="flex-1 p-2">
<CameraFeed
camera={frontPorchCamera}
camera={camera}
className="w-full h-full"
showLabel={false}
/>
@@ -174,9 +183,10 @@ export default function App() {
const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen);
const { isOpen: cameraOverlayOpen } = useCameraOverlay();
// Front porch alert state
const [showFrontPorchAlert, setShowFrontPorchAlert] = useState(false);
const frontPorchAlertShownRef = useRef(false);
// Person detection alert state
const personDetectionEntities = useSettingsStore((state) => state.config.personDetectionEntities);
const [alertCamera, setAlertCamera] = useState<string | null>(null);
const alertShownForRef = useRef<Set<string>>(new Set());
// Motion detection now runs in the Electron main process (MotionDetector.ts)
// This prevents browser throttling when the screensaver is active
@@ -230,29 +240,32 @@ export default function App() {
initConfig();
}, [accessToken, connect]);
// Listen for Front Porch person detection - show full screen overlay for 30 seconds
// Listen for person detection on all configured entities
useEffect(() => {
if (!isConnected) return;
const frontPorchEntity = entities['binary_sensor.front_porch_person_occupancy'];
const isPersonDetected = frontPorchEntity?.state === 'on';
for (const entityId of personDetectionEntities) {
const entity = entities[entityId];
const isDetected = entity?.state === 'on';
const cameraName = entityToCameraName(entityId);
if (isPersonDetected && !frontPorchAlertShownRef.current) {
// Person just detected - show alert
frontPorchAlertShownRef.current = true;
setShowFrontPorchAlert(true);
// Also wake the screen
if (isDetected && cameraName && !alertShownForRef.current.has(entityId)) {
// Person just detected on this camera - show alert
alertShownForRef.current.add(entityId);
setAlertCamera(cameraName);
if (window.electronAPI?.screen?.wake) {
window.electronAPI.screen.wake();
}
} else if (!isPersonDetected) {
// Reset flag when person clears so next detection triggers alert
frontPorchAlertShownRef.current = false;
break; // Show one alert at a time
} else if (!isDetected) {
// Reset so next detection on this entity triggers again
alertShownForRef.current.delete(entityId);
}
}, [isConnected, entities]);
}
}, [isConnected, entities, personDetectionEntities]);
const closeFrontPorchAlert = useCallback(() => {
setShowFrontPorchAlert(false);
const closePersonAlert = useCallback(() => {
setAlertCamera(null);
}, []);
// Set up screen idle timeout
@@ -321,7 +334,7 @@ export default function App() {
{mediaOverlayOpen && <JellyfinOverlay />}
{cameraOverlayOpen && <CameraOverlay />}
{settingsOpen && <SettingsPanel />}
{showFrontPorchAlert && <FrontPorchAlert onClose={closeFrontPorchAlert} />}
{alertCamera && <PersonAlert cameraName={alertCamera} onClose={closePersonAlert} />}
<GlobalKeyboard />
</>
);

View File

@@ -1,7 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useCallback } from 'react';
import { Go2RTCWebRTC } from '@/services/go2rtc';
import { CameraConfig } from '@/config/entities';
const RECONNECT_DELAY_MS = 3000;
const MAX_RECONNECT_DELAY_MS = 30000;
interface CameraFeedProps {
camera: CameraConfig;
onClick?: () => void;
@@ -21,50 +24,99 @@ export function CameraFeed({
}: CameraFeedProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const webrtcRef = useRef<Go2RTCWebRTC | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout>>();
const reconnectDelayRef = useRef(RECONNECT_DELAY_MS);
const mountedRef = useRef(true);
const [isConnecting, setIsConnecting] = useState(true);
const [error, setError] = useState<string | null>(null);
// Use substream for grid view (lower bandwidth)
const streamName = useSubstream ? `${camera.go2rtcStream}_sub` : camera.go2rtcStream;
useEffect(() => {
let mounted = true;
let timeoutId: ReturnType<typeof setTimeout>;
const connect = useCallback(async () => {
if (!mountedRef.current) return;
const connect = async () => {
try {
setIsConnecting(true);
setError(null);
webrtcRef.current = new Go2RTCWebRTC(streamName);
// Disconnect previous instance
webrtcRef.current?.disconnect();
await webrtcRef.current.connect((stream) => {
if (mounted && videoRef.current) {
const webrtc = new Go2RTCWebRTC(streamName);
webrtcRef.current = webrtc;
await webrtc.connect(
// onTrack
(stream) => {
if (mountedRef.current && videoRef.current) {
videoRef.current.srcObject = stream;
setIsConnecting(false);
setError(null);
// Reset backoff on successful connection
reconnectDelayRef.current = RECONNECT_DELAY_MS;
}
});
},
// onDisconnect - schedule reconnect
() => {
if (!mountedRef.current) return;
console.log(`WebRTC disconnected for ${streamName}, reconnecting in ${reconnectDelayRef.current}ms`);
setError('Stream disconnected');
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = setTimeout(() => {
if (mountedRef.current) {
connect();
}
}, reconnectDelayRef.current);
// Exponential backoff capped at max
reconnectDelayRef.current = Math.min(
reconnectDelayRef.current * 1.5,
MAX_RECONNECT_DELAY_MS,
);
},
);
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err.message : 'Failed to connect');
if (!mountedRef.current) return;
const msg = err instanceof Error ? err.message : 'Failed to connect';
setError(msg);
setIsConnecting(false);
// Retry on connection failure too
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = setTimeout(() => {
if (mountedRef.current) {
connect();
}
}, reconnectDelayRef.current);
reconnectDelayRef.current = Math.min(
reconnectDelayRef.current * 1.5,
MAX_RECONNECT_DELAY_MS,
);
}
};
}, [streamName]);
useEffect(() => {
mountedRef.current = true;
reconnectDelayRef.current = RECONNECT_DELAY_MS;
let initTimer: ReturnType<typeof setTimeout>;
// Stagger connections to avoid overwhelming go2rtc
if (delayMs > 0) {
timeoutId = setTimeout(connect, delayMs);
initTimer = setTimeout(connect, delayMs);
} else {
connect();
}
return () => {
mounted = false;
if (timeoutId) clearTimeout(timeoutId);
mountedRef.current = false;
clearTimeout(initTimer);
clearTimeout(reconnectTimerRef.current);
webrtcRef.current?.disconnect();
};
}, [streamName, delayMs]);
}, [connect, delayMs]);
return (
<div
@@ -93,13 +145,14 @@ export function CameraFeed({
)}
{/* Error Overlay */}
{error && (
{error && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-imperial-black/80">
<div className="flex flex-col items-center gap-2 text-center p-4">
<svg className="w-8 h-8 text-status-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="text-sm text-gray-400">{error}</span>
<span className="text-xs text-gray-500">Reconnecting...</span>
</div>
</div>
)}

View File

@@ -10,13 +10,22 @@ export class Go2RTCWebRTC {
private mediaStream: MediaStream | null = null;
private streamName: string;
private onTrackCallback: ((stream: MediaStream) => void) | null = null;
private onDisconnectCallback: (() => void) | null = null;
private disposed = false;
constructor(streamName: string) {
this.streamName = streamName;
}
async connect(onTrack: (stream: MediaStream) => void): Promise<void> {
async connect(
onTrack: (stream: MediaStream) => void,
onDisconnect?: () => void,
): Promise<void> {
this.onTrackCallback = onTrack;
this.onDisconnectCallback = onDisconnect ?? null;
// Clean up any existing connection before reconnecting
this.cleanupConnection();
// Create peer connection with STUN servers
const config: RTCConfiguration = {
@@ -29,6 +38,7 @@ export class Go2RTCWebRTC {
// Handle incoming tracks
this.peerConnection.ontrack = (event) => {
if (this.disposed) return;
console.log(`Received track for ${this.streamName}:`, event.track.kind);
if (event.streams && event.streams[0]) {
this.mediaStream = event.streams[0];
@@ -44,9 +54,13 @@ export class Go2RTCWebRTC {
}
};
// Handle connection state changes
// Handle connection state changes - notify on disconnect/failure
this.peerConnection.onconnectionstatechange = () => {
console.log(`WebRTC connection state for ${this.streamName}:`, this.peerConnection?.connectionState);
const state = this.peerConnection?.connectionState;
console.log(`WebRTC connection state for ${this.streamName}:`, state);
if (!this.disposed && (state === 'disconnected' || state === 'failed')) {
this.onDisconnectCallback?.();
}
};
// Add transceiver for video (receive only)
@@ -62,6 +76,20 @@ export class Go2RTCWebRTC {
await this.peerConnection.setRemoteDescription(answer);
}
private cleanupConnection(): void {
if (this.mediaStream) {
this.mediaStream.getTracks().forEach((track) => track.stop());
this.mediaStream = null;
}
if (this.peerConnection) {
this.peerConnection.ontrack = null;
this.peerConnection.onicecandidate = null;
this.peerConnection.onconnectionstatechange = null;
this.peerConnection.close();
this.peerConnection = null;
}
}
private async sendOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
const url = `${env.go2rtcUrl}/api/webrtc?src=${encodeURIComponent(this.streamName)}`;
@@ -91,17 +119,10 @@ export class Go2RTCWebRTC {
}
disconnect(): void {
if (this.mediaStream) {
this.mediaStream.getTracks().forEach((track) => track.stop());
this.mediaStream = null;
}
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
this.disposed = true;
this.cleanupConnection();
this.onTrackCallback = null;
this.onDisconnectCallback = null;
}
getMediaStream(): MediaStream | null {

View File

@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Increment this when default cameras/config changes to force refresh
const CONFIG_VERSION = 8;
const CONFIG_VERSION = 9;
export interface ThermostatConfig {
entityId: string;