From e74cac697e5d23edf74bd26ce3270e214111e3de Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 17 Apr 2025 15:36:34 +0200 Subject: [PATCH] Add fit mode support to picture glance card and picture entity card (#25005) Co-authored-by: karwosts --- src/components/ha-camera-stream.ts | 23 ++ src/components/ha-hls-player.ts | 14 +- src/components/ha-web-rtc-player.ts | 12 +- src/panels/lovelace/cards/hui-area-card.ts | 3 +- .../lovelace/cards/hui-picture-entity-card.ts | 10 +- .../lovelace/cards/hui-picture-glance-card.ts | 14 +- src/panels/lovelace/cards/types.ts | 2 + src/panels/lovelace/components/hui-image.ts | 10 + .../hui-picture-entity-card-editor.ts | 203 ++++++++++++------ .../hui-picture-glance-card-editor.ts | 201 +++++++++++------ src/translations/en.json | 11 + 11 files changed, 357 insertions(+), 146 deletions(-) diff --git a/src/components/ha-camera-stream.ts b/src/components/ha-camera-stream.ts index 229ec729f7..9869def6ec 100644 --- a/src/components/ha-camera-stream.ts +++ b/src/components/ha-camera-stream.ts @@ -1,6 +1,7 @@ import { css, html, LitElement, nothing, type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; import { repeat } from "lit/directives/repeat"; +import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; import { computeStateName } from "../common/entity/compute_state_name"; import { supportsFeature } from "../common/entity/supports-feature"; @@ -32,6 +33,10 @@ export class HaCameraStream extends LitElement { @property({ attribute: false }) public stateObj?: CameraEntity; + @property({ attribute: false }) public aspectRatio?: number; + + @property({ attribute: false }) public fitMode?: "cover" | "contain" | "fill"; + @property({ type: Boolean, attribute: "controls" }) public controls = false; @@ -101,6 +106,10 @@ export class HaCameraStream extends LitElement { : this._connected ? computeMJPEGStreamUrl(this.stateObj) : this._posterUrl || ""} + style=${styleMap({ + aspectRatio: this.aspectRatio, + objectFit: this.fitMode, + })} alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`} />`; } @@ -117,6 +126,8 @@ export class HaCameraStream extends LitElement { .posterUrl=${this._posterUrl} @streams=${this._handleHlsStreams} class=${stream.visible ? "" : "hidden"} + .aspectRatio=${this.aspectRatio} + .fitMode=${this.fitMode} >`; } @@ -131,6 +142,8 @@ export class HaCameraStream extends LitElement { .posterUrl=${this._posterUrl} @streams=${this._handleWebRtcStreams} class=${stream.visible ? "" : "hidden"} + .aspectRatio=${this.aspectRatio} + .fitMode=${this.fitMode} >`; } @@ -259,6 +272,16 @@ export class HaCameraStream extends LitElement { width: 100%; } + ha-web-rtc-player { + width: 100%; + height: 100%; + } + + ha-hls-player { + width: 100%; + height: 100%; + } + .hidden { display: none; } diff --git a/src/components/ha-hls-player.ts b/src/components/ha-hls-player.ts index 64ba51706e..332e11f0d3 100644 --- a/src/components/ha-hls-player.ts +++ b/src/components/ha-hls-player.ts @@ -2,12 +2,13 @@ import type HlsType from "hls.js"; import type { PropertyValues, TemplateResult } from "lit"; import { css, html, LitElement } from "lit"; import { customElement, property, query, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import { isComponentLoaded } from "../common/config/is_component_loaded"; import { fireEvent } from "../common/dom/fire_event"; import { nextRender } from "../common/util/render-status"; +import { fetchStreamUrl } from "../data/camera"; import type { HomeAssistant } from "../types"; import "./ha-alert"; -import { fetchStreamUrl } from "../data/camera"; -import { isComponentLoaded } from "../common/config/is_component_loaded"; type HlsLite = Omit< HlsType, @@ -24,6 +25,10 @@ class HaHLSPlayer extends LitElement { @property({ attribute: "poster-url" }) public posterUrl?: string; + @property({ attribute: false }) public aspectRatio?: number; + + @property({ attribute: false }) public fitMode?: "cover" | "contain" | "fill"; + @property({ type: Boolean, attribute: "controls" }) public controls = false; @@ -87,6 +92,11 @@ class HaHLSPlayer extends LitElement { ?playsinline=${this.playsInline} ?controls=${this.controls} @loadeddata=${this._loadedData} + style=${styleMap({ + height: this.aspectRatio == null ? "100%" : "auto", + aspectRatio: this.aspectRatio, + objectFit: this.fitMode, + })} >` : ""} `; diff --git a/src/components/ha-web-rtc-player.ts b/src/components/ha-web-rtc-player.ts index 8ed6cc809d..e11cf5794e 100644 --- a/src/components/ha-web-rtc-player.ts +++ b/src/components/ha-web-rtc-player.ts @@ -1,8 +1,9 @@ -import type { PropertyValues, TemplateResult } from "lit"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import type { PropertyValues, TemplateResult } from "lit"; import { css, html, LitElement } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { ifDefined } from "lit/directives/if-defined"; +import { styleMap } from "lit/directives/style-map"; import { fireEvent } from "../common/dom/fire_event"; import { addWebRtcCandidate, @@ -26,6 +27,10 @@ class HaWebRtcPlayer extends LitElement { @property() public entityid?: string; + @property({ attribute: false }) public aspectRatio?: number; + + @property({ attribute: false }) public fitMode?: "cover" | "contain" | "fill"; + @property({ type: Boolean, attribute: "controls" }) public controls = false; @@ -69,6 +74,11 @@ class HaWebRtcPlayer extends LitElement { ?controls=${this.controls} poster=${ifDefined(this.posterUrl)} @loadeddata=${this._loadedData} + style=${styleMap({ + height: this.aspectRatio == null ? "100%" : "auto", + aspectRatio: this.aspectRatio, + objectFit: this.fitMode, + })} > `; } diff --git a/src/panels/lovelace/cards/hui-area-card.ts b/src/panels/lovelace/cards/hui-area-card.ts index 3a901f064f..b9496675eb 100644 --- a/src/panels/lovelace/cards/hui-area-card.ts +++ b/src/panels/lovelace/cards/hui-area-card.ts @@ -93,8 +93,7 @@ export class HuiAreaCard @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: false }) - public layout?: string; + @property({ attribute: false }) public layout?: string; @state() private _config?: AreaCardConfig; diff --git a/src/panels/lovelace/cards/hui-picture-entity-card.ts b/src/panels/lovelace/cards/hui-picture-entity-card.ts index 37f24fd9f0..6fa5be786c 100644 --- a/src/panels/lovelace/cards/hui-picture-entity-card.ts +++ b/src/panels/lovelace/cards/hui-picture-entity-card.ts @@ -55,6 +55,8 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard { @property({ attribute: false }) public hass?: HomeAssistant; + @property({ attribute: false }) public layout?: string; + @state() private _config?: PictureEntityCardConfig; public getCardSize(): number { @@ -155,6 +157,10 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard { } } + const ignoreAspectRatio = + this.layout === "grid" && + typeof this._config.grid_options?.rows === "number"; + return html`
${this._config.title @@ -324,6 +333,9 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard { height: 100%; box-sizing: border-box; } + hui-image { + height: 100%; + } hui-image.clickable { cursor: pointer; } diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 30227b5fe3..0d02968c8a 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -440,6 +440,7 @@ export interface PictureEntityCardConfig extends LovelaceCardConfig { state_image?: Record; state_filter?: string[]; aspect_ratio?: string; + fit_mode?: "cover" | "contain" | "fill"; tap_action?: ActionConfig; hold_action?: ActionConfig; double_tap_action?: ActionConfig; @@ -458,6 +459,7 @@ export interface PictureGlanceCardConfig extends LovelaceCardConfig { state_image?: Record; state_filter?: string[]; aspect_ratio?: string; + fit_mode?: "cover" | "contain" | "fill"; entity?: string; tap_action?: ActionConfig; hold_action?: ActionConfig; diff --git a/src/panels/lovelace/components/hui-image.ts b/src/panels/lovelace/components/hui-image.ts index 1cf23eb329..08c54b61bf 100644 --- a/src/panels/lovelace/components/hui-image.ts +++ b/src/panels/lovelace/components/hui-image.ts @@ -217,6 +217,10 @@ export class HuiImage extends LitElement { muted .hass=${this.hass} .stateObj=${cameraObj} + .fitMode=${this.fitMode} + .aspectRatio=${this._ratio + ? this._ratio.w / this._ratio.h + : undefined} @load=${this._onVideoLoad} > ` @@ -400,6 +404,12 @@ export class HuiImage extends LitElement { object-fit: cover; } + ha-camera-stream { + display: block; + height: 100%; + width: 100%; + } + .progress-container { display: flex; justify-content: center; diff --git a/src/panels/lovelace/editor/config-elements/hui-picture-entity-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-picture-entity-card-editor.ts index 86a5686909..5fbd83db4e 100644 --- a/src/panels/lovelace/editor/config-elements/hui-picture-entity-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-picture-entity-card-editor.ts @@ -2,11 +2,24 @@ import { mdiGestureTap } from "@mdi/js"; import type { CSSResultGroup } from "lit"; import { html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { assert, assign, boolean, object, optional, string } from "superstruct"; +import memoizeOne from "memoize-one"; +import { + assert, + assign, + boolean, + enums, + object, + optional, + string, +} from "superstruct"; import { fireEvent } from "../../../../common/dom/fire_event"; import { computeDomain } from "../../../../common/entity/compute_domain"; +import type { LocalizeFunc } from "../../../../common/translations/localize"; import "../../../../components/ha-form/ha-form"; -import type { SchemaUnion } from "../../../../components/ha-form/types"; +import type { + HaFormSchema, + SchemaUnion, +} from "../../../../components/ha-form/types"; import type { HomeAssistant } from "../../../../types"; import { STUB_IMAGE } from "../../cards/hui-picture-entity-card"; import type { PictureEntityCardConfig } from "../../cards/types"; @@ -22,7 +35,7 @@ const cardConfigStruct = assign( image: optional(string()), name: optional(string()), camera_image: optional(string()), - camera_view: optional(string()), + camera_view: optional(enums(["auto", "live"])), aspect_ratio: optional(string()), tap_action: optional(actionConfigStruct), hold_action: optional(actionConfigStruct), @@ -30,74 +43,10 @@ const cardConfigStruct = assign( show_name: optional(boolean()), show_state: optional(boolean()), theme: optional(string()), - fit_mode: optional(string()), + fit_mode: optional(enums(["cover", "contain", "fill"])), }) ); -const SCHEMA = [ - { name: "entity", required: true, selector: { entity: {} } }, - { name: "name", selector: { text: {} } }, - { name: "image", selector: { image: {} } }, - { name: "camera_image", selector: { entity: { domain: "camera" } } }, - { - name: "", - type: "grid", - schema: [ - { - name: "camera_view", - selector: { select: { options: ["auto", "live"] } }, - }, - { name: "aspect_ratio", selector: { text: {} } }, - ], - }, - { - name: "", - type: "grid", - schema: [ - { - name: "show_name", - selector: { boolean: {} }, - }, - { - name: "show_state", - selector: { boolean: {} }, - }, - ], - }, - { name: "theme", selector: { theme: {} } }, - { - name: "interactions", - type: "expandable", - flatten: true, - iconPath: mdiGestureTap, - schema: [ - { - name: "tap_action", - selector: { - ui_action: { - default_action: "more-info", - }, - }, - }, - { - name: "", - type: "optional_actions", - flatten: true, - schema: (["hold_action", "double_tap_action"] as const).map( - (action) => ({ - name: action, - selector: { - ui_action: { - default_action: "none" as const, - }, - }, - }) - ), - }, - ], - }, -] as const; - @customElement("hui-picture-entity-card-editor") export class HuiPictureEntityCardEditor extends LitElement @@ -112,6 +61,99 @@ export class HuiPictureEntityCardEditor this._config = config; } + private _schema = memoizeOne( + (localize: LocalizeFunc) => + [ + { name: "entity", required: true, selector: { entity: {} } }, + { name: "name", selector: { text: {} } }, + { name: "image", selector: { image: {} } }, + { name: "camera_image", selector: { entity: { domain: "camera" } } }, + { + name: "", + type: "grid", + schema: [ + { + name: "camera_view", + required: true, + selector: { + select: { + options: ["auto", "live"].map((value) => ({ + value, + label: localize( + `ui.panel.lovelace.editor.card.generic.camera_view_options.${value}` + ), + })), + mode: "dropdown", + }, + }, + }, + { + name: "fit_mode", + required: true, + selector: { + select: { + options: ["cover", "contain", "fill"].map((value) => ({ + value, + label: localize( + `ui.panel.lovelace.editor.card.generic.fit_mode_options.${value}` + ), + })), + mode: "dropdown", + }, + }, + }, + { name: "aspect_ratio", selector: { text: {} } }, + ], + }, + { + name: "", + type: "grid", + schema: [ + { + name: "show_name", + selector: { boolean: {} }, + }, + { + name: "show_state", + selector: { boolean: {} }, + }, + ], + }, + { name: "theme", selector: { theme: {} } }, + { + name: "interactions", + type: "expandable", + flatten: true, + iconPath: mdiGestureTap, + schema: [ + { + name: "tap_action", + selector: { + ui_action: { + default_action: "more-info", + }, + }, + }, + { + name: "", + type: "optional_actions", + flatten: true, + schema: (["hold_action", "double_tap_action"] as const).map( + (action) => ({ + name: action, + selector: { + ui_action: { + default_action: "none" as const, + }, + }, + }) + ), + }, + ], + }, + ] as const satisfies HaFormSchema[] + ); + protected render() { if (!this.hass || !this._config) { return nothing; @@ -121,15 +163,19 @@ export class HuiPictureEntityCardEditor show_state: true, show_name: true, camera_view: "auto", + fit_mode: "cover", ...this._config, }; + const schema = this._schema(this.hass.localize); + return html`
@@ -152,7 +198,9 @@ export class HuiPictureEntityCardEditor fireEvent(this, "config-changed", { config }); } - private _computeLabelCallback = (schema: SchemaUnion) => { + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { switch (schema.name) { case "theme": case "tap_action": @@ -170,6 +218,21 @@ export class HuiPictureEntityCardEditor } }; + private _computeHelperCallback = ( + schema: SchemaUnion> + ) => { + switch (schema.name) { + case "aspect_ratio": + return typeof this._config?.grid_options?.rows === "number" + ? this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.aspect_ratio_ignored` + ) + : ""; + default: + return ""; + } + }; + static styles: CSSResultGroup = configElementStyle; } diff --git a/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts index 763380265d..53cf5fd8b0 100644 --- a/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts @@ -1,11 +1,24 @@ +import { mdiGestureTap } from "@mdi/js"; import type { CSSResultGroup } from "lit"; import { html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { array, assert, assign, object, optional, string } from "superstruct"; -import { mdiGestureTap } from "@mdi/js"; +import memoizeOne from "memoize-one"; +import { + array, + assert, + assign, + enums, + object, + optional, + string, +} from "superstruct"; import { fireEvent } from "../../../../common/dom/fire_event"; +import type { LocalizeFunc } from "../../../../common/translations/localize"; import "../../../../components/ha-form/ha-form"; -import type { SchemaUnion } from "../../../../components/ha-form/types"; +import type { + HaFormSchema, + SchemaUnion, +} from "../../../../components/ha-form/types"; import type { ActionConfig } from "../../../../data/lovelace/config/action"; import type { HomeAssistant } from "../../../../types"; import type { PictureGlanceCardConfig } from "../../cards/types"; @@ -26,78 +39,17 @@ const cardConfigStruct = assign( image: optional(string()), image_entity: optional(string()), camera_image: optional(string()), - camera_view: optional(string()), + camera_view: optional(enums(["auto", "live"])), aspect_ratio: optional(string()), tap_action: optional(actionConfigStruct), hold_action: optional(actionConfigStruct), double_tap_action: optional(actionConfigStruct), entities: array(entitiesConfigStruct), theme: optional(string()), + fit_mode: optional(enums(["cover", "contain", "fill"])), }) ); -const SCHEMA = [ - { name: "title", selector: { text: {} } }, - { name: "image", selector: { image: {} } }, - { - name: "image_entity", - selector: { entity: { domain: ["image", "person"] } }, - }, - { name: "camera_image", selector: { entity: { domain: "camera" } } }, - { - name: "", - type: "grid", - schema: [ - { - name: "camera_view", - selector: { select: { options: ["auto", "live"] } }, - }, - { name: "aspect_ratio", selector: { text: {} } }, - ], - }, - { name: "entity", selector: { entity: {} } }, - { name: "theme", selector: { theme: {} } }, - { - name: "interactions", - type: "expandable", - flatten: true, - iconPath: mdiGestureTap, - schema: [ - { - name: "tap_action", - selector: { - ui_action: { - default_action: "more-info", - }, - }, - }, - { - name: "hold_action", - selector: { - ui_action: { - default_action: "more-info", - }, - }, - }, - { - name: "", - type: "optional_actions", - flatten: true, - schema: [ - { - name: "double_tap_action", - selector: { - ui_action: { - default_action: "none", - }, - }, - }, - ], - }, - ], - }, -] as const; - @customElement("hui-picture-glance-card-editor") export class HuiPictureGlanceCardEditor extends LitElement @@ -109,6 +61,97 @@ export class HuiPictureGlanceCardEditor @state() private _configEntities?: EntityConfig[]; + private _schema = memoizeOne( + (localize: LocalizeFunc) => + [ + { name: "title", selector: { text: {} } }, + { name: "image", selector: { image: {} } }, + { + name: "image_entity", + selector: { entity: { domain: ["image", "person"] } }, + }, + { name: "camera_image", selector: { entity: { domain: "camera" } } }, + { + name: "", + type: "grid", + schema: [ + { + name: "camera_view", + required: true, + selector: { + select: { + options: ["auto", "live"].map((value) => ({ + value, + label: localize( + `ui.panel.lovelace.editor.card.generic.camera_view_options.${value}` + ), + })), + mode: "dropdown", + }, + }, + }, + { + name: "fit_mode", + required: true, + selector: { + select: { + options: ["cover", "contain", "fill"].map((value) => ({ + value, + label: localize( + `ui.panel.lovelace.editor.card.generic.fit_mode_options.${value}` + ), + })), + mode: "dropdown", + }, + }, + }, + { name: "aspect_ratio", selector: { text: {} } }, + ], + }, + { name: "entity", selector: { entity: {} } }, + { name: "theme", selector: { theme: {} } }, + { + name: "interactions", + type: "expandable", + flatten: true, + iconPath: mdiGestureTap, + schema: [ + { + name: "tap_action", + selector: { + ui_action: { + default_action: "more-info", + }, + }, + }, + { + name: "hold_action", + selector: { + ui_action: { + default_action: "more-info", + }, + }, + }, + { + name: "", + type: "optional_actions", + flatten: true, + schema: [ + { + name: "double_tap_action", + selector: { + ui_action: { + default_action: "none", + }, + }, + }, + ], + }, + ], + }, + ] as const satisfies HaFormSchema[] + ); + public setConfig(config: PictureGlanceCardConfig): void { assert(config, cardConfigStruct); this._config = config; @@ -128,14 +171,17 @@ export class HuiPictureGlanceCardEditor return nothing; } - const data = { camera_view: "auto", ...this._config }; + const data = { camera_view: "auto", fit_mode: "cover", ...this._config }; + + const schema = this._schema(this.hass.localize); return html`
@@ -164,7 +210,9 @@ export class HuiPictureGlanceCardEditor fireEvent(this, "config-changed", { config: this._config }); } - private _computeLabelCallback = (schema: SchemaUnion) => { + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { switch (schema.name) { case "theme": case "tap_action": @@ -186,6 +234,21 @@ export class HuiPictureGlanceCardEditor } }; + private _computeHelperCallback = ( + schema: SchemaUnion> + ) => { + switch (schema.name) { + case "aspect_ratio": + return typeof this._config?.grid_options?.rows === "number" + ? this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.aspect_ratio_ignored` + ) + : ""; + default: + return ""; + } + }; + static styles: CSSResultGroup = configElementStyle; } diff --git a/src/translations/en.json b/src/translations/en.json index 4b2f7ef013..3377222435 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7194,13 +7194,24 @@ "generic": { "alt_text": "Alternative text", "aspect_ratio": "Aspect ratio", + "aspect_ratio_ignored": "Will be ignored because the card is resized.", "attribute": "Attribute", "camera_image": "Camera entity", "image_entity": "Image entity", "camera_view": "Camera view", + "camera_view_options": { + "auto": "Auto", + "live": "Live" + }, "double_tap_action": "Double tap behavior", "entities": "Entities", "entity": "Entity", + "fit_mode": "Fit mode", + "fit_mode_options": { + "contain": "Contain", + "cover": "Cover", + "fill": "Fill" + }, "hold_action": "Hold behavior", "hours_to_show": "Hours to show", "days_to_show": "Days to show",