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}