Compare commits

...
Author SHA1 Message Date
Claude f216e17c00 Don't start a WebRTC stream on a disconnected player
Lit still runs a pending update after the element was disconnected, so
a player added and removed in the same task (or given a new entityid
after removal) started a stream after its last clean up, which nothing
closed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZzvX8SAHt1knzzJNULtZx
2026-10-05 09:45:16 +00:00
Claude 8d4845801d Rename WebRTC start id to clean up count
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZzvX8SAHt1knzzJNULtZx
2026-10-05 07:18:48 +00:00
Claude 0f544c5449 Localize WebRTC player errors and handle rejected offer subscriptions
Error messages of ha-web-rtc-player were hardcoded English strings. They
are now stored as an error type plus the backend message and localized
when rendering.

The try/catch around webRtcOffer() never caught anything because the
subscription returns a promise, so a rejected offer left an unhandled
rejection and no error. Handle the rejection, and don't let clean up
re-raise it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZzvX8SAHt1knzzJNULtZx
2026-10-05 07:14:15 +00:00
Claude 2937434ea2 Bind WebRTC signaling callbacks to their own peer connection
Events of an offer subscription that belongs to a superseded start could
still reach the current stream before the unsubscribe completed, sending
candidates to the old session or applying the old answer to the new
offer. Ignore events whose peer connection is no longer the current one,
and don't report errors from operations rejected because a superseded
connection was closed.

Also show an error instead of leaving an unhandled rejection when
fetching the WebRTC client configuration fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZzvX8SAHt1knzzJNULtZx
2026-10-05 07:10:15 +00:00
Claude a546af75fc Abandon superseded WebRTC starts to avoid leaking peer connections
If ha-web-rtc-player was disconnected (or restarted) while awaiting the
WebRTC client configuration, the start continued afterwards and created
an RTCPeerConnection that nothing ever closed.

Track a start id that is bumped on every clean up and bail out when a
start has been superseded. Negotiation now also checks that it still
belongs to the current peer connection.

