diff --git a/README.md b/README.md index 3a69bf5..34b4582 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,12 @@ Erweiterung) mit: - **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`), einmal bei Google anmelden reicht für beide Dienste. - **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und - einen Link-Button in deinem Discord-Profil, sobald etwas läuft. + einen Link-Button in deinem Discord-Profil, sobald etwas läuft. Ist der + **Einstellungen-Tab** offen, steht dort stattdessen „In den Einstellungen“ und welches + Feld du gerade bearbeitest (z.B. „Discord Rich Presence › Client-ID“) - nur der + Feldname, nie der Inhalt. Das folgt der Option „Status anzeigen, wenn gerade nichts + läuft“ (aus = auch kein Einstellungs-Status). Discord übernimmt Änderungen höchstens + alle 15 Sekunden, die Anzeige hinkt dem Klicken also etwas hinterher. - **System-Tray** – Fenster schliessen minimiert nur (Musik läuft weiter), Rechtsklick aufs Tray-Icon zum Beenden, Play/Pause/Skip direkt aus dem Menü. - **Eigener Name** – erscheint als "Playtube" im Taskmanager, Fenstertitel, Alt-Tab diff --git a/playtube/discord_rpc.py b/playtube/discord_rpc.py index e01b1f0..9f0f1fe 100644 --- a/playtube/discord_rpc.py +++ b/playtube/discord_rpc.py @@ -112,6 +112,24 @@ def build_idle_payload(session_start: int) -> dict[str, Any]: } +SETTINGS_DETAILS = "In den Einstellungen" +SETTINGS_DEFAULT_STATE = "Schaut sich die Einstellungen an" + + +def build_settings_payload(session_start: int, activity: str | None) -> dict[str, Any]: + """Presence, solange der Einstellungen-Tab offen ist. `activity` beschreibt, was + gerade bearbeitet wird (z.B. "Bearbeitet: Discord Rich Presence › Client-ID") - es + wird bewusst nur der NAME des Feldes uebergeben, nie sein Inhalt (sonst wuerde + z.B. die eingetragene Client-ID oeffentlich im Discord-Profil stehen).""" + return { + "details": SETTINGS_DETAILS, + "state": _truncate(activity, fallback=SETTINGS_DEFAULT_STATE), + "large_image": ASSET_DEFAULT, + "large_text": APP_NAME, + "start": session_start, + } + + class DiscordRPCWorker(QThread): """Eigener Thread, der die Verbindung zu Discord haelt und Presence-Updates debounced (max. 1 Update pro `interval` Sekunden) versendet.""" @@ -143,6 +161,11 @@ class DiscordRPCWorker(QThread): """Neuester bekannter Zustand (None = nichts spielt / idle).""" self._replace_queue(info if info is not None else _IDLE_SENTINEL) + def submit_settings_activity(self, activity: str | None) -> None: + """Nutzer ist im Einstellungen-Tab; `activity` = was gerade bearbeitet wird + (None = nur "schaut sich die Einstellungen an").""" + self._replace_queue(_SettingsActivity(activity)) + def _replace_queue(self, item) -> None: try: self._queue.get_nowait() @@ -254,6 +277,14 @@ class DiscordRPCWorker(QThread): else: payload = None self._presence.clear() + elif isinstance(item, _SettingsActivity): + # Wie beim Leerlauf: der naechste echte Track bekommt einen frischen + # Zeit-Anker. + self._track_key = None + self._track_start_ts = None + payload = build_settings_payload(self._session_start, item.activity) + self._presence.update(**payload) + log_line("[send-settings] state=%r" % (payload["state"],)) else: start_ts, end_ts = self._track_timestamps(item) payload = build_presence_payload(item, self._session_start, start_ts, end_ts) @@ -287,6 +318,15 @@ class DiscordRPCWorker(QThread): pass +class _SettingsActivity: + """Queue-Eintrag: Nutzer ist in den Einstellungen und bearbeitet `activity`.""" + + __slots__ = ("activity",) + + def __init__(self, activity: str | None) -> None: + self.activity = activity + + class _Sentinel: __slots__ = () diff --git a/playtube/mainwindow.py b/playtube/mainwindow.py index d66958a..f8b0b85 100644 --- a/playtube/mainwindow.py +++ b/playtube/mainwindow.py @@ -62,6 +62,7 @@ class MainWindow(QMainWindow): self._tabs.addTab(self._settings_tab, "Einstellungen") self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1) self._settings_tab.settingsSaved.connect(self._on_settings_saved) + self._settings_tab.activityChanged.connect(lambda _activity: self._update_discord_presence()) self._settings_tab.checkUpdatesRequested.connect(self._check_for_updates) self._settings_tab.installUpdateRequested.connect( lambda: self._start_update_install(self._pending_download_url) @@ -180,26 +181,46 @@ class MainWindow(QMainWindow): tab = self._current_browser_tab() self._url_bar.setEnabled(tab is not None) self._url_bar.setText(tab.url().toString() if tab is not None else "") + # Tab-Wechsel von/zu den Einstellungen aendert, was Discord anzeigen soll. + self._update_discord_presence() + + def _active_media_info(self) -> dict | None: + """Bevorzugt der Tab, der gerade wirklich abspielt, sonst der aktuell sichtbare.""" + playing_info = next((i for i in self._latest_media.values() if i.get("playing")), None) + return playing_info or self._latest_media.get(id(self._current_tab())) def _on_media_info(self, info: dict) -> None: sender = self.sender() self._latest_media[id(sender)] = info - # Update Fenstertitel + Discord: bevorzugt der Tab, der gerade wirklich - # abspielt, sonst der aktuell sichtbare Tab. - playing_info = next((i for i in self._latest_media.values() if i.get("playing")), None) - active_info = playing_info or self._latest_media.get(id(self._current_tab())) - + active_info = self._active_media_info() if active_info and active_info.get("title"): self.setWindowTitle(f"{active_info['title']} – {APP_NAME}") else: self.setWindowTitle(APP_NAME) - if self._rpc_worker is not None: - if active_info and active_info.get("hasVideo"): - self._rpc_worker.submit_media_info(active_info) - else: - self._rpc_worker.submit_media_info(None) + self._update_discord_presence() + + def _update_discord_presence(self) -> None: + """Entscheidet, was Discord gerade anzeigt: solange der Einstellungen-Tab offen + ist, "In den Einstellungen" samt dem gerade bearbeiteten Feld; sonst wie bisher + die laufende Wiedergabe bzw. den Leerlauf. Die Einstellungs-Anzeige folgt der + Option "Status anzeigen, wenn gerade nichts laeuft" (show_idle_presence) - wer den + Leerlauf-Status abgeschaltet hat, bekommt auch keinen Einstellungs-Status.""" + worker = getattr(self, "_rpc_worker", None) + if worker is None: + return + + discord_cfg = self._config.get("discord", {}) + if self._current_tab() is self._settings_tab and discord_cfg.get("show_idle_presence", True): + worker.submit_settings_activity(self._settings_tab.current_activity()) + return + + active_info = self._active_media_info() + if active_info and active_info.get("hasVideo"): + worker.submit_media_info(active_info) + else: + worker.submit_media_info(None) def _start_discord_worker(self) -> None: discord_cfg = self._config.get("discord", {}) @@ -219,6 +240,7 @@ class MainWindow(QMainWindow): self._rpc_worker.wait(2000) self._rpc_worker = None self._start_discord_worker() + self._update_discord_presence() # frischen Worker sofort mit dem aktuellen Stand versorgen self._setup_auto_update() diff --git a/playtube/settings_tab.py b/playtube/settings_tab.py index e35676d..2382f0d 100644 --- a/playtube/settings_tab.py +++ b/playtube/settings_tab.py @@ -6,6 +6,7 @@ from typing import Any from PySide6.QtCore import Signal from PySide6.QtWidgets import ( + QApplication, QCheckBox, QComboBox, QFormLayout, @@ -55,6 +56,9 @@ class SettingsTab(QWidget): settingsSaved = Signal(dict) checkUpdatesRequested = Signal() installUpdateRequested = Signal() + # Neue Beschreibung, was gerade bearbeitet wird (fuer die Discord-Presence, siehe + # MainWindow._update_discord_presence). + activityChanged = Signal(str) def __init__(self, config: dict[str, Any], parent=None): super().__init__(parent) @@ -155,6 +159,42 @@ class SettingsTab(QWidget): outer.addStretch(1) + # Beschreibung je Eingabefeld fuer die Discord-Presence ("was wird bearbeitet"). + # Bewusst nur Feld-NAMEN, nie Inhalte (z.B. nicht die Client-ID selbst). + self._activity_by_widget: dict[QWidget, str] = { + self._discord_enabled: "Bearbeitet: Discord Rich Presence › Aktiviert", + self._client_id: "Bearbeitet: Discord Rich Presence › Client-ID", + self._discord_interval: "Bearbeitet: Discord Rich Presence › Update-Intervall", + self._show_idle: "Bearbeitet: Discord Rich Presence › Status im Leerlauf", + self._updates_enabled: "Bearbeitet: Automatische Updates › Aktiviert", + self._check_interval: "Bearbeitet: Automatische Updates › Prüfintervall", + self._check_updates_btn: "Sucht nach Updates", + self._install_update_btn: "Installiert ein Update", + self._start_tab: "Bearbeitet: Allgemein › Beim Start öffnen", + save_btn: "Speichert die Einstellungen", + } + self._current_activity: str | None = None + app = QApplication.instance() + if app is not None: + app.focusChanged.connect(self._on_focus_changed) + + def current_activity(self) -> str | None: + """Zuletzt bearbeitetes Feld (None, solange noch kein Feld angeklickt wurde).""" + return self._current_activity + + def _on_focus_changed(self, _old: QWidget | None, new: QWidget | None) -> None: + # Vom fokussierten Widget nach oben suchen: bei QSpinBox/QComboBox liegt der Fokus + # auf einem internen Kind-Widget (z.B. dem Textfeld des Spinners). + widget = new + while widget is not None and widget is not self: + activity = self._activity_by_widget.get(widget) + if activity: + if activity != self._current_activity: + self._current_activity = activity + self.activityChanged.emit(activity) + return + widget = widget.parentWidget() + def _on_save(self) -> None: self._config.setdefault("discord", {}) self._config.setdefault("updates", {})