Manual entry mode for media selector (#26753)

This commit is contained in:
karwosts
2025-09-15 07:48:03 -07:00
committed by GitHub
parent af31b5add3
commit 3d005c8316
7 changed files with 341 additions and 144 deletions

View File

@@ -246,6 +246,8 @@ export class HaMediaSelector extends LitElement {
entityId: this._getActiveEntityId(), entityId: this._getActiveEntityId(),
navigateIds: this.value?.metadata?.navigateIds, navigateIds: this.value?.metadata?.navigateIds,
accept: this.selector.media?.accept, accept: this.selector.media?.accept,
defaultId: this.value?.media_content_id,
defaultType: this.value?.media_content_type,
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => { mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value: { value: {

View File

@@ -165,6 +165,8 @@ class DialogMediaPlayerBrowse extends LitElement {
.action=${this._action} .action=${this._action}
.preferredLayout=${this._preferredLayout} .preferredLayout=${this._preferredLayout}
.accept=${this._params.accept} .accept=${this._params.accept}
.defaultId=${this._params.defaultId}
.defaultType=${this._params.defaultType}
@close-dialog=${this.closeDialog} @close-dialog=${this.closeDialog}
@media-picked=${this._mediaPicked} @media-picked=${this._mediaPicked}
@media-browsed=${this._mediaBrowsed} @media-browsed=${this._mediaBrowsed}

View File

@@ -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`
<ha-card>
<div class="card-content">
<ha-form
.hass=${this.hass}
.schema=${this._schema()}
.data=${this.item}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
</div>
<div class="card-actions">
<ha-button @click=${this._mediaPicked}>
${this.hass.localize("ui.common.submit")}
</ha-button>
</div>
</ha-card>
`;
}
private _valueChanged(ev: CustomEvent) {
const value = { ...ev.detail.value };
this.item = value;
}
private _computeLabel = (
entry: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(`ui.components.selectors.media.${entry.name}`);
private _computeHelper = (
entry: SchemaUnion<ReturnType<typeof this._schema>>
): 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;
}
}

View File

@@ -1,7 +1,7 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer"; import type { LitVirtualizer } from "@lit-labs/virtualizer";
import { grid } from "@lit-labs/virtualizer/layouts/grid"; 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 type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { import {
@@ -28,7 +28,11 @@ import {
BROWSER_PLAYER, BROWSER_PLAYER,
MediaClassBrowserSettings, MediaClassBrowserSettings,
} from "../../data/media-player"; } 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 { isTTSMediaSource } from "../../data/tts";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box"; import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../resources/styles"; import { haStyle } from "../../resources/styles";
@@ -53,7 +57,9 @@ import "../ha-spinner";
import "../ha-svg-icon"; import "../ha-svg-icon";
import "../ha-tooltip"; import "../ha-tooltip";
import "./ha-browse-media-tts"; import "./ha-browse-media-tts";
import "./ha-browse-media-manual";
import type { TtsMediaPickedEvent } from "./ha-browse-media-tts"; import type { TtsMediaPickedEvent } from "./ha-browse-media-tts";
import type { ManualMediaPickedEvent } from "./ha-browse-media-manual";
declare global { declare global {
interface HASSDomEvents { interface HASSDomEvents {
@@ -74,6 +80,18 @@ export interface MediaPlayerItemId {
media_content_type: string | undefined; 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") @customElement("ha-media-player-browse")
export class HaMediaPlayerBrowse extends LitElement { export class HaMediaPlayerBrowse extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @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 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 // @todo Consider reworking to eliminate need for attribute since it is manipulated internally
@property({ type: Boolean, reflect: true }) public narrow = false; @property({ type: Boolean, reflect: true }) public narrow = false;
@@ -216,56 +238,69 @@ export class HaMediaPlayerBrowse extends LitElement {
} }
} }
// Fetch current // Fetch current
if (!currentProm) { if (
currentProm = this._fetchData( currentId.media_content_id &&
this.entityId, isManualMediaSourceContentId(currentId.media_content_id)
currentId.media_content_id, ) {
currentId.media_content_type 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 // Fetch parent
if (!parentProm && parentId !== undefined) { if (!parentProm && parentId !== undefined) {
parentProm = this._fetchData( parentProm = this._fetchData(
@@ -479,111 +514,120 @@ export class HaMediaPlayerBrowse extends LitElement {
</ha-alert> </ha-alert>
</div> </div>
` `
: isTTSMediaSource(currentItem.media_content_id) : isManualMediaSourceContentId(currentItem.media_content_id)
? html` ? html`<ha-browse-media-manual
<ha-browse-media-tts .item=${{
.item=${currentItem} media_content_id: this.defaultId || "",
.hass=${this.hass} media_content_type: this.defaultType || "",
.action=${this.action} }}
@tts-picked=${this._ttsPicked} .hass=${this.hass}
></ha-browse-media-tts> @manual-media-picked=${this._manualPicked}
` ></ha-browse-media-manual>`
: !children.length && !currentItem.not_shown : isTTSMediaSource(currentItem.media_content_id)
? html` ? html`
<div class="container no-items"> <ha-browse-media-tts
${currentItem.media_content_id === .item=${currentItem}
"media-source://media_source/local/." .hass=${this.hass}
? html` .action=${this.action}
<div class="highlight-add-button"> @tts-picked=${this._ttsPicked}
<span> ></ha-browse-media-tts>
<ha-svg-icon
.path=${mdiArrowUpRight}
></ha-svg-icon>
</span>
<span>
${this.hass.localize(
"ui.components.media-browser.file_management.highlight_button"
)}
</span>
</div>
`
: this.hass.localize(
"ui.components.media-browser.no_items"
)}
</div>
` `
: this.preferredLayout === "grid" || : !children.length && !currentItem.not_shown
(this.preferredLayout === "auto" &&
childrenMediaClass.layout === "grid")
? html` ? html`
<lit-virtualizer <div class="container no-items">
scroller ${currentItem.media_content_id ===
.layout=${grid({ "media-source://media_source/local/."
itemSize: { ? html`
width: "175px", <div class="highlight-add-button">
height: <span>
childrenMediaClass.thumbnail_ratio === <ha-svg-icon
"portrait" .path=${mdiArrowUpRight}
? "312px" ></ha-svg-icon>
: "225px", </span>
}, <span>
gap: "16px", ${this.hass.localize(
flex: { preserve: "aspect-ratio" }, "ui.components.media-browser.file_management.highlight_button"
justify: "space-evenly", )}
direction: "vertical", </span>
})}
.items=${children}
.renderItem=${this._renderGridItem}
class="children ${classMap({
portrait:
childrenMediaClass.thumbnail_ratio ===
"portrait",
not_shown: !!currentItem.not_shown,
})}"
></lit-virtualizer>
${currentItem.not_shown
? html`
<div class="grid not-shown">
<div class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
)}
</div> </div>
</div> `
` : this.hass.localize(
: ""} "ui.components.media-browser.no_items"
)}
</div>
` `
: html` : this.preferredLayout === "grid" ||
<ha-list> (this.preferredLayout === "auto" &&
childrenMediaClass.layout === "grid")
? html`
<lit-virtualizer <lit-virtualizer
scroller scroller
.items=${children} .layout=${grid({
style=${styleMap({ itemSize: {
height: `${children.length * 72 + 26}px`, width: "175px",
height:
childrenMediaClass.thumbnail_ratio ===
"portrait"
? "312px"
: "225px",
},
gap: "16px",
flex: { preserve: "aspect-ratio" },
justify: "space-evenly",
direction: "vertical",
})} })}
.renderItem=${this._renderListItem} .items=${children}
.renderItem=${this._renderGridItem}
class="children ${classMap({
portrait:
childrenMediaClass.thumbnail_ratio ===
"portrait",
not_shown: !!currentItem.not_shown,
})}"
></lit-virtualizer> ></lit-virtualizer>
${currentItem.not_shown ${currentItem.not_shown
? html` ? html`
<ha-list-item <div class="grid not-shown">
noninteractive <div class="title">
class="not-shown"
.graphic=${mediaClass.show_list_images
? "medium"
: "avatar"}
>
<span class="title">
${this.hass.localize( ${this.hass.localize(
"ui.components.media-browser.not_shown", "ui.components.media-browser.not_shown",
{ count: currentItem.not_shown } { count: currentItem.not_shown }
)} )}
</span> </div>
</ha-list-item> </div>
` `
: ""} : ""}
</ha-list> `
` : html`
<ha-list>
<lit-virtualizer
scroller
.items=${children}
style=${styleMap({
height: `${children.length * 72 + 26}px`,
})}
.renderItem=${this._renderListItem}
></lit-virtualizer>
${currentItem.not_shown
? html`
<ha-list-item
noninteractive
class="not-shown"
.graphic=${mediaClass.show_list_images
? "medium"
: "avatar"}
>
<span class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
)}
</span>
</ha-list-item>
`
: ""}
</ha-list>
`
} }
</div> </div>
</div> </div>
@@ -617,8 +661,9 @@ export class HaMediaPlayerBrowse extends LitElement {
: html` : html`
<div class="icon-holder image"> <div class="icon-holder image">
<ha-svg-icon <ha-svg-icon
class="folder" class=${child.iconPath ? "icon" : "folder"}
.path=${MediaClassBrowserSettings[ .path=${child.iconPath ||
MediaClassBrowserSettings[
child.media_class === "directory" child.media_class === "directory"
? child.children_media_class || child.media_class ? child.children_media_class || child.media_class
: child.media_class : child.media_class
@@ -768,6 +813,14 @@ export class HaMediaPlayerBrowse extends LitElement {
}); });
} }
private _manualPicked(ev: CustomEvent<ManualMediaPickedEvent>) {
ev.stopPropagation();
fireEvent(this, "media-picked", {
item: ev.detail.item as MediaPlayerItem,
navigateIds: this.navigateIds,
});
}
private _childClicked = async (ev: MouseEvent): Promise<void> => { private _childClicked = async (ev: MouseEvent): Promise<void> => {
const target = ev.currentTarget as any; const target = ev.currentTarget as any;
const item: MediaPlayerItem = target.item; const item: MediaPlayerItem = target.item;
@@ -791,9 +844,23 @@ export class HaMediaPlayerBrowse extends LitElement {
mediaContentId?: string, mediaContentId?: string,
mediaContentType?: string mediaContentType?: string
): Promise<MediaPlayerItem> { ): Promise<MediaPlayerItem> {
return entityId && entityId !== BROWSER_PLAYER const prom =
? browseMediaPlayer(this.hass, entityId, mediaContentId, mediaContentType) entityId && entityId !== BROWSER_PLAYER
: browseLocalMediaPlayer(this.hass, mediaContentId); ? 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 { private _measureCard(): void {
@@ -1141,6 +1208,11 @@ export class HaMediaPlayerBrowse extends LitElement {
--mdc-icon-size: calc(var(--media-browse-item-size, 175px) * 0.4); --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 { .child .play {
position: absolute; position: absolute;
transition: color 0.5s; transition: color 0.5s;

View File

@@ -12,6 +12,8 @@ export interface MediaPlayerBrowseDialogParams {
navigateIds?: MediaPlayerItemId[]; navigateIds?: MediaPlayerItemId[];
minimumNavigateLevel?: number; minimumNavigateLevel?: number;
accept?: string[]; accept?: string[];
defaultId?: string;
defaultType?: string;
} }
export const showMediaBrowserDialog = ( export const showMediaBrowserDialog = (

View File

@@ -199,10 +199,12 @@ export interface MediaPlayerItem {
media_content_type: string; media_content_type: string;
media_content_id: string; media_content_id: string;
media_class: keyof TranslationDict["ui"]["components"]["media-browser"]["class"]; media_class: keyof TranslationDict["ui"]["components"]["media-browser"]["class"];
children_media_class?: string; children_media_class?: string | null;
can_play: boolean; can_play: boolean;
can_expand: boolean; can_expand: boolean;
can_search: boolean;
thumbnail?: string; thumbnail?: string;
iconPath?: string;
children?: MediaPlayerItem[]; children?: MediaPlayerItem[];
not_shown?: number; not_shown?: number;
} }

View File

@@ -24,6 +24,11 @@ export const browseLocalMediaPlayer = (
media_content_id: mediaContentId, 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) => export const isMediaSourceContentId = (mediaId: string) =>
mediaId.startsWith("media-source://"); mediaId.startsWith("media-source://");