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:
63
src/App.tsx
63
src/App.tsx
@@ -19,17 +19,26 @@ import { useUIStore, useCameraOverlay } from '@/stores/uiStore';
|
|||||||
import { useSettingsStore } from '@/stores/settingsStore';
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
import { env } from '@/config/environment';
|
import { env } from '@/config/environment';
|
||||||
|
|
||||||
// Front porch alert overlay - shows for 30 seconds when person detected
|
// Map a Frigate person_occupancy entity to its camera name
|
||||||
function FrontPorchAlert({ onClose }: { onClose: () => void }) {
|
// 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 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(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(onClose, 30000); // 30 seconds
|
const timer = setTimeout(onClose, 30000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
if (!frontPorchCamera) return null;
|
if (!camera) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
<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="flex items-center gap-3">
|
||||||
<div className="w-3 h-3 rounded-full bg-status-warning animate-pulse" />
|
<div className="w-3 h-3 rounded-full bg-status-warning animate-pulse" />
|
||||||
<h2 className="text-lg font-semibold text-status-warning">
|
<h2 className="text-lg font-semibold text-status-warning">
|
||||||
Person Detected - Front Porch
|
Person Detected - {camera.displayName}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -51,7 +60,7 @@ function FrontPorchAlert({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 p-2">
|
<div className="flex-1 p-2">
|
||||||
<CameraFeed
|
<CameraFeed
|
||||||
camera={frontPorchCamera}
|
camera={camera}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
/>
|
/>
|
||||||
@@ -174,9 +183,10 @@ export default function App() {
|
|||||||
const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen);
|
const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen);
|
||||||
const { isOpen: cameraOverlayOpen } = useCameraOverlay();
|
const { isOpen: cameraOverlayOpen } = useCameraOverlay();
|
||||||
|
|
||||||
// Front porch alert state
|
// Person detection alert state
|
||||||
const [showFrontPorchAlert, setShowFrontPorchAlert] = useState(false);
|
const personDetectionEntities = useSettingsStore((state) => state.config.personDetectionEntities);
|
||||||
const frontPorchAlertShownRef = useRef(false);
|
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)
|
// Motion detection now runs in the Electron main process (MotionDetector.ts)
|
||||||
// This prevents browser throttling when the screensaver is active
|
// This prevents browser throttling when the screensaver is active
|
||||||
@@ -230,29 +240,32 @@ export default function App() {
|
|||||||
initConfig();
|
initConfig();
|
||||||
}, [accessToken, connect]);
|
}, [accessToken, connect]);
|
||||||
|
|
||||||
// Listen for Front Porch person detection - show full screen overlay for 30 seconds
|
// Listen for person detection on all configured entities
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
|
|
||||||
const frontPorchEntity = entities['binary_sensor.front_porch_person_occupancy'];
|
for (const entityId of personDetectionEntities) {
|
||||||
const isPersonDetected = frontPorchEntity?.state === 'on';
|
const entity = entities[entityId];
|
||||||
|
const isDetected = entity?.state === 'on';
|
||||||
|
const cameraName = entityToCameraName(entityId);
|
||||||
|
|
||||||
if (isPersonDetected && !frontPorchAlertShownRef.current) {
|
if (isDetected && cameraName && !alertShownForRef.current.has(entityId)) {
|
||||||
// Person just detected - show alert
|
// Person just detected on this camera - show alert
|
||||||
frontPorchAlertShownRef.current = true;
|
alertShownForRef.current.add(entityId);
|
||||||
setShowFrontPorchAlert(true);
|
setAlertCamera(cameraName);
|
||||||
// Also wake the screen
|
|
||||||
if (window.electronAPI?.screen?.wake) {
|
if (window.electronAPI?.screen?.wake) {
|
||||||
window.electronAPI.screen.wake();
|
window.electronAPI.screen.wake();
|
||||||
}
|
}
|
||||||
} else if (!isPersonDetected) {
|
break; // Show one alert at a time
|
||||||
// Reset flag when person clears so next detection triggers alert
|
} else if (!isDetected) {
|
||||||
frontPorchAlertShownRef.current = false;
|
// Reset so next detection on this entity triggers again
|
||||||
|
alertShownForRef.current.delete(entityId);
|
||||||
}
|
}
|
||||||
}, [isConnected, entities]);
|
}
|
||||||
|
}, [isConnected, entities, personDetectionEntities]);
|
||||||
|
|
||||||
const closeFrontPorchAlert = useCallback(() => {
|
const closePersonAlert = useCallback(() => {
|
||||||
setShowFrontPorchAlert(false);
|
setAlertCamera(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Set up screen idle timeout
|
// Set up screen idle timeout
|
||||||
@@ -321,7 +334,7 @@ export default function App() {
|
|||||||
{mediaOverlayOpen && <JellyfinOverlay />}
|
{mediaOverlayOpen && <JellyfinOverlay />}
|
||||||
{cameraOverlayOpen && <CameraOverlay />}
|
{cameraOverlayOpen && <CameraOverlay />}
|
||||||
{settingsOpen && <SettingsPanel />}
|
{settingsOpen && <SettingsPanel />}
|
||||||
{showFrontPorchAlert && <FrontPorchAlert onClose={closeFrontPorchAlert} />}
|
{alertCamera && <PersonAlert cameraName={alertCamera} onClose={closePersonAlert} />}
|
||||||
<GlobalKeyboard />
|
<GlobalKeyboard />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { Go2RTCWebRTC } from '@/services/go2rtc';
|
import { Go2RTCWebRTC } from '@/services/go2rtc';
|
||||||
import { CameraConfig } from '@/config/entities';
|
import { CameraConfig } from '@/config/entities';
|
||||||
|
|
||||||
|
const RECONNECT_DELAY_MS = 3000;
|
||||||
|
const MAX_RECONNECT_DELAY_MS = 30000;
|
||||||
|
|
||||||
interface CameraFeedProps {
|
interface CameraFeedProps {
|
||||||
camera: CameraConfig;
|
camera: CameraConfig;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@@ -21,50 +24,99 @@ export function CameraFeed({
|
|||||||
}: CameraFeedProps) {
|
}: CameraFeedProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const webrtcRef = useRef<Go2RTCWebRTC | null>(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 [isConnecting, setIsConnecting] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Use substream for grid view (lower bandwidth)
|
// Use substream for grid view (lower bandwidth)
|
||||||
const streamName = useSubstream ? `${camera.go2rtcStream}_sub` : camera.go2rtcStream;
|
const streamName = useSubstream ? `${camera.go2rtcStream}_sub` : camera.go2rtcStream;
|
||||||
|
|
||||||
useEffect(() => {
|
const connect = useCallback(async () => {
|
||||||
let mounted = true;
|
if (!mountedRef.current) return;
|
||||||
let timeoutId: ReturnType<typeof setTimeout>;
|
|
||||||
|
|
||||||
const connect = async () => {
|
|
||||||
try {
|
try {
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
webrtcRef.current = new Go2RTCWebRTC(streamName);
|
// Disconnect previous instance
|
||||||
|
webrtcRef.current?.disconnect();
|
||||||
|
|
||||||
await webrtcRef.current.connect((stream) => {
|
const webrtc = new Go2RTCWebRTC(streamName);
|
||||||
if (mounted && videoRef.current) {
|
webrtcRef.current = webrtc;
|
||||||
|
|
||||||
|
await webrtc.connect(
|
||||||
|
// onTrack
|
||||||
|
(stream) => {
|
||||||
|
if (mountedRef.current && videoRef.current) {
|
||||||
videoRef.current.srcObject = stream;
|
videoRef.current.srcObject = stream;
|
||||||
setIsConnecting(false);
|
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) {
|
} catch (err) {
|
||||||
if (mounted) {
|
if (!mountedRef.current) return;
|
||||||
setError(err instanceof Error ? err.message : 'Failed to connect');
|
const msg = err instanceof Error ? err.message : 'Failed to connect';
|
||||||
|
setError(msg);
|
||||||
setIsConnecting(false);
|
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
|
// Stagger connections to avoid overwhelming go2rtc
|
||||||
if (delayMs > 0) {
|
if (delayMs > 0) {
|
||||||
timeoutId = setTimeout(connect, delayMs);
|
initTimer = setTimeout(connect, delayMs);
|
||||||
} else {
|
} else {
|
||||||
connect();
|
connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mountedRef.current = false;
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
clearTimeout(initTimer);
|
||||||
|
clearTimeout(reconnectTimerRef.current);
|
||||||
webrtcRef.current?.disconnect();
|
webrtcRef.current?.disconnect();
|
||||||
};
|
};
|
||||||
}, [streamName, delayMs]);
|
}, [connect, delayMs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -93,13 +145,14 @@ export function CameraFeed({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error Overlay */}
|
{/* Error Overlay */}
|
||||||
{error && (
|
{error && !isConnecting && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-imperial-black/80">
|
<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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="text-sm text-gray-400">{error}</span>
|
<span className="text-sm text-gray-400">{error}</span>
|
||||||
|
<span className="text-xs text-gray-500">Reconnecting...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,13 +10,22 @@ export class Go2RTCWebRTC {
|
|||||||
private mediaStream: MediaStream | null = null;
|
private mediaStream: MediaStream | null = null;
|
||||||
private streamName: string;
|
private streamName: string;
|
||||||
private onTrackCallback: ((stream: MediaStream) => void) | null = null;
|
private onTrackCallback: ((stream: MediaStream) => void) | null = null;
|
||||||
|
private onDisconnectCallback: (() => void) | null = null;
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
constructor(streamName: string) {
|
constructor(streamName: string) {
|
||||||
this.streamName = streamName;
|
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.onTrackCallback = onTrack;
|
||||||
|
this.onDisconnectCallback = onDisconnect ?? null;
|
||||||
|
|
||||||
|
// Clean up any existing connection before reconnecting
|
||||||
|
this.cleanupConnection();
|
||||||
|
|
||||||
// Create peer connection with STUN servers
|
// Create peer connection with STUN servers
|
||||||
const config: RTCConfiguration = {
|
const config: RTCConfiguration = {
|
||||||
@@ -29,6 +38,7 @@ export class Go2RTCWebRTC {
|
|||||||
|
|
||||||
// Handle incoming tracks
|
// Handle incoming tracks
|
||||||
this.peerConnection.ontrack = (event) => {
|
this.peerConnection.ontrack = (event) => {
|
||||||
|
if (this.disposed) return;
|
||||||
console.log(`Received track for ${this.streamName}:`, event.track.kind);
|
console.log(`Received track for ${this.streamName}:`, event.track.kind);
|
||||||
if (event.streams && event.streams[0]) {
|
if (event.streams && event.streams[0]) {
|
||||||
this.mediaStream = 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 = () => {
|
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)
|
// Add transceiver for video (receive only)
|
||||||
@@ -62,6 +76,20 @@ export class Go2RTCWebRTC {
|
|||||||
await this.peerConnection.setRemoteDescription(answer);
|
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> {
|
private async sendOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
|
||||||
const url = `${env.go2rtcUrl}/api/webrtc?src=${encodeURIComponent(this.streamName)}`;
|
const url = `${env.go2rtcUrl}/api/webrtc?src=${encodeURIComponent(this.streamName)}`;
|
||||||
|
|
||||||
@@ -91,17 +119,10 @@ export class Go2RTCWebRTC {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
if (this.mediaStream) {
|
this.disposed = true;
|
||||||
this.mediaStream.getTracks().forEach((track) => track.stop());
|
this.cleanupConnection();
|
||||||
this.mediaStream = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.peerConnection) {
|
|
||||||
this.peerConnection.close();
|
|
||||||
this.peerConnection = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.onTrackCallback = null;
|
this.onTrackCallback = null;
|
||||||
|
this.onDisconnectCallback = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMediaStream(): MediaStream | null {
|
getMediaStream(): MediaStream | null {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
// Increment this when default cameras/config changes to force refresh
|
// Increment this when default cameras/config changes to force refresh
|
||||||
const CONFIG_VERSION = 8;
|
const CONFIG_VERSION = 9;
|
||||||
|
|
||||||
export interface ThermostatConfig {
|
export interface ThermostatConfig {
|
||||||
entityId: string;
|
entityId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user