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
+37 -28
View File
@@ -179,6 +179,9 @@ export default function LiveContextMenu({
],
);
const isForcedLowBandwidth =
groupStreamingSettings?.[camera]?.playerMode === "jsmpeg";
// ui
const audioControlsUsed = useRef(false);
@@ -270,12 +273,14 @@ export default function LiveContextMenu({
<div className="text-primary-variant smart-capitalize">
<CameraNameLabel camera={camera} />
</div>
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<div className="flex flex-row items-center gap-1">
<IoIosWarning className="mr-1 size-4 text-danger" />
<p className="mr-2 text-xs">{t("lowBandwidthMode")}</p>
</div>
)}
{preferredLiveMode == "jsmpeg" &&
isRestreamed &&
!isForcedLowBandwidth && (
<div className="flex flex-row items-center gap-1">
<IoIosWarning className="mr-1 size-4 text-danger" />
<p className="mr-2 text-xs">{t("lowBandwidthMode")}</p>
</div>
)}
</div>
{preferredLiveMode != "jsmpeg" && isRestreamed && supportsAudio && (
<>
@@ -374,21 +379,23 @@ export default function LiveContextMenu({
</ContextMenuItem>
</>
)}
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<>
<ContextMenuSeparator />
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? resetPreferredLiveMode : undefined}
>
<div className="text-primary">
{t("button.reset", { ns: "common" })}
{preferredLiveMode == "jsmpeg" &&
isRestreamed &&
!isForcedLowBandwidth && (
<>
<ContextMenuSeparator />
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? resetPreferredLiveMode : undefined}
>
<div className="text-primary">
{t("button.reset", { ns: "common" })}
</div>
</div>
</div>
</ContextMenuItem>
</>
)}
</ContextMenuItem>
</>
)}
{notificationsEnabledInConfig && isEnabled && (
<>
<ContextMenuSeparator />
@@ -548,14 +555,16 @@ export default function LiveContextMenu({
</ContextMenu>
<Dialog open={showSettings} onOpenChange={setShowSettings}>
<CameraStreamingDialog
camera={camera}
groupStreamingSettings={groupStreamingSettings}
setGroupStreamingSettings={setGroupStreamingSettings}
setIsDialogOpen={setShowSettings}
onSave={onSave}
streamMetadata={streamMetadata}
/>
{showSettings && (
<CameraStreamingDialog
camera={camera}
groupStreamingSettings={groupStreamingSettings}
setGroupStreamingSettings={setGroupStreamingSettings}
setIsDialogOpen={setShowSettings}
onSave={onSave}
streamMetadata={streamMetadata}
/>
)}
</Dialog>
</div>
);
@@ -174,7 +174,6 @@ export default function JSMpegPlayer({
streamType: "jsmpeg",
bandwidth: Math.round(bitrate),
totalFrames: frameCount,
latency: undefined,
droppedFrames: undefined,
decodedFrames: undefined,
droppedFrameRate: undefined,
+9 -2
View File
@@ -11,6 +11,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useCameraActivity } from "@/hooks/use-camera-activity";
import {
LivePlayerError,
TwoWayTalkError,
LivePlayerMode,
PlayerStatsType,
VideoResolutionType,
@@ -51,6 +52,7 @@ type LivePlayerProps = {
onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void;
onMicrophoneError?: (error: TwoWayTalkError) => void;
onResetLiveMode?: () => void;
};
@@ -76,6 +78,7 @@ export default function LivePlayer({
onClick,
setFullResolution,
onError,
onMicrophoneError,
onResetLiveMode,
}: LivePlayerProps) {
const { t } = useTranslation(["components/player"]);
@@ -98,7 +101,6 @@ export default function LivePlayer({
const [stats, setStats] = useState<PlayerStatsType>({
streamType: "-",
bandwidth: 0, // in kBps
latency: undefined, // in seconds
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
@@ -272,6 +274,7 @@ export default function LivePlayer({
onPlaying={playerIsPlaying}
pip={pip}
onError={onError}
onMicrophoneError={onMicrophoneError}
/>
);
} else if (preferredLiveMode == "mse") {
@@ -363,7 +366,11 @@ export default function LivePlayer({
{cameraEnabled &&
!offline &&
(!showStillWithoutActivity || isReEnabling) &&
!liveReady && <ActivityIndicator />}
!liveReady && (
<div className="absolute inset-0 flex items-center justify-center">
<ActivityIndicator />
</div>
)}
{((showStillWithoutActivity && !liveReady) || liveReady) &&
objects.length > 0 && (
-11
View File
@@ -759,15 +759,6 @@ function MSEPlayer({
lastLoadedBytes = bytesLoaded;
lastTimestamp = now;
const latency =
video.seekable.length > 0
? Math.max(
0,
video.seekable.end(video.seekable.length - 1) -
video.currentTime,
)
: 0;
const videoQuality = video.getVideoPlaybackQuality();
const { totalVideoFrames, droppedVideoFrames } = videoQuality;
const droppedFrameRate = totalVideoFrames
@@ -777,7 +768,6 @@ function MSEPlayer({
setStats?.({
streamType: "MSE",
bandwidth,
latency,
totalFrames: totalVideoFrames,
droppedFrames: droppedVideoFrames || undefined,
decodedFrames: totalVideoFrames - droppedVideoFrames,
@@ -793,7 +783,6 @@ function MSEPlayer({
setStats?.({
streamType: "-",
bandwidth: 0,
latency: undefined,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
+7 -27
View File
@@ -8,27 +8,21 @@ type PlayerStatsProps = {
};
export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
const { t } = useTranslation(["components/player"]);
const { t } = useTranslation(["components/player", "views/live"]);
const streamTypeLabel = t(
`stream.technology.name.${stats.streamType.toLowerCase()}`,
{ ns: "views/live", defaultValue: stats.streamType },
);
const fullStatsContent = (
<>
<p>
<span className="text-white/70">{t("stats.streamType.title")}</span>{" "}
<span className="text-white">{stats.streamType}</span>
<span className="text-white">{streamTypeLabel}</span>
</p>
<p>
<span className="text-white/70">{t("stats.bandwidth.title")}</span>{" "}
<span className="text-white">{stats.bandwidth.toFixed(2)} kBps</span>
</p>
{stats.latency != undefined && (
<p>
<span className="text-white/70">{t("stats.latency.title")}</span>{" "}
<span
className={`text-white ${stats.latency > 2 ? "text-danger" : ""}`}
>
{t("stats.latency.value", { seconds: stats.latency.toFixed(2) })}
</span>
</p>
)}
<p>
<span className="text-white/70">{t("stats.totalFrames")}</span>{" "}
<span className="text-white">{stats.totalFrames}</span>
@@ -62,26 +56,12 @@ export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
<div className="flex flex-row items-center justify-center gap-4">
<div className="flex flex-col items-center justify-start gap-1">
<span className="text-white/70">{t("stats.streamType.short")}</span>
<span className="text-white">{stats.streamType}</span>
<span className="text-white">{streamTypeLabel}</span>
</div>
<div className="flex flex-col items-center gap-1">
<span className="text-white/70">{t("stats.bandwidth.short")}</span>{" "}
<span className="text-white">{stats.bandwidth.toFixed(2)} kBps</span>
</div>
{stats.latency != undefined && (
<div className="hidden flex-col items-center gap-1 md:flex">
<span className="text-white/70">
{t("stats.latency.short.title")}
</span>
<span
className={`text-white ${stats.latency >= 2 ? "text-danger" : ""}`}
>
{t("stats.latency.short.value", {
seconds: stats.latency.toFixed(2),
})}
</span>
</div>
)}
{stats.droppedFrames != undefined && (
<div className="flex flex-col items-center justify-end gap-1">
<span className="text-white/70">
@@ -0,0 +1,96 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { LivePlayerMode, WebRTCUnavailableReason } from "@/types/live";
import { LuX } from "react-icons/lu";
import { useTranslation } from "react-i18next";
type StreamTechnologySelectProps = {
value: LivePlayerMode;
onValueChange: (value: LivePlayerMode) => void;
isWebRTCAvailable: boolean;
webRTCUnavailableReason?: WebRTCUnavailableReason;
disabled: boolean;
};
export default function StreamTechnologySelect({
value,
onValueChange,
isWebRTCAvailable,
webRTCUnavailableReason,
disabled,
}: StreamTechnologySelectProps) {
const { t } = useTranslation(["views/live"]);
const isChecking = webRTCUnavailableReason === "checking";
return (
<Select
value={value}
onValueChange={onValueChange as (value: string) => void}
disabled={disabled}
>
<SelectTrigger className="w-full">
<span>{t(`stream.technology.name.${value}`)}</span>
</SelectTrigger>
<SelectContent className="w-[var(--radix-select-trigger-width)]">
<SelectItem value="mse">
<span className="flex flex-col gap-0.5 whitespace-normal">
<span>{t("stream.technology.name.mse")}</span>
<span className="text-xs text-muted-foreground">
{t("stream.technology.tips.mse")}
</span>
</span>
</SelectItem>
<SelectItem
value="webrtc"
disabled={!isWebRTCAvailable}
className="data-[disabled]:opacity-100"
>
<span className="flex flex-col gap-0.5 whitespace-normal">
<span className={cn(!isWebRTCAvailable && "opacity-50")}>
{t("stream.technology.name.webrtc")}
</span>
<span
className={cn(
"text-xs text-muted-foreground",
!isWebRTCAvailable && "opacity-50",
)}
>
{t("stream.technology.tips.webrtc")}
</span>
{!isWebRTCAvailable && webRTCUnavailableReason && (
<span
className={cn(
"mt-1 flex flex-row items-start gap-1.5 rounded-md border p-2",
isChecking
? "border-secondary-foreground/20 bg-secondary-foreground/10"
: "border-danger/20 bg-danger/10",
)}
>
{isChecking ? (
<ActivityIndicator
className="mt-0.5 size-3.5 shrink-0"
size={14}
/>
) : (
<LuX className="mt-0.5 size-3.5 shrink-0 text-danger" />
)}
<span className="text-xs text-secondary-foreground">
{t(
`stream.technology.unavailable.${webRTCUnavailableReason}`,
)}
</span>
</span>
)}
</span>
</SelectItem>
</SelectContent>
</Select>
);
}
+147 -60
View File
@@ -1,6 +1,13 @@
import { baseUrl } from "@/api/baseUrl";
import { LivePlayerError, PlayerStatsType } from "@/types/live";
import {
LivePlayerError,
PlayerStatsType,
TwoWayTalkError,
} from "@/types/live";
import { FrigateConfig } from "@/types/frigateConfig";
import { webRTCIceServers } from "@/utils/webrtcUtil";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
type WebRtcPlayerProps = {
className?: string;
@@ -15,6 +22,7 @@ type WebRtcPlayerProps = {
setStats?: (stats: PlayerStatsType) => void;
onPlaying?: () => void;
onError?: (error: LivePlayerError) => void;
onMicrophoneError?: (error: TwoWayTalkError) => void;
};
export default function WebRtcPlayer({
@@ -30,9 +38,22 @@ export default function WebRtcPlayer({
setStats,
onPlaying,
onError,
onMicrophoneError,
}: WebRtcPlayerProps) {
// metadata
const { data: config } = useSWR<FrigateConfig>("config");
// Keyed on the serialized list so an unrelated config update doesn't
// reconnect every WebRTC player.
const iceServersKey = JSON.stringify(
config?.go2rtc?.webrtc?.ice_servers ?? [],
);
const iceServers = useMemo(
() => webRTCIceServers(JSON.parse(iceServersKey)),
[iceServersKey],
);
const wsURL = useMemo(() => {
return `${baseUrl.replace(/^http/, "ws")}live/webrtc/api/ws?src=${camera}`;
}, [camera]);
@@ -54,6 +75,10 @@ export default function WebRtcPlayer({
const pcRef = useRef<RTCPeerConnection | undefined>(undefined);
const wsRef = useRef<WebSocket | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
// Separate sendonly-audio connection for two-way talk: go2rtc only wires the
// backchannel from a connection's initial offer.
const micPcRef = useRef<RTCPeerConnection | undefined>(undefined);
const micWsRef = useRef<WebSocket | null>(null);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const videoLoadTimeoutRef = useRef<NodeJS.Timeout>(undefined);
@@ -65,7 +90,7 @@ export default function WebRtcPlayer({
const pc = new RTCPeerConnection({
bundlePolicy: "max-bundle",
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
iceServers,
});
const localTracks = [];
@@ -105,7 +130,7 @@ export default function WebRtcPlayer({
videoRef.current.srcObject = new MediaStream(localTracks);
return pc;
},
[videoRef],
[videoRef, iceServers],
);
async function getMediaTracks(
@@ -123,51 +148,57 @@ export default function WebRtcPlayer({
}
}
// Offer/answer/ICE exchange over the WebSocket; shared by both connections.
const startSignaling = useCallback((pc: RTCPeerConnection, ws: WebSocket) => {
ws.addEventListener("open", () => {
pc.addEventListener("icecandidate", (ev) => {
if (!ev.candidate) return;
ws.send(
JSON.stringify({
type: "webrtc/candidate",
value: ev.candidate.candidate,
}),
);
});
pc.createOffer()
.then((offer) => pc.setLocalDescription(offer))
.then(() => {
ws.send(
JSON.stringify({
type: "webrtc/offer",
value: pc.localDescription?.sdp,
}),
);
});
});
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "webrtc/candidate") {
pc.addIceCandidate({ candidate: msg.value, sdpMid: "0" });
} else if (msg.type === "webrtc/answer") {
pc.setRemoteDescription({ type: "answer", sdp: msg.value });
}
});
}, []);
const connect = useCallback(
async (aPc: Promise<RTCPeerConnection | undefined>) => {
if (!aPc) {
return;
}
pcRef.current = await aPc;
const pc = await aPc;
if (!pc) {
return;
}
pcRef.current = pc;
wsRef.current = new WebSocket(wsURL);
const ws = wsRef.current;
ws.addEventListener("open", () => {
pcRef.current?.addEventListener("icecandidate", (ev) => {
if (!ev.candidate) return;
const msg = {
type: "webrtc/candidate",
value: ev.candidate.candidate,
};
ws.send(JSON.stringify(msg));
});
pcRef.current
?.createOffer()
.then((offer) => pcRef.current?.setLocalDescription(offer))
.then(() => {
const msg = {
type: "webrtc/offer",
value: pcRef.current?.localDescription?.sdp,
};
ws.send(JSON.stringify(msg));
});
});
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "webrtc/candidate") {
pcRef.current?.addIceCandidate({ candidate: msg.value, sdpMid: "0" });
} else if (msg.type === "webrtc/answer") {
pcRef.current?.setRemoteDescription({
type: "answer",
sdp: msg.value,
});
}
});
startSignaling(pc, wsRef.current);
},
[wsURL],
[wsURL, startSignaling],
);
useEffect(() => {
@@ -179,9 +210,8 @@ export default function WebRtcPlayer({
return;
}
const aPc = PeerConnection(
microphoneEnabled ? "video+audio+microphone" : "video+audio",
);
// No mic here. It's a separate connection, so toggling talk never reloads.
const aPc = PeerConnection("video+audio");
connect(aPc);
return () => {
@@ -194,14 +224,80 @@ export default function WebRtcPlayer({
pcRef.current = undefined;
}
};
}, [camera, connect, PeerConnection, pcRef, videoRef, playbackEnabled]);
// Backchannel connection, alive only while the mic is on.
useEffect(() => {
if (!microphoneEnabled || !playbackEnabled) {
return;
}
let cancelled = false;
(async () => {
const tracks = await getMediaTracks("user", {
video: false,
audio: true,
});
if (cancelled) {
tracks.forEach((track) => track.stop());
return;
}
if (tracks.length === 0) {
onMicrophoneError?.("microphone");
return;
}
const pc = new RTCPeerConnection({
bundlePolicy: "max-bundle",
iceServers,
});
tracks.forEach((track) =>
pc.addTransceiver(track, { direction: "sendonly" }),
);
micPcRef.current = pc;
const ws = new WebSocket(wsURL);
micWsRef.current = ws;
startSignaling(pc, ws);
// go2rtc sends an error instead of an answer when it can't attach the
// microphone to the camera's backchannel.
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type !== "error" || cancelled) {
return;
}
// eslint-disable-next-line no-console
console.error(
`${camera} - Two-way talk error: ${msg.value} See the documentation: https://docs.frigate.video/configuration/live/#two-way-talk`,
);
onMicrophoneError?.("refused");
});
})();
return () => {
cancelled = true;
micPcRef.current?.getSenders().forEach((sender) => sender.track?.stop());
if (micWsRef.current) {
micWsRef.current.close();
micWsRef.current = null;
}
if (micPcRef.current) {
micPcRef.current.close();
micPcRef.current = undefined;
}
};
}, [
camera,
connect,
PeerConnection,
pcRef,
videoRef,
playbackEnabled,
microphoneEnabled,
playbackEnabled,
wsURL,
startSignaling,
iceServers,
camera,
onMicrophoneError,
]);
// ios compat
@@ -262,9 +358,7 @@ export default function WebRtcPlayer({
const report = await pcRef.current.getStats();
let bytesReceived = 0;
let timestamp = 0;
let roundTripTime = 0;
let framesReceived = 0;
let framesDropped = 0;
let framesDecoded = 0;
report.forEach((stat) => {
@@ -272,12 +366,8 @@ export default function WebRtcPlayer({
bytesReceived = stat.bytesReceived;
timestamp = stat.timestamp;
framesReceived = stat.framesReceived;
framesDropped = stat.framesDropped;
framesDecoded = stat.framesDecoded;
}
if (stat.type === "candidate-pair" && stat.state === "succeeded") {
roundTripTime = stat.currentRoundTripTime;
}
});
const timeDiff = (timestamp - lastTimestamp) / 1000; // in seconds
@@ -289,12 +379,10 @@ export default function WebRtcPlayer({
setStats?.({
streamType: "WebRTC",
bandwidth: Math.round(bitrate),
latency: roundTripTime,
totalFrames: framesReceived,
droppedFrames: framesDropped,
droppedFrames: undefined,
decodedFrames: framesDecoded,
droppedFrameRate:
framesReceived > 0 ? (framesDropped / framesReceived) * 100 : 0,
droppedFrameRate: undefined,
});
lastBytesReceived = bytesReceived;
@@ -307,7 +395,6 @@ export default function WebRtcPlayer({
setStats?.({
streamType: "-",
bandwidth: 0,
latency: undefined,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
@@ -30,11 +30,14 @@ import ActivityIndicator from "../indicators/activity-indicator";
import useSWR from "swr";
import { LuCheck, LuExternalLink, LuInfo, LuX } from "react-icons/lu";
import { Link } from "react-router-dom";
import { LiveStreamMetadata } from "@/types/live";
import { LivePlayerMode, LiveStreamMetadata } from "@/types/live";
import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
import { Switch } from "@/components/ui/switch";
import { useWebRTCAvailableForStream } from "@/hooks/use-webrtc-availability";
import StreamTechnologySelect from "@/components/player/StreamTechnologySelect";
type CameraStreamingDialogProps = {
camera: string;
@@ -55,7 +58,11 @@ export function CameraStreamingDialog({
setIsDialogOpen,
onSave,
}: CameraStreamingDialogProps) {
const { t } = useTranslation(["components/camera", "components/dialog"]);
const { t } = useTranslation([
"components/camera",
"components/dialog",
"views/live",
]);
const { getLocaleDocUrl } = useDocDomain();
const { data: config } = useSWR<FrigateConfig>("config");
@@ -68,6 +75,8 @@ export function CameraStreamingDialog({
Object.entries(config?.cameras[camera]?.live?.streams || {})[0]?.[1] || "",
);
const [streamType, setStreamType] = useState<StreamType>("smart");
const [playerMode, setPlayerMode] = useState<LivePlayerMode>("mse");
const [forceLowBandwidth, setForceLowBandwidth] = useState(false);
const [compatibilityMode, setCompatibilityMode] = useState(false);
// metadata
@@ -79,13 +88,39 @@ export function CameraStreamingDialog({
[config, streamName],
);
const cameraMetadata = streamName ? streamMetadata?.[streamName] : undefined;
// Fetch the go2rtc stream metadata directly when the parent didn't provide it
// so codec/availability detection works regardless of caller
const { data: fetchedMetadata } = useSWR<LiveStreamMetadata>(
isRestreamed && streamName && !streamMetadata?.[streamName]
? `go2rtc/streams/${streamName}`
: null,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 60000,
},
);
const cameraMetadata = streamName
? (streamMetadata?.[streamName] ?? fetchedMetadata)
: undefined;
const { audioOutput: supportsAudioOutput } = useMemo(
() => detectCameraAudioFeatures(cameraMetadata),
[cameraMetadata],
);
const { available: isWebRTCAvailable, reason: webRTCUnavailableReason } =
useWebRTCAvailableForStream(cameraMetadata, streamName);
// The chosen technology resolved for the currently selected stream, WITHOUT
// rewriting the saved choice
const resolvedPlayerMode = useMemo<LivePlayerMode>(
() => (playerMode === "webrtc" && !isWebRTCAvailable ? "mse" : playerMode),
[playerMode, isWebRTCAvailable],
);
// handlers
useEffect(() => {
@@ -107,10 +142,15 @@ export function CameraStreamingDialog({
setStreamName(streamExists ? streamNameFromSettings : firstStreamEntry);
setStreamType(cameraSettings.streamType || "smart");
const savedPlayerMode = cameraSettings.playerMode || "mse";
setPlayerMode(savedPlayerMode === "jsmpeg" ? "mse" : savedPlayerMode);
setForceLowBandwidth(savedPlayerMode === "jsmpeg");
setCompatibilityMode(cameraSettings.compatibilityMode || false);
} else {
setStreamName(firstStreamEntry);
setStreamType("smart");
setPlayerMode("mse");
setForceLowBandwidth(false);
setCompatibilityMode(false);
}
}, [groupStreamingSettings, camera, config]);
@@ -122,6 +162,7 @@ export function CameraStreamingDialog({
[camera]: {
streamName,
streamType,
playerMode: forceLowBandwidth ? "jsmpeg" : playerMode,
compatibilityMode,
playAudio: groupStreamingSettings?.[camera]?.playAudio ?? false,
volume: groupStreamingSettings?.[camera]?.volume ?? 1,
@@ -138,6 +179,8 @@ export function CameraStreamingDialog({
camera,
streamName,
streamType,
playerMode,
forceLowBandwidth,
compatibilityMode,
setIsDialogOpen,
onSave,
@@ -162,10 +205,15 @@ export function CameraStreamingDialog({
setStreamName(streamExists ? streamNameFromSettings : firstStreamEntry);
setStreamType(cameraSettings.streamType || "smart");
const savedPlayerMode = cameraSettings.playerMode || "mse";
setPlayerMode(savedPlayerMode === "jsmpeg" ? "mse" : savedPlayerMode);
setForceLowBandwidth(savedPlayerMode === "jsmpeg");
setCompatibilityMode(cameraSettings.compatibilityMode || false);
} else {
setStreamName(firstStreamEntry);
setStreamType("smart");
setPlayerMode("mse");
setForceLowBandwidth(false);
setCompatibilityMode(false);
}
@@ -234,7 +282,11 @@ export function CameraStreamingDialog({
<Label htmlFor="stream" className="text-right">
{t("group.camera.setting.stream")}
</Label>
<Select value={streamName} onValueChange={setStreamName}>
<Select
value={streamName}
onValueChange={setStreamName}
disabled={forceLowBandwidth}
>
<SelectTrigger className="">
<SelectValue
placeholder={t("group.camera.setting.placeholder")}
@@ -250,46 +302,86 @@ export function CameraStreamingDialog({
),
)}
</SelectContent>
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("group.camera.setting.audioIsAvailable")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>{t("group.camera.setting.audioIsUnavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("group.camera.setting.audio.tips.title")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/live")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
{!forceLowBandwidth && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("group.camera.setting.audioIsAvailable")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>
{t("group.camera.setting.audioIsUnavailable")}
</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("group.camera.setting.audio.tips.title")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/live")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
)}
</Select>
</div>
)}
{isRestreamed &&
Object.entries(config?.cameras[camera].live.streams).length > 0 && (
<div className="flex flex-col items-start gap-2">
<Label htmlFor="streaming-technology" className="text-right">
{t("stream.mode", { ns: "views/live" })}
</Label>
<StreamTechnologySelect
value={resolvedPlayerMode}
onValueChange={setPlayerMode}
isWebRTCAvailable={isWebRTCAvailable}
webRTCUnavailableReason={webRTCUnavailableReason}
disabled={forceLowBandwidth}
/>
<p className="text-sm text-muted-foreground">
{t("stream.technology.description", { ns: "views/live" })}
</p>
</div>
)}
{isRestreamed &&
Object.entries(config?.cameras[camera].live.streams).length > 0 && (
<div className="flex flex-col items-start gap-2">
<div className="flex w-full items-center justify-between">
<Label htmlFor="force-low-bandwidth" className="cursor-pointer">
{t("stream.lowBandwidth.force.label", { ns: "views/live" })}
</Label>
<Switch
id="force-low-bandwidth"
checked={forceLowBandwidth}
onCheckedChange={setForceLowBandwidth}
/>
</div>
<p className="text-sm text-muted-foreground">
{t("stream.lowBandwidth.force.desc", { ns: "views/live" })}
</p>
</div>
)}
<div className="flex flex-col items-start gap-2">
<Label htmlFor="streaming-method" className="text-right">
{t("group.camera.setting.streamMethod.label")}