From 5c5459bcaff02939e56fcfa5191e865d93f6f90e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 18 Feb 2022 13:21:00 +0100 Subject: [PATCH] Add play media action (#11702) Co-authored-by: Zack Barett Co-authored-by: Paulus Schoutsen --- .../src/pages/automation/describe-action.ts | 27 +- gallery/src/pages/automation/editor-action.ts | 2 +- gallery/src/pages/components/ha-form.ts | 8 + gallery/src/pages/components/ha-selector.ts | 222 ++++++++++++++- .../ha-selector/ha-selector-media.ts | 264 ++++++++++++++++++ src/components/ha-selector/ha-selector.ts | 1 + .../dialog-media-player-browse.ts | 6 +- .../media-player/ha-browse-media-tts.ts | 144 ++++++---- .../media-player/ha-media-player-browse.ts | 15 +- .../media-player/show-media-browser-dialog.ts | 4 +- src/data/media-player.ts | 2 + src/data/script.ts | 87 +++++- src/data/script_i18n.ts | 44 ++- src/data/selector.ts | 21 +- .../action/ha-automation-action-row.ts | 55 ++-- ...=> ha-automation-action-activate_scene.ts} | 4 +- .../types/ha-automation-action-play_media.ts | 68 +++++ .../types/ha-automation-action-service.ts | 2 +- src/translations/en.json | 40 ++- 19 files changed, 886 insertions(+), 130 deletions(-) create mode 100644 src/components/ha-selector/ha-selector-media.ts rename src/panels/config/automation/action/types/{ha-automation-action-scene.ts => ha-automation-action-activate_scene.ts} (93%) create mode 100644 src/panels/config/automation/action/types/ha-automation-action-play_media.ts diff --git a/gallery/src/pages/automation/describe-action.ts b/gallery/src/pages/automation/describe-action.ts index 55c3317acc..dd3d6c6e93 100644 --- a/gallery/src/pages/automation/describe-action.ts +++ b/gallery/src/pages/automation/describe-action.ts @@ -3,10 +3,20 @@ import { html, css, LitElement, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators"; import "../../../../src/components/ha-card"; import { describeAction } from "../../../../src/data/script_i18n"; +import { getEntity } from "../../../../src/fake_data/entity"; import { provideHass } from "../../../../src/fake_data/provide_hass"; import { HomeAssistant } from "../../../../src/types"; -const actions = [ +const ENTITIES = [ + getEntity("scene", "kitchen_morning", "scening", { + friendly_name: "Kitchen Morning", + }), + getEntity("media_player", "kitchen", "playing", { + friendly_name: "Sonos Kitchen", + }), +]; + +const ACTIONS = [ { wait_template: "{{ true }}", alias: "Something with an alias" }, { delay: "0:05" }, { wait_template: "{{ true }}" }, @@ -19,8 +29,20 @@ const actions = [ device_id: "abcdefgh", domain: "plex", entity_id: "media_player.kitchen", + type: "turn_on", }, { scene: "scene.kitchen_morning" }, + { + service: "scene.turn_on", + target: { entity_id: "scene.kitchen_morning" }, + metadata: {}, + }, + { + service: "media_player.play_media", + target: { entity_id: "media_player.kitchen" }, + data: { media_content_id: "", media_content_type: "" }, + metadata: { title: "Happy Song" }, + }, { wait_for_trigger: [ { @@ -52,7 +74,7 @@ export class DemoAutomationDescribeAction extends LitElement { } return html` - ${actions.map( + ${ACTIONS.map( (conf) => html`
${describeAction(this.hass, conf as any)} @@ -68,6 +90,7 @@ export class DemoAutomationDescribeAction extends LitElement { super.firstUpdated(changedProps); const hass = provideHass(this); hass.updateTranslations(null, "en"); + hass.addEntities(ENTITIES); } static get styles() { diff --git a/gallery/src/pages/automation/editor-action.ts b/gallery/src/pages/automation/editor-action.ts index 01769ccaa8..1f7a0d8206 100644 --- a/gallery/src/pages/automation/editor-action.ts +++ b/gallery/src/pages/automation/editor-action.ts @@ -14,7 +14,7 @@ import { HaDelayAction } from "../../../../src/panels/config/automation/action/t import { HaDeviceAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-device_id"; import { HaEventAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-event"; import { HaRepeatAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-repeat"; -import { HaSceneAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-scene"; +import { HaSceneAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-activate_scene"; import { HaServiceAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-service"; import { HaWaitForTriggerAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-wait_for_trigger"; import { HaWaitAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-wait_template"; diff --git a/gallery/src/pages/components/ha-form.ts b/gallery/src/pages/components/ha-form.ts index dac1320a37..b6ff6d1711 100644 --- a/gallery/src/pages/components/ha-form.ts +++ b/gallery/src/pages/components/ha-form.ts @@ -36,6 +36,8 @@ const SCHEMAS: { text_multiline: "Text Multiline", object: "Object", select: "Select", + icon: "Icon", + media: "Media", }, schema: [ { name: "addon", selector: { addon: {} } }, @@ -67,6 +69,12 @@ const SCHEMAS: { icon: {}, }, }, + { + name: "media", + selector: { + media: {}, + }, + }, ], }, { diff --git a/gallery/src/pages/components/ha-selector.ts b/gallery/src/pages/components/ha-selector.ts index 60890a3e4d..afa9d15e4c 100644 --- a/gallery/src/pages/components/ha-selector.ts +++ b/gallery/src/pages/components/ha-selector.ts @@ -12,6 +12,100 @@ import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry"; import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry"; import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry"; import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor"; +import { getEntity } from "../../../../src/fake_data/entity"; +import { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin"; +import { showDialog } from "../../../../src/dialogs/make-dialog-manager"; + +const ENTITIES = [ + getEntity("alarm_control_panel", "alarm", "disarmed", { + friendly_name: "Alarm", + }), + getEntity("media_player", "livingroom", "playing", { + friendly_name: "Livingroom", + }), + getEntity("media_player", "lounge", "idle", { + friendly_name: "Lounge", + supported_features: 444983, + }), + getEntity("light", "bedroom", "on", { + friendly_name: "Bedroom", + }), + getEntity("switch", "coffee", "off", { + friendly_name: "Coffee", + }), +]; + +const DEVICES = [ + { + area_id: "bedroom", + configuration_url: null, + config_entries: ["config_entry_1"], + connections: [], + disabled_by: null, + entry_type: null, + id: "device_1", + identifiers: [["demo", "volume1"] as [string, string]], + manufacturer: null, + model: null, + name_by_user: null, + name: "Dishwasher", + sw_version: null, + hw_version: null, + via_device_id: null, + }, + { + area_id: "backyard", + configuration_url: null, + config_entries: ["config_entry_2"], + connections: [], + disabled_by: null, + entry_type: null, + id: "device_2", + identifiers: [["demo", "pwm1"] as [string, string]], + manufacturer: null, + model: null, + name_by_user: null, + name: "Lamp", + sw_version: null, + hw_version: null, + via_device_id: null, + }, + { + area_id: null, + configuration_url: null, + config_entries: ["config_entry_3"], + connections: [], + disabled_by: null, + entry_type: null, + id: "device_3", + identifiers: [["demo", "pwm1"] as [string, string]], + manufacturer: null, + model: null, + name_by_user: "User name", + name: "Technical name", + sw_version: null, + hw_version: null, + via_device_id: null, + }, +]; + +const AREAS = [ + { + area_id: "backyard", + name: "Backyard", + picture: null, + }, + { + area_id: "bedroom", + name: "Bedroom", + picture: null, + }, + { + area_id: "livingroom", + name: "Livingroom", + picture: null, + }, +]; const SCHEMAS: { name: string; @@ -73,13 +167,14 @@ const SCHEMAS: { selector: { select: { options: ["Option 1", "Option 2"] } }, }, icon: { name: "Icon", selector: { icon: {} } }, + media: { name: "Media", selector: { media: {} } }, }, }, ]; @customElement("demo-components-ha-selector") -class DemoHaSelector extends LitElement { - @state() private hass!: HomeAssistant; +class DemoHaSelector extends LitElement implements ProvideHassElement { + @state() public hass!: HomeAssistant; private data = SCHEMAS.map(() => ({})); @@ -88,12 +183,130 @@ class DemoHaSelector extends LitElement { const hass = provideHass(this); hass.updateTranslations(null, "en"); hass.updateTranslations("config", "en"); + hass.addEntities(ENTITIES); mockEntityRegistry(hass); - mockDeviceRegistry(hass); - mockAreaRegistry(hass); + mockDeviceRegistry(hass, DEVICES); + mockAreaRegistry(hass, AREAS); mockHassioSupervisor(hass); + hass.mockWS("auth/sign_path", (params) => params); + hass.mockWS("media_player/browse_media", this._browseMedia); } + public provideHass(el) { + el.hass = this.hass; + } + + public connectedCallback() { + super.connectedCallback(); + this.addEventListener("show-dialog", this._dialogManager); + } + + public disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener("show-dialog", this._dialogManager); + } + + private _browseMedia = ({ media_content_id }) => { + if (media_content_id === undefined) { + return { + title: "Media", + media_class: "directory", + media_content_type: "", + media_content_id: "media-source://media_source/local/.", + can_play: false, + can_expand: true, + children_media_class: "directory", + thumbnail: null, + children: [ + { + title: "Misc", + media_class: "directory", + media_content_type: "", + media_content_id: "media-source://media_source/local/misc", + can_play: false, + can_expand: true, + children_media_class: null, + thumbnail: null, + }, + { + title: "Movies", + media_class: "directory", + media_content_type: "", + media_content_id: "media-source://media_source/local/movies", + can_play: true, + can_expand: true, + children_media_class: "movie", + thumbnail: null, + }, + { + title: "Music", + media_class: "album", + media_content_type: "", + media_content_id: "media-source://media_source/local/music", + can_play: false, + can_expand: true, + children_media_class: "music", + thumbnail: "/images/album_cover_2.jpg", + }, + ], + }; + } + return { + title: "Subfolder", + media_class: "directory", + media_content_type: "", + media_content_id: "media-source://media_source/local/sub", + can_play: false, + can_expand: true, + children_media_class: "directory", + thumbnail: null, + children: [ + { + title: "audio.mp3", + media_class: "music", + media_content_type: "audio/mpeg", + media_content_id: "media-source://media_source/local/audio.mp3", + can_play: true, + can_expand: false, + children_media_class: null, + thumbnail: "/images/album_cover.jpg", + }, + { + title: "image.jpg", + media_class: "image", + media_content_type: "image/jpeg", + media_content_id: "media-source://media_source/local/image.jpg", + can_play: true, + can_expand: false, + children_media_class: null, + thumbnail: null, + }, + { + title: "movie.mp4", + media_class: "movie", + media_content_type: "image/jpeg", + media_content_id: "media-source://media_source/local/movie.mp4", + can_play: true, + can_expand: false, + children_media_class: null, + thumbnail: null, + }, + ], + }; + }; + + private _dialogManager = (e) => { + const { dialogTag, dialogImport, dialogParams, addHistory } = e.detail; + showDialog( + this, + this.shadowRoot!, + dialogTag, + dialogParams, + dialogImport, + addHistory + ); + }; + protected render(): TemplateResult { return html` ${SCHEMAS.map((info, idx) => { @@ -132,7 +345,6 @@ class DemoHaSelector extends LitElement { } static styles = css` - paper-input, ha-selector { width: 60; } diff --git a/src/components/ha-selector/ha-selector-media.ts b/src/components/ha-selector/ha-selector-media.ts new file mode 100644 index 0000000000..bbfb203050 --- /dev/null +++ b/src/components/ha-selector/ha-selector-media.ts @@ -0,0 +1,264 @@ +import { mdiPlayBox, mdiPlus } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { fireEvent } from "../../common/dom/fire_event"; +import { supportsFeature } from "../../common/entity/supports-feature"; +import { getSignedPath } from "../../data/auth"; +import { + MediaClassBrowserSettings, + MediaPickedEvent, + SUPPORT_BROWSE_MEDIA, +} from "../../data/media-player"; +import type { MediaSelector, MediaSelectorValue } from "../../data/selector"; +import type { HomeAssistant } from "../../types"; +import "../ha-alert"; +import "../ha-form/ha-form"; +import type { HaFormSchema } from "../ha-form/types"; +import { showMediaBrowserDialog } from "../media-player/show-media-browser-dialog"; + +const MANUAL_SCHEMA = [ + { name: "media_content_id", required: false, selector: { text: {} } }, + { name: "media_content_type", required: false, selector: { text: {} } }, +]; + +@customElement("ha-selector-media") +export class HaMediaSelector extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public selector!: MediaSelector; + + @property({ attribute: false }) public value?: MediaSelectorValue; + + @property() public label?: string; + + @property({ type: Boolean, reflect: true }) public disabled = false; + + @state() private _thumbnailUrl?: string | null; + + willUpdate(changedProps: PropertyValues) { + if (changedProps.has("value")) { + const thumbnail = this.value?.metadata?.thumbnail; + const oldThumbnail = (changedProps.get("value") as this["value"]) + ?.metadata?.thumbnail; + if (thumbnail === oldThumbnail) { + return; + } + if (thumbnail && thumbnail.startsWith("/")) { + this._thumbnailUrl = undefined; + // Thumbnails served by local API require authentication + getSignedPath(this.hass, thumbnail).then((signedPath) => { + this._thumbnailUrl = signedPath.path; + }); + } else { + this._thumbnailUrl = thumbnail; + } + } + } + + protected render() { + const stateObj = this.value?.entity_id + ? this.hass.states[this.value.entity_id] + : undefined; + + const supportsBrowse = + !this.value?.entity_id || + (stateObj && supportsFeature(stateObj, SUPPORT_BROWSE_MEDIA)); + + return html` + ${!supportsBrowse + ? html` + ${this.hass.localize( + "ui.components.selectors.media.browse_not_supported" + )} + + ` + : html` +
+ ${this.value?.metadata?.thumbnail + ? html` +
+ ` + : html` +
+ +
+ `} +
+
+ ${!this.value?.media_content_id + ? this.hass.localize("ui.components.selectors.media.pick_media") + : this.value.metadata?.title || this.value.media_content_id} +
+
`}`; + } + + private _computeLabelCallback = (schema: HaFormSchema): string => + this.hass.localize(`ui.components.selectors.media.${schema.name}`); + + private _entityChanged(ev: CustomEvent) { + ev.stopPropagation(); + fireEvent(this, "value-changed", { + value: { + entity_id: ev.detail.value, + media_content_id: "", + media_content_type: "", + }, + }); + } + + private _pickMedia() { + showMediaBrowserDialog(this, { + action: "pick", + entityId: this.value!.entity_id!, + navigateIds: this.value!.metadata?.navigateIds, + mediaPickedCallback: (pickedMedia: 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, + })), + }, + }, + }); + }, + }); + } + + static get styles(): CSSResultGroup { + return css` + ha-entity-picker { + display: block; + margin-bottom: 16px; + } + mwc-button { + margin-top: 8px; + } + ha-alert { + display: block; + margin-bottom: 16px; + } + ha-card { + position: relative; + width: 200px; + box-sizing: border-box; + cursor: pointer; + } + ha-card.disabled { + pointer-events: none; + color: var(--disabled-text-color); + } + ha-card .thumbnail { + width: 100%; + position: relative; + box-sizing: border-box; + transition: padding-bottom 0.1s ease-out; + padding-bottom: 100%; + } + ha-card .thumbnail.portrait { + padding-bottom: 150%; + } + ha-card .image { + border-radius: 3px 3px 0 0; + } + .folder { + --mdc-icon-size: calc(var(--media-browse-item-size, 175px) * 0.4); + } + .title { + font-size: 16px; + padding-top: 16px; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 16px; + padding-left: 16px; + padding-right: 4px; + white-space: nowrap; + } + .image { + position: absolute; + top: 0; + right: 0; + left: 0; + bottom: 0; + background-size: cover; + background-repeat: no-repeat; + background-position: center; + } + .centered-image { + margin: 0 8px; + background-size: contain; + } + .icon-holder { + display: flex; + justify-content: center; + align-items: center; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-media": HaMediaSelector; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index 53aaa6a841..0181c9c863 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -18,6 +18,7 @@ import "./ha-selector-target"; import "./ha-selector-text"; import "./ha-selector-time"; import "./ha-selector-icon"; +import "./ha-selector-media"; @customElement("ha-selector") export class HaSelector extends LitElement { diff --git a/src/components/media-player/dialog-media-player-browse.ts b/src/components/media-player/dialog-media-player-browse.ts index 31acf550aa..7287d7aba2 100644 --- a/src/components/media-player/dialog-media-player-browse.ts +++ b/src/components/media-player/dialog-media-player-browse.ts @@ -28,10 +28,10 @@ class DialogMediaPlayerBrowse extends LitElement { public showDialog(params: MediaPlayerBrowseDialogParams): void { this._params = params; - this._navigateIds = [ + this._navigateIds = params.navigateIds || [ { - media_content_id: this._params.mediaContentId, - media_content_type: this._params.mediaContentType, + media_content_id: undefined, + media_content_type: undefined, }, ]; } diff --git a/src/components/media-player/ha-browse-media-tts.ts b/src/components/media-player/ha-browse-media-tts.ts index 8bec663a5d..43f4b30190 100644 --- a/src/components/media-player/ha-browse-media-tts.ts +++ b/src/components/media-player/ha-browse-media-tts.ts @@ -11,12 +11,26 @@ import { getCloudTtsLanguages, getCloudTtsSupportedGenders, } from "../../data/cloud/tts"; -import { MediaPlayerBrowseAction } from "../../data/media-player"; +import { + MediaPlayerBrowseAction, + MediaPlayerItem, +} from "../../data/media-player"; import { HomeAssistant } from "../../types"; import "../ha-textarea"; import { buttonLinkStyle } from "../../resources/styles"; import { showAlertDialog } from "../../dialogs/generic/show-dialog-box"; import { LocalStorage } from "../../common/decorators/local-storage"; +import { stopPropagation } from "../../common/dom/stop_propagation"; + +export interface TtsMediaPickedEvent { + item: MediaPlayerItem; +} + +declare global { + interface HASSDomEvents { + "tts-picked": TtsMediaPickedEvent; + } +} @customElement("ha-browse-media-tts") class BrowseMediaTTS extends LitElement { @@ -32,40 +46,55 @@ class BrowseMediaTTS extends LitElement { @state() private _cloudTTSInfo?: CloudTTSInfo; - @LocalStorage("cloudTtsTryMessage", false, false) private _message!: string; + @LocalStorage("cloudTtsTryMessage", true, false) private _message!: string; protected render() { - return html` - - - ${this._cloudDefaultOptions ? this._renderCloudOptions() : ""} -
+ return html` +
+ + + ${this._cloudDefaultOptions ? this._renderCloudOptions() : ""} +
+
${this._cloudDefaultOptions && (this._cloudDefaultOptions![0] !== this._cloudOptions![0] || this._cloudDefaultOptions![1] !== this._cloudOptions![1]) ? html` ` : html``} - + + + ${this.hass.localize( + `ui.components.media-browser.tts.action_${this.action}` + )} +
- `; +
`; } private _renderCloudOptions() { + if (!this._cloudTTSInfo || !this._cloudOptions) { + return ""; + } const languages = this.getLanguages(this._cloudTTSInfo); - const selectedVoice = this._cloudOptions!; + const selectedVoice = this._cloudOptions; const genders = this.getSupportedGenders( selectedVoice[0], this._cloudTTSInfo, @@ -77,9 +106,12 @@ class BrowseMediaTTS extends LitElement { ${languages.map( ([key, label]) => @@ -90,9 +122,10 @@ class BrowseMediaTTS extends LitElement { ${genders.map( ([key, label]) => @@ -106,6 +139,37 @@ class BrowseMediaTTS extends LitElement { protected override willUpdate(changedProps: PropertyValues): void { super.willUpdate(changedProps); + if (changedProps.has("item")) { + if (this.item.media_content_id) { + const params = new URLSearchParams( + this.item.media_content_id.split("?")[1] + ); + const message = params.get("message"); + const language = params.get("language"); + const gender = params.get("gender"); + if (message) { + this._message = message; + } + if (language && gender) { + this._cloudOptions = [language, gender]; + } + } + + if (this.isCloudItem && !this._cloudTTSInfo) { + getCloudTTSInfo(this.hass).then((info) => { + this._cloudTTSInfo = info; + }); + fetchCloudStatus(this.hass).then((status) => { + if (status.logged_in) { + this._cloudDefaultOptions = status.prefs.tts_default_voice; + if (!this._cloudOptions) { + this._cloudOptions = { ...this._cloudDefaultOptions }; + } + } + }); + } + } + if (changedProps.has("message")) { return; } @@ -133,30 +197,12 @@ class BrowseMediaTTS extends LitElement { this._cloudOptions = [this._cloudOptions![0], ev.target.value]; } - protected updated(changedProps: PropertyValues): void { - super.updated(changedProps); - - if (changedProps.has("item")) { - if (this.isCloudItem && !this._cloudTTSInfo) { - getCloudTTSInfo(this.hass).then((info) => { - this._cloudTTSInfo = info; - }); - fetchCloudStatus(this.hass).then((status) => { - if (status.logged_in) { - this._cloudDefaultOptions = status.prefs.tts_default_voice; - this._cloudOptions = { ...this._cloudDefaultOptions }; - } - }); - } - } - } - private getLanguages = memoizeOne(getCloudTtsLanguages); private getSupportedGenders = memoizeOne(getCloudTtsSupportedGenders); private get isCloudItem(): boolean { - return this.item.media_content_id === "media-source://tts/cloud"; + return this.item.media_content_id.startsWith("media-source://tts/cloud"); } private async _ttsClicked(): Promise { @@ -169,9 +215,12 @@ class BrowseMediaTTS extends LitElement { query.append("language", this._cloudOptions[0]); query.append("gender", this._cloudOptions[1]); } - item.media_content_id += `?${query.toString()}`; + item.media_content_id = `${ + item.media_content_id.split("?")[0] + }?${query.toString()}`; item.can_play = true; - fireEvent(this, "media-picked", { item }); + item.title = message; + fireEvent(this, "tts-picked", { item }); } private async _storeDefaults() { @@ -185,7 +234,7 @@ class BrowseMediaTTS extends LitElement { this._cloudDefaultOptions = oldDefaults; showAlertDialog(this, { text: this.hass.localize( - "ui.panel.media-browser.tts.faild_to_store_defaults", + "ui.components.media-browser.tts.faild_to_store_defaults", { error: err.message || err } ), }); @@ -210,15 +259,16 @@ class BrowseMediaTTS extends LitElement { .cloud-options mwc-select { width: 48%; } - - .actions { - display: flex; - justify-content: space-between; - margin-top: 16px; + ha-textarea { + width: 100%; } button.link { color: var(--primary-color); } + .card-actions { + display: flex; + justify-content: space-between; + } `, ]; } diff --git a/src/components/media-player/ha-media-player-browse.ts b/src/components/media-player/ha-media-player-browse.ts index be644eacca..fa81e42716 100644 --- a/src/components/media-player/ha-media-player-browse.ts +++ b/src/components/media-player/ha-media-player-browse.ts @@ -49,7 +49,7 @@ import "../ha-svg-icon"; import "../ha-fab"; import { browseLocalMediaPlayer } from "../../data/media_source"; import { isTTSMediaSource } from "../../data/tts"; -import "./ha-browse-media-tts"; +import { TtsMediaPickedEvent } from "./ha-browse-media-tts"; declare global { interface HASSDomEvents { @@ -260,6 +260,7 @@ export class HaMediaPlayerBrowse extends LitElement { .item=${currentItem} .hass=${this.hass} .action=${this.action} + @tts-picked=${this._ttsPicked} > ` : !currentItem.children?.length @@ -562,7 +563,17 @@ export class HaMediaPlayerBrowse extends LitElement { } private _runAction(item: MediaPlayerItem): void { - fireEvent(this, "media-picked", { item }); + fireEvent(this, "media-picked", { item, navigateIds: this.navigateIds }); + } + + private _ttsPicked(ev: CustomEvent): void { + ev.stopPropagation(); + const navigateIds = this.navigateIds.slice(0, -1); + navigateIds.push(ev.detail.item); + fireEvent(this, "media-picked", { + ...ev.detail, + navigateIds, + }); } private async _childClicked(ev: MouseEvent): Promise { diff --git a/src/components/media-player/show-media-browser-dialog.ts b/src/components/media-player/show-media-browser-dialog.ts index b84fd349c7..c99e8184b6 100644 --- a/src/components/media-player/show-media-browser-dialog.ts +++ b/src/components/media-player/show-media-browser-dialog.ts @@ -3,13 +3,13 @@ import { MediaPickedEvent, MediaPlayerBrowseAction, } from "../../data/media-player"; +import { MediaPlayerItemId } from "./ha-media-player-browse"; export interface MediaPlayerBrowseDialogParams { action: MediaPlayerBrowseAction; entityId: string; mediaPickedCallback: (pickedMedia: MediaPickedEvent) => void; - mediaContentId?: string; - mediaContentType?: string; + navigateIds?: MediaPlayerItemId[]; } export const showMediaBrowserDialog = ( diff --git a/src/data/media-player.ts b/src/data/media-player.ts index 6328b4aafe..2d3902faf6 100644 --- a/src/data/media-player.ts +++ b/src/data/media-player.ts @@ -28,6 +28,7 @@ import type { HassEntityBase, } from "home-assistant-js-websocket"; import { supportsFeature } from "../common/entity/supports-feature"; +import { MediaPlayerItemId } from "../components/media-player/ha-media-player-browse"; import type { HomeAssistant } from "../types"; import { UNAVAILABLE_STATES } from "./entity"; @@ -147,6 +148,7 @@ export const MediaClassBrowserSettings: { export interface MediaPickedEvent { item: MediaPlayerItem; + navigateIds: MediaPlayerItemId[]; } export interface MediaPlayerThumbnail { diff --git a/src/data/script.ts b/src/data/script.ts index 81a90d0ed2..41db0160c1 100644 --- a/src/data/script.ts +++ b/src/data/script.ts @@ -3,6 +3,17 @@ import { HassEntityBase, HassServiceTarget, } from "home-assistant-js-websocket"; +import { + object, + optional, + string, + union, + array, + assign, + literal, + is, + Describe, +} from "superstruct"; import { computeObjectId } from "../common/entity/compute_object_id"; import { navigate } from "../common/navigate"; import { HomeAssistant } from "../types"; @@ -12,6 +23,48 @@ import { BlueprintInput } from "./blueprint"; export const MODES = ["single", "restart", "queued", "parallel"] as const; export const MODES_MAX = ["queued", "parallel"]; +export const baseActionStruct = object({ + alias: optional(string()), +}); + +const targetStruct = object({ + entity_id: optional(union([string(), array(string())])), + device_id: optional(union([string(), array(string())])), + area_id: optional(union([string(), array(string())])), +}); + +export const serviceActionStruct: Describe = assign( + baseActionStruct, + object({ + service: optional(string()), + service_template: optional(string()), + entity_id: optional(string()), + target: optional(targetStruct), + data: optional(object()), + }) +); + +const playMediaActionStruct: Describe = assign( + baseActionStruct, + object({ + service: literal("media_player.play_media"), + target: optional(object({ entity_id: optional(string()) })), + entity_id: optional(string()), + data: object({ media_content_id: string(), media_content_type: string() }), + metadata: object(), + }) +); + +const activateSceneActionStruct: Describe = assign( + baseActionStruct, + object({ + service: literal("scene.turn_on"), + target: optional(object({ entity_id: optional(string()) })), + entity_id: optional(string()), + metadata: object(), + }) +); + export interface ScriptEntity extends HassEntityBase { attributes: HassEntityAttributeBase & { last_triggered: string; @@ -48,11 +101,12 @@ export interface ServiceAction { service_template?: string; entity_id?: string; target?: HassServiceTarget; - data?: Record; + data?: Record; } export interface DeviceAction { alias?: string; + type: string; device_id: string; domain: string; entity_id: string; @@ -70,9 +124,12 @@ export interface DelayAction { delay: number | Partial | string; } -export interface ServiceSceneAction extends ServiceAction { +export interface ServiceSceneAction { + alias?: string; service: "scene.turn_on"; - metadata: Record; + target?: { entity_id?: string }; + entity_id?: string; + metadata: Record; } export interface LegacySceneAction { alias?: string; @@ -94,6 +151,15 @@ export interface WaitForTriggerAction { continue_on_timeout?: boolean; } +export interface PlayMediaAction { + alias?: string; + service: "media_player.play_media"; + target?: { entity_id?: string }; + entity_id?: string; + data: { media_content_id: string; media_content_type: string }; + metadata: Record; +} + export interface RepeatAction { alias?: string; repeat: CountRepeat | WhileRepeat | UntilRepeat; @@ -150,6 +216,7 @@ export type Action = | RepeatAction | ChooseAction | VariablesAction + | PlayMediaAction | UnknownAction; export interface ActionTypes { @@ -158,13 +225,13 @@ export interface ActionTypes { check_condition: Condition; fire_event: EventAction; device_action: DeviceAction; - legacy_activate_scene: LegacySceneAction; - activate_scene: ServiceSceneAction; + activate_scene: SceneAction; repeat: RepeatAction; choose: ChooseAction; wait_for_trigger: WaitForTriggerAction; variables: VariablesAction; service: ServiceAction; + play_media: PlayMediaAction; unknown: UnknownAction; } @@ -224,7 +291,7 @@ export const getActionType = (action: Action): ActionType => { return "device_action"; } if ("scene" in action) { - return "legacy_activate_scene"; + return "activate_scene"; } if ("repeat" in action) { return "repeat"; @@ -240,12 +307,12 @@ export const getActionType = (action: Action): ActionType => { } if ("service" in action) { if ("metadata" in action) { - if ( - (action as ServiceAction).service === "scene.turn_on" && - !Array.isArray((action as ServiceAction)?.target?.entity_id) - ) { + if (is(action, activateSceneActionStruct)) { return "activate_scene"; } + if (is(action, playMediaActionStruct)) { + return "play_media"; + } } return "service"; } diff --git a/src/data/script_i18n.ts b/src/data/script_i18n.ts index a8f2b76356..230c867ef0 100644 --- a/src/data/script_i18n.ts +++ b/src/data/script_i18n.ts @@ -9,10 +9,11 @@ import { ActionType, ActionTypes, DelayAction, + DeviceAction, EventAction, getActionType, - LegacySceneAction, - ServiceSceneAction, + PlayMediaAction, + SceneAction, VariablesAction, WaitForTriggerAction, } from "./script"; @@ -103,19 +104,32 @@ export const describeAction = ( return `Delay ${duration}`; } - if (actionType === "legacy_activate_scene") { - const config = action as LegacySceneAction; - const sceneStateObj = hass.states[config.scene]; + if (actionType === "activate_scene") { + const config = action as SceneAction; + let entityId: string | undefined; + if ("scene" in config) { + entityId = config.scene; + } else { + entityId = config.target?.entity_id || config.entity_id; + } + const sceneStateObj = entityId ? hass.states[entityId] : undefined; return `Activate scene ${ - sceneStateObj ? computeStateName(sceneStateObj) : config.scene + sceneStateObj + ? computeStateName(sceneStateObj) + : "scene" in config + ? config.scene + : config.target?.entity_id || config.entity_id }`; } - if (actionType === "activate_scene") { - const config = action as ServiceSceneAction; - const sceneStateObj = hass.states[config.target!.entity_id as string]; - return `Activate scene ${ - sceneStateObj ? computeStateName(sceneStateObj) : config.target!.entity_id + if (actionType === "play_media") { + const config = action as PlayMediaAction; + const entityId = config.target?.entity_id || config.entity_id; + const mediaStateObj = entityId ? hass.states[entityId] : undefined; + return `Play ${config.metadata.title || config.data.media_content_id} on ${ + mediaStateObj + ? computeStateName(mediaStateObj) + : config.target?.entity_id || config.entity_id }`; } @@ -147,5 +161,13 @@ export const describeAction = ( return `Test ${describeCondition(action as Condition)}`; } + if (actionType === "device_action") { + const config = action as DeviceAction; + const stateObj = hass.states[config.entity_id as string]; + return `${config.type || "Perform action with"} ${ + stateObj ? computeStateName(stateObj) : config.entity_id + }`; + } + return actionType; }; diff --git a/src/data/selector.ts b/src/data/selector.ts index 458a84235a..e7be0ac147 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -13,7 +13,8 @@ export type Selector = | StringSelector | ObjectSelector | SelectSelector - | IconSelector; + | IconSelector + | MediaSelector; export interface EntitySelector { entity: { @@ -149,3 +150,21 @@ export interface IconSelector { // eslint-disable-next-line @typescript-eslint/ban-types icon: {}; } + +export interface MediaSelector { + // eslint-disable-next-line @typescript-eslint/ban-types + media: {}; +} + +export interface MediaSelectorValue { + entity_id?: string; + media_content_id?: string; + media_content_type?: string; + metadata?: { + title?: string; + thumbnail?: string | null; + media_class?: string; + children_media_class?: string | null; + navigateIds?: { media_content_type: string; media_content_id: string }[]; + }; +} diff --git a/src/panels/config/automation/action/ha-automation-action-row.ts b/src/panels/config/automation/action/ha-automation-action-row.ts index 5c103e9e67..dbc9150ecf 100644 --- a/src/panels/config/automation/action/ha-automation-action-row.ts +++ b/src/panels/config/automation/action/ha-automation-action-row.ts @@ -1,8 +1,8 @@ import { ActionDetail } from "@material/mwc-list/mwc-list-foundation"; import "@material/mwc-list/mwc-list-item"; -import { mdiArrowDown, mdiArrowUp, mdiDotsVertical } from "@mdi/js"; import "@material/mwc-select"; import type { Select } from "@material/mwc-select"; +import { mdiArrowDown, mdiArrowUp, mdiDotsVertical } from "@mdi/js"; import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; @@ -11,22 +11,23 @@ import { fireEvent } from "../../../../common/dom/fire_event"; import { stringCompare } from "../../../../common/string/compare"; import { handleStructError } from "../../../../common/structs/handle-errors"; import { LocalizeFunc } from "../../../../common/translations/localize"; +import "../../../../components/ha-alert"; import "../../../../components/ha-button-menu"; import "../../../../components/ha-card"; -import "../../../../components/ha-alert"; import "../../../../components/ha-icon-button"; import type { HaYamlEditor } from "../../../../components/ha-yaml-editor"; -import type { Action, ServiceSceneAction } from "../../../../data/script"; +import { Action, getActionType } from "../../../../data/script"; import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../../../resources/styles"; import type { HomeAssistant } from "../../../../types"; +import "./types/ha-automation-action-activate_scene"; import "./types/ha-automation-action-choose"; import "./types/ha-automation-action-condition"; import "./types/ha-automation-action-delay"; import "./types/ha-automation-action-device_id"; import "./types/ha-automation-action-event"; +import "./types/ha-automation-action-play_media"; import "./types/ha-automation-action-repeat"; -import "./types/ha-automation-action-scene"; import "./types/ha-automation-action-service"; import "./types/ha-automation-action-wait_for_trigger"; import "./types/ha-automation-action-wait_template"; @@ -35,7 +36,8 @@ const OPTIONS = [ "condition", "delay", "event", - "scene", + "play_media", + "activate_scene", "service", "wait_template", "wait_for_trigger", @@ -48,21 +50,8 @@ const getType = (action: Action | undefined) => { if (!action) { return undefined; } - if ("metadata" in action && action.service) { - switch (action.service) { - case "scene.turn_on": - // we dont support arrays of entities - if ( - !Array.isArray( - (action as unknown as ServiceSceneAction).target?.entity_id - ) - ) { - return "scene"; - } - break; - default: - break; - } + if ("service" in action || "scene" in action) { + return getActionType(action); } return OPTIONS.find((option) => option in action); }; @@ -133,24 +122,30 @@ export default class HaAutomationActionRow extends LitElement { ).sort((a, b) => stringCompare(a[1], b[1])) ); + protected willUpdate(changedProperties: PropertyValues) { + if (!changedProperties.has("action")) { + return; + } + this._uiModeAvailable = getType(this.action) !== undefined; + if (!this._uiModeAvailable && !this._yamlMode) { + this._yamlMode = true; + } + } + protected updated(changedProperties: PropertyValues) { if (!changedProperties.has("action")) { return; } - this._uiModeAvailable = Boolean(getType(this.action)); - if (!this._uiModeAvailable && !this._yamlMode) { - this._yamlMode = true; - } - - const yamlEditor = this._yamlEditor; - if (this._yamlMode && yamlEditor && yamlEditor.value !== this.action) { - yamlEditor.setValue(this.action); + if (this._yamlMode) { + const yamlEditor = this._yamlEditor; + if (yamlEditor && yamlEditor.value !== this.action) { + yamlEditor.setValue(this.action); + } } } protected render() { const type = getType(this.action); - const selected = type ? OPTIONS.indexOf(type) : -1; const yamlMode = this._yamlMode; return html` @@ -225,7 +220,7 @@ export default class HaAutomationActionRow extends LitElement { : ""} ${yamlMode ? html` - ${selected === -1 + ${type === undefined ? html` ${this.hass.localize( "ui.panel.config.automation.editor.actions.unsupported_action", diff --git a/src/panels/config/automation/action/types/ha-automation-action-scene.ts b/src/panels/config/automation/action/types/ha-automation-action-activate_scene.ts similarity index 93% rename from src/panels/config/automation/action/types/ha-automation-action-scene.ts rename to src/panels/config/automation/action/types/ha-automation-action-activate_scene.ts index a9d02b83ba..0ce146589a 100644 --- a/src/panels/config/automation/action/types/ha-automation-action-scene.ts +++ b/src/panels/config/automation/action/types/ha-automation-action-activate_scene.ts @@ -9,7 +9,7 @@ import { ActionElement } from "../ha-automation-action-row"; const includeDomains = ["scene"]; -@customElement("ha-automation-action-scene") +@customElement("ha-automation-action-activate_scene") export class HaSceneAction extends LitElement implements ActionElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -61,6 +61,6 @@ export class HaSceneAction extends LitElement implements ActionElement { declare global { interface HTMLElementTagNameMap { - "ha-automation-action-scene": HaSceneAction; + "ha-automation-action-activate_scene": HaSceneAction; } } diff --git a/src/panels/config/automation/action/types/ha-automation-action-play_media.ts b/src/panels/config/automation/action/types/ha-automation-action-play_media.ts new file mode 100644 index 0000000000..ba9f537513 --- /dev/null +++ b/src/panels/config/automation/action/types/ha-automation-action-play_media.ts @@ -0,0 +1,68 @@ +import "@polymer/paper-input/paper-input"; +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import "../../../../../components/ha-selector/ha-selector-media"; +import { PlayMediaAction } from "../../../../../data/script"; +import type { MediaSelectorValue } from "../../../../../data/selector"; +import type { HomeAssistant } from "../../../../../types"; +import { ActionElement } from "../ha-automation-action-row"; + +@customElement("ha-automation-action-play_media") +export class HaPlayMediaAction extends LitElement implements ActionElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public action!: PlayMediaAction; + + @property({ type: Boolean }) public narrow = false; + + public static get defaultConfig(): PlayMediaAction { + return { + service: "media_player.play_media", + target: { entity_id: "" }, + data: { media_content_id: "", media_content_type: "" }, + metadata: {}, + }; + } + + private _getSelectorValue = memoizeOne( + (action: PlayMediaAction): MediaSelectorValue => ({ + entity_id: action.target?.entity_id || action.entity_id, + media_content_id: action.data?.media_content_id, + media_content_type: action.data?.media_content_type, + metadata: action.metadata, + }) + ); + + protected render() { + return html` + + `; + } + + private _valueChanged(ev: CustomEvent<{ value: MediaSelectorValue }>) { + ev.stopPropagation(); + fireEvent(this, "value-changed", { + value: { + service: "media_player.play_media", + target: { entity_id: ev.detail.value.entity_id }, + data: { + media_content_id: ev.detail.value.media_content_id, + media_content_type: ev.detail.value.media_content_type, + }, + metadata: ev.detail.value.metadata || {}, + } as PlayMediaAction, + }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-action-play_media": HaPlayMediaAction; + } +} diff --git a/src/panels/config/automation/action/types/ha-automation-action-service.ts b/src/panels/config/automation/action/types/ha-automation-action-service.ts index 251fe0f5f1..fdac1e1824 100644 --- a/src/panels/config/automation/action/types/ha-automation-action-service.ts +++ b/src/panels/config/automation/action/types/ha-automation-action-service.ts @@ -30,7 +30,7 @@ export class HaServiceAction extends LitElement implements ActionElement { return { service: "", data: {} }; } - protected updated(changedProperties: PropertyValues) { + protected willUpdate(changedProperties: PropertyValues) { if (!changedProperties.has("action")) { return; } diff --git a/src/translations/en.json b/src/translations/en.json index 4ad76f9f98..df493b082b 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -317,6 +317,17 @@ "copied_clipboard": "Copied to clipboard" }, "components": { + "selectors": { + "media": { + "pick_media_player": "Select media player", + "browse_not_supported": "Media player does not support browsing media.", + "pick_media": "Pick media", + "browse_media": "Browse media", + "manual": "Manually enter Media ID", + "media_content_id": "Media content ID", + "media_content_type": "Media content type" + } + }, "logbook": { "entries_not_found": "No logbook events found.", "by": "by", @@ -505,6 +516,18 @@ "clear": "Clear" }, "media-browser": { + "tts": { + "message": "Message", + "example_message": "Hello {name}, you can play any text on any supported media player!", + "language": "Language", + "gender": "Gender", + "gender_male": "Male", + "gender_female": "Female", + "action_play": "Say", + "action_pick": "Select", + "set_as_default": "Set as default options", + "faild_to_store_defaults": "Failed to store defaults: {error}" + }, "pick": "Pick", "play": "Play", "play-media": "Play Media", @@ -1798,6 +1821,9 @@ "service": { "label": "Call service" }, + "play_media": { + "label": "Play media" + }, "delay": { "label": "Wait for time to pass (delay)", "delay": "Duration" @@ -1836,7 +1862,7 @@ "flash": "Flash" } }, - "scene": { + "activate_scene": { "label": "Activate scene" }, "repeat": { @@ -3699,18 +3725,6 @@ "media-browser": { "error": { "player_not_exist": "Media player {name} does not exist" - }, - "tts": { - "message": "Message", - "example_message": "Hello {name}, you can play any text on any supported media player!", - "language": "Language", - "gender": "Gender", - "gender_male": "Male", - "gender_female": "Female", - "action_play": "Say", - "action_pick": "Select", - "set_as_default": "Set as default options", - "faild_to_store_defaults": "Failed to store defaults: {error}" } }, "map": {