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 { CameraEndpoints } from '../../camera-manager/types.js'; import { MicrophoneState } from '../../card-controller/types.js'; import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js'; import { PartialZoomSettings } from '../../components-lib/zoom/types.js'; import { CameraConfig, CardWideConfig, configDefaults, LiveConfig, LiveProvider, } from '../../config/types.js'; import { STREAM_TROUBLESHOOTING_URL } from '../../const.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 { aspectRatioToString } from '../../utils/basic.js'; import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js'; import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; import '../icon.js'; import { renderMessage } from '../message.js'; import '../next-prev-control.js'; import '../ptz.js'; import '../surround.js'; @customElement('advanced-camera-card-live-provider') export class AdvancedCameraCardLiveProvider extends LitElement implements MediaPlayer { @property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public cameraConfig?: CameraConfig; @property({ attribute: false }) public cameraEndpoints?: CameraEndpoints; @property({ attribute: false }) public liveConfig?: LiveConfig; // Whether or not to load the video for this camera. If `false`, no contents // are rendered until this attribute is set to `true` (this is useful for lazy // loading). @property({ attribute: true, type: Boolean }) public load = false; // 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; @state() protected _isVideoMediaLoaded = false; @state() protected _hasProviderError = false; @state() protected _showStreamTroubleshooting = false; protected _refProvider: Ref = createRef(); // 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. protected _importPromises: Promise[] = []; public async getMediaPlayerController(): Promise { await this.updateComplete; return (await this._refProvider.value?.getMediaPlayerController()) ?? null; } /** * Get the fully resolved live provider. * @returns A live provider (that is not 'auto'). */ protected _getResolvedProvider(): Omit { if (this.cameraConfig?.live_provider === 'auto') { if ( this.cameraConfig?.webrtc_card?.entity || this.cameraConfig?.webrtc_card?.url ) { return 'webrtc-card'; } else if (this.cameraConfig?.camera_entity) { return 'ha'; } else if (this.cameraConfig?.frigate.camera_name) { return 'jsmpeg'; } return configDefaults.cameras.live_provider; } return this.cameraConfig?.live_provider || 'image'; } /** * Determine if a camera image should be shown in lieu of the real stream * whilst loading. * @returns`true` if an image should be shown. */ protected _shouldShowImageDuringLoading(): boolean { return ( !this._isVideoMediaLoaded && !!this.cameraConfig?.camera_entity && !!this.hass && !!this.liveConfig?.show_image_during_load && !this._showStreamTroubleshooting && // Do not continue to show image during loading if an error has occurred. !this._hasProviderError ); } public disconnectedCallback(): void { this._isVideoMediaLoaded = false; } protected _videoMediaShowHandler(): void { this._isVideoMediaLoaded = true; this._showStreamTroubleshooting = false; } protected _providerErrorHandler(): void { this._hasProviderError = true; } protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('load')) { if (!this.load) { this._isVideoMediaLoaded = false; dispatchMediaUnloadedEvent(this); } } 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('cameraConfig')) { const provider = this._getResolvedProvider(); 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')); } updateElementStyleFromMediaLayoutConfig( this, this.cameraConfig?.dimensions?.layout, ); this.style.aspectRatio = aspectRatioToString({ ratio: this.cameraConfig?.dimensions?.aspect_ratio, }); } } 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; } protected _useZoomIfRequired(template: TemplateResult): TemplateResult { return this.liveConfig?.zoomable ? html` this.cameraConfig?.dimensions?.layout ? { pan: this.cameraConfig.dimensions.layout.pan, zoom: this.cameraConfig.dimensions.layout.zoom, } : undefined, )} .settings=${this.zoomSettings} @advanced-camera-card:zoom:zoomed=${async () => (await this.getMediaPlayerController())?.setControls(false)} @advanced-camera-card:zoom:unzoomed=${async () => (await this.getMediaPlayerController())?.setControls()} > ${template} ` : template; } protected render(): TemplateResult | void { if (!this.load || !this.hass || !this.liveConfig || !this.cameraConfig) { return; } // Set title and ariaLabel from the provided label property. this.title = this.label; this.ariaLabel = this.label; const provider = this._getResolvedProvider(); const showImageDuringLoading = this._shouldShowImageDuringLoading(); const showLoadingIcon = !this._isVideoMediaLoaded; const providerClasses = { hidden: showImageDuringLoading, }; if ( provider === 'ha' || provider === 'image' || (this.cameraConfig?.camera_entity && this.cameraConfig.always_error_if_entity_unavailable) ) { if (!this.cameraConfig?.camera_entity) { dispatchLiveErrorEvent(this); return renderMessage({ message: localize('error.no_live_camera'), type: 'error', icon: 'mdi:camera', context: this.cameraConfig, }); } const stateObj = this.hass.states[this.cameraConfig.camera_entity]; if (!stateObj) { dispatchLiveErrorEvent(this); return renderMessage({ message: localize('error.live_camera_not_found'), type: 'error', icon: 'mdi:camera', context: this.cameraConfig, }); } if (stateObj.state === 'unavailable') { dispatchLiveErrorEvent(this); dispatchMediaUnloadedEvent(this); return renderMessage({ message: `${localize('error.live_camera_unavailable')}${ this.label ? `: ${this.label}` : '' }`, type: 'info', icon: 'mdi:cctv-off', dotdotdot: true, }); } } return html`${this._useZoomIfRequired(html` ${showImageDuringLoading || provider === 'image' ? html` this._providerErrorHandler()} @advanced-camera-card:media:loaded=${(ev: Event) => { if (provider === 'image') { // Only count the media has loaded if the required provider is // the image (not just the temporary image shown during // loading). this._videoMediaShowHandler(); } else { ev.stopPropagation(); } }} > ` : html``} ${provider === 'ha' ? html` this._providerErrorHandler()} @advanced-camera-card:media:loaded=${this._videoMediaShowHandler.bind(this)} > ` : provider === 'go2rtc' ? html` this._providerErrorHandler()} @advanced-camera-card:media:loaded=${this._videoMediaShowHandler.bind( this, )} > ` : provider === 'webrtc-card' ? html` this._providerErrorHandler()} @advanced-camera-card:media:loaded=${this._videoMediaShowHandler.bind( this, )} > ` : provider === 'jsmpeg' ? html` this._providerErrorHandler()} @advanced-camera-card:media:loaded=${this._videoMediaShowHandler.bind( this, )} > ` : html``} `)} ${showLoadingIcon ? html` { this._showStreamTroubleshooting = !this._showStreamTroubleshooting; }} >` : ''} ${this._showStreamTroubleshooting ? renderMessage( { type: 'error', icon: 'mdi:camera-off', message: localize('error.stream_not_loading'), troubleshootingURL: STREAM_TROUBLESHOOTING_URL, }, { overlay: true }, ) : ''}`; } static get styles(): CSSResultGroup { return unsafeCSS(liveProviderStyle); } } declare global { interface HTMLElementTagNameMap { 'advanced-camera-card-live-provider': AdvancedCameraCardLiveProvider; } }