More work on Read Aloud

This commit is contained in:
Kovid Goyal 2020-11-29 13:42:29 +05:30
parent 4fd521a88b
commit eaccc523af
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
10 changed files with 127 additions and 14 deletions

1
imgsrc/srv/hourglass.svg Normal file
View File

@ -0,0 +1 @@
<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1536 128q0 261-106.5 461.5t-266.5 306.5q160 106 266.5 306.5t106.5 461.5h96q14 0 23 9t9 23v64q0 14-9 23t-23 9h-1472q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h96q0-261 106.5-461.5t266.5-306.5q-160-106-266.5-306.5t-106.5-461.5h-96q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h1472q14 0 23 9t9 23v64q0 14-9 23t-23 9h-96zm-128 0h-1024q0 206 85 384h854q85-178 85-384zm-57 1216q-54-141-145.5-241.5t-194.5-142.5h-230q-103 42-194.5 142.5t-145.5 241.5h910z"/></svg>

After

Width:  |  Height:  |  Size: 541 B

1
imgsrc/srv/pause.svg Normal file
View File

@ -0,0 +1 @@
<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1664 192v1408q0 26-19 45t-45 19h-512q-26 0-45-19t-19-45v-1408q0-26 19-45t45-19h512q26 0 45 19t19 45zm-896 0v1408q0 26-19 45t-45 19h-512q-26 0-45-19t-19-45v-1408q0-26 19-45t45-19h512q26 0 45 19t19 45z"/></svg>

After

Width:  |  Height:  |  Size: 309 B

1
imgsrc/srv/play.svg Normal file
View File

@ -0,0 +1 @@
<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1576 927l-1328 738q-23 13-39.5 3t-16.5-36v-1472q0-26 16.5-36t39.5 3l1328 738q23 13 23 31t-23 31z"/></svg>

After

Width:  |  Height:  |  Size: 206 B

View File

@ -269,6 +269,7 @@ class ViewerBridge(Bridge):
highlights_changed = from_js(object) highlights_changed = from_js(object)
open_url = from_js(object) open_url = from_js(object)
speak_simple_text = from_js(object) speak_simple_text = from_js(object)
tts = from_js(object, object)
create_view = to_js() create_view = to_js()
start_book_load = to_js() start_book_load = to_js()
@ -519,6 +520,7 @@ class WebView(RestartingWebEngineView):
self.bridge.highlights_changed.connect(self.highlights_changed) self.bridge.highlights_changed.connect(self.highlights_changed)
self.bridge.open_url.connect(safe_open_url) self.bridge.open_url.connect(safe_open_url)
self.bridge.speak_simple_text.connect(self.speak_simple_text) 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.bridge.export_shortcut_map.connect(self.set_shortcut_map)
self.shortcut_map = {} self.shortcut_map = {}
self.bridge.report_cfi.connect(self.call_callback) self.bridge.report_cfi.connect(self.call_callback)
@ -553,6 +555,9 @@ class WebView(RestartingWebEngineView):
except TTSSystemUnavailable as err: except TTSSystemUnavailable as err:
return error_dialog(self, _('Text-to-Speech unavailable'), str(err), show=True) return error_dialog(self, _('Text-to-Speech unavailable'), str(err), show=True)
def tts_action(self, action, data):
pass
def shutdown(self): def shutdown(self):
if self._tts_client is not None: if self._tts_client is not None:
self._tts_client.shutdown() self._tts_client.shutdown()

View File

