blob: daea016e7c2b53c04ed77c2fd175b41cbf6b8501 [file] [log] [blame]
// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import './read_anything_toolbar.js';
import '/strings.m.js';
import '//read-anything-side-panel.top-chrome/shared/sp_empty_state.js';
import '//resources/cr_elements/cr_button/cr_button.js';
import '//resources/cr_elements/cr_toast/cr_toast.js';
import '../read_aloud/language_toast.js';
import {ColorChangeUpdater} from '//resources/cr_components/color_change_listener/colors_css_updater.js';
import {WebUiListenerMixinLit} from '//resources/cr_elements/web_ui_listener_mixin_lit.js';
import {assert} from '//resources/js/assert.js';
import {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js';
import {ContentController, ContentType} from '../content/content_controller.js';
import type {ContentListener, ContentState} from '../content/content_controller.js';
import {NodeStore} from '../content/node_store.js';
import type {SettingsPrefs} from '../content/read_anything_types.js';
import {SelectionController} from '../content/selection_controller.js';
import type {LanguageToastElement} from '../read_aloud/language_toast.js';
import {SpeechController} from '../read_aloud/speech_controller.js';
import type {SpeechListener} from '../read_aloud/speech_controller.js';
import {TextSegmenter} from '../read_aloud/text_segmenter.js';
import {VoiceLanguageController} from '../read_aloud/voice_language_controller.js';
import type {VoiceLanguageListener} from '../read_aloud/voice_language_controller.js';
import {VoiceNotificationManager} from '../read_aloud/voice_notification_manager.js';
import {getWordCount, minOverflowLengthToScroll} from '../shared/common.js';
import {ReadAnythingLogger, TimeFrom} from '../shared/read_anything_logger.js';
import {getCss} from './app.css.js';
import {getHtml} from './app.html.js';
import {AppStyleUpdater} from './app_style_updater.js';
import type {ReadAnythingToolbarElement} from './read_anything_toolbar.js';
const AppElementBase = WebUiListenerMixinLit(CrLitElement);
export interface AppElement {
$: {
toolbar: ReadAnythingToolbarElement,
appFlexParent: HTMLElement,
container: HTMLElement,
languageToast: LanguageToastElement,
containerScroller: HTMLElement,
};
}
export class AppElement extends AppElementBase implements SpeechListener,
VoiceLanguageListener,
ContentListener {
static get is() {
return 'read-anything-app';
}
static override get styles() {
return getCss();
}
override render() {
return getHtml.bind(this)();
}
static override get properties() {
return {
isSpeechActive_: {type: Boolean},
isAudioCurrentlyPlaying_: {type: Boolean},
enabledLangs_: {type: Array},
settingsPrefs_: {type: Object},
selectedVoice_: {type: Object},
availableVoices_: {type: Array},
previewVoicePlaying_: {type: Object},
localeToDisplayName_: {type: Object},
contentState_: {type: Object},
speechEngineLoaded_: {type: Boolean},
willDrawAgainSoon_: {type: Boolean},
pageLanguage_: {type: String},
};
}
private startTime_ = Date.now();
protected accessor contentState_: ContentState;
private isReadAloudEnabled_: boolean;
protected isDocsLoadMoreButtonVisible_: boolean = false;
// If the speech engine is considered "loaded." If it is, we should display
// the play / pause buttons normally. Otherwise, we should disable the
// Read Aloud controls until the engine has loaded in order to provide
// visual feedback that a voice is about to be spoken.
private accessor speechEngineLoaded_: boolean = true;
// Sometimes distillations are queued up while distillation is happening so
// when the current distillation finishes, we re-distill immediately. In that
// case we shouldn't allow playing speech until the next distillation to avoid
// resetting speech right after starting it.
private accessor willDrawAgainSoon_: boolean = false;
protected accessor selectedVoice_: SpeechSynthesisVoice|null = null;
// The set of languages currently enabled for use by Read Aloud. This
// includes user-enabled languages and auto-downloaded languages. The former
// are stored in preferences. The latter are not.
protected accessor enabledLangs_: string[] = [];
// All possible available voices for the current speech engine.
protected accessor availableVoices_: SpeechSynthesisVoice[] = [];
// If a preview is playing, this is set to the voice the preview is playing.
// Otherwise, this is null.
protected accessor previewVoicePlaying_: SpeechSynthesisVoice|null = null;
protected accessor localeToDisplayName_: {[locale: string]: string} = {};
protected accessor pageLanguage_: string = '';
private notificationManager_ = VoiceNotificationManager.getInstance();
private logger_: ReadAnythingLogger = ReadAnythingLogger.getInstance();
private styleUpdater_: AppStyleUpdater;
private nodeStore_: NodeStore = NodeStore.getInstance();
private voiceLanguageController_: VoiceLanguageController =
VoiceLanguageController.getInstance();
private speechController_: SpeechController = SpeechController.getInstance();
private contentController_: ContentController =
ContentController.getInstance();
private selectionController_: SelectionController =
SelectionController.getInstance();
protected accessor settingsPrefs_: SettingsPrefs = {
letterSpacing: 0,
lineSpacing: 0,
theme: 0,
speechRate: 0,
font: '',
highlightGranularity: 0,
};
protected accessor isSpeechActive_: boolean = false;
protected accessor isAudioCurrentlyPlaying_: boolean = false;
constructor() {
super();
this.logger_.logTimeFrom(TimeFrom.APP, this.startTime_, Date.now());
this.isReadAloudEnabled_ = chrome.readingMode.isReadAloudEnabled;
this.styleUpdater_ = new AppStyleUpdater(this);
this.nodeStore_.clear();
ColorChangeUpdater.forDocument().start();
TextSegmenter.getInstance().updateLanguage(
chrome.readingMode.baseLanguageForSpeech);
this.contentState_ = this.contentController_.getState();
}
override disconnectedCallback() {
super.disconnectedCallback();
// Even though disconnectedCallback isn't always called reliably in prod,
// it is called in tests, and the speech extension timeout can cause
// flakiness.
this.voiceLanguageController_.stopWaitingForSpeechExtension();
}
override connectedCallback() {
super.connectedCallback();
// onConnected should always be called first in connectedCallback to ensure
// we're not blocking onConnected on anything else during WebUI setup.
if (chrome.readingMode) {
chrome.readingMode.onConnected();
}
// Push ShowUI() callback to the event queue to allow deferred rendering
// to take place.
setTimeout(() => chrome.readingMode.shouldShowUi(), 0);
this.styleUpdater_.setMaxLineWidth();
this.contentController_.addListener(this);
if (this.isReadAloudEnabled_) {
this.speechController_.addListener(this);
this.voiceLanguageController_.addListener(this);
this.notificationManager_.addListener(this.$.languageToast);
// Clear state. We don't do this in disconnectedCallback because that's
// not always reliabled called.
this.nodeStore_.clearDomNodes();
}
this.showLoading();
this.settingsPrefs_ = {
letterSpacing: chrome.readingMode.letterSpacing,
lineSpacing: chrome.readingMode.lineSpacing,
theme: chrome.readingMode.colorTheme,
speechRate: chrome.readingMode.speechRate,
font: chrome.readingMode.fontName,
highlightGranularity: chrome.readingMode.highlightGranularity,
};
document.onselectionchange = () => {
// When Read Aloud is playing, user-selection is disabled on the Read
// Anything panel, so don't attempt to update selection, as this can
// end up clearing selection in the main part of the browser.
if (!this.contentController_.hasContent() ||
this.speechController_.isSpeechActive()) {
return;
}
const selection = this.getSelection();
this.selectionController_.onSelectionChange(selection);
if (this.isReadAloudEnabled_) {
this.speechController_.onSelectionChange();
this.contentController_.onSelectionChange(this.shadowRoot);
}
};
// Pass copy commands to main page. Copy commands will not work if they are
// disabled on the main page.
document.oncopy = () => {
chrome.readingMode.onCopy();
return false;
};
document.onkeydown = this.onKeyDown_.bind(this);
/////////////////////////////////////////////////////////////////////
// Called by ReadAnythingUntrustedPageHandler via callback router. //
/////////////////////////////////////////////////////////////////////
chrome.readingMode.updateContent = () => {
this.updateContent();
};
chrome.readingMode.updateLinks = () => {
this.updateLinks_();
};
chrome.readingMode.updateImages = () => {
this.updateImages_();
};
chrome.readingMode.onImageDownloaded = (nodeId) => {
this.contentController_.onImageDownloaded(nodeId);
};
chrome.readingMode.updateSelection = () => {
this.selectionController_.updateSelection(
this.getSelection(), this.$.container);
};
chrome.readingMode.updateVoicePackStatus =
(lang: string, status: string) => {
this.voiceLanguageController_.updateLanguageStatus(lang, status);
};
chrome.readingMode.showLoading = () => {
this.showLoading();
};
chrome.readingMode.showEmpty = () => {
this.contentController_.setEmpty();
};
chrome.readingMode.restoreSettingsFromPrefs = () => {
this.restoreSettingsFromPrefs_();
};
chrome.readingMode.languageChanged = () => {
this.languageChanged();
};
chrome.readingMode.onLockScreen = () => {
this.speechController_.onLockScreen();
};
chrome.readingMode.readingModeWillClose = () => {
this.speechController_.onReadingModeWillClose();
};
chrome.readingMode.onTtsEngineInstalled = () => {
this.voiceLanguageController_.onTtsEngineInstalled();
};
chrome.readingMode.onTabMuteStateChange = (muted: boolean) => {
this.speechController_.onTabMuteStateChange(muted);
};
chrome.readingMode.onNodeWillBeDeleted = (nodeId: number) => {
this.contentController_.onNodeWillBeDeleted(nodeId);
};
}
protected onContainerScroll_() {
this.selectionController_.onScroll();
if (this.isReadAloudEnabled_) {
this.speechController_.onScroll();
}
}
protected onContainerScrollEnd_() {
this.nodeStore_.estimateWordsSeenWithDelay();
}
showLoading() {
this.contentController_.setState(ContentType.LOADING);
if (this.isReadAloudEnabled_) {
this.speechController_.resetForNewContent();
}
}
// TODO: crbug.com/40927698 - Handle focus changes for speech, including
// updating speech state.
updateContent() {
this.willDrawAgainSoon_ = chrome.readingMode.requiresDistillation;
this.isDocsLoadMoreButtonVisible_ =
chrome.readingMode.isDocsLoadMoreButtonVisible;
// Remove all children from container. Use `replaceChildren` rather than
// setting `innerHTML = ''` in order to remove all listeners, too.
this.$.container.replaceChildren();
const newRoot = this.contentController_.updateContent();
if (newRoot) {
this.$.container.appendChild(newRoot);
}
if (!this.willDrawAgainSoon_) {
const wordCount = (newRoot && newRoot.textContent) ?
getWordCount(newRoot.textContent) :
0;
chrome.readingMode.onDistilled(wordCount);
}
}
getSelection(): Selection|null {
assert(this.shadowRoot, 'no shadow root');
return this.shadowRoot.getSelection();
}
protected updateLinks_() {
this.contentController_.updateLinks(this.shadowRoot);
}
protected updateImages_() {
this.contentController_.updateImages(this.shadowRoot);
}
protected onDocsLoadMoreButtonClick_() {
chrome.readingMode.onScrolledToBottom();
}
protected onLanguageMenuOpen_() {
this.notificationManager_.removeListener(this.$.languageToast);
}
protected onLanguageMenuClose_() {
this.notificationManager_.addListener(this.$.languageToast);
}
protected onPreviewVoice_(
event: CustomEvent<{previewVoice: SpeechSynthesisVoice}>) {
event.preventDefault();
event.stopPropagation();
this.speechController_.previewVoice(event.detail.previewVoice);
}
protected onVoiceMenuOpen_(event: CustomEvent) {
event.preventDefault();
event.stopPropagation();
this.speechController_.onVoiceMenuOpen();
}
protected onVoiceMenuClose_(event: CustomEvent) {
event.preventDefault();
event.stopPropagation();
this.speechController_.onVoiceMenuClose();
}
protected onPlayPauseClick_() {
this.speechController_.onPlayPauseToggle(this.$.container);
}
onContentStateChange(): void {
this.contentState_ = this.contentController_.getState();
}
onNewPageDrawn(): void {
this.$.containerScroller.scrollTop = 0;
}
onPlayingFromSelection(): void {
// Clear the selection so we don't keep trying to play from the same
// selection every time they press play.
this.getSelection()?.removeAllRanges();
}
onIsSpeechActiveChange(): void {
this.isSpeechActive_ = this.speechController_.isSpeechActive();
if (chrome.readingMode.linksEnabled &&
!this.speechController_.isTemporaryPause()) {
this.updateLinks_();
}
}
onIsAudioCurrentlyPlayingChange(): void {
this.isAudioCurrentlyPlaying_ =
this.speechController_.isAudioCurrentlyPlaying();
}
onEngineStateChange(): void {
this.speechEngineLoaded_ = this.speechController_.isEngineLoaded();
}
onPreviewVoicePlaying(): void {
this.previewVoicePlaying_ = this.speechController_.getPreviewVoicePlaying();
}
onEnabledLangsChange(): void {
this.enabledLangs_ = this.voiceLanguageController_.getEnabledLangs();
}
onAvailableVoicesChange(): void {
this.availableVoices_ = this.voiceLanguageController_.getAvailableVoices();
this.localeToDisplayName_ =
this.voiceLanguageController_.getDisplayNamesForLocaleCodes();
}
onCurrentVoiceChange(): void {
this.selectedVoice_ = this.voiceLanguageController_.getCurrentVoice();
this.speechController_.onSpeechSettingsChange();
}
protected onNextGranularityClick_() {
this.speechController_.onNextGranularityClick();
}
protected onPreviousGranularityClick_() {
this.speechController_.onPreviousGranularityClick();
}
protected onSelectVoice_(
event: CustomEvent<{selectedVoice: SpeechSynthesisVoice}>) {
event.preventDefault();
event.stopPropagation();
this.speechController_.onVoiceSelected(event.detail.selectedVoice);
}
protected onVoiceLanguageToggle_(event: CustomEvent<{language: string}>) {
event.preventDefault();
event.stopPropagation();
this.voiceLanguageController_.onLanguageToggle(event.detail.language);
}
protected onSpeechRateChange_() {
this.speechController_.onSpeechSettingsChange();
}
private restoreSettingsFromPrefs_() {
if (this.isReadAloudEnabled_) {
this.voiceLanguageController_.restoreFromPrefs();
}
this.settingsPrefs_ = {
...this.settingsPrefs_,
letterSpacing: chrome.readingMode.letterSpacing,
lineSpacing: chrome.readingMode.lineSpacing,
theme: chrome.readingMode.colorTheme,
speechRate: chrome.readingMode.speechRate,
font: chrome.readingMode.fontName,
highlightGranularity: chrome.readingMode.highlightGranularity,
};
this.styleUpdater_.setAllTextStyles();
// TODO: crbug.com/40927698 - Remove this call. Using this.settingsPrefs_
// should replace this direct call to the toolbar.
this.$.toolbar.restoreSettingsFromPrefs();
}
protected onLineSpacingChange_() {
this.styleUpdater_.setLineSpacing();
}
protected onLetterSpacingChange_() {
this.styleUpdater_.setLetterSpacing();
}
protected onFontChange_() {
this.styleUpdater_.setFont();
}
protected onFontSizeChange_() {
this.styleUpdater_.setFontSize();
}
protected onThemeChange_() {
this.styleUpdater_.setTheme();
}
protected onResetToolbar_() {
this.styleUpdater_.resetToolbar();
}
protected onToolbarOverflow_(event: CustomEvent<{overflowLength: number}>) {
const shouldScroll =
(event.detail.overflowLength >= minOverflowLengthToScroll);
this.styleUpdater_.overflowToolbar(shouldScroll);
}
protected onHighlightChange_(event: CustomEvent<{data: number}>) {
this.speechController_.onHighlightGranularityChange(event.detail.data);
// Apply highlighting changes to the DOM.
this.styleUpdater_.setHighlight();
}
languageChanged() {
this.pageLanguage_ = chrome.readingMode.baseLanguageForSpeech;
if (this.isReadAloudEnabled_) {
this.voiceLanguageController_.onPageLanguageChanged();
TextSegmenter.getInstance().updateLanguage(this.pageLanguage_);
}
}
protected computeHasContent(): boolean {
return this.contentState_.type === ContentType.HAS_CONTENT;
}
protected computeIsReadAloudPlayable(): boolean {
return (this.contentState_.type === ContentType.HAS_CONTENT) &&
this.speechEngineLoaded_ && !!this.selectedVoice_ &&
!this.willDrawAgainSoon_;
}
protected onKeyDown_(e: KeyboardEvent) {
if (e.key === 'k') {
e.stopPropagation();
e.preventDefault();
this.speechController_.onPlayPauseKeyPress(this.$.container);
}
}
}
declare global {
interface HTMLElementTagNameMap {
'read-anything-app': AppElement;
}
}
customElements.define(AppElement.is, AppElement);