From 9725e92d17d0a60f479b096098e456894857f7f8 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 26 Nov 2024 12:42:13 +0530 Subject: [PATCH] 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. --- src/calibre/gui2/tts/config.py | 39 +++++++++++++ src/calibre/gui2/tts/manager.py | 6 +- src/calibre/gui2/viewer/config.py | 10 ++++ src/calibre/gui2/viewer/tts.py | 2 + src/calibre/gui2/viewer/web_view.py | 7 ++- src/pyj/read_book/read_aloud.pyj | 87 +++++++++++++++++++---------- src/pyj/session.pyj | 1 + src/pyj/viewer-main.pyj | 9 ++- 8 files changed, 127 insertions(+), 34 deletions(-) diff --git a/src/calibre/gui2/tts/config.py b/src/calibre/gui2/tts/config.py index 36b2ef4b12e4..89a67bbdad83 100644 --- a/src/calibre/gui2/tts/config.py +++ b/src/calibre/gui2/tts/config.py @@ -481,6 +481,41 @@ def current_voice_is_downloaded(self) -> bool: 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): def __init__(self, parent=None): @@ -496,6 +531,8 @@ def setup_ui(self): l.addWidget(esc) self.voice_button = b = QPushButton(self) b.clicked.connect(self.voice_action) + self.bar_position = bp = BarPosition(self) + l.addWidget(bp) h = QHBoxLayout() l.addLayout(h) h.addWidget(b), h.addStretch(10), h.addWidget(self.bb) @@ -508,6 +545,7 @@ def setup_ui(self): def restore_defaults(self): self.engine_choice.restore_defaults() self.engine_specific_config.restore_defaults() + self.bar_position.restore_defaults() def set_engine(self, engine_name: str) -> None: metadata = self.engine_specific_config.set_engine(engine_name) @@ -545,6 +583,7 @@ def accept(self): else: prefs.pop('engine', None) s.save_to_config(prefs) + self.bar_position.commit() super().accept() diff --git a/src/calibre/gui2/tts/manager.py b/src/calibre/gui2/tts/manager.py index 74621107151d..f66ca281efac 100644 --- a/src/calibre/gui2/tts/manager.py +++ b/src/calibre/gui2/tts/manager.py @@ -121,6 +121,7 @@ class TTSManager(QObject): state_event = pyqtSignal(str) saying = pyqtSignal(int, int) + configured = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) @@ -228,7 +229,9 @@ def configure(self) -> None: from calibre.gui2.tts.types import widget_parent with self.resume_after() as rd: 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 if d.engine_changed: if rd.is_speaking: @@ -236,6 +239,7 @@ def configure(self) -> None: self._tts = None else: self.tts.reload_after_configure() + self.configured.emit() def _state_changed(self, state: QTextToSpeech.State) -> None: prev_state, self.state = self.state, state diff --git a/src/calibre/gui2/viewer/config.py b/src/calibre/gui2/viewer/config.py index 6d33d82ac9c4..99edd22d744b 100644 --- a/src/calibre/gui2/viewer/config.py +++ b/src/calibre/gui2/viewer/config.py @@ -28,6 +28,16 @@ def get_session_pref(name, default=None, group='standalone_misc_settings'): 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): sd = vprefs['session_data'] return sd.get(name) or {} diff --git a/src/calibre/gui2/viewer/tts.py b/src/calibre/gui2/viewer/tts.py index 1ae971d3efc0..a2636fb4e031 100644 --- a/src/calibre/gui2/viewer/tts.py +++ b/src/calibre/gui2/viewer/tts.py @@ -13,6 +13,7 @@ class TTS(QObject): event_received = pyqtSignal(object, object) settings_changed = pyqtSignal(object) + configured = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) @@ -25,6 +26,7 @@ def manager(self): self._manager = TTSManager(self) self._manager.saying.connect(self.saying) self._manager.state_event.connect(self.state_event) + self._manager.configured.connect(self.configured) return self._manager def shutdown(self): diff --git a/src/calibre/gui2/viewer/web_view.py b/src/calibre/gui2/viewer/web_view.py index 1969c853f4dc..4e64405f6b5e 100644 --- a/src/calibre/gui2/viewer/web_view.py +++ b/src/calibre/gui2/viewer/web_view.py @@ -41,7 +41,7 @@ from calibre.ebooks.oeb.polish.utils import guess_type 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.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.webengine import RestartingWebEngineView from calibre.srv.code import get_translations_data @@ -299,6 +299,7 @@ class ViewerBridge(Bridge): create_view = to_js() start_book_load = to_js() + redraw_tts_bar = to_js() goto_toc_node = to_js() goto_cfi = to_js() full_screen_state_changed = to_js() @@ -514,6 +515,7 @@ def __init__(self, parent=None): self.tts = TTS(self) self.tts.settings_changed.connect(self.tts_settings_changed) self.tts.event_received.connect(self.tts_event_received) + self.tts.configured.connect(self.redraw_tts_bar) self.dead_renderer_error_shown = False self.render_process_failed.connect(self.render_process_died) w = self.screen().availableSize().width() @@ -769,6 +771,9 @@ def get_current_cfi(self, callback): def show_home_page(self): 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): files = choose_images(self, 'viewer-background-image', _('Choose background image'), formats=['png', 'gif', 'jpg', 'jpeg', 'webp']) if files: diff --git a/src/pyj/read_book/read_aloud.pyj b/src/pyj/read_book/read_aloud.pyj index 30f87038847b..c449a4ed0ac1 100644 --- a/src/pyj/read_book/read_aloud.pyj +++ b/src/pyj/read_book/read_aloud.pyj @@ -12,6 +12,7 @@ from read_book.globals import ui_operations from read_book.highlights import ICON_SIZE from read_book.selection_bar import BUTTON_MARGIN, get_margins, map_to_iframe_coords from read_book.shortcuts import shortcut_for_key_event +from book_list.globals import get_session_data HIDDEN = 0 WAITING_FOR_PLAY_TO_START = 1 @@ -26,6 +27,13 @@ def is_flow_mode(): 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: dont_hide_on_content_loaded = True @@ -38,14 +46,13 @@ class ReadAloud: container = self.container container.setAttribute('tabindex', '0') container.style.overflow = 'hidden' - container.style.justifyContent = 'flex-end' - 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.div(id=self.bar_id)) 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:hover '+'{ opacity: 1.0 }\n\n', )) @@ -95,31 +102,13 @@ class ReadAloud: def focus(self): self.container.focus() - def build_bar(self, annot_id): - if self.state is HIDDEN: - return - self.container.style.alignItems = 'flex-end' if is_flow_mode() else 'flex-start' - bar_container = self.bar - 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 build_docked_bar(self, bar_container, bar_position): + container = self.container + container.style.alignItems = 'flex-end' if 'bottom' in bar_position else 'flex-start' + container.style.justifyContent = 'flex-end' if 'right' in bar_position else ('flex-start' if 'left' in bar_position else 'center') + self.create_buttons(bar_container) + def create_buttons(self, bar): def cb(name, icon, text): ans = svgicon(icon, ICON_SIZE, ICON_SIZE, text) if name: @@ -142,6 +131,42 @@ class ReadAloud: bar.appendChild(cb('faster', 'faster', _('Speed up speech'))) bar.appendChild(cb('configure', 'cogs', _('Configure 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: notes_container = bar_container.lastChild notes_container.style.display = notes_container.previousSibling.style.display = 'block' diff --git a/src/pyj/session.pyj b/src/pyj/session.pyj index cf281a07f36b..c92be565088f 100644 --- a/src/pyj/session.pyj +++ b/src/pyj/session.pyj @@ -80,6 +80,7 @@ all_settings = { 'gesture_overrides': {'default': {}, 'category': 'read_book'}, 'cite_text_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 = {} diff --git a/src/pyj/viewer-main.pyj b/src/pyj/viewer-main.pyj index db88e9ab0e1c..318ce6dabbaa 100644 --- a/src/pyj/viewer-main.pyj +++ b/src/pyj/viewer-main.pyj @@ -283,6 +283,14 @@ def show_home_page(): 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 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) @@ -503,7 +511,6 @@ if window is window.top: to_python.on_iframe_ready() ui_operations.update_reading_rates = def(rates): to_python.update_reading_rates(rates) - document.body.appendChild(E.div(id='view')) window.onerror = onerror create_modal_container()