From 33043f1319df53a17b9b7dd7b06996040a78266e Mon Sep 17 00:00:00 2001 From: fojadrachi Date: Fri, 25 Sep 2026 23:43:17 +0200 Subject: [PATCH] feat: lokale Fernsteuerung per Named Pipe fuer das Stream Dock Plugin Playback, Lautstaerke, Tab-Wechsel sowie list_playlists/play_playlist ueber \.\pipe\Playtube.Remote.; Tests inklusive. --- README.md | 18 +++ main.py | 7 + playtube/browser.py | 28 ++++ playtube/config.py | 3 + playtube/mainwindow.py | 39 +++++ playtube/media_control.py | 156 ++++++++++++++++++ playtube/media_probe.py | 30 +++- playtube/remote_control.py | 300 +++++++++++++++++++++++++++++++++++ tests/test_remote_control.py | 272 +++++++++++++++++++++++++++++++ 9 files changed, 852 insertions(+), 1 deletion(-) create mode 100644 playtube/media_control.py create mode 100644 playtube/remote_control.py create mode 100644 tests/test_remote_control.py diff --git a/README.md b/README.md index 9a35f97..3aa74f5 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,24 @@ Wichtig zu wissen: Gerätenamen). In der Konfiguration stehen die Namen unter `audio.youtube_output` und `audio.music_output` (leer = Systemstandard). +## Fernsteuerung (Stream Dock) + +Playtube lässt sich lokal fernsteuern, z.B. über das Stream-Dock-Plugin +`com.fojadrachi.playtube.sdPlugin` (Ajazz/Mirabox AKP153E & Co.). Dafür lauscht Playtube +auf einer Named Pipe (`\\.\pipe\Playtube.Remote.Playtube`, im Entwicklungsmodus +`...PlaytubeDev`). Es gibt keinen Netzwerk-Port, verbinden darf nur der angemeldete +Benutzer. + +- Protokoll: eine JSON-Nachricht pro Zeile, z.B. `{"id":1,"cmd":"play_pause","target":"auto"}`, + Antwort `{"id":1,"ok":true}`. Details in [playtube/remote_control.py](playtube/remote_control.py). +- Befehle: `status`, `show`, `switch_tab`, `play_pause`, `play`, `pause`, `next`, + `previous`, `seek`, `volume_change`, `set_volume`, `mute_toggle`, `like`, `dislike`, + `shuffle`, `repeat`, `list_playlists`, `play_playlist`. Es gibt eine feste Whitelist; + beliebiges JavaScript oder URLs lassen sich nicht senden. +- `target: "auto"` steuert den Tab, der gerade abspielt, sonst den sichtbaren. +- Abschalten: in `config.json` `"remote_control": {"enabled": false}`. +- Tests: `.venv\Scripts\python -m unittest discover -s tests -v` + ## App als eigenständige Playtube.exe packen Für die volle Taskmanager-/Audiomixer-Markierung wird die App als eigene .exe gebaut diff --git a/main.py b/main.py index 2b81ae4..fbbcb91 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from playtube import __version__, app_id # noqa: E402 from playtube.config import APP_NAME, app_data_dir, clear_cache_on_update, load_config # noqa: E402 +from playtube.remote_control import PIPE_PREFIX, RemoteControlServer # noqa: E402 from playtube.single_instance import SingleInstanceGuard # noqa: E402 from playtube.shortcuts import ( # noqa: E402 ensure_play_file_association, @@ -87,6 +88,12 @@ def main() -> int: instance_guard.showRequested.connect(window.show_and_raise) instance_guard.patchRequested.connect(window.install_local_patch) + # Lokale Fernsteuerung (Stream-Dock-Plugin), eigener Pipe-Name pro Datenordner - so + # steuert das Plugin die installierte Playtube.exe, nicht versehentlich einen Dev-Start. + if config.get("remote_control", {}).get("enabled", True): + remote_server = RemoteControlServer(f"{PIPE_PREFIX}{app_data_dir().name}", window, app) + remote_server.start() + # Playtube wurde per Doppelklick auf eine heruntergeladene .play-Patchdatei # gestartet -> direkt installieren statt selbst etwas herunterzuladen. if local_patch: diff --git a/playtube/browser.py b/playtube/browser.py index 169360c..0093709 100644 --- a/playtube/browser.py +++ b/playtube/browser.py @@ -21,8 +21,13 @@ from .audio_routing import SCRIPT_NAME as AUDIO_SCRIPT_NAME from .audio_routing import build_router_script from .chrome_shim import CHROME_FULL, CHROME_MAJOR, CHROME_SHIM_JS from .config import profile_dir +from .media_control import LIST_PLAYLISTS_JS, MUSIC_PLAYLIST_URL, build_control_js from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PLAYBACK_JS +# Nach einem Fernsteuerungsbefehl den Status kurz darauf neu auslesen (Seite braucht einen +# Moment, bis z.B. der neue Titel/Like-Status im DOM steht). +_REFRESH_AFTER_COMMAND_MS = 300 + # Chrome-Versionsnummer, die exakt zur tatsaechlich in QtWebEngine eingebetteten # Chromium-Version passt (siehe QWebEngineCore.qWebEngineChromiumVersion()). Legacy- # User-Agent, die "Sec-CH-UA" Client-Hints (Header) UND navigator.userAgentData (JS, @@ -180,6 +185,29 @@ class BrowserTab(QWebEngineView): def previous_track(self) -> None: self.page().runJavaScript(PREV_TRACK_JS) + def run_media_command(self, command: str, value: float = 0) -> None: + """Fuehrt einen Fernsteuerungsbefehl (siehe media_control.py) aus und liest den + Wiedergabestatus kurz danach neu aus, damit z.B. das Stream Dock den neuen Zustand + sofort statt erst beim naechsten 2-Sekunden-Poll sieht.""" + self.page().runJavaScript(build_control_js(command, value)) + QTimer.singleShot(_REFRESH_AFTER_COMMAND_MS, self._poll_media_state) + + def list_playlists(self, callback) -> None: + """Ruft callback(list | None) mit den Playlists aus der Seitenleiste auf.""" + + def on_result(result) -> None: + try: + callback(json.loads(result) if isinstance(result, str) and result else None) + except json.JSONDecodeError: + callback(None) + + self.page().runJavaScript(LIST_PLAYLISTS_JS, on_result) + + def play_playlist(self, playlist_id: str) -> None: + """Oeffnet die Playlist (ID vorher in remote_control validiert); Autoplay startet + die Wiedergabe (PlaybackRequiresUserGesture ist aus).""" + self.load(QUrl(MUSIC_PLAYLIST_URL.format(playlist_id=playlist_id))) + def _on_full_screen_requested(self, request) -> None: # Erlaubt echtes Fullscreen-Video (z.B. per YouTube-Fullscreen-Button). request.accept() diff --git a/playtube/config.py b/playtube/config.py index f7393f0..8c17649 100644 --- a/playtube/config.py +++ b/playtube/config.py @@ -38,6 +38,9 @@ DEFAULT_CONFIG: dict[str, Any] = { "youtube_output": "", "music_output": "", }, + # Lokale Fernsteuerung fuer das Stream-Dock-Plugin (Named Pipe, nur dieser Benutzer) - + # siehe playtube/remote_control.py. + "remote_control": {"enabled": True}, "start_tab": "youtube", # "youtube" oder "music" "home_youtube": "https://www.youtube.com/", "home_music": "https://music.youtube.com/", diff --git a/playtube/mainwindow.py b/playtube/mainwindow.py index 64c33ef..7a31e51 100644 --- a/playtube/mainwindow.py +++ b/playtube/mainwindow.py @@ -24,6 +24,8 @@ from .audio_routing import sync_audio_permissions from .browser import BrowserTab, get_shared_profile from .config import APP_NAME from .discord_rpc import DiscordRPCWorker +from .remote_control import build_state as build_remote_state +from .remote_control import resolve_target as resolve_remote_target from .settings_tab import SettingsTab from .updater import UpdateChecker, UpdateInstaller @@ -292,6 +294,43 @@ class MainWindow(QMainWindow): aus dem Tray, wenn Playtube ein zweites Mal gestartet wird.""" self._show_and_raise() + # ------------------------------------------------- Fernsteuerung (remote_control.py) + + def _browser_tabs_by_name(self) -> dict[str, BrowserTab]: + return {"youtube": self._youtube_tab, "music": self._music_tab} + + def _visible_tab_name(self) -> str: + current = self._current_tab() + for name, tab in self._browser_tabs_by_name().items(): + if current is tab: + return name + return "settings" + + def _media_by_tab_name(self) -> dict[str, dict | None]: + return {name: self._latest_media.get(id(tab)) for name, tab in self._browser_tabs_by_name().items()} + + def remote_state(self) -> dict: + return build_remote_state(APP_VERSION, self._visible_tab_name(), self._media_by_tab_name()) + + def remote_show(self) -> None: + self._show_and_raise() + + def remote_switch_tab(self, target: str) -> None: + """"youtube"/"music" waehlt den Tab; "auto" wechselt zwischen beiden hin und her.""" + if target == "auto": + target = "music" if self._visible_tab_name() == "youtube" else "youtube" + self._tabs.setCurrentWidget(self._browser_tabs_by_name()[target]) + + def remote_media_command(self, target: str, command: str, value: float) -> None: + name = resolve_remote_target(target, self._visible_tab_name(), self._media_by_tab_name()) + self._browser_tabs_by_name()[name].run_media_command(command, value) + + def remote_list_playlists(self, callback) -> None: + self._music_tab.list_playlists(callback) + + def remote_play_playlist(self, playlist_id: str) -> None: + self._music_tab.play_playlist(playlist_id) + def _quit(self) -> None: if self._rpc_worker is not None: self._rpc_worker.stop() diff --git a/playtube/media_control.py b/playtube/media_control.py new file mode 100644 index 0000000..7a06e9b --- /dev/null +++ b/playtube/media_control.py @@ -0,0 +1,156 @@ +"""JavaScript fuer die Fernsteuerung (siehe remote_control.py): baut pro Befehl ein kleines +Skript, das im YouTube- bzw. YouTube-Music-Tab ausgefuehrt wird. + +Es werden ausschliesslich feste Skripte aus einer Whitelist erzeugt; der einzige +variable Teil ist ein bereits validierter Zahlenwert (Sekunden/Lautstaerke), der als +Zahl eingesetzt wird - fremder Text landet nie im Skript.""" +from __future__ import annotations + +import math + +# Gemeinsame Helfer: der YouTube-Player (#movie_player) bietet auf youtube.com UND +# music.youtube.com eine API fuer Lautstaerke/Stummschaltung/Titelwechsel; das +#