From b630ba0337582af42b4117f705f058e2bb6d626e Mon Sep 17 00:00:00 2001 From: root Date: Wed, 25 Feb 2026 22:36:13 -0600 Subject: [PATCH] Fix streaming: MSE-first with go2rtc init protocol - Switch from WebRTC-first to MSE-first streaming (more reliable across all camera types including high-res IP cameras) - Send required {"type":"mse"} init message to go2rtc WebSocket - Fix infinite re-render loop in configStore (pre-compute enabled cameras instead of deriving in selector) - Fix mqtt_bridge global variable scope in broadcast() - Add React ErrorBoundary for visible crash reporting - Remove unused go2rtcUrl dependency from useStream hook --- backend/mqtt_bridge.py | 4 +- frontend/src/components/ErrorBoundary.tsx | 32 +++++++++ frontend/src/components/alerts/AlertPopup.tsx | 2 +- frontend/src/components/grid/CameraGrid.tsx | 2 +- .../src/components/grid/CameraGridCell.tsx | 20 +++--- .../src/components/player/CameraPlayer.tsx | 4 -- .../src/components/player/FullscreenView.tsx | 2 +- frontend/src/hooks/useStream.ts | 69 ++++++++++++------- frontend/src/main.tsx | 5 +- frontend/src/services/go2rtc.ts | 5 ++ frontend/src/stores/configStore.ts | 34 ++++----- frontend/tsconfig.tsbuildinfo | 2 +- 12 files changed, 112 insertions(+), 69 deletions(-) create mode 100644 frontend/src/components/ErrorBoundary.tsx diff --git a/backend/mqtt_bridge.py b/backend/mqtt_bridge.py index 29d21ae..a030a2e 100644 --- a/backend/mqtt_bridge.py +++ b/backend/mqtt_bridge.py @@ -23,6 +23,7 @@ def unregister_ws_client(ws): async def broadcast(message: dict): + global _ws_clients data = json.dumps(message) disconnected = set() for ws in _ws_clients: @@ -30,7 +31,8 @@ async def broadcast(message: dict): await ws.send_text(data) except Exception: disconnected.add(ws) - _ws_clients -= disconnected + if disconnected: + _ws_clients -= disconnected async def mqtt_listener(): diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..933838d --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +interface State { + error: Error | null; +} + +export class ErrorBoundary extends React.Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + render() { + if (this.state.error) { + return ( +
+

App Error

+
{this.state.error.message}
+
{this.state.error.stack}
+ +
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/components/alerts/AlertPopup.tsx b/frontend/src/components/alerts/AlertPopup.tsx index 0c9e991..306850a 100644 --- a/frontend/src/components/alerts/AlertPopup.tsx +++ b/frontend/src/components/alerts/AlertPopup.tsx @@ -6,7 +6,7 @@ import { CameraPlayer } from '@/components/player/CameraPlayer'; export function AlertPopup() { const { activeAlert, dismissAlert } = useAlertStore(); const config = useConfigStore((s) => s.config); - const cameras = useConfigStore((s) => s.enabledCameras()); + const cameras = useConfigStore((s) => s.cameras); const [countdown, setCountdown] = useState(30); const autoDismiss = config?.alerts.auto_dismiss_seconds ?? 30; diff --git a/frontend/src/components/grid/CameraGrid.tsx b/frontend/src/components/grid/CameraGrid.tsx index fb02695..db18c26 100644 --- a/frontend/src/components/grid/CameraGrid.tsx +++ b/frontend/src/components/grid/CameraGrid.tsx @@ -4,7 +4,7 @@ import { CameraGridCell } from './CameraGridCell'; const STAGGER_MS = 200; export function CameraGrid() { - const cameras = useConfigStore((s) => s.enabledCameras()); + const cameras = useConfigStore((s) => s.cameras); const gridConfig = useConfigStore((s) => s.config?.grid); const count = cameras.length; diff --git a/frontend/src/components/grid/CameraGridCell.tsx b/frontend/src/components/grid/CameraGridCell.tsx index c49a3f5..937c857 100644 --- a/frontend/src/components/grid/CameraGridCell.tsx +++ b/frontend/src/components/grid/CameraGridCell.tsx @@ -1,5 +1,4 @@ import { useStream } from '@/hooks/useStream'; -import { useConfigStore } from '@/stores/configStore'; import { useUIStore } from '@/stores/uiStore'; import type { CameraConfig } from '@/types/config'; @@ -9,12 +8,10 @@ interface CameraGridCellProps { } export function CameraGridCell({ camera, delayMs }: CameraGridCellProps) { - const go2rtcUrl = useConfigStore((s) => s.config?.go2rtc.url ?? ''); const setFullscreen = useUIStore((s) => s.setFullscreenCamera); const { videoRef, isConnecting, error, retry } = useStream({ streamName: camera.name, - go2rtcUrl, delayMs, }); @@ -36,7 +33,7 @@ export function CameraGridCell({ camera, delayMs }: CameraGridCellProps) {
- Connecting... + {camera.display_name}
)} @@ -48,7 +45,8 @@ export function CameraGridCell({ camera, delayMs }: CameraGridCellProps) { - Offline + {camera.display_name} + {error} @@ -57,11 +55,13 @@ export function CameraGridCell({ camera, delayMs }: CameraGridCellProps) { )} {/* Label */} -
- - {camera.display_name} - -
+ {!isConnecting && !error && ( +
+ + {camera.display_name} + +
+ )}
); } diff --git a/frontend/src/components/player/CameraPlayer.tsx b/frontend/src/components/player/CameraPlayer.tsx index 00dbafc..5b779a4 100644 --- a/frontend/src/components/player/CameraPlayer.tsx +++ b/frontend/src/components/player/CameraPlayer.tsx @@ -1,5 +1,4 @@ import { useStream } from '@/hooks/useStream'; -import { useConfigStore } from '@/stores/configStore'; import type { CameraConfig } from '@/types/config'; interface CameraPlayerProps { @@ -9,11 +8,8 @@ interface CameraPlayerProps { } export function CameraPlayer({ camera, className = '', showLabel = true }: CameraPlayerProps) { - const go2rtcUrl = useConfigStore((s) => s.config?.go2rtc.url ?? ''); - const { videoRef, isConnecting, error, retry } = useStream({ streamName: camera.name, - go2rtcUrl, }); return ( diff --git a/frontend/src/components/player/FullscreenView.tsx b/frontend/src/components/player/FullscreenView.tsx index 32bbede..28b28f1 100644 --- a/frontend/src/components/player/FullscreenView.tsx +++ b/frontend/src/components/player/FullscreenView.tsx @@ -5,7 +5,7 @@ import { CameraPlayer } from './CameraPlayer'; export function FullscreenView() { const { fullscreenCamera, setFullscreenCamera } = useUIStore(); - const cameras = useConfigStore((s) => s.enabledCameras()); + const cameras = useConfigStore((s) => s.cameras); const currentIdx = cameras.findIndex((c) => c.name === fullscreenCamera); const camera = currentIdx >= 0 ? cameras[currentIdx] : null; diff --git a/frontend/src/hooks/useStream.ts b/frontend/src/hooks/useStream.ts index 45568e8..4c58102 100644 --- a/frontend/src/hooks/useStream.ts +++ b/frontend/src/hooks/useStream.ts @@ -3,7 +3,6 @@ import { Go2RTCWebRTC, Go2RTCMSE } from '@/services/go2rtc'; interface UseStreamOptions { streamName: string; - go2rtcUrl: string; delayMs?: number; enabled?: boolean; } @@ -15,10 +14,10 @@ interface UseStreamResult { retry: () => void; } -export function useStream({ streamName, go2rtcUrl, delayMs = 0, enabled = true }: UseStreamOptions): UseStreamResult { +export function useStream({ streamName, delayMs = 0, enabled = true }: UseStreamOptions): UseStreamResult { const videoRef = useRef(null!); - const webrtcRef = useRef(null); const mseRef = useRef(null); + const webrtcRef = useRef(null); const [isConnecting, setIsConnecting] = useState(true); const [error, setError] = useState(null); const [retryCount, setRetryCount] = useState(0); @@ -28,17 +27,49 @@ export function useStream({ streamName, go2rtcUrl, delayMs = 0, enabled = true } }, []); useEffect(() => { - if (!enabled || !streamName || !go2rtcUrl) return; + if (!enabled || !streamName) return; let mounted = true; let timer: ReturnType; + let readyCheck: ReturnType; - const connectWebRTC = async () => { + const connectMSE = async () => { try { setIsConnecting(true); setError(null); - const webrtc = new Go2RTCWebRTC(streamName, go2rtcUrl); + const mse = new Go2RTCMSE(streamName); + mseRef.current = mse; + + if (!videoRef.current) return; + await mse.connect(videoRef.current); + + // Poll for video readiness + const checkReady = () => { + if (!mounted) return; + const v = videoRef.current; + if (v && v.readyState >= 2) { + setIsConnecting(false); + } else { + readyCheck = setTimeout(checkReady, 300); + } + }; + readyCheck = setTimeout(checkReady, 300); + + } catch (err) { + if (!mounted) return; + console.warn(`MSE failed for ${streamName}, trying WebRTC...`, err); + connectWebRTC(); + } + }; + + const connectWebRTC = async () => { + try { + if (!mounted) return; + setIsConnecting(true); + setError(null); + + const webrtc = new Go2RTCWebRTC(streamName); webrtcRef.current = webrtc; await webrtc.connect((stream) => { @@ -47,21 +78,6 @@ export function useStream({ streamName, go2rtcUrl, delayMs = 0, enabled = true } setIsConnecting(false); } }); - } catch (err) { - if (!mounted) return; - console.warn(`WebRTC failed for ${streamName}, trying MSE...`); - await connectMSE(); - } - }; - - const connectMSE = async () => { - try { - if (!mounted || !videoRef.current) return; - - const mse = new Go2RTCMSE(streamName, go2rtcUrl); - mseRef.current = mse; - await mse.connect(videoRef.current); - if (mounted) setIsConnecting(false); } catch (err) { if (mounted) { setError(err instanceof Error ? err.message : 'Connection failed'); @@ -71,20 +87,21 @@ export function useStream({ streamName, go2rtcUrl, delayMs = 0, enabled = true } }; if (delayMs > 0) { - timer = setTimeout(connectWebRTC, delayMs); + timer = setTimeout(connectMSE, delayMs); } else { - connectWebRTC(); + connectMSE(); } return () => { mounted = false; if (timer) clearTimeout(timer); - webrtcRef.current?.disconnect(); - webrtcRef.current = null; + if (readyCheck) clearTimeout(readyCheck); mseRef.current?.disconnect(); mseRef.current = null; + webrtcRef.current?.disconnect(); + webrtcRef.current = null; }; - }, [streamName, go2rtcUrl, delayMs, enabled, retryCount]); + }, [streamName, delayMs, enabled, retryCount]); return { videoRef, isConnecting, error, retry }; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9aa52ff..7749b81 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,11 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; +import { ErrorBoundary } from './components/ErrorBoundary'; import './index.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - + - , + , ); diff --git a/frontend/src/services/go2rtc.ts b/frontend/src/services/go2rtc.ts index 83d0489..81f3a8a 100644 --- a/frontend/src/services/go2rtc.ts +++ b/frontend/src/services/go2rtc.ts @@ -88,6 +88,11 @@ export class Go2RTCMSE { this.ws = new WebSocket(wsUrl); this.ws.binaryType = 'arraybuffer'; + this.ws.onopen = () => { + // go2rtc requires this init message to start MSE streaming + this.ws?.send(JSON.stringify({ type: 'mse' })); + }; + this.ws.onmessage = (event) => { if (typeof event.data === 'string') { const msg = JSON.parse(event.data); diff --git a/frontend/src/stores/configStore.ts b/frontend/src/stores/configStore.ts index 018200e..a3fa0da 100644 --- a/frontend/src/stores/configStore.ts +++ b/frontend/src/stores/configStore.ts @@ -6,50 +6,40 @@ interface ConfigState { config: AppConfig | null; loading: boolean; error: string | null; + cameras: CameraConfig[]; loadConfig: () => Promise; saveConfig: (config: AppConfig) => Promise; - enabledCameras: () => CameraConfig[]; } -const defaultConfig: AppConfig = { - title: 'Camera Viewer', - go2rtc: { url: 'http://192.168.1.241:1985' }, - frigate: { url: 'http://192.168.1.241:5000' }, - mqtt: { host: '', port: 1883, topic_prefix: 'frigate', username: '', password: '' }, - cameras: [], - alerts: { enabled: false, auto_dismiss_seconds: 30, suppression_seconds: 60, cameras: [], detection_types: ['person'] }, - grid: { columns: null, aspect_ratio: '16:9', gap: 4 }, -}; +function deriveEnabledCameras(config: AppConfig | null): CameraConfig[] { + if (!config) return []; + return config.cameras + .filter((c) => c.enabled) + .sort((a, b) => a.order - b.order); +} -export const useConfigStore = create((set, get) => ({ +export const useConfigStore = create((set) => ({ config: null, loading: true, error: null, + cameras: [], loadConfig: async () => { set({ loading: true, error: null }); try { const config = await fetchConfig(); - set({ config, loading: false }); + set({ config, loading: false, cameras: deriveEnabledCameras(config) }); } catch (e) { - set({ config: defaultConfig, loading: false, error: String(e) }); + set({ loading: false, error: String(e), cameras: [] }); } }, saveConfig: async (config: AppConfig) => { try { await apiSaveConfig(config); - set({ config }); + set({ config, cameras: deriveEnabledCameras(config) }); } catch (e) { set({ error: String(e) }); } }, - - enabledCameras: () => { - const config = get().config; - if (!config) return []; - return config.cameras - .filter((c) => c.enabled) - .sort((a, b) => a.order - b.order); - }, })); diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index ce3c2b1..c930551 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/alerts/AlertPopup.tsx","./src/components/grid/CameraGrid.tsx","./src/components/grid/CameraGridCell.tsx","./src/components/layout/AppShell.tsx","./src/components/layout/Header.tsx","./src/components/player/CameraPlayer.tsx","./src/components/player/FullscreenView.tsx","./src/components/settings/AlertSettings.tsx","./src/components/settings/CameraSettings.tsx","./src/components/settings/GeneralSettings.tsx","./src/components/settings/SettingsPage.tsx","./src/hooks/useAlerts.ts","./src/hooks/useStream.ts","./src/services/alerts.ts","./src/services/api.ts","./src/services/go2rtc.ts","./src/stores/alertStore.ts","./src/stores/configStore.ts","./src/stores/uiStore.ts","./src/types/config.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/ErrorBoundary.tsx","./src/components/alerts/AlertPopup.tsx","./src/components/grid/CameraGrid.tsx","./src/components/grid/CameraGridCell.tsx","./src/components/layout/AppShell.tsx","./src/components/layout/Header.tsx","./src/components/player/CameraPlayer.tsx","./src/components/player/FullscreenView.tsx","./src/components/settings/AlertSettings.tsx","./src/components/settings/CameraSettings.tsx","./src/components/settings/GeneralSettings.tsx","./src/components/settings/SettingsPage.tsx","./src/hooks/useAlerts.ts","./src/hooks/useStream.ts","./src/services/alerts.ts","./src/services/api.ts","./src/services/go2rtc.ts","./src/stores/alertStore.ts","./src/stores/configStore.ts","./src/stores/uiStore.ts","./src/types/config.ts"],"version":"5.6.3"} \ No newline at end of file