@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user