382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
import {
|
|
CSSResultGroup,
|
|
html,
|
|
LitElement,
|
|
PropertyValues,
|
|
TemplateResult,
|
|
unsafeCSS,
|
|
} from 'lit';
|
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
import { classMap } from 'lit/directives/class-map.js';
|
|
import { guard } from 'lit/directives/guard.js';
|
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
|
import { Camera } from '../../camera-manager/camera.js';
|
|
import { MicrophoneState } from '../../card-controller/types.js';
|
|
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
|
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
|
|
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
|
|
import { PartialZoomSettings } from '../../components-lib/zoom/types.js';
|
|
import { LiveConfig } from '../../config/schema/live.js';
|
|
import { CardWideConfig } from '../../config/schema/types.js';
|
|
import { HomeAssistant } from '../../ha/types.js';
|
|
import { localize } from '../../localize/localize.js';
|
|
import liveProviderStyle from '../../scss/live-provider.scss';
|
|
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js';
|
|
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event.js';
|
|
import { getResolvedLiveProvider } from '../../utils/live-provider.js';
|
|
import '../icon.js';
|
|
import { renderNotificationBlockFromText } from '../notification/block.js';
|
|
import './../media-dimensions-container';
|
|
|
|
@customElement('advanced-camera-card-live-provider')
|
|
export class AdvancedCameraCardLiveProvider extends LitElement implements MediaPlayer {
|
|
@property({ attribute: false })
|
|
public hass?: HomeAssistant;
|
|
|
|
@property({ attribute: false })
|
|
public camera?: Camera;
|
|
|
|
// The BASE camera ID (camera property may be a substream)
|
|
@property({ attribute: false })
|
|
public targetID?: string;
|
|
|
|
@property({ attribute: false })
|
|
public liveConfig?: LiveConfig;
|
|
|
|
// Label that is used for ARIA support and as tooltip.
|
|
@property({ attribute: false })
|
|
public label = '';
|
|
|
|
@property({ attribute: false })
|
|
public cardWideConfig?: CardWideConfig;
|
|
|
|
@property({ attribute: false })
|
|
public microphoneState?: MicrophoneState;
|
|
|
|
@property({ attribute: false })
|
|
public zoomSettings?: PartialZoomSettings | null;
|
|
|
|
@property({ attribute: false })
|
|
public zoom = true;
|
|
|
|
private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, {
|
|
getTargetID: () => this.targetID ?? null,
|
|
});
|
|
|
|
@state()
|
|
private _zoomed = false;
|
|
|
|
@state()
|
|
private _hasProviderError = false;
|
|
|
|
// Whether the camera entity has ever been in a non-unavailable state. Used
|
|
// to suppress transient unavailability errors for entities that were
|
|
// previously working (e.g. during PTZ operations).
|
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2124
|
|
private _entityHasBeenAvailable = false;
|
|
|
|
private _refProvider: Ref<MediaPlayerElement> = createRef();
|
|
|
|
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
|
|
|
|
// A note on dynamic imports:
|
|
//
|
|
// We gather the dynamic live provider import promises and do not consider the
|
|
// update of the element complete until these imports have returned. Without
|
|
// this behavior calls to the media methods (e.g. `mute()`) may throw if the
|
|
// underlying code is not yet loaded.
|
|
//
|
|
// Test case: A card with a non-live view, but live pre-loaded, attempts to
|
|
// call mute() when the <advanced-camera-card-live> element first renders in
|
|
// the background. These calls fail without waiting for loading here.
|
|
private _importPromises: Promise<unknown>[] = [];
|
|
|
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
|
await this.updateComplete;
|
|
return (await this._refProvider.value?.getMediaPlayerController()) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Determine if a camera image should be shown in lieu of the real stream
|
|
* whilst loading.
|
|
* @returns`true` if an image should be shown.
|
|
*/
|
|
private _shouldShowImageDuringLoading(): boolean {
|
|
return (
|
|
!this._mediaLoadedInfoSinkController.has() &&
|
|
!!this.camera?.getConfig()?.camera_entity &&
|
|
!!this.hass &&
|
|
!!this.liveConfig?.show_image_during_load &&
|
|
!this._hasProviderError
|
|
);
|
|
}
|
|
|
|
public disconnectedCallback(): void {
|
|
this._entityHasBeenAvailable = false;
|
|
super.disconnectedCallback();
|
|
}
|
|
|
|
private _providerErrorHandler(ev: Event): void {
|
|
ev.stopPropagation();
|
|
this._hasProviderError = true;
|
|
|
|
if (this.targetID) {
|
|
fireAdvancedCameraCardEvent(this, 'issue:trigger', {
|
|
key: 'media_load' as const,
|
|
targetID: this.targetID,
|
|
});
|
|
}
|
|
}
|
|
|
|
protected willUpdate(changedProps: PropertyValues): void {
|
|
if (
|
|
changedProps.has('liveConfig') ||
|
|
(!this._lazyLoadController && this.liveConfig)
|
|
) {
|
|
this._lazyLoadController.setConfiguration(
|
|
this.liveConfig?.lazy_load,
|
|
this.liveConfig?.lazy_unload,
|
|
);
|
|
}
|
|
|
|
if (changedProps.has('liveConfig')) {
|
|
if (this.liveConfig?.show_image_during_load) {
|
|
this._importPromises.push(import('./providers/image.js'));
|
|
}
|
|
if (this.liveConfig?.zoomable) {
|
|
this._importPromises.push(import('../zoomer.js'));
|
|
}
|
|
}
|
|
|
|
if (changedProps.has('camera')) {
|
|
this._hasProviderError = false;
|
|
this._entityHasBeenAvailable = false;
|
|
|
|
const provider = getResolvedLiveProvider(this.camera?.getConfig());
|
|
if (provider === 'jsmpeg') {
|
|
this._importPromises.push(import('./providers/jsmpeg.js'));
|
|
} else if (provider === 'ha') {
|
|
this._importPromises.push(import('./providers/ha.js'));
|
|
} else if (provider === 'webrtc-card') {
|
|
this._importPromises.push(import('./providers/webrtc-card.js'));
|
|
} else if (provider === 'image') {
|
|
this._importPromises.push(import('./providers/image.js'));
|
|
} else if (provider === 'go2rtc') {
|
|
this._importPromises.push(import('./providers/go2rtc/index.js'));
|
|
}
|
|
}
|
|
}
|
|
|
|
override async getUpdateComplete(): Promise<boolean> {
|
|
// See 'A note on dynamic imports' above for explanation of why this is
|
|
// necessary.
|
|
const result = await super.getUpdateComplete();
|
|
await Promise.all(this._importPromises);
|
|
this._importPromises = [];
|
|
return result;
|
|
}
|
|
|
|
// Builtin (native) video controls require all three conditions:
|
|
// - controls.builtin: user config enables native controls.
|
|
// - zoom: Whether digital zoom/panning is allowed (this will be false when a
|
|
// 'gesture' type PTZ control is active).
|
|
// - !_zoomed: the user has not actually digital zoomed in (when zoomed, we
|
|
// want to hide the controls).
|
|
private _getEffectiveBuiltinControls(): boolean {
|
|
return !!this.liveConfig?.controls.builtin && this.zoom && !this._zoomed;
|
|
}
|
|
|
|
private _renderContainer(template: TemplateResult): TemplateResult {
|
|
const config = this.camera?.getConfig();
|
|
const intermediateTemplate = html` <advanced-camera-card-media-dimensions-container
|
|
.dimensionsConfig=${config?.dimensions}
|
|
>
|
|
${template}
|
|
</advanced-camera-card-media-dimensions-container>`;
|
|
|
|
return html` ${this.liveConfig?.zoomable
|
|
? html` <advanced-camera-card-zoomer
|
|
.defaultSettings=${guard([config?.dimensions?.layout], () =>
|
|
config?.dimensions?.layout
|
|
? {
|
|
pan: config.dimensions.layout.pan,
|
|
zoom: config.dimensions.layout.zoom,
|
|
}
|
|
: undefined,
|
|
)}
|
|
.settings=${this.zoomSettings}
|
|
.zoom=${this.zoom}
|
|
@advanced-camera-card:zoom:zoomed=${() => (this._zoomed = true)}
|
|
@advanced-camera-card:zoom:unzoomed=${() => (this._zoomed = false)}
|
|
>
|
|
${intermediateTemplate}
|
|
</advanced-camera-card-zoomer>`
|
|
: intermediateTemplate}`;
|
|
}
|
|
|
|
protected render(): TemplateResult | void {
|
|
const cameraConfig = this.camera?.getConfig();
|
|
if (
|
|
!this._lazyLoadController?.isLoaded() ||
|
|
!this.hass ||
|
|
!this.liveConfig ||
|
|
!this.camera ||
|
|
!cameraConfig
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Set title and ariaLabel from the provided label property.
|
|
this.title = this.label;
|
|
this.ariaLabel = this.label;
|
|
|
|
const provider = getResolvedLiveProvider(this.camera?.getConfig());
|
|
|
|
if (
|
|
provider === 'ha' ||
|
|
provider === 'image' ||
|
|
(cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable)
|
|
) {
|
|
if (!cameraConfig?.camera_entity) {
|
|
dispatchLiveErrorEvent(this);
|
|
return renderNotificationBlockFromText(localize('error.no_live_camera'), {
|
|
icon: 'mdi:camera',
|
|
context: cameraConfig,
|
|
});
|
|
}
|
|
|
|
const stateObj = this.hass.states[cameraConfig.camera_entity];
|
|
if (!stateObj) {
|
|
dispatchLiveErrorEvent(this);
|
|
return renderNotificationBlockFromText(localize('error.live_camera_not_found'), {
|
|
icon: 'mdi:camera',
|
|
context: cameraConfig,
|
|
});
|
|
}
|
|
|
|
if (stateObj.state === 'unavailable') {
|
|
if (
|
|
!this._entityHasBeenAvailable ||
|
|
cameraConfig.always_error_if_entity_unavailable
|
|
) {
|
|
dispatchLiveErrorEvent(this);
|
|
return renderNotificationBlockFromText(
|
|
`${localize('error.live_camera_unavailable')}${
|
|
this.label ? `: ${this.label}` : ''
|
|
}`,
|
|
{ icon: 'mdi:cctv-off', in_progress: true },
|
|
);
|
|
}
|
|
} else {
|
|
this._entityHasBeenAvailable = true;
|
|
}
|
|
}
|
|
|
|
const showImageDuringLoading = this._shouldShowImageDuringLoading();
|
|
const showLoadingIcon = !this._mediaLoadedInfoSinkController.has();
|
|
|
|
const classes = {
|
|
hidden: showImageDuringLoading,
|
|
};
|
|
|
|
return html`${this._renderContainer(html`
|
|
${showImageDuringLoading || provider === 'image'
|
|
? html` <advanced-camera-card-live-image
|
|
${ref(this._refProvider)}
|
|
.hass=${this.hass}
|
|
.camera=${this.camera}
|
|
.targetID=${this.targetID}
|
|
class=${classMap({
|
|
...classes,
|
|
// The image provider is providing the temporary loading image,
|
|
// so it should not be hidden.
|
|
hidden: false,
|
|
})}
|
|
@advanced-camera-card:live:error=${(ev: Event) =>
|
|
this._providerErrorHandler(ev)}
|
|
@advanced-camera-card:media:loaded=${(ev: Event) => {
|
|
// When the image is rendered as a placeholder behind another
|
|
// provider, suppress its load event so it doesn't reach the
|
|
// card-root listener and clobber the real provider's
|
|
// registration. The real provider's load event will arrive
|
|
// afterwards.
|
|
if (provider !== 'image') {
|
|
ev.stopPropagation();
|
|
}
|
|
}}
|
|
>
|
|
</advanced-camera-card-live-image>`
|
|
: html``}
|
|
${provider === 'ha'
|
|
? html` <advanced-camera-card-live-ha
|
|
${ref(this._refProvider)}
|
|
class=${classMap(classes)}
|
|
.hass=${this.hass}
|
|
.camera=${this.camera}
|
|
.targetID=${this.targetID}
|
|
?controls=${this._getEffectiveBuiltinControls()}
|
|
@advanced-camera-card:live:error=${(ev: Event) =>
|
|
this._providerErrorHandler(ev)}
|
|
>
|
|
</advanced-camera-card-live-ha>`
|
|
: provider === 'go2rtc'
|
|
? html`<advanced-camera-card-live-go2rtc
|
|
${ref(this._refProvider)}
|
|
class=${classMap(classes)}
|
|
.hass=${this.hass}
|
|
.camera=${this.camera}
|
|
.targetID=${this.targetID}
|
|
.microphoneState=${this.microphoneState}
|
|
.microphoneConfig=${this.liveConfig.microphone}
|
|
?controls=${this._getEffectiveBuiltinControls()}
|
|
@advanced-camera-card:live:error=${(ev: Event) =>
|
|
this._providerErrorHandler(ev)}
|
|
>
|
|
</advanced-camera-card-live-go2rtc>`
|
|
: provider === 'webrtc-card'
|
|
? html`<advanced-camera-card-live-webrtc-card
|
|
${ref(this._refProvider)}
|
|
class=${classMap(classes)}
|
|
.hass=${this.hass}
|
|
.camera=${this.camera}
|
|
.targetID=${this.targetID}
|
|
.cardWideConfig=${this.cardWideConfig}
|
|
?controls=${this._getEffectiveBuiltinControls()}
|
|
@advanced-camera-card:live:error=${(ev: Event) =>
|
|
this._providerErrorHandler(ev)}
|
|
>
|
|
</advanced-camera-card-live-webrtc-card>`
|
|
: provider === 'jsmpeg'
|
|
? html` <advanced-camera-card-live-jsmpeg
|
|
${ref(this._refProvider)}
|
|
class=${classMap(classes)}
|
|
.hass=${this.hass}
|
|
.camera=${this.camera}
|
|
.targetID=${this.targetID}
|
|
.cardWideConfig=${this.cardWideConfig}
|
|
@advanced-camera-card:live:error=${(ev: Event) =>
|
|
this._providerErrorHandler(ev)}
|
|
>
|
|
</advanced-camera-card-live-jsmpeg>`
|
|
: html``}
|
|
`)}
|
|
${showLoadingIcon
|
|
? html`<advanced-camera-card-icon
|
|
title=${localize('error.awaiting_live')}
|
|
.icon=${{ icon: 'mdi:progress-helper' }}
|
|
@click=${() => fireAdvancedCameraCardEvent(this, 'issue:notify', 'media_load')}
|
|
></advanced-camera-card-icon>`
|
|
: ''}`;
|
|
}
|
|
|
|
static get styles(): CSSResultGroup {
|
|
return unsafeCSS(liveProviderStyle);
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'advanced-camera-card-live-provider': AdvancedCameraCardLiveProvider;
|
|
}
|
|
}
|