diff --git a/src/components/media-player/ha-browse-media-tts.ts b/src/components/media-player/ha-browse-media-tts.ts
new file mode 100644
index 0000000000..8bec663a5d
--- /dev/null
+++ b/src/components/media-player/ha-browse-media-tts.ts
@@ -0,0 +1,230 @@
+import "@material/mwc-select";
+import "@material/mwc-list/mwc-list-item";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import memoizeOne from "memoize-one";
+import { fireEvent } from "../../common/dom/fire_event";
+import { fetchCloudStatus, updateCloudPref } from "../../data/cloud";
+import {
+ CloudTTSInfo,
+ getCloudTTSInfo,
+ getCloudTtsLanguages,
+ getCloudTtsSupportedGenders,
+} from "../../data/cloud/tts";
+import { MediaPlayerBrowseAction } 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";
+
+@customElement("ha-browse-media-tts")
+class BrowseMediaTTS extends LitElement {
+ @property() public hass!: HomeAssistant;
+
+ @property() public item;
+
+ @property() public action!: MediaPlayerBrowseAction;
+
+ @state() private _cloudDefaultOptions?: [string, string];
+
+ @state() private _cloudOptions?: [string, string];
+
+ @state() private _cloudTTSInfo?: CloudTTSInfo;
+
+ @LocalStorage("cloudTtsTryMessage", false, false) private _message!: string;
+
+ protected render() {
+ return html`
+
+
+ ${this._cloudDefaultOptions ? this._renderCloudOptions() : ""}
+
+ ${this._cloudDefaultOptions &&
+ (this._cloudDefaultOptions![0] !== this._cloudOptions![0] ||
+ this._cloudDefaultOptions![1] !== this._cloudOptions![1])
+ ? html`
+
+ `
+ : html``}
+
+
+ `;
+ }
+
+ private _renderCloudOptions() {
+ const languages = this.getLanguages(this._cloudTTSInfo);
+ const selectedVoice = this._cloudOptions!;
+ const genders = this.getSupportedGenders(
+ selectedVoice[0],
+ this._cloudTTSInfo,
+ this.hass.localize
+ );
+
+ return html`
+
+
+ ${languages.map(
+ ([key, label]) =>
+ html`${label}`
+ )}
+
+
+
+ ${genders.map(
+ ([key, label]) =>
+ html`${label}`
+ )}
+
+
+ `;
+ }
+
+ protected override willUpdate(changedProps: PropertyValues): void {
+ super.willUpdate(changedProps);
+
+ if (changedProps.has("message")) {
+ return;
+ }
+
+ // Re-rendering can reset message because textarea content is newer than local storage.
+ // But we don't want to write every keystroke to local storage.
+ // So instead we just do it when we're going to render.
+ const message = this.shadowRoot!.querySelector("ha-textarea")?.value;
+ if (message !== undefined && message !== this._message) {
+ this._message = message;
+ }
+ }
+
+ async _handleLanguageChange(ev) {
+ if (ev.target.value === this._cloudOptions![0]) {
+ return;
+ }
+ this._cloudOptions = [ev.target.value, this._cloudOptions![1]];
+ }
+
+ async _handleGenderChange(ev) {
+ if (ev.target.value === this._cloudOptions![1]) {
+ return;
+ }
+ 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";
+ }
+
+ private async _ttsClicked(): Promise {
+ const message = this.shadowRoot!.querySelector("ha-textarea")!.value;
+ this._message = message;
+ const item = { ...this.item };
+ const query = new URLSearchParams();
+ query.append("message", message);
+ if (this._cloudOptions) {
+ query.append("language", this._cloudOptions[0]);
+ query.append("gender", this._cloudOptions[1]);
+ }
+ item.media_content_id += `?${query.toString()}`;
+ item.can_play = true;
+ fireEvent(this, "media-picked", { item });
+ }
+
+ private async _storeDefaults() {
+ const oldDefaults = this._cloudDefaultOptions!;
+ this._cloudDefaultOptions = [...this._cloudOptions!];
+ try {
+ await updateCloudPref(this.hass, {
+ tts_default_voice: this._cloudDefaultOptions,
+ });
+ } catch (err: any) {
+ this._cloudDefaultOptions = oldDefaults;
+ showAlertDialog(this, {
+ text: this.hass.localize(
+ "ui.panel.media-browser.tts.faild_to_store_defaults",
+ { error: err.message || err }
+ ),
+ });
+ }
+ }
+
+ static override styles = [
+ buttonLinkStyle,
+ css`
+ :host {
+ margin: 16px auto;
+ padding: 0 8px;
+ display: flex;
+ flex-direction: column;
+ max-width: 400px;
+ }
+ .cloud-options {
+ margin-top: 16px;
+ display: flex;
+ justify-content: space-between;
+ }
+ .cloud-options mwc-select {
+ width: 48%;
+ }
+
+ .actions {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 16px;
+ }
+ button.link {
+ color: var(--primary-color);
+ }
+ `,
+ ];
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-browse-media-tts": BrowseMediaTTS;
+ }
+}
diff --git a/src/components/media-player/ha-media-player-browse.ts b/src/components/media-player/ha-media-player-browse.ts
index 939cb78b82..27e2274169 100644
--- a/src/components/media-player/ha-media-player-browse.ts
+++ b/src/components/media-player/ha-media-player-browse.ts
@@ -49,6 +49,8 @@ import "../ha-icon-button";
import "../ha-svg-icon";
import "../ha-fab";
import { browseLocalMediaPlayer } from "../../data/media_source";
+import { isTTSMediaSource } from "../../data/tts";
+import "./ha-browse-media-tts";
declare global {
interface HASSDomEvents {
@@ -246,131 +248,16 @@ export class HaMediaPlayerBrowse extends LitElement {
${this._renderError(this._error)}
`
- : currentItem.children?.length
- ? childrenMediaClass.layout === "grid"
- ? html`
-
- `
- : html`
-
- ${currentItem.children.map(
- (child) => html`
-
-
-
-
- ${child.title}
-
-
- `
- )}
-
- `
- : html`
+ : isTTSMediaSource(currentItem.media_content_id)
+ ? html`
+
+ `
+ : !currentItem.children?.length
+ ? html`
${this.hass.localize(
"ui.components.media-browser.no_items"
@@ -400,6 +287,128 @@ export class HaMediaPlayerBrowse extends LitElement {
: ""}
`
+ : childrenMediaClass.layout === "grid"
+ ? html`
+
+ `
+ : html`
+
+ ${currentItem.children.map(
+ (child) => html`
+
+
+
+
+ ${child.title}
+
+
+ `
+ )}
+
+ `
}
diff --git a/src/data/cloud.ts b/src/data/cloud.ts
index 4679d97a75..8f4f14b72b 100644
--- a/src/data/cloud.ts
+++ b/src/data/cloud.ts
@@ -186,10 +186,3 @@ export const updateCloudAlexaEntityConfig = (
entity_id: entityId,
...values,
});
-
-export interface CloudTTSInfo {
- languages: Array<[string, string]>;
-}
-
-export const getCloudTTSInfo = (hass: HomeAssistant) =>
- hass.callWS({ type: "cloud/tts/info" });
diff --git a/src/data/cloud/tts.ts b/src/data/cloud/tts.ts
new file mode 100644
index 0000000000..02fe969e21
--- /dev/null
+++ b/src/data/cloud/tts.ts
@@ -0,0 +1,70 @@
+import { caseInsensitiveStringCompare } from "../../common/string/compare";
+import { LocalizeFunc } from "../../common/translations/localize";
+import { translationMetadata } from "../../resources/translations-metadata";
+import { HomeAssistant } from "../../types";
+
+export interface CloudTTSInfo {
+ languages: Array<[string, string]>;
+}
+
+export const getCloudTTSInfo = (hass: HomeAssistant) =>
+ hass.callWS({ type: "cloud/tts/info" });
+
+export const getCloudTtsLanguages = (info?: CloudTTSInfo) => {
+ const languages: Array<[string, string]> = [];
+
+ if (!info) {
+ return languages;
+ }
+
+ const seen = new Set();
+ for (const [lang] of info.languages) {
+ if (seen.has(lang)) {
+ continue;
+ }
+ seen.add(lang);
+
+ let label = lang;
+
+ if (lang in translationMetadata.translations) {
+ label = translationMetadata.translations[lang].nativeName;
+ } else {
+ const [langFamily, dialect] = lang.split("-");
+ if (langFamily in translationMetadata.translations) {
+ label = `${translationMetadata.translations[langFamily].nativeName}`;
+
+ if (langFamily.toLowerCase() !== dialect.toLowerCase()) {
+ label += ` (${dialect})`;
+ }
+ }
+ }
+
+ languages.push([lang, label]);
+ }
+ return languages.sort((a, b) => caseInsensitiveStringCompare(a[1], b[1]));
+};
+
+export const getCloudTtsSupportedGenders = (
+ language: string,
+ info: CloudTTSInfo | undefined,
+ localize: LocalizeFunc
+) => {
+ const genders: Array<[string, string]> = [];
+
+ if (!info) {
+ return genders;
+ }
+
+ for (const [curLang, gender] of info.languages) {
+ if (curLang === language) {
+ genders.push([
+ gender,
+ localize(`ui.panel.media-browser.tts.gender_${gender}`) ||
+ localize(`ui.panel.config.cloud.account.tts.${gender}`) ||
+ gender,
+ ]);
+ }
+ }
+
+ return genders.sort((a, b) => caseInsensitiveStringCompare(a[1], b[1]));
+};
diff --git a/src/data/tts.ts b/src/data/tts.ts
index e4469ffb24..f4d0d1346b 100644
--- a/src/data/tts.ts
+++ b/src/data/tts.ts
@@ -10,3 +10,11 @@ export const convertTextToSpeech = (
options?: Record;
}
) => hass.callApi<{ url: string; path: string }>("POST", "tts_get_url", data);
+
+const TTS_MEDIA_SOURCE_PREFIX = "media-source://tts/";
+
+export const isTTSMediaSource = (mediaContentId: string) =>
+ mediaContentId.startsWith(TTS_MEDIA_SOURCE_PREFIX);
+
+export const getProviderFromTTSMediaSource = (mediaContentId: string) =>
+ mediaContentId.substring(TTS_MEDIA_SOURCE_PREFIX.length);
diff --git a/src/panels/config/cloud/account/cloud-tts-pref.ts b/src/panels/config/cloud/account/cloud-tts-pref.ts
index 95e68745f2..b92a9e45d7 100644
--- a/src/panels/config/cloud/account/cloud-tts-pref.ts
+++ b/src/panels/config/cloud/account/cloud-tts-pref.ts
@@ -5,18 +5,17 @@ import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
-import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-switch";
+import { CloudStatusLoggedIn, updateCloudPref } from "../../../../data/cloud";
import {
- CloudStatusLoggedIn,
CloudTTSInfo,
getCloudTTSInfo,
- updateCloudPref,
-} from "../../../../data/cloud";
+ getCloudTtsLanguages,
+ getCloudTtsSupportedGenders,
+} from "../../../../data/cloud/tts";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
-import { translationMetadata } from "../../../../resources/translations-metadata";
import type { HomeAssistant } from "../../../../types";
import { showTryTtsDialog } from "./show-dialog-cloud-tts-try";
@@ -37,7 +36,11 @@ export class CloudTTSPref extends LitElement {
const languages = this.getLanguages(this.ttsInfo);
const defaultVoice = this.cloudStatus.prefs.tts_default_voice;
- const genders = this.getSupportedGenders(defaultVoice[0], this.ttsInfo);
+ const genders = this.getSupportedGenders(
+ defaultVoice[0],
+ this.ttsInfo,
+ this.hass.localize
+ );
return html`
{
- const languages: Array<[string, string]> = [];
+ private getLanguages = memoizeOne(getCloudTtsLanguages);
- if (!info) {
- return languages;
- }
-
- const seen = new Set();
- for (const [lang] of info.languages) {
- if (seen.has(lang)) {
- continue;
- }
- seen.add(lang);
-
- let label = lang;
-
- if (lang in translationMetadata.translations) {
- label = translationMetadata.translations[lang].nativeName;
- } else {
- const [langFamily, dialect] = lang.split("-");
- if (langFamily in translationMetadata.translations) {
- label = `${translationMetadata.translations[langFamily].nativeName}`;
-
- if (langFamily.toLowerCase() !== dialect.toLowerCase()) {
- label += ` (${dialect})`;
- }
- }
- }
-
- languages.push([lang, label]);
- }
- return languages.sort((a, b) => caseInsensitiveStringCompare(a[1], b[1]));
- });
-
- private getSupportedGenders = memoizeOne(
- (language: string, info?: CloudTTSInfo) => {
- const genders: Array<[string, string]> = [];
-
- if (!info) {
- return genders;
- }
-
- for (const [curLang, gender] of info.languages) {
- if (curLang === language) {
- genders.push([
- gender,
- this.hass.localize(`ui.panel.config.cloud.account.tts.${gender}`) ||
- gender,
- ]);
- }
- }
-
- return genders.sort((a, b) => caseInsensitiveStringCompare(a[1], b[1]));
- }
- );
+ private getSupportedGenders = memoizeOne(getCloudTtsSupportedGenders);
private _openTryDialog() {
showTryTtsDialog(this, {
@@ -170,7 +121,11 @@ export class CloudTTSPref extends LitElement {
const language = ev.target.value;
const curGender = this.cloudStatus!.prefs.tts_default_voice[1];
- const genders = this.getSupportedGenders(language, this.ttsInfo);
+ const genders = this.getSupportedGenders(
+ language,
+ this.ttsInfo,
+ this.hass.localize
+ );
const newGender = genders.find((item) => item[0] === curGender)
? curGender
: genders[0][0];
diff --git a/src/panels/media-browser/ha-panel-media-browser.ts b/src/panels/media-browser/ha-panel-media-browser.ts
index f3b541f7a3..0ab9f870c4 100644
--- a/src/panels/media-browser/ha-panel-media-browser.ts
+++ b/src/panels/media-browser/ha-panel-media-browser.ts
@@ -219,18 +219,18 @@ class PanelMediaBrowser extends LitElement {
return;
}
- if (item.media_content_type.startsWith("audio/")) {
+ const resolvedUrl = await resolveMediaSource(
+ this.hass,
+ item.media_content_id
+ );
+
+ if (resolvedUrl.mime_type.startsWith("audio/")) {
await this.shadowRoot!.querySelector("ha-bar-media-player")!.playItem(
item
);
return;
}
- const resolvedUrl: any = await resolveMediaSource(
- this.hass,
- item.media_content_id
- );
-
showWebBrowserPlayMediaDialog(this, {
sourceUrl: resolvedUrl.url,
sourceType: resolvedUrl.mime_type,
@@ -270,10 +270,6 @@ class PanelMediaBrowser extends LitElement {
return [
haStyle,
css`
- :host {
- --mdc-theme-primary: var(--app-header-text-color);
- }
-
ha-media-player-browse {
height: calc(100vh - (100px + var(--header-height)));
}
diff --git a/src/translations/en.json b/src/translations/en.json
index cafd39ed58..5bcc4ce4e8 100755
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -3689,6 +3689,18 @@
"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": {