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