Eigene Anwendung neben der Qt-Version: PlaytubeEdge.exe, eigene Installer-ID, eigener Datenordner, Updates nur ueber Releases mit Tag *-edge.
353 lines
14 KiB
Python
353 lines
14 KiB
Python
"""Eingebetteter Browser-Tab (Microsoft Edge WebView2) fuer YouTube bzw. YouTube Music, mit
|
|
persistentem Login-Profil und periodischem Auslesen der aktuellen Wiedergabe fuer Discord Rich
|
|
Presence.
|
|
|
|
Edge-Variante von Playtube: statt QtWebEngine (kein AAC/H.264 - hochgeladene Titel liessen sich
|
|
nicht abspielen) rendert hier WebView2. Die Schnittstelle von BrowserTab ist dieselbe wie in der
|
|
Qt-Variante, damit MainWindow, Fernsteuerung und Discord unveraendert bleiben.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ctypes
|
|
import json
|
|
import os
|
|
from typing import Any, Callable
|
|
|
|
from PySide6.QtCore import Qt, QTimer, QUrl, Signal
|
|
from PySide6.QtWidgets import QWidget
|
|
|
|
from . import webview2_runtime
|
|
from .audio_routing import build_router_script
|
|
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
|
|
_TASK_POLL_MS = 25
|
|
_MEDIA_POLL_MS = 2000
|
|
_YOUTUBE_ORIGINS = ("https://www.youtube.com", "https://music.youtube.com")
|
|
# Ohne Nutzergeste starten koennen (Playlist-Start per Stream Dock, Autoplay).
|
|
_BROWSER_ARGUMENTS = "--autoplay-policy=no-user-gesture-required"
|
|
|
|
_user32 = ctypes.windll.user32 if os.name == "nt" else None
|
|
if _user32 is not None:
|
|
# 64-Bit-sichere Signaturen (Fensterstil und Handles sind pointer-gross).
|
|
_user32.GetWindowLongPtrW.restype = ctypes.c_ssize_t
|
|
_user32.GetWindowLongPtrW.argtypes = (ctypes.c_void_p, ctypes.c_int)
|
|
_user32.SetWindowLongPtrW.restype = ctypes.c_ssize_t
|
|
_user32.SetWindowLongPtrW.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_ssize_t)
|
|
_user32.SetParent.argtypes = (ctypes.c_void_p, ctypes.c_void_p)
|
|
_user32.MoveWindow.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_bool)
|
|
_user32.ShowWindow.argtypes = (ctypes.c_void_p, ctypes.c_int)
|
|
|
|
_GWL_STYLE = -16
|
|
_WS_POPUP = 0x80000000
|
|
_WS_CAPTION = 0x00C00000
|
|
_WS_CHILD = 0x40000000
|
|
_WS_VISIBLE = 0x10000000
|
|
_WS_CLIPSIBLINGS = 0x04000000
|
|
_WS_CLIPCHILDREN = 0x02000000
|
|
_SW_SHOW = 5
|
|
|
|
|
|
def _decode_script_result(raw: Any) -> Any:
|
|
"""ExecuteScriptAsync liefert das Ergebnis JSON-kodiert; unsere Skripte geben selbst
|
|
einen JSON-Text zurueck, deshalb wird bei Bedarf ein zweites Mal dekodiert."""
|
|
if raw is None:
|
|
return None
|
|
try:
|
|
value = json.loads(str(raw))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if isinstance(value, str):
|
|
try:
|
|
return json.loads(value)
|
|
except ValueError:
|
|
return value
|
|
return value
|
|
|
|
|
|
def _in_document(source: str) -> str:
|
|
"""Umhuellt ein Skript, das document.documentElement braucht, damit es auch beim frueh
|
|
(vor dem DOM) ausgefuehrten WebView2-Dokumentskript funktioniert."""
|
|
return (
|
|
"(function(){function go(){" + source + "}"
|
|
"if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',go);}"
|
|
"else{go();}})();"
|
|
)
|
|
|
|
|
|
class BrowserTab(QWidget):
|
|
"""Ein WebView2-Tab mit Medien-Ueberwachung fuer Discord Rich Presence."""
|
|
|
|
mediaInfoChanged = Signal(dict)
|
|
titleUpdated = Signal(str)
|
|
loadFinished = Signal(bool)
|
|
urlChanged = Signal(QUrl)
|
|
|
|
def __init__(self, home_url: str, parent=None):
|
|
super().__init__(parent)
|
|
self._home_url = home_url
|
|
self._current_url = QUrl(home_url)
|
|
self._audio_output_set = False
|
|
self._allow_device_names = False
|
|
self._router_script_id: str | None = None
|
|
self._core = None
|
|
self._pending: list[tuple[Any, Callable[[Any], None] | None]] = []
|
|
self._queued_urls: list[str] = [home_url]
|
|
self._queued_router: str | None = None
|
|
|
|
# Natives Fenster-Handle, damit das WebView2-Steuerelement als Kindfenster einhaengt.
|
|
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
|
|
self.setMinimumSize(200, 150)
|
|
|
|
runtime = webview2_runtime.load()
|
|
self._runtime = runtime
|
|
properties = runtime.CreationProperties()
|
|
properties.UserDataFolder = str(profile_dir() / "webview2")
|
|
properties.AdditionalBrowserArguments = _BROWSER_ARGUMENTS
|
|
properties.Language = "de-DE"
|
|
self._view = runtime.WebView2()
|
|
self._view.CreationProperties = properties
|
|
self._view.CoreWebView2InitializationCompleted += self._on_core_initialized
|
|
|
|
self._task_timer = QTimer(self)
|
|
self._task_timer.setInterval(_TASK_POLL_MS)
|
|
self._task_timer.timeout.connect(self._drain_tasks)
|
|
self._task_timer.start()
|
|
|
|
self._poll_timer = QTimer(self)
|
|
self._poll_timer.setInterval(_MEDIA_POLL_MS)
|
|
self._poll_timer.timeout.connect(self._poll_media_state)
|
|
self._poll_timer.start()
|
|
|
|
# Steuerelement erst nach dem Aufbau des Fensters erzeugen.
|
|
QTimer.singleShot(0, self._create_control)
|
|
|
|
# ------------------------------------------------------------ Aufbau
|
|
|
|
def _create_control(self) -> None:
|
|
self._view.CreateControl()
|
|
handle = self._handle()
|
|
# Aus dem frei stehenden WinForms-Fenster ein sichtbares Kindfenster des Qt-Widgets machen.
|
|
style = _user32.GetWindowLongPtrW(handle, _GWL_STYLE)
|
|
style = (style & ~_WS_POPUP & ~_WS_CAPTION) | _WS_CHILD | _WS_VISIBLE | _WS_CLIPSIBLINGS | _WS_CLIPCHILDREN
|
|
_user32.SetWindowLongPtrW(handle, _GWL_STYLE, style)
|
|
_user32.SetParent(handle, int(self.winId()))
|
|
_user32.ShowWindow(handle, _SW_SHOW)
|
|
self._fit_to_widget()
|
|
self._view.EnsureCoreWebView2Async(None)
|
|
|
|
def _handle(self) -> int:
|
|
return int(self._view.Handle.ToInt64())
|
|
|
|
def _fit_to_widget(self) -> None:
|
|
if _user32 is None or self._view is None or not self._view.IsHandleCreated:
|
|
return
|
|
ratio = self.devicePixelRatioF()
|
|
_user32.MoveWindow(self._handle(), 0, 0, int(self.width() * ratio), int(self.height() * ratio), True)
|
|
|
|
def resizeEvent(self, event) -> None: # noqa: N802 (Qt-Override)
|
|
super().resizeEvent(event)
|
|
self._fit_to_widget()
|
|
|
|
def _on_core_initialized(self, _sender, args) -> None:
|
|
try:
|
|
self._setup_core(args)
|
|
except Exception: # pythonnet verschluckt Fehler in Ereignis-Handlern sonst still
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
def _setup_core(self, args) -> None:
|
|
if not args.IsSuccess:
|
|
print(f"[webview2] Initialisierung fehlgeschlagen: {args.InitializationException}", flush=True)
|
|
self.loadFinished.emit(False)
|
|
return
|
|
core = self._view.CoreWebView2
|
|
self._core = core
|
|
settings = core.Settings
|
|
settings.IsStatusBarEnabled = False
|
|
settings.AreDefaultScriptDialogsEnabled = True
|
|
core.NavigationCompleted += lambda _s, a: self.loadFinished.emit(bool(a.IsSuccess))
|
|
core.SourceChanged += lambda _s, _a: self._on_source_changed()
|
|
core.DocumentTitleChanged += lambda _s, _a: self.titleUpdated.emit(str(core.DocumentTitle or ""))
|
|
core.NewWindowRequested += self._on_new_window
|
|
core.ContainsFullScreenElementChanged += lambda _s, _a: self._on_fullscreen_changed()
|
|
|
|
self._apply_device_name_permission()
|
|
if self._queued_router is not None:
|
|
self._install_router(self._queued_router)
|
|
self._queued_router = None
|
|
for url in self._queued_urls:
|
|
core.Navigate(url)
|
|
self._queued_urls.clear()
|
|
|
|
def _on_source_changed(self) -> None:
|
|
self._current_url = QUrl(str(self._core.Source))
|
|
self.urlChanged.emit(self._current_url)
|
|
|
|
def _on_new_window(self, _sender, args) -> None:
|
|
# Links mit target=_blank im selben Tab oeffnen (kein zusaetzliches Fenster).
|
|
args.Handled = True
|
|
self.setUrl(QUrl(str(args.Uri)))
|
|
|
|
def _on_fullscreen_changed(self) -> None:
|
|
window = self.window()
|
|
if self._core.ContainsFullScreenElement:
|
|
window.showFullScreen()
|
|
else:
|
|
window.showNormal()
|
|
self._fit_to_widget()
|
|
|
|
# ------------------------------------------------ asynchrone .NET-Aufgaben
|
|
|
|
def _await(self, task: Any, callback: Callable[[Any], None] | None = None) -> None:
|
|
"""Wartet ohne Blockieren auf eine .NET-Task und ruft danach callback(Ergebnis) auf."""
|
|
self._pending.append((task, callback))
|
|
|
|
def _drain_tasks(self) -> None:
|
|
if not self._pending:
|
|
return
|
|
still_pending = []
|
|
for task, callback in self._pending:
|
|
if not task.IsCompleted:
|
|
still_pending.append((task, callback))
|
|
continue
|
|
if callback is None:
|
|
continue
|
|
try:
|
|
callback(None if task.IsFaulted or task.IsCanceled else task.Result)
|
|
except Exception: # Fehler im Rueckruf duerfen die Warteschlange nicht blockieren
|
|
pass
|
|
self._pending = still_pending
|
|
|
|
def _run_js(self, script: str, callback: Callable[[Any], None] | None = None) -> None:
|
|
if self._core is None:
|
|
return
|
|
task = self._core.ExecuteScriptAsync(script)
|
|
self._await(task, (lambda raw: callback(_decode_script_result(raw))) if callback else None)
|
|
|
|
# ------------------------------------------------------------ Navigation
|
|
|
|
def url(self) -> QUrl:
|
|
return self._current_url
|
|
|
|
def setUrl(self, url: QUrl) -> None: # noqa: N802 (Qt-kompatibler Name)
|
|
target = url.toString()
|
|
self._current_url = url
|
|
if self._core is None:
|
|
self._queued_urls = [target]
|
|
else:
|
|
self._core.Navigate(target)
|
|
|
|
def go_home(self) -> None:
|
|
self.setUrl(QUrl(self._home_url))
|
|
|
|
def back(self) -> None:
|
|
if self._core is not None and self._core.CanGoBack:
|
|
self._core.GoBack()
|
|
|
|
def forward(self) -> None:
|
|
if self._core is not None and self._core.CanGoForward:
|
|
self._core.GoForward()
|
|
|
|
def reload(self) -> None:
|
|
if self._core is not None:
|
|
self._core.Reload()
|
|
|
|
# ------------------------------------------------------ Audio-Ausgabe
|
|
|
|
def set_audio_output(self, device_name: str) -> None:
|
|
"""Legt den Ton dieses Tabs auf das Ausgabegeraet `device_name` (leer =
|
|
Systemstandard), siehe audio_routing.py. Wirkt sofort auf die geoeffnete Seite und
|
|
bleibt bei Seitenwechseln erhalten (Skript pro Dokument)."""
|
|
source = build_router_script(device_name)
|
|
if self._core is None:
|
|
self._queued_router = source if device_name else None
|
|
else:
|
|
self._install_router(source if device_name else None)
|
|
# Bereits geladene Seite sofort umstellen - auch zurueck auf den Systemstandard.
|
|
if device_name or self._audio_output_set:
|
|
self._run_js(source)
|
|
self._audio_output_set = bool(device_name)
|
|
|
|
def _install_router(self, source: str | None) -> None:
|
|
old_id, self._router_script_id = self._router_script_id, None
|
|
if old_id:
|
|
self._core.RemoveScriptToExecuteOnDocumentCreated(old_id)
|
|
if source:
|
|
task = self._core.AddScriptToExecuteOnDocumentCreatedAsync(_in_document(source))
|
|
self._await(task, self._remember_router_id)
|
|
|
|
def _remember_router_id(self, script_id: Any) -> None:
|
|
self._router_script_id = str(script_id) if script_id else None
|
|
|
|
def set_device_names_allowed(self, allowed: bool) -> None:
|
|
"""Erlaubt der Seite, die Namen der Ausgabegeraete zu sehen (siehe audio_routing.py)."""
|
|
self._allow_device_names = allowed
|
|
self._apply_device_name_permission()
|
|
|
|
def _apply_device_name_permission(self) -> None:
|
|
if self._core is None:
|
|
return
|
|
state = self._runtime.PermissionState.Allow if self._allow_device_names else self._runtime.PermissionState.Default
|
|
try:
|
|
for origin in _YOUTUBE_ORIGINS:
|
|
self._core.Profile.SetPermissionStateAsync(self._runtime.PermissionKind.Microphone, origin, state)
|
|
except Exception as error: # aeltere Runtime ohne diese API: Seite laedt trotzdem
|
|
print(f"[webview2] Geraetenamen-Freigabe nicht setzbar: {error}", flush=True)
|
|
|
|
# ----------------------------------------------------------- Wiedergabe
|
|
|
|
def toggle_playback(self) -> None:
|
|
self._run_js(TOGGLE_PLAYBACK_JS)
|
|
|
|
def next_track(self) -> None:
|
|
self._run_js(NEXT_TRACK_JS)
|
|
|
|
def previous_track(self) -> None:
|
|
self._run_js(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._run_js(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."""
|
|
if self._core is None:
|
|
callback(None)
|
|
return
|
|
self._run_js(LIST_PLAYLISTS_JS, lambda result: callback(result if isinstance(result, list) else None))
|
|
|
|
def play_playlist(self, playlist_id: str) -> None:
|
|
"""Oeffnet die Playlist (ID vorher in remote_control validiert); Autoplay startet
|
|
die Wiedergabe."""
|
|
self.setUrl(QUrl(MUSIC_PLAYLIST_URL.format(playlist_id=playlist_id)))
|
|
|
|
def _poll_media_state(self) -> None:
|
|
self._run_js(MEDIA_PROBE_JS, self._on_media_probe_result)
|
|
|
|
def _on_media_probe_result(self, info: Any) -> None:
|
|
if os.environ.get("PLAYTUBE_DEBUG"):
|
|
print(f"[media-probe:{self._home_url}] geparst={info!r}", flush=True)
|
|
if isinstance(info, dict):
|
|
self.mediaInfoChanged.emit(info)
|
|
|
|
# ----------------------------------------------------------- Aufraeumen
|
|
|
|
def closeEvent(self, event) -> None: # noqa: N802 (Qt-Override)
|
|
self.dispose()
|
|
super().closeEvent(event)
|
|
|
|
def dispose(self) -> None:
|
|
self._task_timer.stop()
|
|
self._poll_timer.stop()
|
|
if self._view is not None:
|
|
self._view.Dispose()
|
|
self._view = None
|