chromium/chrome/browser/resources/side_panel/read_anything/voice_selection_menu.ts

// 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_lazy_render/cr_lazy_render_lit.js';
import '//resources/cr_elements/cr_icon/cr_icon.js';
import '//resources/cr_elements/icons_lit.html.js';
import '//resources/cr_elements/cr_action_menu/cr_action_menu.js';
import '//resources/cr_elements/cr_lazy_render/cr_lazy_render.js';
import './language_menu.js';

import type {CrActionMenuElement} from '//resources/cr_elements/cr_action_menu/cr_action_menu.js';
import type {CrLazyRenderLitElement} from '//resources/cr_elements/cr_lazy_render/cr_lazy_render_lit.js';
import {WebUiListenerMixinLit} from '//resources/cr_elements/web_ui_listener_mixin_lit.js';
import {assert} from '//resources/js/assert.js';
import {loadTimeData} from '//resources/js/load_time_data.js';
import type {PropertyValues} from '//resources/lit/v3_0/lit.rollup.js';
import {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js';

import {openMenu, ToolbarEvent} from './common.js';
import type {LanguageMenuElement} from './language_menu.js';
import {ReadAloudSettingsChange} from './metrics_browser_proxy.js';
import {ReadAnythingLogger} from './read_anything_logger.js';
import {areVoicesEqual, convertLangOrLocaleForVoicePackManager, isNatural, VoiceClientSideStatusCode} from './voice_language_util.js';
import {getCss} from './voice_selection_menu.css.js';
import {getHtml} from './voice_selection_menu.html.js';

export interface VoiceSelectionMenuElement {
  $: {
    voiceSelectionMenu: CrLazyRenderLitElement<CrActionMenuElement>,
    languageMenu: LanguageMenuElement,
  };
}

interface VoiceDropdownGroup {
  language: string;
  voices: VoiceDropdownItem[];
}

interface VoiceDropdownItem {
  title: string;
  voice: SpeechSynthesisVoice;
  selected: boolean;
  previewPlaying: boolean;
  // This ID is currently just used for testing purposes and does not ensure
  // uniqueness
  id: string;
}

const VoiceSelectionMenuElementBase = WebUiListenerMixinLit(CrLitElement);

export class VoiceSelectionMenuElement extends VoiceSelectionMenuElementBase {
  static get is() {
    return 'voice-selection-menu';
  }

  static override get styles() {
    return getCss();
  }

  override render() {
    return getHtml.bind(this)();
  }

  static override get properties() {
    return {
      selectedVoice: {type: Object},
      availableVoices: {type: Array},
      enabledLangs: {type: Array},
      previewVoicePlaying: {type: Object},
      voicePackInstallStatus: {type: Object},
      isSpeechActive: {type: Boolean},
      localeToDisplayName: {type: Object},
      lastDownloadedLang: {type: String},
      showLanguageMenuDialog_: {type: Boolean},
      downloadingMessages_: {type: Boolean},
      voiceGroups_: {type: Object},
    };
  }

  voicePackInstallStatus: {[language: string]: VoiceClientSideStatusCode} = {};
  selectedVoice?: SpeechSynthesisVoice;
  localeToDisplayName: {[lang: string]: string} = {};
  previewVoicePlaying?: SpeechSynthesisVoice;
  enabledLangs: string[] = [];
  availableVoices: SpeechSynthesisVoice[] = [];
  isSpeechActive: boolean = false;
  lastDownloadedLang?: string;

  protected downloadingMessages_: string[] = [];
  protected voiceGroups_: VoiceDropdownGroup[] = [];
  protected showLanguageMenuDialog_: boolean = false;

  private voicePlayingWhenMenuOpened_: boolean = false;
  private readonly spBodyPadding_ = Number.parseInt(
      window.getComputedStyle(document.body)
          .getPropertyValue('--sp-body-padding'),
      10);
  private logger_: ReadAnythingLogger = ReadAnythingLogger.getInstance();


  override willUpdate(changedProperties: PropertyValues<this>) {
    super.willUpdate(changedProperties);

    if (changedProperties.has('voicePackInstallStatus')) {
      this.downloadingMessages_ = this.computeDownloadingMessages_();
    }

    if (changedProperties.has('selectedVoice') ||
        changedProperties.has('availableVoices') ||
        changedProperties.has('enabledLangs') ||
        changedProperties.has('previewVoicePlaying') ||
        changedProperties.has('localeToDisplayName')) {
      this.voiceGroups_ = this.computeVoiceDropdown_();
    }
  }


  onVoiceSelectionMenuClick(targetElement: HTMLElement) {
    this.voicePlayingWhenMenuOpened_ = this.isSpeechActive;

    const menu = this.$.voiceSelectionMenu.get();
    openMenu(menu, targetElement, {
      minX: this.spBodyPadding_,
      maxX: document.body.clientWidth - this.spBodyPadding_,
    });

    // Scroll to the selected voice.
    requestAnimationFrame(() => {
      const selectedItem =
          menu.querySelector<HTMLElement>('.item-invisible-false');
      selectedItem?.scrollIntoViewIfNeeded();
    });
  }

  private computeEnabledVoices_(): SpeechSynthesisVoice[] {
    if (!this.availableVoices || !this.enabledLangs) {
      return [];
    }
    const enablesLangsLowerCase: Set<string> =
        new Set(this.enabledLangs.map(lang => lang.toLowerCase()));
    return this.availableVoices.filter(
        ({lang}) => enablesLangsLowerCase.has(lang.toLowerCase()));
  }

  private getLangDisplayName(lang: string): string {
    const langLower = lang.toLowerCase();
    return this.localeToDisplayName[langLower] || langLower;
  }

  private computeVoiceDropdown_(): VoiceDropdownGroup[] {
    const enabledVoices = this.computeEnabledVoices_();
    if (!enabledVoices) {
      return [];
    }
    const languageToVoices =
        enabledVoices.reduce((languageToDropdownItems, voice) => {
          const dropdownItem: VoiceDropdownItem = {
            title: voice.name,
            voice,
            id: this.stringToHtmlTestId_(voice.name),
            selected: areVoicesEqual(this.selectedVoice, voice),
            previewPlaying: areVoicesEqual(this.previewVoicePlaying, voice),
          };

          const lang = this.getLangDisplayName(voice.lang);

          if (languageToDropdownItems[lang]) {
            languageToDropdownItems[lang].push(dropdownItem);
          } else {
            languageToDropdownItems[lang] = [dropdownItem];
          }

          return languageToDropdownItems;
        }, {} as {[language: string]: VoiceDropdownItem[]});

    for (const lang of Object.keys(languageToVoices)) {
      languageToVoices[lang].sort(voiceQualityRankComparator);
    }

    return Object.entries(languageToVoices).map(([
                                                  language,
                                                  voices,
                                                ]) => ({language, voices}));
  }

  // This ID does not ensure uniqueness and is just used for testing purposes.
  private stringToHtmlTestId_(s: string): string {
    return s.replace(/\s/g, '-').replace(/[()]/g, '');
  }

  protected onVoiceSelectClick_(e: Event) {
    this.logger_.logSpeechSettingsChange(
        ReadAloudSettingsChange.VOICE_NAME_CHANGE);

    const selectedVoice = this.getVoiceItemForEvent_(e).voice;
    this.fire(ToolbarEvent.VOICE, {selectedVoice});
  }

  protected onVoicePreviewClick_(e: Event) {
    // Because the preview button is layered onto the voice-selection button,
    // the onVoiceSelectClick_() listener is also subscribed to this event. This
    // line is to make sure that the voice-selection callback is not triggered.
    e.stopImmediatePropagation();

    const dropdownItem = this.getVoiceItemForEvent_(e);
    this.fire(
        ToolbarEvent.PLAY_PREVIEW,
        // If preview is currently playing, we pass null to indicate the audio
        // should be paused.
        dropdownItem.previewPlaying ? null :
                                      {previewVoice: dropdownItem.voice});
  }

  protected openLanguageMenu_() {
    this.showLanguageMenuDialog_ = true;
  }

  protected onLanguageMenuClose_(event: CustomEvent) {
    event.preventDefault();
    event.stopPropagation();

    this.showLanguageMenuDialog_ = false;
  }

  protected onClose_() {
    this.dispatchEvent(new CustomEvent('voice-menu-close', {
      bubbles: true,
      composed: true,
      detail: {
        voicePlayingWhenMenuOpened: this.voicePlayingWhenMenuOpened_,
      },
    }));
  }

  protected onVoiceMenuKeyDown_(e: KeyboardEvent) {
    const currentElement = e.target as HTMLElement;
    assert(currentElement, 'no key target');
    const targetIsVoiceOption =
        (currentElement.classList.contains('dropdown-voice-selection-button')) ?
        true :
        false;
    const targetIsPreviewButton =
        (currentElement.id === 'preview-icon') ? true : false;

    // For voice options, only handle the right arrow - everything else is
    // default
    if (targetIsVoiceOption && !['ArrowRight'].includes(e.key)) {
      return;
    }
    // For a voice preview, handle up, down and left arrows
    if (targetIsPreviewButton &&
        !['ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
      return;
    }

    // When the menu first opens, the target is the whole menu.
    // In that case, use default behavior.
    if (!targetIsVoiceOption && !targetIsPreviewButton) return;

    e.preventDefault();

    if (targetIsVoiceOption) {
      // From a voice option, go to its preview button
      const visiblePreviewButton =
          currentElement.querySelector<HTMLElement>('#preview-icon');
      assert(visiblePreviewButton, 'can\'t find preview button');
      visiblePreviewButton!.focus();
    }
    // This action is also handled by the menu itself
    // For left arrow, this takes us to the voice being previewed,
    // For up and down arrows this is combined with the default up/down
    // action, taking us to the next or previous voice.
    currentElement.parentElement!.focus();
  }

  protected previewLabel_(previewPlaying: boolean): string {
    if (previewPlaying) {
      return loadTimeData.getString('stopLabel');
    } else {
      return loadTimeData.getString('previewTooltip');
    }
  }

  protected previewAriaLabel_(previewPlaying: boolean, voiceName: string):
      string {
    let nameSuffix = '';
    if (voiceName.length > 0) {
      nameSuffix = ' ' + voiceName;
    }
    if (previewPlaying) {
      return loadTimeData.getString('stopLabel') + nameSuffix;
    } else {
      return loadTimeData.getStringF(
          'previewVoiceAccessibilityLabel', nameSuffix);
    }
  }

  protected previewIcon_(previewPlaying: boolean): string {
    if (previewPlaying) {
      return 'read-anything-20:stop-circle';
    } else {
      return 'read-anything-20:play-circle';
    }
  }

  private getVoiceItemForEvent_(e: Event): VoiceDropdownItem {
    const groupIndex = Number.parseInt(
        (e.currentTarget as HTMLElement).dataset['groupIndex']!);
    const voiceIndex = Number.parseInt(
        (e.currentTarget as HTMLElement).dataset['voiceIndex']!);

    return this.voiceGroups_[groupIndex].voices[voiceIndex];
  }

  private computeDownloadingMessages_(): string[] {
    return Object.entries(this.voicePackInstallStatus)
        .filter(
            ([_, status]) => status ===
                    VoiceClientSideStatusCode.INSTALLED_AND_UNAVAILABLE ||
                status === VoiceClientSideStatusCode.SENT_INSTALL_REQUEST)
        .map(([lang, _]) => this.getDisplayNameForLocale(lang))
        .filter(possibleName => possibleName.length > 0)
        .map(
            displayName => loadTimeData.getStringF(
                'readingModeVoiceMenuDownloading', displayName));
  }

  private getDisplayNameForLocale(language: string): string {
    const voicePackLang = convertLangOrLocaleForVoicePackManager(language);
    return voicePackLang ? chrome.readingMode.getDisplayNameForLocale(
                               voicePackLang, voicePackLang) :
                           '';
  }
}

function voiceQualityRankComparator(
    voice1: VoiceDropdownItem,
    voice2: VoiceDropdownItem,
    ): number {
  if (isNatural(voice1.voice) && isNatural(voice2.voice)) {
    return 0;
  }

  if (!isNatural(voice1.voice) && !isNatural(voice2.voice)) {
    return 0;
  }

  // voice1 is a Natural voice and voice2 is not
  if (isNatural(voice1.voice)) {
    return -1;
  }

  // voice2 is a Natural voice and voice1 is not
  return 1;
}

declare global {
  interface HTMLElementTagNameMap {
    'voice-selection-menu': VoiceSelectionMenuElement;
  }
}

customElements.define(VoiceSelectionMenuElement.is, VoiceSelectionMenuElement);