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` -
- ${currentItem.children.map( - (child) => html` -
- -
- ${child.thumbnail - ? html` -
- ` - : html` -
- -
- `} - ${child.can_play - ? html` - - ` - : ""} -
-
- ${child.title} - ${child.title} -
-
-
- ` - )} -
- ` - : 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` +
    + ${currentItem.children.map( + (child) => html` +
    + +
    + ${child.thumbnail + ? html` +
    + ` + : html` +
    + +
    + `} + ${child.can_play + ? html` + + ` + : ""} +
    +
    + ${child.title} + ${child.title} +
    +
    +
    + ` + )} +
    + ` + : 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": {