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, ResolvedMedia } 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 { classifyMimeType } from '../../utils/mime-type.js'; import { ViewItemClassifier } from '../../view/item-classifier.js'; import { ViewMedia } from '../../view/item.js'; import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js'; import '../image-player.js'; import { renderNotificationBlockFromText } from '../notification/block.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 _resolvedMedia: ResolvedMedia | null = null; private _signedURLController = new SignedURLController(this, () => { if (!this.hass || !this._resolvedMedia) { return {}; } // HA-relative URLs need no proxying or signing. if (isHARelativeURL(this._resolvedMedia.url)) { return { endpoint: { endpoint: canonicalizeHAURL(this.hass, this._resolvedMedia.url) }, }; } const cameraID = this.media?.getCameraID(); const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null; return { hass: this.hass, endpoint: { endpoint: this._resolvedMedia.url }, 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._resolvedMedia = null; return; } // Clear immediately so the SignedURLController doesn't see a stale URL // from the previous media item during the async gap. this._resolvedMedia = null; this._resolvedMedia = this.resolvedMediaCache?.get(contentID) ?? (await resolveMedia(this.hass, contentID, this.resolvedMediaCache)) ?? null; this.requestUpdate(); } protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('viewerConfig')) { this._lazyLoadController.setConfiguration({ lazyLoad: this.viewerConfig?.lazy_load, }); } if ( changedProps.has('media') || changedProps.has('viewerConfig') || changedProps.has('resolvedMediaCache') || changedProps.has('hass') ) { void this._resolveURL(); } if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) { void 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) { const contentID = this.media?.getContentID(); return renderNotificationBlockFromText( localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'), { ...(contentID && { metadata: [{ text: contentID, icon: 'mdi:identifier' }] }), }, ); } const url = this._signedURLController.getValue(); if (!url) { return renderProgressIndicator({ cardWideConfig: this.cardWideConfig, }); } // Note: crossorigin="anonymous" is required on