E-book viewer: Read Aloud: Add an option to control the position of the popup control bar. It can now be placed along the top or bottom edges so as to overlap less with text.

This commit is contained in:
Kovid Goyal 2024-11-26 12:42:13 +05:30
parent 6f1d28eafb
commit 9725e92d17
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
8 changed files with 127 additions and 34 deletions

View File

@ -481,6 +481,41 @@ class EngineSpecificConfig(QWidget):
return tts.is_voice_downloaded(v) return tts.is_voice_downloaded(v)
class BarPosition(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.l = l = QFormLayout(self)
self.choices = c = QComboBox(self)
l.addRow(_('Position of control bar:'), c)
c.addItem(_('Floating with help text'), 'float')
c.addItem(_('Top'), 'top')
c.addItem(_('Bottom'), 'bottom')
c.addItem(_('Top right'), 'top-right')
c.addItem(_('Top left'), 'top-left')
c.addItem(_('Bottom right'), 'bottom-right')
c.addItem(_('Bottom left'), 'bottom-left')
from calibre.gui2.viewer.config import get_session_pref
self.val = get_session_pref('tts_bar_position', 'float', None)
@property
def val(self):
return self.choices.currentData()
@val.setter
def val(self, x):
idx = self.choices.findData(x)
if idx > -1:
self.choices.setCurrentIndex(idx)
def commit(self):
from calibre.gui2.viewer.config import set_session_pref
set_session_pref('tts_bar_position', self.val, None)
def restore_defaults(self):
self.val = 'float'
class ConfigDialog(Dialog): class ConfigDialog(Dialog):
def __init__(self, parent=None): def __init__(self, parent=None):
@ -496,6 +531,8 @@ class ConfigDialog(Dialog):
l.addWidget(esc) l.addWidget(esc)
self.voice_button = b = QPushButton(self) self.voice_button = b = QPushButton(self)
b.clicked.connect(self.voice_action) b.clicked.connect(self.voice_action)
self.bar_position = bp = BarPosition(self)
l.addWidget(bp)
h = QHBoxLayout() h = QHBoxLayout()
l.addLayout(h) l.addLayout(h)
h.addWidget(b), h.addStretch(10), h.addWidget(self.bb) h.addWidget(b), h.addStretch(10), h.addWidget(self.bb)
@ -508,6 +545,7 @@ class ConfigDialog(Dialog):
def restore_defaults(self): def restore_defaults(self):
self.engine_choice.restore_defaults() self.engine_choice.restore_defaults()
self.engine_specific_config.restore_defaults() self.engine_specific_config.restore_defaults()
self.bar_position.restore_defaults()
def set_engine(self, engine_name: str) -> None: def set_engine(self, engine_name: str) -> None:
metadata = self.engine_specific_config.set_engine(engine_name) metadata = self.engine_specific_config.set_engine(engine_name)
@ -545,6 +583,7 @@ class ConfigDialog(Dialog):
else: else:
prefs.pop('engine', None) prefs.pop('engine', None)
s.save_to_config(prefs) s.save_to_config(prefs)
self.bar_position.commit()
super().accept() super().accept()

View File

@ -121,6 +121,7 @@ class TTSManager(QObject):
state_event = pyqtSignal(str) state_event = pyqtSignal(str)
saying = pyqtSignal(int, int) saying = pyqtSignal(int, int)
configured = pyqtSignal()
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@ -228,7 +229,9 @@ class TTSManager(QObject):
from calibre.gui2.tts.types import widget_parent from calibre.gui2.tts.types import widget_parent
with self.resume_after() as rd: with self.resume_after() as rd:
d = ConfigDialog(parent=widget_parent(self)) d = ConfigDialog(parent=widget_parent(self))
if d.exec() == QDialog.DialogCode.Accepted and self._tts is not None: if d.exec() != QDialog.DialogCode.Accepted:
return
if self._tts is not None:
rd.needs_full_resume = True rd.needs_full_resume = True
if d.engine_changed: if d.engine_changed:
if rd.is_speaking: if rd.is_speaking:
@ -236,6 +239,7 @@ class TTSManager(QObject):
self._tts = None self._tts = None
else: else:
self.tts.reload_after_configure() self.tts.reload_after_configure()
self.configured.emit()
def _state_changed(self, state: QTextToSpeech.State) -> None: def _state_changed(self, state: QTextToSpeech.State) -> None:
prev_state, self.state = self.state, state prev_state, self.state = self.state, state

View File

@ -28,6 +28,16 @@ def get_session_pref(name, default=None, group='standalone_misc_settings'):
return g.get(name, default) return g.get(name, default)
def set_session_pref(name, val=None, group='standalone_misc_settings'):
sd = vprefs['session_data']
g = sd.get(group, {}) if group else sd
if val is None:
g.pop(name, None)
else:
g[name] = val
vprefs['session_data'] = sd
def get_pref_group(name): def get_pref_group(name):
sd = vprefs['session_data'] sd = vprefs['session_data']
return sd.get(name) or {} return sd.get(name) or {}

View File

@ -13,6 +13,7 @@ class TTS(QObject):
event_received = pyqtSignal(object, object) event_received = pyqtSignal(object, object)
settings_changed = pyqtSignal(object) settings_changed = pyqtSignal(object)
configured = pyqtSignal()
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@ -25,6 +26,7 @@ class TTS(QObject):
self._manager = TTSManager(self) self._manager = TTSManager(self)
self._manager.saying.connect(self.saying) self._manager.saying.connect(self.saying)
self._manager.state_event.connect(self.state_event) self._manager.state_event.connect(self.state_event)
self._manager.configured.connect(self.configured)
return self._manager return self._manager
def shutdown(self): def shutdown(self):

View File

@ -41,7 +41,7 @@ from calibre.ebooks.metadata.book.base import field_metadata
from calibre.ebooks.oeb.polish.utils import guess_type from calibre.ebooks.oeb.polish.utils import guess_type
from calibre.gui2 import choose_images, config, error_dialog, safe_open_url from calibre.gui2 import choose_images, config, error_dialog, safe_open_url
from calibre.gui2.viewer import link_prefix_for_location_links, performance_monitor, url_for_book_in_library from calibre.gui2.viewer import link_prefix_for_location_links, performance_monitor, url_for_book_in_library
from calibre.gui2.viewer.config import load_viewer_profiles, save_viewer_profile, viewer_config_dir, vprefs from calibre.gui2.viewer.config import get_session_pref, load_viewer_profiles, save_viewer_profile, viewer_config_dir, vprefs
from calibre.gui2.viewer.tts import TTS from calibre.gui2.viewer.tts import TTS
from calibre.gui2.webengine import RestartingWebEngineView from calibre.gui2.webengine import RestartingWebEngineView
from calibre.srv.code import get_translations_data from calibre.srv.code import get_translations_data
@ -299,6 +299,7 @@ class ViewerBridge(Bridge):
create_view = to_js() create_view = to_js()
start_book_load = to_js() start_book_load = to_js()
redraw_tts_bar = to_js()
goto_toc_node = to_js() goto_toc_node = to_js()
goto_cfi = to_js() goto_cfi = to_js()
full_screen_state_changed = to_js() full_screen_state_changed = to_js()
@ -514,6 +515,7 @@ class WebView(RestartingWebEngineView):
self.tts = TTS(self) self.tts = TTS(self)
self.tts.settings_changed.connect(self.tts_settings_changed) self.tts.settings_changed.connect(self.tts_settings_changed)
self.tts.event_received.connect(self.tts_event_received) self.tts.event_received.connect(self.tts_event_received)
self.tts.configured.connect(self.redraw_tts_bar)
self.dead_renderer_error_shown = False self.dead_renderer_error_shown = False
self.render_process_failed.connect(self.render_process_died) self.render_process_failed.connect(self.render_process_died)
w = self.screen().availableSize().width() w = self.screen().availableSize().width()
@ -769,6 +771,9 @@ class WebView(RestartingWebEngineView):
def show_home_page(self): def show_home_page(self):
self.execute_when_ready('show_home_page') self.execute_when_ready('show_home_page')
def redraw_tts_bar(self):
self.execute_when_ready('redraw_tts_bar', get_session_pref('tts_bar_position', 'float', None))
def change_background_image(self, img_id): def change_background_image(self, img_id):
files = choose_images(self, 'viewer-background-image', _('Choose background image'), formats=['png', 'gif', 'jpg', 'jpeg', 'webp']) files = choose_images(self, 'viewer-background-image', _('Choose background image'), formats=['png', 'gif', 'jpg', 'jpeg', 'webp'])
if files: if files:

View File

@ -12,6 +12,7 @@ from read_book.globals import ui_operations
from read_book.highlights import ICON_SIZE from read_book.highlights import ICON_SIZE
from read_book.selection_bar import BUTTON_MARGIN, get_margins, map_to_iframe_coords from read_book.selection_bar import BUTTON_MARGIN, get_margins, map_to_iframe_coords
from read_book.shortcuts import shortcut_for_key_event from read_book.shortcuts import shortcut_for_key_event
from book_list.globals import get_session_data
HIDDEN = 0 HIDDEN = 0
WAITING_FOR_PLAY_TO_START = 1 WAITING_FOR_PLAY_TO_START = 1
@ -26,6 +27,13 @@ def is_flow_mode():
return mode is 'flow' return mode is 'flow'
def bar_class_and_position():
sd = get_session_data()
bp = sd.get('tts_bar_position')
iclass = 'floating' if 'float' in bp else 'docked'
return iclass, bp
class ReadAloud: class ReadAloud:
dont_hide_on_content_loaded = True dont_hide_on_content_loaded = True
@ -38,14 +46,13 @@ class ReadAloud:
container = self.container container = self.container
container.setAttribute('tabindex', '0') container.setAttribute('tabindex', '0')
container.style.overflow = 'hidden' container.style.overflow = 'hidden'
container.style.justifyContent = 'flex-end' container.appendChild(E.div(id=self.bar_id))
container.style.alignItems = 'flex-end' if is_flow_mode() else 'flex-start'
container.appendChild(E.div(
id=self.bar_id,
style='border: solid 1px currentColor; border-radius: 5px;'
'display: flex; flex-direction: column; margin: 1rem;'
))
container.appendChild(E.style( container.appendChild(E.style(
f'#{self.bar_id}.floating'+'{ border: solid 1px currentColor; border-radius: 5px; display: flex;' +
' flex-direction: column; margin: 1rem; }\n\n',
f'#{self.bar_id}.docked'+'{ border-radius: 1em; height: 2em; padding:0.5em; display: flex; justify-content: center; align-items: center; }\n\n',
f'#{self.bar_id}.speaking '+'{ opacity: 0.5 }\n\n', f'#{self.bar_id}.speaking '+'{ opacity: 0.5 }\n\n',
f'#{self.bar_id}.speaking:hover '+'{ opacity: 1.0 }\n\n', f'#{self.bar_id}.speaking:hover '+'{ opacity: 1.0 }\n\n',
)) ))
@ -95,31 +102,13 @@ class ReadAloud:
def focus(self): def focus(self):
self.container.focus() self.container.focus()
def build_bar(self, annot_id): def build_docked_bar(self, bar_container, bar_position):
if self.state is HIDDEN: container = self.container
return container.style.alignItems = 'flex-end' if 'bottom' in bar_position else 'flex-start'
self.container.style.alignItems = 'flex-end' if is_flow_mode() else 'flex-start' container.style.justifyContent = 'flex-end' if 'right' in bar_position else ('flex-start' if 'left' in bar_position else 'center')
bar_container = self.bar self.create_buttons(bar_container)
if self.state is PLAYING:
bar_container.classList.add('speaking')
else:
bar_container.classList.remove('speaking')
clear(bar_container)
bar_container.style.maxWidth = 'min(40rem, 80vw)'
bar_container.style.backgroundColor = get_color("window-background")
for x in [
E.div(style='height: 4ex; display: flex; align-items: center; padding: 5px; justify-content: center'),
E.hr(style='border-top: solid 1px; margin: 0; padding: 0; display: none'),
E.div(
style='display: none; padding: 5px; font-size: smaller',
E.div()
)
]:
bar_container.appendChild(x)
bar = bar_container.firstChild
def create_buttons(self, bar):
def cb(name, icon, text): def cb(name, icon, text):
ans = svgicon(icon, ICON_SIZE, ICON_SIZE, text) ans = svgicon(icon, ICON_SIZE, ICON_SIZE, text)
if name: if name:
@ -142,6 +131,42 @@ class ReadAloud:
bar.appendChild(cb('faster', 'faster', _('Speed up speech'))) bar.appendChild(cb('faster', 'faster', _('Speed up speech')))
bar.appendChild(cb('configure', 'cogs', _('Configure Read aloud'))) bar.appendChild(cb('configure', 'cogs', _('Configure Read aloud')))
bar.appendChild(cb('hide', 'off', _('Close Read aloud'))) bar.appendChild(cb('hide', 'off', _('Close Read aloud')))
def build_bar(self):
if self.state is HIDDEN:
return
bar_container = self.bar
bar_container.classList.remove('floating')
bar_container.classList.remove('docked')
iclass, bp = bar_class_and_position()
bar_container.classList.add(iclass)
bar_container.style.maxWidth = 'min(40rem, 80vw)'
bar_container.style.backgroundColor = get_color("window-background")
if self.state is PLAYING:
bar_container.classList.add('speaking')
else:
bar_container.classList.remove('speaking')
clear(bar_container)
if iclass is not 'floating':
return self.build_docked_bar(bar_container, bp)
container = self.container
container.style.alignItems = 'flex-end' if is_flow_mode() else 'flex-start'
container.style.justifyContent = 'flex-end'
for x in [
E.div(style='height: 4ex; display: flex; align-items: center; padding: 5px; justify-content: center'),
E.hr(style='border-top: solid 1px; margin: 0; padding: 0; display: none'),
E.div(
style='display: none; padding: 5px; font-size: smaller',
E.div()
)
]:
bar_container.appendChild(x)
self.create_buttons(bar_container.firstChild)
if self.state is not WAITING_FOR_PLAY_TO_START: if self.state is not WAITING_FOR_PLAY_TO_START:
notes_container = bar_container.lastChild notes_container = bar_container.lastChild
notes_container.style.display = notes_container.previousSibling.style.display = 'block' notes_container.style.display = notes_container.previousSibling.style.display = 'block'

View File

@ -80,6 +80,7 @@ all_settings = {
'gesture_overrides': {'default': {}, 'category': 'read_book'}, 'gesture_overrides': {'default': {}, 'category': 'read_book'},
'cite_text_template': {'default': '[{text}]({url})', 'category': 'read_book'}, 'cite_text_template': {'default': '[{text}]({url})', 'category': 'read_book'},
'cite_hl_template': {'default': '[{text}]({url})', 'category': 'read_book'}, 'cite_hl_template': {'default': '[{text}]({url})', 'category': 'read_book'},
'tts_bar_position': {'default': 'float', 'category': 'read_book', 'is_local': False, 'disallowed_in_profile': False},
} }
defaults = {} defaults = {}

