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 0000000..2ff0708 Binary files /dev/null and b/assets/icon.ico differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..56e95f6 Binary files /dev/null and b/assets/icon.png differ diff --git a/config/yt-playlist-config.example.json b/config/yt-playlist-config.example.json index 81626ad..9dae509 100644 --- a/config/yt-playlist-config.example.json +++ b/config/yt-playlist-config.example.json @@ -3,6 +3,12 @@ "max_parallel_downloads": 2, "retry_max_retries": 2, "retry_delay_seconds": 1.5, + "ui": { + "tray": { + "close_to_tray": true, + "minimize_to_tray": false + } + }, "playlists": [ { "url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE", diff --git a/src/app/gui/app_icon.py b/src/app/gui/app_icon.py new file mode 100644 index 0000000..a2665ce --- /dev/null +++ b/src/app/gui/app_icon.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6 import QtGui, QtWidgets + + +def _resource_base() -> 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: