feat: Add experimental rewrite of go2rtc live provider (MSE/WebRTC/MP4/MJPEG) (#2580)

- Closes #2556
- Closes #2450

**Key intended features:**
 - go2rtc compatible
- 100% test coverage to significantly improve ability to test, maintain
and work around browser weirdnesses (e.g. Safari).
 - Written from the ground up in the style of the rest of the project.

**To use:**
 - Change `live_provider` from `go2rtc` to `go2rtc-experimental`.
This commit is contained in:
Dermot Duffy
2026-07-14 14:22:41 -07:00
committed by GitHub
parent 5662e48c22
commit c02c692f68
125 changed files with 9608 additions and 807 deletions
+11 -6
View File
@@ -25,15 +25,17 @@ export const mayHaveAudio = (video: HTMLVideoElement & AudioProperties): boolean
/**
* Determine if audio is available for a go2rtc stream.
* @param pc The RTCPeerConnection (for WebRTC streams).
* @param mseCodecs The negotiated MSE codecs string (for MSE streams).
* @param video The video element (fallback for browser-based detection).
* @param options.pc The RTCPeerConnection (for WebRTC streams).
* @param options.mseCodecs The negotiated MSE codecs string (for MSE streams).
* @returns True if audio is available.
*/
export const hasAudio = (
video: HTMLVideoElement & AudioProperties,
pc?: RTCPeerConnection | null,
mseCodecs?: string,
options?: {
pc?: RTCPeerConnection | null;
mseCodecs?: string | null;
},
): boolean => {
// For WebRTC: Check if there's an audio receiver with an active track. We
// check that the track is not muted because muted means no media data is
@@ -43,15 +45,17 @@ export const hasAudio = (
// (e.g. WebRTC failed, fell back to MSE) will have receivers with muted
// tracks that don't reflect actual media availability.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2417
if (pc && pc.connectionState === 'connected') {
const receivers = pc.getReceivers();
if (options?.pc && options.pc.connectionState === 'connected') {
const receivers = options.pc.getReceivers();
if (receivers.length > 0) {
return receivers.some(
(receiver) => receiver.track?.kind === 'audio' && !receiver.track?.muted,
);
}
}
// For MSE: Check negotiated codecs for audio codecs
const mseCodecs = options?.mseCodecs;
if (mseCodecs) {
return (
mseCodecs.includes('mp4a') ||
@@ -59,6 +63,7 @@ export const hasAudio = (
mseCodecs.includes('flac')
);
}
// Fallback to browser-based detection (unreliable in Chrome)
return mayHaveAudio(video);
};
-4
View File
@@ -352,10 +352,6 @@ export const ignoreFunctionIdentity = (a: unknown, b: unknown): boolean | undefi
? typeof a === 'function' && typeof b === 'function'
: undefined;
export const convertHTTPAdressToWebsocket = (url: string): string => {
return url.replace(/^http/i, 'ws');
};
export const forceReflow = (element: HTMLElement): void => {
// Force reflow by measuring the height.
void element.offsetHeight;
+29
View File
@@ -0,0 +1,29 @@
// A monotonic counter for guarding against stale asynchronous results. Snapshot
// the current generation before an `await`; afterwards, `isCurrent()` reports
// whether anything has invalidated that snapshot since (a reset, a teardown, or
// a newer operation), so a superseded result can be dropped.
export class Generation {
private _value = 0;
// Invalidate all outstanding snapshots (e.g. on reset or teardown).
public invalidate(): void {
this._value++;
}
// Start a new latest-wins operation -- invalidating any outstanding snapshot --
// and return its token.
public next(): number {
return ++this._value;
}
// Snapshot the current generation to compare after an await.
public current(): number {
return this._value;
}
// Whether the token is still current (nothing has invalidated it since it was
// taken).
public isCurrent(token: number): boolean {
return token === this._value;
}
}
@@ -0,0 +1,71 @@
/**
* Runs an async operation for the latest submitted value, one run at a time.
*
* While a run is in flight, only the most recently submitted value is kept;
* values submitted in between are dropped. The kept value runs when the current
* run finishes, so the operation always converges on the newest input without
* running more than once at a time.
*
* Each `submit` returns a promise that resolves after the next run completes, so
* a caller can wait for a run to have happened without tracking which value ran
* (under load its own value may have been superseded by a newer one).
*
* The operation owns its own errors: a run that rejects still counts as done
* (its waiters resolve and draining continues), so a single bad value cannot
* strand later ones.
*/
export class LatestValueRunner<T> {
private _run: (value: T) => Promise<void>;
private _running = false;
private _pending: { value: T } | null = null;
private _waiters: Array<() => void> = [];
constructor(run: (value: T) => Promise<void>) {
this._run = run;
}
// Submit a value to run. Returns a promise that resolves once the next run
// has completed.
public submit(value: T): Promise<void> {
this._pending = { value };
const ran = new Promise<void>((resolve) => this._waiters.push(resolve));
if (!this._running) {
this._running = true;
// The drain loop cannot reject (the operation's errors are caught within
// it); the catch only satisfies the no-floating-promises rule.
/* istanbul ignore next -- @preserve */
this._drain().catch(() => {});
}
return ran;
}
// Drop any value still waiting to run.
public clear(): void {
this._pending = null;
}
private async _drain(): Promise<void> {
try {
while (this._pending) {
const { value } = this._pending;
this._pending = null;
try {
await this._run(value);
} catch {
// The operation owns its errors; the runner only guarantees progress.
}
// Release everyone waiting on a run; values submitted during the run
// were queued after this snapshot and wait for the next one.
const waiters = this._waiters;
this._waiters = [];
waiters.forEach((resolve) => resolve());
}
} finally {
this._running = false;
}
}
}
+8 -1
View File
@@ -22,6 +22,13 @@ export const getResolvedLiveProvider = (
return config?.live_provider ?? 'image';
};
// Live providers that stream from a go2rtc server and share the `go2rtc`
// config block and endpoints.
const GO2RTC_LIVE_PROVIDERS: readonly LiveProvider[] = ['go2rtc', 'go2rtc-experimental'];
export const isGo2RTCLiveProvider = (provider: LiveProvider): boolean =>
GO2RTC_LIVE_PROVIDERS.includes(provider);
export const liveProviderSupports2WayAudio = async (
hass: HomeAssistant,
config: CameraConfig,
@@ -29,7 +36,7 @@ export const liveProviderSupports2WayAudio = async (
go2rtcMetadataEndpoint?: Endpoint | null,
proxyConfig?: EnabledProxyConfig,
): Promise<boolean> => {
if (getResolvedLiveProvider(config) !== 'go2rtc') {
if (!isGo2RTCLiveProvider(getResolvedLiveProvider(config))) {
return false;
}
+10
View File
@@ -0,0 +1,10 @@
// Convert an HTTP(S) or origin-relative URL to its WS(S) equivalent.
export const convertToWebSocketURL = (url: string, origin?: string): string => {
if (/^http/i.test(url)) {
return 'ws' + url.substring(4);
}
if (url.startsWith('/')) {
return 'ws' + (origin ?? location.origin).substring(4) + url;
}
return url;
};