// 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_icon/cr_icon.js';
import '//resources/cr_elements/icons_lit.html.js';
import '//resources/cr_elements/cr_dialog/cr_dialog.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 {I18nMixinLit} from '//resources/cr_elements/i18n_mixin_lit.js';
import {WebUiListenerMixinLit} from '//resources/cr_elements/web_ui_listener_mixin_lit.js';
import {loadTimeData} from '//resources/js/load_time_data.js';
import {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from '//resources/lit/v3_0/lit.rollup.js';
import {toastDurationMs, ToolbarEvent} from './common.js';
import {getCss} from './language_menu.css.js';
import {getHtml} from './language_menu.html.js';
import {AVAILABLE_GOOGLE_TTS_LOCALES, convertLangOrLocaleForVoicePackManager, VoiceClientSideStatusCode} from './voice_language_util.js';
export interface LanguageMenuElement {
$: {
languageMenu: CrDialogElement,
};
}
interface Notification {
isError: boolean;
text?: string;
}
interface LanguageDropdownItem {
readableLanguage: string;
checked: boolean;
languageCode: string;
notification: Notification;
// Whether this toggle should be disabled
disabled: boolean;
}
function isDownloading(voiceStatus: VoiceClientSideStatusCode) {
switch (voiceStatus) {
case VoiceClientSideStatusCode.SENT_INSTALL_REQUEST:
case VoiceClientSideStatusCode.SENT_INSTALL_REQUEST_ERROR_RETRY:
case VoiceClientSideStatusCode.INSTALLED_AND_UNAVAILABLE:
return true;
case VoiceClientSideStatusCode.AVAILABLE:
case VoiceClientSideStatusCode.ERROR_INSTALLING:
case VoiceClientSideStatusCode.INSTALL_ERROR_ALLOCATION:
case VoiceClientSideStatusCode.NOT_INSTALLED:
return false;
default:
// This ensures the switch statement is exhaustive
return voiceStatus satisfies never;
}
}
// Returns whether `substring` is a non-case-sensitive substring of `value`
function isSubstring(value: string, substring: string): boolean {
return value.toLowerCase().includes(substring.toLowerCase());
}
const LanguageMenuElementBase =
WebUiListenerMixinLit(I18nMixinLit(CrLitElement));
export class LanguageMenuElement extends LanguageMenuElementBase {
static get is() {
return 'language-menu';
}
static override get styles() {
return getCss();
}
override render() {
return getHtml.bind(this)();
}
static override get properties() {
return {
enabledLangs: {type: Array},
availableVoices: {type: Array},
localeToDisplayName: {type: Object},
voicePackInstallStatus: {type: Object},
selectedLang: {type: String},
lastDownloadedLang: {type: String},
languageSearchValue_: {type: String},
currentNotifications_: {type: Array},
toastTitle_: {type: String},
availableLanguages_: {type: Array},
};
}
override willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
const changedPrivateProperties =
changedProperties as Map<PropertyKey, unknown>;
if (changedProperties.has('selectedLang') ||
changedProperties.has('localeToDisplayName') ||
changedPrivateProperties.has('currentNotifications_') ||
changedPrivateProperties.has('languageSearchValue_')) {
this.availableLanguages_ = this.computeAvailableLanguages_();
}
if (changedProperties.has('lastDownloadedLang')) {
this.toastTitle_ = this.getLanguageDownloadedTitle_();
}
}
override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (changedProperties.has('voicePackInstallStatus')) {
this.updateNotifications_(
/* newVoiceStatuses= */ this.voicePackInstallStatus,
/* oldVoiceStatuses= */
changedProperties.get('voicePackInstallStatus'));
}
}
selectedLang: string;
localeToDisplayName: {[lang: string]: string} = {};
enabledLangs: string[] = [];
lastDownloadedLang: string;
availableVoices: SpeechSynthesisVoice[];
protected languageSearchValue_: string = '';
protected toastTitle_: string = '';
protected toastDuration_: number = toastDurationMs;
voicePackInstallStatus: {[language: string]: VoiceClientSideStatusCode};
protected availableLanguages_: LanguageDropdownItem[] = [];
// Use this variable instead of AVAILABLE_GOOGLE_TTS_LOCALES
// directly to better aid in testing.
localesOfLangPackVoices: Set<string> =
this.getSupportedNaturalVoiceDownloadLocales();
// The current notifications that should be used in the language menu.
// This is cleared each time the language menu reopens. After the language
// menu reopens, only new changes to voicePackInstallStatus will be reflected
// in notifications.
private currentNotifications_:
{[language: string]: VoiceClientSideStatusCode} = {};
// Returns a copy of voicePackInstallStatus to use as a snapshot of the
// current state. Before copying over the map, check the diff of
// the new voicePackInstallStatus and our previous snapshot. If there are
// any differences, add these to the currentNotifications_ map.
private updateNotifications_(
newVoiceStatuses: {[language: string]: VoiceClientSideStatusCode},
oldVoiceStatuses?: {[language: string]: VoiceClientSideStatusCode}) {
for (const lang of Object.keys(newVoiceStatuses)) {
const newStatus = newVoiceStatuses[lang];
// Since the downloading messages are cleared quickly, we should still
// show "downloading" notifications, even if they were previously shown.
if (isDownloading(newStatus)) {
this.setNotification(lang, newStatus);
} else if (oldVoiceStatuses && oldVoiceStatuses[lang] !== newStatus) {
// Update the notification status for recently changed language keys.
// Only show updates that occur while the language menu is open- don't
// show notifications if updates occurred before the menu opened.
this.setNotification(lang, newStatus);
}
}
}
private setNotification(lang: string, status: VoiceClientSideStatusCode) {
this.currentNotifications_ = {
...this.currentNotifications_,
[lang]: status,
};
}
protected closeLanguageMenu_() {
this.$.languageMenu.close();
}
protected onClearSearchClick_() {
this.languageSearchValue_ = '';
}
protected onToggleChange_(e: Event) {
const index =
Number.parseInt((e.currentTarget as HTMLElement).dataset['index']!);
const language = this.availableLanguages_[index].languageCode;
this.fire(ToolbarEvent.LANGUAGE_TOGGLE, {language});
}
private getDisplayName(lang: string) {
const langLower = lang.toLowerCase();
return this.localeToDisplayName[langLower] || langLower;
}
private getLanguageDownloadedTitle_() {
if (!this.lastDownloadedLang) {
return '';
}
const langDisplayName = this.getDisplayName(this.lastDownloadedLang);
return loadTimeData.getStringF(
'readingModeVoiceDownloadedTitle', langDisplayName);
}
private getSupportedNaturalVoiceDownloadLocales(): Set<string> {
if (chrome.readingMode.isLanguagePackDownloadingEnabled &&
chrome.readingMode.isChromeOsAsh) {
return AVAILABLE_GOOGLE_TTS_LOCALES;
}
return new Set([]);
}
private computeAvailableLanguages_(): LanguageDropdownItem[] {
if (!this.availableVoices) {
return [];
}
const selectedLangLowerCase = this.selectedLang?.toLowerCase();
const availableLangs: string[] = [...new Set([
...this.localesOfLangPackVoices,
...this.availableVoices.map(({lang}) => lang.toLowerCase()),
])];
// Sort the list of languages alphabetically by display name.
availableLangs.sort((lang1, lang2) => {
return this.getDisplayName(lang1).localeCompare(
this.getDisplayName(lang2));
});
return availableLangs
.filter(
// Check whether the search term matches the readable lang (e.g.
// 'ras' will match 'Portugues (Brasil)'), and also if it matches
// the language code (e.g. 'pt-br' matches 'Portugues (Brasil)')
lang => isSubstring(
/* value= */ this.getDisplayName(lang),
/* substring= */ this.languageSearchValue_) ||
isSubstring(
/* value= */ lang,
/* substring= */ this.languageSearchValue_))
.map(lang => ({
readableLanguage: this.getDisplayName(lang),
checked: this.enabledLangs.includes(lang),
languageCode: lang,
notification: this.getNotificationFor(lang),
disabled: this.enabledLangs.includes(lang) &&
(lang.toLowerCase() === selectedLangLowerCase),
}));
}
private hasAvailableNaturalVoices(lang: string): boolean {
return this.localesOfLangPackVoices.has(lang.toLowerCase());
}
private getNotificationFor(lang: string): Notification {
// Don't show notification text for a non-Google TTS language, as we're
// not attempting a download.
if (!this.hasAvailableNaturalVoices(lang)) {
return {isError: false};
}
// Convert the lang code string to the language-pack format
const voicePackLanguage = convertLangOrLocaleForVoicePackManager(lang);
// No need to check the install status if the language is missing.
if (!voicePackLanguage) {
return {isError: false};
}
const notification = this.currentNotifications_[voicePackLanguage];
if (notification === undefined) {
return {isError: false};
}
// TODO(b/300259625): Show more error messages.
switch (notification) {
case VoiceClientSideStatusCode.SENT_INSTALL_REQUEST:
case VoiceClientSideStatusCode.SENT_INSTALL_REQUEST_ERROR_RETRY:
case VoiceClientSideStatusCode.INSTALLED_AND_UNAVAILABLE:
return {isError: false, text: 'readingModeLanguageMenuDownloading'};
case VoiceClientSideStatusCode.ERROR_INSTALLING:
// 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 {isError: true, text: 'readingModeLanguageMenuNoInternet'};
}
// Show a generic error message.
return {isError: true, text: 'languageMenuDownloadFailed'};
case VoiceClientSideStatusCode.INSTALL_ERROR_ALLOCATION:
// If we get an allocation error but voices exist for the given
// language, show an allocation error specific to downloading high
// quality voices.
if (this.availableVoices.some(
voice => voice.lang.toLowerCase() === lang)) {
return {isError: true, text: 'allocationErrorHighQuality'};
}
return {isError: true, text: 'allocationError'};
case VoiceClientSideStatusCode.AVAILABLE:
case VoiceClientSideStatusCode.NOT_INSTALLED:
return {isError: false};
default:
// This ensures the switch statement is exhaustive
return notification satisfies never;
}
}
// 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.
protected i18nWraper(s: string|undefined): string {
return s ? this.i18n(s) : '';
}
protected searchHasLanguages(): boolean {
// We should only show the "No results" string when there are no available
// languages and there is a valid search term.
return (this.availableLanguages_.length > 0) ||
(!this.languageSearchValue_) ||
(this.languageSearchValue_.trim().length === 0);
}
protected onLanguageSearchValueChanged_(e: CustomEvent<{value: string}>) {
this.languageSearchValue_ = e.detail.value;
}
}
declare global {
interface HTMLElementTagNameMap {
'language-menu': LanguageMenuElement;
}
}
customElements.define(LanguageMenuElement.is, LanguageMenuElement);