Add TTS to media browser (#11679)

This commit is contained in:
Paulus Schoutsen 2022-02-14 07:50:44 -08:00 committed by GitHub
parent 63c9b3f830
commit a321432175
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 476 additions and 203 deletions

View File

@ -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`
<ha-textarea
autogrow
.label=${this.hass.localize("ui.panel.media-browser.tts.message")}
.value=${this._message ||
this.hass.localize("ui.panel.media-browser.tts.example_message", {
name: this.hass.user?.name || "",
})}
>
</ha-textarea>
${this._cloudDefaultOptions ? this._renderCloudOptions() : ""}
<div class="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"
)}
</button>
`
: html`<span></span>`}
<mwc-button raised label="Say" @click=${this._ttsClicked}></mwc-button>
</div>
`;
}
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`
<div class="cloud-options">
<mwc-select
fixedMenuPosition
naturalMenuWidth
.label=${this.hass.localize("ui.panel.media-browser.tts.language")}
.value=${selectedVoice[0]}
@selected=${this._handleLanguageChange}
>
${languages.map(
([key, label]) =>
html`<mwc-list-item .value=${key}>${label}</mwc-list-item>`
)}
</mwc-select>
<mwc-select
fixedMenuPosition
naturalMenuWidth
.label=${this.hass.localize("ui.panel.media-browser.tts.gender")}
.value=${selectedVoice[1]}
@selected=${this._handleGenderChange}
>
${genders.map(
([key, label]) =>
html`<mwc-list-item .value=${key}>${label}</mwc-list-item>`
)}
</mwc-select>
</div>
`;
}
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<void> {
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;
}
}

View File

@ -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)}
</div>
`
: currentItem.children?.length
? childrenMediaClass.layout === "grid"
? html`
<div
class="children ${classMap({
portrait:
childrenMediaClass.thumbnail_ratio === "portrait",
})}"
>
${currentItem.children.map(
(child) => html`
<div
class="child"
.item=${child}
@click=${this._childClicked}
>
<ha-card outlined>
<div class="thumbnail">
${child.thumbnail
? html`
<div
class="${[
"app",
"directory",
].includes(child.media_class)
? "centered-image"
: ""} image lazythumbnail"
data-src=${child.thumbnail}
></div>
`
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${MediaClassBrowserSettings[
child.media_class === "directory"
? child.children_media_class ||
child.media_class
: child.media_class
].icon}
></ha-svg-icon>
</div>
`}
${child.can_play
? html`
<ha-icon-button
class="play ${classMap({
can_expand: child.can_expand,
})}"
.item=${child}
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${this.action === "play"
? mdiPlay
: mdiPlus}
@click=${this._actionClicked}
></ha-icon-button>
`
: ""}
</div>
<div class="title">
${child.title}
<paper-tooltip
fitToVisibleBounds
position="top"
offset="4"
>${child.title}</paper-tooltip
>
</div>
</ha-card>
</div>
`
)}
</div>
`
: html`
<mwc-list>
${currentItem.children.map(
(child) => html`
<mwc-list-item
@click=${this._childClicked}
.item=${child}
.graphic=${mediaClass.show_list_images
? "medium"
: "avatar"}
dir=${computeRTLDirection(this.hass)}
>
<div
class=${classMap({
graphic: true,
lazythumbnail:
mediaClass.show_list_images === true,
})}
data-src=${ifDefined(
mediaClass.show_list_images && child.thumbnail
? child.thumbnail
: undefined
)}
slot="graphic"
>
<ha-icon-button
class="play ${classMap({
show:
!mediaClass.show_list_images ||
!child.thumbnail,
})}"
.item=${child}
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${this.action === "play"
? mdiPlay
: mdiPlus}
@click=${this._actionClicked}
></ha-icon-button>
</div>
<span class="title">${child.title}</span>
</mwc-list-item>
<li divider role="separator"></li>
`
)}
</mwc-list>
`
: html`
: isTTSMediaSource(currentItem.media_content_id)
? html`
<ha-browse-media-tts
.item=${currentItem}
.hass=${this.hass}
.action=${this.action}
></ha-browse-media-tts>
`
: !currentItem.children?.length
? html`
<div class="container no-items">
${this.hass.localize(
"ui.components.media-browser.no_items"
@ -400,6 +287,128 @@ export class HaMediaPlayerBrowse extends LitElement {
: ""}
</div>
`
: childrenMediaClass.layout === "grid"
? html`
<div
class="children ${classMap({
portrait:
childrenMediaClass.thumbnail_ratio === "portrait",
})}"
>
${currentItem.children.map(
(child) => html`
<div
class="child"
.item=${child}
@click=${this._childClicked}
>
<ha-card outlined>
<div class="thumbnail">
${child.thumbnail
? html`
<div
class="${["app", "directory"].includes(
child.media_class
)
? "centered-image"
: ""} image lazythumbnail"
data-src=${child.thumbnail}
></div>
`
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${MediaClassBrowserSettings[
child.media_class === "directory"
? child.children_media_class ||
child.media_class
: child.media_class
].icon}
></ha-svg-icon>
</div>
`}
${child.can_play
? html`
<ha-icon-button
class="play ${classMap({
can_expand: child.can_expand,
})}"
.item=${child}
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${this.action === "play"
? mdiPlay
: mdiPlus}
@click=${this._actionClicked}
></ha-icon-button>
`
: ""}
</div>
<div class="title">
${child.title}
<paper-tooltip
fitToVisibleBounds
position="top"
offset="4"
>${child.title}</paper-tooltip
>
</div>
</ha-card>
</div>
`
)}
</div>
`
: html`
<mwc-list>
${currentItem.children.map(
(child) => html`
<mwc-list-item
@click=${this._childClicked}
.item=${child}
.graphic=${mediaClass.show_list_images
? "medium"
: "avatar"}
dir=${computeRTLDirection(this.hass)}
>
<div
class=${classMap({
graphic: true,
lazythumbnail:
mediaClass.show_list_images === true,
})}
data-src=${ifDefined(
mediaClass.show_list_images && child.thumbnail
? child.thumbnail
: undefined
)}
slot="graphic"
>
<ha-icon-button
class="play ${classMap({
show:
!mediaClass.show_list_images ||
!child.thumbnail,
})}"
.item=${child}
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${this.action === "play"
? mdiPlay
: mdiPlus}
@click=${this._actionClicked}
></ha-icon-button>
</div>
<span class="title">${child.title}</span>
</mwc-list-item>
<li divider role="separator"></li>
`
)}
</mwc-list>
`
}
</div>
</div>

View File

@ -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<CloudTTSInfo>({ type: "cloud/tts/info" });

70
src/data/cloud/tts.ts Normal file
View File

@ -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<CloudTTSInfo>({ type: "cloud/tts/info" });
export const getCloudTtsLanguages = (info?: CloudTTSInfo) => {
const languages: Array<[string, string]> = [];
if (!info) {
return languages;
}
const seen = new Set<string>();
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]));
};

View File

@ -10,3 +10,11 @@ export const convertTextToSpeech = (
options?: Record<string, unknown>;
}
) => 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);

View File

@ -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`
<ha-card
@ -100,61 +103,9 @@ export class CloudTTSPref extends LitElement {
}
}
private getLanguages = memoizeOne((info?: CloudTTSInfo) => {
const languages: Array<[string, string]> = [];
private getLanguages = memoizeOne(getCloudTtsLanguages);
if (!info) {
return languages;
}
const seen = new Set<string>();
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];

View File

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

View File

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