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.
This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 31d0c9322f
commit 9d088d7bcb
3 changed files with 62 additions and 5 deletions
+10 -2
View File
@@ -74,7 +74,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
} }
disconnectedCallback(): void { disconnectedCallback(): void {
this._player = undefined; this._destroyPlayer();
super.disconnectedCallback(); super.disconnectedCallback();
} }
@@ -86,6 +86,14 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
this.requestUpdate(); 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 { private _createPlayer(): void {
const src = this._signedURLController.getValue(); const src = this._signedURLController.getValue();
if (!src) { if (!src) {
@@ -112,7 +120,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
if (changedProps.has('camera')) { if (changedProps.has('camera')) {
// Clear old player; the new one is created by the // Clear old player; the new one is created by the
// SignedURLController's valueChangeCallback once the URL resolves. // 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 // Only treat a missing go2rtc endpoint as an error after the camera's
+1
View File
@@ -35,5 +35,6 @@ export class VideoRTC extends HTMLElement {
microphoneStream: MediaStream | null; microphoneStream: MediaStream | null;
targetID: string | null; targetID: string | null;
reconnect(); reconnect();
reset(): void;
setControls(controls: boolean): void; setControls(controls: boolean): void;
} }
@@ -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. * Reconnect the stream.
*/ */
@@ -714,14 +737,39 @@ export class VideoRTC extends HTMLElement {
} }
}; };
this.createOffer(pc).then((offer) => { this.negotiateOffer(pc);
this.send({ type: 'webrtc/offer', value: offer.sdp });
});
this.pcState = WebSocket.CONNECTING; this.pcState = WebSocket.CONNECTING;
this.pc = pc; 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} * @param pc {RTCPeerConnection}
* @return {Promise<RTCSessionDescriptionInit>} * @return {Promise<RTCSessionDescriptionInit>}