feat: Allow per-camera customization of grid width (#2273)

- Closes #2271
This commit is contained in:
Dermot Duffy
2025-12-09 22:18:56 -08:00
committed by GitHub
parent c9c7e93e19
commit 252ead62dc
7 changed files with 129 additions and 4 deletions
+17
View File
@@ -135,6 +135,7 @@ cameras:
| 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`. |
| `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. |
| `rotation` | `0` | Rotates the camera clockwise by `0`, `90`, `180` or `270` degrees. |
@@ -147,6 +148,22 @@ cameras:
> [!WARNING]
> 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
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) ).
+22 -2
View File
@@ -2,7 +2,11 @@ import { isEqual, throttle } from 'lodash-es';
import Masonry from 'masonry-layout';
import { ViewDisplayConfig } from '../config/schema/common/display';
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 {
AdvancedCameraCardMediaLoadedEventTarget,
@@ -30,6 +34,7 @@ export interface MediaGridSelected {
export interface MediaGridConstructorOptions {
selected?: GridID;
idAttribute?: string;
widthFactorAttribute?: string;
displayConfig?: ViewDisplayConfig;
}
@@ -51,6 +56,7 @@ export class MediaGridController {
protected _displayConfig: ViewDisplayConfig | null = null;
protected _hostWidth: number;
protected _idAttribute: string;
protected _widthFactorAttribute: string;
protected _throttledLayout = throttle(
() => this._masonry?.layout?.(),
@@ -79,6 +85,7 @@ export class MediaGridController {
this._host = host;
this._selected = options?.selected ?? null;
this._idAttribute = options?.idAttribute ?? 'grid-id';
this._widthFactorAttribute = options?.widthFactorAttribute ?? 'grid-width-factor';
this._hostWidth = this._host.getBoundingClientRect().width;
this._hostResizeObserver.observe(host);
this._displayConfig = options?.displayConfig ?? null;
@@ -233,7 +240,7 @@ export class MediaGridController {
this._cellResizeObserver.disconnect();
for (const child of gridContents.values()) {
this._cellMutationObserver.observe(child, {
attributeFilter: [this._idAttribute],
attributeFilter: [this._idAttribute, this._widthFactorAttribute],
attributes: true,
});
this._cellResizeObserver.observe(child);
@@ -241,6 +248,7 @@ export class MediaGridController {
this._sortItemsInGrid();
this._updateSelectedStylesOnElements();
this._updateWidthFactorStyles();
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 {
const columns = this._getColumns();
if (columns === 1) {
+7
View File
@@ -45,9 +45,16 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
const view = this.viewManagerEpoch?.manager.getView();
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`
<advanced-camera-card-live-carousel
grid-id=${ifDefined(cameraID)}
grid-width-factor=${ifDefined(gridWidthFactor)}
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewFilterCameraID=${cameraID}
+8
View File
@@ -41,9 +41,17 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
protected _renderCarousel(filterCamera?: string): TemplateResult {
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`
<advanced-camera-card-viewer-carousel
grid-id=${ifDefined(filterCamera)}
grid-width-factor=${ifDefined(gridWidthFactor)}
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewFilterCameraID=${filterCamera}
+5
View File
@@ -161,10 +161,15 @@ const rotationSchema = z
.or(z.literal(270));
export type Rotation = z.infer<typeof rotationSchema>;
const cameraDimensionsGridSchema = z.object({
width_factor: z.number().min(0.1).optional(),
});
const cameraDimensionsSchema = z.object({
aspect_ratio: aspectRatioSchema.optional(),
layout: mediaLayoutConfigSchema.optional(),
rotation: rotationSchema.optional(),
grid: cameraDimensionsGridSchema.optional(),
});
export type CameraDimensionsConfig = z.infer<typeof cameraDimensionsSchema>;
+12 -2
View File
@@ -23,7 +23,14 @@
::slotted(*) {
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
// gutter between elements, and to ensure when the item is selected it does
@@ -35,10 +42,13 @@
::slotted([selected]) {
border: var(--advanced-camera-card-grid-border-size) solid
var(--advanced-camera-card-grid-selected-border-color);
// Selected width = columns × selection factor, capped at 100%.
width: min(
100%,
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)
)
);
@@ -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('');
});
});
});