From 9d088d7bcbfb060d8d757d74396ab884880ee36d Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 8 May 2026 12:34:40 -0700 Subject: [PATCH] fix: Add support for synchronously reseting go2rtc sessions (#2478) This is part 1 of https://docs.google.com/document/d/1zbHYWqulEUaNLeX0fK3rfIiLugMlCQo_SFg3hcgiRRw/edit?tab=t.0 . There is a significant chance this will improve issues people have been experiencing with poorly designed doorbells that can only open a single stream at a time. --- src/components/live/providers/go2rtc/index.ts | 12 ++++- .../live/providers/go2rtc/video-rtc.d.ts | 1 + .../live/providers/go2rtc/video-rtc.js | 54 +++++++++++++++++-- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/components/live/providers/go2rtc/index.ts b/src/components/live/providers/go2rtc/index.ts index b2e4f219..4cf4c6ee 100644 --- a/src/components/live/providers/go2rtc/index.ts +++ b/src/components/live/providers/go2rtc/index.ts @@ -74,7 +74,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer } disconnectedCallback(): void { - this._player = undefined; + this._destroyPlayer(); super.disconnectedCallback(); } @@ -86,6 +86,14 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer this.requestUpdate(); } + private _destroyPlayer(): void { + // Tear down synchronously so backchannel-enabled streams (e.g. doorbell + // 2-way audio) release immediately rather than waiting out + // DISCONNECT_TIMEOUT. + this._player?.reset(); + this._player = undefined; + } + private _createPlayer(): void { const src = this._signedURLController.getValue(); if (!src) { @@ -112,7 +120,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer if (changedProps.has('camera')) { // Clear old player; the new one is created by the // SignedURLController's valueChangeCallback once the URL resolves. - this._player = undefined; + this._destroyPlayer(); } // Only treat a missing go2rtc endpoint as an error after the camera's diff --git a/src/components/live/providers/go2rtc/video-rtc.d.ts b/src/components/live/providers/go2rtc/video-rtc.d.ts index 7ee6fdf8..571d273c 100644 --- a/src/components/live/providers/go2rtc/video-rtc.d.ts +++ b/src/components/live/providers/go2rtc/video-rtc.d.ts @@ -35,5 +35,6 @@ export class VideoRTC extends HTMLElement { microphoneStream: MediaStream | null; targetID: string | null; reconnect(); + reset(): void; setControls(controls: boolean): void; } diff --git a/src/components/live/providers/go2rtc/video-rtc.js b/src/components/live/providers/go2rtc/video-rtc.js index 33c75821..c89ab424 100644 --- a/src/components/live/providers/go2rtc/video-rtc.js +++ b/src/components/live/providers/go2rtc/video-rtc.js @@ -236,6 +236,29 @@ export class VideoRTC extends HTMLElement { ); } + /** + * Synchronously tear down the stream: clear pending disconnect/reconnect + * timers, abort any in-flight load registration, and close the WebSocket + * and peer connection immediately. Used when intentionally destroying the + * player (e.g. swapping cameras) to avoid the DISCONNECT_TIMEOUT defer + * that would otherwise keep backchannel-enabled streams connected. + */ + reset() { + if (this.disconnectTID) { + clearTimeout(this.disconnectTID); + this.disconnectTID = 0; + } + if (this.reconnectTID) { + clearTimeout(this.reconnectTID); + this.reconnectTID = 0; + } + this._abortController?.abort(); + this._abortController = null; + if (this.wsState !== WebSocket.CLOSED || this.pcState !== WebSocket.CLOSED) { + this.ondisconnect(); + } + } + /** * Reconnect the stream. */ @@ -714,14 +737,39 @@ export class VideoRTC extends HTMLElement { } }; - this.createOffer(pc).then((offer) => { - this.send({ type: 'webrtc/offer', value: offer.sdp }); - }); + this.negotiateOffer(pc); this.pcState = WebSocket.CONNECTING; this.pc = pc; } + /** + * Build and send the WebRTC offer, guarding against the peer connection being + * torn down or replaced while `createOffer` is in flight (e.g. by `reset()` + * during a stream swap, or by a `webrtc/offer` error message closing `pc` + * without resetting `this.pc`/`this.pcState`). Without these guards a stale + * offer would be sent and `createOffer` rejections from mid-await + * `pc.close()` would surface as unhandled promise rejections. + * @param pc {RTCPeerConnection} + */ + async negotiateOffer(pc) { + try { + const offer = await this.createOffer(pc); + if ( + this.pc !== pc || + this.pcState === WebSocket.CLOSED || + pc.signalingState === 'closed' + ) { + return; + } + this.send({ type: 'webrtc/offer', value: offer.sdp }); + } catch (er) { + if (this.pc === pc && this.pcState !== WebSocket.CLOSED) { + console.warn(er); + } + } + } + /** * @param pc {RTCPeerConnection} * @return {Promise}