mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 02:48:57 +03:00
Live streaming tech selection (#24374)
* allow users to select live streaming technology * fix webrtc being downgraded to mse on load `useUserPersistence` seeds state with the default and loads asynchronously, so the first render always used `mse` instead of the saved choice, and `useWebRTCGloballyAvailable` reports `checking` until the probe settles and re-enters that state on every consumer mount, so a saved `webrtc` was rewritten to `mse` even after the probe had already passed. On Safari the MSE player then timed out and latched the jsmpeg fallback. A pending probe now counts as available, the player waits on `autoLive` until the stored preferences load, and `handleError` gates on the mode in use since the fallback flag no longer implies webrtc is untried. A rejected IndexedDB read also resolves `loaded` now, so a blocked store can't leave the player waiting forever. * add support for configurable ICE servers in WebRTC player * add mic error state, fix dialog overwriting saved choice and dashboard ignoring stream * tweaks
This commit is contained in:
@@ -4,11 +4,42 @@ import useSWR from "swr";
|
||||
import { LivePlayerMode } from "@/types/live";
|
||||
import useDeferredStreamMetadata from "./use-deferred-stream-metadata";
|
||||
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
|
||||
import {
|
||||
evaluateStreamWebRTCAvailability,
|
||||
useWebRTCGloballyAvailable,
|
||||
} from "./use-webrtc-availability";
|
||||
|
||||
// Shared by the initial computation and the context-menu "Reset" so the two
|
||||
// can't diverge.
|
||||
function resolveLiveMode(
|
||||
isRestreamed: boolean,
|
||||
mseSupported: boolean,
|
||||
webRTCAvailable: boolean,
|
||||
requested: LivePlayerMode | undefined,
|
||||
): LivePlayerMode {
|
||||
let mode: LivePlayerMode;
|
||||
if (!mseSupported) {
|
||||
mode = isRestreamed && webRTCAvailable ? "webrtc" : "jsmpeg";
|
||||
} else {
|
||||
mode = isRestreamed ? "mse" : "jsmpeg";
|
||||
}
|
||||
|
||||
if (requested === "jsmpeg") {
|
||||
mode = "jsmpeg";
|
||||
} else if (requested === "webrtc" && isRestreamed && webRTCAvailable) {
|
||||
mode = "webrtc";
|
||||
} else if (requested === "mse" && mseSupported && isRestreamed) {
|
||||
mode = "mse";
|
||||
}
|
||||
|
||||
return mode;
|
||||
}
|
||||
|
||||
export default function useCameraLiveMode(
|
||||
cameras: CameraConfig[],
|
||||
windowVisible: boolean,
|
||||
activeStreams?: { [cameraName: string]: string },
|
||||
preferredModes?: { [cameraName: string]: LivePlayerMode | undefined },
|
||||
) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@@ -46,6 +77,25 @@ export default function useCameraLiveMode(
|
||||
// Fetch stream metadata with deferred loading (doesn't block initial render)
|
||||
const streamMetadata = useDeferredStreamMetadata(restreamedStreamNames);
|
||||
|
||||
const { globallyAvailable, globalReason } = useWebRTCGloballyAvailable();
|
||||
|
||||
// "checking" counts as usable because the probe re-enters it on every mount,
|
||||
// and treating it as unavailable would downgrade a saved WebRTC choice.
|
||||
const webRTCUsableStates = useMemo(() => {
|
||||
const states: { [cameraName: string]: boolean } = {};
|
||||
cameras.forEach((camera) => {
|
||||
const streamName =
|
||||
activeStreams?.[camera.name] ?? Object.values(camera.live.streams)[0];
|
||||
const { available, reason } = evaluateStreamWebRTCAvailability({
|
||||
globallyAvailable,
|
||||
globalReason,
|
||||
metadata: streamMetadata[streamName],
|
||||
});
|
||||
states[camera.name] = available || reason === "checking";
|
||||
});
|
||||
return states;
|
||||
}, [cameras, activeStreams, globallyAvailable, globalReason, streamMetadata]);
|
||||
|
||||
// Compute live mode states
|
||||
const [preferredLiveModes, setPreferredLiveModes] = useState<{
|
||||
[key: string]: LivePlayerMode;
|
||||
@@ -81,11 +131,14 @@ export default function useCameraLiveMode(
|
||||
|
||||
newIsRestreamedStates[camera.name] = isRestreamed ?? false;
|
||||
|
||||
if (!mseSupported) {
|
||||
newPreferredLiveModes[camera.name] = isRestreamed ? "webrtc" : "jsmpeg";
|
||||
} else {
|
||||
newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg";
|
||||
}
|
||||
// Auto-selected default, overridden by the user's per-camera choice when viable
|
||||
// Runtime fallback (player errors) still adjusts the mode on top of this base
|
||||
newPreferredLiveModes[camera.name] = resolveLiveMode(
|
||||
!!isRestreamed,
|
||||
mseSupported,
|
||||
webRTCUsableStates[camera.name] ?? false,
|
||||
preferredModes?.[camera.name],
|
||||
);
|
||||
|
||||
// Check each stream for audio support
|
||||
if (isRestreamed) {
|
||||
@@ -108,7 +161,15 @@ export default function useCameraLiveMode(
|
||||
setPreferredLiveModes(newPreferredLiveModes);
|
||||
setIsRestreamedStates(newIsRestreamedStates);
|
||||
setSupportsAudioOutputStates(newSupportsAudioOutputStates);
|
||||
}, [activeStreams, cameras, config, windowVisible, streamMetadata]);
|
||||
}, [
|
||||
activeStreams,
|
||||
cameras,
|
||||
config,
|
||||
windowVisible,
|
||||
streamMetadata,
|
||||
webRTCUsableStates,
|
||||
preferredModes,
|
||||
]);
|
||||
|
||||
const resetPreferredLiveMode = useCallback(
|
||||
(cameraName: string) => {
|
||||
@@ -124,19 +185,17 @@ export default function useCameraLiveMode(
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(selectedStreamName);
|
||||
|
||||
setPreferredLiveModes((prevModes) => {
|
||||
const newModes = { ...prevModes };
|
||||
|
||||
if (!mseSupported) {
|
||||
newModes[cameraName] = isRestreamed ? "webrtc" : "jsmpeg";
|
||||
} else {
|
||||
newModes[cameraName] = isRestreamed ? "mse" : "jsmpeg";
|
||||
}
|
||||
|
||||
return newModes;
|
||||
});
|
||||
setPreferredLiveModes((prevModes) => ({
|
||||
...prevModes,
|
||||
[cameraName]: resolveLiveMode(
|
||||
!!isRestreamed,
|
||||
mseSupported,
|
||||
webRTCUsableStates[cameraName] ?? false,
|
||||
preferredModes?.[cameraName],
|
||||
),
|
||||
}));
|
||||
},
|
||||
[activeStreams, cameras, config],
|
||||
[activeStreams, cameras, config, webRTCUsableStates, preferredModes],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -146,5 +205,6 @@ export default function useCameraLiveMode(
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
streamMetadata,
|
||||
webRTCUsableStates,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,7 +185,12 @@ export function useUserPersistence<S>(
|
||||
setLoaded(true);
|
||||
}
|
||||
|
||||
loadWithMigration();
|
||||
// Consumers gate on this flag and the state already holds the defaults,
|
||||
// so a rejected read must still resolve to "loaded".
|
||||
loadWithMigration().catch(() => {
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
});
|
||||
}, [
|
||||
auth.isLoading,
|
||||
isAuthenticated,
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { LiveStreamMetadata, WebRTCUnavailableReason } from "@/types/live";
|
||||
import {
|
||||
browserSupportsWebRTC,
|
||||
browserSupportsWebRTCVideoCodec,
|
||||
browserWebRTCVideoCodecs,
|
||||
logWebRTCUnavailable,
|
||||
resetWebRTCUnavailableLog,
|
||||
webRTCIceServers,
|
||||
} from "@/utils/webrtcUtil";
|
||||
import {
|
||||
getPlaybackAudioCodecs,
|
||||
getStreamVideoCodecs,
|
||||
} from "@/utils/cameraUtil";
|
||||
import { probeWebRTCAvailability, resetWebRTCProbe } from "@/utils/webrtcProbe";
|
||||
|
||||
const WEBRTC_AUDIO_CODECS = new Set(["OPUS", "PCMA", "PCMU"]);
|
||||
|
||||
type GlobalAvailability = {
|
||||
globallyAvailable: boolean;
|
||||
globalReason: WebRTCUnavailableReason | null;
|
||||
};
|
||||
|
||||
export type StreamAvailability = {
|
||||
available: boolean;
|
||||
reason?: WebRTCUnavailableReason;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
/** Split out of the hook so the decision can be exercised without React. */
|
||||
export function evaluateStreamWebRTCAvailability(args: {
|
||||
globallyAvailable: boolean;
|
||||
globalReason: WebRTCUnavailableReason | null;
|
||||
metadata: LiveStreamMetadata | null | undefined;
|
||||
}): StreamAvailability {
|
||||
const { globallyAvailable, globalReason, metadata } = args;
|
||||
|
||||
if (!globallyAvailable) {
|
||||
return { available: false, reason: globalReason ?? "unreachable" };
|
||||
}
|
||||
|
||||
// The metadata endpoint enumerates every producer variant including `#video=`
|
||||
// transcodes, so an empty list means unknown rather than unplayable.
|
||||
const videoCodecs = getStreamVideoCodecs(metadata);
|
||||
if (
|
||||
videoCodecs.length > 0 &&
|
||||
!videoCodecs.some((c) => browserSupportsWebRTCVideoCodec(c))
|
||||
) {
|
||||
const browserCodecs = browserWebRTCVideoCodecs();
|
||||
return {
|
||||
available: false,
|
||||
reason: "video-codec",
|
||||
detail:
|
||||
`Stream video codecs [${videoCodecs.join(", ")}] cannot be received over WebRTC in this browser ` +
|
||||
`(browser supports: ${browserCodecs.length ? browserCodecs.join(", ") : "none"}).`,
|
||||
};
|
||||
}
|
||||
|
||||
// A stream with no playback audio is not disqualified.
|
||||
const playbackAudioCodecs = getPlaybackAudioCodecs(metadata);
|
||||
if (
|
||||
playbackAudioCodecs.length > 0 &&
|
||||
!playbackAudioCodecs.some((c) => WEBRTC_AUDIO_CODECS.has(c))
|
||||
) {
|
||||
return {
|
||||
available: false,
|
||||
reason: "audio-codec",
|
||||
detail:
|
||||
`Stream playback audio codecs [${playbackAudioCodecs.join(", ")}] cannot be carried over WebRTC ` +
|
||||
`(supported: ${Array.from(WEBRTC_AUDIO_CODECS).join(", ")}).`,
|
||||
};
|
||||
}
|
||||
|
||||
return { available: true };
|
||||
}
|
||||
|
||||
/** Console detail for a reason decided by the global availability check. */
|
||||
function describeGlobalReason(
|
||||
reason: WebRTCUnavailableReason,
|
||||
testStream: string | undefined,
|
||||
probeDetail: string | undefined,
|
||||
): string {
|
||||
switch (reason) {
|
||||
case "browser":
|
||||
return "This browser does not expose RTCPeerConnection.";
|
||||
case "not-configured":
|
||||
return "No candidates or ice_servers are set under go2rtc.webrtc.";
|
||||
case "unreachable":
|
||||
return `The connectivity probe against stream '${testStream}' failed: ${probeDetail ?? "unknown cause"}.`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** go2rtc config the cached probe result and log messages belong to. */
|
||||
let lastProbeSignature: string | null = null;
|
||||
|
||||
/**
|
||||
* Once-per-session: browser support, go2rtc config, and a live handshake probe.
|
||||
*/
|
||||
export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [probe, setProbe] = useState<{
|
||||
state: "pending" | "pass" | "fail";
|
||||
detail?: string;
|
||||
}>({ state: "pending" });
|
||||
|
||||
const browserOk = browserSupportsWebRTC();
|
||||
|
||||
const configured = useMemo(() => {
|
||||
const webrtc = config?.go2rtc?.webrtc;
|
||||
if (!webrtc) return false;
|
||||
return (
|
||||
(webrtc.candidates?.length ?? 0) > 0 ||
|
||||
(webrtc.ice_servers?.length ?? 0) > 0
|
||||
);
|
||||
}, [config]);
|
||||
|
||||
// Representative restreamed stream to probe against.
|
||||
const testStream = useMemo(() => {
|
||||
const streams = config?.go2rtc?.streams ?? {};
|
||||
return Object.keys(streams)[0];
|
||||
}, [config]);
|
||||
|
||||
const iceServers = useMemo(
|
||||
() => webRTCIceServers(config?.go2rtc?.webrtc?.ice_servers),
|
||||
[config],
|
||||
);
|
||||
|
||||
// Identity of the cached page-session probe: a config change here must
|
||||
// re-probe rather than return a stale result.
|
||||
const probeSignature = useMemo(
|
||||
() =>
|
||||
JSON.stringify({
|
||||
candidates: config?.go2rtc?.webrtc?.candidates ?? [],
|
||||
iceServers,
|
||||
testStream,
|
||||
}),
|
||||
[config, iceServers, testStream],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Runs on every consumer's mount, but the probe cache and log are page-wide:
|
||||
// clearing them unconditionally would re-probe once per consumer.
|
||||
if (lastProbeSignature !== probeSignature) {
|
||||
lastProbeSignature = probeSignature;
|
||||
resetWebRTCProbe();
|
||||
resetWebRTCUnavailableLog();
|
||||
}
|
||||
setProbe({ state: "pending" });
|
||||
}, [probeSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!browserOk || !configured || !testStream) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
probeWebRTCAvailability(testStream, iceServers).then((result) => {
|
||||
if (!cancelled) {
|
||||
setProbe({
|
||||
state: result.ok ? "pass" : "fail",
|
||||
detail: result.detail,
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [browserOk, configured, testStream, iceServers]);
|
||||
|
||||
const availability = useMemo<GlobalAvailability>(() => {
|
||||
if (!browserOk) {
|
||||
return { globallyAvailable: false, globalReason: "browser" };
|
||||
}
|
||||
if (!configured) {
|
||||
return { globallyAvailable: false, globalReason: "not-configured" };
|
||||
}
|
||||
if (probe.state === "pending") {
|
||||
return { globallyAvailable: false, globalReason: "checking" };
|
||||
}
|
||||
if (probe.state === "fail") {
|
||||
return { globallyAvailable: false, globalReason: "unreachable" };
|
||||
}
|
||||
return { globallyAvailable: true, globalReason: null };
|
||||
}, [browserOk, configured, probe]);
|
||||
|
||||
useEffect(() => {
|
||||
const reason = availability.globalReason;
|
||||
// Before the config resolves, `configured` is false and the reason reads as
|
||||
// not-configured, which would mislog on a correctly configured install.
|
||||
if (!config || !reason || reason === "checking") {
|
||||
return;
|
||||
}
|
||||
logWebRTCUnavailable(
|
||||
undefined,
|
||||
reason,
|
||||
describeGlobalReason(reason, testStream, probe.detail),
|
||||
);
|
||||
}, [config, availability, testStream, probe.detail]);
|
||||
|
||||
return availability;
|
||||
}
|
||||
|
||||
/** Per-stream WebRTC availability for selectors and auto-selection. */
|
||||
export function useWebRTCAvailableForStream(
|
||||
metadata: LiveStreamMetadata | null | undefined,
|
||||
streamName?: string,
|
||||
): StreamAvailability {
|
||||
const { globallyAvailable, globalReason } = useWebRTCGloballyAvailable();
|
||||
|
||||
const availability = useMemo(
|
||||
() =>
|
||||
evaluateStreamWebRTCAvailability({
|
||||
globallyAvailable,
|
||||
globalReason,
|
||||
metadata,
|
||||
}),
|
||||
[globallyAvailable, globalReason, metadata],
|
||||
);
|
||||
|
||||
// Only stream-specific verdicts carry a detail; a global reason passing
|
||||
// through here was already logged by useWebRTCGloballyAvailable.
|
||||
useEffect(() => {
|
||||
if (!availability.reason || !availability.detail) {
|
||||
return;
|
||||
}
|
||||
logWebRTCUnavailable(streamName, availability.reason, availability.detail);
|
||||
}, [availability, streamName]);
|
||||
|
||||
return availability;
|
||||
}
|
||||
Reference in New Issue
Block a user