From 6653333c38ff128a62a7cc9046edf0da9e135f7c Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Fri, 10 Oct 2025 02:26:49 -0700 Subject: [PATCH] Add media selector to picture-card-editor (#26317) --- src/components/ha-picture-upload.ts | 67 +++++++--- .../ha-selector/ha-selector-media.ts | 57 ++++++++- .../dialog-media-player-browse.ts | 2 + .../media-player/ha-browse-media-manual.ts | 56 +++++++-- .../media-player/ha-media-player-browse.ts | 10 +- .../media-player/show-media-browser-dialog.ts | 2 + src/data/selector.ts | 4 + src/panels/lovelace/cards/hui-picture-card.ts | 12 +- src/panels/lovelace/cards/types.ts | 3 +- .../hui-picture-card-editor.ts | 119 +++++++++++------- src/translations/en.json | 3 +- 11 files changed, 251 insertions(+), 84 deletions(-) diff --git a/src/components/ha-picture-upload.ts b/src/components/ha-picture-upload.ts index 8c0bc91ba8..ad5d0c4908 100644 --- a/src/components/ha-picture-upload.ts +++ b/src/components/ha-picture-upload.ts @@ -39,6 +39,15 @@ export class HaPictureUpload extends LitElement { @property({ type: Boolean, attribute: "select-media" }) public selectMedia = false; + // This property is set when this component is used inside a media selector. + // When set, it returns selected media or uploaded files as MediaSelectorValue + // When unset, it only allows selecting images from image-upload, and returns + // selected or uploaded images as a string starting with /api/... + @property({ type: Boolean, attribute: "full-media" }) public fullMedia = + false; + + @property({ attribute: false }) public contentIdHelper?: string; + @property({ attribute: false }) public cropOptions?: CropOptions; @property({ type: Boolean }) public original = false; @@ -164,12 +173,33 @@ export class HaPictureUpload extends LitElement { this._uploading = true; try { const media = await createImage(this.hass, file); - this.value = generateImageThumbnailUrl( - media.id, - this.size, - this.original - ); - fireEvent(this, "change"); + if (this.fullMedia) { + const item = { + media_content_id: `${MEDIA_PREFIX}/${media.id}`, + media_content_type: media.content_type, + title: media.name, + media_class: "image" as const, + can_play: true, + can_expand: false, + can_search: false, + thumbnail: generateImageThumbnailUrl(media.id, 256), + } as const; + const navigateIds = [ + {}, + { media_content_type: "app", media_content_id: MEDIA_PREFIX }, + ]; + fireEvent(this, "media-picked", { + item, + navigateIds, + }); + } else { + this.value = generateImageThumbnailUrl( + media.id, + this.size, + this.original + ); + fireEvent(this, "change"); + } } catch (err: any) { showAlertDialog(this, { text: err.toString(), @@ -183,15 +213,24 @@ export class HaPictureUpload extends LitElement { showMediaBrowserDialog(this, { action: "pick", entityId: "browser", - navigateIds: [ - { media_content_id: undefined, media_content_type: undefined }, - { - media_content_id: MEDIA_PREFIX, - media_content_type: "app", - }, - ], - minimumNavigateLevel: 2, + accept: ["image/*"], + navigateIds: this.fullMedia + ? undefined + : [ + { media_content_id: undefined, media_content_type: undefined }, + { + media_content_id: MEDIA_PREFIX, + media_content_type: "app", + }, + ], + minimumNavigateLevel: this.fullMedia ? undefined : 2, + hideContentType: true, + contentIdHelper: this.contentIdHelper, mediaPickedCallback: async (pickedMedia: MediaPickedEvent) => { + if (this.fullMedia) { + fireEvent(this, "media-picked", pickedMedia); + return; + } const mediaId = getIdFromUrl(pickedMedia.item.media_content_id); if (mediaId) { if (this.crop) { diff --git a/src/components/ha-selector/ha-selector-media.ts b/src/components/ha-selector/ha-selector-media.ts index 1bd07cf985..d86d8a4a24 100644 --- a/src/components/ha-selector/ha-selector-media.ts +++ b/src/components/ha-selector/ha-selector-media.ts @@ -19,6 +19,7 @@ import "../ha-form/ha-form"; import type { SchemaUnion } from "../ha-form/types"; import { showMediaBrowserDialog } from "../media-player/show-media-browser-dialog"; import { ensureArray } from "../../common/array/ensure-array"; +import "../ha-picture-upload"; const MANUAL_SCHEMA = [ { name: "media_content_id", required: false, selector: { text: {} } }, @@ -105,6 +106,17 @@ export class HaMediaSelector extends LitElement { (stateObj && supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA)); + if (this.selector.media?.image_upload && !this.value) { + return html``; + } + return html` ${this._hasAccept || (this._contextEntities && this._contextEntities.length <= 1) @@ -142,8 +154,7 @@ export class HaMediaSelector extends LitElement { .computeHelper=${this._computeHelperCallback} > ` - : html` - - `} + ${this.selector.media?.clearable + ? html`
+ + ${this.hass.localize( + "ui.components.picture-upload.clear_picture" + )} + +
` + : nothing}`} `; } @@ -248,6 +272,8 @@ export class HaMediaSelector extends LitElement { accept: this.selector.media?.accept, defaultId: this.value?.media_content_id, defaultType: this.value?.media_content_type, + hideContentType: this.selector.media?.hide_content_type, + contentIdHelper: this.selector.media?.content_id_helper, mediaPickedCallback: (pickedMedia: MediaPickedEvent) => { fireEvent(this, "value-changed", { value: { @@ -289,6 +315,31 @@ export class HaMediaSelector extends LitElement { } } + private _pictureUploadMediaPicked(ev) { + const pickedMedia = ev.detail as MediaPickedEvent; + fireEvent(this, "value-changed", { + value: { + ...this.value, + media_content_id: pickedMedia.item.media_content_id, + media_content_type: pickedMedia.item.media_content_type, + metadata: { + title: pickedMedia.item.title, + thumbnail: pickedMedia.item.thumbnail, + media_class: pickedMedia.item.media_class, + children_media_class: pickedMedia.item.children_media_class, + navigateIds: pickedMedia.navigateIds?.map((id) => ({ + media_content_type: id.media_content_type, + media_content_id: id.media_content_id, + })), + }, + }, + }); + } + + private _clearValue() { + fireEvent(this, "value-changed", { value: undefined }); + } + static styles = css` ha-entity-picker { display: block; diff --git a/src/components/media-player/dialog-media-player-browse.ts b/src/components/media-player/dialog-media-player-browse.ts index 9a3948068f..a55edd09fe 100644 --- a/src/components/media-player/dialog-media-player-browse.ts +++ b/src/components/media-player/dialog-media-player-browse.ts @@ -167,6 +167,8 @@ class DialogMediaPlayerBrowse extends LitElement { .accept=${this._params.accept} .defaultId=${this._params.defaultId} .defaultType=${this._params.defaultType} + .hideContentType=${this._params.hideContentType} + .contentIdHelper=${this._params.contentIdHelper} @close-dialog=${this.closeDialog} @media-picked=${this._mediaPicked} @media-browsed=${this._mediaBrowsed} diff --git a/src/components/media-player/ha-browse-media-manual.ts b/src/components/media-player/ha-browse-media-manual.ts index df09e7b277..1e9dac3aa7 100644 --- a/src/components/media-player/ha-browse-media-manual.ts +++ b/src/components/media-player/ha-browse-media-manual.ts @@ -19,8 +19,12 @@ class BrowseMediaManual extends LitElement { @property({ attribute: false }) public item!: MediaPlayerItemId; + @property({ attribute: false }) public hideContentType = false; + + @property({ attribute: false }) public contentIdHelper?: string; + private _schema = memoizeOne( - () => + (hideContentType: boolean) => [ { name: "media_content_id", @@ -29,13 +33,17 @@ class BrowseMediaManual extends LitElement { text: {}, }, }, - { - name: "media_content_type", - required: false, - selector: { - text: {}, - }, - }, + ...(hideContentType + ? [] + : [ + { + name: "media_content_type", + required: false, + selector: { + text: {}, + }, + }, + ]), ] as const ); @@ -45,7 +53,7 @@ class BrowseMediaManual extends LitElement {
> - ): string => - this.hass.localize(`ui.components.selectors.media.${entry.name}`); + ): string => { + switch (entry.name) { + case "media_content_id": + case "media_content_type": + return this.hass.localize( + `ui.components.selectors.media.${entry.name}` + ); + } + return entry.name; + }; private _computeHelper = ( entry: SchemaUnion> - ): string => - this.hass.localize(`ui.components.selectors.media.${entry.name}_detail`); + ): string => { + switch (entry.name) { + case "media_content_id": + return ( + this.contentIdHelper || + this.hass.localize( + `ui.components.selectors.media.${entry.name}_detail` + ) + ); + case "media_content_type": + return this.hass.localize( + `ui.components.selectors.media.${entry.name}_detail` + ); + } + return ""; + }; private _mediaPicked() { fireEvent(this, "manual-media-picked", { diff --git a/src/components/media-player/ha-media-player-browse.ts b/src/components/media-player/ha-media-player-browse.ts index 25e4571087..880dd22626 100644 --- a/src/components/media-player/ha-media-player-browse.ts +++ b/src/components/media-player/ha-media-player-browse.ts @@ -76,8 +76,8 @@ declare global { } export interface MediaPlayerItemId { - media_content_id: string | undefined; - media_content_type: string | undefined; + media_content_id?: string | undefined; + media_content_type?: string | undefined; } const MANUAL_ITEM: MediaPlayerItem = { @@ -113,6 +113,10 @@ export class HaMediaPlayerBrowse extends LitElement { @property({ attribute: false }) public defaultType?: string; + @property({ attribute: false }) public hideContentType = false; + + @property({ attribute: false }) public contentIdHelper?: string; + // @todo Consider reworking to eliminate need for attribute since it is manipulated internally @property({ type: Boolean, reflect: true }) public narrow = false; @@ -521,6 +525,8 @@ export class HaMediaPlayerBrowse extends LitElement { media_content_type: this.defaultType || "", }} .hass=${this.hass} + .hideContentType=${this.hideContentType} + .contentIdHelper=${this.contentIdHelper} @manual-media-picked=${this._manualPicked} >` : isTTSMediaSource(currentItem.media_content_id) diff --git a/src/components/media-player/show-media-browser-dialog.ts b/src/components/media-player/show-media-browser-dialog.ts index 978e8a2604..8232ae188e 100644 --- a/src/components/media-player/show-media-browser-dialog.ts +++ b/src/components/media-player/show-media-browser-dialog.ts @@ -14,6 +14,8 @@ export interface MediaPlayerBrowseDialogParams { accept?: string[]; defaultId?: string; defaultType?: string; + hideContentType?: boolean; + contentIdHelper?: string; } export const showMediaBrowserDialog = ( diff --git a/src/data/selector.ts b/src/data/selector.ts index 7276355c6f..ae84dbcd28 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -312,6 +312,10 @@ export interface LocationSelectorValue { export interface MediaSelector { media: { accept?: string[]; + image_upload?: boolean; + clearable?: boolean; + hide_content_type?: boolean; + content_id_helper?: string; } | null; } diff --git a/src/panels/lovelace/cards/hui-picture-card.ts b/src/panels/lovelace/cards/hui-picture-card.ts index c27e12f931..fa26fdb384 100644 --- a/src/panels/lovelace/cards/hui-picture-card.ts +++ b/src/panels/lovelace/cards/hui-picture-card.ts @@ -93,17 +93,21 @@ export class HuiPictureCard extends LitElement implements LovelaceCard { changedProps.has("_config") && changedProps.get("_config")?.image !== this._config?.image; + const image = + (typeof this._config?.image === "object" && + this._config.image.media_content_id) || + (this._config.image as string | undefined); if ( (firstHass || imageChanged) && - typeof this._config?.image === "string" && - isMediaSourceContentId(this._config.image) + typeof image === "string" && + isMediaSourceContentId(image) ) { this._resolvedImage = undefined; - resolveMediaSource(this.hass, this._config?.image).then((result) => { + resolveMediaSource(this.hass, image).then((result) => { this._resolvedImage = result.url; }); } else if (imageChanged) { - this._resolvedImage = this._config?.image; + this._resolvedImage = image; } } diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index b651c173a8..1cc17521e4 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -29,6 +29,7 @@ import type { import type { LovelaceHeaderFooterConfig } from "../header-footer/types"; import type { LovelaceHeadingBadgeConfig } from "../heading-badges/types"; import type { HomeSummary } from "../strategies/home/helpers/home-summaries"; +import type { MediaSelectorValue } from "../../../data/selector"; export type AlarmPanelCardConfigState = | "arm_away" @@ -441,7 +442,7 @@ export interface StatisticCardConfig extends LovelaceCardConfig { } export interface PictureCardConfig extends LovelaceCardConfig { - image?: string; + image?: string | MediaSelectorValue; image_entity?: string; tap_action?: ActionConfig; hold_action?: ActionConfig; diff --git a/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts index d084a2a400..d933057061 100644 --- a/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts @@ -1,7 +1,8 @@ import { mdiGestureTap } from "@mdi/js"; import { html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { assert, assign, object, optional, string } from "superstruct"; +import { assert, assign, object, optional, string, union } from "superstruct"; +import memoizeOne from "memoize-one"; import { fireEvent } from "../../../../common/dom/fire_event"; import type { SchemaUnion } from "../../../../components/ha-form/types"; import "../../../../components/ha-theme-picker"; @@ -11,11 +12,12 @@ import "../../components/hui-action-editor"; import type { LovelaceCardEditor } from "../../types"; import { actionConfigStruct } from "../structs/action-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct"; +import type { LocalizeFunc } from "../../../../common/translations/localize"; const cardConfigStruct = assign( baseLovelaceCardConfig, object({ - image: optional(string()), + image: optional(union([string(), object()])), image_entity: optional(string()), tap_action: optional(actionConfigStruct), hold_action: optional(actionConfigStruct), @@ -25,47 +27,6 @@ const cardConfigStruct = assign( }) ); -const SCHEMA = [ - { name: "image", selector: { image: {} } }, - { - name: "image_entity", - selector: { entity: { domain: ["image", "person"] } }, - }, - { name: "alt_text", selector: { text: {} } }, - { 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-card-editor") export class HuiPictureCardEditor extends LitElement @@ -75,6 +36,63 @@ export class HuiPictureCardEditor @state() private _config?: PictureCardConfig; + private _schema = memoizeOne( + (localize: LocalizeFunc) => + [ + { + name: "image", + selector: { + media: { + accept: ["image/*"] as string[], + clearable: true, + image_upload: true, + hide_content_type: true, + content_id_helper: localize( + "ui.panel.lovelace.editor.card.picture.content_id_helper" + ), + }, + }, + }, + { + name: "image_entity", + selector: { entity: { domain: ["image", "person"] } }, + }, + { name: "alt_text", selector: { text: {} } }, + { 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 + ); + public setConfig(config: PictureCardConfig): void { assert(config, cardConfigStruct); this._config = config; @@ -88,19 +106,28 @@ export class HuiPictureCardEditor return html` `; } + private _processData = memoizeOne((config: PictureCardConfig) => ({ + ...config, + ...(typeof config.image === "string" + ? { image: { media_content_id: config.image } } + : {}), + })); + private _valueChanged(ev: CustomEvent): void { fireEvent(this, "config-changed", { config: ev.detail.value }); } - private _computeLabelCallback = (schema: SchemaUnion) => { + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { switch (schema.name) { case "theme": return `${this.hass!.localize( diff --git a/src/translations/en.json b/src/translations/en.json index 1f080f0c9d..f81c07c455 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7880,7 +7880,8 @@ }, "picture": { "name": "Picture", - "description": "The Picture card allows you to set an image to use for navigation to various paths in your interface or to perform an action." + "description": "The Picture card allows you to set an image to use for navigation to various paths in your interface or to perform an action.", + "content_id_helper": "Enter a media_source id or a URL for the image to be displayed." }, "picture-elements": { "name": "Picture elements",