From 1f5b7fb9464ea232ca4a28a864207c79ab609e5e Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 29 Nov 2025 14:34:53 -0700 Subject: [PATCH] Stop redundant go2rtc stream metadata requests and defer audio information to allow bandwidth for image requests --- web/src/components/menu/LiveContextMenu.tsx | 4 + .../settings/CameraStreamingDialog.tsx | 9 +- web/src/hooks/use-camera-live-mode.ts | 68 +++----------- web/src/hooks/use-deferred-stream-metadata.ts | 90 +++++++++++++++++++ web/src/views/live/DraggableGridLayout.tsx | 49 +++++----- web/src/views/live/LiveDashboardView.tsx | 7 ++ 6 files changed, 138 insertions(+), 89 deletions(-) create mode 100644 web/src/hooks/use-deferred-stream-metadata.ts diff --git a/web/src/components/menu/LiveContextMenu.tsx b/web/src/components/menu/LiveContextMenu.tsx index 73c19d9f7..671506712 100644 --- a/web/src/components/menu/LiveContextMenu.tsx +++ b/web/src/components/menu/LiveContextMenu.tsx @@ -48,6 +48,7 @@ import { useTranslation } from "react-i18next"; import { useDateLocale } from "@/hooks/use-date-locale"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { CameraNameLabel } from "../camera/FriendlyNameLabel"; +import { LiveStreamMetadata } from "@/types/live"; type LiveContextMenuProps = { className?: string; @@ -68,6 +69,7 @@ type LiveContextMenuProps = { resetPreferredLiveMode: () => void; config?: FrigateConfig; children?: ReactNode; + streamMetadata?: { [key: string]: LiveStreamMetadata }; }; export default function LiveContextMenu({ className, @@ -88,6 +90,7 @@ export default function LiveContextMenu({ resetPreferredLiveMode, config, children, + streamMetadata, }: LiveContextMenuProps) { const { t } = useTranslation("views/live"); const [showSettings, setShowSettings] = useState(false); @@ -558,6 +561,7 @@ export default function LiveContextMenu({ setGroupStreamingSettings={setGroupStreamingSettings} setIsDialogOpen={setShowSettings} onSave={onSave} + streamMetadata={streamMetadata} /> diff --git a/web/src/components/settings/CameraStreamingDialog.tsx b/web/src/components/settings/CameraStreamingDialog.tsx index 62b0a1472..4820a5e6b 100644 --- a/web/src/components/settings/CameraStreamingDialog.tsx +++ b/web/src/components/settings/CameraStreamingDialog.tsx @@ -38,6 +38,7 @@ import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; type CameraStreamingDialogProps = { camera: string; groupStreamingSettings: GroupStreamingSettings; + streamMetadata?: { [key: string]: LiveStreamMetadata }; setGroupStreamingSettings: React.Dispatch< React.SetStateAction >; @@ -48,6 +49,7 @@ type CameraStreamingDialogProps = { export function CameraStreamingDialog({ camera, groupStreamingSettings, + streamMetadata, setGroupStreamingSettings, setIsDialogOpen, onSave, @@ -76,12 +78,7 @@ export function CameraStreamingDialog({ [config, streamName], ); - const { data: cameraMetadata } = useSWR( - isRestreamed ? `go2rtc/streams/${streamName}` : null, - { - revalidateOnFocus: false, - }, - ); + const cameraMetadata = streamName ? streamMetadata?.[streamName] : undefined; const supportsAudioOutput = useMemo(() => { if (!cameraMetadata) { diff --git a/web/src/hooks/use-camera-live-mode.ts b/web/src/hooks/use-camera-live-mode.ts index 51170dd11..eaf5bdfeb 100644 --- a/web/src/hooks/use-camera-live-mode.ts +++ b/web/src/hooks/use-camera-live-mode.ts @@ -1,8 +1,8 @@ -import { baseUrl } from "@/api/baseUrl"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { useCallback, useEffect, useState, useMemo } from "react"; import useSWR from "swr"; -import { LivePlayerMode, LiveStreamMetadata } from "@/types/live"; +import { LivePlayerMode } from "@/types/live"; +import useDeferredStreamMetadata from "./use-deferred-stream-metadata"; export default function useCameraLiveMode( cameras: CameraConfig[], @@ -11,9 +11,9 @@ export default function useCameraLiveMode( ) { const { data: config } = useSWR("config"); - // Get comma-separated list of restreamed stream names for SWR key - const restreamedStreamsKey = useMemo(() => { - if (!cameras || !config) return null; + // Compute which streams need metadata (restreamed streams only) + const restreamedStreamNames = useMemo(() => { + if (!cameras || !config) return []; const streamNames = new Set(); cameras.forEach((camera) => { @@ -32,56 +32,13 @@ export default function useCameraLiveMode( } }); - return streamNames.size > 0 - ? Array.from(streamNames).sort().join(",") - : null; + return Array.from(streamNames); }, [cameras, config, activeStreams]); - const streamsFetcher = useCallback(async (key: string) => { - const streamNames = key.split(","); - - const metadataPromises = streamNames.map(async (streamName) => { - try { - const response = await fetch( - `${baseUrl}api/go2rtc/streams/${streamName}`, - { - priority: "low", - }, - ); - - if (response.ok) { - const data = await response.json(); - return { streamName, data }; - } - return { streamName, data: null }; - } catch (error) { - // eslint-disable-next-line no-console - console.error(`Failed to fetch metadata for ${streamName}:`, error); - return { streamName, data: null }; - } - }); - - const results = await Promise.allSettled(metadataPromises); - - const metadata: { [key: string]: LiveStreamMetadata } = {}; - results.forEach((result) => { - if (result.status === "fulfilled" && result.value.data) { - metadata[result.value.streamName] = result.value.data; - } - }); - - return metadata; - }, []); - - const { data: allStreamMetadata = {} } = useSWR<{ - [key: string]: LiveStreamMetadata; - }>(restreamedStreamsKey, streamsFetcher, { - revalidateOnFocus: false, - revalidateOnReconnect: false, - revalidateIfStale: false, - dedupingInterval: 60000, - }); + // Fetch stream metadata with deferred loading (doesn't block initial render) + const streamMetadata = useDeferredStreamMetadata(restreamedStreamNames); + // Compute live mode states const [preferredLiveModes, setPreferredLiveModes] = useState<{ [key: string]: LivePlayerMode; }>({}); @@ -122,10 +79,10 @@ export default function useCameraLiveMode( newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg"; } - // check each stream for audio support + // Check each stream for audio support if (isRestreamed) { Object.values(camera.live.streams).forEach((streamName) => { - const metadata = allStreamMetadata?.[streamName]; + const metadata = streamMetadata[streamName]; newSupportsAudioOutputStates[streamName] = { supportsAudio: metadata ? metadata.producers.find( @@ -150,7 +107,7 @@ export default function useCameraLiveMode( setPreferredLiveModes(newPreferredLiveModes); setIsRestreamedStates(newIsRestreamedStates); setSupportsAudioOutputStates(newSupportsAudioOutputStates); - }, [cameras, config, windowVisible, allStreamMetadata]); + }, [cameras, config, windowVisible, streamMetadata]); const resetPreferredLiveMode = useCallback( (cameraName: string) => { @@ -180,5 +137,6 @@ export default function useCameraLiveMode( resetPreferredLiveMode, isRestreamedStates, supportsAudioOutputStates, + streamMetadata, }; } diff --git a/web/src/hooks/use-deferred-stream-metadata.ts b/web/src/hooks/use-deferred-stream-metadata.ts new file mode 100644 index 000000000..8e68b6a6a --- /dev/null +++ b/web/src/hooks/use-deferred-stream-metadata.ts @@ -0,0 +1,90 @@ +import { baseUrl } from "@/api/baseUrl"; +import { useCallback, useEffect, useState, useMemo } from "react"; +import useSWR from "swr"; +import { LiveStreamMetadata } from "@/types/live"; + +const FETCH_TIMEOUT_MS = 10000; +const DEFER_DELAY_MS = 2000; + +/** + * Hook that fetches go2rtc stream metadata with deferred loading. + * + * Metadata fetching is delayed to prevent blocking initial page load + * and camera image requests. + * + * @param streamNames - Array of stream names to fetch metadata for + * @returns Object containing stream metadata keyed by stream name + */ +export default function useDeferredStreamMetadata(streamNames: string[]) { + const [fetchEnabled, setFetchEnabled] = useState(false); + + useEffect(() => { + const timeoutId = setTimeout(() => { + setFetchEnabled(true); + }, DEFER_DELAY_MS); + + return () => clearTimeout(timeoutId); + }, []); + + const swrKey = useMemo(() => { + if (!fetchEnabled || streamNames.length === 0) return null; + // Use spread to avoid mutating the original array + return `deferred-streams:${[...streamNames].sort().join(",")}`; + }, [fetchEnabled, streamNames]); + + const fetcher = useCallback(async (key: string) => { + // Extract stream names from key (remove prefix) + const names = key.replace("deferred-streams:", "").split(","); + + const promises = names.map(async (streamName) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch( + `${baseUrl}api/go2rtc/streams/${streamName}`, + { + priority: "low", + signal: controller.signal, + }, + ); + clearTimeout(timeoutId); + + if (response.ok) { + const data = await response.json(); + return { streamName, data }; + } + return { streamName, data: null }; + } catch (error) { + clearTimeout(timeoutId); + if ((error as Error).name !== "AbortError") { + // eslint-disable-next-line no-console + console.error(`Failed to fetch metadata for ${streamName}:`, error); + } + return { streamName, data: null }; + } + }); + + const results = await Promise.allSettled(promises); + + const metadata: { [key: string]: LiveStreamMetadata } = {}; + results.forEach((result) => { + if (result.status === "fulfilled" && result.value.data) { + metadata[result.value.streamName] = result.value.data; + } + }); + + return metadata; + }, []); + + const { data: metadata = {} } = useSWR<{ + [key: string]: LiveStreamMetadata; + }>(swrKey, fetcher, { + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + dedupingInterval: 60000, + }); + + return metadata; +} diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index 259e4093f..6b1985bd7 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -24,6 +24,7 @@ import "react-resizable/css/styles.css"; import { AudioState, LivePlayerMode, + LiveStreamMetadata, StatsState, VolumeState, } from "@/types/live"; @@ -47,7 +48,6 @@ import { TooltipContent, } from "@/components/ui/tooltip"; import { Toaster } from "@/components/ui/sonner"; -import useCameraLiveMode from "@/hooks/use-camera-live-mode"; import LiveContextMenu from "@/components/menu/LiveContextMenu"; import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { useTranslation } from "react-i18next"; @@ -65,6 +65,16 @@ type DraggableGridLayoutProps = { setIsEditMode: React.Dispatch>; fullscreen: boolean; toggleFullscreen: () => void; + preferredLiveModes: { [key: string]: LivePlayerMode }; + setPreferredLiveModes: React.Dispatch< + React.SetStateAction<{ [key: string]: LivePlayerMode }> + >; + resetPreferredLiveMode: (cameraName: string) => void; + isRestreamedStates: { [key: string]: boolean }; + supportsAudioOutputStates: { + [key: string]: { supportsAudio: boolean; cameraName: string }; + }; + streamMetadata: { [key: string]: LiveStreamMetadata }; }; export default function DraggableGridLayout({ cameras, @@ -79,6 +89,12 @@ export default function DraggableGridLayout({ setIsEditMode, fullscreen, toggleFullscreen, + preferredLiveModes, + setPreferredLiveModes, + resetPreferredLiveMode, + isRestreamedStates, + supportsAudioOutputStates, + streamMetadata, }: DraggableGridLayoutProps) { const { t } = useTranslation(["views/live"]); const { data: config } = useSWR("config"); @@ -98,33 +114,6 @@ export default function DraggableGridLayout({ } }, [allGroupsStreamingSettings, cameraGroup]); - const activeStreams = useMemo(() => { - const streams: { [cameraName: string]: string } = {}; - cameras.forEach((camera) => { - const availableStreams = camera.live.streams || {}; - const streamNameFromSettings = - currentGroupStreamingSettings?.[camera.name]?.streamName || ""; - const streamExists = - streamNameFromSettings && - Object.values(availableStreams).includes(streamNameFromSettings); - - const streamName = streamExists - ? streamNameFromSettings - : Object.values(availableStreams)[0] || ""; - - streams[camera.name] = streamName; - }); - return streams; - }, [cameras, currentGroupStreamingSettings]); - - const { - preferredLiveModes, - setPreferredLiveModes, - resetPreferredLiveMode, - isRestreamedStates, - supportsAudioOutputStates, - } = useCameraLiveMode(cameras, windowVisible, activeStreams); - // grid layout const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); @@ -624,6 +613,7 @@ export default function DraggableGridLayout({ resetPreferredLiveMode(camera.name) } config={config} + streamMetadata={streamMetadata} > void; resetPreferredLiveMode: () => void; config?: FrigateConfig; + streamMetadata?: { [key: string]: LiveStreamMetadata }; }; const GridLiveContextMenu = React.forwardRef< @@ -868,6 +859,7 @@ const GridLiveContextMenu = React.forwardRef< unmuteAll, resetPreferredLiveMode, config, + streamMetadata, ...props }, ref, @@ -899,6 +891,7 @@ const GridLiveContextMenu = React.forwardRef< unmuteAll={unmuteAll} resetPreferredLiveMode={resetPreferredLiveMode} config={config} + streamMetadata={streamMetadata} > {children} diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index e4e935ac6..dcaedc87a 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -265,6 +265,7 @@ export default function LiveDashboardView({ resetPreferredLiveMode, isRestreamedStates, supportsAudioOutputStates, + streamMetadata, } = useCameraLiveMode(cameras, windowVisible, activeStreams); const birdseyeConfig = useMemo(() => config?.birdseye, [config]); @@ -650,6 +651,12 @@ export default function LiveDashboardView({ setIsEditMode={setIsEditMode} fullscreen={fullscreen} toggleFullscreen={toggleFullscreen} + preferredLiveModes={preferredLiveModes} + setPreferredLiveModes={setPreferredLiveModes} + resetPreferredLiveMode={resetPreferredLiveMode} + isRestreamedStates={isRestreamedStates} + supportsAudioOutputStates={supportsAudioOutputStates} + streamMetadata={streamMetadata} /> )}