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 {
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
+1
View File
@@ -35,5 +35,6 @@ export class VideoRTC extends HTMLElement {
microphoneStream: MediaStream | null;
targetID: string | null;
reconnect();
reset(): 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.
*/
@@ -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<RTCSessionDescriptionInit>}