mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 20:38:58 +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:
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IconName } from "@/components/icons/IconPicker";
|
||||
import { TriggerAction, TriggerType } from "./trigger";
|
||||
import { LivePlayerMode } from "./live";
|
||||
|
||||
export interface UiConfig {
|
||||
timezone?: string;
|
||||
@@ -359,6 +360,7 @@ export type StreamType = "no-streaming" | "smart" | "continuous";
|
||||
export type CameraStreamingSettings = {
|
||||
streamName: string;
|
||||
streamType: StreamType;
|
||||
playerMode?: LivePlayerMode;
|
||||
compatibilityMode: boolean;
|
||||
playAudio: boolean;
|
||||
volume: number;
|
||||
@@ -519,6 +521,11 @@ export interface FrigateConfig {
|
||||
streams: Record<string, string | string[]>;
|
||||
webrtc: {
|
||||
candidates: string[];
|
||||
ice_servers?: {
|
||||
urls: string | string[];
|
||||
username?: string;
|
||||
credential?: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+10
-1
@@ -33,6 +33,16 @@ export type LiveStreamMetadata = {
|
||||
|
||||
export type LivePlayerError = "stalled" | "startup" | "mse-decode";
|
||||
|
||||
export type TwoWayTalkError = "microphone" | "refused";
|
||||
|
||||
export type WebRTCUnavailableReason =
|
||||
| "browser"
|
||||
| "not-configured"
|
||||
| "unreachable"
|
||||
| "video-codec"
|
||||
| "audio-codec"
|
||||
| "checking";
|
||||
|
||||
export type AudioState = Record<string, boolean>;
|
||||
export type StatsState = Record<string, boolean>;
|
||||
export type VolumeState = Record<string, number>;
|
||||
@@ -40,7 +50,6 @@ export type VolumeState = Record<string, number>;
|
||||
export type PlayerStatsType = {
|
||||
streamType: string;
|
||||
bandwidth: number;
|
||||
latency: number | undefined;
|
||||
totalFrames: number;
|
||||
droppedFrames: number | undefined;
|
||||
decodedFrames: number | undefined;
|
||||
|
||||
@@ -174,7 +174,7 @@ export function detectCameraAudioFeatures(
|
||||
|
||||
const twoWayAudio =
|
||||
(!requireSecureContext || window.isSecureContext) &&
|
||||
metadata.producers.find(
|
||||
(metadata.producers ?? []).find(
|
||||
(prod) =>
|
||||
prod.medias &&
|
||||
prod.medias.find((media) => media.includes("audio, sendonly")) !=
|
||||
@@ -182,7 +182,7 @@ export function detectCameraAudioFeatures(
|
||||
) != undefined;
|
||||
|
||||
const audioOutput =
|
||||
metadata.producers.find(
|
||||
(metadata.producers ?? []).find(
|
||||
(prod) =>
|
||||
prod.medias &&
|
||||
prod.medias.find((media) => media.includes("audio, recvonly")) !=
|
||||
@@ -195,6 +195,62 @@ export function detectCameraAudioFeatures(
|
||||
};
|
||||
}
|
||||
|
||||
const MEDIA_DIRECTIONS = new Set(["sendonly", "recvonly", "sendrecv"]);
|
||||
|
||||
// "sendonly" is the two-way-talk backchannel, not playback audio.
|
||||
const PLAYBACK_DIRECTIONS = new Set(["recvonly", "sendrecv"]);
|
||||
|
||||
// Parses a go2rtc media line: "audio, recvonly, OPUS/48000/2" -> ["OPUS"],
|
||||
// stripping the "/clockrate[/channels]" suffix.
|
||||
function codecsFromMedia(media: string): string[] {
|
||||
const parts = media.split(",").map((p) => p.trim());
|
||||
return parts
|
||||
.slice(1)
|
||||
.filter((p) => !MEDIA_DIRECTIONS.has(p.toLowerCase()))
|
||||
.map((p) => p.split("/")[0].toUpperCase());
|
||||
}
|
||||
|
||||
function mediaDirection(media: string): string | undefined {
|
||||
return media
|
||||
.split(",")
|
||||
.map((p) => p.trim().toLowerCase())
|
||||
.find((p) => MEDIA_DIRECTIONS.has(p));
|
||||
}
|
||||
|
||||
function codecsForKind(
|
||||
metadata: LiveStreamMetadata | null | undefined,
|
||||
kind: "video" | "audio",
|
||||
directions?: Set<string>,
|
||||
): string[] {
|
||||
if (!metadata) return [];
|
||||
const codecs = new Set<string>();
|
||||
for (const producer of metadata.producers ?? []) {
|
||||
for (const media of producer.medias ?? []) {
|
||||
if (!media.trim().toLowerCase().startsWith(kind)) continue;
|
||||
if (directions) {
|
||||
const direction = mediaDirection(media);
|
||||
if (!direction || !directions.has(direction)) continue;
|
||||
}
|
||||
codecsFromMedia(media).forEach((c) => codecs.add(c));
|
||||
}
|
||||
}
|
||||
return Array.from(codecs);
|
||||
}
|
||||
|
||||
export function getStreamVideoCodecs(
|
||||
metadata: LiveStreamMetadata | null | undefined,
|
||||
): string[] {
|
||||
return codecsForKind(metadata, "video");
|
||||
}
|
||||
|
||||
// go2rtc exposes a WebRTC-capable playback codec only when one is configured
|
||||
// (e.g. an opus transcode) or the camera streams G.711 natively.
|
||||
export function getPlaybackAudioCodecs(
|
||||
metadata: LiveStreamMetadata | null | undefined,
|
||||
): string[] {
|
||||
return codecsForKind(metadata, "audio", PLAYBACK_DIRECTIONS);
|
||||
}
|
||||
|
||||
const REPLAY_CAMERA_PREFIX = "_replay_";
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ export type TransferSection = "layouts" | "streaming" | "preferences";
|
||||
const cameraStreamingSettingsSchema = z.object({
|
||||
streamName: z.string(),
|
||||
streamType: z.enum(["no-streaming", "smart", "continuous"]),
|
||||
playerMode: z.enum(["mse", "webrtc", "jsmpeg"]).optional().catch(undefined),
|
||||
compatibilityMode: z.boolean(),
|
||||
playAudio: z.boolean(),
|
||||
volume: z.number(),
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
|
||||
/**
|
||||
* Performs a single real WebRTC handshake against go2rtc to verify that a
|
||||
* media connection can actually be established (validates candidates, port
|
||||
* 8555 reachability, and STUN/TURN end-to-end). Result is cached per page
|
||||
* session via a module-level promise.
|
||||
*/
|
||||
|
||||
export type WebRTCProbeResult = {
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
let probePromise: Promise<WebRTCProbeResult> | null = null;
|
||||
|
||||
function describeError(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
function runProbe(
|
||||
testStream: string,
|
||||
iceServers: RTCIceServer[],
|
||||
timeoutMs: number,
|
||||
): Promise<WebRTCProbeResult> {
|
||||
return new Promise<WebRTCProbeResult>((resolve) => {
|
||||
let settled = false;
|
||||
const wsURL = `${baseUrl.replace(/^http/, "ws")}live/webrtc/api/ws?src=${testStream}`;
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
bundlePolicy: "max-bundle",
|
||||
iceServers,
|
||||
});
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
const cleanup = (result: WebRTCProbeResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
ws?.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
pc.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const fail = (detail: string) => cleanup({ ok: false, detail });
|
||||
|
||||
const timer = setTimeout(
|
||||
() => fail(`no ICE connection within ${timeoutMs}ms`),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
const state = pc.iceConnectionState;
|
||||
if (state === "connected" || state === "completed") {
|
||||
cleanup({ ok: true });
|
||||
} else if (state === "failed" || state === "closed") {
|
||||
fail(`ICE connection state: ${state}`);
|
||||
}
|
||||
};
|
||||
|
||||
pc.addTransceiver("video", { direction: "recvonly" });
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsURL);
|
||||
} catch (err) {
|
||||
fail(`WebSocket to go2rtc could not be opened: ${describeError(err)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
ws.addEventListener("error", () => fail("WebSocket to go2rtc errored"));
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
pc.addEventListener("icecandidate", (ev) => {
|
||||
if (!ev.candidate || !ws) 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,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((err) =>
|
||||
fail(`failed to create the local offer: ${describeError(err)}`),
|
||||
);
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
let msg: { type: string; value: string };
|
||||
try {
|
||||
msg = JSON.parse((ev as MessageEvent).data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type === "webrtc/candidate") {
|
||||
pc.addIceCandidate({ candidate: msg.value, sdpMid: "0" }).catch((err) =>
|
||||
fail(`remote ICE candidate rejected: ${describeError(err)}`),
|
||||
);
|
||||
} else if (msg.type === "webrtc/answer") {
|
||||
pc.setRemoteDescription({ type: "answer", sdp: msg.value }).catch(
|
||||
(err) => fail(`remote answer rejected: ${describeError(err)}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function probeWebRTCAvailability(
|
||||
testStream: string,
|
||||
iceServers: RTCIceServer[],
|
||||
timeoutMs: number = 5000,
|
||||
): Promise<WebRTCProbeResult> {
|
||||
if (!probePromise) {
|
||||
probePromise = runProbe(testStream, iceServers, timeoutMs);
|
||||
}
|
||||
return probePromise;
|
||||
}
|
||||
|
||||
/** Clears the cached probe result (e.g. when go2rtc config changes). */
|
||||
export function resetWebRTCProbe(): void {
|
||||
probePromise = null;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { WebRTCUnavailableReason } from "@/types/live";
|
||||
|
||||
/**
|
||||
* Helpers for detecting what the current browser can do over WebRTC.
|
||||
* Used to gate the WebRTC live streaming option (e.g. H.265 is only
|
||||
* decodable over WebRTC on Chrome 136+ / Safari 18+ with HEVC hardware).
|
||||
*/
|
||||
|
||||
const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
|
||||
{ urls: "stun:stun.l.google.com:19302" },
|
||||
];
|
||||
|
||||
type ConfiguredIceServers = FrigateConfig["go2rtc"]["webrtc"]["ice_servers"];
|
||||
|
||||
/** ICE servers for browser peer connections, falling back to public STUN. */
|
||||
export function webRTCIceServers(
|
||||
configured: ConfiguredIceServers,
|
||||
): RTCIceServer[] {
|
||||
if (!configured?.length) {
|
||||
return DEFAULT_ICE_SERVERS;
|
||||
}
|
||||
|
||||
return configured.map((server) => ({
|
||||
urls: server.urls,
|
||||
username: server.username,
|
||||
credential: server.credential,
|
||||
}));
|
||||
}
|
||||
|
||||
export function browserSupportsWebRTC(): boolean {
|
||||
return typeof window !== "undefined" && "RTCPeerConnection" in window;
|
||||
}
|
||||
|
||||
/** Codec aliases that refer to the same underlying codec. */
|
||||
const CODEC_ALIASES: Record<string, string> = {
|
||||
HEVC: "H265",
|
||||
AVC: "H264",
|
||||
};
|
||||
|
||||
function normalizeCodec(codec: string): string {
|
||||
const upper = codec.toUpperCase();
|
||||
return CODEC_ALIASES[upper] ?? upper;
|
||||
}
|
||||
|
||||
/** The set of video codec names the browser can receive over WebRTC. */
|
||||
export function browserWebRTCVideoCodecs(): string[] {
|
||||
if (
|
||||
typeof RTCRtpReceiver === "undefined" ||
|
||||
typeof RTCRtpReceiver.getCapabilities !== "function"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const caps = RTCRtpReceiver.getCapabilities("video");
|
||||
if (!caps) return [];
|
||||
|
||||
const codecs = new Set<string>();
|
||||
for (const codec of caps.codecs) {
|
||||
// mimeType is like "video/H264"; ignore infra codecs (rtx, red, ulpfec)
|
||||
const name = codec.mimeType.split("/")[1]?.toUpperCase();
|
||||
if (!name || ["RTX", "RED", "ULPFEC", "FLEXFEC-03"].includes(name)) {
|
||||
continue;
|
||||
}
|
||||
codecs.add(normalizeCodec(name));
|
||||
}
|
||||
return Array.from(codecs);
|
||||
}
|
||||
|
||||
export function browserSupportsWebRTCVideoCodec(codec: string): boolean {
|
||||
return browserWebRTCVideoCodecs().includes(normalizeCodec(codec));
|
||||
}
|
||||
|
||||
const DOCS_URL =
|
||||
"https://docs.frigate.video/configuration/live/#selecting-a-streaming-technology";
|
||||
|
||||
// Availability is consumed by several components at once, so without this the
|
||||
// same fact prints once per consumer.
|
||||
const loggedMessages = new Set<string>();
|
||||
|
||||
/**
|
||||
* Logs why WebRTC is unavailable, supplementing the short message shown inline
|
||||
* in the technology selector with the underlying detail.
|
||||
*
|
||||
* @param scope - Stream name for per-stream reasons, omitted for global ones
|
||||
* @param reason - The reason surfaced in the UI
|
||||
* @param description - The detail the UI message leaves out
|
||||
*/
|
||||
export function logWebRTCUnavailable(
|
||||
scope: string | undefined,
|
||||
reason: WebRTCUnavailableReason,
|
||||
description: string,
|
||||
): void {
|
||||
const message = `${scope ? `${scope} - ` : ""}WebRTC unavailable '${reason}': ${description} See the documentation: ${DOCS_URL}`;
|
||||
|
||||
if (loggedMessages.has(message)) {
|
||||
return;
|
||||
}
|
||||
loggedMessages.add(message);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
/** Clears the emitted messages (e.g. when go2rtc config changes). */
|
||||
export function resetWebRTCUnavailableLog(): void {
|
||||
loggedMessages.clear();
|
||||
}
|
||||
@@ -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]}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user