blob: 942a0fcca68e3f82d57f37761ae3b69a11b103ec [file] [log] [blame]
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import '//resources/cr_elements/cr_icon_button/cr_icon_button.js';
import '//resources/cr_elements/cr_icons.css.js';
import '//resources/cr_elements/icons.html.js';
import '//resources/cr_elements/cr_dialog/cr_dialog.js';
import '//resources/cr_elements/cr_icons.css.js';
import '//resources/cr_elements/cr_input/cr_input.js';
import '//resources/cr_elements/cr_toggle/cr_toggle.js';
import './icons.html.js';
import type {CrDialogElement} from '//resources/cr_elements/cr_dialog/cr_dialog.js';
import {I18nMixin} from '//resources/cr_elements/i18n_mixin.js';
import {WebUiListenerMixin} from '//resources/cr_elements/web_ui_listener_mixin.js';
import type {DomRepeatEvent} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {getTemplate} from './language_menu.html.js';
import {AVAILABLE_GOOGLE_TTS_LOCALES, convertLangOrLocaleForVoicePackManager, VoicePackStatus} from './voice_language_util.js';
export interface LanguageMenuElement {
$: {
languageMenu: CrDialogElement,
};
}
interface Notification {
isError: boolean;
text: string|undefined;
}
interface LanguageDropdownItem {
readableLanguage: string;
checked: boolean;
languageCode: string;
notification: Notification;
// Whether this is the language of the currently selected voice
selectedVoiceLang: boolean;
callback: () => void;
}
const LanguageMenuElementBase = WebUiListenerMixin(I18nMixin(PolymerElement));
export const LANGUAGE_TOGGLE_EVENT = 'voice-language-toggle';
export class LanguageMenuElement extends LanguageMenuElementBase {
static get is() {
return 'language-menu';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
enabledLanguagesInPref: Array,
availableVoices: Array,
languageSearchValue_: String,
localeToDisplayName: Object,
voicePackInstallStatus: Object,
selectedLang: String,
availableLanguages_: {
type: Array,
computed:
'computeAvailableLanguages_(availableVoices,localeToDisplayName,' +
'voicePackInstallStatus,selectedLang,languageSearchValue_)',
},
};
}
private languageSearchValue_: string;
private readonly voicePackInstallStatus:
{[language: string]: VoicePackStatus};
private readonly enabledLanguagesInPref: string[];
private readonly availableLanguages_: LanguageDropdownItem[];
// Use this variable instead of AVAILABLE_GOOGLE_TTS_LOCALES
// directly to better aid in testing.
private baseLanguages = AVAILABLE_GOOGLE_TTS_LOCALES;
private closeLanguageMenu_() {
this.$.languageMenu.close();
}
private onCloseDialog_() {
this.onClearSearchClick_();
}
private onClearSearchClick_() {
this.languageSearchValue_ = '';
}
private onToggleChange_(event: DomRepeatEvent<LanguageDropdownItem>) {
event.model.item.callback();
}
private getDisplayName(
localeToDisplayName: {[lang: string]: string}, lang: string) {
return (localeToDisplayName && lang in localeToDisplayName) ?
localeToDisplayName[lang] :
lang;
}
private computeAvailableLanguages_(
availableVoices: SpeechSynthesisVoice[],
localeToDisplayName: {[lang: string]: string},
voicePackInstallStatus: {[language: string]: VoicePackStatus},
selectedLang: string|undefined,
languageSearchValue: string|undefined): LanguageDropdownItem[] {
if (!availableVoices) {
return [];
}
const selectedLangLowerCase = selectedLang?.toLowerCase();
// Ensure we've added the available pack manager supported languages to
// the language menu first, only on ChromeOS.
const langsAndReadableLangs: Array<[string, string]> =
chrome.readingMode.isLanguagePackDownloadingEnabled &&
chrome.readingMode.isChromeOsAsh ?
Array.from(
this.baseLanguages,
(key) => [key, this.getDisplayName(localeToDisplayName, key)]) :
[];
// Next, add any other supported languages to the menu, if they don't
// already exist.
availableVoices.forEach((voice) => {
const lang = voice.lang;
if (!langsAndReadableLangs.some(
([key, _]) => key === lang.toLowerCase())) {
langsAndReadableLangs.push([
lang.toLowerCase(),
this.getDisplayName(localeToDisplayName, lang),
]);
}
});
// Sort the list of languages alphabetically by display name.
langsAndReadableLangs.sort(([, firstDisplay], [, secondDisplay]) => {
return firstDisplay.localeCompare(secondDisplay);
});
return langsAndReadableLangs
.filter(([_, readableLang]) => {
if (languageSearchValue) {
return readableLang.toLowerCase().includes(
languageSearchValue.toLowerCase());
} else {
return true;
}
})
.map(
([lang, readableLang]) => ({
readableLanguage: readableLang,
checked: this.enabledLanguagesInPref &&
this.enabledLanguagesInPref.includes(lang),
languageCode: lang,
notification: {
isError: this.isNotificationError(lang, voicePackInstallStatus),
text: this.getNotificationText(lang, voicePackInstallStatus),
},
selectedVoiceLang: lang.toLowerCase() === selectedLangLowerCase,
callback: () =>
this.dispatchEvent(new CustomEvent(LANGUAGE_TOGGLE_EVENT, {
bubbles: true,
composed: true,
detail: {
language: lang,
},
})),
}));
}
private computeAriaSetting(isError: boolean): string {
return isError ? `polite` : `off`;
}
private isNotificationError(
lang: string,
voicePackInstallStatus: {[language: string]: VoicePackStatus}): boolean {
const voicePackLanguage = convertLangOrLocaleForVoicePackManager(lang);
if (!voicePackLanguage) {
// If the voice pack language doesn't exist, no need to update the
// notification error status.
return false;
}
const notification: VoicePackStatus|undefined =
voicePackInstallStatus[voicePackLanguage];
if (notification === undefined) {
return false;
}
// TODO(b/40927698): In the future, some of our install error messages
// might not be set to an "error" in the notification status span, so
// be more specific.
return notification === VoicePackStatus.INSTALL_ERROR;
}
private getNotificationText(
lang: string,
voicePackInstallStatus: {[language: string]: VoicePackStatus}): string {
// Make sure to convert the lang string, otherwise there could be a
// mismatch in a language and locale and what is stored in the installation
// map.
const voicePackLanguage = convertLangOrLocaleForVoicePackManager(lang);
// No need to check the install status if the language is missing.
if (!voicePackLanguage) {
return '';
}
const notification: VoicePackStatus|undefined =
voicePackInstallStatus[voicePackLanguage];
if (notification === undefined) {
return '';
}
// TODO(b/300259625): Show more error messages.
switch (notification) {
case VoicePackStatus.INSTALLING:
case VoicePackStatus.DOWNLOADED:
return 'readingModeLanguageMenuDownloading';
case VoicePackStatus.INSTALL_ERROR:
// There's not a specific error code from the language pack installer
// for internet connectivity, but if there's an installation error
// and we detect we're offline, we can assume that the install error
// was due to lack of internet connection.
// TODO(b/40927698): Consider setting the error status directly in
// app.ts so that this can be reused by the voice menu when other
// errors are added to the voice menu.
if (!window.navigator.onLine) {
return 'readingModeLanguageMenuNoInternet';
}
return '';
case VoicePackStatus.NONE:
case VoicePackStatus.EXISTS:
case VoicePackStatus.INSTALLED:
case VoicePackStatus.REMOVED_BY_USER:
default:
return '';
}
}
// Runtime errors were thrown when this.i18n() was called in a Polymer
// computed bindining callback function, so instead we call this.i18n from the
// html via a wrapper.
private i18nWraper(s: string): string {
if (!s) {
return '';
}
return `${this.i18n(s)}`;
}
showDialog() {
this.$.languageMenu.showModal();
}
private searchHasLanguages(
availableLanguages: LanguageDropdownItem[],
languageSearchValue: string): boolean {
// We should only show the "No results" string when there are no available
// languages and there is a valid search term.
return (availableLanguages.length > 0) || (!languageSearchValue) ||
(languageSearchValue.trim().length === 0);
}
}
declare global {
interface HTMLElementTagNameMap {
'language-menu': LanguageMenuElement;
}
}
customElements.define(LanguageMenuElement.is, LanguageMenuElement);