Compare commits

..

2 Commits

6 changed files with 138 additions and 89 deletions

View File

@ -48,6 +48,7 @@ import { useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale"; import { useDateLocale } from "@/hooks/use-date-locale";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { CameraNameLabel } from "../camera/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { LiveStreamMetadata } from "@/types/live";
type LiveContextMenuProps = { type LiveContextMenuProps = {
className?: string; className?: string;
@ -68,6 +69,7 @@ type LiveContextMenuProps = {
resetPreferredLiveMode: () => void; resetPreferredLiveMode: () => void;
config?: FrigateConfig; config?: FrigateConfig;
children?: ReactNode; children?: ReactNode;
streamMetadata?: { [key: string]: LiveStreamMetadata };
}; };
export default function LiveContextMenu({ export default function LiveContextMenu({
className, className,
@ -88,6 +90,7 @@ export default function LiveContextMenu({
resetPreferredLiveMode, resetPreferredLiveMode,
config, config,
children, children,
streamMetadata,
}: LiveContextMenuProps) { }: LiveContextMenuProps) {
const { t } = useTranslation("views/live"); const { t } = useTranslation("views/live");
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
@ -558,6 +561,7 @@ export default function LiveContextMenu({
setGroupStreamingSettings={setGroupStreamingSettings} setGroupStreamingSettings={setGroupStreamingSettings}
setIsDialogOpen={setShowSettings} setIsDialogOpen={setShowSettings}
onSave={onSave} onSave={onSave}
streamMetadata={streamMetadata}
/> />
</Dialog> </Dialog>
</div> </div>

View File

@ -38,6 +38,7 @@ import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
type CameraStreamingDialogProps = { type CameraStreamingDialogProps = {
camera: string; camera: string;
groupStreamingSettings: GroupStreamingSettings; groupStreamingSettings: GroupStreamingSettings;
streamMetadata?: { [key: string]: LiveStreamMetadata };
setGroupStreamingSettings: React.Dispatch< setGroupStreamingSettings: React.Dispatch<
React.SetStateAction<GroupStreamingSettings> React.SetStateAction<GroupStreamingSettings>
>; >;
@ -48,6 +49,7 @@ type CameraStreamingDialogProps = {
export function CameraStreamingDialog({ export function CameraStreamingDialog({
camera, camera,
groupStreamingSettings, groupStreamingSettings,
streamMetadata,
setGroupStreamingSettings, setGroupStreamingSettings,
setIsDialogOpen, setIsDialogOpen,
onSave, onSave,
@ -76,12 +78,7 @@ export function CameraStreamingDialog({
[config, streamName], [config, streamName],
); );
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>( const cameraMetadata = streamName ? streamMetadata?.[streamName] : undefined;
isRestreamed ? `go2rtc/streams/${streamName}` : null,
{
revalidateOnFocus: false,
},
);
const supportsAudioOutput = useMemo(() => { const supportsAudioOutput = useMemo(() => {
if (!cameraMetadata) { if (!cameraMetadata) {

View File

@ -1,8 +1,8 @@
import { baseUrl } from "@/api/baseUrl";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useEffect, useState, useMemo } from "react"; import { useCallback, useEffect, useState, useMemo } from "react";
import useSWR from "swr"; 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( export default function useCameraLiveMode(
cameras: CameraConfig[], cameras: CameraConfig[],
@ -11,9 +11,9 @@ export default function useCameraLiveMode(
) { ) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
// Get comma-separated list of restreamed stream names for SWR key // Compute which streams need metadata (restreamed streams only)
const restreamedStreamsKey = useMemo(() => { const restreamedStreamNames = useMemo(() => {
if (!cameras || !config) return null; if (!cameras || !config) return [];
const streamNames = new Set<string>(); const streamNames = new Set<string>();
cameras.forEach((camera) => { cameras.forEach((camera) => {
@ -32,56 +32,13 @@ export default function useCameraLiveMode(
} }
}); });
return streamNames.size > 0 return Array.from(streamNames);
? Array.from(streamNames).sort().join(",")
: null;
}, [cameras, config, activeStreams]); }, [cameras, config, activeStreams]);
const streamsFetcher = useCallback(async (key: string) => { // Fetch stream metadata with deferred loading (doesn't block initial render)
const streamNames = key.split(","); const streamMetadata = useDeferredStreamMetadata(restreamedStreamNames);
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,
});
// Compute live mode states
const [preferredLiveModes, setPreferredLiveModes] = useState<{ const [preferredLiveModes, setPreferredLiveModes] = useState<{
[key: string]: LivePlayerMode; [key: string]: LivePlayerMode;
}>({}); }>({});
@ -122,10 +79,10 @@ export default function useCameraLiveMode(
newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg"; newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg";
} }
// check each stream for audio support // Check each stream for audio support
if (isRestreamed) { if (isRestreamed) {
Object.values(camera.live.streams).forEach((streamName) => { Object.values(camera.live.streams).forEach((streamName) => {
const metadata = allStreamMetadata?.[streamName]; const metadata = streamMetadata[streamName];
newSupportsAudioOutputStates[streamName] = { newSupportsAudioOutputStates[streamName] = {
supportsAudio: metadata supportsAudio: metadata
? metadata.producers.find( ? metadata.producers.find(
@ -150,7 +107,7 @@ export default function useCameraLiveMode(
setPreferredLiveModes(newPreferredLiveModes); setPreferredLiveModes(newPreferredLiveModes);
setIsRestreamedStates(newIsRestreamedStates); setIsRestreamedStates(newIsRestreamedStates);
setSupportsAudioOutputStates(newSupportsAudioOutputStates); setSupportsAudioOutputStates(newSupportsAudioOutputStates);
}, [cameras, config, windowVisible, allStreamMetadata]); }, [cameras, config, windowVisible, streamMetadata]);
const resetPreferredLiveMode = useCallback( const resetPreferredLiveMode = useCallback(
(cameraName: string) => { (cameraName: string) => {
@ -180,5 +137,6 @@ export default function useCameraLiveMode(
resetPreferredLiveMode, resetPreferredLiveMode,
isRestreamedStates, isRestreamedStates,
supportsAudioOutputStates, supportsAudioOutputStates,
streamMetadata,
}; };
} }

View File

@ -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;
}

View File

@ -24,6 +24,7 @@ import "react-resizable/css/styles.css";
import { import {
AudioState, AudioState,
LivePlayerMode, LivePlayerMode,
LiveStreamMetadata,
StatsState, StatsState,
VolumeState, VolumeState,
} from "@/types/live"; } from "@/types/live";
@ -47,7 +48,6 @@ import {
TooltipContent, TooltipContent,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import LiveContextMenu from "@/components/menu/LiveContextMenu"; import LiveContextMenu from "@/components/menu/LiveContextMenu";
import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -65,6 +65,16 @@ type DraggableGridLayoutProps = {
setIsEditMode: React.Dispatch<React.SetStateAction<boolean>>; setIsEditMode: React.Dispatch<React.SetStateAction<boolean>>;
fullscreen: boolean; fullscreen: boolean;
toggleFullscreen: () => void; 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({ export default function DraggableGridLayout({
cameras, cameras,
@ -79,6 +89,12 @@ export default function DraggableGridLayout({
setIsEditMode, setIsEditMode,
fullscreen, fullscreen,
toggleFullscreen, toggleFullscreen,
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
streamMetadata,
}: DraggableGridLayoutProps) { }: DraggableGridLayoutProps) {
const { t } = useTranslation(["views/live"]); const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -98,33 +114,6 @@ export default function DraggableGridLayout({
} }
}, [allGroupsStreamingSettings, cameraGroup]); }, [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 // grid layout
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
@ -624,6 +613,7 @@ export default function DraggableGridLayout({
resetPreferredLiveMode(camera.name) resetPreferredLiveMode(camera.name)
} }
config={config} config={config}
streamMetadata={streamMetadata}
> >
<LivePlayer <LivePlayer
key={camera.name} key={camera.name}
@ -838,6 +828,7 @@ type GridLiveContextMenuProps = {
unmuteAll: () => void; unmuteAll: () => void;
resetPreferredLiveMode: () => void; resetPreferredLiveMode: () => void;
config?: FrigateConfig; config?: FrigateConfig;
streamMetadata?: { [key: string]: LiveStreamMetadata };
}; };
const GridLiveContextMenu = React.forwardRef< const GridLiveContextMenu = React.forwardRef<
@ -868,6 +859,7 @@ const GridLiveContextMenu = React.forwardRef<
unmuteAll, unmuteAll,
resetPreferredLiveMode, resetPreferredLiveMode,
config, config,
streamMetadata,
...props ...props
}, },
ref, ref,
@ -899,6 +891,7 @@ const GridLiveContextMenu = React.forwardRef<
unmuteAll={unmuteAll} unmuteAll={unmuteAll}
resetPreferredLiveMode={resetPreferredLiveMode} resetPreferredLiveMode={resetPreferredLiveMode}
config={config} config={config}
streamMetadata={streamMetadata}
> >
{children} {children}
</LiveContextMenu> </LiveContextMenu>

View File

@ -265,6 +265,7 @@ export default function LiveDashboardView({
resetPreferredLiveMode, resetPreferredLiveMode,
isRestreamedStates, isRestreamedStates,
supportsAudioOutputStates, supportsAudioOutputStates,
streamMetadata,
} = useCameraLiveMode(cameras, windowVisible, activeStreams); } = useCameraLiveMode(cameras, windowVisible, activeStreams);
const birdseyeConfig = useMemo(() => config?.birdseye, [config]); const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
@ -650,6 +651,12 @@ export default function LiveDashboardView({
setIsEditMode={setIsEditMode} setIsEditMode={setIsEditMode}
fullscreen={fullscreen} fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen} toggleFullscreen={toggleFullscreen}
preferredLiveModes={preferredLiveModes}
setPreferredLiveModes={setPreferredLiveModes}
resetPreferredLiveMode={resetPreferredLiveMode}
isRestreamedStates={isRestreamedStates}
supportsAudioOutputStates={supportsAudioOutputStates}
streamMetadata={streamMetadata}
/> />
)} )}
</> </>