@ -4,7 +4,7 @@ from __python__ import bound_methods, hash_literals
import traceback import traceback
from gettext import gettext as _ 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 fs_images import fix_fullscreen_svg_images
from iframe_comm import IframeClient from iframe_comm import IframeClient
@ -143,6 +143,7 @@ class IframeBoss:
'show_search_result': self.show_search_result, 'show_search_result': self.show_search_result,
'handle_navigation_shortcut': self.on_handle_navigation_shortcut, 'handle_navigation_shortcut': self.on_handle_navigation_shortcut,
'annotations': self.annotations_msg_received, 'annotations': self.annotations_msg_received,
'tts': self.tts_msg_received,
'copy_selection': self.copy_selection, 'copy_selection': self.copy_selection,
'replace_highlights': self.replace_highlights, 'replace_highlights': self.replace_highlights,
'clear_selection': def(): window.getSelection().removeAllRanges();, 'clear_selection': def(): window.getSelection().removeAllRanges();,
@ -546,7 +547,8 @@ class IframeBoss:
annot_id = highlight_associated_with_selection(sel, annot_id_uuid_map) annot_id = highlight_associated_with_selection(sel, annot_id_uuid_map)
r = sel.getRangeAt(0) r = sel.getRangeAt(0)
start_is_anchor = r.startContainer is sel.anchorNode and r.startOffset is sel.anchorOffset 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( self.send_message(
'selectionchange', text=text, empty=v'!!collapsed', annot_id=annot_id, 'selectionchange', text=text, empty=v'!!collapsed', annot_id=annot_id,
drag_mouse_position=drag_mouse_position, selection_change_caused_by_search=by_search, 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()) container.appendChild(s.getRangeAt(i).cloneContents())
self.send_message('copy_text_to_clipboard', text=text, html=container.innerHTML) 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(): def main():
main.boss = IframeBoss() main.boss = IframeBoss()

View File

@ -3,29 +3,35 @@
from __python__ import bound_methods, hash_literals from __python__ import bound_methods, hash_literals
from elementmaker import E from elementmaker import E
from gettext import gettext as _
from book_list.theme import get_color from book_list.theme import get_color
from dom import clear, unique_id from dom import clear, unique_id, svgicon
from read_book.globals import runtime 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 HIDDEN = 0
PAUSED = 1 WAITING_FOR_PLAY_TO_START = 1
PLAYING = 2 PAUSED = 2
PLAYING = 3
STOPPED = 4
class ReadAloud: class ReadAloud:
def __init__(self, view): def __init__(self, view):
self.view = view self.view = view
self.state = HIDDEN self._state = HIDDEN
self.bar_id = unique_id('bar') self.bar_id = unique_id('bar')
container = self.container container = self.container
container.setAttribute('tabindex', '0') container.setAttribute('tabindex', '0')
container.style.overflow = 'hidden' container.style.overflow = 'hidden'
container.style.textAlign = 'right'
container.appendChild(E.div( container.appendChild(E.div(
id=self.bar_id, id=self.bar_id,
style='position: fixed; border: solid 1px currentColor; border-radius: 5px;' style='position: static; border: solid 1px currentColor; border-radius: 5px;'
'right: 95vw; top: 0; display: flex; flex-direction: column;' 'display: inline-flex; flex-direction: column; margin: 1rem;'
)) ))
@property @property
@ -44,7 +50,19 @@ class ReadAloud:
def is_visible(self): def is_visible(self):
return self.container.style.display is not 'none' 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): def hide(self):
if self.state is not HIDDEN:
ui_operations.tts('stop')
self.state = HIDDEN self.state = HIDDEN
self.container.style.display = 'none' self.container.style.display = 'none'
self.view.focus_iframe() self.view.focus_iframe()
@ -52,13 +70,15 @@ class ReadAloud:
def show(self): def show(self):
if self.state is HIDDEN: if self.state is HIDDEN:
self.container.style.display = 'block' self.container.style.display = 'block'
self.state = PAUSED self.state = STOPPED
self.focus() self.focus()
def focus(self): def focus(self):
self.container.focus() self.container.focus()
def build_bar(self, annot_id): def build_bar(self, annot_id):
if self.state is HIDDEN:
return
bar_container = self.bar bar_container = self.bar
clear(bar_container) clear(bar_container)
bar_container.style.maxWidth = 'min(50rem, 90vw)' if self.supports_css_min_max else '50rem' 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_container.appendChild(x)
bar = bar_container.firstChild 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})

View File

@ -188,6 +188,12 @@ def shortcuts_definition():
_('Show/hide Table of Contents'), _('Show/hide Table of Contents'),
), ),
'read_aloud': desc(
'Ctrl+s',
'ui',
_('Read aloud')
),
'copy_to_clipboard': desc( 'copy_to_clipboard': desc(
v"['Ctrl+c', 'Meta+c']", v"['Ctrl+c', 'Meta+c']",
'ui', 'ui',

View File

@ -294,6 +294,7 @@ class View:
ui_operations.search_result_not_found(data.search_result) ui_operations.search_result_not_found(data.search_result)
, ,
'annotations': self.on_annotations_message, 'annotations': self.on_annotations_message,
'tts': self.on_tts_message,
'copy_text_to_clipboard': def(data): 'copy_text_to_clipboard': def(data):
ui_operations.copy_selection(data.text, data.html) ui_operations.copy_selection(data.text, data.html)
, ,
@ -345,6 +346,9 @@ class View:
def on_annotations_message(self, data): def on_annotations_message(self, data):
self.selection_bar.handle_message(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): def left_margin_clicked(self, event):
if event.button is 0: if event.button is 0:
event.preventDefault(), event.stopPropagation() event.preventDefault(), event.stopPropagation()
@ -501,6 +505,8 @@ class View:
ui_operations.toggle_full_screen() ui_operations.toggle_full_screen()
elif data.name is 'toggle_reference_mode': elif data.name is 'toggle_reference_mode':
self.toggle_reference_mode() self.toggle_reference_mode()
elif data.name is 'read_aloud':
self.start_read_aloud()
elif data.name is 'reload_book': elif data.name is 'reload_book':
ui_operations.reload_book() ui_operations.reload_book()
elif data.name is 'next_section': elif data.name is 'next_section':
@ -668,6 +674,10 @@ class View:
else: else:
self.iframe.contentWindow.focus() self.iframe.contentWindow.focus()
def start_read_aloud(self):
self.selection_bar.hide()
self.read_aloud.show()
def show_chrome(self, data): def show_chrome(self, data):
elements = {} elements = {}
if data and data.elements: if data and data.elements:

View File

@ -191,3 +191,17 @@ def move_end_of_selection(pos, start):
else: else:
if r.endContainer is not p.offsetNode or r.endOffset is not p.offset: if r.endContainer is not p.offsetNode or r.endOffset is not p.offset:
r.setEnd(p.offsetNode, 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

View File

@ -400,6 +400,8 @@ if window is window.top:
to_python.highlights_changed(amap.highlight) to_python.highlights_changed(amap.highlight)
ui_operations.speak_simple_text = def(text): ui_operations.speak_simple_text = def(text):
to_python.speak_simple_text(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')) document.body.appendChild(E.div(id='view'))
window.onerror = onerror window.onerror = onerror