feat: Allow per-camera customization of grid width (#2273)
- Closes #2271
This commit is contained in:
@@ -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) ).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user