Go2RTC WebRTC Streaming
ZoneMinder can publish a go2rtc endpoint alongside its own MJPEG streamer (ZMS). When it does, zmNinjaNg plays live video through go2rtc as an H.264/H.265 stream over MSE or WebRTC instead of a JPEG-per-frame MJPEG stream. That cuts latency and moves decode to hardware, so a montage tile no longer repaints a full JPEG per frame.
MJPEG stays on every path: it is the fallback for every way go2rtc can fail, and it is the picture on screen while go2rtc is still connecting. The whole feature hangs off one per-profile toggle (Settings, Live Streaming, “Enable WebRTC/HLS/MSE”); everything below is what happens once that toggle is on.
The end-to-end trace of a tile starting a go2rtc stream is Flow 8 in Call Flows. This chapter is the reference behind that trace.
Choosing go2rtc or MJPEG
LiveMonitorPlayer is the only component that makes this decision. Montage
tiles, the monitor detail page, the dashboard monitor widget, and MonitorCard
all render it rather than an <img> of their own, so the choice is made once.
// components/monitors/LiveMonitorPlayer.tsx
const canUseWebRTC =
userStreamingPreference !== 'mjpeg' &&
monitor.Go2RTCEnabled === true &&
!!profile?.go2rtcUrl;
const method = canUseWebRTC ? 'webrtc' : 'mjpeg';
The profile setting (or a per-monitor override) must not force MJPEG, the
monitor must have Go2RTCEnabled set in ZoneMinder, and the profile must
carry a go2rtcUrl; all three have to hold. There is no separate availability
boolean: the truthiness of go2rtcUrl is the check.
This runs inside useMemo, a React hook that recomputes a value only when its
listed inputs change, so switching a monitor’s override recomputes the method but
an unrelated re-render does not. The decision is logged once per
monitor-and-method pair (log.videoPlayer('Streaming: WebRTC' | 'Streaming: MJPEG')),
which is the first line to grep for when a monitor plays the wrong way.
LiveMonitorPlayer owns the streaming decision and its watchdogs; see
Component Architecture for how it sits among the other monitor
components.
Where go2rtcUrl comes from
Nothing probes the network for go2rtc. There is no port scan and no
services/discovery.ts involvement. The URL is read from ZoneMinder’s own
config during profile bootstrap.
bootstrapGo2RTCPath runs as part of performBootstrap when a profile is
switched to or rehydrated from storage. It calls fetchGo2RTCPath(), which
reads the ZM_GO2RTC_PATH config value from the API:
// api/auth.ts
const response = await client.get<Go2RTCPathResponse>(
'/configs/viewByName/ZM_GO2RTC_PATH.json'
);
const validated = Go2RTCPathResponseSchema.parse(response.data);
const go2rtcPath = validated.config.Value;
An empty or missing value means go2rtc is not configured, and
bootstrapGo2RTCPath clears profile.go2rtcUrl if it was set previously. A
present value is written to the profile, and every player picks it up from there.
Because the value comes from the server, an admin who turns go2rtc on in
ZoneMinder does not need the user to type a URL, and one who turns it off
demotes every tile to MJPEG on the next profile bootstrap. See
API and Data Fetching for the config-fetch layer and
Application Lifecycle for when bootstrap runs.
WebSocket URL and stream name
getGo2RTCWebSocketUrl in lib/zm/url-builder.ts turns the configured path
into a signaling URL. It takes the path, the monitor id, and a channel:
getGo2RTCWebSocketUrl(
go2rtcPath: string, // ZM_GO2RTC_PATH, e.g. 'http://zm.example.com:1984'
monitorId: string,
channel: string | number = 0, // 0 = primary, 1 = secondary
options: { token?: string; expectedHost?: string } = {}
): string
getGo2RTCWebSocketUrl('http://zm.example.com:1984', '1', 0)
// 'ws://zm.example.com:1984/ws?src=1_0'
getGo2RTCWebSocketUrl('http://zm.example.com:1984/go2rtc', '5', 1)
// 'ws://zm.example.com:1984/go2rtc/ws?src=5_1'
http becomes ws and https becomes wss. /ws is appended to
whatever pathname ZM_GO2RTC_PATH already has, so a reverse-proxied go2rtc at
/go2rtc keeps its prefix. And the stream name is always
{monitorId}_{channel}, matching ZoneMinder’s own go2rtc naming.
The channel comes straight from the monitor:
// components/monitors/LiveMonitorPlayer.tsx
channel: monitor.StreamChannel || 0,
StreamChannel is a nullable string on MonitorSchema. It picks which of a
camera’s streams go2rtc should serve (primary versus a lower-resolution
substream). Monitors without one fall back to channel 0. Nothing reads
RTSPStreamName; that field exists on the schema because ZoneMinder returns
it, and the stream name is derived from the id and channel alone.
Protocol negotiation runs in parallel
There is no ladder. Nothing tries WebRTC, waits for it to fail, then tries MSE.
useGo2RTCStream joins the configured protocols into a single comma-separated
string and hands it to the vendored video-rtc custom element once:
// hooks/useGo2RTCStream.ts
const modeString = protocols.join(','); // default: 'webrtc,mse,hls'
// ...
videoRtc.mode = modeString;
videoRtc.media = 'video,audio';
videoRtc.background = true;
When the WebSocket opens, video-rtc’s onopen() starts every compatible
protocol at once over that one socket. Reading the vendored source
(lib/vendor/go2rtc/video-rtc.js), the rules are:
At most one of MSE, HLS, or MP4 is started, in that priority order. MSE requires
MediaSourceorManagedMediaSource; HLS requires the<video>to report it can playapplication/vnd.apple.mpegurl; the MP4 branch has no capability check at all. If no branch matches, none of the three starts. HLS is a fallback for browsers without MSE, not a step after WebRTC fails.WebRTC is started as well, alongside it, whenever
webrtcis in the mode string andRTCPeerConnectionexists.MJPEG is started only when nothing else started. If something else did start,
mjpegin the mode string instead registers a handler that starts MJPEG when the server reports an error for the first started mode. Either way it does not apply here: the app never putsmjpegin the mode string. MJPEG fallback isLiveMonitorPlayer’s job, not the element’s.
Both then race to produce video. If the peer connection delivers a video track,
onpcvideo() scores WebRTC against MSE (video plus audio beats video alone,
H.265 beats H.264, and a tie goes to WebRTC). The winner keeps playing and the
loser is closed. When WebRTC wins, the WebSocket itself is closed because media
now flows over the peer connection, a fact that makes teardown harder than
closing a socket (see below). If WebRTC never produces a track, MSE simply keeps
playing.
The protocol the UI reports is not necessarily the winner:
// hooks/useGo2RTCStream.ts, inside the wrapped onopen
const modes = originalOnopen();
setState('connected');
if (modes && modes.length > 0) {
setActiveProtocol(modes[0] as StreamingProtocol);
}
modes[0] is whichever of MSE/HLS/MP4 started, because video-rtc pushes it
before WebRTC. That is only the protocol attempted first. The race is decided
later, in onpcvideo, which adopts the WebRTC stream and leaves pcState
open when WebRTC wins, or closes the peer connection when it loses. The hook’s
onpcvideo wrapper reads that flag and promotes activeProtocol to
webrtc, so the badge names the protocol carrying the frames rather than the
one that started first. The badge is rendered only when the
showProtocolLabel setting is on.
STUN servers
video-rtc.js hardcodes stun.cloudflare.com and stun.l.google.com
into the browser RTCPeerConnection. useGo2RTCStream overrides
videoRtc.pcConfig.iceServers on the element instance before the connection
starts (the vendored file is left unchanged). onwebrtc() reads pcConfig
lazily when it builds the peer connection, so the override wins.
videoRtc.pcConfig = {
...videoRtc.pcConfig,
iceServers: useStun ? GO2RTC_STUN_SERVERS : [],
};
The per-profile webrtcUseStun setting selects the list: off (the default)
applies []; on applies GO2RTC_STUN_SERVERS from
lib/zmninja-ng-constants.ts. It is off by default because LAN and portal/VPN
reach go2rtc on host candidates, so STUN is never on the path. An empty list also
stops Chromium from starting a STUN hostname lookup that it cancels when
video-rtc tears the peer connection down (the WebRTC/MSE race, or a montage tile
rotating), which otherwise logs Failed to resolve address for stun...
errorcode: -105 even though DNS resolves. Turn it on only to reach go2rtc
directly over the public internet without a portal/VPN, where NAT traversal needs
a server-reflexive candidate.
Starting and stopping the connection
useGo2RTCStream owns the element’s lifetime, using two React mechanisms: a
ref and an effect.
A ref is a mutable box React hands back on every render, and writing to it
does not re-render anything. The hook uses containerRef to reach the real
<div> in the DOM (video-rtc is a custom element, appended by hand rather
than rendered as JSX), videoRtcRef to hold the element across renders, and
mountedRef to know whether the component is still alive. An effect is a
function React runs after render and again whenever its dependencies change,
returning a cleanup function it runs before the next pass and on unmount. Both are
covered in React Fundamentals.
// hooks/useGo2RTCStream.ts, connect effect (simplified: logging removed)
useEffect(() => {
mountedRef.current = true;
if (!enabled) { stop(); return; }
if (!go2rtcUrl || !monitorId || !containerRef.current) return;
connectTimeoutRef.current = setTimeout(() => {
connectTimeoutRef.current = null;
if (mountedRef.current) connect();
}, GO2RTC_CONNECT_DELAY_MS);
return () => {
mountedRef.current = false;
cleanup();
};
}, [enabled, go2rtcUrl, monitorId, token, protocolsKey]);
GO2RTC_CONNECT_DELAY_MS is 100 ms. The delay exists because React’s Strict
Mode, in development only, mounts every component, unmounts it, and mounts it
again to surface effects that are not cleanup-safe. Without the delay each tile
would open a WebSocket, immediately tear it down, and open another. With it, the
first mount’s timer is cleared by its own cleanup before it ever fires. Montage
tiles are not staggered beyond this one shared delay; they all connect together.
protocolsKey is protocols.join(',') rather than the array itself, because
an array in a dependency list is compared by identity. LiveMonitorPlayer
passes rawSettings?.webrtcProtocols, and when a profile has never set it the
hook’s own default parameter (protocols = ['webrtc', 'mse', 'hls']) mints a
fresh array on every render. Keying on the string stops a harmless re-render from
tearing the stream down and rebuilding it.
Falling back to MJPEG
Everything in this section lives in LiveMonitorPlayer, not in the hook. The
hook reports state; the component decides when go2rtc has lost.
MJPEG shows first. While go2rtc is selected but has produced no decoded
frames yet, the component renders the MJPEG <img> as the visible picture with
a blinking ellipsis badge over it (data-testid="mse-connecting-badge"):
const showMjpegPlaceholder = effectiveStreamingMethod === 'webrtc' && !hasVideoFrames;
The tile is never blank. When decoded frames appear (videoWidth > 0) the
component swaps to the <video> and drops the badge. A poll every
GO2RTC_FRAME_POLL_MS (250 ms) makes that swap happen the instant frames
arrive rather than at the timeout deadline. derivePlayerViewState() folds
these signals into one named state (connecting, mjpeg-placeholder,
mse-playing, mjpeg, no-video) that the render branches read.
go2rtc loses in three ways, and each latches go2rtcFailed, which flips
effectiveStreamingMethod to mjpeg for this player:
The hook reported an error. Any
state === 'error'demotes on the next effect run. Usually the WebSocket never opened:video-rtc’sonclosefires whilewsConnectedRefis still false. The hook also sets that state when the container ref is missing at connect time, and when building the element or its WebSocket URL throws.Connected, but no frames. After the hook reports
connected, a timer ofGO2RTC_VIDEO_TIMEOUT_S(15 seconds) checks the<video>for real dimensions. No dimensions means demote. Dimensions but a paused element means autoplay was blocked, so it callsplay()and treats the stream as good.It froze after playing. The first two checks stop running once frames arrive, so a stream that stalls later (source hiccup, MSE buffer underrun, missing keyframe, silent WebSocket stall) would sit frozen forever. A liveness check every
GO2RTC_LIVENESS_CHECK_MS(3000 ms) watchescurrentTimeadvance andreadyState.GO2RTC_FREEZE_THRESHOLD_S(7 seconds) without advance counts as a freeze: the hook retries, up toGO2RTC_MAX_FREEZE_RETRIES(2) times, and then demotes to MJPEG. Playing healthily forGO2RTC_FREEZE_RESET_S(60 seconds) after a freeze resets the counter, so one hiccup an hour does not permanently cost a monitor its hardware-decoded stream.
Failure cache
A monitor that fails go2rtc is recorded in a module-level Map and skipped for
GO2RTC_RETRY_INTERVAL_MIN (5 minutes, declared at the top of
LiveMonitorPlayer.tsx). Being module-level, it is shared by every
LiveMonitorPlayer instance and survives in-app navigation. An entry goes away
when isGo2rtcCachedFailure next reads it past its five-minute TTL and deletes
it, when the recovery paths below delete it, or on a full reload. This is what
stops a montage of twenty monitors from re-attempting a broken stream on every
tile, every time you open the page.
The single-monitor detail view passes bypassGo2rtcFailureCache so it neither
reads nor writes this cache. Montage opens many connections at once and some fail
under that load, marking those monitors failed. Without the bypass the detail view
inherited that and skipped go2rtc, showing the loading placeholder until a reload.
With the bypass the detail view always attempts go2rtc (a single connection
succeeds), and a failure there falls back to MJPEG locally without poisoning the
montage cache.
Recovery
Two things clear a latched failure. Toggling a monitor back to go2rtc deletes its
cache entry and retries at once. And useVisibilityResume, when the page
returns from the background, resets the freeze counters, clears the latch, and
nudges a retry: the browser suspends video in a backgrounded tab, and the freeze
watchdog would otherwise have spent its retry budget on a stream that was never
actually broken.
Tearing the stream down
Leaving a live view (montage or single monitor) must stop the stream.
connect() sets videoRtc.background = true so a tile keeps streaming when
the page is hidden: the flag makes oninit skip the visibilitychange
listener that would otherwise disconnect a backgrounded tab. But the same flag
short-circuits the element’s own disconnectedCallback, so teardown is the
hook’s job. WebRTC needs extra care: once it wins negotiation, media flows over
the RTCPeerConnection and the WebSocket is already closed, so closing the
socket alone does nothing.
Scroll-out pausing is a separate flag. visibilityThreshold defaults to 0
and the app never sets it, so tiles keep streaming off-screen too.
cleanup() calls destroyVideoRtc(), which:
flips
backgroundback to false so DOM removal also triggers native teardown;cancels the element’s
reconnectTID/disconnectTIDtimers so no scheduled reconnect can re-open a socket after the view is gone;calls
ondisconnect()(closes the WebSocket and peer connection) and stops the video’sMediaStreamtracks defensively;removes the element from the DOM.
cleanup() also sweeps any stray VideoRTC left in the container, and
connect() bails when the hook is no longer mounted, so a late retry()
from the visibility resume or freeze watchdog cannot open a socket that nothing
owns. Without this, tiles unmounted in bulk (navigating away from montage) leak
their connections and keep pulling video in the background.
Settings that affect go2rtc
All of these are profile-scoped in ProfileSettings (stores/settings.ts)
and read through the settings store. See
State Management with Zustand for how the store is subscribed to.
streamingMethod: StreamingMethod'auto' | 'mjpeg', default'auto'. There is no'webrtc'value: the Live Streaming settings switch writes'auto'when on and'mjpeg'when off, and'auto'means “use go2rtc where the monitor and server support it”.monitorStreamingOverrides: Record<string, StreamingMethod>Per-monitor override, written by
MonitorSettingsDialogand read byLiveMonitorPlayerahead of the profile-level setting. Turning go2rtc off for one monitor stores'mjpeg'against its id; turning it back on deletes the entry so the monitor inherits the profile setting again.webrtcProtocols: WebRTCProtocol[]('webrtc' | 'mse' | 'hls')[], default all three. This is the list joined intovideoRtc.mode. The settings UI exposes it as three checkboxes and refuses to save an empty list. Uncheckingwebrtcleaves MSE to carry the stream on its own.webrtcUseStun: booleanDefault false. Covered under STUN servers above.
showProtocolLabel: booleanDefault true. Gates the protocol badge everywhere it renders: the monitor detail page, montage tiles,
MonitorCard, and the dashboard monitor widget. On an MJPEG player it readsMJPEG. On a go2rtc player it reads the started protocol upper-cased (MSE,WEBRTC,HLS), orGo2RTCwhile no protocol has been reported yet.
bypassGo2rtcFailureCache is not a setting. It is a prop, and only
MonitorDetail passes it.
Video element
The hook wraps video-rtc’s oninit to configure the <video> the moment
the element creates it, and wraps onpcvideo to re-apply muting after the
element has picked its winning protocol. When WebRTC wins, onpcvideo calls
play(), whose autoplay-rejection handler sets this.video.muted = true and
retries; an unmuted player would come back muted. Muting is applied at three
points for that reason: on oninit, on onpcvideo, and in an effect when the
muted prop changes.
Native controls are enabled only where showControls is passed, which today is
only MonitorDetail:
// hooks/useGo2RTCStream.ts, inside the wrapped oninit
videoRtc.video.controls = controls;
videoRtc.video.disablePictureInPicture = true;
videoRtc.video.playsInline = true;
if (controls) {
videoRtc.video.setAttribute('controlsList', 'nodownload noplaybackrate');
videoRtc.video.addEventListener('click', (e: Event) => e.stopPropagation());
}
Picture-in-Picture is disabled on the go2rtc <video>, on every platform. The
vendored video-rtc.js already does this in its own oninit; the hook sets
it again after taking the element over. No comment or commit message in the source
records why, so treat the reason as unknown rather than inferring one. It is not a
blanket app policy: the recorded-event player (Mp4EventPlayer) disables PiP
only on Android and drives it through PipContext everywhere else. The click
handler stops propagation so that using the controls in montage does not also
navigate to monitor detail.
Snapshots come from the same element. LiveMonitorPlayer publishes it through
externalMediaRef, and downloadSnapshotFromElement draws an
HTMLVideoElement onto a canvas and exports a JPEG. The canvas is not
cross-origin tainted here: the <video> is fed by a MediaStream or an MSE
buffer, not by a remote URL. A black snapshot means no decoded frames, not CORS.
(wrapWithImageProxyIfNeeded in lib/zm/proxy-utils.ts applies to
URL-based downloads, and only when Platform.shouldUseProxy is true, which is
dev-mode web.) See Shared Services and Reusable Components for the download
service.
Type definitions
On Monitor (api/types.ts, validated by MonitorSchema):
RTSPStreamName: z.string().nullable(),
StreamChannel: z.string().nullable().optional(),
Go2RTCEnabled: z.coerce.boolean().optional().default(false),
RTSP2WebEnabled: z.coerce.boolean().optional().default(false),
JanusEnabled: z.coerce.boolean().optional().default(false),
Only Go2RTCEnabled and StreamChannel are read by the app.
RTSPStreamName, RTSP2WebEnabled, and JanusEnabled are parsed because
ZoneMinder returns them; there is no RTSP2Web or Janus support and none is
planned.
On Profile:
go2rtcUrl?: string; // ZM_GO2RTC_PATH from server config (full URL)
Security
Authentication. getGo2RTCWebSocketUrl accepts a token option and
appends it as a query parameter, but LiveMonitorPlayer does not pass one.
ZoneMinder authenticates the go2rtc WebSocket through credentials embedded in
ZM_GO2RTC_PATH itself, in the form https://user:pass@host/.... The URL
builder deliberately preserves those rather than stripping them, because
stripping them breaks WebRTC streaming on setups that rely on them.
Host guard. hardenGo2RTCUrl logs a warning via log.http when a token
is attached and the go2rtc hostname differs from the configured portal
hostname, so a token cannot quietly be sent to a third-party host.
LiveMonitorPlayer passes expectedHost (the portal’s hostname) for exactly
this check. With no token in play today, the warning does not fire; the guard is
there for when one is.
Secure context. WebRTC requires HTTPS or localhost. A page served over HTTPS
cannot open a plaintext ws:// socket, so an https portal needs an
https ZM_GO2RTC_PATH: the URL builder maps the scheme through, and mixed
content is blocked by the browser, not by the app.
Testing
Unit tests, run with npm test from app/:
app/src/hooks/__tests__/useGo2RTCStream.test.ts: connection lifecycle (idle, connecting, connected), the connect delay with no per-tile stagger,pcConfig.iceServersbeing emptied by default and populated whenuseStunis on, custom protocol order, WebSocket failure producing the error state, and teardown (element removed from the DOM, media tracks stopped, reconnect timers cancelled).app/src/lib/zm/__tests__/url-builder.test.ts:getGo2RTCWebSocketUrl, including scheme mapping, path prefixes, trailing slashes, embedded credentials, and the cross-host token warning.app/src/components/monitors/__tests__/LiveMonitorPlayer.test.tsx: MJPEG error recovery, and failure-cache scoping (the detail view does not inherit a montage-recorded failure; montage tiles do share the cache).app/src/components/monitors/__tests__/derivePlayerViewState.test.ts: the view-state table.
There is no go2rtc e2e feature file. app/tests/features/montage.feature and
monitor-detail.feature exercise LiveMonitorPlayer but assert nothing about
which protocol carried the video, so protocol negotiation and the fallback paths
are covered by unit tests and manual checks only. See Testing Strategy.
Troubleshooting
Every step below is visible in the logs. log.videoPlayer carries the whole
decision path.
A monitor plays MJPEG when it should not. Look for
Streaming: MJPEG and its context object. It logs
monitorGo2RTCEnabled, so a false value there is a ZoneMinder setting, not an
app bug. If that flag is true, check profile.go2rtcUrl: an empty
ZM_GO2RTC_PATH on the server leaves it unset. Then check the profile’s
streamingMethod and any per-monitor override.
It plays MJPEG only in montage. That is the failure cache. Open the monitor’s
detail page (which bypasses the cache) to confirm go2rtc works there, and look
for the log line that recorded the failure: Go2RTC error, falling back to
MJPEG, or Go2RTC connected but no video frames, falling back to MJPEG, or
Go2RTC stream frozen, max retries reached, falling back to MJPEG. The cache
entry expires after 5 minutes, or on a full reload.
The badge reads MSE but I expected WebRTC. Expected. The badge reports the
first protocol video-rtc started, and MSE is started before WebRTC. See
Protocol negotiation runs in parallel.
“Go2RTC WebSocket connection failed”. The socket closed before it opened.
Confirm go2rtc is reachable at ZM_GO2RTC_PATH from the device (not just from
the server), and that a reverse proxy in front of it forwards WebSocket upgrade
headers on the /ws path.
Connected, then falls back after 15 seconds. The socket opened but no frames
decoded. The stream {monitorId}_{channel} must exist in go2rtc’s config; a
monitor whose StreamChannel points at a substream the camera does not publish
produces exactly this.
The stream freezes and recovers, repeatedly. The liveness watchdog is doing
its job. Go2RTC stream frozen, retrying connection logs the reason
(no-advance, readyState, ended, or disconnected) and how long the
video had been stalled.
A downloaded snapshot is black. The <video> had no decoded frames when the
canvas was drawn. Check videoWidth on the element. This is not a CORS problem.
References
video-rtc.js source (vendored at
app/src/lib/vendor/go2rtc/video-rtc.js)