import { CSSResultGroup, html, LitElement, PropertyValues, TemplateResult, unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { guard } from 'lit/directives/guard.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../../camera-manager/manager.js'; import { QueryType } from '../../camera-manager/types.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { LazyLoadController } from '../../components-lib/lazy-load-controller.js'; import { SignedURLController } from '../../components-lib/signed-url-controller.js'; import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js'; import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js'; import { CameraConfig } from '../../config/schema/cameras.js'; import { CardWideConfig } from '../../config/schema/types.js'; import { ViewerConfig } from '../../config/schema/viewer.js'; import { canonicalizeHAURL } from '../../ha/canonical-url.js'; import { isHARelativeURL } from '../../ha/is-ha-relative-url.js'; import { ResolvedMediaCache, resolveMedia } from '../../ha/resolved-media.js'; import { HomeAssistant } from '../../ha/types.js'; import { localize } from '../../localize/localize.js'; import '../../patches/ha-hls-player.js'; import viewerProviderStyle from '../../scss/viewer-provider.scss'; import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js'; import { ViewItemClassifier } from '../../view/item-classifier.js'; import { VideoContentType, ViewMedia } from '../../view/item.js'; import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js'; import '../image-player.js'; import { renderMessage } from '../message.js'; import { renderProgressIndicator } from '../progress-indicator.js'; import '../video-player.js'; import './../media-dimensions-container'; @customElement('advanced-camera-card-viewer-provider') export class AdvancedCameraCardViewerProvider extends LitElement implements MediaPlayer { @property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) public media?: ViewMedia; @property({ attribute: false }) public viewerConfig?: ViewerConfig; @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; @property({ attribute: false }) public cameraManager?: CameraManager; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; private _refProvider: Ref = createRef(); private _lazyLoadController: LazyLoadController = new LazyLoadController(this); private _resolvedMediaURL: string | null = null; private _signedURLController = new SignedURLController(this, () => { if (!this.hass || !this._resolvedMediaURL) { return {}; } // HA-relative URLs need no proxying or signing. if (isHARelativeURL(this._resolvedMediaURL)) { return { endpoint: { endpoint: canonicalizeHAURL(this.hass, this._resolvedMediaURL) }, }; } const cameraID = this.media?.getCameraID(); const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null; return { hass: this.hass, endpoint: { endpoint: this._resolvedMediaURL }, proxyConfig: camera?.getMediaProxyConfig(), }; }); constructor() { super(); this._lazyLoadController.addListener((loaded) => loaded && this._resolveURL()); } public async getMediaPlayerController(): Promise { await this.updateComplete; return (await this._refProvider.value?.getMediaPlayerController()) ?? null; } private async _switchToRelatedClipView(): Promise { const view = this.viewManagerEpoch?.manager.getView(); if ( !this.hass || !view || !this.cameraManager || !this.media || // If this specific media item has no clip, then do nothing (even if all // the other media items do). !ViewItemClassifier.isEvent(this.media) || !view.query?.hasMediaQueriesOfType(QueryType.Event) ) { return; } // Convert the query to a clips equivalent. const clipQuery = UnifiedQueryTransformer.convertToClips(view.query); await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ params: { view: 'media', query: clipQuery, }, queryExecutorOptions: { selectResult: { id: this.media.getID() ?? undefined, }, rejectResults: (results) => !results.hasSelectedResult(), }, }); } private async _resolveURL(): Promise { const contentID = this.media?.getContentID(); if (!contentID || !this.hass || !this._lazyLoadController?.isLoaded()) { this._resolvedMediaURL = null; return; } // Clear immediately so the SignedURLController doesn't see a stale URL // from the previous media item during the async gap. this._resolvedMediaURL = null; const resolved = this.resolvedMediaCache?.get(contentID) ?? (await resolveMedia(this.hass, contentID, this.resolvedMediaCache)); this._resolvedMediaURL = resolved?.url ?? null; this.requestUpdate(); } protected willUpdate(changedProps: PropertyValues): void { if ( changedProps.has('viewerConfig') || (!this._lazyLoadController && this.viewerConfig) ) { this._lazyLoadController.setConfiguration(this.viewerConfig?.lazy_load); } if ( changedProps.has('media') || changedProps.has('viewerConfig') || changedProps.has('resolvedMediaCache') || changedProps.has('hass') ) { this._resolveURL(); } if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) { import('../zoomer.js'); } } private _getRelevantCameraConfig(): CameraConfig | null { const cameraID = this.media?.getCameraID(); return cameraID ? this.cameraManager?.getStore().getCameraConfig(cameraID) ?? null : null; } private _renderContainer(template: TemplateResult): TemplateResult { if (!this.media) { return template; } const cameraID = this.media.getCameraID(); const mediaID = this.media.getID() ?? undefined; const cameraConfig = cameraID ? this.cameraManager?.getStore().getCameraConfig(cameraID) ?? null : null; const view = this.viewManagerEpoch?.manager.getView(); const intermediateTemplate = html` ${template} `; return html` ${this.viewerConfig?.zoomable ? html` cameraConfig?.dimensions?.layout ? { pan: cameraConfig.dimensions.layout.pan, zoom: cameraConfig.dimensions.layout.zoom, } : undefined, )} .settings=${mediaID ? view?.context?.zoom?.[mediaID]?.requested : undefined} @advanced-camera-card:zoom:zoomed=${async () => (await this.getMediaPlayerController())?.setControls(false)} @advanced-camera-card:zoom:unzoomed=${async () => (await this.getMediaPlayerController())?.setControls()} @advanced-camera-card:zoom:change=${( ev: CustomEvent, ) => handleZoomSettingsObservedEvent( ev, this.viewManagerEpoch?.manager, mediaID, )} > ${intermediateTemplate} ` : intermediateTemplate} `; } protected render(): TemplateResult | void { if ( !this._lazyLoadController?.isLoaded() || !this.media || !this.hass || !this.viewerConfig ) { return; } const error = this._signedURLController.getError(); if (error) { return renderMessage({ type: 'error', message: localize( error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign', ), context: this.media?.getContentID(), }); } const url = this._signedURLController.getValue(); if (!url) { return renderProgressIndicator({ cardWideConfig: this.cardWideConfig, }); } // Note: crossorigin="anonymous" is required on