diff --git a/browser/speedreader/speedreader_tab_helper.cc b/browser/speedreader/speedreader_tab_helper.cc index 39a40ad179ef..dbb9b3dd33ac 100644 --- a/browser/speedreader/speedreader_tab_helper.cc +++ b/browser/speedreader/speedreader_tab_helper.cc @@ -26,6 +26,7 @@ #include "brave/components/speedreader/speedreader_rewriter_service.h" #include "brave/components/speedreader/speedreader_service.h" #include "brave/components/speedreader/speedreader_util.h" +#include "brave/components/speedreader/tts_player.h" #include "chrome/common/chrome_isolated_world_ids.h" #include "components/dom_distiller/content/browser/distillable_page_utils.h" #include "components/grit/brave_components_resources.h" @@ -204,6 +205,17 @@ void SpeedreaderTabHelper::OnShowOriginalPage() { TransitStateTo(DistillStates::ViewOriginal()); } +void SpeedreaderTabHelper::OnTtsPlayPause(int paragraph_index) { + auto& tts_controller = + speedreader::TtsPlayer::GetInstance()->GetControllerFor(web_contents()); + if (tts_controller.IsPlaying() && + tts_controller.IsPlayingRequestedWebContents(paragraph_index)) { + tts_controller.Pause(); + } else { + tts_controller.Play(paragraph_index); + } +} + void SpeedreaderTabHelper::ClearPersistedData() { if (auto* entry = web_contents()->GetController().GetLastCommittedEntry()) { SpeedreaderExtendedInfoHandler::ClearPersistedData(entry); @@ -234,8 +246,10 @@ void SpeedreaderTabHelper::ProcessNavigation( auto* rewriter_service = g_brave_browser_process->speedreader_rewriter_service(); + auto* nav_entry = navigation_handle->GetNavigationEntry(); + const bool url_looks_readable = - rewriter_service && + nav_entry && !nav_entry->IsViewSourceMode() && rewriter_service && rewriter_service->URLLooksReadable(navigation_handle->GetURL()); const bool enabled_for_site = @@ -302,19 +316,22 @@ void SpeedreaderTabHelper::DidStopLoading() { void SpeedreaderTabHelper::DOMContentLoaded( content::RenderFrameHost* render_frame_host) { - if (!render_frame_host->IsInPrimaryMainFrame()) { - return; - } - - if (!IsPageDistillationAllowed()) { + if (!render_frame_host->IsInPrimaryMainFrame() || + !DistillStates::IsDistilled(distill_state_)) { return; - } else { - UpdateUI(); } + UpdateUI(); - static base::NoDestructor kSpeedreaderData(GetSpeedreaderData( - {{"showOriginalLinkText", IDS_READER_MODE_SHOW_ORIGINAL_PAGE_LINK}, - {"minutesText", IDS_READER_MODE_MINUTES_TEXT}})); + static base::NoDestructor kSpeedreaderData( + GetSpeedreaderData({ + {"showOriginalLinkText", IDS_READER_MODE_SHOW_ORIGINAL_PAGE_LINK}, + {"minutesText", IDS_READER_MODE_MINUTES_TEXT}, +#if defined(IDS_READER_MODE_TEXT_TO_SPEECH_PLAY_PAUSE) + { + "playButtonTitle", IDS_READER_MODE_TEXT_TO_SPEECH_PLAY_PAUSE + } +#endif + })); static base::NoDestructor kJsScript(base::UTF8ToUTF16( ui::ResourceBundle::GetSharedInstance().LoadDataResourceString( diff --git a/browser/speedreader/speedreader_tab_helper.h b/browser/speedreader/speedreader_tab_helper.h index b5aa03709170..b1fb7ed23b9c 100644 --- a/browser/speedreader/speedreader_tab_helper.h +++ b/browser/speedreader/speedreader_tab_helper.h @@ -98,6 +98,7 @@ class SpeedreaderTabHelper // mojom::SpeedreaderHost: void OnShowOriginalPage() override; + void OnTtsPlayPause(int index) override; private: friend class content::WebContentsUserData; diff --git a/browser/ui/webui/speedreader/speedreader_toolbar_data_handler_impl.cc b/browser/ui/webui/speedreader/speedreader_toolbar_data_handler_impl.cc index f96d0d9604b9..4207b962e699 100644 --- a/browser/ui/webui/speedreader/speedreader_toolbar_data_handler_impl.cc +++ b/browser/ui/webui/speedreader/speedreader_toolbar_data_handler_impl.cc @@ -68,6 +68,11 @@ SpeedreaderToolbarDataHandlerImpl::SpeedreaderToolbarDataHandlerImpl( speedreader::TtsPlayer::GetInstance()->set_delegate( std::make_unique()); + + const auto& tts_settings = GetSpeedreaderService()->GetTtsSettings(); + speedreader::TtsPlayer::GetInstance()->SetSpeed( + static_cast(tts_settings.speed) / 100.0); + speedreader::TtsPlayer::GetInstance()->SetVoice(tts_settings.voice); } SpeedreaderToolbarDataHandlerImpl::~SpeedreaderToolbarDataHandlerImpl() = diff --git a/components/speedreader/common/speedreader.mojom b/components/speedreader/common/speedreader.mojom index 221122645719..64df9a0aa510 100644 --- a/components/speedreader/common/speedreader.mojom +++ b/components/speedreader/common/speedreader.mojom @@ -8,4 +8,7 @@ module speedreader.mojom; interface SpeedreaderHost { // The browser handler for clicking on the "Show original page" link. OnShowOriginalPage(); + + // The browser handler for clicking on the "Play/Pause" button. + OnTtsPlayPause(int32 paragraph_index); }; diff --git a/components/speedreader/renderer/speedreader_js_handler.cc b/components/speedreader/renderer/speedreader_js_handler.cc index 6bfde1338e1c..84e7722f4dcf 100644 --- a/components/speedreader/renderer/speedreader_js_handler.cc +++ b/components/speedreader/renderer/speedreader_js_handler.cc @@ -55,13 +55,15 @@ void SpeedreaderJSHandler::Install( v8::Local speedreader_value = global->Get(context, gin::StringToV8(isolate, kSpeedreader)) .ToLocalChecked(); - if (!speedreader_value->IsUndefined()) + if (!speedreader_value->IsUndefined()) { return; + } gin::Handle handler = gin::CreateHandle(isolate, new SpeedreaderJSHandler(std::move(owner))); - if (handler.IsEmpty()) + if (handler.IsEmpty()) { return; + } v8::PropertyDescriptor desc(handler.ToV8(), false); desc.set_configurable(false); @@ -75,13 +77,15 @@ void SpeedreaderJSHandler::Install( gin::ObjectTemplateBuilder SpeedreaderJSHandler::GetObjectTemplateBuilder( v8::Isolate* isolate) { return gin::Wrappable::GetObjectTemplateBuilder(isolate) - .SetMethod("showOriginalPage", &SpeedreaderJSHandler::ShowOriginalPage); + .SetMethod("showOriginalPage", &SpeedreaderJSHandler::ShowOriginalPage) + .SetMethod("ttsPlayPause", &SpeedreaderJSHandler::TtsPlayPause); } void SpeedreaderJSHandler::ShowOriginalPage(v8::Isolate* isolate) { DCHECK(isolate); - if (!owner_) + if (!owner_) { return; + } mojo::AssociatedRemote speedreader_host; owner_->render_frame()->GetRemoteAssociatedInterfaces()->GetInterface( @@ -92,4 +96,20 @@ void SpeedreaderJSHandler::ShowOriginalPage(v8::Isolate* isolate) { } } +void SpeedreaderJSHandler::TtsPlayPause(v8::Isolate* isolate, + int paragraph_index) { + DCHECK(isolate); + if (!owner_) { + return; + } + + mojo::AssociatedRemote speedreader_host; + owner_->render_frame()->GetRemoteAssociatedInterfaces()->GetInterface( + &speedreader_host); + + if (speedreader_host.is_bound()) { + speedreader_host->OnTtsPlayPause(paragraph_index); + } +} + } // namespace speedreader diff --git a/components/speedreader/renderer/speedreader_js_handler.h b/components/speedreader/renderer/speedreader_js_handler.h index adb190bfaeeb..c2d78ea3137b 100644 --- a/components/speedreader/renderer/speedreader_js_handler.h +++ b/components/speedreader/renderer/speedreader_js_handler.h @@ -36,6 +36,8 @@ class SpeedreaderJSHandler final : public gin::Wrappable { // A function to be called from JS void ShowOriginalPage(v8::Isolate* isolate); + void TtsPlayPause(v8::Isolate* isolate, int index); + base::WeakPtr owner_; }; diff --git a/components/speedreader/resources/panel/components/tts-control/index.tsx b/components/speedreader/resources/panel/components/tts-control/index.tsx index 9389cb9b9ab9..38d653b9aa0e 100644 --- a/components/speedreader/resources/panel/components/tts-control/index.tsx +++ b/components/speedreader/resources/panel/components/tts-control/index.tsx @@ -20,15 +20,22 @@ interface TtsControlProps { function TtsControl(props: TtsControlProps) { const [voices, setVoices] = React.useState(speechSynthesis.getVoices()) - speechSynthesis.onvoiceschanged = () => { - setVoices(speechSynthesis.getVoices()) - } - const [playbackState, setPlaybackState] = React.useState(PlaybackState.kStopped) - getToolbarAPI().dataHandler.getPlaybackState().then(res => setPlaybackState(res.playbackState)) - getToolbarAPI().eventsRouter.setPlaybackState.addListener((state: PlaybackState) => { - setPlaybackState(state) - }) + + React.useEffect(() => { + const updateVoices = () => { + setVoices(speechSynthesis.getVoices().filter((v) => { + return navigator.languages.find((l) => { return v.lang.startsWith(l) }) + })) + } + speechSynthesis.onvoiceschanged = updateVoices + window.onlanguagechange = updateVoices + + getToolbarAPI().dataHandler.getPlaybackState().then(res => setPlaybackState(res.playbackState)) + getToolbarAPI().eventsRouter.setPlaybackState.addListener((state: PlaybackState) => { + setPlaybackState(state) + }) + }, []) return ( diff --git a/components/speedreader/resources/speedreader-desktop.css b/components/speedreader/resources/speedreader-desktop.css index 5dbcacfbeecc..8520ed96a811 100644 --- a/components/speedreader/resources/speedreader-desktop.css +++ b/components/speedreader/resources/speedreader-desktop.css @@ -548,7 +548,7 @@ iframe { html, html[data-theme='light'] { - position: relative; !important; + position: relative !important; scrollbar-gutter: stable; background-color: var(--background-color); @@ -647,13 +647,19 @@ html[data-content-style='text-only'] img { display: none !important; } -.tts-highlighted, .tts-highlighted * { +.tts-highlighted, +.tts-highlighted * { position: relative; } @keyframes tts-fade-in { - 0% { opacity: 0; } - 100% { opacity: 1; } + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } } .tts-highlighted::after { @@ -675,3 +681,43 @@ html[data-content-style='text-only'] img { pointer-events: none; } + +[tts-paragraph-index] { + position: relative; +} + +[tts-paragraph-index]:hover>.tts-paragraph-player { + opacity: 1; +} + +.tts-paragraph-player { + opacity: 0; + position: absolute; + + left: -4rem; + width: 4rem; + display: flex; + flex-direction: column; + flex-wrap: wrap; + overflow: hidden; + height: max(2rem, 100%); + flex: none; + gap: 1rem; +} + +.tts-paragraph-player-button { + -webkit-mask-position: left; + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: 2rem; + + position: relative; + display: inline-block; + background-color: var(--summary-text-color); + cursor: pointer; + width: 4rem; + height: 2rem; +} + +.tts-play-icon { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Cpath fill='%23687485' fill-rule='evenodd' d='M7.105 5.62a.2.2 0 0 0-.305.17v12.42a.2.2 0 0 0 .305.17l10.092-6.21a.2.2 0 0 0 0-.34L7.105 5.62ZM5.2 5.79c0-1.409 1.544-2.271 2.743-1.533l10.092 6.21a1.8 1.8 0 0 1 0 3.066l-10.092 6.21C6.744 20.481 5.2 19.62 5.2 18.21V5.79Z' clip-rule='evenodd'/%3E%3C/svg%3E"); +} \ No newline at end of file diff --git a/components/speedreader/resources/speedreader-desktop.js b/components/speedreader/resources/speedreader-desktop.js index 06e641c5adf3..456359a9590b 100644 --- a/components/speedreader/resources/speedreader-desktop.js +++ b/components/speedreader/resources/speedreader-desktop.js @@ -43,12 +43,28 @@ const calculateReadtime = () => { const defaultSpeedreaderData = { showOriginalLinkText: 'View original', + playButtonTitle: 'Play/Pause', averageWordsPerMinute: 265, minutesText: 'min. read', } -const extractTextToSpeak = () => { - const textTags = ['P', 'DIV', 'MAIN', 'ARTICLE', 'H1', 'H2', 'H3', 'H4', 'H5', 'STRONG', 'BLOCKQUOTE' ] +const getTextContent = (element) => { + if (!element) { + return null + } + const text = element.innerText.replace(/\n|\r +/g, ' ').trim() + if (text.length > 0) { + return text + } + return null +} + +const initTextToSpeak = () => { + if (navigator.userAgentData.mobile) { + return + } + + const textTags = ['P', 'DIV', 'MAIN', 'ARTICLE', 'H1', 'H2', 'H3', 'H4', 'H5', 'STRONG', 'BLOCKQUOTE', 'EM'] const extractParagraphs = (node) => { let paragraphs = [] @@ -68,40 +84,53 @@ const extractTextToSpeak = () => { return paragraphs } - const getTextContent = (element) => { - if (!element) { - return null + let textToSpeak = 0 + + const makeParagraph = (elem) => { + if (!elem) { + return false } - const text = element.innerText.replace(/\n|\r +/g, ' ').trim() - if (text.length > 0) { - return text + const text = getTextContent(elem) + if (text) { + elem.setAttribute('tts-paragraph-index', textToSpeak++) + return true } - return null + return false } - const textToSpeak = [] - const title = $(metaDataDivId)?.querySelector('.title') - const titleText = getTextContent(title) - if (titleText) { - title.setAttribute('tts-paragraph-index', textToSpeak.length) - textToSpeak.push(titleText) + const createPlayer = (p) => { + const player = document.createElement('span') + player.classList.add('tts-paragraph-player') + const playButton = document.createElement('span') + playButton.classList.add('tts-paragraph-player-button', 'tts-play-icon') + playButton.title = speedreaderData.playButtonTitle + playButton.onclick = (ev) => { + window.speedreader.ttsPlayPause(parseInt(p.getAttribute('tts-paragraph-index'))) + } + player.insertAdjacentElement('afterbegin', playButton) + p.insertAdjacentElement('afterbegin', player) } - const paragraphs = extractParagraphs($(contentDivId)) + makeParagraph($(metaDataDivId)?.querySelector('.title')) - for (const p of paragraphs) { - const text = getTextContent(p) - if (text) { - p.setAttribute('tts-paragraph-index', textToSpeak.length) - textToSpeak.push(text) + extractParagraphs($(contentDivId)).forEach((p) => { + if (makeParagraph(p)) { + createPlayer(p) } - } + }) +} + +const extractTextToSpeak = () => { + const paragraphs = Array.from(document.querySelectorAll('[tts-paragraph-index]')) + .map((p) => { + return getTextContent(p) + }) return { title: document.title, author: $(metaDataDivId)?.querySelector('.author')?.textContent, desciption: $(metaDataDivId)?.querySelector('.subhead')?.textContent, - paragraphs: textToSpeak + paragraphs: paragraphs } } @@ -125,6 +154,7 @@ const main = () => { initShowOriginalLink() calculateReadtime() + initTextToSpeak() } (() => { main() })() diff --git a/components/speedreader/tts_player.cc b/components/speedreader/tts_player.cc index 4337a87d3d02..1011d14289cb 100644 --- a/components/speedreader/tts_player.cc +++ b/components/speedreader/tts_player.cc @@ -81,21 +81,30 @@ bool TtsPlayer::Controller::IsPlaying() const { return tts->IsSpeaking(); } -bool TtsPlayer::Controller::IsPlayingRequestedWebContents() const { +bool TtsPlayer::Controller::IsPlayingRequestedWebContents( + absl::optional paragraph_index) const { + if (paragraph_index.has_value() && paragraph_index != paragraph_index_) { + return false; + } return playing_web_contents_ == request_web_contents_; } -void TtsPlayer::Controller::Play() { +void TtsPlayer::Controller::Play(absl::optional paragraph_index) { DCHECK(request_web_contents_); if (IsPlayingRequestedWebContents()) { Observe(playing_web_contents_); + if (paragraph_index.has_value() && paragraph_index != paragraph_index_) { + paragraph_index_ = paragraph_index.value(); + reading_start_position_ = 0; + reading_position_ = 0; + } Resume(true); } else { Stop(); TtsPlayer::GetInstance()->delegate_->RequestReadingContent( request_web_contents_, base::BindOnce(&Controller::OnContentReady, base::Unretained(this), - request_web_contents_)); + request_web_contents_, std::move(paragraph_index))); } } @@ -273,6 +282,7 @@ void TtsPlayer::Controller::OnTtsEvent(content::TtsUtterance* utterance, } void TtsPlayer::Controller::OnContentReady(content::WebContents* web_contents, + absl::optional paragraph_index, base::Value content) { if (!content.is_dict() || web_contents != request_web_contents_) { return; @@ -281,7 +291,7 @@ void TtsPlayer::Controller::OnContentReady(content::WebContents* web_contents, Observe(playing_web_contents_); - paragraph_index_ = 0; + paragraph_index_ = paragraph_index.value_or(0); reading_content_ = std::move(content); reading_position_ = 0; reading_start_position_ = 0; diff --git a/components/speedreader/tts_player.h b/components/speedreader/tts_player.h index 079f81307004..d72acabf7dcd 100644 --- a/components/speedreader/tts_player.h +++ b/components/speedreader/tts_player.h @@ -17,6 +17,7 @@ #include "base/values.h" #include "content/public/browser/tts_utterance.h" #include "content/public/browser/web_contents_observer.h" +#include "third_party/abseil-cpp/absl/types/optional.h" namespace content { class WebContents; @@ -58,9 +59,10 @@ class TtsPlayer { public content::UtteranceEventDelegate { public: bool IsPlaying() const; - bool IsPlayingRequestedWebContents() const; + bool IsPlayingRequestedWebContents( + absl::optional paragraph_index = absl::nullopt) const; - void Play(); + void Play(absl::optional paragraph_index = absl::nullopt); void Pause(); void Resume(); void Stop(); @@ -92,6 +94,7 @@ class TtsPlayer { const std::string& error_message) override; void OnContentReady(content::WebContents* web_contents, + absl::optional paragraph_index, base::Value content); raw_ptr owner_ = nullptr;