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:
Josh Hawkins
2026-09-16 09:12:06 -05:00
committed by GitHub
parent 79ca18d439
commit 64d6366ac4
26 changed files with 1940 additions and 283 deletions
+4 -15
View File
@@ -22,6 +22,7 @@ import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
import {
AudioState,
LivePlayerError,
LivePlayerMode,
LiveStreamMetadata,
StatsState,
@@ -65,9 +66,7 @@ type DraggableGridLayoutProps = {
fullscreen: boolean;
toggleFullscreen: () => void;
preferredLiveModes: { [key: string]: LivePlayerMode };
setPreferredLiveModes: React.Dispatch<
React.SetStateAction<{ [key: string]: LivePlayerMode }>
>;
handleError: (cameraName: string, error: LivePlayerError) => void;
resetPreferredLiveMode: (cameraName: string) => void;
isRestreamedStates: { [key: string]: boolean };
supportsAudioOutputStates: {
@@ -89,7 +88,7 @@ export default function DraggableGridLayout({
fullscreen,
toggleFullscreen,
preferredLiveModes,
setPreferredLiveModes,
handleError,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
@@ -672,17 +671,7 @@ export default function DraggableGridLayout({
onSelectCamera(camera.name);
}
}}
onError={(e) => {
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (e === "mse-decode") {
newModes[camera.name] = "webrtc";
} else {
newModes[camera.name] = "jsmpeg";
}
return newModes;
});
}}
onError={(e) => handleError(camera.name, e)}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
playAudio={audioStates[camera.name]}
volume={volumeStates[camera.name]}
+250 -53
View File
@@ -12,6 +12,7 @@ import {
import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle";
import FilterSwitch from "@/components/filter/FilterSwitch";
import LivePlayer from "@/components/player/LivePlayer";
import StreamTechnologySelect from "@/components/player/StreamTechnologySelect";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import {
@@ -26,11 +27,18 @@ import {
} from "@/components/ui/popover";
import { useResizeObserver } from "@/hooks/resize-observer";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import {
useWebRTCAvailableForStream,
useWebRTCGloballyAvailable,
} from "@/hooks/use-webrtc-availability";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import {
LivePlayerError,
TwoWayTalkError,
LivePlayerMode,
LiveStreamMetadata,
VideoResolutionType,
WebRTCUnavailableReason,
} from "@/types/live";
import { RecordingStartingPoint } from "@/types/record";
import React, {
@@ -158,6 +166,23 @@ export default function LiveCameraView({
Object.values(camera.live.streams)[0],
);
const [
userPreferredLiveMode,
setUserPreferredLiveMode,
userPreferredLiveModeLoaded,
] = useUserPersistence<LivePlayerMode>(
`${camera.name}-preferred-live-mode`,
"mse",
);
const [forceLowBandwidth, setForceLowBandwidth, forceLowBandwidthLoaded] =
useUserPersistence<boolean>(`${camera.name}-force-low-bandwidth`, false);
// useUserPersistence seeds state with the defaults and loads asynchronously,
// so until this is true the values above are not the user's choices.
const preferencesLoaded =
streamNameLoaded && userPreferredLiveModeLoaded && forceLowBandwidthLoaded;
const isRestreamed = useMemo(
() =>
config &&
@@ -188,6 +213,43 @@ export default function LiveCameraView({
},
);
const webRTCAvailability = useWebRTCAvailableForStream(
cameraMetadata,
streamName,
);
const isWebRTCAvailable = webRTCAvailability.available;
// Two-way talk is the sendonly backchannel: global, not per-stream.
const { globallyAvailable: webRTCGloballyAvailable } =
useWebRTCGloballyAvailable();
// "checking" means the probe has not answered yet, and it re-enters that on
// every mount, so treating it as unavailable downgrades the saved choice.
const webRTCVerdictPending = webRTCAvailability.reason === "checking";
const webRTCUsable = isWebRTCAvailable || webRTCVerdictPending;
// Resolves the saved preference without overwriting it. Transient error
// fallbacks layer on top in preferredLiveMode.
const resolvedUserMode = useMemo<LivePlayerMode>(() => {
const mseSupported =
"MediaSource" in window || "ManagedMediaSource" in window;
if (!isRestreamed) {
return "jsmpeg";
}
// jsmpeg is the force low-bandwidth switch, not a technology choice.
const requested =
!userPreferredLiveMode || userPreferredLiveMode === "jsmpeg"
? "mse"
: userPreferredLiveMode;
if (requested === "webrtc" && !webRTCUsable) {
return mseSupported ? "mse" : "jsmpeg";
}
if (requested === "mse" && !mseSupported) {
return webRTCUsable ? "webrtc" : "jsmpeg";
}
return requested;
}, [userPreferredLiveMode, webRTCUsable, isRestreamed]);
const { twoWayAudio: supports2WayTalk, audioOutput: supportsAudioOutput } =
useMemo(() => detectCameraAudioFeatures(cameraMetadata), [cameraMetadata]);
@@ -362,11 +424,15 @@ export default function LiveCameraView({
});
const preferredLiveMode = useMemo(() => {
if (mic) {
if (mic && isWebRTCAvailable) {
return "webrtc";
}
if (webRTC && isRestreamed) {
if (forceLowBandwidth) {
return "jsmpeg";
}
if (webRTC && isRestreamed && isWebRTCAvailable) {
return "webrtc";
}
@@ -378,16 +444,37 @@ export default function LiveCameraView({
return "jsmpeg";
}
if (!("MediaSource" in window || "ManagedMediaSource" in window)) {
if (
!("MediaSource" in window || "ManagedMediaSource" in window) &&
isWebRTCAvailable
) {
return "webrtc";
}
if (!isRestreamed) {
return "jsmpeg";
}
return resolvedUserMode;
}, [
lowBandwidth,
forceLowBandwidth,
mic,
webRTC,
isRestreamed,
resolvedUserMode,
isWebRTCAvailable,
]);
return "mse";
}, [lowBandwidth, mic, webRTC, isRestreamed]);
// A latched error fallback would keep overriding the user's new choice.
useEffect(() => {
setWebRTC(false);
setLowBandwidth(false);
}, [userPreferredLiveMode, streamName, forceLowBandwidth]);
// A fallback chosen before the verdict arrived was made on incomplete info.
useEffect(() => {
if (!webRTCVerdictPending) {
setWebRTC(false);
setLowBandwidth(false);
}
}, [webRTCVerdictPending]);
useKeyboardListener(["m", "Escape"], (key, modifiers) => {
if (!modifiers.down) {
@@ -499,11 +586,9 @@ export default function LiveCameraView({
const handleError = useCallback(
(e: LivePlayerError) => {
if (e) {
if (
!webRTC &&
config &&
config.go2rtc?.webrtc?.candidates?.length > 0
) {
// WebRTC can now be the user's own choice, so the fallback flag no
// longer implies untried: hopping to the mode that just failed sticks.
if (preferredLiveMode !== "webrtc" && webRTCUsable) {
setWebRTC(true);
} else {
setWebRTC(false);
@@ -511,7 +596,15 @@ export default function LiveCameraView({
}
}
},
[config, webRTC],
[preferredLiveMode, webRTCUsable],
);
const handleMicrophoneError = useCallback(
(error: TwoWayTalkError) => {
setMic(false);
toast.error(t(`twoWayTalk.error.${error}`), { position: "top-center" });
},
[t],
);
return (
@@ -644,9 +737,11 @@ export default function LiveCameraView({
Icon={mic ? FaMicrophone : FaMicrophoneSlash}
isActive={mic}
title={
mic
? t("twoWayTalk.disable", { ns: "views/live" })
: t("twoWayTalk.enable", { ns: "views/live" })
!webRTCGloballyAvailable
? t("twoWayTalk.requiresWebRTC", { ns: "views/live" })
: mic
? t("twoWayTalk.disable", { ns: "views/live" })
: t("twoWayTalk.enable", { ns: "views/live" })
}
onClick={() => {
setMic(!mic);
@@ -654,7 +749,7 @@ export default function LiveCameraView({
setAudio(true);
}
}}
disabled={!cameraEnabled || debug}
disabled={!cameraEnabled || debug || !webRTCGloballyAvailable}
/>
)}
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
@@ -683,12 +778,18 @@ export default function LiveCameraView({
fullscreen={fullscreen}
streamName={streamName ?? ""}
setStreamName={setStreamName}
userPreferredLiveMode={resolvedUserMode}
setUserPreferredLiveMode={setUserPreferredLiveMode}
forceLowBandwidth={forceLowBandwidth ?? false}
setForceLowBandwidth={setForceLowBandwidth}
preferredLiveMode={preferredLiveMode}
playInBackground={playInBackground ?? false}
setPlayInBackground={setPlayInBackground}
showStats={showStats}
setShowStats={setShowStats}
isRestreamed={isRestreamed ?? false}
isWebRTCAvailable={isWebRTCAvailable}
webRTCUnavailableReason={webRTCAvailability.reason}
setLowBandwidth={setLowBandwidth}
supportsAudioOutput={supportsAudioOutput}
supports2WayTalk={supports2WayTalk}
@@ -770,12 +871,14 @@ export default function LiveCameraView({
micEnabled={mic}
iOSCompatFullScreen={isIOS}
preferredLiveMode={preferredLiveMode}
autoLive={preferencesLoaded}
useWebGL={true}
streamName={streamName ?? ""}
pip={pip}
containerRef={containerRef}
setFullResolution={setFullResolution}
onError={handleError}
onMicrophoneError={handleMicrophoneError}
/>
</div>
</TransformComponent>
@@ -830,12 +933,18 @@ type FrigateCameraFeaturesProps = {
fullscreen: boolean;
streamName: string;
setStreamName?: (value: string | undefined) => void;
preferredLiveMode: string;
userPreferredLiveMode: LivePlayerMode;
setUserPreferredLiveMode: (value: LivePlayerMode | undefined) => void;
forceLowBandwidth: boolean;
setForceLowBandwidth: (value: boolean | undefined) => void;
preferredLiveMode: LivePlayerMode;
playInBackground: boolean;
setPlayInBackground: (value: boolean | undefined) => void;
showStats: boolean;
setShowStats: (value: boolean) => void;
isRestreamed: boolean;
isWebRTCAvailable: boolean;
webRTCUnavailableReason?: WebRTCUnavailableReason;
setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>;
supportsAudioOutput: boolean;
supports2WayTalk: boolean;
@@ -852,12 +961,18 @@ function FrigateCameraFeatures({
fullscreen,
streamName,
setStreamName,
userPreferredLiveMode,
setUserPreferredLiveMode,
forceLowBandwidth,
setForceLowBandwidth,
preferredLiveMode,
playInBackground,
setPlayInBackground,
showStats,
setShowStats,
isRestreamed,
isWebRTCAvailable,
webRTCUnavailableReason,
setLowBandwidth,
supportsAudioOutput,
supports2WayTalk,
@@ -890,6 +1005,10 @@ function FrigateCameraFeatures({
const isAdmin = useIsAdmin();
const streamSelectLabel = Object.keys(camera.live.streams).find(
(key) => camera.live.streams[key] === streamName,
);
// manual event
const recordingEventIdRef = useRef<string | null>(null);
@@ -1235,17 +1354,13 @@ function FrigateCameraFeatures({
</Label>
<Select
value={streamName}
disabled={debug}
disabled={debug || forceLowBandwidth}
onValueChange={(value) => {
setStreamName?.(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue>
{Object.keys(camera.live.streams).find(
(key) => camera.live.streams[key] === streamName,
)}
</SelectValue>
<SelectValue>{streamSelectLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -1367,6 +1482,8 @@ function FrigateCameraFeatures({
)}
{preferredLiveMode == "jsmpeg" &&
userPreferredLiveMode != "jsmpeg" &&
!forceLowBandwidth &&
!debug &&
isRestreamed && (
<div className="flex flex-col items-center gap-3">
@@ -1393,6 +1510,55 @@ function FrigateCameraFeatures({
)}
</div>
)}
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-1">
<Label htmlFor="streaming-mode">{t("stream.mode")}</Label>
<StreamTechnologySelect
value={userPreferredLiveMode}
onValueChange={setUserPreferredLiveMode}
isWebRTCAvailable={isWebRTCAvailable}
webRTCUnavailableReason={webRTCUnavailableReason}
disabled={debug || forceLowBandwidth}
/>
{debug ? (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<LuX className="size-4 text-danger" />
<div>{t("stream.debug.technology")}</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
{t("stream.technology.description")}
</p>
)}
</div>
)}
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Label
className="mx-0 cursor-pointer text-primary"
htmlFor="forcelowbandwidth"
>
{t("stream.lowBandwidth.force.label")}
</Label>
<Switch
className="ml-1"
id="forcelowbandwidth"
disabled={debug}
checked={forceLowBandwidth}
onCheckedChange={(checked) =>
setForceLowBandwidth(checked)
}
/>
</div>
<p className="text-sm text-muted-foreground">
{t("stream.lowBandwidth.force.desc")}
</p>
</div>
)}
{isRestreamed && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
@@ -1600,14 +1766,10 @@ function FrigateCameraFeatures({
onValueChange={(value) => {
setStreamName?.(value);
}}
disabled={debug}
disabled={debug || forceLowBandwidth}
>
<SelectTrigger className="w-full">
<SelectValue>
{Object.keys(camera.live.streams).find(
(key) => camera.live.streams[key] === streamName,
)}
</SelectValue>
<SelectValue>{streamSelectLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -1725,29 +1887,64 @@ function FrigateCameraFeatures({
)}
</div>
)}
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<div className="mt-2 flex flex-col items-center gap-3">
<div className="flex flex-row items-center gap-2">
<IoIosWarning className="mr-1 size-8 text-danger" />
<p className="text-sm">
{t("stream.lowBandwidth.tips")}
</p>
</div>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline"
size="sm"
disabled={debug}
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
{t("stream.lowBandwidth.resetStream")}
{preferredLiveMode == "jsmpeg" &&
userPreferredLiveMode != "jsmpeg" &&
!forceLowBandwidth &&
isRestreamed && (
<div className="mt-2 flex flex-col items-center gap-3">
<div className="flex flex-row items-center gap-2">
<IoIosWarning className="mr-1 size-8 text-danger" />
<p className="text-sm">
{t("stream.lowBandwidth.tips")}
</p>
</div>
</Button>
</div>
)}
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline"
size="sm"
disabled={debug}
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
{t("stream.lowBandwidth.resetStream")}
</div>
</Button>
</div>
)}
</div>
)}
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="px-2">
<div className="mb-1 text-sm">{t("stream.mode")}</div>
<StreamTechnologySelect
value={userPreferredLiveMode}
onValueChange={setUserPreferredLiveMode}
isWebRTCAvailable={isWebRTCAvailable}
webRTCUnavailableReason={webRTCUnavailableReason}
disabled={debug || forceLowBandwidth}
/>
<p className="mt-1 text-sm text-muted-foreground">
{t("stream.technology.description")}
</p>
</div>
)}
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-2">
<FilterSwitch
label={t("stream.lowBandwidth.force.label")}
isChecked={forceLowBandwidth}
onCheckedChange={(checked) =>
setForceLowBandwidth(checked)
}
disabled={debug}
/>
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
{t("stream.lowBandwidth.force.desc")}
</p>
</div>
)}
<div className="flex flex-col gap-1 px-2">
+16 -4
View File
@@ -43,6 +43,7 @@ import { cn } from "@/lib/utils";
import {
AudioState,
LivePlayerError,
LivePlayerMode,
StatsState,
VolumeState,
} from "@/types/live";
@@ -271,6 +272,16 @@ export default function LiveDashboardView({
return streams;
}, [cameras, currentGroupStreamingSettings]);
// Per-camera streaming-technology choice from the camera group settings.
const preferredModes = useMemo(() => {
const modes: { [cameraName: string]: LivePlayerMode | undefined } = {};
cameras.forEach((camera) => {
modes[camera.name] =
currentGroupStreamingSettings?.[camera.name]?.playerMode;
});
return modes;
}, [cameras, currentGroupStreamingSettings]);
const {
preferredLiveModes,
setPreferredLiveModes,
@@ -278,7 +289,8 @@ export default function LiveDashboardView({
isRestreamedStates,
supportsAudioOutputStates,
streamMetadata,
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
webRTCUsableStates,
} = useCameraLiveMode(cameras, windowVisible, activeStreams, preferredModes);
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
@@ -286,7 +298,7 @@ export default function LiveDashboardView({
(cameraName: string, error: LivePlayerError) => {
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (error === "mse-decode") {
if (error === "mse-decode" && webRTCUsableStates[cameraName]) {
newModes[cameraName] = "webrtc";
} else {
newModes[cameraName] = "jsmpeg";
@@ -294,7 +306,7 @@ export default function LiveDashboardView({
return newModes;
});
},
[setPreferredLiveModes],
[setPreferredLiveModes, webRTCUsableStates],
);
// audio states
@@ -673,7 +685,7 @@ export default function LiveDashboardView({
fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen}
preferredLiveModes={preferredLiveModes}
setPreferredLiveModes={setPreferredLiveModes}
handleError={handleError}
resetPreferredLiveMode={resetPreferredLiveMode}
isRestreamedStates={isRestreamedStates}
supportsAudioOutputStates={supportsAudioOutputStates}