From 252ead62dc91b039ff8d5520f3de2b4a533d3460 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 9 Dec 2025 22:18:56 -0800 Subject: [PATCH] feat: Allow per-camera customization of grid width (#2273) - Closes #2271 --- docs/configuration/cameras/README.md | 17 ++++++ src/components-lib/media-grid-controller.ts | 24 +++++++- src/components/live/grid.ts | 7 +++ src/components/viewer/grid.ts | 8 +++ src/config/schema/cameras.ts | 5 ++ src/scss/media-grid.scss | 14 ++++- .../media-grid-controller.test.ts | 58 +++++++++++++++++++ 7 files changed, 129 insertions(+), 4 deletions(-) diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index b93adccd..ec76703d 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -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) ). diff --git a/src/components-lib/media-grid-controller.ts b/src/components-lib/media-grid-controller.ts index 9b877cf2..34e95372 100644 --- a/src/components-lib/media-grid-controller.ts +++ b/src/components-lib/media-grid-controller.ts @@ -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) { diff --git a/src/components/live/grid.ts b/src/components/live/grid.ts index 5cbe1c8a..310f70b1 100644 --- a/src/components/live/grid.ts +++ b/src/components/live/grid.ts @@ -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` ; +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; diff --git a/src/scss/media-grid.scss b/src/scss/media-grid.scss index 6e1e270c..4d1010b9 100644 --- a/src/scss/media-grid.scss +++ b/src/scss/media-grid.scss @@ -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) ) ); diff --git a/tests/components-lib/media-grid-controller.test.ts b/tests/components-lib/media-grid-controller.test.ts index 97e48e4f..fc91034d 100644 --- a/tests/components-lib/media-grid-controller.test.ts +++ b/tests/components-lib/media-grid-controller.test.ts @@ -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(''); + }); + }); });