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