From 4d4b64232a9756a5f76f5a37f18342040c2bb519 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Aug 2025 17:30:53 -0700 Subject: [PATCH] feat: Add support for rotating camera streams (#2149) * Closes #1307 --- docs/configuration/cameras/README.md | 17 + docs/configuration/status-bar.md | 1 + .../media-dimensions-container-controller.ts | 346 +++++++++ .../media-provider-dimensions-controller.ts | 163 ---- src/components/image.ts | 84 +- src/components/live/provider.ts | 92 +-- src/components/media-dimensions-container.ts | 57 ++ src/components/viewer/provider.ts | 29 +- src/config/schema/cameras.ts | 8 + src/const.ts | 2 + src/editor.ts | 16 + src/localize/languages/ca.json | 7 + src/localize/languages/en.json | 7 + src/localize/languages/fr.json | 7 + src/localize/languages/it.json | 7 + src/localize/languages/pt-BR.json | 7 + src/localize/languages/pt-PT.json | 7 + src/scss/media-dimensions-container.scss | 22 + src/scss/provider.scss | 28 +- src/types.ts | 4 + src/utils/basic.ts | 23 +- src/utils/media-layout.ts | 49 +- ...ia-dimensions-container-controller.test.ts | 733 ++++++++++++++++++ ...dia-provider-dimensions-controller.test.ts | 419 ---------- tests/utils/basic.test.ts | 22 + 25 files changed, 1393 insertions(+), 764 deletions(-) create mode 100644 src/components-lib/media-dimensions-container-controller.ts delete mode 100644 src/components-lib/media-provider-dimensions-controller.ts create mode 100644 src/components/media-dimensions-container.ts create mode 100644 src/scss/media-dimensions-container.scss create mode 100644 tests/components-lib/media-dimensions-container-controller.test.ts delete mode 100644 tests/components-lib/media-provider-dimensions-controller.test.ts diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index 44a459b2..4b7975bc 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -135,6 +135,11 @@ cameras: | -------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `aspect_ratio` | | An optional aspect ratio for media from this camera which will be used in `live` or media viewer related views (e.g. `clip`, `snapshot` and `recording`). Format is the same as the parameter of the same name under the [dimensions block](../dimensions.md) (which controls dimensions for the whole card), e.g. `16 / 9`. | | `layout` | | How the media should be laid out _within_ the camera dimensions. See below. | +| `rotation` | `0` | Rotates the camera clockwise by `0`, `90`, `180` or `270` degrees. | + +?> Use of `rotation` causes the browser to rotate the video player, unavoidably _including_ rotating the builtin video controls on the player, which may be distracting or confusing (e.g. upside down controls). Builtin controls can be disabled using the [`live.controls.builtin` parameter](../live.md?id=controls). Rotation is not available in iOS fullscreen, due to the limited fullscreen support offered by that OS. + +!> Rotating the camera incurs a rendering performance penalty. Always rotate "upstream" if possible (e.g. in your camera settings). ### Layout Configuration @@ -182,6 +187,15 @@ See [media layout examples](../../examples.md?id=media-layout). ![](../../images/media_layout/pan-zoom.png 'Panning and zooming :size=400') +### Order of Operations + +Camera `dimensions` settings are applied in this order: + +- `aspect_ratio` defines the aspect ratio of the video player ... +- ... then `fit`, `position` and `view_box` defines how the media is laid out within that ratio ... +- ... then `rotation` defines whether the video is rotated ... +- ... then `zoom` and `pan` define the zoom and pan settings respectively. + ## `ptz` Configure the PTZ actions taken for a camera (not to be confused with configuration of the PTZ _controls_, see [Live PTZ Controls](../live.md?id=ptz) or [Media Viewer PTZ Controls](../media-viewer.md?id=ptz)). Manually configured actions override any auto-detected actions. @@ -437,6 +451,9 @@ cameras: - trigger disable: # Capabilities to selectively disable. + - camera_entity: camera.rotated + dimensions: + rotation: 90 cameras_global: triggers: motion: false diff --git a/docs/configuration/status-bar.md b/docs/configuration/status-bar.md index 6d7a0de0..70fcf780 100644 --- a/docs/configuration/status-bar.md +++ b/docs/configuration/status-bar.md @@ -53,6 +53,7 @@ This card supports several menu styles. | `none` | No status bar is shown. | | `outside` | Render the status bar outside the card (i.e. above it if `position` is `top`, or below it if `position` is `bottom`). | | `overlay` | Overlay the status bar over the card contents. | +| `popup` | Equivalent to `overlay` except the status bar disappears after `popup_seconds`. | ## Fully expanded reference diff --git a/src/components-lib/media-dimensions-container-controller.ts b/src/components-lib/media-dimensions-container-controller.ts new file mode 100644 index 00000000..91628a78 --- /dev/null +++ b/src/components-lib/media-dimensions-container-controller.ts @@ -0,0 +1,346 @@ +import { ReactiveController, ReactiveControllerHost } from 'lit'; +import { debounce } from 'lodash-es'; +import { CameraDimensionsConfig } from '../config/schema/cameras'; +import { MediaLoadedInfo } from '../types'; +import { + aspectRatioToString, + setOrRemoveAttribute, + setOrRemoveStyleProperty, +} from '../utils/basic'; +import { AdvancedCameraCardMediaLoadedEventTarget } from '../utils/media-info'; +import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout'; + +const ROTATED_ATTRIBUTE = 'rotated'; + +interface MediaDimensions { + width: number; + height: number; +} + +/** + * Controller for managing media dimensions in a container. This accepts two + * containers (inner and outer). The inner container is expected to contain the + * media itself, the outer container is used to change the height that the inner + * container is allowed to be. This is necessary since when the inner container + * is rotated, the outer container will already have been sized by browser + * ignoring the rotation -- so the outer container has its height manually set + * based on the expected rotation height. The host itself (this element) needs + * to not have a fixed height, in order for the ResizeObserver to work + * correctly, necessitating the use of a special outer container. + */ +export class MediaDimensionsContainerController implements ReactiveController { + private _host: HTMLElement & ReactiveControllerHost; + + private _dimensionsConfig: CameraDimensionsConfig | null = null; + + private _innerContainer: + | (HTMLElement & AdvancedCameraCardMediaLoadedEventTarget) + | null = null; + private _outerContainer: HTMLElement | null = null; + + public resize = debounce(this._resize.bind(this), 100, { trailing: true }); + private _resizeObserver = new ResizeObserver(this.resize); + + private _mediaDimensions: MediaDimensions | null = null; + + constructor(host: HTMLElement & ReactiveControllerHost) { + this._host = host; + this._host.addController(this); + } + + public hostConnected(): void { + this._resizeObserver.observe(this._host); + this._addInnerContainerListeners(); + } + + public hostDisconnected(): void { + this._resizeObserver.disconnect(); + this._removeInnerContainerListeners(); + } + + private _removeInnerContainerListeners(): void { + if (!this._innerContainer) { + return; + } + this._innerContainer.removeEventListener('slotchange', this.resize); + this._innerContainer.removeEventListener( + 'advanced-camera-card:media:loaded', + this._mediaLoadedHandler, + ); + } + + private _addInnerContainerListeners(): void { + if (!this._host.isConnected || !this._innerContainer) { + return; + } + + this._innerContainer.addEventListener('slotchange', this.resize); + this._innerContainer.addEventListener( + 'advanced-camera-card:media:loaded', + this._mediaLoadedHandler, + ); + } + + private _mediaLoadedHandler = (ev: CustomEvent): void => { + // Only resize if the media dimensions have changed (otherwise the loading + // image whilst waiting for the stream, will trigger aresize every second). + if ( + this._mediaDimensions?.width === ev.detail.width && + this._mediaDimensions.height === ev.detail.height + ) { + return; + } + + this._mediaDimensions = { + width: ev.detail.width, + height: ev.detail.height, + }; + this.resize(); + }; + + public setConfig(dimensionsConfig?: CameraDimensionsConfig): void { + if (dimensionsConfig === this._dimensionsConfig) { + return; + } + + this._dimensionsConfig = dimensionsConfig ?? null; + this._setInnerContainerProperties(); + } + + public setContainers( + innerContainer?: HTMLElement, + outerContainer?: HTMLElement, + ): void { + if ( + (innerContainer ?? null) === this._innerContainer && + (outerContainer ?? null) === this._outerContainer + ) { + return; + } + + this._removeInnerContainerListeners(); + + this._innerContainer = innerContainer ?? null; + this._outerContainer = outerContainer ?? null; + + this._addInnerContainerListeners(); + this._setInnerContainerProperties(); + this._resize(); + } + + private _hasFixedAspectRatio(): boolean { + return this._dimensionsConfig?.aspect_ratio?.length === 2; + } + + private _requiresRotation(): boolean { + return !!this._dimensionsConfig?.rotation; + } + + private _requiresContainerRotation(): boolean { + // The actual container only needs to rotate if the rotation parameter is 90 + // or 270. + return ( + this._dimensionsConfig?.rotation === 90 || this._dimensionsConfig?.rotation === 270 + ); + } + + private _setInnerContainerProperties(): void { + if (!this._innerContainer) { + return; + } + + setOrRemoveStyleProperty( + this._innerContainer, + this._requiresRotation(), + '--advanced-camera-card-media-rotation', + `${this._dimensionsConfig?.rotation}deg`, + ); + + this._innerContainer.style.aspectRatio = aspectRatioToString({ + ratio: this._dimensionsConfig?.aspect_ratio, + }); + + updateElementStyleFromMediaLayoutConfig( + this._innerContainer, + this._dimensionsConfig?.layout, + ); + } + + // ============== + // Resize helpers + // ============== + + private _setMaxSize = (element: HTMLElement): void => + this._setSize(element, { + width: '100%', + height: '100%', + }); + + private _setIntrinsicSize = (element: HTMLElement): void => + this._setSize(element, { + width: 'max-content', + height: 'max-content', + }); + + private _setWidthBoundIntrinsicSize = (element: HTMLElement): void => + this._setSize(element, { + width: '100%', + height: 'auto', + }); + + private _setHeightBoundIntrinsicSize = (element: HTMLElement): void => + this._setSize(element, { + width: 'auto', + height: '100%', + }); + + private _setInvisible = (element: HTMLElement): void => { + element.style.visibility = 'hidden'; + }; + + private _setVisible = (element: HTMLElement): void => { + element.style.visibility = ''; + }; + + private _setSize( + element: HTMLElement, + options: { width?: number | string; height: number | string }, + ): void { + const toCSS = (value: number | string): string => + typeof value === 'number' ? `${value}px` : value; + + if (options.width !== undefined) { + element.style.width = toCSS(options.width); + } + + element.style.height = toCSS(options.height); + } + + private _setRotation(element: HTMLElement, rotate: boolean): void { + setOrRemoveAttribute(element, rotate, ROTATED_ATTRIBUTE); + } + + private _resize(): void { + if (this._requiresRotation()) { + this._resizeAndRotate(); + } else if (this._hasFixedAspectRatio()) { + this._resizeWithFixedAspectRatio(); + } else { + this._resizeDefault(); + } + } + + private _resizeDefault(): void { + if (!this._innerContainer || !this._outerContainer) { + return; + } + this._setMaxSize(this._innerContainer); + this._setMaxSize(this._outerContainer); + this._setRotation(this._host, false); + this._setVisible(this._innerContainer); + } + + private _resizeWithFixedAspectRatio(): void { + if (!this._innerContainer || !this._outerContainer) { + return; + } + + this._setInvisible(this._innerContainer); + + this._setWidthBoundIntrinsicSize(this._innerContainer); + this._setMaxSize(this._outerContainer); + this._setRotation(this._host, false); + + const hostSize = this._host.getBoundingClientRect(); + const innerContainerSize = this._innerContainer.getBoundingClientRect(); + + if (this._resizeDefaultIfInvalidSizes([hostSize, innerContainerSize])) { + return; + } + + // 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 (innerContainerSize.height > hostSize.height) { + this._setHeightBoundIntrinsicSize(this._innerContainer); + } + + this._setVisible(this._innerContainer); + } + + private _hasValidSize(size: DOMRect): boolean { + return size.width > 0 && size.height > 0; + } + + private _resizeDefaultIfInvalidSizes(sizes: DOMRect[]): boolean { + if (sizes.some((size) => !this._hasValidSize(size))) { + this._resizeDefault(); + return true; + } + return false; + } + + private _resizeAndRotate(): void { + if (!this._innerContainer || !this._outerContainer) { + return; + } + + if (!this._requiresContainerRotation()) { + this._resizeDefault(); + this._setRotation(this._host, true); + return; + } + + this._setInvisible(this._innerContainer); + + // Render the media entirely unhindered to get the native aspect ratio. + this._setIntrinsicSize(this._innerContainer); + this._setMaxSize(this._outerContainer); + this._setRotation(this._host, false); + + let hostSize = this._host.getBoundingClientRect(); + let innerContainerSize = this._innerContainer.getBoundingClientRect(); + + if (this._resizeDefaultIfInvalidSizes([hostSize, innerContainerSize])) { + return; + } + + const aspectRatio = innerContainerSize.width / innerContainerSize.height; + + this._setRotation(this._host, true); + + // Set the inner container to the correct rotated sizes (ignoring any + // constraint of host size). + this._setSize(this._innerContainer, { + width: hostSize.width * aspectRatio, + height: hostSize.width, + }); + + this._setSize(this._outerContainer, { + height: hostSize.width * aspectRatio, + }); + + // Refresh the sizes post rotation & initial sizing. + innerContainerSize = this._innerContainer.getBoundingClientRect(); + hostSize = this._host.getBoundingClientRect(); + + if (this._resizeDefaultIfInvalidSizes([hostSize, innerContainerSize])) { + return; + } + + // As in `_resizeWithFixedAspectRatio` resize the media if the host was not + // able to expand to cover the size. + if (innerContainerSize.height > hostSize.height) { + this._setSize(this._innerContainer, { + width: hostSize.height, + height: hostSize.height / aspectRatio, + }); + this._setSize(this._outerContainer, { + height: hostSize.height, + }); + } + + this._setVisible(this._innerContainer); + } +} diff --git a/src/components-lib/media-provider-dimensions-controller.ts b/src/components-lib/media-provider-dimensions-controller.ts deleted file mode 100644 index fc8b0d74..00000000 --- a/src/components-lib/media-provider-dimensions-controller.ts +++ /dev/null @@ -1,163 +0,0 @@ -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 = 'custom' | 'max' | 'max-height' | 'max-width'; - -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] - ? 'max-width' - : 'max-height' - : 'max', - ); - } - - 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 { - // Custom sizing only applies if the aspect ratio is set. - if (!this._cameraConfig?.aspect_ratio?.length) { - return; - } - - const rememberHostSize = (): void => { - this._intendedHostSize = this._host.getBoundingClientRect(); - }; - - const setMaxAttribute = (): void => { - setOrRemoveAttribute(this._host, true, SIZE_ATTRIBUTE, 'max'); - }; - - 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) { - setMaxAttribute(); - 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) { - setMaxAttribute(); - 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, 'custom'); - } -} diff --git a/src/components/image.ts b/src/components/image.ts index 4f1bf58f..2c2a7c79 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -1,17 +1,9 @@ -import { - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS, -} from 'lit'; +import { CSSResultGroup, html, LitElement, 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'; 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'; @@ -22,6 +14,7 @@ import imageStyle from '../scss/image.scss'; import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types.js'; import './image-updating-player'; import { resolveImageMode } from './image-updating-player'; +import './media-dimensions-container'; import './zoomer.js'; @customElement('advanced-camera-card-image') @@ -41,28 +34,13 @@ 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 willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('cameraConfig') || changedProps.has('imageConfig')) { - this._dimensionsController.setCameraConfig( - resolveImageMode({ - imageConfig: this.imageConfig, - cameraConfig: this.cameraConfig, - }) === 'camera' - ? this.cameraConfig?.dimensions - : undefined, - ); - } - } - protected _renderContainer(template: TemplateResult): TemplateResult { const zoomTarget = IMAGE_VIEW_ZOOM_TARGET_SENTINEL; const view = this.viewManagerEpoch?.manager.getView(); @@ -71,33 +49,35 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer { cameraConfig: this.cameraConfig, }); - 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, + const intermediateTemplate = html` + ${template} + `; + + 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, + )} + .settings=${view?.context?.zoom?.[zoomTarget]?.requested} + @advanced-camera-card:zoom:change=${(ev: CustomEvent) => + handleZoomSettingsObservedEvent( + ev, + this.viewManagerEpoch?.manager, + zoomTarget, )} - .settings=${view?.context?.zoom?.[zoomTarget]?.requested} - @advanced-camera-card:zoom:change=${( - ev: CustomEvent, - ) => - handleZoomSettingsObservedEvent( - ev, - this.viewManagerEpoch?.manager, - zoomTarget, - )} - > - ${template} - ` - : template} -
`; + > + ${intermediateTemplate} + ` + : intermediateTemplate}`; } protected render(): TemplateResult | void { @@ -117,10 +97,6 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer { `); } - public updated(): void { - this._dimensionsController.setContainer(this._refContainer.value); - } - static get styles(): CSSResultGroup { return unsafeCSS(imageStyle); } diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index fc3adf3a..5d5b6734 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -14,7 +14,6 @@ import { CameraEndpoints } from '../../camera-manager/types.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 { MediaProviderDimensionsController } from '../../components-lib/media-provider-dimensions-controller.js'; import { PartialZoomSettings } from '../../components-lib/zoom/types.js'; import { CameraConfig, LiveProvider } from '../../config/schema/cameras.js'; import { LiveConfig } from '../../config/schema/live.js'; @@ -32,6 +31,7 @@ import { import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js'; import '../icon.js'; import { renderMessage } from '../message.js'; +import './../media-dimensions-container'; @customElement('advanced-camera-card-live-provider') export class AdvancedCameraCardLiveProvider extends LitElement implements MediaPlayer { @@ -70,9 +70,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP protected _showStreamTroubleshooting = false; protected _refProvider: Ref = createRef(); - protected _refContainer: Ref = createRef(); + protected _lazyLoadController: LazyLoadController = new LazyLoadController(this); - protected _dimensionsController = new MediaProviderDimensionsController(this); // A note on dynamic imports: // @@ -82,8 +81,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP // 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 element first renders in the - // background. These calls fail without waiting for loading here. + // call mute() when the element first renders in + // the background. These calls fail without waiting for loading here. protected _importPromises: Promise[] = []; constructor() { @@ -186,8 +185,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP } else if (provider === 'go2rtc') { this._importPromises.push(import('./providers/go2rtc/index.js')); } - - this._dimensionsController.setCameraConfig(this.cameraConfig?.dimensions); } } @@ -201,29 +198,38 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP } 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} -
`; + const intermediateTemplate = html` ) => { + if (ev.detail.placeholder) { + ev.stopPropagation(); + } else { + this._videoMediaShowHandler(); + } + }} + > + ${template} + `; + + 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()} + > + ${intermediateTemplate} + ` + : intermediateTemplate}`; } protected render(): TemplateResult | void { @@ -304,17 +310,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP })} @advanced-camera-card:live:error=${() => this._providerErrorHandler()} @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(); - } + ev.detail.placeholder = provider !== 'image'; }} > ` @@ -327,7 +323,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP .cameraConfig=${this.cameraConfig} ?controls=${this.liveConfig.controls.builtin} @advanced-camera-card:live:error=${() => this._providerErrorHandler()} - @advanced-camera-card:media:loaded=${this._videoMediaShowHandler.bind(this)} > ` : provider === 'go2rtc' @@ -341,9 +336,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP .microphoneConfig=${this.liveConfig.microphone} ?controls=${this.liveConfig.controls.builtin} @advanced-camera-card:live:error=${() => this._providerErrorHandler()} - @advanced-camera-card:media:loaded=${this._videoMediaShowHandler.bind( - this, - )} > ` : provider === 'webrtc-card' @@ -356,9 +348,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP .cardWideConfig=${this.cardWideConfig} ?controls=${this.liveConfig.controls.builtin} @advanced-camera-card:live:error=${() => this._providerErrorHandler()} - @advanced-camera-card:media:loaded=${this._videoMediaShowHandler.bind( - this, - )} > ` : provider === 'jsmpeg' @@ -370,9 +359,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP .cameraEndpoints=${this.cameraEndpoints} .cardWideConfig=${this.cardWideConfig} @advanced-camera-card:live:error=${() => this._providerErrorHandler()} - @advanced-camera-card:media:loaded=${this._videoMediaShowHandler.bind( - this, - )} > ` : html``} @@ -402,10 +388,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP : ''}`; } - public updated(): void { - this._dimensionsController.setContainer(this._refContainer.value); - } - static get styles(): CSSResultGroup { return unsafeCSS(liveProviderStyle); } diff --git a/src/components/media-dimensions-container.ts b/src/components/media-dimensions-container.ts new file mode 100644 index 00000000..92bd7cad --- /dev/null +++ b/src/components/media-dimensions-container.ts @@ -0,0 +1,57 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { MediaDimensionsContainerController } from '../components-lib/media-dimensions-container-controller.js'; +import { CameraDimensionsConfig } from '../config/schema/cameras'; +import mediaDimensionsContainerStyle from '../scss/media-dimensions-container.scss'; + +@customElement('advanced-camera-card-media-dimensions-container') +export class AdvancedCameraCardMediaDimensionsContainer extends LitElement { + @property({ attribute: false }) + public dimensionsConfig?: CameraDimensionsConfig; + + protected _controller = new MediaDimensionsContainerController(this); + + protected _refInnerContainer: Ref = createRef(); + protected _refOuterContainer: Ref = createRef(); + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('dimensionsConfig')) { + this._controller.setConfig(this.dimensionsConfig); + } + } + + protected render(): TemplateResult | void { + return html` +
+
+ +
+
+ `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(mediaDimensionsContainerStyle); + } + + public updated(): void { + this._controller.setContainers( + this._refInnerContainer.value, + this._refOuterContainer.value, + ); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-media-dimensions-container': AdvancedCameraCardMediaDimensionsContainer; + } +} diff --git a/src/components/viewer/provider.ts b/src/components/viewer/provider.ts index 6fb52f7e..ae730d70 100644 --- a/src/components/viewer/provider.ts +++ b/src/components/viewer/provider.ts @@ -12,7 +12,6 @@ import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../../camera-manager/manager.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { LazyLoadController } from '../../components-lib/lazy-load-controller.js'; -import { MediaProviderDimensionsController } from '../../components-lib/media-provider-dimensions-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'; @@ -38,6 +37,7 @@ import { QueryClassifier } from '../../view/query-classifier.js'; import '../image-player.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 { @@ -65,7 +65,6 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi protected _refProvider: Ref = createRef(); protected _refContainer: Ref = createRef(); protected _lazyLoadController: LazyLoadController = new LazyLoadController(this); - protected _dimensionsController = new MediaProviderDimensionsController(this); @state() protected _url: string | null = null; @@ -201,12 +200,6 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) { import('../zoomer.js'); } - - if (changedProps.has('media') || changedProps.has('cameraManager')) { - this._dimensionsController.setCameraConfig( - this._getRelevantCameraConfig()?.dimensions, - ); - } } private _getRelevantCameraConfig(): CameraConfig | null { @@ -227,9 +220,13 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi : null; const view = this.viewManagerEpoch?.manager.getView(); - // 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`
+ const intermediateTemplate = html` + ${template} + `; + + return html` ${this.viewerConfig?.zoomable ? html` @@ -254,10 +251,10 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi mediaID, )} > - ${template} + ${intermediateTemplate} ` - : template} -
`; + : intermediateTemplate} + `; } protected render(): TemplateResult | void { @@ -319,10 +316,6 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi `); } - public updated(): void { - this._dimensionsController.setContainer(this._refContainer.value); - } - static get styles(): CSSResultGroup { return unsafeCSS(viewerProviderStyle); } diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index 52bc1e45..c25c2517 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -146,9 +146,17 @@ const proxyConfigSchema = z.object({ }); export type ProxyConfig = z.infer; +const rotationSchema = z + .literal(0) + .or(z.literal(90)) + .or(z.literal(180)) + .or(z.literal(270)); +export type Rotation = z.infer; + const cameraDimensionsSchema = z.object({ aspect_ratio: aspectRatioSchema.optional(), layout: mediaLayoutConfigSchema.optional(), + rotation: rotationSchema.optional(), }); export type CameraDimensionsConfig = z.infer; diff --git a/src/const.ts b/src/const.ts index 1365b557..086716f3 100644 --- a/src/const.ts +++ b/src/const.ts @@ -27,6 +27,8 @@ export const CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH = `${CONF_CAMERAS}.#.cast.dashboard.view_path` as const; export const CONF_CAMERAS_ARRAY_DIMENSIONS_ASPECT_RATIO = `${CONF_CAMERAS}.#.dimensions.aspect_ratio` as const; +export const CONF_CAMERAS_ARRAY_DIMENSIONS_ROTATION = + `${CONF_CAMERAS}.#.dimensions.rotation` as const; export const CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID = `${CONF_CAMERAS}.#.frigate.client_id` as const; export const CONF_CAMERAS_ARRAY_FRIGATE_LABELS = diff --git a/src/editor.ts b/src/editor.ts index c851a60a..0a22d156 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -54,6 +54,7 @@ import { CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT, CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_TOP, CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR, + CONF_CAMERAS_ARRAY_DIMENSIONS_ROTATION, CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME, CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID, CONF_CAMERAS_ARRAY_FRIGATE_LABELS, @@ -962,6 +963,14 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard { value: 'traditional', label: localize('config.view.theme.themes.traditional') }, ]; + protected _rotations: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 0, label: localize('config.cameras.dimensions.rotations.0') }, + { value: 90, label: localize('config.cameras.dimensions.rotations.90') }, + { value: 180, label: localize('config.cameras.dimensions.rotations.180') }, + { value: 270, label: localize('config.cameras.dimensions.rotations.270') }, + ]; + public setConfig(config: RawAdvancedCameraCardConfig): void { // Note: This does not use Zod to parse the full configuration, so it may be // partially or completely invalid. It's more useful to have a partially @@ -2466,6 +2475,13 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard cameraIndex, ), )} + ${this._renderOptionSelector( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_DIMENSIONS_ROTATION, + cameraIndex, + ), + this._rotations, + )} ${this._renderMediaLayout( MENU_CAMERAS_DIMENSIONS_LAYOUT, 'config.cameras.dimensions.layout.editor_label', diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index bd9ce3cc..3c179b57 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -79,6 +79,13 @@ "top": "" }, "zoom": "" + }, + "rotation": "", + "rotations": { + "0": "", + "90": "", + "180": "", + "270": "" } }, "engines": { diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 51781bc0..5aaffb9c 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -79,6 +79,13 @@ "top": "Top inset percentage" }, "zoom": "Zoom factor" + }, + "rotation": "Rotation", + "rotations": { + "0": "No rotation", + "90": "90 degrees clockwise", + "180": "180 degrees clockwise", + "270": "270 degrees clockwise" } }, "engines": { diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index b504cfdf..52747dd0 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -79,6 +79,13 @@ "top": "Pourcentage d'écart en haut" }, "zoom": "Facteur de zoom" + }, + "rotation": "", + "rotations": { + "0": "", + "90": "", + "180": "", + "270": "" } }, "engines": { diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 9f46a079..d23d8c6b 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -79,6 +79,13 @@ "top": "" }, "zoom": "" + }, + "rotation": "", + "rotations": { + "0": "", + "90": "", + "180": "", + "270": "" } }, "engines": { diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 842e6929..6ac04be4 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -79,6 +79,13 @@ "top": "" }, "zoom": "" + }, + "rotation": "", + "rotations": { + "0": "", + "90": "", + "180": "", + "270": "" } }, "engines": { diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 077e944c..6c7085c1 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -79,6 +79,13 @@ "top": "" }, "zoom": "" + }, + "rotation": "", + "rotations": { + "0": "", + "90": "", + "180": "", + "270": "" } }, "engines": { diff --git a/src/scss/media-dimensions-container.scss b/src/scss/media-dimensions-container.scss new file mode 100644 index 00000000..ebf779f5 --- /dev/null +++ b/src/scss/media-dimensions-container.scss @@ -0,0 +1,22 @@ +:host, +.outer { + display: flex; + width: 100%; + height: 100%; + + justify-content: center; + align-items: center; +} + +.inner { + display: block; + + // The container will be sized by the provider resize controller, but don't + // allow the outer container to shrink or contain the inner (as we need to + // calculate the true intrinsic size, otherwise on landscape videos the + // rotated size will be incorrectly calculated). + flex-shrink: 0; +} +:host([rotated]) .inner { + transform: rotate(var(--advanced-camera-card-media-rotation, 0deg)); +} diff --git a/src/scss/provider.scss b/src/scss/provider.scss index 7a2fc952..afeea038 100644 --- a/src/scss/provider.scss +++ b/src/scss/provider.scss @@ -1,32 +1,8 @@ @use 'media-background.scss'; +@use 'basic-block.scss'; -:host { - display: flex; - position: relative; - - width: 100%; - height: 100%; - - justify-content: center; - align-items: center; -} - -.container { +.zoom-wrapper { display: block; - - // The container will be sized by the provider resize controller. - width: fit-content; - height: auto; - max-width: 100%; -} - -:host([size='max']) > .container { width: 100%; height: 100%; } -:host([size='max-height']) > .container { - height: 100%; -} -:host([size='max-width']) > .container { - width: 100%; -} diff --git a/src/types.ts b/src/types.ts index 8a36bc30..f3978530 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,10 @@ export interface MediaLoadedInfo { mediaPlayerController?: MediaPlayerController; capabilities?: MediaLoadedCapabilities; + + // Whether or not this media is a placeholder (temporary image) whilst another + // media item is being loaded. + placeholder?: boolean; } export type MessageType = 'info' | 'error' | 'connection' | 'diagnostics'; diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 3c3e6df0..2b6a11d7 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -209,6 +209,27 @@ export const setOrRemoveAttribute = ( } }; +/** + * Set or remove a style property on a HTMLElement. + * @param element The element. + * @param set If `true` sets the property, otherwise removes it. + * @param name The property name. + * @param value An optional value to set the property to. Never used if set is + * `false`. + */ +export const setOrRemoveStyleProperty = ( + element: HTMLElement, + set: boolean, + name: string, + value?: T, +): void => { + if (set) { + element.style.setProperty(name, value ?? ''); + } else { + element.style.removeProperty(name); + } +}; + /** * Allow typescript to narrow types based on truthy filter. */ @@ -243,7 +264,7 @@ export const recursivelyMergeObjectsConcatenatingArraysUniquely = ( }; export const aspectRatioToString = (options?: { - ratio?: number[]; + ratio?: number[] | null; defaultStatic?: boolean; }): string => { if (options?.ratio && options.ratio.length === 2) { diff --git a/src/utils/media-layout.ts b/src/utils/media-layout.ts index 7de9333c..2adde991 100644 --- a/src/utils/media-layout.ts +++ b/src/utils/media-layout.ts @@ -1,4 +1,5 @@ import { MediaLayoutConfig } from '../config/schema/camera/media-layout'; +import { setOrRemoveStyleProperty } from './basic'; /** * Update element style from a media configuration. @@ -9,36 +10,28 @@ export const updateElementStyleFromMediaLayoutConfig = ( element: HTMLElement, mediaLayoutConfig?: MediaLayoutConfig, ): void => { - if (mediaLayoutConfig?.fit !== undefined) { - element.style.setProperty( - '--advanced-camera-card-media-layout-fit', - mediaLayoutConfig.fit, - ); - } else { - element.style.removeProperty('--advanced-camera-card-media-layout-fit'); - } + setOrRemoveStyleProperty( + element, + !!mediaLayoutConfig?.fit, + '--advanced-camera-card-media-layout-fit', + mediaLayoutConfig?.fit, + ); + for (const dimension of ['x', 'y']) { - if (mediaLayoutConfig?.position?.[dimension] !== undefined) { - element.style.setProperty( - `--advanced-camera-card-media-layout-position-${dimension}`, - `${mediaLayoutConfig.position[dimension]}%`, - ); - } else { - element.style.removeProperty( - `--advanced-camera-card-media-layout-position-${dimension}`, - ); - } + setOrRemoveStyleProperty( + element, + !!mediaLayoutConfig?.position?.[dimension], + `--advanced-camera-card-media-layout-position-${dimension}`, + `${mediaLayoutConfig?.position?.[dimension]}%`, + ); } + for (const dimension of ['top', 'bottom', 'left', 'right']) { - if (mediaLayoutConfig?.view_box?.[dimension] !== undefined) { - element.style.setProperty( - `--advanced-camera-card-media-layout-view-box-${dimension}`, - `${mediaLayoutConfig.view_box[dimension]}%`, - ); - } else { - element.style.removeProperty( - `--advanced-camera-card-media-layout-view-box-${dimension}`, - ); - } + setOrRemoveStyleProperty( + element, + !!mediaLayoutConfig?.view_box?.[dimension], + `--advanced-camera-card-media-layout-view-box-${dimension}`, + `${mediaLayoutConfig?.view_box?.[dimension]}%`, + ); } }; diff --git a/tests/components-lib/media-dimensions-container-controller.test.ts b/tests/components-lib/media-dimensions-container-controller.test.ts new file mode 100644 index 00000000..48369dd0 --- /dev/null +++ b/tests/components-lib/media-dimensions-container-controller.test.ts @@ -0,0 +1,733 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MediaDimensionsContainerController } from '../../src/components-lib/media-dimensions-container-controller'; +import { CameraDimensionsConfig, Rotation } from '../../src/config/schema/cameras'; +import { MediaLoadedInfo } from '../../src/types'; +import { + callResizeHandler, + createLitElement, + getResizeObserver, + ResizeObserverMock, +} from '../test-utils'; + +vi.mock('lodash-es', () => ({ + debounce: vi.fn((fn) => fn), +})); + +// @vitest-environment jsdom +describe('MediaDimensionsContainerController', () => { + beforeAll(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + }); + afterAll(() => { + vi.unstubAllGlobals(); + }); + beforeEach(() => { + vi.clearAllMocks(); + }); + + const configWithAspectRatio: CameraDimensionsConfig = { + aspect_ratio: [16, 9], + }; + const configWithRotation: CameraDimensionsConfig = { + rotation: 90, + }; + + it('should construct', () => { + const host = createLitElement(); + const eventListener = vi.fn(); + host.addEventListener = eventListener; + + new MediaDimensionsContainerController(host); + + const observer = getResizeObserver(); + + // No resize observer should be created. + expect(observer?.observe).not.toBeCalled(); + expect(eventListener).not.toBeCalled(); + }); + + describe('should connect and disconnect', () => { + it('should connect and disconnect without a container', () => { + const host = createLitElement(); + const controller = new MediaDimensionsContainerController(host); + + const observer = getResizeObserver(); + expect(observer?.observe).toBeCalledTimes(0); + + controller.hostConnected(); + expect(observer?.observe).toBeCalledWith(host); + expect(observer?.observe).toBeCalledTimes(1); + + controller.hostDisconnected(); + expect(observer?.disconnect).toBeCalled(); + }); + + it('should connect and disconnect with a container when host is connected', () => { + const host = createLitElement(); + Object.defineProperty(host, 'isConnected', { + value: true, + }); + + const controller = new MediaDimensionsContainerController(host); + + const observer = getResizeObserver(0); + + const container = createLitElement(); + controller.setContainers(container); + + expect(observer?.observe).not.toBeCalled(); + + controller.hostDisconnected(); + expect(observer?.disconnect).toBeCalled(); + + controller.hostConnected(); + expect(observer?.observe).toBeCalledWith(host); + }); + }); + + describe('should set container respecting config', () => { + it('should set aspect ratio on container', () => { + const host = createLitElement(); + const container = document.createElement('div'); + const controller = new MediaDimensionsContainerController(host); + + controller.setConfig(configWithAspectRatio); + controller.setContainers(container); + + expect(container.style.aspectRatio).toBe('16 / 9'); + }); + + it('should set layout attributes', () => { + const host = createLitElement(); + const container = document.createElement('div'); + const controller = new MediaDimensionsContainerController(host); + + const config = { + layout: { + fit: 'contain' as const, + position: { x: 1, y: 2 }, + view_box: { top: 3, bottom: 4, left: 5, right: 6 }, + }, + }; + controller.setConfig(config); + controller.setContainers(container); + + expect( + container.style.getPropertyValue('--advanced-camera-card-media-layout-fit'), + ).toBe('contain'); + expect( + container.style.getPropertyValue( + '--advanced-camera-card-media-layout-position-x', + ), + ).toBe('1%'); + expect( + container.style.getPropertyValue( + '--advanced-camera-card-media-layout-position-y', + ), + ).toBe('2%'); + expect( + container.style.getPropertyValue( + '--advanced-camera-card-media-layout-view-box-top', + ), + ).toBe('3%'); + expect( + container.style.getPropertyValue( + '--advanced-camera-card-media-layout-view-box-bottom', + ), + ).toBe('4%'); + expect( + container.style.getPropertyValue( + '--advanced-camera-card-media-layout-view-box-left', + ), + ).toBe('5%'); + expect( + container.style.getPropertyValue( + '--advanced-camera-card-media-layout-view-box-right', + ), + ).toBe('6%'); + }); + }); + + it('should ignore multiple calls to set same container', () => { + const host = createLitElement(); + const container = document.createElement('div'); + const controller = new MediaDimensionsContainerController(host); + controller.setConfig(configWithAspectRatio); + + controller.setContainers(container); + expect(container.style.aspectRatio).toBe('16 / 9'); + + container.style.aspectRatio = ''; + + controller.setContainers(container); + expect(container.style.aspectRatio).toBe(''); + }); + + it('should ignore multiple calls to set same config', () => { + const host = createLitElement(); + const container = document.createElement('div'); + const controller = new MediaDimensionsContainerController(host); + controller.setConfig(configWithAspectRatio); + + controller.setContainers(container); + + container.style.aspectRatio = ''; + + controller.setConfig(configWithAspectRatio); + + expect(container.style.aspectRatio).toBe(''); + }); + + it('should reset config', () => { + const host = createLitElement(); + const container = document.createElement('div'); + const controller = new MediaDimensionsContainerController(host); + controller.setConfig(configWithAspectRatio); + + controller.setContainers(container); + + expect(container.style.aspectRatio).toBe('16 / 9'); + + controller.setConfig(); + + expect(container.style.aspectRatio).toBe('auto'); + }); + + describe('should respond to size changes', () => { + it('should ignore without an aspect ratio or rotation', () => { + const host = createLitElement(); + + host.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 600, + width: 300, + }); + + const controller = new MediaDimensionsContainerController(host); + + const innerContainer = document.createElement('div'); + const outerContainer = document.createElement('div'); + innerContainer.style.width = '1px'; + innerContainer.style.height = '2px'; + outerContainer.style.width = '3px'; + outerContainer.style.height = '4px'; + controller.setContainers(innerContainer, outerContainer); + + callResizeHandler(); + + expect(innerContainer.style.width).toBe('100%'); + expect(innerContainer.style.height).toBe('100%'); + expect(outerContainer.style.width).toBe('100%'); + expect(outerContainer.style.height).toBe('100%'); + }); + + describe('should ignore without containers', () => { + describe.each([ + ['with aspect ratio', configWithAspectRatio], + ['with rotation', configWithRotation], + ])('%s', async (_name: string, config: CameraDimensionsConfig) => { + it('should ignore without inner container', () => { + const host = createLitElement(); + const controller = new MediaDimensionsContainerController(host); + + controller.setConfig(config); + + const outerContainer = document.createElement('div'); + outerContainer.style.width = '1px'; + outerContainer.style.height = '2px'; + + controller.setContainers(undefined, outerContainer); + + callResizeHandler(); + + expect(outerContainer.style.width).toBe('1px'); + expect(outerContainer.style.height).toBe('2px'); + }); + + it('should ignore without outer container', () => { + const host = createLitElement(); + const controller = new MediaDimensionsContainerController(host); + + controller.setConfig(config); + + const innerContainer = document.createElement('div'); + innerContainer.style.width = '1px'; + innerContainer.style.height = '2px'; + + controller.setContainers(innerContainer); + + callResizeHandler(); + + expect(innerContainer.style.width).toBe('1px'); + expect(innerContainer.style.height).toBe('2px'); + }); + }); + }); + + describe('should ignore without container sizes', () => { + describe('should ignore without container sizes with aspect ratio', () => { + it('should ignore without inner container size', () => { + const host = createLitElement(); + const controller = new MediaDimensionsContainerController(host); + + controller.setConfig(configWithAspectRatio); + + const outerContainer = document.createElement('div'); + + const innerContainer = document.createElement('div'); + innerContainer.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 0, + width: 0, + }); + innerContainer.style.width = '1px'; + innerContainer.style.height = '2px'; + + controller.setContainers(innerContainer, outerContainer); + + callResizeHandler(); + + expect(outerContainer.style.width).toBe('100%'); + expect(outerContainer.style.height).toBe('100%'); + }); + + it('should ignore without host size', () => { + const host = createLitElement(); + host.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 0, + width: 0, + }); + + const controller = new MediaDimensionsContainerController(host); + + controller.setConfig(configWithAspectRatio); + + const innerContainer = document.createElement('div'); + innerContainer.style.width = '100px'; + innerContainer.style.height = '200px'; + innerContainer.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 100, + width: 200, + }); + + controller.setContainers(innerContainer, document.createElement('div')); + + callResizeHandler(); + + expect(innerContainer.style.width).toBe('100%'); + expect(innerContainer.style.height).toBe('100%'); + }); + }); + + describe('should ignore without container sizes with rotation', () => { + it('should ignore without inner container size', () => { + const host = createLitElement(); + const controller = new MediaDimensionsContainerController(host); + + controller.setConfig(configWithRotation); + + const outerContainer = document.createElement('div'); + + const innerContainer = document.createElement('div'); + innerContainer.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 0, + width: 0, + }); + innerContainer.style.width = '1px'; + innerContainer.style.height = '2px'; + + controller.setContainers(innerContainer, outerContainer); + + callResizeHandler(); + + expect(outerContainer.style.width).toBe('100%'); + expect(outerContainer.style.height).toBe('100%'); + }); + + it('should ignore without host size', () => { + const host = createLitElement(); + host.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 0, + width: 0, + }); + + const controller = new MediaDimensionsContainerController(host); + + controller.setConfig(configWithRotation); + + const innerContainer = document.createElement('div'); + innerContainer.style.width = '100px'; + innerContainer.style.height = '200px'; + innerContainer.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 100, + width: 200, + }); + + controller.setContainers(innerContainer, document.createElement('div')); + + callResizeHandler(); + + expect(innerContainer.style.width).toBe('100%'); + expect(innerContainer.style.height).toBe('100%'); + }); + + it('should ignore without inner container size during resize', () => { + const host = createLitElement(); + host.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 100, + width: 200, + }); + + const controller = new MediaDimensionsContainerController(host); + + controller.setConfig(configWithRotation); + + const innerContainer = document.createElement('div'); + innerContainer.style.width = '100px'; + innerContainer.style.height = '200px'; + innerContainer.getBoundingClientRect = vi + .fn() + .mockReturnValueOnce({ + height: 100, + width: 200, + }) + .mockReturnValueOnce({ + height: 0, + width: 0, + }); + + controller.setContainers(innerContainer, document.createElement('div')); + + expect(innerContainer.style.width).toBe('100%'); + expect(innerContainer.style.height).toBe('100%'); + }); + + it('should ignore without host size during resize', () => { + const host = createLitElement(); + host.getBoundingClientRect = vi + .fn() + .mockReturnValueOnce({ + height: 100, + width: 200, + }) + .mockReturnValueOnce({ + height: 0, + width: 0, + }); + + const controller = new MediaDimensionsContainerController(host); + + controller.setConfig(configWithRotation); + + const innerContainer = document.createElement('div'); + innerContainer.style.width = '100px'; + innerContainer.style.height = '200px'; + innerContainer.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 100, + width: 200, + }); + + controller.setContainers(innerContainer, document.createElement('div')); + + expect(innerContainer.style.width).toBe('100%'); + expect(innerContainer.style.height).toBe('100%'); + }); + }); + }); + + describe('should set rotation attributes and properties on container', () => { + it.each([ + [false, undefined], + [false, 0 as const], + [true, 90 as const], + [true, 180 as const], + [true, 270 as const], + ])( + 'should rotate %s with rotation degrees %s', + (shouldRotate: boolean, degrees?: Rotation) => { + const host = createLitElement(); + host.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 100, + width: 200, + }); + + const controller = new MediaDimensionsContainerController(host); + controller.setConfig({ + rotation: degrees, + }); + + const innerContainer = document.createElement('div'); + innerContainer.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 100, + width: 200, + }); + controller.setContainers(innerContainer, document.createElement('div')); + + callResizeHandler(); + + expect(host.hasAttribute('rotated')).toBe(shouldRotate); + + expect( + innerContainer.style.getPropertyValue( + '--advanced-camera-card-media-rotation', + ), + ).toBe(shouldRotate ? `${degrees}deg` : ''); + }, + ); + }); + + describe('should resize container with aspect-ratio', () => { + it('should resize container to fit width-limited container', () => { + const host = createLitElement(); + host.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 200, + width: 200, + }); + + const innerContainer = document.createElement('div'); + innerContainer.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 90, + width: 160, + }); + const outerContainer = document.createElement('div'); + + const controller = new MediaDimensionsContainerController(host); + controller.setConfig(configWithAspectRatio); + controller.setContainers(innerContainer, outerContainer); + + callResizeHandler(); + + expect(innerContainer.style.width).toBe('100%'); + expect(innerContainer.style.height).toBe('auto'); + expect(outerContainer.style.width).toBe('100%'); + expect(outerContainer.style.height).toBe('100%'); + + expect(host.hasAttribute('rotated')).toBeFalsy(); + }); + + it('should resize container to fit height-limited container', () => { + const host = createLitElement(); + host.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 100, + width: 400, + }); + + const innerContainer = document.createElement('div'); + innerContainer.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 200, + width: 160, + }); + const outerContainer = document.createElement('div'); + + const controller = new MediaDimensionsContainerController(host); + controller.setConfig(configWithAspectRatio); + controller.setContainers(innerContainer, outerContainer); + + callResizeHandler(); + + expect(innerContainer.style.width).toBe(`auto`); + expect(innerContainer.style.height).toBe('100%'); + expect(outerContainer.style.width).toBe('100%'); + expect(outerContainer.style.height).toBe('100%'); + + expect(host.hasAttribute('rotated')).toBeFalsy(); + }); + }); + + describe('should resize container with rotation', () => { + it('should resize container to fit width-limited container', () => { + const host = createLitElement(); + host.getBoundingClientRect = vi.fn().mockReturnValue({ + width: 500, + height: 1000, + }); + + const innerContainer = document.createElement('div'); + innerContainer.getBoundingClientRect = vi + .fn() + .mockReturnValue({ + // These are the pre-rotation dimensions. + width: 1000, + height: 2000, + }) + .mockReturnValue({ + // These are the post-rotation dimensions. + width: 500, + height: 1000, + }); + const outerContainer = document.createElement('div'); + + const controller = new MediaDimensionsContainerController(host); + controller.setConfig(configWithRotation); + controller.setContainers(innerContainer, outerContainer); + + callResizeHandler(); + + // These are pre-rotation dimensions. + expect(innerContainer.style.width).toBe('250px'); + expect(innerContainer.style.height).toBe('500px'); + + expect(outerContainer.style.width).toBe('100%'); + expect(outerContainer.style.height).toBe('250px'); + + expect(host.hasAttribute('rotated')).toBeTruthy(); + }); + + it('should resize container to fit height-limited container', () => { + const host = createLitElement(); + host.getBoundingClientRect = vi.fn().mockReturnValue({ + width: 500, + height: 1000, + }); + + const innerContainer = document.createElement('div'); + innerContainer.getBoundingClientRect = vi + .fn() + .mockReturnValue({ + // These are the pre-rotation dimensions. + width: 1000, + height: 2000, + }) + .mockReturnValue({ + // These are the post-rotation dimensions. + width: 500, + height: 2000, + }); + const outerContainer = document.createElement('div'); + + const controller = new MediaDimensionsContainerController(host); + controller.setConfig(configWithRotation); + controller.setContainers(innerContainer, outerContainer); + + callResizeHandler(); + + // These are pre-rotation dimensions. + expect(innerContainer.style.width).toBe('1000px'); + expect(innerContainer.style.height).toBe('4000px'); + + expect(outerContainer.style.width).toBe('100%'); + expect(outerContainer.style.height).toBe('1000px'); + + expect(host.hasAttribute('rotated')).toBeTruthy(); + }); + }); + }); + + describe('should respond to slot changes', () => { + it('should resize container on slotchange event', () => { + const host = createLitElement(); + host.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 200, + width: 200, + }); + + const innerContainer = document.createElement('div'); + innerContainer.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 90, + width: 160, + }); + const outerContainer = document.createElement('div'); + + const controller = new MediaDimensionsContainerController(host); + + Object.defineProperty(host, 'isConnected', { + value: true, + }); + controller.hostConnected(); + + controller.setConfig(configWithRotation); + controller.setContainers(innerContainer, outerContainer); + + host.removeAttribute('rotated'); + + innerContainer.dispatchEvent(new Event('slotchange')); + + expect(host.hasAttribute('rotated')).toBeTruthy(); + }); + }); + + describe('should respond to media load', () => { + it('should resize container on media load', () => { + const host = createLitElement(); + host.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 200, + width: 200, + }); + + const innerContainer = document.createElement('div'); + innerContainer.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 90, + width: 160, + }); + const outerContainer = document.createElement('div'); + + const controller = new MediaDimensionsContainerController(host); + + Object.defineProperty(host, 'isConnected', { + value: true, + }); + controller.hostConnected(); + + controller.setConfig(configWithRotation); + controller.setContainers(innerContainer, outerContainer); + + host.removeAttribute('rotated'); + + const mediaLoadedInfo: MediaLoadedInfo = { + width: 90, + height: 160, + }; + innerContainer.dispatchEvent( + new CustomEvent('advanced-camera-card:media:loaded', { + detail: mediaLoadedInfo, + }), + ); + + expect(host.hasAttribute('rotated')).toBeTruthy(); + }); + + it('should not resize container on media load to same size', () => { + const host = createLitElement(); + host.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 200, + width: 200, + }); + + const innerContainer = document.createElement('div'); + innerContainer.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 90, + width: 160, + }); + const outerContainer = document.createElement('div'); + + const controller = new MediaDimensionsContainerController(host); + + Object.defineProperty(host, 'isConnected', { + value: true, + }); + controller.hostConnected(); + + controller.setConfig(configWithRotation); + controller.setContainers(innerContainer, outerContainer); + + const mediaLoadedInfo: MediaLoadedInfo = { + width: 90, + height: 160, + }; + innerContainer.dispatchEvent( + new CustomEvent('advanced-camera-card:media:loaded', { + detail: mediaLoadedInfo, + }), + ); + + expect(host.hasAttribute('rotated')).toBeTruthy(); + host.removeAttribute('rotated'); + + innerContainer.dispatchEvent( + new CustomEvent('advanced-camera-card:media:loaded', { + detail: mediaLoadedInfo, + }), + ); + + expect(host.hasAttribute('rotated')).toBeFalsy(); + }); + }); +}); diff --git a/tests/components-lib/media-provider-dimensions-controller.test.ts b/tests/components-lib/media-provider-dimensions-controller.test.ts deleted file mode 100644 index 79ea862e..00000000 --- a/tests/components-lib/media-provider-dimensions-controller.test.ts +++ /dev/null @@ -1,419 +0,0 @@ -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest'; -import { MediaProviderDimensionsController } from '../../src/components-lib/media-provider-dimensions-controller'; -import { CameraDimensionsConfig } from '../../src/config/schema/cameras'; -import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info'; -import { - callResizeHandler, - createLitElement, - createMediaLoadedInfo, - getResizeObserver, - requestAnimationFrameMock, - ResizeObserverMock, -} from '../test-utils'; - -vi.mock('lodash-es', () => ({ - throttle: vi.fn((fn) => fn), -})); - -// @vitest-environment jsdom -describe('MediaProviderDimensionsController', () => { - beforeAll(() => { - vi.stubGlobal('ResizeObserver', ResizeObserverMock); - }); - afterAll(() => { - vi.unstubAllGlobals(); - }); - beforeEach(() => { - vi.clearAllMocks(); - }); - - const configWithAspectRatioLandscape: CameraDimensionsConfig = { - aspect_ratio: [16, 9], - }; - const configWithAspectRatioPortrait: CameraDimensionsConfig = { - aspect_ratio: [9, 16], - }; - - it('should construct', () => { - const host = createLitElement(); - const eventListener = vi.fn(); - host.addEventListener = eventListener; - - new MediaProviderDimensionsController(host); - - const observer = getResizeObserver(); - - // No resize observer should be created. - expect(observer?.observe).not.toBeCalled(); - expect(eventListener).not.toBeCalled(); - }); - - describe('should connect and disconnect', () => { - it('should connect and disconnect without a container', () => { - const host = createLitElement(); - const controller = new MediaProviderDimensionsController(host); - - const observer = getResizeObserver(); - expect(observer?.observe).toBeCalledTimes(0); - - controller.hostConnected(); - expect(observer?.observe).toBeCalledWith(host); - expect(observer?.observe).toBeCalledTimes(1); - - controller.hostDisconnected(); - expect(observer?.disconnect).toBeCalled(); - }); - - it('should connect and disconnect with a container when host is connected', () => { - const host = createLitElement(); - Object.defineProperty(host, 'isConnected', { - value: true, - }); - - const controller = new MediaProviderDimensionsController(host); - - const observer = getResizeObserver(0); - - const container = createLitElement(); - controller.setContainer(container); - - expect(observer?.observe).not.toBeCalled(); - - controller.hostDisconnected(); - expect(observer?.disconnect).toBeCalled(); - - controller.hostConnected(); - expect(observer?.observe).toBeCalledWith(host); - }); - }); - - describe('should set container respecting config ', () => { - it('should set aspect ratio on container', () => { - const host = createLitElement(); - const container = document.createElement('div'); - const controller = new MediaProviderDimensionsController(host); - - controller.setCameraConfig(configWithAspectRatioLandscape); - controller.setContainer(container); - - expect(container.style.aspectRatio).toBe('16 / 9'); - }); - - describe('should set host attribute', () => { - it.each([ - ['max', {}], - ['max-width', { aspect_ratio: [16, 9] }], - ['max-height', { aspect_ratio: [9, 16] }], - ])('%s', async (value: string, config: CameraDimensionsConfig) => { - const host = createLitElement(); - const container = document.createElement('div'); - const controller = new MediaProviderDimensionsController(host); - controller.setCameraConfig(config); - - controller.setContainer(container); - - expect(host.getAttribute('size')).toBe(value); - }); - }); - - it('should set layout attributes', () => { - const host = createLitElement(); - const container = document.createElement('div'); - const controller = new MediaProviderDimensionsController(host); - - const config = { - layout: { - fit: 'contain' as const, - position: { x: 1, y: 2 }, - view_box: { top: 3, bottom: 4, left: 5, right: 6 }, - }, - }; - controller.setCameraConfig(config); - controller.setContainer(container); - - expect( - host.style.getPropertyValue('--advanced-camera-card-media-layout-fit'), - ).toBe('contain'); - expect( - host.style.getPropertyValue('--advanced-camera-card-media-layout-position-x'), - ).toBe('1%'); - expect( - host.style.getPropertyValue('--advanced-camera-card-media-layout-position-y'), - ).toBe('2%'); - expect( - host.style.getPropertyValue('--advanced-camera-card-media-layout-view-box-top'), - ).toBe('3%'); - expect( - host.style.getPropertyValue( - '--advanced-camera-card-media-layout-view-box-bottom', - ), - ).toBe('4%'); - expect( - host.style.getPropertyValue('--advanced-camera-card-media-layout-view-box-left'), - ).toBe('5%'); - expect( - host.style.getPropertyValue( - '--advanced-camera-card-media-layout-view-box-right', - ), - ).toBe('6%'); - }); - }); - - it('should ignore multiple calls to set same container', () => { - const host = createLitElement(); - const container = document.createElement('div'); - const controller = new MediaProviderDimensionsController(host); - controller.setCameraConfig(configWithAspectRatioLandscape); - - controller.setContainer(container); - - expect(host.getAttribute('size')).toBe('max-width'); - host.setAttribute('size', 'custom'); - - controller.setContainer(container); - expect(host.getAttribute('size')).toBe('custom'); - }); - - it('should reset container', () => { - const host = createLitElement(); - const container = document.createElement('div'); - const controller = new MediaProviderDimensionsController(host); - controller.setCameraConfig(configWithAspectRatioLandscape); - - controller.setContainer(container); - controller.setContainer(); - - expect(host.getAttribute('size')).toBe('max-width'); - }); - - describe('should set size attribute correctly', () => { - it('should set max-width', () => { - const host = createLitElement(); - const container = document.createElement('div'); - const controller = new MediaProviderDimensionsController(host); - - controller.setCameraConfig(configWithAspectRatioLandscape); - controller.setContainer(container); - - expect(container.style.aspectRatio).toBe('16 / 9'); - expect(host.getAttribute('size')).toBe('max-width'); - }); - - it('should set max-height', () => { - const host = createLitElement(); - const container = document.createElement('div'); - const controller = new MediaProviderDimensionsController(host); - - controller.setCameraConfig(configWithAspectRatioPortrait); - controller.setContainer(container); - - expect(container.style.aspectRatio).toBe('9 / 16'); - expect(host.getAttribute('size')).toBe('max-height'); - }); - - it('should set max', () => { - const host = createLitElement(); - const container = document.createElement('div'); - const controller = new MediaProviderDimensionsController(host); - - controller.setContainer(container); - expect(host.getAttribute('size')).toBe('max'); - }); - - it('should set max without a config', () => { - const host = createLitElement(); - const container = document.createElement('div'); - const controller = new MediaProviderDimensionsController(host); - - controller.setCameraConfig(); - controller.setContainer(container); - - expect(host.getAttribute('size')).toBe('max'); - }); - }); - - describe('should respond to size changes', () => { - it('should ignore without an aspect ratio', () => { - const host = createLitElement(); - host.setAttribute('size', '__RANDOM__'); - - host.getBoundingClientRect = vi.fn().mockReturnValue({ - height: 600, - width: 300, - }); - - new MediaProviderDimensionsController(host); - - callResizeHandler(); - - expect(host.getAttribute('size')).toBe('__RANDOM__'); - }); - - it('should set host to max if no container', () => { - const host = createLitElement(); - host.setAttribute('size', 'custom'); - - host.getBoundingClientRect = vi.fn().mockReturnValue({ - height: 600, - width: 300, - }); - - const controller = new MediaProviderDimensionsController(host); - controller.setCameraConfig(configWithAspectRatioLandscape); - - callResizeHandler(); - - expect(host.getAttribute('size')).toBe('max'); - }); - - it('should set host to max if container has no dimensions', () => { - const host = createLitElement(); - host.setAttribute('size', 'custom'); - - host.getBoundingClientRect = vi.fn().mockReturnValue({ - height: 100, - width: 200, - }); - - const container = document.createElement('div'); - container.getBoundingClientRect = vi.fn().mockReturnValue({ - height: 0, - width: 0, - }); - - const controller = new MediaProviderDimensionsController(host); - controller.setCameraConfig(configWithAspectRatioLandscape); - controller.setContainer(container); - - callResizeHandler(); - - expect(host.getAttribute('size')).toBe('max'); - }); - - it('should ignore resize calls where actual equals intended size', () => { - const host = createLitElement(); - host.setAttribute('size', 'custom'); - - host.getBoundingClientRect = vi.fn().mockReturnValue({ - height: 100, - width: 200, - }); - - const container = document.createElement('div'); - container.getBoundingClientRect = vi.fn().mockReturnValue({ - height: 100, - width: 200, - }); - - const controller = new MediaProviderDimensionsController(host); - controller.setCameraConfig(configWithAspectRatioLandscape); - controller.setContainer(container); - - callResizeHandler(); - - host.setAttribute('size', '__RANDOM__'); - - // 2nd call should be ignored. - callResizeHandler(); - - expect(host.getAttribute('size')).toBe('__RANDOM__'); - }); - - describe('should resize container to fit width-limited container', () => { - it('should resize container to fit width-limited container', () => { - const host = createLitElement(); - host.getBoundingClientRect = vi.fn().mockReturnValue({ - height: 200, - width: 200, - }); - - const container = document.createElement('div'); - container.getBoundingClientRect = vi.fn().mockReturnValue({ - height: 90, - width: 160, - }); - - const controller = new MediaProviderDimensionsController(host); - controller.setCameraConfig(configWithAspectRatioLandscape); - controller.setContainer(container); - - callResizeHandler(); - - expect(container.style.width).toBe('100%'); - expect(container.style.height).toBe('auto'); - expect(host.getAttribute('size')).toBe('custom'); - }); - - it('should resize container to fit height-limited container', () => { - const host = createLitElement(); - host.getBoundingClientRect = vi.fn().mockReturnValue({ - height: 100, - width: 400, - }); - - const container = document.createElement('div'); - container.getBoundingClientRect = vi.fn().mockReturnValue({ - height: 200, - width: 160, - }); - - const controller = new MediaProviderDimensionsController(host); - controller.setCameraConfig(configWithAspectRatioLandscape); - controller.setContainer(container); - - callResizeHandler(); - - expect(container.style.width).toBe(`${100 * (160 / 200)}px`); - expect(container.style.height).toBe('100px'); - expect(host.getAttribute('size')).toBe('custom'); - }); - }); - }); - - describe('should respond to media loading', () => { - beforeEach(() => { - vi.spyOn(window, 'requestAnimationFrame').mockImplementation( - requestAnimationFrameMock, - ); - }); - - afterEach(() => { - vi.mocked(window.requestAnimationFrame).mockRestore(); - }); - - it('should resize container after a media load', () => { - const host = createLitElement(); - host.getBoundingClientRect = vi.fn().mockReturnValue({ - height: 100, - width: 400, - }); - - const container = document.createElement('div'); - container.getBoundingClientRect = vi.fn().mockReturnValue({ - height: 90, - width: 160, - }); - - const controller = new MediaProviderDimensionsController(host); - controller.setCameraConfig(configWithAspectRatioLandscape); - controller.setContainer(container); - - controller.hostConnected(); - - dispatchExistingMediaLoadedInfoAsEvent(host, createMediaLoadedInfo()); - - expect(container.style.width).toBe(`100%`); - expect(container.style.height).toBe('auto'); - expect(host.getAttribute('size')).toBe('custom'); - }); - }); -}); diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts index 47bac62b..0ba659fb 100644 --- a/tests/utils/basic.test.ts +++ b/tests/utils/basic.test.ts @@ -26,6 +26,7 @@ import { runWhenIdleIfSupported, setify, setOrRemoveAttribute, + setOrRemoveStyleProperty, } from '../../src/utils/basic.js'; import { createSlot, createSlotHost } from '../test-utils.js'; @@ -248,6 +249,27 @@ describe('setOrRemoveAttribute', () => { }); }); +describe('setOrRemoveStyleProperty', () => { + it('should set style property without value', () => { + const element = document.createElement('div'); + setOrRemoveStyleProperty(element, true, 'width'); + expect(element.style.getPropertyValue('width')).toBe(''); + }); + + it('should set style property with value', () => { + const element = document.createElement('div'); + setOrRemoveStyleProperty(element, true, 'width', '100px'); + expect(element.style.getPropertyValue('width')).toBe('100px'); + }); + + it('should remove style property', () => { + const element = document.createElement('div'); + element.style.setProperty('width', '100px'); + setOrRemoveStyleProperty(element, false, 'width'); + expect(element.style.getPropertyValue('width')).toBeFalsy(); + }); +}); + describe('isTruthy', () => { it('should return true for true', () => { expect(isTruthy(true)).toBeTruthy();