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(),
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: {

View File

@@ -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}

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 { 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 {
</ha-alert>
</div>
`
: isTTSMediaSource(currentItem.media_content_id)
? html`
<ha-browse-media-tts
.item=${currentItem}
.hass=${this.hass}
.action=${this.action}
@tts-picked=${this._ttsPicked}
></ha-browse-media-tts>
`
: !children.length && !currentItem.not_shown
: isManualMediaSourceContentId(currentItem.media_content_id)
? html`<ha-browse-media-manual
.item=${{
media_content_id: this.defaultId || "",
media_content_type: this.defaultType || "",
}}
.hass=${this.hass}
@manual-media-picked=${this._manualPicked}
></ha-browse-media-manual>`
: isTTSMediaSource(currentItem.media_content_id)
? html`
<div class="container no-items">
${currentItem.media_content_id ===
"media-source://media_source/local/."
? html`
<div class="highlight-add-button">
<span>
<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>
<ha-browse-media-tts
.item=${currentItem}
.hass=${this.hass}
.action=${this.action}
@tts-picked=${this._ttsPicked}
></ha-browse-media-tts>
`
: this.preferredLayout === "grid" ||
(this.preferredLayout === "auto" &&
childrenMediaClass.layout === "grid")
: !children.length && !currentItem.not_shown
? html`
<lit-virtualizer
scroller
.layout=${grid({
itemSize: {
width: "175px",
height:
childrenMediaClass.thumbnail_ratio ===
"portrait"
? "312px"
: "225px",
},
gap: "16px",
flex: { preserve: "aspect-ratio" },
justify: "space-evenly",
direction: "vertical",
})}
.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 class="container no-items">
${currentItem.media_content_id ===
"media-source://media_source/local/."
? html`
<div class="highlight-add-button">
<span>
<ha-svg-icon
.path=${mdiArrowUpRight}
></ha-svg-icon>
</span>
<span>
${this.hass.localize(
"ui.components.media-browser.file_management.highlight_button"
)}
</span>
</div>
</div>
`
: ""}
`
: this.hass.localize(
"ui.components.media-browser.no_items"
)}
</div>
`
: html`
<ha-list>
: this.preferredLayout === "grid" ||
(this.preferredLayout === "auto" &&
childrenMediaClass.layout === "grid")
? html`
<lit-virtualizer
scroller
.items=${children}
style=${styleMap({
height: `${children.length * 72 + 26}px`,
.layout=${grid({
itemSize: {
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>
${currentItem.not_shown
? html`
<ha-list-item
noninteractive
class="not-shown"
.graphic=${mediaClass.show_list_images
? "medium"
: "avatar"}
>
<span class="title">
<div class="grid not-shown">
<div class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
)}
</span>
</ha-list-item>
</div>
</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>
@@ -617,8 +661,9 @@ export class HaMediaPlayerBrowse extends LitElement {
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${MediaClassBrowserSettings[
class=${child.iconPath ? "icon" : "folder"}
.path=${child.iconPath ||
MediaClassBrowserSettings[
child.media_class === "directory"
? child.children_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> => {
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<MediaPlayerItem> {
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;

View File

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