import { CSSResultGroup, html, LitElement, PropertyValues, TemplateResult, unsafeCSS, } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { guard } from 'lit/directives/guard.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { Camera } from '../../camera-manager/camera.js'; import { MicrophoneState } from '../../card-controller/types.js'; import { LazyLoadController } from '../../components-lib/lazy-load-controller.js'; import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js'; import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js'; import { PartialZoomSettings } from '../../components-lib/zoom/types.js'; import { LiveConfig } from '../../config/schema/live.js'; import { CardWideConfig } from '../../config/schema/types.js'; import { HomeAssistant } from '../../ha/types.js'; import { localize } from '../../localize/localize.js'; import liveProviderStyle from '../../scss/live-provider.scss'; import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js'; import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event.js'; import { getResolvedLiveProvider } from '../../utils/live-provider.js'; import '../icon.js'; import { renderNotificationBlockFromText } from '../notification/block.js'; import './../media-dimensions-container'; @customElement('advanced-camera-card-live-provider') export class AdvancedCameraCardLiveProvider extends LitElement implements MediaPlayer { @property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public camera?: Camera; // The BASE camera ID (camera property may be a substream) @property({ attribute: false }) public targetID?: string; @property({ attribute: false }) public liveConfig?: LiveConfig; // Label that is used for ARIA support and as tooltip. @property({ attribute: false }) public label = ''; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @property({ attribute: false }) public microphoneState?: MicrophoneState; @property({ attribute: false }) public zoomSettings?: PartialZoomSettings | null; @property({ attribute: false }) public zoom = true; private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, { getTargetID: () => this.targetID ?? null, }); @state() private _zoomed = false; @state() private _hasProviderError = false; // Whether the camera entity has ever been in a non-unavailable state. Used // to suppress transient unavailability errors for entities that were // previously working (e.g. during PTZ operations). // See: https://github.com/dermotduffy/advanced-camera-card/issues/2124 private _entityHasBeenAvailable = false; private _refProvider: Ref = createRef(); private _lazyLoadController: LazyLoadController = new LazyLoadController(this); // A note on dynamic imports: // // We gather the dynamic live provider import promises and do not consider the // update of the element complete until these imports have returned. Without // this behavior calls to the media methods (e.g. `mute()`) may throw if the // underlying code is not yet loaded. // // Test case: A card with a non-live view, but live pre-loaded, attempts to // call mute() when the element first renders in // the background. These calls fail without waiting for loading here. private _importPromises: Promise[] = []; public async getMediaPlayerController(): Promise { await this.updateComplete; return (await this._refProvider.value?.getMediaPlayerController()) ?? null; } /** * Determine if a camera image should be shown in lieu of the real stream * whilst loading. * @returns`true` if an image should be shown. */ private _shouldShowImageDuringLoading(): boolean { return ( !this._mediaLoadedInfoSinkController.has() && !!this.camera?.getConfig()?.camera_entity && !!this.hass && !!this.liveConfig?.show_image_during_load && !this._hasProviderError ); } public disconnectedCallback(): void { this._entityHasBeenAvailable = false; super.disconnectedCallback(); } private _providerErrorHandler(ev: Event): void { ev.stopPropagation(); this._hasProviderError = true; if (this.targetID) { fireAdvancedCameraCardEvent(this, 'issue:trigger', { key: 'media_load' as const, targetID: this.targetID, }); } } protected willUpdate(changedProps: PropertyValues): void { if ( changedProps.has('liveConfig') || (!this._lazyLoadController && this.liveConfig) ) { this._lazyLoadController.setConfiguration( this.liveConfig?.lazy_load, this.liveConfig?.lazy_unload, ); } if (changedProps.has('liveConfig')) { if (this.liveConfig?.show_image_during_load) { this._importPromises.push(import('./providers/image.js')); } if (this.liveConfig?.zoomable) { this._importPromises.push(import('../zoomer.js')); } } if (changedProps.has('camera')) { this._hasProviderError = false; this._entityHasBeenAvailable = false; const provider = getResolvedLiveProvider(this.camera?.getConfig()); if (provider === 'jsmpeg') { this._importPromises.push(import('./providers/jsmpeg.js')); } else if (provider === 'ha') { this._importPromises.push(import('./providers/ha.js')); } else if (provider === 'webrtc-card') { this._importPromises.push(import('./providers/webrtc-card.js')); } else if (provider === 'image') { this._importPromises.push(import('./providers/image.js')); } else if (provider === 'go2rtc') { this._importPromises.push(import('./providers/go2rtc/index.js')); } } } override async getUpdateComplete(): Promise { // See 'A note on dynamic imports' above for explanation of why this is // necessary. const result = await super.getUpdateComplete(); await Promise.all(this._importPromises); this._importPromises = []; return result; } // Builtin (native) video controls require all three conditions: // - controls.builtin: user config enables native controls. // - zoom: Whether digital zoom/panning is allowed (this will be false when a // 'gesture' type PTZ control is active). // - !_zoomed: the user has not actually digital zoomed in (when zoomed, we // want to hide the controls). private _getEffectiveBuiltinControls(): boolean { return !!this.liveConfig?.controls.builtin && this.zoom && !this._zoomed; } private _renderContainer(template: TemplateResult): TemplateResult { const config = this.camera?.getConfig(); const intermediateTemplate = html` ${template} `; return html` ${this.liveConfig?.zoomable ? html` config?.dimensions?.layout ? { pan: config.dimensions.layout.pan, zoom: config.dimensions.layout.zoom, } : undefined, )} .settings=${this.zoomSettings} .zoom=${this.zoom} @advanced-camera-card:zoom:zoomed=${() => (this._zoomed = true)} @advanced-camera-card:zoom:unzoomed=${() => (this._zoomed = false)} > ${intermediateTemplate} ` : intermediateTemplate}`; } protected render(): TemplateResult | void { const cameraConfig = this.camera?.getConfig(); if ( !this._lazyLoadController?.isLoaded() || !this.hass || !this.liveConfig || !this.camera || !cameraConfig ) { return; } // Set title and ariaLabel from the provided label property. this.title = this.label; this.ariaLabel = this.label; const provider = getResolvedLiveProvider(this.camera?.getConfig()); if ( provider === 'ha' || provider === 'image' || (cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable) ) { if (!cameraConfig?.camera_entity) { dispatchLiveErrorEvent(this); return renderNotificationBlockFromText(localize('error.no_live_camera'), { icon: 'mdi:camera', context: cameraConfig, }); } const stateObj = this.hass.states[cameraConfig.camera_entity]; if (!stateObj) { dispatchLiveErrorEvent(this); return renderNotificationBlockFromText(localize('error.live_camera_not_found'), { icon: 'mdi:camera', context: cameraConfig, }); } if (stateObj.state === 'unavailable') { if ( !this._entityHasBeenAvailable || cameraConfig.always_error_if_entity_unavailable ) { dispatchLiveErrorEvent(this); return renderNotificationBlockFromText( `${localize('error.live_camera_unavailable')}${ this.label ? `: ${this.label}` : '' }`, { icon: 'mdi:cctv-off', in_progress: true }, ); } } else { this._entityHasBeenAvailable = true; } } const showImageDuringLoading = this._shouldShowImageDuringLoading(); const showLoadingIcon = !this._mediaLoadedInfoSinkController.has(); const classes = { hidden: showImageDuringLoading, }; return html`${this._renderContainer(html` ${showImageDuringLoading || provider === 'image' ? html` this._providerErrorHandler(ev)} @advanced-camera-card:media:loaded=${(ev: Event) => { // When the image is rendered as a placeholder behind another // provider, suppress its load event so it doesn't reach the // card-root listener and clobber the real provider's // registration. The real provider's load event will arrive // afterwards. if (provider !== 'image') { ev.stopPropagation(); } }} > ` : html``} ${provider === 'ha' ? html` this._providerErrorHandler(ev)} > ` : provider === 'go2rtc' ? html` this._providerErrorHandler(ev)} > ` : provider === 'webrtc-card' ? html` this._providerErrorHandler(ev)} > ` : provider === 'jsmpeg' ? html` this._providerErrorHandler(ev)} > ` : html``} `)} ${showLoadingIcon ? html` fireAdvancedCameraCardEvent(this, 'issue:notify', 'media_load')} >` : ''}`; } static get styles(): CSSResultGroup { return unsafeCSS(liveProviderStyle); } } declare global { interface HTMLElementTagNameMap { 'advanced-camera-card-live-provider': AdvancedCameraCardLiveProvider; } }