From 3d005c8316eb5061cdc54e5036e686b57cbf3e71 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Mon, 15 Sep 2025 07:48:03 -0700 Subject: [PATCH] Manual entry mode for media selector (#26753) --- .../ha-selector/ha-selector-media.ts | 2 + .../dialog-media-player-browse.ts | 2 + .../media-player/ha-browse-media-manual.ts | 112 ++++++ .../media-player/ha-media-player-browse.ts | 358 +++++++++++------- .../media-player/show-media-browser-dialog.ts | 2 + src/data/media-player.ts | 4 +- src/data/media_source.ts | 5 + 7 files changed, 341 insertions(+), 144 deletions(-) create mode 100644 src/components/media-player/ha-browse-media-manual.ts diff --git a/src/components/ha-selector/ha-selector-media.ts b/src/components/ha-selector/ha-selector-media.ts index fae8d781ac..c23afd34f8 100644 --- a/src/components/ha-selector/ha-selector-media.ts +++ b/src/components/ha-selector/ha-selector-media.ts @@ -246,6 +246,8 @@ export class HaMediaSelector extends LitElement { entityId: this._getActiveEntityId(), navigateIds: this.value?.metadata?.navigateIds, accept: this.selector.media?.accept, + defaultId: this.value?.media_content_id, + defaultType: this.value?.media_content_type, mediaPickedCallback: (pickedMedia: MediaPickedEvent) => { fireEvent(this, "value-changed", { value: { diff --git a/src/components/media-player/dialog-media-player-browse.ts b/src/components/media-player/dialog-media-player-browse.ts index 1254579492..9a3948068f 100644 --- a/src/components/media-player/dialog-media-player-browse.ts +++ b/src/components/media-player/dialog-media-player-browse.ts @@ -165,6 +165,8 @@ class DialogMediaPlayerBrowse extends LitElement { .action=${this._action} .preferredLayout=${this._preferredLayout} .accept=${this._params.accept} + .defaultId=${this._params.defaultId} + .defaultType=${this._params.defaultType} @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 new file mode 100644 index 0000000000..df09e7b277 --- /dev/null +++ b/src/components/media-player/ha-browse-media-manual.ts @@ -0,0 +1,112 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../common/dom/fire_event"; +import type { HomeAssistant } from "../../types"; +import "../ha-button"; +import "../ha-card"; +import "../ha-form/ha-form"; +import type { SchemaUnion } from "../ha-form/types"; +import type { MediaPlayerItemId } from "./ha-media-player-browse"; + +export interface ManualMediaPickedEvent { + item: MediaPlayerItemId; +} + +@customElement("ha-browse-media-manual") +class BrowseMediaManual extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public item!: MediaPlayerItemId; + + private _schema = memoizeOne( + () => + [ + { + name: "media_content_id", + required: true, + selector: { + text: {}, + }, + }, + { + name: "media_content_type", + required: false, + selector: { + text: {}, + }, + }, + ] as const + ); + + protected render() { + return html` + +
+ +
+
+ + ${this.hass.localize("ui.common.submit")} + +
+
+ `; + } + + private _valueChanged(ev: CustomEvent) { + const value = { ...ev.detail.value }; + + this.item = value; + } + + private _computeLabel = ( + entry: SchemaUnion> + ): string => + this.hass.localize(`ui.components.selectors.media.${entry.name}`); + + private _computeHelper = ( + entry: SchemaUnion> + ): string => + this.hass.localize(`ui.components.selectors.media.${entry.name}_detail`); + + private _mediaPicked() { + fireEvent(this, "manual-media-picked", { + item: { + media_content_id: this.item.media_content_id || "", + media_content_type: this.item.media_content_type || "", + }, + }); + } + + static override styles = css` + :host { + margin: 16px auto; + padding: 0 8px; + display: flex; + flex-direction: column; + max-width: 448px; + } + .card-actions { + display: flex; + justify-content: flex-end; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-browse-media-manual": BrowseMediaManual; + } + + interface HASSDomEvents { + "manual-media-picked": ManualMediaPickedEvent; + } +} diff --git a/src/components/media-player/ha-media-player-browse.ts b/src/components/media-player/ha-media-player-browse.ts index 4c4889dbca..eb2a8674d6 100644 --- a/src/components/media-player/ha-media-player-browse.ts +++ b/src/components/media-player/ha-media-player-browse.ts @@ -1,7 +1,7 @@ import type { LitVirtualizer } from "@lit-labs/virtualizer"; import { grid } from "@lit-labs/virtualizer/layouts/grid"; -import { mdiArrowUpRight, mdiPlay, mdiPlus } from "@mdi/js"; +import { mdiArrowUpRight, mdiPlay, mdiPlus, mdiKeyboard } from "@mdi/js"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { @@ -28,7 +28,11 @@ import { BROWSER_PLAYER, MediaClassBrowserSettings, } from "../../data/media-player"; -import { browseLocalMediaPlayer } from "../../data/media_source"; +import { + browseLocalMediaPlayer, + isManualMediaSourceContentId, + MANUAL_MEDIA_SOURCE_PREFIX, +} from "../../data/media_source"; import { isTTSMediaSource } from "../../data/tts"; import { showAlertDialog } from "../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../resources/styles"; @@ -53,7 +57,9 @@ import "../ha-spinner"; import "../ha-svg-icon"; import "../ha-tooltip"; import "./ha-browse-media-tts"; +import "./ha-browse-media-manual"; import type { TtsMediaPickedEvent } from "./ha-browse-media-tts"; +import type { ManualMediaPickedEvent } from "./ha-browse-media-manual"; declare global { interface HASSDomEvents { @@ -74,6 +80,18 @@ export interface MediaPlayerItemId { media_content_type: string | undefined; } +const MANUAL_ITEM: MediaPlayerItem = { + can_expand: true, + can_play: false, + can_search: false, + children_media_class: "", + media_class: "app", + media_content_id: MANUAL_MEDIA_SOURCE_PREFIX, + media_content_type: "", + iconPath: mdiKeyboard, + title: "Manual entry", +}; + @customElement("ha-media-player-browse") export class HaMediaPlayerBrowse extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -91,6 +109,10 @@ export class HaMediaPlayerBrowse extends LitElement { @property({ attribute: false }) public accept?: string[]; + @property({ attribute: false }) public defaultId?: string; + + @property({ attribute: false }) public defaultType?: string; + // @todo Consider reworking to eliminate need for attribute since it is manipulated internally @property({ type: Boolean, reflect: true }) public narrow = false; @@ -216,56 +238,69 @@ export class HaMediaPlayerBrowse extends LitElement { } } // Fetch current - if (!currentProm) { - currentProm = this._fetchData( - this.entityId, - currentId.media_content_id, - currentId.media_content_type + if ( + currentId.media_content_id && + isManualMediaSourceContentId(currentId.media_content_id) + ) { + this._currentItem = MANUAL_ITEM; + fireEvent(this, "media-browsed", { + ids: navigateIds, + current: this._currentItem, + }); + } else { + if (!currentProm) { + currentProm = this._fetchData( + this.entityId, + currentId.media_content_id, + currentId.media_content_type + ); + } + currentProm.then( + (item) => { + this._currentItem = item; + fireEvent(this, "media-browsed", { + ids: navigateIds, + current: item, + }); + }, + (err) => { + // When we change entity ID, we will first try to see if the new entity is + // able to resolve the new path. If that results in an error, browse the root. + const isNewEntityWithSamePath = + oldNavigateIds && + changedProps.has("entityId") && + navigateIds.length === oldNavigateIds.length && + oldNavigateIds.every( + (oldItem, idx) => + navigateIds[idx].media_content_id === + oldItem.media_content_id && + navigateIds[idx].media_content_type === + oldItem.media_content_type + ); + if (isNewEntityWithSamePath) { + fireEvent(this, "media-browsed", { + ids: [ + { media_content_id: undefined, media_content_type: undefined }, + ], + replace: true, + }); + } else if ( + err.code === "entity_not_found" && + this.entityId && + isUnavailableState(this.hass.states[this.entityId]?.state) + ) { + this._setError({ + message: this.hass.localize( + `ui.components.media-browser.media_player_unavailable` + ), + code: "entity_not_found", + }); + } else { + this._setError(err); + } + } ); } - currentProm.then( - (item) => { - this._currentItem = item; - fireEvent(this, "media-browsed", { - ids: navigateIds, - current: item, - }); - }, - (err) => { - // When we change entity ID, we will first try to see if the new entity is - // able to resolve the new path. If that results in an error, browse the root. - const isNewEntityWithSamePath = - oldNavigateIds && - changedProps.has("entityId") && - navigateIds.length === oldNavigateIds.length && - oldNavigateIds.every( - (oldItem, idx) => - navigateIds[idx].media_content_id === oldItem.media_content_id && - navigateIds[idx].media_content_type === oldItem.media_content_type - ); - if (isNewEntityWithSamePath) { - fireEvent(this, "media-browsed", { - ids: [ - { media_content_id: undefined, media_content_type: undefined }, - ], - replace: true, - }); - } else if ( - err.code === "entity_not_found" && - this.entityId && - isUnavailableState(this.hass.states[this.entityId]?.state) - ) { - this._setError({ - message: this.hass.localize( - `ui.components.media-browser.media_player_unavailable` - ), - code: "entity_not_found", - }); - } else { - this._setError(err); - } - } - ); // Fetch parent if (!parentProm && parentId !== undefined) { parentProm = this._fetchData( @@ -479,111 +514,120 @@ export class HaMediaPlayerBrowse extends LitElement { ` - : isTTSMediaSource(currentItem.media_content_id) - ? html` - - ` - : !children.length && !currentItem.not_shown + : isManualMediaSourceContentId(currentItem.media_content_id) + ? html`` + : isTTSMediaSource(currentItem.media_content_id) ? html` -
- ${currentItem.media_content_id === - "media-source://media_source/local/." - ? html` -
- - - - - ${this.hass.localize( - "ui.components.media-browser.file_management.highlight_button" - )} - -
- ` - : this.hass.localize( - "ui.components.media-browser.no_items" - )} -
+ ` - : this.preferredLayout === "grid" || - (this.preferredLayout === "auto" && - childrenMediaClass.layout === "grid") + : !children.length && !currentItem.not_shown ? html` - - ${currentItem.not_shown - ? html` -
-
- ${this.hass.localize( - "ui.components.media-browser.not_shown", - { count: currentItem.not_shown } - )} +
+ ${currentItem.media_content_id === + "media-source://media_source/local/." + ? html` +
+ + + + + ${this.hass.localize( + "ui.components.media-browser.file_management.highlight_button" + )} +
-
- ` - : ""} + ` + : this.hass.localize( + "ui.components.media-browser.no_items" + )} +
` - : html` - + : this.preferredLayout === "grid" || + (this.preferredLayout === "auto" && + childrenMediaClass.layout === "grid") + ? html` ${currentItem.not_shown ? html` - - +
+
${this.hass.localize( "ui.components.media-browser.not_shown", { count: currentItem.not_shown } )} - - +
+
` : ""} -
- ` + ` + : html` + + + ${currentItem.not_shown + ? html` + + + ${this.hass.localize( + "ui.components.media-browser.not_shown", + { count: currentItem.not_shown } + )} + + + ` + : ""} + + ` }
@@ -617,8 +661,9 @@ export class HaMediaPlayerBrowse extends LitElement { : html`
) { + ev.stopPropagation(); + fireEvent(this, "media-picked", { + item: ev.detail.item as MediaPlayerItem, + navigateIds: this.navigateIds, + }); + } + private _childClicked = async (ev: MouseEvent): Promise => { const target = ev.currentTarget as any; const item: MediaPlayerItem = target.item; @@ -791,9 +844,23 @@ export class HaMediaPlayerBrowse extends LitElement { mediaContentId?: string, mediaContentType?: string ): Promise { - return entityId && entityId !== BROWSER_PLAYER - ? browseMediaPlayer(this.hass, entityId, mediaContentId, mediaContentType) - : browseLocalMediaPlayer(this.hass, mediaContentId); + const prom = + entityId && entityId !== BROWSER_PLAYER + ? browseMediaPlayer( + this.hass, + entityId, + mediaContentId, + mediaContentType + ) + : browseLocalMediaPlayer(this.hass, mediaContentId); + + return prom.then((item) => { + if (!mediaContentId && this.action === "pick") { + item.children = item.children || []; + item.children.push(MANUAL_ITEM); + } + return item; + }); } private _measureCard(): void { @@ -1141,6 +1208,11 @@ export class HaMediaPlayerBrowse extends LitElement { --mdc-icon-size: calc(var(--media-browse-item-size, 175px) * 0.4); } + .child .icon { + color: #00a9f7; /* Match the png color from brands repo */ + --mdc-icon-size: calc(var(--media-browse-item-size, 175px) * 0.4); + } + .child .play { position: absolute; transition: color 0.5s; diff --git a/src/components/media-player/show-media-browser-dialog.ts b/src/components/media-player/show-media-browser-dialog.ts index 5fdfeac789..978e8a2604 100644 --- a/src/components/media-player/show-media-browser-dialog.ts +++ b/src/components/media-player/show-media-browser-dialog.ts @@ -12,6 +12,8 @@ export interface MediaPlayerBrowseDialogParams { navigateIds?: MediaPlayerItemId[]; minimumNavigateLevel?: number; accept?: string[]; + defaultId?: string; + defaultType?: string; } export const showMediaBrowserDialog = ( diff --git a/src/data/media-player.ts b/src/data/media-player.ts index 6e846162be..968c4b48a5 100644 --- a/src/data/media-player.ts +++ b/src/data/media-player.ts @@ -199,10 +199,12 @@ export interface MediaPlayerItem { media_content_type: string; media_content_id: string; media_class: keyof TranslationDict["ui"]["components"]["media-browser"]["class"]; - children_media_class?: string; + children_media_class?: string | null; can_play: boolean; can_expand: boolean; + can_search: boolean; thumbnail?: string; + iconPath?: string; children?: MediaPlayerItem[]; not_shown?: number; } diff --git a/src/data/media_source.ts b/src/data/media_source.ts index 2015c1f8e7..0717a64765 100644 --- a/src/data/media_source.ts +++ b/src/data/media_source.ts @@ -24,6 +24,11 @@ export const browseLocalMediaPlayer = ( media_content_id: mediaContentId, }); +export const MANUAL_MEDIA_SOURCE_PREFIX = "__MANUAL_ENTRY__"; + +export const isManualMediaSourceContentId = (mediaContentId: string) => + mediaContentId.startsWith(MANUAL_MEDIA_SOURCE_PREFIX); + export const isMediaSourceContentId = (mediaId: string) => mediaId.startsWith("media-source://");