import { html, LitElement, unsafeCSS, type CSSResultGroup, type PropertyValues, type TemplateResult, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { guard } from 'lit/directives/guard.js'; import { createRef, ref, type Ref } from 'lit/directives/ref.js'; import type { CameraManager } from '../../camera-manager/manager.js'; import { QueryType } from '../../camera-manager/types.js'; import type { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { LazyLoadController } from '../../components-lib/lazy-load-controller.js'; import { MediaLoadWatchdogController } from '../../components-lib/media-load-watchdog-controller.js'; import { getSignedURLErrorText, SignedURLController, } from '../../components-lib/signed-url-controller.js'; import type { ZoomSettingsObserved } from '../../components-lib/zoom/types.js'; import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js'; import type { CameraConfig } from '../../config/schema/cameras.js'; import type { CardWideConfig } from '../../config/schema/types.js'; import type { ViewerConfig } from '../../config/schema/viewer.js'; import { canonicalizeHAURL } from '../../ha/canonical-url.js'; import { isHARelativeURL } from '../../ha/is-ha-relative-url.js'; import { resolveMedia, type ResolvedMediaCache } from '../../ha/resolved-media.js'; import type { HomeAssistant, ResolvedMedia } from '../../ha/types.js'; import '../../patches/ha-hls-player.js'; import viewerProviderStyle from '../../scss/viewer-provider.scss?inline'; import type { MediaPlayer, MediaPlayerController, MediaPlayerElement, } from '../../types.js'; import { Generation } from '../../utils/concurrency/generation.js'; import { classifyMimeType } from '../../utils/mime-type.js'; import { ViewItemClassifier } from '../../view/item-classifier.js'; import type { 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; // Whether to force this slide to behave as if it is selected and // intersecting. Set by the carousel on its currently-selected slide. This is // necessary: `render` below draws nothing until the slide has loaded, and a // slide drawing nothing can have no height for IntersectionObserver to see. // Left to the observer only, the slide would wait to be seen before drawing // anything there was to see. See `LazyLoadConfiguration.forceSelected`. @property({ attribute: false }) public forceSelected = false; private _refProvider: Ref = createRef(); private _lazyLoadController: LazyLoadController = new LazyLoadController(this); private _resolvedMedia: ResolvedMedia | null = null; // Drops a slow resolution for media the provider has since moved off. private _resolveGeneration = new Generation(); 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()); // Watch for media load failure (including resolving media ID and signing). new MediaLoadWatchdogController(this, { getTargetID: () => this.media?.getID() ?? null, isLoadExpected: () => this._shouldLoad(), }); } 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._shouldLoad()) { this._resolveGeneration.invalidate(); this._resolvedMedia = null; return; } const generation = this._resolveGeneration.next(); // Clear immediately so the SignedURLController doesn't see a stale URL // from the previous media item during the async gap. this._resolvedMedia = null; const resolved = this.resolvedMediaCache?.get(contentID) ?? (await resolveMedia(this.hass, contentID, this.resolvedMediaCache)) ?? null; if (!this._resolveGeneration.isCurrent(generation)) { return; } this._resolvedMedia = resolved; this.requestUpdate(); } protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('viewerConfig') || changedProps.has('forceSelected')) { this._lazyLoadController.setConfiguration({ lazyLoad: this.viewerConfig?.lazy_load, forceSelected: this.forceSelected, }); } 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 _shouldLoad(): boolean { return this._lazyLoadController.isLoaded(); } 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._shouldLoad() || !this.media || !this.hass || !this.viewerConfig) { return; } const error = this._signedURLController.getError(); if (error) { const contentID = this.media?.getContentID(); return renderNotificationBlockFromText(getSignedURLErrorText(error), { ...(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