diff --git a/src/components-lib/live/providers/webrtc-card/controller.ts b/src/components-lib/live/providers/webrtc-card/controller.ts new file mode 100644 index 00000000..6fb32df5 --- /dev/null +++ b/src/components-lib/live/providers/webrtc-card/controller.ts @@ -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 { + 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; + } +} diff --git a/src/components/live/providers/webrtc-card.ts b/src/components/live/providers/webrtc-card.ts index c6f87cf3..0c52693a 100644 --- a/src/components/live/providers/webrtc-card.ts +++ b/src/components/live/providers/webrtc-card.ts @@ -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 { 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 { - 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) => 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'), diff --git a/tests/components-lib/live/providers/webrtc-card/controller.test.ts b/tests/components-lib/live/providers/webrtc-card/controller.test.ts new file mode 100644 index 00000000..fec8265a --- /dev/null +++ b/tests/components-lib/live/providers/webrtc-card/controller.test.ts @@ -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; + webrtcCardEndpoint?: string; +}): Camera => { + const camera = mock(); + 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(); + }); +});