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