diff --git a/src/card.ts b/src/card.ts index 8b99b9d5..18909c92 100644 --- a/src/card.ts +++ b/src/card.ts @@ -57,6 +57,7 @@ import { homeAssistantWSRequest, isValidMediaShowInfo, shouldUpdateBasedOnHass, + sideLoadHomeAssistantElements, } from './common.js'; import { localize } from './localize/localize.js'; import { renderMessage, renderProgressIndicator } from './components/message.js'; @@ -186,6 +187,9 @@ export class FrigateCard extends LitElement { // per second for performance reasons. protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000); + // Whether the card has been successfully initialized. + protected _initialized = false; + /** * Set the Home Assistant object. */ @@ -830,6 +834,19 @@ export class FrigateCard extends LitElement { this._changeView({ view: e.detail }); } + /** + * Called before each update. + */ + protected willUpdate(): void { + if (!this._initialized) { + sideLoadHomeAssistantElements().then((success) => { + if (success) { + this._initialized = true; + } + }) + } + } + /** * Determine whether the element should be updated. * @param changedProps The changed properties if any. diff --git a/src/common.ts b/src/common.ts index 42a50576..d3873370 100644 --- a/src/common.ts +++ b/src/common.ts @@ -21,6 +21,7 @@ import { ActionsConfig, ActionType, CameraConfig, + CardHelpers, ExtendedHomeAssistant, FrigateCardAction, FrigateCardCustomAction, @@ -33,7 +34,7 @@ import { signedPathSchema, StateParameters, } from './types.js'; -import { stateIcon } from './icons/state-icon.js' +import { stateIcon } from './icons/state-icon.js'; const MEDIA_INFO_HEIGHT_CUTOFF = 50; const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF; @@ -290,10 +291,10 @@ export function convertActionToFrigateCardCustomAction( export function createFrigateCardCustomAction( action: FrigateCardAction, args?: { - camera?: string, - media_player?: string, - media_player_action?: 'play' | 'stop', - } + camera?: string; + media_player?: string; + media_player_action?: 'play' | 'stop'; + }, ): FrigateCardCustomAction | null { if (action === 'camera_select') { if (!args?.camera) { @@ -473,10 +474,7 @@ export function getEntityTitle( * @param hass The Home Assistant object. * @returns The icon or undefined. */ -export function getEntityIcon( - hass?: HomeAssistant, - entity?: string, -): string { +export function getEntityIcon(hass?: HomeAssistant, entity?: string): string { return stateIcon(entity ? hass?.states[entity] : null); } @@ -682,3 +680,44 @@ export function getEventDurationString(event: FrigateEvent): string { duration += `${seconds}s`; return duration; } + +/** + * Side loads the HA elements this card needs. This trickery is unfortunate + * necessary, see: + * - https://github.com/thomasloven/hass-config/wiki/PreLoading-Lovelace-Elements + * @returns `true` if the load is successful, `false` otherwise. + */ +export const sideLoadHomeAssistantElements = async (): Promise => { + const neededElements = [ + 'ha-selector', + 'ha-menu-button', + 'ha-camera-stream', + 'ha-hls-player', + 'ha-web-rtc-player', + 'ha-icon', + 'ha-circular-progress', + 'ha-icon-button', + 'ha-card', + 'ha-svg-icon', + 'ha-button-menu', + ]; + + if (neededElements.every((element) => customElements.get(element))) { + return true; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const helpers: CardHelpers = await (window as any).loadCardHelpers(); + + // The picture-glance editor loads everything this card needs. + const pictureGlance = await helpers.createCardElement({ + type: 'picture-glance', + entities: [], + camera_image: 'dummy-to-load-editor-components', + }); + if (pictureGlance.constructor.getConfigElement) { + await pictureGlance.constructor.getConfigElement(); + return true; + } + return false; +}; diff --git a/src/components/drawer.ts b/src/components/drawer.ts index 2945b3f3..7863080c 100644 --- a/src/components/drawer.ts +++ b/src/components/drawer.ts @@ -24,9 +24,18 @@ export class FrigateCardDrawer extends LitElement { @property({ type: Boolean, reflect: true, attribute: true }) public open = false; + // The 'empty' attribute is used in the styling to change the drawer + // visibility and that of all descendants if there is no content. Styling is + // used rather than display or hidden in order to ensure the contents continue + // to have a measurable size. + @property({ type: Boolean, reflect: true, attribute: true }) + public empty = true; + protected _refDrawer: Ref = createRef(); protected _refSlot: Ref = createRef(); + protected _resizeObserver = new ResizeObserver(() => this._hideDrawerIfNecessary()); + /** * Called on the first update. * @param changedProps The changed properties. @@ -42,19 +51,45 @@ export class FrigateCardDrawer extends LitElement { this._refDrawer.value?.shadowRoot?.appendChild(style); } + /** + * Called when the slotted children in the drawer change. + */ protected _slotChanged(): void { const elements = this._refSlot.value?.assignedElements({ flatten: true }); - if (elements && elements.length && this._refDrawer.value) { - // Hide the drawer unless there is content. - this._refDrawer.value.hidden = false; + + // Watch all slot children for size changes. + this._resizeObserver.disconnect(); + for (const element of elements ?? []) { + this._resizeObserver.observe(element); } + this._hideDrawerIfNecessary(); + } + + /** + * Hide the drawer if there is nothing to show. + * @returns + */ + protected _hideDrawerIfNecessary(): void { + if (!this._refDrawer.value) { + return; + } + + const elements = this._refSlot.value?.assignedElements({ flatten: true }); + this.empty = + !elements || + !elements.length || + // If the element has the special attribute 'empty' also hide it, this is + // used to hide carousels that have no actual contents. + elements.every((element) => { + const box = element.getBoundingClientRect(); + return !box.width || !box.height; + }); } protected render(): TemplateResult { return html` diff --git a/src/components/live.ts b/src/components/live.ts index b36e2a18..7c1d52ce 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -512,7 +512,7 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { protected render(): TemplateResult | void { const [slides, cameraToSlide] = this._getSlides(); this._cameraToSlide = cameraToSlide; - if (!slides || !this.liveConfig || !this.cameras || !this.view) { + if (!slides.length || !this.liveConfig || !this.cameras || !this.view) { return; } diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index ae05d5de..bfa0b36b 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -5,10 +5,7 @@ import { classMap } from 'lit/directives/class-map.js'; import { customElement, property, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import type { - FrigateBrowseMediaSource, - ThumbnailsControlConfig, -} from '../types.js'; +import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; import { FrigateCardCarousel } from './carousel.js'; import { View } from '../view.js'; import { @@ -201,7 +198,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { */ protected render(): TemplateResult | void { const slides = this._getSlides(); - if (!slides || !this._config || this._config.mode == 'none') { + if (!slides.length || !this._config || this._config.mode == 'none') { return; } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 28ec2abc..2f4c1dcd 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -599,7 +599,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { protected _render(): TemplateResult | void { const [slides, slideToChild] = this._getSlides(); this._slideToChild = slideToChild; - if (!slides) { + if (!slides.length) { return; } diff --git a/src/editor.ts b/src/editor.ts index 739439f0..b003f60b 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; @@ -90,7 +89,12 @@ import { CONF_VIEW_UPDATE_FORCE, CONF_VIEW_UPDATE_SECONDS, } from './const.js'; -import { arrayMove, getCameraID, getCameraTitle } from './common.js'; +import { + arrayMove, + getCameraID, + getCameraTitle, + sideLoadHomeAssistantElements, +} from './common.js'; import { copyConfig, deleteConfigValue, @@ -183,7 +187,6 @@ const options: EditorOptions = { export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { @property({ attribute: false }) public hass?: HomeAssistant; @state() protected _config?: RawFrigateCardConfig; - @state() protected _helpers?: any; protected _initialized = false; protected _configUpgradeable = false; @@ -386,15 +389,19 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor // such, RawFrigateCardConfig is used as the type. this._config = config; this._configUpgradeable = isConfigUpgradeable(config); - this.loadCardHelpers(); } - protected shouldUpdate(): boolean { + /** + * Called before each update. + */ + protected willUpdate(): void { if (!this._initialized) { - this._initialize(); + sideLoadHomeAssistantElements().then((success) => { + if (success) { + this._initialized = true; + } + }); } - - return true; } protected _getEntities(domain: string): string[] { @@ -929,7 +936,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor } protected render(): TemplateResult | void { - if (!this.hass || !this._helpers || !this._config) { + if (!this.hass || !this._config) { return html``; } @@ -1219,36 +1226,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor `; } - /** - * Verify editor is initialized. - */ - protected _initialize(): void { - if (this.hass === undefined) return; - if (this._config === undefined) return; - if (this._helpers === undefined) return; - - (async (): Promise => { - // The picture-glance editor loads the ha-selectors. - // See: https://github.com/thomasloven/hass-config/wiki/PreLoading-Lovelace-Elements - const pictureGlance = await this._helpers.createCardElement({ - type: 'picture-glance', - entities: [], - camera_image: 'dummy-to-load-editor-components', - }); - if (pictureGlance.constructor.getConfigElement) { - await pictureGlance.constructor.getConfigElement(); - this._initialized = true; - } - })(); - } - - /** - * Load card helpers. - */ - protected async loadCardHelpers(): Promise { - this._helpers = await (window as any).loadCardHelpers(); - } - /** * Close the editor menu with the given domain. * @param targetDomain The menu domain to close. diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index ebfca95e..68a4ddd9 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -9,9 +9,11 @@ // available as compilation time. // ==================================================================== -import { Ref, createRef, ref } from 'lit/directives/ref.js'; import { TemplateResult, css, html } from 'lit'; import { customElement } from 'lit/decorators.js'; +import { query } from 'lit/decorators/query.js'; + +import { FrigateCardMediaPlayer } from '../types.js'; import { dispatchMediaShowEvent } from '../common.js'; customElements.whenDefined('ha-camera-stream').then(() => { @@ -38,7 +40,10 @@ customElements.whenDefined('ha-camera-stream').then(() => { @customElement('frigate-card-ha-camera-stream') // eslint-disable-next-line @typescript-eslint/no-unused-vars class FrigateCardHaCameraStream extends customElements.get('ha-camera-stream') { - protected _playerRef: Ref = createRef(); + // Due to an obscure behavior when this card is casted, this element needs + // to use query rather than the ref directive to find the player. + @query('#player') + protected _player: FrigateCardMediaPlayer; // ======================================================================================== // Minor modifications from: @@ -49,28 +54,28 @@ customElements.whenDefined('ha-camera-stream').then(() => { * Play the video. */ public play(): void { - this._playerRef.value?.play(); + this._player?.play(); } /** * Pause the video. */ public pause(): void { - this._playerRef.value?.pause(); + this._player?.pause(); } /** * Mute the video. */ public mute(): void { - this._playerRef.value?.mute(); + this._player?.mute(); } /** * Unmute the video. */ public unmute(): void { - this._playerRef.value?.unmute(); + this._player?.unmute(); } /** @@ -98,7 +103,7 @@ customElements.whenDefined('ha-camera-stream').then(() => { if (this.stateObj.attributes.frontend_stream_type === STREAM_TYPE_HLS) { return this._url ? html` { } if (this.stateObj.attributes.frontend_stream_type === STREAM_TYPE_WEB_RTC) { return html` { @customElement('frigate-card-ha-hls-player') // eslint-disable-next-line @typescript-eslint/no-unused-vars class FrigateCardHaHlsPlayer extends customElements.get('ha-hls-player') { - protected _videoRef: Ref = createRef(); + // Due to an obscure behavior when this card is casted, this element needs + // to use query rather than the ref directive to find the player. + @query('#video') + protected _video: HTMLVideoElement; /** * Play the video. */ public play(): void { - this._videoRef.value?.play(); + this._video?.play(); } /** * Pause the video. */ public pause(): void { - this._videoRef.value?.pause(); + this._video?.pause(); } /** @@ -40,8 +44,8 @@ customElements.whenDefined('ha-hls-player').then(() => { public mute(): void { // The muted property is only for the initial muted state. Must explicitly // set the muted on the video player to make the change dynamic. - if (this._videoRef.value) { - this._videoRef.value.muted = true; + if (this._video) { + this._video.muted = true; } } @@ -50,8 +54,8 @@ customElements.whenDefined('ha-hls-player').then(() => { */ public unmute(): void { // See note in mute(). - if (this._videoRef.value) { - this._videoRef.value.muted = false; + if (this._video) { + this._video.muted = false; } } @@ -66,7 +70,7 @@ customElements.whenDefined('ha-hls-player').then(() => { } return html`