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