fix: Stop the webrtc-card provider recreating its player on every render (#2629)

- Closes: #2625
This commit is contained in:
Dermot Duffy
2026-07-29 13:11:12 -07:00
committed by GitHub
parent 15b08db3e8
commit 70abb39762
3 changed files with 416 additions and 49 deletions
@@ -0,0 +1,143 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import { isEqual } from 'lodash-es';
import type { Camera } from '../../../../camera-manager/camera';
import type {
HomeAssistant,
LovelaceCard,
LovelaceCardConfig,
} from '../../../../ha/types';
// The custom element name AlexxIT's WebRTC Card registers itself under.
export const WEBRTC_CARD_ELEMENT_NAME = 'webrtc-camera';
interface WebRTCCardControllerOptions {
// Called when an element that was handed out is discarded.
destroyCallback: () => void;
}
interface WebRTCCardElementRequest {
camera?: Camera;
hass?: HomeAssistant;
}
/**
* Owns the lifetime of the `webrtc-camera` element for
* `advanced-camera-card-live-webrtc-card`.
*
* The element is a stateful player: it negotiates a peer connection on
* construction and tears it down when removed from the DOM. A LIT child binding
* compares nodes by identity, so returning a fresh instance for an unrelated
* render (e.g. a zoom, a controls toggle) would restart the stream. The same
* element is therefore returned until the config changes.
*
* See: https://github.com/dermotduffy/advanced-camera-card/issues/2625
*/
export class WebRTCCardController implements ReactiveController {
private _options: WebRTCCardControllerOptions;
private _element: LovelaceCard | null = null;
private _config: LovelaceCardConfig | null = null;
constructor(host: ReactiveControllerHost, options: WebRTCCardControllerOptions) {
this._options = options;
host.addController(this);
}
public hostDisconnected(): void {
// The player is not reused across a detach.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/996
this._destroy();
}
/**
* Wait for the WebRTC Card to register its custom element. Must be awaited
* before an element is requested.
*/
public async awaitRegistration(): Promise<void> {
await customElements.whenDefined(WEBRTC_CARD_ELEMENT_NAME);
}
/**
* Get the element for the given inputs, constructing it if necessary.
* @returns The element, or `null` if it cannot yet be constructed.
* @throws If the WebRTC card rejects the configuration.
*/
public getElement(request: WebRTCCardElementRequest): HTMLElement | null {
const config = this._createConfig(request.camera);
const hass = request.hass;
if (!config || !hass) {
this._destroy();
return null;
}
if (this._element && isEqual(this._config, config)) {
this._element.hass = hass;
return this._element;
}
// Discard the outgoing element before the replacement is built, so a
// configuration the WebRTC card rejects leaves no element behind rather
// than a stale one.
this._destroy();
const element = document.createElement(WEBRTC_CARD_ELEMENT_NAME);
element.setConfig(config);
element.hass = hass;
this._element = element;
this._config = config;
return element;
}
private _createConfig(camera?: Camera): LovelaceCardConfig | null {
if (!camera) {
return null;
}
const cameraConfig = camera.getConfig();
const config: LovelaceCardConfig = {
type: `custom:${WEBRTC_CARD_ELEMENT_NAME}`,
// By default, webrtc-card will stop the video when 50% of the video is
// hidden. This is incompatible with the card zoom support, since the
// video will easily stop if the user zooms in too much. Disable this
// feature by default.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1614
intersection: 0,
// Advanced Camera Card always starts muted (unlike webrtc-card).
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1654
muted: true,
...cameraConfig.webrtc_card,
};
const webrtcCardEndpoint = camera.getEndpoints()?.webrtcCard;
if (!config.url && !config.entity && webrtcCardEndpoint) {
config.entity = webrtcCardEndpoint.endpoint;
}
return config;
}
private _destroy(): void {
if (!this._element) {
return;
}
this._element = null;
this._config = null;
this._options.destroyCallback();
}
}
declare global {
interface HTMLElementTagNameMap {
'webrtc-camera': LovelaceCard;
}
}
+18 -49
View File
@@ -10,6 +10,7 @@ import {
import { customElement, property, state } from 'lit/decorators.js';
import type { Camera } from '../../../camera-manager/camera.js';
import { WebRTCCardController } from '../../../components-lib/live/providers/webrtc-card/controller.js';
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get-technology-for-video-rtc.js';
import { MediaLoadedInfoSourceController } from '../../../components-lib/media-loaded-info-source-controller.js';
@@ -82,12 +83,24 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
getTargetID: () => this.targetID ?? null,
});
private _webrtcCardController = new WebRTCCardController(this, {
// The video belongs to the discarded element, so it must stop being claimed
// -- otherwise the card would be told media is loaded during the window
// where there is no element at all, and would keep believing it if the
// replacement never loads.
destroyCallback: () => this._mediaLoadedInfoSourceController.clear(),
});
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._mediaPlayerController;
}
// A task to await the load of the WebRTC component.
private _webrtcTask = new Task(this, this._getWebRTCCardElement, () => [1]);
private _webrtcTask = new Task(
this,
() => this._webrtcCardController.awaitRegistration(),
() => [1],
);
connectedCallback(): void {
super.connectedCallback();
@@ -101,12 +114,6 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
this._videoRTC = null;
this._notification = null;
// A reconnect builds a brand new WebRTC element, so the video that was
// announced is gone and must stop being claimed -- otherwise the card would
// be told media is loaded during the window where there is no element at
// all, and would keep believing it if the replacement never loads.
this._mediaLoadedInfoSourceController.clear();
super.disconnectedCallback();
}
@@ -124,47 +131,6 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
return this._videoRTC?.video ?? null;
}
private async _getWebRTCCardElement(): Promise<CustomElementConstructor | undefined> {
await customElements.whenDefined('webrtc-camera');
return customElements.get('webrtc-camera');
}
/**
* Create the WebRTC element. May throw.
*/
private _createWebRTC(): HTMLElement | null {
const webrtcElement = this._webrtcTask.value;
const cameraConfig = this.camera?.getConfig();
if (webrtcElement && this.hass && cameraConfig) {
const webrtc = new webrtcElement() as HTMLElement & {
hass: HomeAssistant;
setConfig: (config: Record<string, unknown>) => void;
};
const config = {
// By default, webrtc-card will stop the video when 50% of the video is
// hidden. This is incompatible with the card zoom support, since the
// video will easily stop if the user zooms in too much. Disable this
// feature by default.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1614
intersection: 0,
// Advanced Camera Card always starts muted (unlike webrtc-card).
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1654
muted: true,
...cameraConfig.webrtc_card,
};
const webrtcCardEndpoint = this.camera?.getEndpoints()?.webrtcCard;
if (!config.url && !config.entity && webrtcCardEndpoint) {
config.entity = webrtcCardEndpoint.endpoint;
}
webrtc.setConfig(config);
webrtc.hass = this.hass;
return webrtc;
}
return null;
}
protected render(): TemplateResult | void {
if (this._notification) {
return renderNotificationBlock(this._notification);
@@ -173,7 +139,10 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
const render = (): TemplateResult | void => {
let webrtcElement: HTMLElement | null;
try {
webrtcElement = this._createWebRTC();
webrtcElement = this._webrtcCardController.getElement({
camera: this.camera,
hass: this.hass,
});
} catch (e) {
this._notification = createMediaNotification({
title: localize('error.webrtc_card_reported_error'),