diff --git a/src/card.ts b/src/card.ts index eb5ddf2d..7997e839 100644 --- a/src/card.ts +++ b/src/card.ts @@ -129,6 +129,15 @@ class AdvancedCameraCard extends LitElement { } } + set isPanel(isPanel: boolean) { + this._controller.getConditionStateManager().setState({ + panel: isPanel, + }); + } + get isPanel(): boolean { + return !!this._controller.getConditionStateManager().getState().panel; + } + public static async getConfigElement(): Promise { return await CardController.getConfigElement(); } @@ -380,6 +389,7 @@ class AdvancedCameraCard extends LitElement { .configManager=${this._controller.getConfigManager()} .hide=${!!this._controller.getMessageManager().hasMessage()} .microphoneState=${this._controller.getMicrophoneManager().getState()} + .conditionStateManager=${this._controller.getConditionStateManager()} .triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status ? this._controller.getTriggersManager().getTriggeredCameraIDs() : undefined} diff --git a/src/components-lib/media-provider-dimensions-controller.ts b/src/components-lib/media-provider-dimensions-controller.ts new file mode 100644 index 00000000..eca0df3c --- /dev/null +++ b/src/components-lib/media-provider-dimensions-controller.ts @@ -0,0 +1,158 @@ +import { ReactiveController, ReactiveControllerHost } from 'lit'; +import { throttle } from 'lodash-es'; +import { CameraDimensionsConfig } from '../config/schema/cameras'; +import { MediaLoadedInfo } from '../types'; +import { aspectRatioToString, setOrRemoveAttribute } from '../utils/basic'; +import { AdvancedCameraCardMediaLoadedEventTarget } from '../utils/media-info'; +import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout'; + +const SIZE_ATTRIBUTE = 'size'; +type SizeMode = 'sized' | 'unsized' | 'unsized-portrait' | 'unsized-landscape'; + +export class MediaProviderDimensionsController implements ReactiveController { + public resize = throttle(this._resizeHandler.bind(this), 100, { + trailing: true, + }); + + private _host: HTMLElement & + ReactiveControllerHost & + AdvancedCameraCardMediaLoadedEventTarget; + private _container: HTMLElement | null = null; + private _cameraConfig: CameraDimensionsConfig | null = null; + private _resizeObserver = new ResizeObserver(this.resize); + private _intendedHostSize: DOMRect | null = null; + + constructor(host: HTMLElement & ReactiveControllerHost) { + this._host = host; + this._host.addController(this); + } + + public hostConnected(): void { + this._host.addEventListener( + 'advanced-camera-card:media:loaded', + this._mediaLoadHandler, + ); + + this._resizeObserver.observe(this._host); + } + + public hostDisconnected(): void { + this._host.removeEventListener( + 'advanced-camera-card:media:loaded', + this._mediaLoadHandler, + ); + this._resizeObserver.disconnect(); + } + + public setContainer(container?: HTMLElement): void { + if (container === this._container) { + return; + } + this._container = container ?? null; + this._setAttributesFromConfig(); + } + + private _setAttributesFromConfig(): void { + if (this._container) { + this._container.style.aspectRatio = aspectRatioToString({ + ratio: this._cameraConfig?.aspect_ratio, + }); + } + + updateElementStyleFromMediaLayoutConfig(this._host, this._cameraConfig?.layout); + + // When the provider is not precisely sized, we guess the best aspect + // ratio to "maximize" if known. This prevents media "hopping" from no + // forced aspect ratio to a forced one, once its true size is known. + setOrRemoveAttribute( + this._host, + true, + SIZE_ATTRIBUTE, + this._cameraConfig?.aspect_ratio + ? this._cameraConfig?.aspect_ratio[0] >= this._cameraConfig?.aspect_ratio[1] + ? 'unsized-landscape' + : 'unsized-portrait' + : 'unsized', + ); + } + + public setCameraConfig(config?: CameraDimensionsConfig): void { + this._cameraConfig = config ?? null; + this._setAttributesFromConfig(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private _mediaLoadHandler = (_ev: CustomEvent): void => { + // Allow the browser to render the media fully before attempting to resize. + // Without this, viewer provider will not be sized correctly. + window.requestAnimationFrame(() => this.resize()); + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private _resizeHandler(_entries?: ResizeObserverEntry[]): void { + const rememberHostSize = (): void => { + this._intendedHostSize = this._host.getBoundingClientRect(); + }; + + const setUnsizedAttribute = (): void => { + setOrRemoveAttribute(this._host, true, SIZE_ATTRIBUTE, 'unsized'); + }; + + const setContainerIntrinsicSize = (container: HTMLElement): void => { + container.style.width = '100%'; + container.style.height = 'auto'; + rememberHostSize(); + }; + + const setContainerSize = ( + container: HTMLElement, + width: number, + height: number, + ): void => { + container.style.width = `${width}px`; + container.style.height = `${height}px`; + rememberHostSize(); + }; + + const hostSize = this._host.getBoundingClientRect(); + if ( + hostSize.width === this._intendedHostSize?.width && + hostSize.height === this._intendedHostSize?.height + ) { + return; + } + + if (!this._container) { + setUnsizedAttribute(); + return; + } + + // In the ideal case, the width can be maximum and the height can be + // whatever is necessary to support the aspect ratio. + setContainerIntrinsicSize(this._container); + + const containerSize = this._container.getBoundingClientRect(); + + if (!containerSize.width || !containerSize.height) { + setUnsizedAttribute(); + return; + } + + const mediaAspectRatio = containerSize.width / containerSize.height; + const newHostSize = this._host.getBoundingClientRect(); + + // If the container is larger than the host, the host was not able to expand + // enough to cover the size (e.g. fullscreen, panel or height constrained in + // configuration). In this case, just limit the container to the host height + // at the same aspect ratio. + if (containerSize.height > newHostSize.height) { + setContainerSize( + this._container, + newHostSize.height * mediaAspectRatio, + newHostSize.height, + ); + } + + setOrRemoveAttribute(this._host, true, SIZE_ATTRIBUTE, 'sized'); + } +} diff --git a/src/components/image-updating-player.ts b/src/components/image-updating-player.ts index 952ed888..52480fd3 100644 --- a/src/components/image-updating-player.ts +++ b/src/components/image-updating-player.ts @@ -64,7 +64,7 @@ export const resolveImageMode = (options?: { }; /** - * A media player to wrap a image that updates continuously. + * A media player to wrap an image that updates continuously. */ @customElement('advanced-camera-card-image-updating-player') export class AdvancedCameraCardImageUpdatingPlayer diff --git a/src/components/image.ts b/src/components/image.ts index 9d16438e..4f1bf58f 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -11,6 +11,7 @@ import { guard } from 'lit/directives/guard.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { CameraManager } from '../camera-manager/manager'; import { ViewManagerEpoch } from '../card-controller/view/types'; +import { MediaProviderDimensionsController } from '../components-lib/media-provider-dimensions-controller'; import { ZoomSettingsObserved } from '../components-lib/zoom/types'; import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context'; import { CameraConfig } from '../config/schema/cameras'; @@ -19,8 +20,6 @@ import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const'; import { HomeAssistant } from '../ha/types'; import imageStyle from '../scss/image.scss'; import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types.js'; -import { aspectRatioToString } from '../utils/basic'; -import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; import './image-updating-player'; import { resolveImageMode } from './image-updating-player'; import './zoomer.js'; @@ -42,36 +41,29 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer { @property({ attribute: false }) public imageConfig?: ImageViewConfig; + protected _dimensionsController = new MediaProviderDimensionsController(this); + protected _refImage: Ref = createRef(); + protected _refContainer: Ref = createRef(); + public async getMediaPlayerController(): Promise { await this.updateComplete; return (await this._refImage.value?.getMediaPlayerController()) ?? null; } - protected _refImage: Ref = createRef(); - protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('cameraConfig') || changedProps.has('imageConfig')) { - if ( + this._dimensionsController.setCameraConfig( resolveImageMode({ imageConfig: this.imageConfig, cameraConfig: this.cameraConfig, }) === 'camera' - ) { - updateElementStyleFromMediaLayoutConfig( - this, - this.cameraConfig?.dimensions?.layout, - ); - this.style.aspectRatio = aspectRatioToString({ - ratio: this.cameraConfig?.dimensions?.aspect_ratio, - }); - } else { - updateElementStyleFromMediaLayoutConfig(this); - this.style.removeProperty('aspect-ratio'); - } + ? this.cameraConfig?.dimensions + : undefined, + ); } } - protected _useZoomIfRequired(template: TemplateResult): TemplateResult { + protected _renderContainer(template: TemplateResult): TemplateResult { const zoomTarget = IMAGE_VIEW_ZOOM_TARGET_SENTINEL; const view = this.viewManagerEpoch?.manager.getView(); const mode = resolveImageMode({ @@ -79,29 +71,33 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer { cameraConfig: this.cameraConfig, }); - return this.imageConfig?.zoomable - ? html` - mode === 'camera' && this.cameraConfig?.dimensions?.layout - ? { - pan: this.cameraConfig.dimensions.layout.pan, - zoom: this.cameraConfig.dimensions.layout.zoom, - } - : undefined, - )} - .settings=${view?.context?.zoom?.[zoomTarget]?.requested} - @advanced-camera-card:zoom:change=${(ev: CustomEvent) => - handleZoomSettingsObservedEvent( - ev, - this.viewManagerEpoch?.manager, - zoomTarget, + return html`
+ ${this.imageConfig?.zoomable + ? html` + mode === 'camera' && this.cameraConfig?.dimensions?.layout + ? { + pan: this.cameraConfig.dimensions.layout.pan, + zoom: this.cameraConfig.dimensions.layout.zoom, + } + : undefined, )} - > - ${template} - ` - : template; + .settings=${view?.context?.zoom?.[zoomTarget]?.requested} + @advanced-camera-card:zoom:change=${( + ev: CustomEvent, + ) => + handleZoomSettingsObservedEvent( + ev, + this.viewManagerEpoch?.manager, + zoomTarget, + )} + > + ${template} + ` + : template} +
`; } protected render(): TemplateResult | void { @@ -109,7 +105,7 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer { return; } - return this._useZoomIfRequired(html` + return this._renderContainer(html` = createRef(); + protected _refContainer: Ref = createRef(); protected _lazyLoadController: LazyLoadController = new LazyLoadController(this); + protected _dimensionsController = new MediaProviderDimensionsController(this); // A note on dynamic imports: // @@ -184,13 +187,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP this._importPromises.push(import('./providers/go2rtc/index.js')); } - updateElementStyleFromMediaLayoutConfig( - this, - this.cameraConfig?.dimensions?.layout, - ); - this.style.aspectRatio = aspectRatioToString({ - ratio: this.cameraConfig?.dimensions?.aspect_ratio, - }); + this._dimensionsController.setCameraConfig(this.cameraConfig?.dimensions); } } @@ -203,26 +200,30 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP return result; } - protected _useZoomIfRequired(template: TemplateResult): TemplateResult { - return this.liveConfig?.zoomable - ? html` - this.cameraConfig?.dimensions?.layout - ? { - pan: this.cameraConfig.dimensions.layout.pan, - zoom: this.cameraConfig.dimensions.layout.zoom, - } - : undefined, - )} - .settings=${this.zoomSettings} - @advanced-camera-card:zoom:zoomed=${async () => - (await this.getMediaPlayerController())?.setControls(false)} - @advanced-camera-card:zoom:unzoomed=${async () => - (await this.getMediaPlayerController())?.setControls()} - > - ${template} - ` - : template; + protected _renderContainer(template: TemplateResult): TemplateResult { + // Place the zoomer in a separate div, as the zoom library misinterprets the + // explicit width/height setting from the provider resizer as zooming. + return html`
+ ${this.liveConfig?.zoomable + ? html` + this.cameraConfig?.dimensions?.layout + ? { + pan: this.cameraConfig.dimensions.layout.pan, + zoom: this.cameraConfig.dimensions.layout.zoom, + } + : undefined, + )} + .settings=${this.zoomSettings} + @advanced-camera-card:zoom:zoomed=${async () => + (await this.getMediaPlayerController())?.setControls(false)} + @advanced-camera-card:zoom:unzoomed=${async () => + (await this.getMediaPlayerController())?.setControls()} + > + ${template} + ` + : template} +
`; } protected render(): TemplateResult | void { @@ -240,11 +241,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP this.ariaLabel = this.label; const provider = this._getResolvedProvider(); - const showImageDuringLoading = this._shouldShowImageDuringLoading(); - const showLoadingIcon = !this._isVideoMediaLoaded; - const providerClasses = { - hidden: showImageDuringLoading, - }; if ( provider === 'ha' || @@ -287,20 +283,36 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP } } - return html`${this._useZoomIfRequired(html` + const showImageDuringLoading = this._shouldShowImageDuringLoading(); + const showLoadingIcon = !this._isVideoMediaLoaded; + + const classes = { + hidden: showImageDuringLoading, + }; + + return html`${this._renderContainer(html` ${showImageDuringLoading || provider === 'image' ? html` this._providerErrorHandler()} - @advanced-camera-card:media:loaded=${(ev: Event) => { + @advanced-camera-card:media:loaded=${(ev: CustomEvent) => { if (provider === 'image') { // Only count the media has loaded if the required provider is // the image (not just the temporary image shown during // loading). this._videoMediaShowHandler(); } else { + // Manually call resize(), since the dimensions controller won't + // receive the after that stopPropagation(). + this._dimensionsController.resize(); ev.stopPropagation(); } }} @@ -310,7 +322,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP ${provider === 'ha' ? html` = createRef(); + protected _refContainer: Ref = createRef(); protected _lazyLoadController: LazyLoadController = new LazyLoadController(this); + protected _dimensionsController = new MediaProviderDimensionsController(this); @state() protected _url: string | null = null; @@ -200,19 +203,20 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi } if (changedProps.has('media') || changedProps.has('cameraManager')) { - const cameraID = this.media?.getCameraID(); - const cameraConfig = cameraID - ? this.cameraManager?.getStore().getCameraConfig(cameraID) - : null; - updateElementStyleFromMediaLayoutConfig(this, cameraConfig?.dimensions?.layout); - - this.style.aspectRatio = aspectRatioToString({ - ratio: cameraConfig?.dimensions?.aspect_ratio, - }); + this._dimensionsController.setCameraConfig( + this._getRelevantCameraConfig()?.dimensions, + ); } } - protected _useZoomIfRequired(template: TemplateResult): TemplateResult { + private _getRelevantCameraConfig(): CameraConfig | null { + const cameraID = this.media?.getCameraID(); + return cameraID + ? this.cameraManager?.getStore().getCameraConfig(cameraID) ?? null + : null; + } + + protected _renderContainer(template: TemplateResult): TemplateResult { if (!this.media) { return template; } @@ -223,27 +227,37 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi : null; const view = this.viewManagerEpoch?.manager.getView(); - return 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)} - > - ${template} - ` - : template; + // Place the zoomer in a separate div, as the zoom library misinterprets the + // explicit width/height setting from the provider resizer as zooming. + 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, + )} + > + ${template} + ` + : template} +
`; } protected render(): TemplateResult | void { @@ -264,7 +278,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi // Note: crossorigin="anonymous" is required on