View File

@ -283,6 +283,14 @@ def show_home_page():
view.overlay.open_book(False) view.overlay.open_book(False)
@from_python
def redraw_tts_bar(pos):
sd = get_session_data()
sd.set('tts_bar_position', pos)
if view:
view.read_aloud.build_bar()
@from_python @from_python
def start_book_load(key, initial_position, pathtoebook, highlights, book_url, reading_rates, book_in_library_url): def start_book_load(key, initial_position, pathtoebook, highlights, book_url, reading_rates, book_in_library_url):
xhr = ajax('manifest', manifest_received.bind(None, key, initial_position, pathtoebook, highlights, book_url, reading_rates, book_in_library_url), ok_code=0) xhr = ajax('manifest', manifest_received.bind(None, key, initial_position, pathtoebook, highlights, book_url, reading_rates, book_in_library_url), ok_code=0)
@ -503,7 +511,6 @@ if window is window.top:
to_python.on_iframe_ready() to_python.on_iframe_ready()
ui_operations.update_reading_rates = def(rates): ui_operations.update_reading_rates = def(rates):
to_python.update_reading_rates(rates) to_python.update_reading_rates(rates)
document.body.appendChild(E.div(id='view')) document.body.appendChild(E.div(id='view'))
window.onerror = onerror window.onerror = onerror
create_modal_container() create_modal_container()