feat: Allow per-camera customization of grid width (#2273)
- Closes #2271
This commit is contained in:
@@ -135,6 +135,7 @@ cameras:
|
|||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| -------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `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`. |
|
| `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`. |
|
||||||
|
| `grid` | | Grid layout configuration for this camera when displayed in grid mode. See below. |
|
||||||
| `layout` | | How the media should be laid out _within_ the camera dimensions. See below. |
|
| `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. |
|
| `rotation` | `0` | Rotates the camera clockwise by `0`, `90`, `180` or `270` degrees. |
|
||||||
|
|
||||||
@@ -147,6 +148,22 @@ cameras:
|
|||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Rotating the camera incurs a rendering performance penalty. Always rotate "upstream" if possible (e.g. in your camera settings).
|
> Rotating the camera incurs a rendering performance penalty. Always rotate "upstream" if possible (e.g. in your camera settings).
|
||||||
|
|
||||||
|
### Grid Configuration
|
||||||
|
|
||||||
|
The `grid` block configures how this camera appears in grid display mode.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.office
|
||||||
|
dimensions:
|
||||||
|
grid:
|
||||||
|
width_factor: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
| -------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `width_factor` | `1` | Width multiplier for this camera in grid mode (minimum: `0.1`). When selected, width becomes `width_factor * grid_selected_width_factor`, capped at 100%. |
|
||||||
|
|
||||||
### Layout Configuration
|
### Layout Configuration
|
||||||
|
|
||||||
The `layout` block configures the fit and position of the media _within_ the camera dimensions (in order to control the dimensions for the whole card see [the card dimensions configuration](../dimensions.md) ).
|
The `layout` block configures the fit and position of the media _within_ the camera dimensions (in order to control the dimensions for the whole card see [the card dimensions configuration](../dimensions.md) ).
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { isEqual, throttle } from 'lodash-es';
|
|||||||
import Masonry from 'masonry-layout';
|
import Masonry from 'masonry-layout';
|
||||||
import { ViewDisplayConfig } from '../config/schema/common/display';
|
import { ViewDisplayConfig } from '../config/schema/common/display';
|
||||||
import { MediaLoadedInfo } from '../types';
|
import { MediaLoadedInfo } from '../types';
|
||||||
import { getChildrenFromElement, setOrRemoveAttribute } from '../utils/basic';
|
import {
|
||||||
|
getChildrenFromElement,
|
||||||
|
setOrRemoveAttribute,
|
||||||
|
setOrRemoveStyleProperty,
|
||||||
|
} from '../utils/basic';
|
||||||
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event';
|
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event';
|
||||||
import {
|
import {
|
||||||
AdvancedCameraCardMediaLoadedEventTarget,
|
AdvancedCameraCardMediaLoadedEventTarget,
|
||||||
@@ -30,6 +34,7 @@ export interface MediaGridSelected {
|
|||||||
export interface MediaGridConstructorOptions {
|
export interface MediaGridConstructorOptions {
|
||||||
selected?: GridID;
|
selected?: GridID;
|
||||||
idAttribute?: string;
|
idAttribute?: string;
|
||||||
|
widthFactorAttribute?: string;
|
||||||
displayConfig?: ViewDisplayConfig;
|
displayConfig?: ViewDisplayConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +56,7 @@ export class MediaGridController {
|
|||||||
protected _displayConfig: ViewDisplayConfig | null = null;
|
protected _displayConfig: ViewDisplayConfig | null = null;
|
||||||
protected _hostWidth: number;
|
protected _hostWidth: number;
|
||||||
protected _idAttribute: string;
|
protected _idAttribute: string;
|
||||||
|
protected _widthFactorAttribute: string;
|
||||||
|
|
||||||
protected _throttledLayout = throttle(
|
protected _throttledLayout = throttle(
|
||||||
() => this._masonry?.layout?.(),
|
() => this._masonry?.layout?.(),
|
||||||
@@ -79,6 +85,7 @@ export class MediaGridController {
|
|||||||
this._host = host;
|
this._host = host;
|
||||||
this._selected = options?.selected ?? null;
|
this._selected = options?.selected ?? null;
|
||||||
this._idAttribute = options?.idAttribute ?? 'grid-id';
|
this._idAttribute = options?.idAttribute ?? 'grid-id';
|
||||||
|
this._widthFactorAttribute = options?.widthFactorAttribute ?? 'grid-width-factor';
|
||||||
this._hostWidth = this._host.getBoundingClientRect().width;
|
this._hostWidth = this._host.getBoundingClientRect().width;
|
||||||
this._hostResizeObserver.observe(host);
|
this._hostResizeObserver.observe(host);
|
||||||
this._displayConfig = options?.displayConfig ?? null;
|
this._displayConfig = options?.displayConfig ?? null;
|
||||||
@@ -233,7 +240,7 @@ export class MediaGridController {
|
|||||||
this._cellResizeObserver.disconnect();
|
this._cellResizeObserver.disconnect();
|
||||||
for (const child of gridContents.values()) {
|
for (const child of gridContents.values()) {
|
||||||
this._cellMutationObserver.observe(child, {
|
this._cellMutationObserver.observe(child, {
|
||||||
attributeFilter: [this._idAttribute],
|
attributeFilter: [this._idAttribute, this._widthFactorAttribute],
|
||||||
attributes: true,
|
attributes: true,
|
||||||
});
|
});
|
||||||
this._cellResizeObserver.observe(child);
|
this._cellResizeObserver.observe(child);
|
||||||
@@ -241,6 +248,7 @@ export class MediaGridController {
|
|||||||
|
|
||||||
this._sortItemsInGrid();
|
this._sortItemsInGrid();
|
||||||
this._updateSelectedStylesOnElements();
|
this._updateSelectedStylesOnElements();
|
||||||
|
this._updateWidthFactorStyles();
|
||||||
this._setColumnSizeStyles();
|
this._setColumnSizeStyles();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +351,18 @@ export class MediaGridController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _updateWidthFactorStyles(): void {
|
||||||
|
for (const element of this._gridContents.values()) {
|
||||||
|
const widthFactor = element.getAttribute(this._widthFactorAttribute);
|
||||||
|
setOrRemoveStyleProperty(
|
||||||
|
element,
|
||||||
|
!!widthFactor,
|
||||||
|
'--advanced-camera-card-grid-width-factor',
|
||||||
|
widthFactor ?? undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected _getColumnSize(): number {
|
protected _getColumnSize(): number {
|
||||||
const columns = this._getColumns();
|
const columns = this._getColumns();
|
||||||
if (columns === 1) {
|
if (columns === 1) {
|
||||||
|
|||||||
@@ -45,9 +45,16 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
|
|||||||
const view = this.viewManagerEpoch?.manager.getView();
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
const triggeredCameraID = cameraID ?? view?.camera;
|
const triggeredCameraID = cameraID ?? view?.camera;
|
||||||
|
|
||||||
|
// Get the camera's grid width factor from its dimensions config.
|
||||||
|
const gridWidthFactor = cameraID
|
||||||
|
? this.cameraManager?.getStore().getCameraConfig(cameraID)?.dimensions?.grid
|
||||||
|
?.width_factor
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<advanced-camera-card-live-carousel
|
<advanced-camera-card-live-carousel
|
||||||
grid-id=${ifDefined(cameraID)}
|
grid-id=${ifDefined(cameraID)}
|
||||||
|
grid-width-factor=${ifDefined(gridWidthFactor)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.viewFilterCameraID=${cameraID}
|
.viewFilterCameraID=${cameraID}
|
||||||
|
|||||||
@@ -41,9 +41,17 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
|
|||||||
|
|
||||||
protected _renderCarousel(filterCamera?: string): TemplateResult {
|
protected _renderCarousel(filterCamera?: string): TemplateResult {
|
||||||
const selectedCameraID = this.viewManagerEpoch?.manager.getView()?.camera;
|
const selectedCameraID = this.viewManagerEpoch?.manager.getView()?.camera;
|
||||||
|
|
||||||
|
// Get the camera's grid width factor from its dimensions config.
|
||||||
|
const gridWidthFactor = filterCamera
|
||||||
|
? this.cameraManager?.getStore().getCameraConfig(filterCamera)?.dimensions?.grid
|
||||||
|
?.width_factor
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<advanced-camera-card-viewer-carousel
|
<advanced-camera-card-viewer-carousel
|
||||||
grid-id=${ifDefined(filterCamera)}
|
grid-id=${ifDefined(filterCamera)}
|
||||||
|
grid-width-factor=${ifDefined(gridWidthFactor)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.viewFilterCameraID=${filterCamera}
|
.viewFilterCameraID=${filterCamera}
|
||||||
|
|||||||
@@ -161,10 +161,15 @@ const rotationSchema = z
|
|||||||
.or(z.literal(270));
|
.or(z.literal(270));
|
||||||
export type Rotation = z.infer<typeof rotationSchema>;
|
export type Rotation = z.infer<typeof rotationSchema>;
|
||||||
|
|
||||||
|
const cameraDimensionsGridSchema = z.object({
|
||||||
|
width_factor: z.number().min(0.1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const cameraDimensionsSchema = z.object({
|
const cameraDimensionsSchema = z.object({
|
||||||
aspect_ratio: aspectRatioSchema.optional(),
|
aspect_ratio: aspectRatioSchema.optional(),
|
||||||
layout: mediaLayoutConfigSchema.optional(),
|
layout: mediaLayoutConfigSchema.optional(),
|
||||||
rotation: rotationSchema.optional(),
|
rotation: rotationSchema.optional(),
|
||||||
|
grid: cameraDimensionsGridSchema.optional(),
|
||||||
});
|
});
|
||||||
export type CameraDimensionsConfig = z.infer<typeof cameraDimensionsSchema>;
|
export type CameraDimensionsConfig = z.infer<typeof cameraDimensionsSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,14 @@
|
|||||||
|
|
||||||
::slotted(*) {
|
::slotted(*) {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: var(--advanced-camera-card-grid-column-size);
|
|
||||||
|
// Support multi-column cells via --advanced-camera-card-grid-width-factor CSS variable.
|
||||||
|
// Default to 1 column if not specified.
|
||||||
|
--advanced-camera-card-grid-width-factor: 1;
|
||||||
|
width: calc(
|
||||||
|
var(--advanced-camera-card-grid-width-factor) *
|
||||||
|
var(--advanced-camera-card-grid-column-size)
|
||||||
|
);
|
||||||
|
|
||||||
// Unselected items included a transparent border to act as the effective
|
// Unselected items included a transparent border to act as the effective
|
||||||
// gutter between elements, and to ensure when the item is selected it does
|
// gutter between elements, and to ensure when the item is selected it does
|
||||||
@@ -35,10 +42,13 @@
|
|||||||
::slotted([selected]) {
|
::slotted([selected]) {
|
||||||
border: var(--advanced-camera-card-grid-border-size) solid
|
border: var(--advanced-camera-card-grid-border-size) solid
|
||||||
var(--advanced-camera-card-grid-selected-border-color);
|
var(--advanced-camera-card-grid-selected-border-color);
|
||||||
|
|
||||||
|
// Selected width = columns × selection factor, capped at 100%.
|
||||||
width: min(
|
width: min(
|
||||||
100%,
|
100%,
|
||||||
calc(
|
calc(
|
||||||
var(--advanced-camera-card-grid-selected-width-factor) *
|
var(--advanced-camera-card-grid-width-factor) *
|
||||||
|
var(--advanced-camera-card-grid-selected-width-factor) *
|
||||||
var(--advanced-camera-card-grid-column-size)
|
var(--advanced-camera-card-grid-column-size)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -589,4 +589,62 @@ describe('MediaGridController', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('should set width factor styles correctly', () => {
|
||||||
|
it('should apply width factor CSS variable when attribute is present', () => {
|
||||||
|
const children = createChildren(['0', '1', '2']);
|
||||||
|
children[0].setAttribute('grid-width-factor', '2');
|
||||||
|
children[1].setAttribute('grid-width-factor', '3');
|
||||||
|
const parent = createParent({ children: children });
|
||||||
|
createController(parent);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
children[0].style.getPropertyValue('--advanced-camera-card-grid-width-factor'),
|
||||||
|
).toBe('2');
|
||||||
|
expect(
|
||||||
|
children[1].style.getPropertyValue('--advanced-camera-card-grid-width-factor'),
|
||||||
|
).toBe('3');
|
||||||
|
expect(
|
||||||
|
children[2].style.getPropertyValue('--advanced-camera-card-grid-width-factor'),
|
||||||
|
).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update width factor styles when attribute changes', () => {
|
||||||
|
const children = createChildren(['0', '1', '2']);
|
||||||
|
const parent = createParent({ children: children });
|
||||||
|
createController(parent);
|
||||||
|
|
||||||
|
// Initially no width factor.
|
||||||
|
expect(
|
||||||
|
children[0].style.getPropertyValue('--advanced-camera-card-grid-width-factor'),
|
||||||
|
).toBe('');
|
||||||
|
|
||||||
|
// Set the attribute.
|
||||||
|
children[0].setAttribute('grid-width-factor', '4');
|
||||||
|
triggerMutationObserver('cell');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
children[0].style.getPropertyValue('--advanced-camera-card-grid-width-factor'),
|
||||||
|
).toBe('4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove width factor style when attribute is removed', () => {
|
||||||
|
const children = createChildren(['0', '1', '2']);
|
||||||
|
children[0].setAttribute('grid-width-factor', '2');
|
||||||
|
const parent = createParent({ children: children });
|
||||||
|
createController(parent);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
children[0].style.getPropertyValue('--advanced-camera-card-grid-width-factor'),
|
||||||
|
).toBe('2');
|
||||||
|
|
||||||
|
// Remove the attribute.
|
||||||
|
children[0].removeAttribute('grid-width-factor');
|
||||||
|
triggerMutationObserver('cell');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
children[0].style.getPropertyValue('--advanced-camera-card-grid-width-factor'),
|
||||||
|
).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user