feat: Add support for rotating camera streams (#2149)

* Closes #1307
This commit is contained in:
Dermot Duffy
2025-08-23 17:30:53 -07:00
committed by GitHub
parent 69ae80d6f1
commit 4d4b64232a
25 changed files with 1393 additions and 764 deletions
+17
View File
@@ -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
+1
View File
@@ -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
@@ -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<MediaLoadedInfo>): 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);
}
}
@@ -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<SizeMode>(
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<MediaLoadedInfo>): 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<SizeMode>(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<SizeMode>(this._host, true, SIZE_ATTRIBUTE, 'custom');
}
}
+30 -54
View File
@@ -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<MediaPlayerElement> = createRef();
protected _refContainer: Ref<HTMLElement> = createRef();
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
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`<div class="container" ${ref(this._refContainer)}>
${this.imageConfig?.zoomable
? html`<advanced-camera-card-zoomer
.defaultSettings=${guard(
[this.imageConfig, this.cameraConfig?.dimensions?.layout],
() =>
mode === 'camera' && this.cameraConfig?.dimensions?.layout
? {
pan: this.cameraConfig.dimensions.layout.pan,
zoom: this.cameraConfig.dimensions.layout.zoom,
}
: undefined,
const intermediateTemplate = html` <advanced-camera-card-media-dimensions-container
.dimensionsConfig=${mode === 'camera' ? this.cameraConfig?.dimensions : undefined}
>
${template}
</advanced-camera-card-media-dimensions-container>`;
return html` ${this.imageConfig?.zoomable
? html`<advanced-camera-card-zoomer
.defaultSettings=${guard(
[this.imageConfig, this.cameraConfig?.dimensions?.layout],
() =>
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<ZoomSettingsObserved>) =>
handleZoomSettingsObservedEvent(
ev,
this.viewManagerEpoch?.manager,
zoomTarget,
)}
.settings=${view?.context?.zoom?.[zoomTarget]?.requested}
@advanced-camera-card:zoom:change=${(
ev: CustomEvent<ZoomSettingsObserved>,
) =>
handleZoomSettingsObservedEvent(
ev,
this.viewManagerEpoch?.manager,
zoomTarget,
)}
>
${template}
</advanced-camera-card-zoomer>`
: template}
</div>`;
>
${intermediateTemplate}
</advanced-camera-card-zoomer>`
: 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);
}
+37 -55
View File
@@ -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<MediaPlayerElement> = createRef();
protected _refContainer: Ref<HTMLElement> = 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 <advanced-camera-card-live> element first renders in the
// background. These calls fail without waiting for loading here.
// call mute() when the <advanced-camera-card-live> element first renders in
// the background. These calls fail without waiting for loading here.
protected _importPromises: Promise<unknown>[] = [];
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`<div class="container" ${ref(this._refContainer)}>
${this.liveConfig?.zoomable
? html` <advanced-camera-card-zoomer
.defaultSettings=${guard([this.cameraConfig?.dimensions?.layout], () =>
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}
</advanced-camera-card-zoomer>`
: template}
</div>`;
const intermediateTemplate = html` <advanced-camera-card-media-dimensions-container
.dimensionsConfig=${this.cameraConfig?.dimensions}
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
if (ev.detail.placeholder) {
ev.stopPropagation();
} else {
this._videoMediaShowHandler();
}
}}
>
${template}
</advanced-camera-card-media-dimensions-container>`;
return html` ${this.liveConfig?.zoomable
? html` <advanced-camera-card-zoomer
.defaultSettings=${guard([this.cameraConfig?.dimensions?.layout], () =>
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}
</advanced-camera-card-zoomer>`
: 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<MediaLoadedInfo>) => {
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';
}}
>
</advanced-camera-card-live-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)}
>
</advanced-camera-card-live-ha>`
: 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,
)}
>
</advanced-camera-card-live-go2rtc>`
: 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,
)}
>
</advanced-camera-card-live-webrtc-card>`
: 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,
)}
>
</advanced-camera-card-live-jsmpeg>`
: 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);
}
@@ -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<HTMLElement> = createRef();
protected _refOuterContainer: Ref<HTMLElement> = createRef();
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('dimensionsConfig')) {
this._controller.setConfig(this.dimensionsConfig);
}
}
protected render(): TemplateResult | void {
return html`
<div class="outer" ${ref(this._refOuterContainer)}>
<div class="inner" ${ref(this._refInnerContainer)}>
<slot></slot>
</div>
</div>
`;
}
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;
}
}
+11 -18
View File
@@ -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<MediaPlayerElement> = createRef();
protected _refContainer: Ref<HTMLElement> = 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`<div class="container" ${ref(this._refContainer)}>
const intermediateTemplate = html` <advanced-camera-card-media-dimensions-container
.dimensionsConfig=${this._getRelevantCameraConfig()?.dimensions}
>
${template}
</advanced-camera-card-media-dimensions-container>`;
return html`
${this.viewerConfig?.zoomable
? html`<advanced-camera-card-zoomer
.defaultSettings=${guard([cameraConfig?.dimensions?.layout], () =>
@@ -254,10 +251,10 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
mediaID,
)}
>
${template}
${intermediateTemplate}
</advanced-camera-card-zoomer>`
: template}
</div>`;
: 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);
}
+8
View File
@@ -146,9 +146,17 @@ const proxyConfigSchema = z.object({
});
export type ProxyConfig = z.infer<typeof proxyConfigSchema>;
const rotationSchema = z
.literal(0)
.or(z.literal(90))
.or(z.literal(180))
.or(z.literal(270));
export type Rotation = z.infer<typeof rotationSchema>;
const cameraDimensionsSchema = z.object({
aspect_ratio: aspectRatioSchema.optional(),
layout: mediaLayoutConfigSchema.optional(),
rotation: rotationSchema.optional(),
});
export type CameraDimensionsConfig = z.infer<typeof cameraDimensionsSchema>;
+2
View File
@@ -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 =
+16
View File
@@ -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',
+7
View File
@@ -79,6 +79,13 @@
"top": ""
},
"zoom": ""
},
"rotation": "",
"rotations": {
"0": "",
"90": "",
"180": "",
"270": ""
}
},
"engines": {
+7
View File
@@ -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": {
+7
View File
@@ -79,6 +79,13 @@
"top": "Pourcentage d'écart en haut"
},
"zoom": "Facteur de zoom"
},
"rotation": "",
"rotations": {
"0": "",
"90": "",
"180": "",
"270": ""
}
},
"engines": {
+7
View File
@@ -79,6 +79,13 @@
"top": ""
},
"zoom": ""
},
"rotation": "",
"rotations": {
"0": "",
"90": "",
"180": "",
"270": ""
}
},
"engines": {
+7
View File
@@ -79,6 +79,13 @@
"top": ""
},
"zoom": ""
},
"rotation": "",
"rotations": {
"0": "",
"90": "",
"180": "",
"270": ""
}
},
"engines": {
+7
View File
@@ -79,6 +79,13 @@
"top": ""
},
"zoom": ""
},
"rotation": "",
"rotations": {
"0": "",
"90": "",
"180": "",
"270": ""
}
},
"engines": {
+22
View File
@@ -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));
}
+2 -26
View File
@@ -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%;
}
+4
View File
@@ -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';
+22 -1
View File
@@ -209,6 +209,27 @@ export const setOrRemoveAttribute = <T extends string>(
}
};
/**
* 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 = <T extends string>(
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 = <T>(
};
export const aspectRatioToString = (options?: {
ratio?: number[];
ratio?: number[] | null;
defaultStatic?: boolean;
}): string => {
if (options?.ratio && options.ratio.length === 2) {
+21 -28
View File
@@ -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]}%`,
);
}
};
@@ -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<MediaLoadedInfo>('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<MediaLoadedInfo>('advanced-camera-card:media:loaded', {
detail: mediaLoadedInfo,
}),
);
expect(host.hasAttribute('rotated')).toBeTruthy();
host.removeAttribute('rotated');
innerContainer.dispatchEvent(
new CustomEvent<MediaLoadedInfo>('advanced-camera-card:media:loaded', {
detail: mediaLoadedInfo,
}),
);
expect(host.hasAttribute('rotated')).toBeFalsy();
});
});
});
@@ -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');
});
});
});
+22
View File
@@ -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();