Fix stream freezing and alert camera name matching
- Add auto-reconnect with exponential backoff to MSE and WebRTC streams when connections drop, preventing permanent freezes in grid view - Add onDisconnect callback to Go2RTCWebRTC (connectionstate monitoring) and Go2RTCMSE (WebSocket close detection) - Fix alert camera names to use Frigate names (Front_Porch, etc.) instead of display names which never matched MQTT events - Disable offline WyzePanV3 camera
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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<HTMLVideoElement>(null!);
|
||||
const mseRef = useRef<Go2RTCMSE | null>(null);
|
||||
const webrtcRef = useRef<Go2RTCWebRTC | null>(null);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const reconnectDelay = useRef(RECONNECT_DELAY);
|
||||
const mountedRef = useRef(true);
|
||||
const [isConnecting, setIsConnecting] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<typeof setTimeout>;
|
||||
mountedRef.current = true;
|
||||
reconnectDelay.current = RECONNECT_DELAY;
|
||||
let initTimer: ReturnType<typeof setTimeout>;
|
||||
let readyCheck: ReturnType<typeof setTimeout>;
|
||||
|
||||
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) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
async connect(
|
||||
onTrack: (stream: MediaStream) => void,
|
||||
onDisconnect?: () => void,
|
||||
): Promise<void> {
|
||||
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();
|
||||
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<void> {
|
||||
async connect(
|
||||
videoElement: HTMLVideoElement,
|
||||
onDisconnect?: () => void,
|
||||
): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user