Skip to content

Commit 4d4b642

Browse files
authored
feat: Add support for rotating camera streams (#2149)
* Closes #1307
1 parent 69ae80d commit 4d4b642

25 files changed

+1393
-764
lines changed

docs/configuration/cameras/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ cameras:
135135
| -------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
136136
| `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`. |
137137
| `layout` | | How the media should be laid out _within_ the camera dimensions. See below. |
138+
| `rotation` | `0` | Rotates the camera clockwise by `0`, `90`, `180` or `270` degrees. |
139+
140+
?> 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.
141+
142+
!> Rotating the camera incurs a rendering performance penalty. Always rotate "upstream" if possible (e.g. in your camera settings).
138143

139144
### Layout Configuration
140145

@@ -182,6 +187,15 @@ See [media layout examples](../../examples.md?id=media-layout).
182187

183188
![](../../images/media_layout/pan-zoom.png 'Panning and zooming :size=400')
184189

190+
### Order of Operations
191+
192+
Camera `dimensions` settings are applied in this order:
193+
194+
- `aspect_ratio` defines the aspect ratio of the video player ...
195+
- ... then `fit`, `position` and `view_box` defines how the media is laid out within that ratio ...
196+
- ... then `rotation` defines whether the video is rotated ...
197+
- ... then `zoom` and `pan` define the zoom and pan settings respectively.
198+
185199
## `ptz`
186200

187201
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:
437451
- trigger
438452
disable:
439453
# Capabilities to selectively disable.
454+
- camera_entity: camera.rotated
455+
dimensions:
456+
rotation: 90
440457
cameras_global:
441458
triggers:
442459
motion: false

docs/configuration/status-bar.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ This card supports several menu styles.
5353
| `none` | No status bar is shown. |
5454
| `outside` | Render the status bar outside the card (i.e. above it if `position` is `top`, or below it if `position` is `bottom`). |
5555
| `overlay` | Overlay the status bar over the card contents. |
56+
| `popup` | Equivalent to `overlay` except the status bar disappears after `popup_seconds`. |
5657

5758
## Fully expanded reference
5859

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
import { ReactiveController, ReactiveControllerHost } from 'lit';
2+
import { debounce } from 'lodash-es';
3+
import { CameraDimensionsConfig } from '../config/schema/cameras';
4+
import { MediaLoadedInfo } from '../types';
5+
import {
6+
aspectRatioToString,
7+
setOrRemoveAttribute,
8+
setOrRemoveStyleProperty,
9+
} from '../utils/basic';
10+
import { AdvancedCameraCardMediaLoadedEventTarget } from '../utils/media-info';
11+
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout';
12+
13+
const ROTATED_ATTRIBUTE = 'rotated';
14+
15+
interface MediaDimensions {
16+
width: number;
17+
height: number;
18+
}
19+
20+
/**
21+
* Controller for managing media dimensions in a container. This accepts two
22+
* containers (inner and outer). The inner container is expected to contain the
23+
* media itself, the outer container is used to change the height that the inner
24+
* container is allowed to be. This is necessary since when the inner container
25+
* is rotated, the outer container will already have been sized by browser
26+
* ignoring the rotation -- so the outer container has its height manually set
27+
* based on the expected rotation height. The host itself (this element) needs
28+
* to not have a fixed height, in order for the ResizeObserver to work
29+
* correctly, necessitating the use of a special outer container.
30+
*/
31+
export class MediaDimensionsContainerController implements ReactiveController {
32+
private _host: HTMLElement & ReactiveControllerHost;
33+
34+
private _dimensionsConfig: CameraDimensionsConfig | null = null;
35+
36+
private _innerContainer:
37+
| (HTMLElement & AdvancedCameraCardMediaLoadedEventTarget)
38+
| null = null;
39+
private _outerContainer: HTMLElement | null = null;
40+
41+
public resize = debounce(this._resize.bind(this), 100, { trailing: true });
42+
private _resizeObserver = new ResizeObserver(this.resize);
43+
44+
private _mediaDimensions: MediaDimensions | null = null;
45+
46+
constructor(host: HTMLElement & ReactiveControllerHost) {
47+
this._host = host;
48+
this._host.addController(this);
49+
}
50+
51+
public hostConnected(): void {
52+
this._resizeObserver.observe(this._host);
53+
this._addInnerContainerListeners();
54+
}
55+
56+
public hostDisconnected(): void {
57+
this._resizeObserver.disconnect();
58+
this._removeInnerContainerListeners();
59+
}
60+
61+
private _removeInnerContainerListeners(): void {
62+
if (!this._innerContainer) {
63+
return;
64+
}
65+
this._innerContainer.removeEventListener('slotchange', this.resize);
66+
this._innerContainer.removeEventListener(
67+
'advanced-camera-card:media:loaded',
68+
this._mediaLoadedHandler,
69+
);
70+
}
71+
72+
private _addInnerContainerListeners(): void {
73+
if (!this._host.isConnected || !this._innerContainer) {
74+
return;
75+
}
76+
77+
this._innerContainer.addEventListener('slotchange', this.resize);
78+
this._innerContainer.addEventListener(
79+
'advanced-camera-card:media:loaded',
80+
this._mediaLoadedHandler,
81+
);
82+
}
83+
84+
private _mediaLoadedHandler = (ev: CustomEvent<MediaLoadedInfo>): void => {
85+
// Only resize if the media dimensions have changed (otherwise the loading
86+
// image whilst waiting for the stream, will trigger aresize every second).
87+
if (
88+
this._mediaDimensions?.width === ev.detail.width &&
89+
this._mediaDimensions.height === ev.detail.height
90+
) {
91+
return;
92+
}
93+
94+
this._mediaDimensions = {
95+
width: ev.detail.width,
96+
height: ev.detail.height,
97+
};
98+
this.resize();
99+
};
100+
101+
public setConfig(dimensionsConfig?: CameraDimensionsConfig): void {
102+
if (dimensionsConfig === this._dimensionsConfig) {
103+
return;
104+
}
105+
106+
this._dimensionsConfig = dimensionsConfig ?? null;
107+
this._setInnerContainerProperties();
108+
}
109+
110+
public setContainers(
111+
innerContainer?: HTMLElement,
112+
outerContainer?: HTMLElement,
113+
): void {
114+
if (
115+
(innerContainer ?? null) === this._innerContainer &&
116+
(outerContainer ?? null) === this._outerContainer
117+
) {
118+
return;
119+
}
120+
121+
this._removeInnerContainerListeners();
122+
123+
this._innerContainer = innerContainer ?? null;
124+
this._outerContainer = outerContainer ?? null;
125+
126+
this._addInnerContainerListeners();
127+
this._setInnerContainerProperties();
128+
this._resize();
129+
}
130+
131+
private _hasFixedAspectRatio(): boolean {
132+
return this._dimensionsConfig?.aspect_ratio?.length === 2;
133+
}
134+
135+
private _requiresRotation(): boolean {
136+
return !!this._dimensionsConfig?.rotation;
137+
}
138+
139+
private _requiresContainerRotation(): boolean {
140+
// The actual container only needs to rotate if the rotation parameter is 90
141+
// or 270.
142+
return (
143+
this._dimensionsConfig?.rotation === 90 || this._dimensionsConfig?.rotation === 270
144+
);
145+
}
146+
147+
private _setInnerContainerProperties(): void {
148+
if (!this._innerContainer) {
149+
return;
150+
}
151+
152+
setOrRemoveStyleProperty(
153+
this._innerContainer,
154+
this._requiresRotation(),
155+
'--advanced-camera-card-media-rotation',
156+
`${this._dimensionsConfig?.rotation}deg`,
157+
);
158+
159+
this._innerContainer.style.aspectRatio = aspectRatioToString({
160+
ratio: this._dimensionsConfig?.aspect_ratio,
161+
});
162+
163+
updateElementStyleFromMediaLayoutConfig(
164+
this._innerContainer,
165+
this._dimensionsConfig?.layout,
166+
);
167+
}
168+
169+
// ==============
170+
// Resize helpers
171+
// ==============
172+
173+
private _setMaxSize = (element: HTMLElement): void =>
174+
this._setSize(element, {
175+
width: '100%',
176+
height: '100%',
177+
});
178+
179+
private _setIntrinsicSize = (element: HTMLElement): void =>
180+
this._setSize(element, {
181+
width: 'max-content',
182+
height: 'max-content',
183+
});
184+
185+
private _setWidthBoundIntrinsicSize = (element: HTMLElement): void =>
186+
this._setSize(element, {
187+
width: '100%',
188+
height: 'auto',
189+
});
190+
191+
private _setHeightBoundIntrinsicSize = (element: HTMLElement): void =>
192+
this._setSize(element, {
193+
width: 'auto',
194+
height: '100%',
195+
});
196+
197+
private _setInvisible = (element: HTMLElement): void => {
198+
element.style.visibility = 'hidden';
199+
};
200+
201+
private _setVisible = (element: HTMLElement): void => {
202+
element.style.visibility = '';
203+
};
204+
205+
private _setSize(
206+
element: HTMLElement,
207+
options: { width?: number | string; height: number | string },
208+
): void {
209+
const toCSS = (value: number | string): string =>
210+
typeof value === 'number' ? `${value}px` : value;
211+
212+
if (options.width !== undefined) {
213+
element.style.width = toCSS(options.width);
214+
}
215+
216+
element.style.height = toCSS(options.height);
217+
}
218+
219+
private _setRotation(element: HTMLElement, rotate: boolean): void {
220+
setOrRemoveAttribute(element, rotate, ROTATED_ATTRIBUTE);
221+
}
222+
223+
private _resize(): void {
224+
if (this._requiresRotation()) {
225+
this._resizeAndRotate();
226+
} else if (this._hasFixedAspectRatio()) {
227+
this._resizeWithFixedAspectRatio();
228+
} else {
229+
this._resizeDefault();
230+
}
231+
}
232+
233+
private _resizeDefault(): void {
234+
if (!this._innerContainer || !this._outerContainer) {
235+
return;
236+
}
237+
this._setMaxSize(this._innerContainer);
238+
this._setMaxSize(this._outerContainer);
239+
this._setRotation(this._host, false);
240+
this._setVisible(this._innerContainer);
241+
}
242+
243+
private _resizeWithFixedAspectRatio(): void {
244+
if (!this._innerContainer || !this._outerContainer) {
245+
return;
246+
}
247+
248+
this._setInvisible(this._innerContainer);
249+
250+
this._setWidthBoundIntrinsicSize(this._innerContainer);
251+
this._setMaxSize(this._outerContainer);
252+
this._setRotation(this._host, false);
253+
254+
const hostSize = this._host.getBoundingClientRect();
255+
const innerContainerSize = this._innerContainer.getBoundingClientRect();
256+
257+
if (this._resizeDefaultIfInvalidSizes([hostSize, innerContainerSize])) {
258+
return;
259+
}
260+
261+
// If the container is larger than the host, the host was not able to expand
262+
// enough to cover the size (e.g. fullscreen, panel or height constrained in
263+
// configuration). In this case, just limit the container to the host height
264+
// at the same aspect ratio.
265+
if (innerContainerSize.height > hostSize.height) {
266+
this._setHeightBoundIntrinsicSize(this._innerContainer);
267+
}
268+
269+
this._setVisible(this._innerContainer);
270+
}
271+
272+
private _hasValidSize(size: DOMRect): boolean {
273+
return size.width > 0 && size.height > 0;
274+
}
275+
276+
private _resizeDefaultIfInvalidSizes(sizes: DOMRect[]): boolean {
277+
if (sizes.some((size) => !this._hasValidSize(size))) {
278+
this._resizeDefault();
279+
return true;
280+
}
281+
return false;
282+
}
283+
284+
private _resizeAndRotate(): void {
285+
if (!this._innerContainer || !this._outerContainer) {
286+
return;
287+
}
288+
289+
if (!this._requiresContainerRotation()) {
290+
this._resizeDefault();
291+
this._setRotation(this._host, true);
292+
return;
293+
}
294+
295+
this._setInvisible(this._innerContainer);
296+
297+
// Render the media entirely unhindered to get the native aspect ratio.
298+
this._setIntrinsicSize(this._innerContainer);
299+
this._setMaxSize(this._outerContainer);
300+
this._setRotation(this._host, false);
301+
302+
let hostSize = this._host.getBoundingClientRect();
303+
let innerContainerSize = this._innerContainer.getBoundingClientRect();
304+
305+
if (this._resizeDefaultIfInvalidSizes([hostSize, innerContainerSize])) {
306+
return;
307+
}
308+
309+
const aspectRatio = innerContainerSize.width / innerContainerSize.height;
310+
311+
this._setRotation(this._host, true);
312+
313+
// Set the inner container to the correct rotated sizes (ignoring any
314+
// constraint of host size).
315+
this._setSize(this._innerContainer, {
316+
width: hostSize.width * aspectRatio,
317+
height: hostSize.width,
318+
});
319+
320+
this._setSize(this._outerContainer, {
321+
height: hostSize.width * aspectRatio,
322+
});
323+
324+
// Refresh the sizes post rotation & initial sizing.
325+
innerContainerSize = this._innerContainer.getBoundingClientRect();
326+
hostSize = this._host.getBoundingClientRect();
327+
328+
if (this._resizeDefaultIfInvalidSizes([hostSize, innerContainerSize])) {
329+
return;
330+
}
331+
332+
// As in `_resizeWithFixedAspectRatio` resize the media if the host was not
333+
// able to expand to cover the size.
334+
if (innerContainerSize.height > hostSize.height) {
335+
this._setSize(this._innerContainer, {
336+
width: hostSize.height,
337+
height: hostSize.height / aspectRatio,
338+
});
339+
this._setSize(this._outerContainer, {
340+
height: hostSize.height,
341+
});
342+
}
343+
344+
this._setVisible(this._innerContainer);
345+
}
346+
}

0 commit comments

Comments
 (0)