feat: Playtube Edge 1.0.0 - WebView2 statt QtWebEngine (eigene Musik/AAC abspielbar)
Eigene Anwendung neben der Qt-Version: PlaytubeEdge.exe, eigene Installer-ID, eigener Datenordner, Updates nur ueber Releases mit Tag *-edge.
This commit is contained in:
+283
-171
@@ -1,25 +1,23 @@
|
||||
"""Eingebetteter Browser-Tab fuer YouTube bzw. YouTube Music, mit persistentem Login-
|
||||
Profil (eigener Datenordner, kein System-Browser-Profil) und periodischem Auslesen
|
||||
der aktuellen Wiedergabe fuer Discord Rich Presence."""
|
||||
"""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
|
||||
import sys
|
||||
from typing import Any, Callable
|
||||
|
||||
from PySide6.QtCore import QTimer, Signal, QUrl
|
||||
from PySide6.QtWebEngineCore import (
|
||||
QWebEnginePage,
|
||||
QWebEngineProfile,
|
||||
QWebEngineScript,
|
||||
QWebEngineSettings,
|
||||
QWebEngineUrlRequestInterceptor,
|
||||
)
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
from PySide6.QtCore import Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from .audio_routing import SCRIPT_NAME as AUDIO_SCRIPT_NAME
|
||||
from . import webview2_runtime
|
||||
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
|
||||
@@ -27,214 +25,328 @@ from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PL
|
||||
# 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"
|
||||
|
||||
# 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,
|
||||
# siehe chrome_shim.py) muessen konsistent dieselbe Version + Marke ("Google Chrome")
|
||||
# melden - sonst erkennt Google-Login den Browser als nicht vertrauenswuerdiges
|
||||
# Embedded-WebView und blockiert die Anmeldung mit "Dieser Browser oder diese App ist
|
||||
# unter Umstaenden nicht sicher".
|
||||
_CHROME_VERSION = CHROME_FULL
|
||||
_CHROME_MAJOR = CHROME_MAJOR
|
||||
_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)
|
||||
|
||||
if sys.platform == "win32":
|
||||
_UA_PLATFORM_TOKEN = "Windows NT 10.0; Win64; x64"
|
||||
_SEC_CH_UA_PLATFORM = b'"Windows"'
|
||||
_SEC_CH_UA_PLATFORM_VERSION = b'"15.0.0"'
|
||||
elif sys.platform.startswith("linux"):
|
||||
_UA_PLATFORM_TOKEN = "X11; Linux x86_64"
|
||||
_SEC_CH_UA_PLATFORM = b'"Linux"'
|
||||
_SEC_CH_UA_PLATFORM_VERSION = b'"6.0.0"'
|
||||
else:
|
||||
_UA_PLATFORM_TOKEN = "Macintosh; Intel Mac OS X 10_15_7"
|
||||
_SEC_CH_UA_PLATFORM = b'"macOS"'
|
||||
_SEC_CH_UA_PLATFORM_VERSION = b'"14.0.0"'
|
||||
|
||||
_USER_AGENT = (
|
||||
f"Mozilla/5.0 ({_UA_PLATFORM_TOKEN}) AppleWebKit/537.36 "
|
||||
f"(KHTML, like Gecko) Chrome/{_CHROME_VERSION} Safari/537.36"
|
||||
)
|
||||
|
||||
_SEC_CH_UA = (
|
||||
f'"Not(A:Brand";v="99", "Google Chrome";v="{_CHROME_MAJOR}", '
|
||||
f'"Chromium";v="{_CHROME_MAJOR}"'
|
||||
).encode("ascii")
|
||||
_SEC_CH_UA_FULL_VERSION_LIST = (
|
||||
f'"Not(A:Brand";v="99.0.0.0", "Google Chrome";v="{_CHROME_VERSION}", '
|
||||
f'"Chromium";v="{_CHROME_VERSION}"'
|
||||
).encode("ascii")
|
||||
_GWL_STYLE = -16
|
||||
_WS_POPUP = 0x80000000
|
||||
_WS_CAPTION = 0x00C00000
|
||||
_WS_CHILD = 0x40000000
|
||||
_WS_VISIBLE = 0x10000000
|
||||
_WS_CLIPSIBLINGS = 0x04000000
|
||||
_WS_CLIPCHILDREN = 0x02000000
|
||||
_SW_SHOW = 5
|
||||
|
||||
|
||||
class _ChromeBrandingInterceptor(QWebEngineUrlRequestInterceptor):
|
||||
"""Ergaenzt/korrigiert die Sec-CH-UA Client-Hint-Header auf jeder Anfrage, damit
|
||||
sie zum gesetzten User-Agent passen (echtes QtWebEngine meldet dort normalerweise
|
||||
nur "Chromium", ohne die Marke "Google Chrome" - genau daran erkennt Googles
|
||||
Login-Seite ein Embedded-WebView und blockiert die Anmeldung)."""
|
||||
|
||||
def interceptRequest(self, info) -> None: # noqa: N802 (Qt-Override)
|
||||
info.setHttpHeader(b"sec-ch-ua", _SEC_CH_UA)
|
||||
info.setHttpHeader(b"sec-ch-ua-full-version-list", _SEC_CH_UA_FULL_VERSION_LIST)
|
||||
info.setHttpHeader(b"sec-ch-ua-mobile", b"?0")
|
||||
info.setHttpHeader(b"sec-ch-ua-platform", _SEC_CH_UA_PLATFORM)
|
||||
info.setHttpHeader(b"sec-ch-ua-platform-version", _SEC_CH_UA_PLATFORM_VERSION)
|
||||
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
|
||||
|
||||
|
||||
_shared_profile: QWebEngineProfile | None = None
|
||||
_shared_interceptor: _ChromeBrandingInterceptor | None = None
|
||||
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();}})();"
|
||||
)
|
||||
|
||||
|
||||
def get_shared_profile() -> QWebEngineProfile:
|
||||
"""Ein gemeinsames, persistentes Profil fuer alle Tabs, damit ein einmaliges
|
||||
Google-Login fuer YouTube und YouTube Music gleichermassen gilt."""
|
||||
global _shared_profile, _shared_interceptor
|
||||
if _shared_profile is None:
|
||||
_shared_profile = QWebEngineProfile("playtube-profile")
|
||||
_shared_profile.setPersistentStoragePath(str(profile_dir() / "storage"))
|
||||
_shared_profile.setCachePath(str(profile_dir() / "cache"))
|
||||
_shared_profile.setPersistentCookiesPolicy(
|
||||
QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
|
||||
)
|
||||
_shared_profile.setHttpUserAgent(_USER_AGENT)
|
||||
_shared_profile.setHttpAcceptLanguage("de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7")
|
||||
|
||||
# Referenz muss gehalten werden, sonst sammelt Python das Objekt vorzeitig ein.
|
||||
_shared_interceptor = _ChromeBrandingInterceptor()
|
||||
_shared_profile.setUrlRequestInterceptor(_shared_interceptor)
|
||||
|
||||
# JS-seitiger "Chrome-Shim" (window.chrome, navigator.userAgentData, Plugins) -
|
||||
# muss vor jedem Seiten-JS laufen, daher DocumentCreation + MainWorld.
|
||||
shim_script = QWebEngineScript()
|
||||
shim_script.setName("playtube-chrome-shim")
|
||||
shim_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
|
||||
shim_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
||||
shim_script.setRunsOnSubFrames(True)
|
||||
shim_script.setSourceCode(CHROME_SHIM_JS)
|
||||
_shared_profile.scripts().insert(shim_script)
|
||||
return _shared_profile
|
||||
|
||||
|
||||
class BrowserTab(QWebEngineView):
|
||||
"""Ein WebEngine-Tab mit Medien-Ueberwachung fuer Discord Rich Presence."""
|
||||
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._audio_output_set = False # war schon ein eigenes Ausgabegeraet gewaehlt? (siehe set_audio_output)
|
||||
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
|
||||
|
||||
page = QWebEnginePage(get_shared_profile(), self)
|
||||
self.setPage(page)
|
||||
# Natives Fenster-Handle, damit das WebView2-Steuerelement als Kindfenster einhaengt.
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
|
||||
self.setMinimumSize(200, 150)
|
||||
|
||||
settings = page.settings()
|
||||
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
|
||||
settings.setAttribute(QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, False)
|
||||
settings.setAttribute(QWebEngineSettings.WebAttribute.FullScreenSupportEnabled, True)
|
||||
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, True)
|
||||
settings.setAttribute(QWebEngineSettings.WebAttribute.ScreenCaptureEnabled, True)
|
||||
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True)
|
||||
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
|
||||
|
||||
page.fullScreenRequested.connect(self._on_full_screen_requested)
|
||||
|
||||
self.load(QUrl(home_url))
|
||||
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(2000)
|
||||
self._poll_timer.setInterval(_MEDIA_POLL_MS)
|
||||
self._poll_timer.timeout.connect(self._poll_media_state)
|
||||
self._poll_timer.start()
|
||||
|
||||
self.titleChanged.connect(self.titleUpdated.emit)
|
||||
# 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.load(QUrl(self._home_url))
|
||||
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/Neuladen erhalten (Skript pro Seite)."""
|
||||
page = self.page()
|
||||
scripts = page.scripts()
|
||||
for old in scripts.find(AUDIO_SCRIPT_NAME):
|
||||
scripts.remove(old)
|
||||
|
||||
bleibt bei Seitenwechseln erhalten (Skript pro Dokument)."""
|
||||
source = build_router_script(device_name)
|
||||
if device_name:
|
||||
script = QWebEngineScript()
|
||||
script.setName(AUDIO_SCRIPT_NAME)
|
||||
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)
|
||||
script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
||||
script.setRunsOnSubFrames(False)
|
||||
script.setSourceCode(source)
|
||||
scripts.insert(script)
|
||||
|
||||
# Bereits geladene Seite sofort umstellen - auch zurueck auf den Systemstandard,
|
||||
# falls vorher ein eigenes Geraet gewaehlt war. Ohne jemals gewaehltes Geraet wird die
|
||||
# Seite gar nicht angefasst.
|
||||
if device_name or self._audio_output_set:
|
||||
page.runJavaScript(source)
|
||||
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.page().runJavaScript(TOGGLE_PLAYBACK_JS)
|
||||
self._run_js(TOGGLE_PLAYBACK_JS)
|
||||
|
||||
def next_track(self) -> None:
|
||||
self.page().runJavaScript(NEXT_TRACK_JS)
|
||||
self._run_js(NEXT_TRACK_JS)
|
||||
|
||||
def previous_track(self) -> None:
|
||||
self.page().runJavaScript(PREV_TRACK_JS)
|
||||
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.page().runJavaScript(build_control_js(command, value))
|
||||
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."""
|
||||
|
||||
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)
|
||||
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 (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()
|
||||
window = self.window()
|
||||
if request.toggleOn():
|
||||
window.showFullScreen()
|
||||
else:
|
||||
window.showNormal()
|
||||
die Wiedergabe."""
|
||||
self.setUrl(QUrl(MUSIC_PLAYLIST_URL.format(playlist_id=playlist_id)))
|
||||
|
||||
def _poll_media_state(self) -> None:
|
||||
self.page().runJavaScript(MEDIA_PROBE_JS, self._on_media_probe_result)
|
||||
|
||||
def _on_media_probe_result(self, result) -> None:
|
||||
# runJavaScript() liefert JS-Objekte ueber diese Bruecke nicht zuverlaessig
|
||||
# als dict (siehe media_probe.py) - das Probe-Skript gibt daher einen
|
||||
# JSON-String zurueck, der hier geparst wird.
|
||||
info = None
|
||||
if isinstance(result, str) and result:
|
||||
try:
|
||||
info = json.loads(result)
|
||||
except json.JSONDecodeError:
|
||||
info = None
|
||||
elif isinstance(result, dict):
|
||||
info = result
|
||||
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}] roh={result!r} geparst={info!r}", flush=True)
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user