Add play media action (#11702)

Co-authored-by: Zack Barett <zackbarett@hey.com>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Bram Kragten 2022-02-18 13:21:00 +01:00 committed by GitHub
parent cbd0ef6b65
commit 5c5459bcaf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 886 additions and 130 deletions

View File

@ -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`
<ha-card header="Actions">
${actions.map(
${ACTIONS.map(
(conf) => html`
<div class="action">
<span>${describeAction(this.hass, conf as any)}</span>
@ -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() {

View File

@ -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";

View File

@ -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: {},
},
},
],
},
{

View File

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

View File

@ -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<this>) {
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`<ha-entity-picker
.hass=${this.hass}
.value=${this.value?.entity_id}
.label=${this.label ||
this.hass.localize("ui.components.selectors.media.pick_media_player")}
.disabled=${this.disabled}
include-domains='["media_player"]'
allow-custom-entity
@value-changed=${this._entityChanged}
></ha-entity-picker>
${!supportsBrowse
? html`<ha-alert>
${this.hass.localize(
"ui.components.selectors.media.browse_not_supported"
)}
</ha-alert>
<ha-form
.hass=${this.hass}
.data=${this.value}
.schema=${MANUAL_SCHEMA}
.computeLabel=${this._computeLabelCallback}
></ha-form>`
: html`<ha-card
outlined
@click=${this._pickMedia}
class=${this.disabled || !this.value?.entity_id ? "disabled" : ""}
>
<div
class="thumbnail ${classMap({
portrait:
!!this.value?.metadata?.media_class &&
MediaClassBrowserSettings[
this.value.metadata.children_media_class ||
this.value.metadata.media_class
].thumbnail_ratio === "portrait",
})}"
>
${this.value?.metadata?.thumbnail
? html`
<div
class="${classMap({
"centered-image":
!!this.value.metadata.media_class &&
["app", "directory"].includes(
this.value.metadata.media_class
),
})}
image"
style=${this._thumbnailUrl
? `background-image: url(${this._thumbnailUrl});`
: ""}
></div>
`
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${!this.value?.media_content_id
? mdiPlus
: this.value?.metadata?.media_class
? MediaClassBrowserSettings[
this.value.metadata.media_class === "directory"
? this.value.metadata.children_media_class ||
this.value.metadata.media_class
: this.value.metadata.media_class
].icon
: mdiPlayBox}
></ha-svg-icon>
</div>
`}
</div>
<div class="title">
${!this.value?.media_content_id
? this.hass.localize("ui.components.selectors.media.pick_media")
: this.value.metadata?.title || this.value.media_content_id}
</div>
</ha-card>`}`;
}
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;
}
}

View File

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

View File

@ -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,
},
];
}

View File

@ -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`
return html`<ha-card>
<div class="card-content">
<ha-textarea
autogrow
.label=${this.hass.localize("ui.panel.media-browser.tts.message")}
.label=${this.hass.localize(
"ui.components.media-browser.tts.message"
)}
.value=${this._message ||
this.hass.localize("ui.panel.media-browser.tts.example_message", {
this.hass.localize(
"ui.components.media-browser.tts.example_message",
{
name: this.hass.user?.name || "",
})}
}
)}
>
</ha-textarea>
${this._cloudDefaultOptions ? this._renderCloudOptions() : ""}
<div class="actions">
</div>
<div class="card-actions">
${this._cloudDefaultOptions &&
(this._cloudDefaultOptions![0] !== this._cloudOptions![0] ||
this._cloudDefaultOptions![1] !== this._cloudOptions![1])
? html`
<button class="link" @click=${this._storeDefaults}>
${this.hass.localize(
"ui.panel.media-browser.tts.set_as_default"
"ui.components.media-browser.tts.set_as_default"
)}
</button>
`
: html`<span></span>`}
<mwc-button raised label="Say" @click=${this._ttsClicked}></mwc-button>
<mwc-button @click=${this._ttsClicked}>
${this.hass.localize(
`ui.components.media-browser.tts.action_${this.action}`
)}
</mwc-button>
</div>
`;
</ha-card> `;
}
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 {
<mwc-select
fixedMenuPosition
naturalMenuWidth
.label=${this.hass.localize("ui.panel.media-browser.tts.language")}
.label=${this.hass.localize(
"ui.components.media-browser.tts.language"
)}
.value=${selectedVoice[0]}
@selected=${this._handleLanguageChange}
@closed=${stopPropagation}
>
${languages.map(
([key, label]) =>
@ -90,9 +122,10 @@ class BrowseMediaTTS extends LitElement {
<mwc-select
fixedMenuPosition
naturalMenuWidth
.label=${this.hass.localize("ui.panel.media-browser.tts.gender")}
.label=${this.hass.localize("ui.components.media-browser.tts.gender")}
.value=${selectedVoice[1]}
@selected=${this._handleGenderChange}
@closed=${stopPropagation}
>
${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<void> {
@ -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;
}
`,
];
}

View File

@ -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}
></ha-browse-media-tts>
`
: !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<TtsMediaPickedEvent>): 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<void> {

View File

@ -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 = (

View File

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

View File

@ -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<ServiceAction> = assign(
baseActionStruct,
object({
service: optional(string()),
service_template: optional(string()),
entity_id: optional(string()),
target: optional(targetStruct),
data: optional(object()),
})
);
const playMediaActionStruct: Describe<PlayMediaAction> = 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<ServiceSceneAction> = 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<string, any>;
data?: Record<string, unknown>;
}
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<DelayActionParts> | string;
}
export interface ServiceSceneAction extends ServiceAction {
export interface ServiceSceneAction {
alias?: string;
service: "scene.turn_on";
metadata: Record<string, any>;
target?: { entity_id?: string };
entity_id?: string;
metadata: Record<string, unknown>;
}
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<string, unknown>;
}
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";
}

View File

@ -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 = <T extends ActionType>(
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 = <T extends ActionType>(
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;
};

View File

@ -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 }[];
};
}

View File

@ -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;
}
if (this._yamlMode) {
const yamlEditor = this._yamlEditor;
if (this._yamlMode && yamlEditor && yamlEditor.value !== this.action) {
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",

View File

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

View File

@ -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`
<ha-selector-media
.hass=${this.hass}
.value=${this._getSelectorValue(this.action)}
@value-changed=${this._valueChanged}
></ha-selector-media>
`;
}
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;
}
}

View File

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

View File

@ -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": {