Fixes #54482

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZzvX8SAHt1knzzJNULtZx
2026-10-05 06:38:35 +00:00
2 changed files with 97 additions and 36 deletions
+92 -36
View File
@@ -6,7 +6,9 @@ import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import {
addWebRtcCandidate,
fetchWebRtcClientConfiguration,
@@ -20,6 +22,11 @@ import "./ha-alert";
const HIDDEN_CLEANUP_DELAY = 60000;
interface WebRtcPlayerError {
type: "not_supported" | "start_failed" | "connect_failed";
message?: string;
}
/**
* A WebRTC stream is established by first sending an offer through a signal
* path via an integration. An answer is returned, then the rest of the stream
@@ -35,6 +42,10 @@ class HaWebRtcPlayer extends LitElement {
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property() public entityid?: string;
@property({ attribute: false }) public aspectRatio?: number;
@@ -55,7 +66,7 @@ class HaWebRtcPlayer extends LitElement {
@property({ attribute: "poster-url" }) public posterUrl?: string;
@state() private _error?: string;
@state() private _error?: WebRtcPlayerError;
@query("#remote-stream") private _videoEl!: HTMLVideoElement;
@@ -73,6 +84,8 @@ class HaWebRtcPlayer extends LitElement {
private _hiddenCleanupTimeout?: number;
private _cleanUpCount = 0;
private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
@@ -94,7 +107,11 @@ class HaWebRtcPlayer extends LitElement {
protected override render(): TemplateResult {
if (this._error) {
return html`<ha-alert alert-type="error">${this._error}</ha-alert>`;
return html`<ha-alert alert-type="error">
${this._localize(`ui.components.web-rtc-player.${this._error.type}`, {
message: this._error.message ?? "",
})}
</ha-alert>`;
}
return html`
<video
@@ -146,12 +163,17 @@ class HaWebRtcPlayer extends LitElement {
// Browser support required for WebRTC
if (typeof RTCPeerConnection === "undefined") {
this._error = "WebRTC is not supported in this browser";
this._error = { type: "not_supported" };
fireEvent(this, "streams", { hasAudio: false, hasVideo: false });
return;
}
if (!this._api || !this._connection || !this.entityid) {
if (
!this._api ||
!this._connection ||
!this.entityid ||
!this.isConnected
) {
return;
}
@@ -159,12 +181,29 @@ class HaWebRtcPlayer extends LitElement {
this._startTimer();
const cleanUpCountAtStart = this._cleanUpCount;
this._logEvent("start clientConfig");
this._clientConfig = await fetchWebRtcClientConfiguration(
this._api,
this.entityid
);
let clientConfig: WebRTCClientConfiguration;
try {
clientConfig = await fetchWebRtcClientConfiguration(
this._api,
this.entityid
);
} catch (err: any) {
if (cleanUpCountAtStart === this._cleanUpCount) {
this._error = { type: "start_failed", message: err.message };
this._cleanUp();
}
return;
}
if (cleanUpCountAtStart !== this._cleanUpCount) {
return;
}
this._clientConfig = clientConfig;
this._logEvent("end clientConfig", this._clientConfig);
@@ -207,7 +246,8 @@ class HaWebRtcPlayer extends LitElement {
}
private _startNegotiation = async () => {
if (!this._peerConnection) {
const peerConnection = this._peerConnection;
if (!peerConnection) {
return;
}
@@ -219,9 +259,9 @@ class HaWebRtcPlayer extends LitElement {
this._logEvent("start createOffer", offerOptions);
const offer: RTCSessionDescriptionInit =
await this._peerConnection.createOffer(offerOptions);
await peerConnection.createOffer(offerOptions);
if (!this._peerConnection) {
if (this._peerConnection !== peerConnection) {
return;
}
@@ -229,9 +269,9 @@ class HaWebRtcPlayer extends LitElement {
this._logEvent("start setLocalDescription");
await this._peerConnection.setLocalDescription(offer);
await peerConnection.setLocalDescription(offer);
if (!this._peerConnection || !this.entityid) {
if (this._peerConnection !== peerConnection || !this.entityid) {
return;
}
@@ -250,17 +290,20 @@ class HaWebRtcPlayer extends LitElement {
this._logEvent("start webRtcOffer", offer_sdp);
try {
this._unsub = webRtcOffer(
this._connection,
this.entityid,
offer_sdp,
(event) => this._handleOfferEvent(event)
);
} catch (err: any) {
this._error = "Failed to start WebRTC stream: " + err.message;
this._unsub = webRtcOffer(
this._connection,
this.entityid,
offer_sdp,
(event) => this._handleOfferEvent(peerConnection, event)
);
this._unsub.catch((err) => {
if (this._peerConnection !== peerConnection) {
return;
}
this._unsub = undefined;
this._error = { type: "start_failed", message: err.message };
this._cleanUp();
}
});
};
private _iceConnectionStateChanged = () => {
@@ -273,8 +316,12 @@ class HaWebRtcPlayer extends LitElement {
}
};
private async _handleOfferEvent(event: WebRtcOfferEvent) {
if (!this.entityid) {
private async _handleOfferEvent(
peerConnection: RTCPeerConnection,
event: WebRtcOfferEvent
) {
// Ignore events of a subscription that belongs to a superseded start
if (this._peerConnection !== peerConnection || !this.entityid) {
return;
}
if (event.type === "session") {
@@ -293,7 +340,7 @@ class HaWebRtcPlayer extends LitElement {
if (event.type === "answer") {
this._logEvent("answer", event.answer);
this._handleAnswer(event);
this._handleAnswer(peerConnection, event);
}
if (event.type === "candidate") {
this._logEvent("remote ice candidate", event.candidate);
@@ -309,14 +356,14 @@ class HaWebRtcPlayer extends LitElement {
sdpMid: "0",
});
await this._peerConnection?.addIceCandidate(candidate);
await peerConnection.addIceCandidate(candidate);
} catch (err: any) {
// eslint-disable-next-line no-console
console.error(err);
}
}
if (event.type === "error") {
this._error = "Failed to start WebRTC stream: " + event.message;
this._error = { type: "start_failed", message: event.message };
this._cleanUp();
}
}
@@ -360,11 +407,11 @@ class HaWebRtcPlayer extends LitElement {
this._videoEl.srcObject = this._remoteStream;
};
private async _handleAnswer(event: WebRtcAnswer) {
if (
!this._peerConnection?.signalingState ||
["stable", "closed"].includes(this._peerConnection.signalingState)
) {
private async _handleAnswer(
peerConnection: RTCPeerConnection,
event: WebRtcAnswer
) {
if (["stable", "closed"].includes(peerConnection.signalingState)) {
return;
}
@@ -375,15 +422,20 @@ class HaWebRtcPlayer extends LitElement {
});
try {
this._logEvent("start setRemoteDescription", remoteDesc);
await this._peerConnection.setRemoteDescription(remoteDesc);
await peerConnection.setRemoteDescription(remoteDesc);
} catch (err: any) {
this._error = "Failed to connect WebRTC stream: " + err.message;
// Closing a superseded connection rejects its pending operations
if (this._peerConnection !== peerConnection) {
return;
}
this._error = { type: "connect_failed", message: err.message };
this._cleanUp();
}
this._logEvent("end setRemoteDescription");
}
private _cleanUp() {
this._cleanUpCount++;
if (this._remoteStream) {
this._remoteStream.getTracks().forEach((track) => {
track.stop();
@@ -413,7 +465,11 @@ class HaWebRtcPlayer extends LitElement {
this._logEvent("stopped");
this._stopTimer();
}
this._unsub?.then((unsub) => unsub());
// A rejected subscription is already handled in _startNegotiation
this._unsub?.then(
(unsub) => unsub(),
() => undefined
);
this._unsub = undefined;
this._sessionId = undefined;
this._candidatesList = [];
+5
View File
@@ -1254,6 +1254,11 @@
"load_error": "Failed to load data",
"retry": "[%key:ui::panel::app::retry%]"
},
"web-rtc-player": {
"not_supported": "WebRTC is not supported in this browser.",
"start_failed": "Failed to start the WebRTC stream: {message}",
"connect_failed": "Failed to connect the WebRTC stream: {message}"
},
"siren": {
"more_controls": "More controls",
"tone": "Tone",