diff --git a/config.yaml b/config.yaml index 99ce6ef..e1d5148 100644 --- a/config.yaml +++ b/config.yaml @@ -67,7 +67,10 @@ alerts: enabled: true auto_dismiss_seconds: 30 suppression_seconds: 60 - cameras: [] + cameras: + - "Front_Porch" + - "Porch_Downstairs" + - "Driveway_door" detection_types: - "person" diff --git a/frontend/src/hooks/useStream.ts b/frontend/src/hooks/useStream.ts index 4c58102..45ec1f2 100644 --- a/frontend/src/hooks/useStream.ts +++ b/frontend/src/hooks/useStream.ts @@ -1,6 +1,9 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { Go2RTCWebRTC, Go2RTCMSE } from '@/services/go2rtc'; +const RECONNECT_DELAY = 3000; +const MAX_RECONNECT_DELAY = 30000; + interface UseStreamOptions { streamName: string; delayMs?: number; @@ -18,6 +21,9 @@ export function useStream({ streamName, delayMs = 0, enabled = true }: UseStream const videoRef = useRef(null!); const mseRef = useRef(null); const webrtcRef = useRef(null); + const reconnectTimer = useRef>(); + const reconnectDelay = useRef(RECONNECT_DELAY); + const mountedRef = useRef(true); const [isConnecting, setIsConnecting] = useState(true); const [error, setError] = useState(null); const [retryCount, setRetryCount] = useState(0); @@ -26,30 +32,59 @@ export function useStream({ streamName, delayMs = 0, enabled = true }: UseStream setRetryCount((c) => c + 1); }, []); + const scheduleReconnect = useCallback(() => { + if (!mountedRef.current) return; + clearTimeout(reconnectTimer.current); + reconnectTimer.current = setTimeout(() => { + if (mountedRef.current) { + setRetryCount((c) => c + 1); + } + }, reconnectDelay.current); + reconnectDelay.current = Math.min(reconnectDelay.current * 1.5, MAX_RECONNECT_DELAY); + }, []); + useEffect(() => { if (!enabled || !streamName) return; - let mounted = true; - let timer: ReturnType; + mountedRef.current = true; + reconnectDelay.current = RECONNECT_DELAY; + let initTimer: ReturnType; let readyCheck: ReturnType; + const cleanup = () => { + mseRef.current?.disconnect(); + mseRef.current = null; + webrtcRef.current?.disconnect(); + webrtcRef.current = null; + }; + + const onDisconnect = () => { + if (!mountedRef.current) return; + setError('Stream disconnected'); + cleanup(); + scheduleReconnect(); + }; + const connectMSE = async () => { try { setIsConnecting(true); setError(null); + cleanup(); const mse = new Go2RTCMSE(streamName); mseRef.current = mse; if (!videoRef.current) return; - await mse.connect(videoRef.current); + await mse.connect(videoRef.current, onDisconnect); // Poll for video readiness const checkReady = () => { - if (!mounted) return; + if (!mountedRef.current) return; const v = videoRef.current; if (v && v.readyState >= 2) { setIsConnecting(false); + setError(null); + reconnectDelay.current = RECONNECT_DELAY; } else { readyCheck = setTimeout(checkReady, 300); } @@ -57,7 +92,7 @@ export function useStream({ streamName, delayMs = 0, enabled = true }: UseStream readyCheck = setTimeout(checkReady, 300); } catch (err) { - if (!mounted) return; + if (!mountedRef.current) return; console.warn(`MSE failed for ${streamName}, trying WebRTC...`, err); connectWebRTC(); } @@ -65,43 +100,47 @@ export function useStream({ streamName, delayMs = 0, enabled = true }: UseStream const connectWebRTC = async () => { try { - if (!mounted) return; + if (!mountedRef.current) return; setIsConnecting(true); setError(null); const webrtc = new Go2RTCWebRTC(streamName); webrtcRef.current = webrtc; - await webrtc.connect((stream) => { - if (mounted && videoRef.current) { - videoRef.current.srcObject = stream; - setIsConnecting(false); - } - }); + await webrtc.connect( + (stream) => { + if (mountedRef.current && videoRef.current) { + videoRef.current.srcObject = stream; + setIsConnecting(false); + setError(null); + reconnectDelay.current = RECONNECT_DELAY; + } + }, + onDisconnect, + ); } catch (err) { - if (mounted) { + if (mountedRef.current) { setError(err instanceof Error ? err.message : 'Connection failed'); setIsConnecting(false); + scheduleReconnect(); } } }; if (delayMs > 0) { - timer = setTimeout(connectMSE, delayMs); + initTimer = setTimeout(connectMSE, delayMs); } else { connectMSE(); } return () => { - mounted = false; - if (timer) clearTimeout(timer); - if (readyCheck) clearTimeout(readyCheck); - mseRef.current?.disconnect(); - mseRef.current = null; - webrtcRef.current?.disconnect(); - webrtcRef.current = null; + mountedRef.current = false; + clearTimeout(initTimer); + clearTimeout(readyCheck); + clearTimeout(reconnectTimer.current); + cleanup(); }; - }, [streamName, delayMs, enabled, retryCount]); + }, [streamName, delayMs, enabled, retryCount, scheduleReconnect]); return { videoRef, isConnecting, error, retry }; } diff --git a/frontend/src/services/go2rtc.ts b/frontend/src/services/go2rtc.ts index 81f3a8a..24d4ef8 100644 --- a/frontend/src/services/go2rtc.ts +++ b/frontend/src/services/go2rtc.ts @@ -5,19 +5,27 @@ export class Go2RTCWebRTC { private mediaStream: MediaStream | null = null; private streamName: string; private onTrackCb: ((stream: MediaStream) => void) | null = null; + private onDisconnectCb: (() => void) | null = null; + private disposed = false; constructor(streamName: string, _go2rtcUrl?: string) { this.streamName = streamName; } - async connect(onTrack: (stream: MediaStream) => void): Promise { + async connect( + onTrack: (stream: MediaStream) => void, + onDisconnect?: () => void, + ): Promise { this.onTrackCb = onTrack; + this.onDisconnectCb = onDisconnect ?? null; + this.cleanup(); this.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }); this.pc.ontrack = (event) => { + if (this.disposed) return; if (event.streams?.[0]) { this.mediaStream = event.streams[0]; this.onTrackCb?.(this.mediaStream); @@ -26,6 +34,13 @@ export class Go2RTCWebRTC { this.pc.onicecandidate = () => {}; + this.pc.onconnectionstatechange = () => { + const state = this.pc?.connectionState; + if (!this.disposed && (state === 'disconnected' || state === 'failed')) { + this.onDisconnectCb?.(); + } + }; + this.pc.addTransceiver('video', { direction: 'recvonly' }); this.pc.addTransceiver('audio', { direction: 'recvonly' }); @@ -48,12 +63,23 @@ export class Go2RTCWebRTC { await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp }); } - disconnect(): void { + private cleanup(): void { this.mediaStream?.getTracks().forEach((t) => t.stop()); this.mediaStream = null; - this.pc?.close(); - this.pc = null; + if (this.pc) { + this.pc.ontrack = null; + this.pc.onicecandidate = null; + this.pc.onconnectionstatechange = null; + this.pc.close(); + this.pc = null; + } + } + + disconnect(): void { + this.disposed = true; + this.cleanup(); this.onTrackCb = null; + this.onDisconnectCb = null; } isConnected(): boolean { @@ -68,12 +94,18 @@ export class Go2RTCMSE { private streamName: string; private videoElement: HTMLVideoElement | null = null; private queue: ArrayBuffer[] = []; + private onDisconnectCb: (() => void) | null = null; + private disposed = false; constructor(streamName: string, _go2rtcUrl?: string) { this.streamName = streamName; } - async connect(videoElement: HTMLVideoElement): Promise { + async connect( + videoElement: HTMLVideoElement, + onDisconnect?: () => void, + ): Promise { + this.onDisconnectCb = onDisconnect ?? null; this.videoElement = videoElement; this.mediaSource = new MediaSource(); videoElement.src = URL.createObjectURL(this.mediaSource); @@ -93,6 +125,16 @@ export class Go2RTCMSE { this.ws?.send(JSON.stringify({ type: 'mse' })); }; + this.ws.onclose = () => { + if (!this.disposed) { + this.onDisconnectCb?.(); + } + }; + + this.ws.onerror = () => { + // onclose will fire after onerror + }; + this.ws.onmessage = (event) => { if (typeof event.data === 'string') { const msg = JSON.parse(event.data); @@ -125,6 +167,7 @@ export class Go2RTCMSE { } disconnect(): void { + this.disposed = true; this.ws?.close(); this.ws = null; if (this.mediaSource?.readyState === 'open') { @@ -137,5 +180,6 @@ export class Go2RTCMSE { this.sourceBuffer = null; this.mediaSource = null; this.queue = []; + this.onDisconnectCb = null; } }