diff --git a/README.md b/README.md index 4f2155e5..45b2c80f 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ sudo apt-get install dialect - Python 3 `python` - PyGObject `python-gobject` -- GTK4 `gtk4` -- libadwaita (>= 1.4.0) `libadwaita` +- GTK4 (>= 4.16.0) `gtk4` +- libadwaita (>= 1.6.0) `libadwaita` - libsoup (>= 3.0) `libsoup` - libsecret - GStreamer 1.0 `gstreamer` diff --git a/dialect/dialect.gresource.xml b/dialect/dialect.gresource.xml index 7227e461..803f73a5 100644 --- a/dialect/dialect.gresource.xml +++ b/dialect/dialect.gresource.xml @@ -10,6 +10,7 @@ widgets/lang_row.ui widgets/lang_selector.ui widgets/provider_preferences.ui + widgets/speech_button.ui widgets/theme_switcher.ui @appstream-path@ @@ -17,5 +18,6 @@ icons/settings-symbolic.svg + icons/speakers-broken-symbolic.svg diff --git a/dialect/icons/speakers-broken-symbolic.svg b/dialect/icons/speakers-broken-symbolic.svg new file mode 100644 index 00000000..fcb59a42 --- /dev/null +++ b/dialect/icons/speakers-broken-symbolic.svg @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/dialect/meson.build b/dialect/meson.build index 4a3b9631..11c7c209 100644 --- a/dialect/meson.build +++ b/dialect/meson.build @@ -10,6 +10,7 @@ blueprints = custom_target('blueprints', 'widgets/lang_selector.blp', 'widgets/lang_row.blp', 'widgets/provider_preferences.blp', + 'widgets/speech_button.blp', 'widgets/theme_switcher.blp', ), output: '.', diff --git a/dialect/style.css b/dialect/style.css index dff47501..cd3a4f4d 100644 --- a/dialect/style.css +++ b/dialect/style.css @@ -29,7 +29,7 @@ padding-left: 12px; padding-top: 9px; padding-bottom: 9px; - background-color: mix(@accent_bg_color, @window_bg_color, 0.7); + background-color: color-mix(in srgb, var(--accent-bg-color), var(--window-bg-color) 70%); } .pronunciation { @@ -38,8 +38,8 @@ /* Lang Selector */ .search_box { - background: @popover_bg_color; - border-bottom: 1px solid @borders; + background: var(--popover-bg-color); + border-bottom: 1px solid var(--border-color); padding: 6px; } @@ -75,12 +75,12 @@ actionbar.flat > revealer > box { padding: 1px; background-clip: content-box; border-radius: 9999px; - box-shadow: inset 0 0 0 1px @borders; + box-shadow: inset 0 0 0 1px var(--border-color); } .themeswitcher checkbutton.system:checked, .themeswitcher checkbutton.light:checked, .themeswitcher checkbutton.dark:checked { - box-shadow: inset 0 0 0 2px @theme_selected_bg_color; + box-shadow: inset 0 0 0 2px var(--accent-bg-color); } .themeswitcher checkbutton.system { background-image: linear-gradient(to bottom right, #fff 49.99%, #202020 50.01%); @@ -103,29 +103,17 @@ actionbar.flat > revealer > box { } .themeswitcher checkbutton.theme-selector radio:checked { -gtk-icon-source: -gtk-icontheme("object-select-symbolic"); - background-color: @theme_selected_bg_color; - color: @theme_selected_fg_color; + background-color: var(--accent-bg-color); + color: var(--accent-fg-color); } -/* Provider settings */ -.provider-feature { - padding: 3px 9px; - border-radius: 12px; - font-weight: bold; -} -.provider-feature:disabled { - opacity: 1; - filter: none; -} -.provider-feature-tts { - color: @green_5; - background-color: alpha(@green_3, .25); -} -.provider-feature-trans { - color: @blue_4; - background-color: alpha(@blue_3, .25); -} -.provider-feature-dic { - color: #ae7b03; - background: alpha(@yellow_5, .25); +/* Speech Button */ +.speech-button { + padding: 0; +} +.speech-button image { + padding: 5px 9px; +} +.speech-button progressbar trough { + min-width: 34px; } diff --git a/dialect/widgets/__init__.py b/dialect/widgets/__init__.py index 2e904f50..565cc12f 100644 --- a/dialect/widgets/__init__.py +++ b/dialect/widgets/__init__.py @@ -4,5 +4,6 @@ from dialect.widgets.lang_selector import LangSelector # noqa from dialect.widgets.provider_preferences import ProviderPreferences # noqa +from dialect.widgets.speech_button import SpeechButton # noqa from dialect.widgets.textview import TextView # noqa from dialect.widgets.theme_switcher import ThemeSwitcher # noqa diff --git a/dialect/widgets/provider_preferences.blp b/dialect/widgets/provider_preferences.blp index 7dcfe97e..59b12da2 100644 --- a/dialect/widgets/provider_preferences.blp +++ b/dialect/widgets/provider_preferences.blp @@ -41,7 +41,7 @@ template $ProviderPreferences : Adw.NavigationPage { StackPage { name: "spinner"; - child: Spinner instance_spinner { + child: Adw.Spinner { valign: center; }; } @@ -71,7 +71,7 @@ template $ProviderPreferences : Adw.NavigationPage { StackPage { name: "spinner"; - child: Spinner api_key_spinner { + child: Adw.Spinner { valign: center; }; } diff --git a/dialect/widgets/provider_preferences.py b/dialect/widgets/provider_preferences.py index 6aa066c7..e498b886 100644 --- a/dialect/widgets/provider_preferences.py +++ b/dialect/widgets/provider_preferences.py @@ -31,11 +31,9 @@ class ProviderPreferences(Adw.NavigationPage): instance_entry: Adw.EntryRow = Gtk.Template.Child() # type: ignore instance_stack: Gtk.Stack = Gtk.Template.Child() # type: ignore instance_reset: Gtk.Button = Gtk.Template.Child() # type: ignore - instance_spinner: Gtk.Spinner = Gtk.Template.Child() # type: ignore api_key_entry: Adw.PasswordEntryRow = Gtk.Template.Child() # type: ignore api_key_stack: Gtk.Stack = Gtk.Template.Child() # type: ignore api_key_reset: Gtk.Button = Gtk.Template.Child() # type: ignore - api_key_spinner: Gtk.Spinner = Gtk.Template.Child() # type: ignore api_usage_group: Adw.PreferencesGroup = Gtk.Template.Child() # type: ignore api_usage: Gtk.LevelBar = Gtk.Template.Child() # type: ignore api_usage_label: Gtk.Label = Gtk.Template.Child() # type: ignore @@ -109,7 +107,6 @@ def on_done(valid): self.instance_entry.props.sensitive = True self.api_key_entry.props.sensitive = True self.instance_stack.props.visible_child_name = "reset" - self.instance_spinner.stop() if not self.provider: return @@ -126,7 +123,6 @@ def on_done(valid): self.instance_entry.props.sensitive = False self.api_key_entry.props.sensitive = False self.instance_stack.props.visible_child_name = "spinner" - self.instance_spinner.start() # TODO: Use on_fail to notify network error self.provider.validate_instance(self.new_instance_url, on_done, lambda _: on_done(False)) @@ -177,7 +173,6 @@ def on_done(valid): self.instance_entry.props.sensitive = True self.api_key_entry.props.sensitive = True self.api_key_stack.props.visible_child_name = "reset" - self.api_key_spinner.stop() if not self.provider: return @@ -191,7 +186,6 @@ def on_done(valid): self.instance_entry.props.sensitive = False self.api_key_entry.props.sensitive = False self.api_key_stack.props.visible_child_name = "spinner" - self.api_key_spinner.start() # TODO: Use on_fail to notify network error self.provider.validate_api_key(self.new_api_key, on_done, lambda _: on_done(False)) diff --git a/dialect/widgets/speech_button.blp b/dialect/widgets/speech_button.blp new file mode 100644 index 00000000..63ad1237 --- /dev/null +++ b/dialect/widgets/speech_button.blp @@ -0,0 +1,46 @@ +using Gtk 4.0; +using Adw 1; + +template $SpeechButton : Button { + tooltip-text: _("Listen"); + + styles ["speech-button"] + + child: Overlay { + [overlay] + ProgressBar progress_bar { + visible: false; + valign: end; + + styles ["osd"] + } + + child: Stack stack { + StackPage { + name: "ready"; + child: Image { + icon-name: "audio-speakers-symbolic"; + }; + } + + StackPage { + name: "progress"; + child: Image { + icon-name: "media-playback-stop-symbolic"; + }; + } + + StackPage { + name: "error"; + child: Image { + icon-name: "dialect-speakers-broken-symbolic"; + }; + } + + StackPage { + name: "loading"; + child: Adw.Spinner {}; + } + }; + }; +} diff --git a/dialect/widgets/speech_button.py b/dialect/widgets/speech_button.py new file mode 100644 index 00000000..a9d5bc91 --- /dev/null +++ b/dialect/widgets/speech_button.py @@ -0,0 +1,41 @@ +# Copyright 2024 Mufeed Ali +# Copyright 2024 Rafael Mardojai CM +# SPDX-License-Identifier: GPL-3.0-or-later + +from gi.repository import Gtk + +from dialect.define import RES_PATH + + +@Gtk.Template(resource_path=f"{RES_PATH}/widgets/speech_button.ui") +class SpeechButton(Gtk.Button): + __gtype_name__ = "SpeechButton" + + stack: Gtk.Stack = Gtk.Template.Child() # type: ignore + progress_bar: Gtk.ProgressBar = Gtk.Template.Child() # type: ignore + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def ready(self): + self.stack.props.visible_child_name = "ready" + self.props.tooltip_text = _("Listen") + self.progress_bar.props.visible = False + + def progress(self, fraction: float): + if self.stack.props.visible_child_name != "progress": + self.stack.props.visible_child_name = "progress" + self.props.tooltip_text = _("Cancel Audio") + self.progress_bar.props.visible = True + + self.progress_bar.props.fraction = fraction + + def error(self, message: str): + self.stack.props.visible_child_name = "error" + self.props.tooltip_text = message + self.progress_bar.props.visible = False + + def loading(self): + self.stack.props.visible_child_name = "loading" + self.props.tooltip_text = _("Loading…") + self.progress_bar.props.visible = False diff --git a/dialect/window.blp b/dialect/window.blp index b84f5888..20129dec 100644 --- a/dialect/window.blp +++ b/dialect/window.blp @@ -59,29 +59,13 @@ template $DialectWindow : Adw.ApplicationWindow { StackPage { name: "loading"; child: WindowHandle { - Box { - orientation: vertical; - spacing: 12; - margin-top: 12; - margin-bottom: 12; - margin-start: 12; - margin-end: 12; - halign: center; - valign: center; - - Spinner { - spinning: true; - width-request: 32; - height-request: 32; - } + Adw.StatusPage loading_page { + paintable: Adw.SpinnerPaintable { + widget: loading_page; + }; - Label { - wrap: true; + accessibility { label: _("Loading…"); - - styles [ - "title-1", - ] } } }; @@ -408,7 +392,7 @@ template $DialectWindow : Adw.ApplicationWindow { icon-name: "edit-paste-symbolic"; } - Button src_voice_btn { + $SpeechButton src_speech_btn { action-name: "win.listen-src"; tooltip-text: _("Listen"); @@ -501,7 +485,7 @@ template $DialectWindow : Adw.ApplicationWindow { StackPage { name: "default"; child: Box { - Spinner trans_spinner { + Adw.Spinner trans_spinner { tooltip-text: _("Translating…"); margin-start: 8; } @@ -526,7 +510,7 @@ template $DialectWindow : Adw.ApplicationWindow { icon-name: "document-edit-symbolic"; } - Button dest_voice_btn { + $SpeechButton dest_speech_btn { action-name: "win.listen-dest"; tooltip-text: _("Listen"); diff --git a/dialect/window.py b/dialect/window.py index fbfb2a4e..b3dbedde 100644 --- a/dialect/window.py +++ b/dialect/window.py @@ -5,7 +5,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later import logging -from typing import IO, Literal +from typing import IO, Literal, TypedDict from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gst, Gtk @@ -22,7 +22,18 @@ from dialect.settings import Settings from dialect.shortcuts import DialectShortcutsWindow from dialect.utils import find_item_match, first_exclude -from dialect.widgets import LangSelector, TextView, ThemeSwitcher +from dialect.widgets import LangSelector, SpeechButton, TextView, ThemeSwitcher + + +class _OngoingSpeech(TypedDict): + text: str + lang: str + called_from: Literal["src", "dest"] + + +class _NotificationAction(TypedDict): + label: str + name: str @Gtk.Template(resource_path=f"{RES_PATH}/window.ui") @@ -58,7 +69,7 @@ class DialectWindow(Adw.ApplicationWindow): src_text: TextView = Gtk.Template.Child() # type: ignore clear_btn: Gtk.Button = Gtk.Template.Child() # type: ignore paste_btn: Gtk.Button = Gtk.Template.Child() # type: ignore - src_voice_btn: Gtk.Button = Gtk.Template.Child() # type: ignore + src_speech_btn: SpeechButton = Gtk.Template.Child() # type: ignore translate_btn: Gtk.Button = Gtk.Template.Child() # type: ignore dest_box: Gtk.Box = Gtk.Template.Child() # type: ignore @@ -66,11 +77,11 @@ class DialectWindow(Adw.ApplicationWindow): dest_pron_label: Gtk.Label = Gtk.Template.Child() # type: ignore dest_text: TextView = Gtk.Template.Child() # type: ignore dest_toolbar_stack: Gtk.Stack = Gtk.Template.Child() # type: ignore - trans_spinner: Gtk.Spinner = Gtk.Template.Child() # type: ignore + trans_spinner: Adw.Spinner = Gtk.Template.Child() # type: ignore trans_warning: Gtk.Image = Gtk.Template.Child() # type: ignore edit_btn: Gtk.Button = Gtk.Template.Child() # type: ignore copy_btn: Gtk.Button = Gtk.Template.Child() # type: ignore - dest_voice_btn: Gtk.Button = Gtk.Template.Child() # type: ignore + dest_speech_btn: SpeechButton = Gtk.Template.Child() # type: ignore actionbar: Gtk.ActionBar = Gtk.Template.Child() # type: ignore src_lang_selector_m: LangSelector = Gtk.Template.Child() # type: ignore @@ -85,8 +96,9 @@ class DialectWindow(Adw.ApplicationWindow): provider: dict[str, BaseProvider | None] = {"trans": None, "tts": None} # Text to speech - current_speech: dict[str, str] = {} - voice_loading = False # tts loading status + speech_provider_failed = False # tts provider loading failed + current_speech: _OngoingSpeech | None = None + speech_loading = False # tts loading status # Preset language values src_langs: list[str] = [] @@ -97,7 +109,6 @@ class DialectWindow(Adw.ApplicationWindow): # Translation-related variables next_trans = {} # for ongoing translation ongoing_trans = False # for ongoing translation - trans_failed = False # for monitoring connectivity issues trans_mistakes: tuple[str | None, str | None] = (None, None) # "mistakes" suggestions # Pronunciations trans_src_pron = None @@ -116,7 +127,7 @@ def __init__(self, **kwargs): if self.player: if bus := self.player.get_bus(): bus.add_signal_watch() - bus.connect("message", self.on_gst_message) + bus.connect("message", self._on_gst_message) # Setup window self.setup_actions() @@ -168,7 +179,8 @@ def setup_actions(self): self.add_action(copy_action) listen_dest_action = Gio.SimpleAction(name="listen-dest") - listen_dest_action.connect("activate", self.ui_dest_voice) + listen_dest_action.connect("activate", self.ui_dest_listen) + listen_dest_action.props.enabled = False self.add_action(listen_dest_action) suggest_action = Gio.SimpleAction(name="suggest") @@ -185,7 +197,8 @@ def setup_actions(self): self.add_action(suggest_cancel_action) listen_src_action = Gio.SimpleAction(name="listen-src") - listen_src_action.connect("activate", self.ui_src_voice) + listen_src_action.connect("activate", self.ui_src_listen) + listen_src_action.props.enabled = False self.add_action(listen_src_action) translation_action = Gio.SimpleAction(name="translation") @@ -270,17 +283,6 @@ def setup_translation(self): self.trans_spinner.hide() self.trans_warning.hide() - # Voice buttons prep-work - self.src_voice_warning = Gtk.Image.new_from_icon_name("dialog-warning-symbolic") - self.src_voice_image = Gtk.Image.new_from_icon_name("audio-speakers-symbolic") - self.src_voice_spinner = Gtk.Spinner() # For use while audio is running or still loading. - - self.dest_voice_warning = Gtk.Image.new_from_icon_name("dialog-warning-symbolic") - self.dest_voice_image = Gtk.Image.new_from_icon_name("audio-speakers-symbolic") - self.dest_voice_spinner = Gtk.Spinner() - - self.toggle_voice_spinner(True) - def load_translator(self): def on_done(): if not self.provider["trans"]: @@ -460,18 +462,33 @@ def remove_key_and_reload(self, _button): def load_tts(self): def on_done(): - self.download_speech() + self.speech_provider_failed = False + self.src_speech_btn.ready() + self.dest_speech_btn.ready() + self._check_speech_enabled() + + def on_fail(error: ProviderError): + button_text = _("Failed loading the text-to-speech service. Retry?") + toast_text = _("Failed loading the text-to-speech service") + if error.code == ProviderErrorCode.NETWORK: + toast_text = _("Failed loading the text-to-speech service, check for network issues") + + self.speech_provider_failed = True + self.src_speech_btn.error(button_text) + self.dest_speech_btn.error(button_text) + self.send_notification(toast_text) + self._check_speech_enabled() - def on_fail(_error: ProviderError): - self.on_listen_failed() + self.src_speech_btn.loading() + self.dest_speech_btn.loading() # TTS name provider = Settings.get().active_tts # Check if TTS is disabled if provider != "": - self.src_voice_btn.props.visible = True - self.dest_voice_btn.props.visible = True + self.src_speech_btn.props.visible = True + self.dest_speech_btn.props.visible = True # TTS Object self.provider["tts"] = TTS[provider]() @@ -486,47 +503,8 @@ def on_fail(_error: ProviderError): ) else: self.provider["tts"] = None - self.src_voice_btn.props.visible = False - self.dest_voice_btn.props.visible = False - - def on_listen_failed(self): - if not self.provider["tts"]: - return - - self.src_voice_btn.props.child = self.src_voice_warning - self.src_voice_spinner.stop() - - self.dest_voice_btn.props.child = self.dest_voice_warning - self.dest_voice_spinner.stop() - - tooltip_text = _("A network issue has occurred. Retry?") - self.src_voice_btn.props.tooltip_text = tooltip_text - self.dest_voice_btn.props.tooltip_text = tooltip_text - - if self.current_speech: - called_from = self.current_speech["called_from"] - action = { - "label": _("Retry"), - "name": "win.listen-src" if called_from == "src" else "win.listen-dest", - } - else: - action = None - - self.send_notification(_("A network issue has occurred. Please try again."), action=action) - - src_text = self.src_buffer.get_text(self.src_buffer.get_start_iter(), self.src_buffer.get_end_iter(), True) - dest_text = self.dest_buffer.get_text(self.dest_buffer.get_start_iter(), self.dest_buffer.get_end_iter(), True) - - if self.provider["tts"].tts_languages: - self.lookup_action("listen-src").set_enabled( # type: ignore - self.src_lang_selector.selected in self.provider["tts"].tts_languages and src_text != "" - ) - self.lookup_action("listen-dest").set_enabled( # type: ignore - self.dest_lang_selector.selected in self.provider["tts"].tts_languages and dest_text != "" - ) - else: - self.lookup_action("listen-src").props.enabled = src_text != "" # type: ignore - self.lookup_action("listen-dest").props.enabled = dest_text != "" # type: ignore + self.src_speech_btn.props.visible = False + self.dest_speech_btn.props.visible = False def translate(self, text: str, src_lang: str | None, dest_lang: str | None): """ @@ -569,7 +547,7 @@ def send_notification( self, text: str, queue: bool | None = False, - action: dict[str, str] | None = None, + action: _NotificationAction | None = None, timeout=5, priority=Adw.ToastPriority.NORMAL, ): @@ -598,34 +576,32 @@ def toast_dismissed(_toast: Adw.Toast): self.toast.props.priority = priority self.toast_overlay.add_toast(self.toast) - def toggle_voice_spinner(self, active=True): + def _check_speech_enabled(self): if not self.provider["tts"]: return - if active: - self.lookup_action("listen-src").props.enabled = False # type: ignore - self.src_voice_btn.props.child = self.src_voice_spinner - self.src_voice_spinner.start() - - self.lookup_action("listen-dest").props.enabled = False # type: ignore - self.dest_voice_btn.props.child = self.dest_voice_spinner - self.dest_voice_spinner.start() - else: - src_text = self.src_buffer.get_text(self.src_buffer.get_start_iter(), self.src_buffer.get_end_iter(), True) - self.lookup_action("listen-src").set_enabled( # type: ignore - self.src_lang_selector.selected in self.provider["tts"].tts_languages and src_text != "" - ) - self.src_voice_btn.props.child = self.src_voice_image - self.src_voice_spinner.stop() + src_playing = dest_playing = False + if self.current_speech: + src_playing = self.current_speech["called_from"] == "src" + dest_playing = self.current_speech["called_from"] == "dest" + + # Check src listen button + self.lookup_action("listen-src").set_enabled( # type: ignore + self.speech_provider_failed + or self.src_lang_selector.selected in self.provider["tts"].tts_languages + and self.src_buffer.get_char_count() != 0 + and not self.speech_loading + and not dest_playing + ) - dest_text = self.dest_buffer.get_text( - self.dest_buffer.get_start_iter(), self.dest_buffer.get_end_iter(), True - ) - self.lookup_action("listen-dest").set_enabled( # type: ignore - self.dest_lang_selector.selected in self.provider["tts"].tts_languages and dest_text != "" - ) - self.dest_voice_btn.props.child = self.dest_voice_image - self.dest_voice_spinner.stop() + # Check dest listen button + self.lookup_action("listen-dest").set_enabled( # type: ignore + self.speech_provider_failed + or self.dest_lang_selector.selected in self.provider["tts"].tts_languages + and self.dest_buffer.get_char_count() != 0 + and not self.speech_loading + and not src_playing + ) @Gtk.Template.Callback() def _on_src_lang_changed(self, _obj, _param): @@ -635,7 +611,6 @@ def _on_src_lang_changed(self, _obj, _param): code = self.src_lang_selector.selected dest_code = self.dest_lang_selector.selected - src_text = self.src_buffer.get_text(self.src_buffer.get_start_iter(), self.src_buffer.get_end_iter(), True) if self.provider["trans"].cmp_langs(code, dest_code): # Get first lang from saved src langs that is not current dest @@ -647,10 +622,6 @@ def _on_src_lang_changed(self, _obj, _param): self.dest_lang_selector.selected = valid or "" - # Disable or enable listen function. - if self.provider["tts"] and Settings.get().active_tts != "": - self.lookup_action("listen-src").set_enabled(code in self.provider["tts"].tts_languages and src_text != "") # type: ignore - if code in self.provider["trans"].src_languages: # Update saved src langs list if code in self.src_langs: @@ -667,6 +638,7 @@ def _on_src_lang_changed(self, _obj, _param): self.src_recent_lang_model.set_langs(self.src_langs, auto=True) self._check_switch_enabled() + self._check_speech_enabled() @Gtk.Template.Callback() def _on_dest_lang_changed(self, _obj, _param): @@ -676,7 +648,6 @@ def _on_dest_lang_changed(self, _obj, _param): code = self.dest_lang_selector.selected src_code = self.src_lang_selector.selected - dest_text = self.dest_buffer.get_text(self.dest_buffer.get_start_iter(), self.dest_buffer.get_end_iter(), True) if self.provider["trans"].cmp_langs(code, src_code): # Get first lang from saved dest langs that is not current src @@ -688,12 +659,6 @@ def _on_dest_lang_changed(self, _obj, _param): self.src_lang_selector.selected = valid or "" - # Disable or enable listen function. - if self.provider["tts"] and Settings.get().active_tts != "": - self.lookup_action("listen-dest").set_enabled( # type: ignore - code in self.provider["tts"].tts_languages and dest_text != "" - ) - # Update saved dest langs list if code in self.dest_langs: # Bring lang to the top @@ -709,6 +674,7 @@ def _on_dest_lang_changed(self, _obj, _param): self.dest_recent_lang_model.set_langs(self.dest_langs) self._check_switch_enabled() + self._check_speech_enabled() def _check_switch_enabled(self): if not self.provider["trans"]: @@ -797,6 +763,7 @@ def ui_copy(self, _action, _param): dest_text = self.dest_buffer.get_text(self.dest_buffer.get_start_iter(), self.dest_buffer.get_end_iter(), True) if display := Gdk.Display.get_default(): display.get_clipboard().set(dest_text) + self.send_notification(_("Copied to clipboard"), timeout=1) def ui_paste(self, _action, _param): def on_paste(clipboard: Gdk.Clipboard, result: Gio.AsyncResult): @@ -855,52 +822,66 @@ def ui_suggest_cancel(self, _action, _param): self.before_suggest = None self.dest_text.props.editable = False - def ui_src_voice(self, _action, _param): + def ui_src_listen(self, _action, _param): + if self.current_speech: + self._speech_reset() + return + src_text = self.src_buffer.get_text(self.src_buffer.get_start_iter(), self.src_buffer.get_end_iter(), True) src_language = self.src_lang_selector.selected self._pre_speech(src_text, src_language, "src") - def ui_dest_voice(self, _action, _param): + def ui_dest_listen(self, _action, _param): + if self.current_speech: + self._speech_reset() + return + dest_text = self.dest_buffer.get_text(self.dest_buffer.get_start_iter(), self.dest_buffer.get_end_iter(), True) dest_language = self.dest_lang_selector.selected self._pre_speech(dest_text, dest_language, "dest") def _pre_speech(self, text: str, lang: str, called_from: Literal["src", "dest"]): if text != "": - self.voice_loading = True - self.toggle_voice_spinner(True) - + self.speech_loading = True self.current_speech = {"text": text, "lang": lang, "called_from": called_from} + self._check_speech_enabled() + + if self.speech_provider_failed: + self.load_tts() + else: + self._download_speech() - self.download_speech() + if called_from == "src": # Show spinner on button + self.src_speech_btn.loading() + else: + self.dest_speech_btn.loading() + elif self.speech_provider_failed: + self.load_tts() - def on_gst_message(self, _bus, message: Gst.Message): + def _speech_reset(self, set_ready: bool = True): if not self.player: return - if message.type == Gst.MessageType.EOS: - self.player.set_state(Gst.State.NULL) - elif message.type == Gst.MessageType.ERROR: - self.player.set_state(Gst.State.NULL) - logging.error("Some error occurred while trying to play.") + self.player.set_state(Gst.State.NULL) + self.current_speech = None + self.speech_loading = False + self._check_speech_enabled() + + if set_ready: + self.src_speech_btn.ready() + self.dest_speech_btn.ready() - def download_speech(self): + def _download_speech(self): def on_done(file: IO): try: self._play_audio(file.name) file.close() except Exception as exc: logging.error(exc) - self.on_listen_failed() - else: - self.toggle_voice_spinner(False) - finally: - self.voice_loading = False - self.current_speech = {} + self._on_speech_failed() - def on_fail(_error: ProviderError): - self.on_listen_failed() - self.toggle_voice_spinner(False) + def on_fail(error: ProviderError): + self._on_speech_failed(error) if not self.provider["tts"]: return @@ -908,9 +889,29 @@ def on_fail(_error: ProviderError): if self.current_speech: lang: str = self.provider["tts"].denormalize_lang(self.current_speech["lang"]) # type: ignore self.provider["tts"].speech(self.current_speech["text"], lang, on_done, on_fail) - else: - self.toggle_voice_spinner(False) - self.voice_loading = False + + def _on_speech_failed(self, error: ProviderError | None = None): + text = _("Text-to-Speech failed") + action: _NotificationAction | None = None + + if error and error.code == ProviderErrorCode.NETWORK: + text = _("Text-to-Speech failed, check for network issues") + + if self.current_speech: + called_from = self.current_speech["called_from"] + action = { + "label": _("Retry"), + "name": "win.listen-src" if called_from == "src" else "win.listen-dest", + } + + button_text = _("Text-to-Speech failed. Retry?") + if called_from == "src": + self.src_speech_btn.error(button_text) + else: + self.dest_speech_btn.error(button_text) + + self.send_notification(text, action=action) + self._speech_reset(False) def _play_audio(self, path: str): if not self.player: @@ -919,6 +920,35 @@ def _play_audio(self, path: str): uri = "file://" + path self.player.set_property("uri", uri) self.player.set_state(Gst.State.PLAYING) + self.add_tick_callback(self._gst_progress_timeout) + + def _on_gst_message(self, _bus, message: Gst.Message): + if message.type == Gst.MessageType.EOS or message.type == Gst.MessageType.ERROR: + if message.type == Gst.MessageType.ERROR: + logging.error("Some error occurred while trying to play.") + self._speech_reset() + + def _gst_progress_timeout(self, _widget, _clock): + if not self.player: + return False + + if self.current_speech and self.player.get_state(Gst.CLOCK_TIME_NONE) != Gst.State.NULL: + have_pos, pos = self.player.query_position(Gst.Format.TIME) + have_dur, dur = self.player.query_duration(Gst.Format.TIME) + + if have_pos and have_dur: + if self.current_speech["called_from"] == "src": + self.src_speech_btn.progress(pos / dur) + else: + self.dest_speech_btn.progress(pos / dur) + + if self.speech_loading: + self.speech_loading = False + self._check_speech_enabled() + + return True + + return False @Gtk.Template.Callback() def _on_key_event(self, _ctrl, keyval: int, _keycode: int, state: Gdk.ModifierType): @@ -975,12 +1005,7 @@ def on_src_text_changed(self, buffer: Gtk.TextBuffer): sensitive = char_count != 0 self.lookup_action("translation").props.enabled = sensitive # type: ignore self.lookup_action("clear").props.enabled = sensitive # type: ignore - if not self.voice_loading and self.provider["tts"]: - self.lookup_action("listen-src").set_enabled( # type: ignore - self.src_lang_selector.selected in self.provider["tts"].tts_languages and sensitive - ) - elif not self.voice_loading and not self.provider["tts"]: - self.lookup_action("listen-src").props.enabled = sensitive # type: ignore + self._check_speech_enabled() def on_dest_text_changed(self, buffer: Gtk.TextBuffer): if not self.provider["trans"]: @@ -991,12 +1016,7 @@ def on_dest_text_changed(self, buffer: Gtk.TextBuffer): self.lookup_action("suggest").set_enabled( # type: ignore ProviderFeature.SUGGESTIONS in self.provider["trans"].features and sensitive ) - if not self.voice_loading and self.provider["tts"]: - self.lookup_action("listen-dest").set_enabled( # type: ignore - self.dest_lang_selector.selected in self.provider["tts"].tts_languages and sensitive - ) - elif not self.voice_loading and self.provider["tts"] is not None and not self.provider["tts"].tts_languages: - self.lookup_action("listen-dest").props.enabled = sensitive # type: ignore + self._check_speech_enabled() def user_action_ended(self, _buffer): if Settings.get().live_translation: @@ -1129,11 +1149,8 @@ def on_translation_success(self, translation: Translation): def on_translation_fail(self, error: ProviderError): if not self.next_trans: self.translation_finish() - self.trans_warning.props.visible = True - self.lookup_action("copy").props.enabled = False # type: ignore - self.lookup_action("listen-src").props.enabled = False # type: ignore - self.lookup_action("listen-dest").props.enabled = False # type: ignore + self.ongoing_trans = False match error.code: case ProviderErrorCode.NETWORK: @@ -1171,12 +1188,10 @@ def on_translation_fail(self, error: ProviderError): def translation_loading(self): self.trans_spinner.show() - self.trans_spinner.start() self.dest_box.props.sensitive = False self.langs_button_box.props.sensitive = False def translation_finish(self): - self.trans_spinner.stop() self.trans_spinner.hide() self.dest_box.props.sensitive = True self.langs_button_box.props.sensitive = True