diff --git a/imgsrc/srv/hourglass.svg b/imgsrc/srv/hourglass.svg new file mode 100644 index 0000000000..52fe69b175 --- /dev/null +++ b/imgsrc/srv/hourglass.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/imgsrc/srv/pause.svg b/imgsrc/srv/pause.svg new file mode 100644 index 0000000000..7ca81f89c3 --- /dev/null +++ b/imgsrc/srv/pause.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/imgsrc/srv/play.svg b/imgsrc/srv/play.svg new file mode 100644 index 0000000000..9c56408a64 --- /dev/null +++ b/imgsrc/srv/play.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/calibre/gui2/viewer/web_view.py b/src/calibre/gui2/viewer/web_view.py index 408f02c25c..84b2fc4376 100644 --- a/src/calibre/gui2/viewer/web_view.py +++ b/src/calibre/gui2/viewer/web_view.py @@ -269,6 +269,7 @@ class ViewerBridge(Bridge): highlights_changed = from_js(object) open_url = from_js(object) speak_simple_text = from_js(object) + tts = from_js(object, object) create_view = to_js() start_book_load = to_js() @@ -519,6 +520,7 @@ class WebView(RestartingWebEngineView): self.bridge.highlights_changed.connect(self.highlights_changed) self.bridge.open_url.connect(safe_open_url) self.bridge.speak_simple_text.connect(self.speak_simple_text) + self.bridge.tts.connect(self.tts_action) self.bridge.export_shortcut_map.connect(self.set_shortcut_map) self.shortcut_map = {} self.bridge.report_cfi.connect(self.call_callback) @@ -553,6 +555,9 @@ class WebView(RestartingWebEngineView): except TTSSystemUnavailable as err: return error_dialog(self, _('Text-to-Speech unavailable'), str(err), show=True) + def tts_action(self, action, data): + pass + def shutdown(self): if self._tts_client is not None: self._tts_client.shutdown() diff --git a/src/pyj/read_book/iframe.pyj b/src/pyj/read_book/iframe.pyj index 3774e2ce7b..37a58a6480 100644 --- a/src/pyj/read_book/iframe.pyj +++ b/src/pyj/read_book/iframe.pyj @@ -4,7 +4,7 @@ from __python__ import bound_methods, hash_literals import traceback from gettext import gettext as _ -from select import move_end_of_selection, selection_extents, word_at_point +from select import move_end_of_selection, selection_extents, word_at_point, range_for_tts from fs_images import fix_fullscreen_svg_images from iframe_comm import IframeClient @@ -143,6 +143,7 @@ class IframeBoss: 'show_search_result': self.show_search_result, 'handle_navigation_shortcut': self.on_handle_navigation_shortcut, 'annotations': self.annotations_msg_received, + 'tts': self.tts_msg_received, 'copy_selection': self.copy_selection, 'replace_highlights': self.replace_highlights, 'clear_selection': def(): window.getSelection().removeAllRanges();, @@ -546,7 +547,8 @@ class IframeBoss: annot_id = highlight_associated_with_selection(sel, annot_id_uuid_map) r = sel.getRangeAt(0) start_is_anchor = r.startContainer is sel.anchorNode and r.startOffset is sel.anchorOffset - by_search = window.performance.now() - self.last_search_at < 1000 + now = window.performance.now() + by_search = now - self.last_search_at < 1000 self.send_message( 'selectionchange', text=text, empty=v'!!collapsed', annot_id=annot_id, drag_mouse_position=drag_mouse_position, selection_change_caused_by_search=by_search, @@ -890,5 +892,11 @@ class IframeBoss: container.appendChild(s.getRangeAt(i).cloneContents()) self.send_message('copy_text_to_clipboard', text=text, html=container.innerHTML) + def tts_msg_received(self, data): + if data.type is 'play': + r = range_for_tts() + text = r.toString() + self.send_message('tts', type='text-extracted', text=text) + def main(): main.boss = IframeBoss() diff --git a/src/pyj/read_book/read_aloud.pyj b/src/pyj/read_book/read_aloud.pyj index 7df1a0a415..b9929c82a4 100644 --- a/src/pyj/read_book/read_aloud.pyj +++ b/src/pyj/read_book/read_aloud.pyj @@ -3,29 +3,35 @@ from __python__ import bound_methods, hash_literals from elementmaker import E +from gettext import gettext as _ from book_list.theme import get_color -from dom import clear, unique_id -from read_book.globals import runtime +from dom import clear, unique_id, svgicon +from read_book.globals import runtime, ui_operations +from read_book.selection_bar import BUTTON_MARGIN +from read_book.highlights import ICON_SIZE HIDDEN = 0 -PAUSED = 1 -PLAYING = 2 +WAITING_FOR_PLAY_TO_START = 1 +PAUSED = 2 +PLAYING = 3 +STOPPED = 4 class ReadAloud: def __init__(self, view): self.view = view - self.state = HIDDEN + self._state = HIDDEN self.bar_id = unique_id('bar') container = self.container container.setAttribute('tabindex', '0') container.style.overflow = 'hidden' + container.style.textAlign = 'right' container.appendChild(E.div( id=self.bar_id, - style='position: fixed; border: solid 1px currentColor; border-radius: 5px;' - 'right: 95vw; top: 0; display: flex; flex-direction: column;' + style='position: static; border: solid 1px currentColor; border-radius: 5px;' + 'display: inline-flex; flex-direction: column; margin: 1rem;' )) @property @@ -44,21 +50,35 @@ class ReadAloud: def is_visible(self): return self.container.style.display is not 'none' + @property + def state(self): + return self._state + + @state.setter + def state(self, val): + if val is not self._state: + self._state = val + self.build_bar() + def hide(self): - self.state = HIDDEN - self.container.style.display = 'none' - self.view.focus_iframe() + if self.state is not HIDDEN: + ui_operations.tts('stop') + self.state = HIDDEN + self.container.style.display = 'none' + self.view.focus_iframe() def show(self): if self.state is HIDDEN: self.container.style.display = 'block' - self.state = PAUSED + self.state = STOPPED self.focus() def focus(self): self.container.focus() def build_bar(self, annot_id): + if self.state is HIDDEN: + return bar_container = self.bar clear(bar_container) bar_container.style.maxWidth = 'min(50rem, 90vw)' if self.supports_css_min_max else '50rem' @@ -76,4 +96,49 @@ class ReadAloud: ]: bar_container.appendChild(x) bar = bar_container.firstChild - bar + + def cb(name, icon, text): + ans = svgicon(icon, ICON_SIZE, ICON_SIZE, text) + if name: + ans.addEventListener('click', def(ev): + ev.stopPropagation(), ev.preventDefault() + self[name](ev) + self.view.focus_iframe() + ) + ans.classList.add('simple-link') + ans.style.marginLeft = ans.style.marginRight = BUTTON_MARGIN + return ans + + if self.state is PLAYING: + bar.appendChild(cb('pause', 'pause', _('Pause reading'))) + elif self.state is WAITING_FOR_PLAY_TO_START: + bar.appendChild(cb(None, 'hourglass', _('Pause reading'))) + else: + bar.appendChild(cb('play', 'play', _('Start reading') if self.state is STOPPED else _('Resume reading'))) + bar.appendChild(cb('hide', 'close', _('Close Read aloud'))) + + def play(self): + if self.state is PAUSED: + ui_operations.tts('resume') + self.state = PLAYING + elif self.state is STOPPED: + self.send_message('play') + self.state = WAITING_FOR_PLAY_TO_START + + def pause(self): + if self.state is PLAYING: + ui_operations.tts('pause') + self.state = PAUSED + + def toggle(self): + if self.state is PLAYING: + self.pause() + elif self.state is PAUSED or self.state is STOPPED: + self.play() + + def send_message(self, type, **kw): + self.view.iframe_wrapper.send_message('tts', type=type, **kw) + + def handle_message(self, msg): + if msg.type is 'text-extracted': + ui_operations.tts('play', {'text': msg.text}) diff --git a/src/pyj/read_book/shortcuts.pyj b/src/pyj/read_book/shortcuts.pyj index c8e7e81b7e..26f88fa8cb 100644 --- a/src/pyj/read_book/shortcuts.pyj +++ b/src/pyj/read_book/shortcuts.pyj @@ -188,6 +188,12 @@ def shortcuts_definition(): _('Show/hide Table of Contents'), ), + 'read_aloud': desc( + 'Ctrl+s', + 'ui', + _('Read aloud') + ), + 'copy_to_clipboard': desc( v"['Ctrl+c', 'Meta+c']", 'ui', diff --git a/src/pyj/read_book/view.pyj b/src/pyj/read_book/view.pyj index d8136599ae..747442f1c1 100644 --- a/src/pyj/read_book/view.pyj +++ b/src/pyj/read_book/view.pyj @@ -294,6 +294,7 @@ class View: ui_operations.search_result_not_found(data.search_result) , 'annotations': self.on_annotations_message, + 'tts': self.on_tts_message, 'copy_text_to_clipboard': def(data): ui_operations.copy_selection(data.text, data.html) , @@ -345,6 +346,9 @@ class View: def on_annotations_message(self, data): self.selection_bar.handle_message(data) + def on_tts_message(self, data): + self.read_aloud.handle_message(data) + def left_margin_clicked(self, event): if event.button is 0: event.preventDefault(), event.stopPropagation() @@ -501,6 +505,8 @@ class View: ui_operations.toggle_full_screen() elif data.name is 'toggle_reference_mode': self.toggle_reference_mode() + elif data.name is 'read_aloud': + self.start_read_aloud() elif data.name is 'reload_book': ui_operations.reload_book() elif data.name is 'next_section': @@ -668,6 +674,10 @@ class View: else: self.iframe.contentWindow.focus() + def start_read_aloud(self): + self.selection_bar.hide() + self.read_aloud.show() + def show_chrome(self, data): elements = {} if data and data.elements: diff --git a/src/pyj/select.pyj b/src/pyj/select.pyj index 35102bf199..383970c736 100644 --- a/src/pyj/select.pyj +++ b/src/pyj/select.pyj @@ -191,3 +191,17 @@ def move_end_of_selection(pos, start): else: if r.endContainer is not p.offsetNode or r.endOffset is not p.offset: r.setEnd(p.offsetNode, p.offset) + + +def range_for_tts(x, y): + p = None + if x? and y?: + p = caret_position_from_point(x, y) + if not p: + p = caret_position_from_point(0, 0) + if not p: + p = {'offsetNode': document.body, 'offset': 0} + r = document.createRange() + r.setStart(p.offsetNode, p.offset) + r.setEndAfter(document.body) + return r diff --git a/src/pyj/viewer-main.pyj b/src/pyj/viewer-main.pyj index 8b0aafb2c7..fc1724925d 100644 --- a/src/pyj/viewer-main.pyj +++ b/src/pyj/viewer-main.pyj @@ -400,6 +400,8 @@ if window is window.top: to_python.highlights_changed(amap.highlight) ui_operations.speak_simple_text = def(text): to_python.speak_simple_text(text) + ui_operations.tts = def(action, data): + to_python.tts(action, data or v'{}') document.body.appendChild(E.div(id='view')) window.onerror = onerror