diff --git a/.github/workflows/test_publish.yml b/.github/workflows/test_publish.yml index 3be0d9d..9b6b0fe 100644 --- a/.github/workflows/test_publish.yml +++ b/.github/workflows/test_publish.yml @@ -2,11 +2,11 @@ name: Build multi-arch Docker Image on: # Build and push - release: - types: [published] + #release: + # types: [published] # Build only - push: - branches: [main] + #push: + # branches: [main] # TODO - enable build on pull requests if build times can be reduced # pull_request: workflow_dispatch: diff --git a/CHANGELOG.md b/CHANGELOG.md index 869c195..6954003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # v1.1.0 (pre-release) - Added a feature that allows selecting multiple items in book handler (in right click menu) by @jborza in #31, that fixes #28 +- Added dark theme support, allowing users to switch between light and dark themes in the settings. # v1.0.9 - Added chunking/segmenting system that fixes memory outage issues when processing large audio files. diff --git a/abogen/assets/settings.png b/abogen/assets/settings.png deleted file mode 100644 index bd6fb88..0000000 Binary files a/abogen/assets/settings.png and /dev/null differ diff --git a/abogen/assets/settings.svg b/abogen/assets/settings.svg new file mode 100644 index 0000000..f7ab139 --- /dev/null +++ b/abogen/assets/settings.svg @@ -0,0 +1,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/abogen/book_handler.py b/abogen/book_handler.py index 55476b6..e5bf8ac 100644 --- a/abogen/book_handler.py +++ b/abogen/book_handler.py @@ -161,6 +161,7 @@ class HandlerDialog(QDialog): self.treeWidget.expandAll() if self.treeWidget.topLevelItemCount() > 0: self.treeWidget.setCurrentItem(self.treeWidget.topLevelItem(0)) + self.treeWidget.setFocus() # Update checkbox states self._update_checkbox_states() diff --git a/abogen/constants.py b/abogen/constants.py index a8fdfef..fca17a0 100644 --- a/abogen/constants.py +++ b/abogen/constants.py @@ -102,3 +102,19 @@ SAMPLE_VOICE_TEXTS = { "p": "Este é um exemplo da voz selecionada.", "z": "这是所选语音的示例。", } + +COLORS = { + "BLUE": "#007dff", + "RED": "#c0392b", + "YELLOW_BACKGROUND": "rgba(255, 221, 51, 0.40)", + "GREY_BACKGROUND": "rgba(128, 128, 128, 0.15)", + "RED_BACKGROUND": "rgba(232, 78, 60, 0.15)", + # Theme palette colors + "DARK_BG": "#202326", + "DARK_BASE": "#141618", + "DARK_ALT": "#2c2f31", + "DARK_BUTTON": "#292c30", + "DARK_DISABLED": "#535353", + "LIGHT_BG": "#eff0f1", + "LIGHT_DISABLED": "#9a9999", +} \ No newline at end of file diff --git a/abogen/conversion.py b/abogen/conversion.py index 5761f9c..1e6b262 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -693,8 +693,8 @@ class ConversionThread(QThread): if 'ass' in subtitle_format: # Generate ASS subtitle - is_centered = 'centered' in subtitle_format - is_narrow = 'narrow' in subtitle_format + is_centered = subtitle_format in ("ass_centered_wide", "ass_centered_narrow") + is_narrow = subtitle_format in ("ass_narrow", "ass_centered_narrow") self._write_ass_subtitle(chapter_subtitle_path, chapter_subtitle_entries, is_centered, is_narrow) else: # Generate SRT subtitle (default) @@ -772,9 +772,9 @@ class ConversionThread(QThread): subtitle_path = os.path.splitext(final_out_path)[0] + f".{file_extension}" if 'ass' in subtitle_format: - # Generate ASS subtitle with optional centering and margin - is_centered = 'centered' in subtitle_format - is_narrow = 'narrow' in subtitle_format + # Generate ASS subtitle + is_centered = subtitle_format in ("ass_centered_wide", "ass_centered_narrow") + is_narrow = subtitle_format in ("ass_narrow", "ass_centered_narrow") self._write_ass_subtitle(subtitle_path, subtitle_entries, is_centered, is_narrow) else: # Generate SRT subtitle (default) diff --git a/abogen/gui.py b/abogen/gui.py index f83910a..452fe8b 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -41,6 +41,7 @@ from PyQt5.QtCore import ( QIODevice, QSize, QTimer, + QEvent, ) from PyQt5.QtGui import ( QTextCursor, @@ -73,6 +74,7 @@ from constants import ( LANGUAGE_DESCRIPTIONS, VOICES_INTERNAL, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, + COLORS ) from threading import Thread from voice_formula_gui import VoiceFormulaDialog @@ -81,7 +83,22 @@ from voice_profiles import load_profiles # Import ctypes for Windows-specific taskbar icon if platform.system() == "Windows": import ctypes + import winreg +class DarkTitleBarEventFilter(QObject): + def __init__(self, is_windows, get_dark_mode_func, set_title_bar_dark_mode_func): + super().__init__() + self.is_windows = is_windows + self.get_dark_mode = get_dark_mode_func + self.set_title_bar_dark_mode = set_title_bar_dark_mode_func + + def eventFilter(self, obj, event): + if event.type() == QEvent.Show: + # Only apply to QWidget windows + if isinstance(obj, QWidget) and obj.isWindow(): + if self.is_windows and self.get_dark_mode(): + self.set_title_bar_dark_mode(obj, True) + return False class ShowWarningSignalEmitter(QObject): # New class to handle signal emission show_warning_signal = pyqtSignal(str, str) @@ -517,11 +534,26 @@ class TextboxDialog(QDialog): self.update_char_count() self.text_edit.setFocus() +def migrate_subtitle_format(config): + """Convert old subtitle_format values to new internal keys.""" + old_to_new = { + "srt": "srt", + "ass (wide)": "ass_wide", + "ass (narrow)": "ass_narrow", + "ass (centered wide)": "ass_centered_wide", + "ass (centered narrow)": "ass_centered_narrow", + } + val = config.get("subtitle_format") + if val in old_to_new: + config["subtitle_format"] = old_to_new[val] + save_config(config) class abogen(QWidget): def __init__(self): super().__init__() self.config = load_config() + self.apply_theme(self.config.get("theme", "system")) + migrate_subtitle_format(self.config) self.check_updates = self.config.get("check_updates", True) self.save_option = self.config.get("save_option", "Save next to input file") self.selected_output_folder = self.config.get("selected_output_folder", None) @@ -811,7 +843,7 @@ class abogen(QWidget): gpu_layout.addLayout(gpu_checkbox_layout) # Settings button with icon - settings_icon_path = get_resource_path("abogen.assets", "settings.png") + settings_icon_path = get_resource_path("abogen.assets", "settings.svg") self.settings_btn = QPushButton(self) if settings_icon_path and os.path.exists(settings_icon_path): self.settings_btn.setIcon(QIcon(settings_icon_path)) @@ -1470,7 +1502,7 @@ class abogen(QWidget): # Pass separate_chapters_format setting self.conversion_thread.separate_chapters_format = self.separate_chapters_format # Pass subtitle format setting - self.conversion_thread.subtitle_format = self.config.get("subtitle_format", "srt") + self.conversion_thread.subtitle_format = self.config.get("subtitle_format", "ass_centered_narrow") # Pass chapter count for EPUB or PDF files if self.selected_file_type in ["epub", "pdf"] and hasattr( self, "selected_chapters" @@ -2053,10 +2085,160 @@ class abogen(QWidget): # If dialog is rejected, cancel the conversion self.cancel_conversion() + def apply_theme(self, theme): + from PyQt5.QtGui import QPalette, QColor + from PyQt5.QtWidgets import QStyleFactory + + app = QApplication.instance() + is_windows = platform.system() == "Windows" + available_styles = [s.lower() for s in QStyleFactory.keys()] + + def is_windows_dark_mode(): + try: + import winreg + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" + ) as key: + value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") + return value == 0 + except Exception: + return False + + # --- Theme selection logic --- + def set_dark_palette(): + palette = QPalette() + dark_bg = QColor(COLORS["DARK_BG"]) + base_bg = QColor(COLORS["DARK_BASE"]) + alt_bg = QColor(COLORS["DARK_ALT"]) + button_bg = QColor(COLORS["DARK_BUTTON"]) + disabled_fg = QColor(COLORS["DARK_DISABLED"]) + palette.setColor(QPalette.ColorRole.Window, dark_bg) + palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Base, base_bg) + palette.setColor(QPalette.ColorRole.AlternateBase, alt_bg) + palette.setColor(QPalette.ColorRole.ToolTipBase, dark_bg) + palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Button, button_bg) + palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white) + # Disabled roles + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_fg) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_fg) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_fg) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, dark_bg) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Button, dark_bg) + app.setPalette(palette) + + + def set_light_palette(): + palette = QPalette() + disabled_fg = QColor(COLORS["LIGHT_DISABLED"]) + palette.setColor(QPalette.ColorRole.Window, QColor(COLORS["LIGHT_BG"])) + palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.black) + palette.setColor(QPalette.ColorRole.Base, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.AlternateBase, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.ToolTipBase, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.black) + palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.black) + palette.setColor(QPalette.ColorRole.Button, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.black) + # Disabled roles + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_fg) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_fg) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_fg) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Button, Qt.GlobalColor.white) + app.setPalette(palette) + + # --- Dark title bar support for Windows --- + def set_title_bar_dark_mode(window, enable): + if is_windows: + try: + window.update() + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + set_window_attribute = ctypes.windll.dwmapi.DwmSetWindowAttribute + hwnd = int(window.winId()) + value = ctypes.c_int(2 if enable else 0) + set_window_attribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ctypes.byref(value), ctypes.sizeof(value)) + except Exception: + pass + + # Main logic + dark_mode = theme == "dark" or (theme == "system" and is_windows and is_windows_dark_mode()) + if dark_mode: + app.setStyle("Fusion") + set_dark_palette() + elif (theme == "light" or theme == "system") and is_windows: + if "windowsvista" in available_styles: + app.setStyle("windowsvista") + else: + app.setStyle("Fusion") + app.setPalette(QPalette()) + elif theme == "light": + app.setStyle("Fusion") + set_light_palette() + else: + app.setStyle("Fusion") + app.setPalette(QPalette()) + + # Always set the title bar mode according to the current theme for all top-level widgets + for widget in app.topLevelWidgets(): + set_title_bar_dark_mode(widget, dark_mode) + + # Refresh all top-level widgets + style_name = app.style().objectName() + app.setStyle(style_name) + for widget in app.topLevelWidgets(): + app.style().polish(widget) + widget.update() + + # Remove old event filter if present, then install a new one for dark title bar on new windows + if hasattr(app, "_dark_titlebar_event_filter"): + app.removeEventFilter(app._dark_titlebar_event_filter) + delattr(app, "_dark_titlebar_event_filter") + + def get_dark_mode(): + return theme == "dark" or (theme == "system" and is_windows and is_windows_dark_mode()) + app._dark_titlebar_event_filter = DarkTitleBarEventFilter( + is_windows, get_dark_mode, set_title_bar_dark_mode + ) + app.installEventFilter(app._dark_titlebar_event_filter) + + # Save config if changed + if self.config.get("theme", "system") != theme: + self.config["theme"] = theme + save_config(self.config) + def show_settings_menu(self): """Show a dropdown menu for settings options.""" menu = QMenu(self) + theme_menu = QMenu("Theme", self) + theme_menu.setToolTip("Choose the application theme") + + theme_group = QActionGroup(self) + theme_group.setExclusive(True) + + # Theme options: (internal_value, display_text) + theme_options = [ + ("system", "System"), + ("light", "Light"), + ("dark", "Dark"), + ] + + # Get current theme from config, default to "system" + current_theme = self.config.get("theme", "system") + for value, text in theme_options: + theme_action = QAction(text, self) + theme_action.setCheckable(True) + theme_action.setChecked(current_theme == value) + theme_action.triggered.connect(lambda checked, v=value: self.apply_theme(v)) + theme_group.addAction(theme_action) + theme_menu.addAction(theme_action) + + menu.addMenu(theme_menu) + # Add replace single newlines option newline_action = QAction("Replace single newlines with spaces", self) newline_action.setCheckable(True) @@ -2072,19 +2254,28 @@ class abogen(QWidget): # Add subtitle format option subtitle_format_menu = QMenu("Subtitle format", self) subtitle_format_menu.setToolTip("Choose the format for generated subtitles") - + subtitle_format_group = QActionGroup(self) subtitle_format_group.setExclusive(True) - - subtitle_format = self.config.get("subtitle_format", "srt") - for format_option in ["srt", "ass (wide)", "ass (narrow)", "ass (centered wide)", "ass (centered narrow)"]: - format_action = QAction(f"{format_option}", self) + + # Define mapping: (internal_value, display_text) + subtitle_formats = [ + ("srt", "SRT (standard)"), + ("ass_wide", "ASS (wide)"), + ("ass_narrow", "ASS (narrow)"), + ("ass_centered_wide", "ASS (centered wide)"), + ("ass_centered_narrow", "ASS (centered narrow)"), + ] + + subtitle_format = self.config.get("subtitle_format", "ass_centered_narrow") + for value, text in subtitle_formats: + format_action = QAction(text, self) format_action.setCheckable(True) - format_action.setChecked(subtitle_format == format_option) - format_action.triggered.connect(lambda checked, fmt=format_option: self.set_subtitle_format(fmt)) + format_action.setChecked(subtitle_format == value) + format_action.triggered.connect(lambda checked, fmt=value: self.set_subtitle_format(fmt)) subtitle_format_group.addAction(format_action) subtitle_format_menu.addAction(format_action) - + menu.addMenu(subtitle_format_menu) # Add separate chapters format option diff --git a/abogen/is_nvidia.py b/abogen/is_nvidia.py index 2777f7b..78bd8cf 100644 --- a/abogen/is_nvidia.py +++ b/abogen/is_nvidia.py @@ -6,11 +6,15 @@ def check(): except Exception: return False + nvidia_keywords = ['nvidia', 'rtx', 'gtx', 'quadro', 'tesla', 'titan', 'mx'] for gpu in stats.gpus: - print(gpu.name) - if 'nvidia' in gpu.name.lower(): + name = gpu.name.lower() + if any(keyword in name for keyword in nvidia_keywords): return True return False if __name__ == "__main__": + stats = gpustat.new_query() + for gpu in stats.gpus: + print(gpu.name) print(check()) \ No newline at end of file diff --git a/abogen/main.py b/abogen/main.py index ad59430..2a311a9 100644 --- a/abogen/main.py +++ b/abogen/main.py @@ -3,6 +3,7 @@ import sys import platform from PyQt5.QtWidgets import QApplication from PyQt5.QtGui import QIcon +from PyQt5.QtCore import qInstallMessageHandler, QtMsgType # Add the directory to Python path sys.path.insert(0, os.path.join(os.path.dirname(__file__))) @@ -24,6 +25,25 @@ if sys.stderr is None: if platform.system() == "Darwin" and platform.processor() == "arm": os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1" +# Custom message handler to filter out specific Qt warnings +def qt_message_handler(mode, context, message): + if "Wayland does not support QWindow::requestActivate()" in message: + return # Suppress this specific message + if "setGrabPopup called with a parent, QtWaylandClient" in message: + return + if mode == QtMsgType.QtWarningMsg: + print(f"Qt Warning: {message}") + elif mode == QtMsgType.QtCriticalMsg: + print(f"Qt Critical: {message}") + elif mode == QtMsgType.QtFatalMsg: + print(f"Qt Fatal: {message}") + elif mode == QtMsgType.QtInfoMsg: + print(f"Qt Info: {message}") + + +# Install the custom message handler +qInstallMessageHandler(qt_message_handler) + # Set application ID for Windows taskbar icon if platform.system() == "Windows": import ctypes diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index b182b09..a9d0d70 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -24,14 +24,18 @@ from PyQt5.QtWidgets import ( QMenu, QAction, QComboBox, + QApplication, ) from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QSize -from PyQt5.QtGui import QPixmap, QIcon, QColor +from PyQt5.QtGui import QPixmap, QIcon from constants import ( VOICES_INTERNAL, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, LANGUAGE_DESCRIPTIONS, + COLORS, ) +import re +import platform from utils import get_resource_path from voice_profiles import ( load_profiles, @@ -221,6 +225,19 @@ class VoiceMixer(QWidget): self.slider.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) self.slider.setFixedWidth(SLIDER_WIDTH) + # Fix slider in Windows + if platform.system() == "Windows": + appstyle = QApplication.instance().style().objectName().lower() + if appstyle != "windowsvista": + # Set custom groove color for disabled state using COLORS["GREY_BACKGROUND"] + self.slider.setStyleSheet(f""" + QSlider::groove:vertical:disabled {{ + background: {COLORS.get("GREY_BACKGROUND")}; + width: 4px; + border-radius: 4px; + }} + """) + # Connect controls self.slider.valueChanged.connect(lambda val: self.spin_box.setValue(val / 100)) self.spin_box.valueChanged.connect( @@ -271,9 +288,9 @@ class HoverLabel(QLabel): self.delete_button = QPushButton("×", self) self.delete_button.setFixedSize(16, 16) self.delete_button.setStyleSheet( - """ - QPushButton { - background-color: #ff5555; + f""" + QPushButton {{ + background-color: {COLORS.get("RED")}; color: white; border-radius: 7px; font-weight: bold; @@ -281,11 +298,11 @@ class HoverLabel(QLabel): border: none; padding: 0px; margin: 0px; - } - QPushButton:hover { + }} + QPushButton:hover {{ background-color: red; - } - """ + }} + """ ) # Make sure the entire button is clickable, not just the text self.delete_button.setFocusPolicy(Qt.NoFocus) @@ -350,6 +367,9 @@ class VoiceFormulaDialog(QDialog): profile_layout.addLayout(header_layout) # Profile list self.profile_list = QListWidget() + self.profile_list.setSelectionMode(QListWidget.SingleSelection) + self.profile_list.setSelectionBehavior(QListWidget.SelectRows) + self.profile_list.setStyleSheet("QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }") icon = QIcon(get_resource_path("abogen.assets", "profile.png")) if self._virtual_new_profile: item = QListWidgetItem(icon, "New profile") @@ -738,7 +758,7 @@ class VoiceFormulaDialog(QDialog): percentage = weight / total * 100 # Make the voice name bold and include percentage voice_label = HoverLabel( - f'{name}: {percentage:.1f}%', + f'{name}: {percentage:.1f}%', name, ) voice_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) @@ -953,6 +973,18 @@ class VoiceFormulaDialog(QDialog): return super().closeEvent(event) + def _parse_rgba_to_qcolor(self, rgba_str): + from PyQt5.QtCore import Qt + from PyQt5.QtGui import QColor + """Helper to convert 'rgba(R,G,B,A_float)' string to QColor.""" + match = re.match(r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)", rgba_str) + if match: + r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3)) + a_float = float(match.group(4)) + a_int = int(a_float * 255) + return QColor(r, g, b, a_int) + return Qt.GlobalColor.transparent + def mark_profile_modified(self): item = self.profile_list.currentItem() if item and not item.text().startswith("*"): @@ -1339,23 +1371,20 @@ class VoiceFormulaDialog(QDialog): self.profile_list.setItemWidget(item, widget) def update_profile_list_colors(self): + from PyQt5.QtCore import Qt profiles = load_profiles() for i in range(self.profile_list.count()): item = self.profile_list.item(i) name = item.text().lstrip("*") if self._virtual_new_profile and name == "New profile": - color = QColor("#ffff00") # yellow - color.setAlpha(64) # Set transparency - item.setBackground(color) + color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND")) + item.setData(Qt.BackgroundRole, color) elif item.text().startswith("*"): - color = QColor("#ffff00") # yellow - color.setAlpha(64) # Set transparency - item.setBackground(color) + color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND")) + item.setData(Qt.BackgroundRole, color) else: - item.setBackground(self.profile_list.palette().base().color()) # Use default list item background - + item.setData(Qt.BackgroundRole, self.profile_list.palette().base().color()) weights = profiles.get(name, {}).get("voices", []) - # Defensive: only sum if weights is a list of (voice, weight) pairs total = 0 if isinstance(weights, list): for entry in weights: @@ -1366,9 +1395,8 @@ class VoiceFormulaDialog(QDialog): ): total += entry[1] if total == 0: - color = QColor("#ff0000") # red - color.setAlpha(64) # Set transparency - item.setBackground(color) + color = self._parse_rgba_to_qcolor(COLORS.get("RED_BACKGROUND")) + item.setData(Qt.BackgroundRole, color) self.update_profile_save_buttons() def preview_current_mix(self):