From 49fedecd4348a7a01a16df33bb481eb749f61d71 Mon Sep 17 00:00:00 2001 From: DARKZOUL5 Date: Sun, 17 May 2026 13:51:15 +0300 Subject: [PATCH] feat: add app icon; feat: add app to tray; --- .github/workflows/build-release.yml | 4 +- README.md | 7 ++ assets/icon.ico | Bin 0 -> 3843 bytes assets/icon.png | Bin 0 -> 4945 bytes config/yt-playlist-config.example.json | 6 ++ src/app/gui/app_icon.py | 46 ++++++++++ src/app/gui/main.py | 111 ++++++++++++++++++++++++- src/app/gui/pages/settings.py | 32 +++++++ 8 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 assets/icon.ico create mode 100644 assets/icon.png create mode 100644 src/app/gui/app_icon.py diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 327dc6d..e622168 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -123,14 +123,14 @@ jobs: shell: pwsh run: | $ErrorActionPreference = "Stop" - pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py" + pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "assets/icon.ico" --add-data "assets/icon.png;assets" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py" - name: Build binary (Linux) if: runner.os == 'Linux' shell: bash run: | set -euo pipefail - pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py" + pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "assets/icon.png" --add-data "assets/icon.png:assets" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py" - name: Stage package shell: bash diff --git a/README.md b/README.md index b8f546e..f97199b 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,13 @@ Queue / retry: - Run `ytpl-sync.exe` (GUI). +## Tray + +- The app supports minimizing to tray on close if the OS provides a system tray; use the tray icon menu to quit. +- Tray behavior settings (Settings page): + - `close_to_tray`: close hides to tray (keeps running). + - `minimize_to_tray`: minimize hides to tray. + ## Data & Layout - Database: `db/app.db` diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..2ff07080213c475496c68e36d7e6b95ce2d34dad GIT binary patch literal 3843 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 literal 0 HcmV?d00001 diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..56e95f694d7dcd62fcd21460c4803473efdbce19 GIT binary patch literal 4945 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 Path: + # PyInstaller sets sys._MEIPASS to the temp extraction dir. + base = getattr(sys, "_MEIPASS", None) + if base: + return Path(str(base)) + return Path.cwd() + + +def load_app_icon() -> QtGui.QIcon: + """ + Best-effort app icon loader. + + Looks for `assets/icon.png` in the current working directory (dev), + or in the PyInstaller bundle root (packaged). + """ + candidates = [ + Path("assets/icon.png"), + _resource_base() / "assets" / "icon.png", + ] + for p in candidates: + try: + if p.exists(): + icon = QtGui.QIcon(str(p)) + if not icon.isNull(): + return icon + except Exception: + pass + + # Fallback to a platform theme icon (Linux) or a generic icon. + try: + themed = QtGui.QIcon.fromTheme("applications-multimedia") + if not themed.isNull(): + return themed + except Exception: + pass + + return QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ComputerIcon) + diff --git a/src/app/gui/main.py b/src/app/gui/main.py index 3e9eb12..60bcaa3 100644 --- a/src/app/gui/main.py +++ b/src/app/gui/main.py @@ -8,6 +8,8 @@ from PySide6 import QtCore, QtGui, QtWidgets from ..config.settings import Settings from ..core.events.event_bus import EventBus from .bus_bridge import BusBridge +from .app_icon import load_app_icon +from .config_store import load_config from .runner import SyncRequest, SyncRunner from .pages.playlists import PlaylistManagerPage from .pages.queue import QueuePage @@ -20,6 +22,7 @@ class MainWindow(QtWidgets.QMainWindow): super().__init__() self.setWindowTitle("ytpl-sync") self.resize(1100, 700) + self.setWindowIcon(load_app_icon()) self._settings = Settings() self._bus = EventBus() @@ -29,6 +32,8 @@ class MainWindow(QtWidgets.QMainWindow): self._runner: SyncRunner | None = None self._cancel_flag: threading.Event | None = None self._pause_flag: threading.Event | None = None + self._tray: QtWidgets.QSystemTrayIcon | None = None + self._tray_notified = False # Sidebar navigation self._nav = QtWidgets.QListWidget() @@ -87,6 +92,109 @@ class MainWindow(QtWidgets.QMainWindow): self._playlists_page.resume_requested.connect(self._resume_sync) self._refresh_queue_labels() + self._init_tray() + + def _tray_config(self) -> dict: + # Read from disk so toggles apply immediately (no restart required). + try: + cfg_path = getattr(self._settings, "path", None) + if cfg_path is None: + return {} + raw = load_config(cfg_path).data + ui = raw.get("ui") + ui = ui if isinstance(ui, dict) else {} + tray = ui.get("tray") + tray = tray if isinstance(tray, dict) else {} + return dict(tray) + except Exception: + return {} + + def _close_to_tray_enabled(self) -> bool: + return bool(self._tray_config().get("close_to_tray", True)) + + def _minimize_to_tray_enabled(self) -> bool: + return bool(self._tray_config().get("minimize_to_tray", False)) + + def _init_tray(self) -> None: + # Tray support is optional and platform-dependent (e.g., some Linux DEs). + try: + if not QtWidgets.QSystemTrayIcon.isSystemTrayAvailable(): + return + except Exception: + return + + icon = load_app_icon() + tray = QtWidgets.QSystemTrayIcon(icon, self) + tray.setToolTip("ytpl-sync") + + menu = QtWidgets.QMenu() + act_toggle = menu.addAction("Show/Hide") + act_quit = menu.addAction("Quit") + tray.setContextMenu(menu) + + act_toggle.triggered.connect(self._toggle_visible) + act_quit.triggered.connect(self._quit_from_tray) + tray.activated.connect(self._on_tray_activated) + + tray.show() + self._tray = tray + + def _toggle_visible(self) -> None: + if self.isVisible(): + self.hide() + else: + self.show() + self.raise_() + self.activateWindow() + + def _quit_from_tray(self) -> None: + # Ensure the closeEvent doesn't just hide the window. + self._tray = None + QtWidgets.QApplication.quit() + + def _on_tray_activated(self, reason: QtWidgets.QSystemTrayIcon.ActivationReason) -> None: + if reason in ( + QtWidgets.QSystemTrayIcon.ActivationReason.Trigger, + QtWidgets.QSystemTrayIcon.ActivationReason.DoubleClick, + ): + self._toggle_visible() + + def closeEvent(self, event: QtGui.QCloseEvent) -> None: # type: ignore[override] + # If tray is active and configured, close-to-tray. + if self._tray is not None and self._close_to_tray_enabled(): + event.ignore() + self.hide() + if not self._tray_notified: + self._tray_notified = True + try: + self._tray.showMessage( + "ytpl-sync", + "Still running in the tray. Use the tray icon menu to quit.", + QtWidgets.QSystemTrayIcon.MessageIcon.Information, + 3000, + ) + except Exception: + pass + return + if self._tray is not None and not self._close_to_tray_enabled(): + # Explicitly quit, because the app may be configured to keep running without windows. + try: + event.accept() + except Exception: + pass + QtWidgets.QApplication.quit() + return + super().closeEvent(event) + + def changeEvent(self, event: QtCore.QEvent) -> None: # type: ignore[override] + try: + if event.type() == QtCore.QEvent.Type.WindowStateChange: + if self._tray is not None and self._minimize_to_tray_enabled(): + if bool(self.windowState() & QtCore.Qt.WindowState.WindowMinimized): + QtCore.QTimer.singleShot(0, self.hide) + except Exception: + pass + super().changeEvent(event) def _refresh_queue_labels(self) -> None: try: @@ -270,7 +378,8 @@ def main() -> int: app = QtWidgets.QApplication(sys.argv) app.setApplicationName("ytpl-sync") app.setOrganizationName("ytpl-sync") - app.setWindowIcon(QtGui.QIcon()) + app.setWindowIcon(load_app_icon()) + app.setQuitOnLastWindowClosed(False) # Avoid Qt warnings when a font with invalid point size is inherited from the environment. f = app.font() diff --git a/src/app/gui/pages/settings.py b/src/app/gui/pages/settings.py index 61327f9..df7fc9e 100644 --- a/src/app/gui/pages/settings.py +++ b/src/app/gui/pages/settings.py @@ -49,6 +49,19 @@ class SettingsPage(QtWidgets.QWidget): form_box.setLayout(form) layout.addWidget(form_box) + tray_form = QtWidgets.QFormLayout() + self._close_to_tray = QtWidgets.QCheckBox() + self._close_to_tray.setChecked(True) + tray_form.addRow("close_to_tray", self._close_to_tray) + + self._minimize_to_tray = QtWidgets.QCheckBox() + self._minimize_to_tray.setChecked(False) + tray_form.addRow("minimize_to_tray", self._minimize_to_tray) + + tray_box = QtWidgets.QGroupBox("Tray behavior") + tray_box.setLayout(tray_form) + layout.addWidget(tray_box) + btns = QtWidgets.QHBoxLayout() self._reload_btn = QtWidgets.QPushButton("Reload") self._reload_btn.clicked.connect(self.reload_from_config) @@ -75,6 +88,8 @@ class SettingsPage(QtWidgets.QWidget): self._retry_max.valueChanged.connect(lambda _v: self._schedule_autosave()) self._retry_delay.valueChanged.connect(lambda _v: self._schedule_autosave()) self._download_delay.valueChanged.connect(lambda _v: self._schedule_autosave()) + self._close_to_tray.stateChanged.connect(lambda _v: self._schedule_autosave()) + self._minimize_to_tray.stateChanged.connect(lambda _v: self._schedule_autosave()) def set_config_path(self, path: Path) -> None: self._config_path = path @@ -96,6 +111,13 @@ class SettingsPage(QtWidgets.QWidget): self._retry_delay.setValue(float(self._config.get("retry_delay_seconds") or 1.5)) self._download_delay.setValue(float(self._config.get("delay_between_downloads_seconds") or 0.0)) + ui = self._config.get("ui") + ui = ui if isinstance(ui, dict) else {} + tray = ui.get("tray") + tray = tray if isinstance(tray, dict) else {} + self._close_to_tray.setChecked(bool(tray.get("close_to_tray", True))) + self._minimize_to_tray.setChecked(bool(tray.get("minimize_to_tray", False))) + self._status.setText(f"Loaded settings from {self._config_path}.") except Exception as exc: self._status.setText(f"Failed to load settings: {exc}") @@ -119,6 +141,16 @@ class SettingsPage(QtWidgets.QWidget): data["retry_max_retries"] = int(self._retry_max.value()) data["retry_delay_seconds"] = float(self._retry_delay.value()) data["delay_between_downloads_seconds"] = float(self._download_delay.value()) + + ui = data.get("ui") + ui = ui if isinstance(ui, dict) else {} + tray = ui.get("tray") + tray = tray if isinstance(tray, dict) else {} + tray["close_to_tray"] = bool(self._close_to_tray.isChecked()) + tray["minimize_to_tray"] = bool(self._minimize_to_tray.isChecked()) + ui["tray"] = tray + data["ui"] = ui + save_config(self._config_path, data) self._status.setText(f"Saved settings to {self._config_path}.") except Exception as exc: