305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
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<MediaPlayerElement> = 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<MediaPlayerController | null> {
|
|
await this.updateComplete;
|
|
return (await this._refProvider.value?.getMediaPlayerController()) ?? null;
|
|
}
|
|
|
|
private async _switchToRelatedClipView(): Promise<void> {
|
|
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<void> {
|
|
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` <advanced-camera-card-media-dimensions-container
|
|
.dimensionsConfig=${this._getRelevantCameraConfig()?.dimensions}
|
|
>
|
|
${template}
|
|
</advanced-camera-card-media-dimensions-container>`;
|
|
|
|
return html`
|
|
${this.viewerConfig?.zoomable
|
|
? html`<advanced-camera-card-zoomer
|
|
.defaultSettings=${guard([cameraConfig?.dimensions?.layout], () =>
|
|
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<ZoomSettingsObserved>,
|
|
) =>
|
|
handleZoomSettingsObservedEvent(
|
|
ev,
|
|
this.viewManagerEpoch?.manager,
|
|
mediaID,
|
|
)}
|
|
>
|
|
${intermediateTemplate}
|
|
</advanced-camera-card-zoomer>`
|
|
: 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 <video> below in order to
|
|
// allow screenshot of motionEye videos which currently go cross-origin.
|
|
return this._renderContainer(html`
|
|
${ViewItemClassifier.isVideo(this.media)
|
|
? this.media.getVideoContentType() === VideoContentType.HLS
|
|
? html`<advanced-camera-card-ha-hls-player
|
|
${ref(this._refProvider)}
|
|
allow-exoplayer
|
|
aria-label="${this.media.getTitle() ?? ''}"
|
|
?autoplay=${false}
|
|
controls
|
|
muted
|
|
playsinline
|
|
title="${this.media.getTitle() ?? ''}"
|
|
url=${url}
|
|
.hass=${this.hass}
|
|
?controls=${this.viewerConfig.controls.builtin}
|
|
>
|
|
</advanced-camera-card-ha-hls-player>`
|
|
: html`
|
|
<advanced-camera-card-video-player
|
|
${ref(this._refProvider)}
|
|
url=${url}
|
|
aria-label="${this.media.getTitle() ?? ''}"
|
|
title="${this.media.getTitle() ?? ''}"
|
|
?controls=${this.viewerConfig.controls.builtin}
|
|
>
|
|
</advanced-camera-card-video-player>
|
|
`
|
|
: html`<advanced-camera-card-image-player
|
|
${ref(this._refProvider)}
|
|
url="${url}"
|
|
aria-label="${this.media.getTitle() ?? ''}"
|
|
title="${this.media.getTitle() ?? ''}"
|
|
@click=${() => {
|
|
if (this.viewerConfig?.snapshot_click_plays_clip) {
|
|
this._switchToRelatedClipView();
|
|
}
|
|
}}
|
|
></advanced-camera-card-image-player>`}
|
|
`);
|
|
}
|
|
|
|
static get styles(): CSSResultGroup {
|
|
return unsafeCSS(viewerProviderStyle);
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'advanced-camera-card-viewer-provider': AdvancedCameraCardViewerProvider;
|
|
}
|
|
}
|