fix: Stop the webrtc-card provider recreating its player on every render (#2629)
- Closes: #2625
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { Camera } from '../../../../../src/camera-manager/camera';
|
||||
import {
|
||||
WEBRTC_CARD_ELEMENT_NAME,
|
||||
WebRTCCardController,
|
||||
} from '../../../../../src/components-lib/live/providers/webrtc-card/controller';
|
||||
import type { HomeAssistant, LovelaceCardConfig } from '../../../../../src/ha/types';
|
||||
import { createCameraConfig } from '../../../../config/test-utils';
|
||||
import { createHASS, createLitElement } from '../../../../test-utils';
|
||||
|
||||
// Configuring this entity makes the stand-in card below reject the
|
||||
// configuration, as AlexxIT's card does when given no usable stream.
|
||||
const REJECTED_ENTITY = 'camera.rejected';
|
||||
|
||||
class TestWebRTCCard extends HTMLElement {
|
||||
public hass?: HomeAssistant;
|
||||
public setConfig = vi.fn((config: LovelaceCardConfig): void => {
|
||||
if (config.entity === REJECTED_ENTITY) {
|
||||
throw new Error('Missing `url` or `entity` or `streams`');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
customElements.define(WEBRTC_CARD_ELEMENT_NAME, TestWebRTCCard);
|
||||
|
||||
const asTestCard = (element: HTMLElement | null): TestWebRTCCard | null =>
|
||||
element instanceof TestWebRTCCard ? element : null;
|
||||
|
||||
const createCamera = (options?: {
|
||||
webrtc_card?: Record<string, unknown>;
|
||||
webrtcCardEndpoint?: string;
|
||||
}): Camera => {
|
||||
const camera = mock<Camera>();
|
||||
camera.getConfig.mockReturnValue(
|
||||
createCameraConfig({ webrtc_card: options?.webrtc_card }),
|
||||
);
|
||||
camera.getEndpoints.mockReturnValue(
|
||||
options?.webrtcCardEndpoint
|
||||
? { webrtcCard: { endpoint: options.webrtcCardEndpoint } }
|
||||
: {},
|
||||
);
|
||||
return camera;
|
||||
};
|
||||
|
||||
const createController = (options?: { destroyCallback?: () => void }) => {
|
||||
const host = createLitElement();
|
||||
const destroyCallback = options?.destroyCallback ?? vi.fn();
|
||||
const controller = new WebRTCCardController(host, { destroyCallback });
|
||||
return { host, controller, destroyCallback };
|
||||
};
|
||||
|
||||
const createRequest = (options?: {
|
||||
webrtcCardEndpoint?: string;
|
||||
hass?: HomeAssistant;
|
||||
}) => ({
|
||||
camera: createCamera({
|
||||
webrtcCardEndpoint: options?.webrtcCardEndpoint ?? 'camera.office',
|
||||
}),
|
||||
hass: options?.hass ?? createHASS(),
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('WebRTCCardController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should register itself with the host', () => {
|
||||
const { host, controller } = createController();
|
||||
|
||||
expect(host.addController).toHaveBeenCalledWith(controller);
|
||||
});
|
||||
|
||||
it('should await the WebRTC card registration', async () => {
|
||||
const { controller } = createController();
|
||||
|
||||
await expect(controller.awaitRegistration()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
describe('should not create an element', () => {
|
||||
it('without a camera', () => {
|
||||
const { controller } = createController();
|
||||
|
||||
expect(controller.getElement({ hass: createHASS() })).toBeNull();
|
||||
});
|
||||
|
||||
it('without hass', () => {
|
||||
const { controller } = createController();
|
||||
|
||||
expect(controller.getElement({ camera: createCamera() })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should create an element', () => {
|
||||
it('with the card defaults', () => {
|
||||
const { controller } = createController();
|
||||
const hass = createHASS();
|
||||
|
||||
const element = controller.getElement(createRequest({ hass }));
|
||||
|
||||
expect(asTestCard(element)?.setConfig).toHaveBeenCalledWith({
|
||||
type: 'custom:webrtc-camera',
|
||||
intersection: 0,
|
||||
muted: true,
|
||||
entity: 'camera.office',
|
||||
});
|
||||
expect(asTestCard(element)?.hass).toBe(hass);
|
||||
});
|
||||
|
||||
it('with user configuration overriding the defaults', () => {
|
||||
const { controller } = createController();
|
||||
|
||||
const element = controller.getElement({
|
||||
camera: createCamera({
|
||||
webrtc_card: { muted: false, url: 'https://camera' },
|
||||
webrtcCardEndpoint: 'camera.office',
|
||||
}),
|
||||
hass: createHASS(),
|
||||
});
|
||||
|
||||
expect(asTestCard(element)?.setConfig).toHaveBeenCalledWith({
|
||||
type: 'custom:webrtc-camera',
|
||||
intersection: 0,
|
||||
muted: false,
|
||||
url: 'https://camera',
|
||||
});
|
||||
});
|
||||
|
||||
it('without an endpoint when the user configures an entity', () => {
|
||||
const { controller } = createController();
|
||||
|
||||
const element = controller.getElement({
|
||||
camera: createCamera({
|
||||
webrtc_card: { entity: 'camera.configured' },
|
||||
webrtcCardEndpoint: 'camera.office',
|
||||
}),
|
||||
hass: createHASS(),
|
||||
});
|
||||
|
||||
expect(asTestCard(element)?.setConfig).toHaveBeenCalledWith({
|
||||
type: 'custom:webrtc-camera',
|
||||
intersection: 0,
|
||||
muted: true,
|
||||
entity: 'camera.configured',
|
||||
});
|
||||
});
|
||||
|
||||
it('without an endpoint at all', () => {
|
||||
const { controller } = createController();
|
||||
|
||||
const element = controller.getElement({
|
||||
camera: createCamera(),
|
||||
hass: createHASS(),
|
||||
});
|
||||
|
||||
expect(asTestCard(element)?.setConfig).toHaveBeenCalledWith({
|
||||
type: 'custom:webrtc-camera',
|
||||
intersection: 0,
|
||||
muted: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should reuse the element', () => {
|
||||
it('when nothing changes', () => {
|
||||
const { controller, destroyCallback } = createController();
|
||||
const request = createRequest();
|
||||
|
||||
const element = controller.getElement(request);
|
||||
|
||||
expect(controller.getElement(request)).toBe(element);
|
||||
expect(destroyCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('when an equivalent camera replaces the old one', () => {
|
||||
const { controller, destroyCallback } = createController();
|
||||
|
||||
const element = controller.getElement(createRequest());
|
||||
|
||||
expect(controller.getElement(createRequest())).toBe(element);
|
||||
expect(destroyCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('and give it the latest hass', () => {
|
||||
const { controller } = createController();
|
||||
|
||||
const element = controller.getElement(createRequest());
|
||||
|
||||
const hass = createHASS();
|
||||
controller.getElement(createRequest({ hass }));
|
||||
|
||||
expect(asTestCard(element)?.hass).toBe(hass);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should rebuild the element', () => {
|
||||
it('when the resolved configuration changes', () => {
|
||||
const { controller, destroyCallback } = createController();
|
||||
|
||||
const element = controller.getElement(createRequest());
|
||||
|
||||
expect(
|
||||
controller.getElement(createRequest({ webrtcCardEndpoint: 'camera.kitchen' })),
|
||||
).not.toBe(element);
|
||||
expect(destroyCallback).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should discard the element', () => {
|
||||
it('when the inputs are no longer sufficient', () => {
|
||||
const { controller, destroyCallback } = createController();
|
||||
|
||||
controller.getElement(createRequest());
|
||||
|
||||
expect(controller.getElement({ hass: createHASS() })).toBeNull();
|
||||
expect(destroyCallback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('when the WebRTC card rejects the configuration', () => {
|
||||
const { controller, destroyCallback } = createController();
|
||||
|
||||
controller.getElement(createRequest());
|
||||
|
||||
expect(() =>
|
||||
controller.getElement(createRequest({ webrtcCardEndpoint: REJECTED_ENTITY })),
|
||||
).toThrow('Missing `url` or `entity` or `streams`');
|
||||
expect(destroyCallback).toHaveBeenCalledOnce();
|
||||
|
||||
// The rejected element must not be cached: the next call rebuilds rather
|
||||
// than handing back a stale element.
|
||||
expect(controller.getElement(createRequest())).not.toBeNull();
|
||||
expect(destroyCallback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('when the host disconnects', () => {
|
||||
const { controller, destroyCallback } = createController();
|
||||
|
||||
const element = controller.getElement(createRequest());
|
||||
controller.hostDisconnected();
|
||||
|
||||
expect(destroyCallback).toHaveBeenCalledOnce();
|
||||
expect(controller.getElement(createRequest())).not.toBe(element);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not invoke the destroy callback when there is no element', () => {
|
||||
const { controller, destroyCallback } = createController();
|
||||
|
||||
controller.hostDisconnected();
|
||||
|
||||
expect(destroyCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user