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",