fix: Avoid go2rtc renegotiation on camera selection change (#2483)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent a68b665f03
commit 9a763db1f3
3 changed files with 79 additions and 24 deletions
+5 -10
View File
@@ -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);
}
}
+1
View File
@@ -37,4 +37,5 @@ export class VideoRTC extends HTMLElement {
reconnect();
reset(): void;
setControls(controls: boolean): void;
setMicrophoneStream(stream: MediaStream | null): Promise<void>;
}
@@ -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<RTCSessionDescriptionInit>}
*/
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' });