From 9a763db1f30dc18b2c9a6132cc0b0fb7e52fb733 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 10 May 2026 11:12:08 -0700 Subject: [PATCH] fix: Avoid go2rtc renegotiation on camera selection change (#2483) --- src/components/live/providers/go2rtc/index.ts | 15 ++-- .../live/providers/go2rtc/video-rtc.d.ts | 1 + .../live/providers/go2rtc/video-rtc.js | 87 ++++++++++++++++--- 3 files changed, 79 insertions(+), 24 deletions(-) diff --git a/src/components/live/providers/go2rtc/index.ts b/src/components/live/providers/go2rtc/index.ts index 4cf4c6ee..871849c3 100644 --- a/src/components/live/providers/go2rtc/index.ts +++ b/src/components/live/providers/go2rtc/index.ts @@ -137,16 +137,11 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer this._player.setControls(this.controls); } - if ( - this._player && - changedProps.has('microphoneState') && - this._player.microphoneStream !== (this.microphoneState?.stream ?? null) - ) { - this._player.microphoneStream = this.microphoneState?.stream ?? null; - - // Need to force a reconnect if the microphone stream changes since - // WebRTC cannot introduce a new stream after the offer is already made. - this._player.reconnect(); + if (this._player && changedProps.has('microphoneState')) { + // VideoRTC owns the transition: it updates microphoneStream, swaps the + // track on the pre-armed transceiver, and validates against stale async + // completions before any reconnect fallback. Fire-and-forget is fine. + /* async */ this._player.setMicrophoneStream(this.microphoneState?.stream ?? null); } } diff --git a/src/components/live/providers/go2rtc/video-rtc.d.ts b/src/components/live/providers/go2rtc/video-rtc.d.ts index 571d273c..9b0dc57b 100644 --- a/src/components/live/providers/go2rtc/video-rtc.d.ts +++ b/src/components/live/providers/go2rtc/video-rtc.d.ts @@ -37,4 +37,5 @@ export class VideoRTC extends HTMLElement { reconnect(); reset(): void; setControls(controls: boolean): void; + setMicrophoneStream(stream: MediaStream | null): Promise; } diff --git a/src/components/live/providers/go2rtc/video-rtc.js b/src/components/live/providers/go2rtc/video-rtc.js index c89ab424..77cc1a31 100644 --- a/src/components/live/providers/go2rtc/video-rtc.js +++ b/src/components/live/providers/go2rtc/video-rtc.js @@ -165,6 +165,15 @@ export class VideoRTC extends HTMLElement { */ this.microphoneStream = null; + /** + * The outbound audio transceiver pre-armed during createOffer. Holds a + * reference so `setMicrophoneStream` can swap the track via + * `replaceTrack` without renegotiating the SDP. Cleared on disconnect + * because transceivers belong to the closed peer connection. + * @type {RTCRtpTransceiver | null} + */ + this._microphoneTransceiver = null; + /** * A reference to a MediaPlayerController for this video * @type {MediaPlayerController | null} @@ -259,6 +268,50 @@ export class VideoRTC extends HTMLElement { } } + /** + * Owns the microphone stream transition end-to-end: updates the property, + * extracts the outbound audio track, and swaps it onto the pre-armed audio + * transceiver via `replaceTrack` — no SDP renegotiation, no visible reload. + * + * Falls back to a full reconnect if `replaceTrack` rejects, but only when + * the rejection still describes the current desired state. The transceiver + * and the requested stream are captured before awaiting so a late rejection + * from a stale operation (e.g. after `reset()` cleared the connection, or + * after a newer `setMicrophoneStream` superseded this one) cannot bring the + * player back online or overwrite a fresher request. + * + * @param {MediaStream | null} stream + */ + async setMicrophoneStream(stream) { + if (this.microphoneStream === stream) { + return; + } + this.microphoneStream = stream; + + const transceiver = this._microphoneTransceiver; + if (!transceiver) { + // No live peer connection yet (or createOffer hasn't run). The next + // createOffer will read `this.microphoneStream` and pre-arm the + // transceiver with the current track, so no separate fix-up is needed. + return; + } + + const desiredTrack = stream?.getAudioTracks()[0] ?? null; + try { + await transceiver.sender.replaceTrack(desiredTrack); + } catch (er) { + const stillCurrent = + transceiver === this._microphoneTransceiver && + this.microphoneStream === stream && + this.pc !== null; + if (!stillCurrent) { + return; + } + console.warn(er); + this.reconnect(); + } + } + /** * Reconnect the stream. */ @@ -495,6 +548,8 @@ export class VideoRTC extends HTMLElement { this.pc.close(); this.pc = null; } + // Transceivers belong to the now-closed peer connection. + this._microphoneTransceiver = null; this.video.src = ''; this.video.srcObject = null; @@ -775,22 +830,26 @@ export class VideoRTC extends HTMLElement { * @return {Promise} */ async createOffer(pc) { - // Must add microphone tracks prior to making the offer. - this.microphoneStream?.getTracks().forEach((track) => { - pc.addTransceiver(track, { direction: 'sendonly' }); + // Always pre-arm a single outbound audio transceiver so the SDP advertises + // the slot from the start. With the slot in place, the mic track can be + // attached/detached later via `setMicrophoneStream` (replaceTrack) without + // renegotiating — avoiding a visible reload of this cell each time grid + // selection moves the mic between cameras. + // + // Pure SDP allocation: the kind-only `addTransceiver('audio', ...)` form + // never calls `getUserMedia`, so users who don't grant mic access see no + // browser permission prompt from this path. + // + // The upstream `media.includes('microphone')` branch (which would have + // performed its own `getUserMedia` and added a second outbound audio + // transceiver) is intentionally omitted: the card drives mic acquisition + // through `MicrophoneManager`, and a second sender would race the one + // owned by `setMicrophoneStream`. + const micTrack = this.microphoneStream?.getAudioTracks()[0] ?? null; + this._microphoneTransceiver = pc.addTransceiver(micTrack ?? 'audio', { + direction: 'sendonly', }); - try { - if (this.media.indexOf('microphone') >= 0) { - const media = await navigator.mediaDevices.getUserMedia({ audio: true }); - media.getTracks().forEach((track) => { - pc.addTransceiver(track, { direction: 'sendonly' }); - }); - } - } catch (e) { - console.warn(e); - } - for (const kind of ['video', 'audio']) { if (this.media.indexOf(kind) >= 0) { pc.addTransceiver(kind, { direction: 'recvonly' });