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:
@@ -1,4 +1,4 @@
|
||||
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
|
||||
|
||||
__app_name__ = "Playtube"
|
||||
__version__ = "4.5.1"
|
||||
__app_name__ = "Playtube Edge"
|
||||
__version__ = "1.0.0"
|
||||
|
||||
@@ -30,15 +30,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from PySide6.QtCore import QUrl
|
||||
from PySide6.QtMultimedia import QMediaDevices
|
||||
from PySide6.QtWebEngineCore import QWebEnginePermission, QWebEngineProfile
|
||||
|
||||
# Name des eingeschleusten Skripts (pro Seite), um es beim Aendern ersetzen zu koennen.
|
||||
SCRIPT_NAME = "playtube-audio-output"
|
||||
|
||||
# Herkunft (Origin), fuer die die Berechtigung erteilt wird - nur die beiden Tabs.
|
||||
YOUTUBE_ORIGINS = ("https://www.youtube.com", "https://music.youtube.com")
|
||||
|
||||
SYSTEM_DEFAULT_LABEL = "Systemstandard"
|
||||
|
||||
@@ -54,19 +46,13 @@ def list_output_devices() -> list[str]:
|
||||
return names
|
||||
|
||||
|
||||
def sync_audio_permissions(profile: QWebEngineProfile, wanted: bool) -> None:
|
||||
"""Erteilt (wanted=True) bzw. entzieht (wanted=False) die Audiogeraete-Berechtigung fuer
|
||||
YouTube/YouTube Musik - damit die Seite die Namen der Ausgabegeraete sehen kann (siehe
|
||||
Moduldoku). Gilt nur fuer die laufende Sitzung, deshalb bei jedem Start aufrufen. Der
|
||||
Status wird von Qt fuer Mikrofon-Freigaben nicht zuverlaessig gemeldet (bleibt "Ask"),
|
||||
daher wird hier einfach jedes Mal erteilt bzw. zurueckgesetzt."""
|
||||
permission_type = QWebEnginePermission.PermissionType.MediaAudioCapture
|
||||
for origin in YOUTUBE_ORIGINS:
|
||||
permission = profile.queryPermission(QUrl(origin), permission_type)
|
||||
if wanted:
|
||||
permission.grant()
|
||||
else:
|
||||
permission.reset()
|
||||
def sync_audio_permissions(tabs, wanted: bool) -> None:
|
||||
"""Erlaubt (wanted=True) bzw. entzieht (wanted=False) den Browser-Tabs die
|
||||
Audiogeraete-Berechtigung fuer YouTube/YouTube Musik - damit die Seite die Namen der
|
||||
Ausgabegeraete sehen kann (siehe Moduldoku). In der Edge-Variante setzt jeder Tab die
|
||||
Freigabe selbst (WebView2-Profil, bleibt dort gespeichert)."""
|
||||
for tab in tabs:
|
||||
tab.set_device_names_allowed(wanted)
|
||||
|
||||
|
||||
# JavaScript-Vorlage; __TARGET__ wird durch den (JSON-kodierten) Geraetenamen ersetzt.
|
||||
|
||||
+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
|
||||
|
||||
+7
-3
@@ -8,15 +8,18 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
APP_NAME = "Playtube"
|
||||
APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID
|
||||
APP_NAME = "Playtube Edge"
|
||||
APP_AUMID = "Playtube.Edge.DesktopClient" # Windows AppUserModelID
|
||||
|
||||
# Der Entwicklungsmodus (python main.py) benutzt einen eigenen APPDATA-Ordner
|
||||
# ("PlaytubeDev" statt "Playtube"), damit Login-Profil und Config sich NIE mit einer
|
||||
# gepackten/installierten Playtube.exe ueberschneiden (frueher fuehrte das dazu, dass
|
||||
# ein lokaler Test-Build und die echte Installation sich dieselbe config.json bzw.
|
||||
# denselben Browser-Profil-Lock geteilt haben).
|
||||
_DATA_DIR_NAME = APP_NAME if getattr(sys, "frozen", False) else f"{APP_NAME}Dev"
|
||||
#
|
||||
# Edge-Variante (WebView2): eigene Ordnernamen ("PlaytubeEdge"/"PlaytubeEdgeDev"), damit sie sich
|
||||
# nie mit der Qt-Variante ueberschneidet (Profil, Einzelinstanz-Schutz, Fernsteuerungs-Pipe).
|
||||
_DATA_DIR_NAME = "PlaytubeEdge" if getattr(sys, "frozen", False) else "PlaytubeEdgeDev"
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"app_name": APP_NAME,
|
||||
@@ -28,6 +31,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
# Wenn nichts laeuft: Idle-Status anzeigen statt Presence komplett zu leeren.
|
||||
"show_idle_presence": True,
|
||||
},
|
||||
# Die Edge-Variante aktualisiert sich nur ueber eigene Releases (Tag "...-edge", siehe updater.py).
|
||||
"updates": {
|
||||
"enabled": True,
|
||||
"check_interval_hours": 6,
|
||||
|
||||
@@ -21,7 +21,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from . import __version__ as APP_VERSION
|
||||
from .audio_routing import sync_audio_permissions
|
||||
from .browser import BrowserTab, get_shared_profile
|
||||
from .browser import BrowserTab
|
||||
from .config import APP_NAME
|
||||
from .discord_rpc import DiscordRPCWorker
|
||||
from .remote_control import build_state as build_remote_state
|
||||
@@ -57,9 +57,9 @@ class MainWindow(QMainWindow):
|
||||
self._tabs.setDocumentMode(True)
|
||||
self.setCentralWidget(self._tabs)
|
||||
|
||||
self._sync_audio_permission()
|
||||
self._youtube_tab = BrowserTab(config["home_youtube"], self)
|
||||
self._music_tab = BrowserTab(config["home_music"], self)
|
||||
self._sync_audio_permission()
|
||||
# Zusaetzlich nach JEDEM Seitenaufbau erneut erteilen: eine nur VOR dem Laden erteilte
|
||||
# Freigabe greift auf der YouTube-Startseite nicht (per Test ermittelt), eine danach
|
||||
# erteilte schon (siehe audio_routing.py).
|
||||
@@ -254,7 +254,7 @@ class MainWindow(QMainWindow):
|
||||
def _sync_audio_permission(self) -> None:
|
||||
"""Erteilt bzw. entzieht die Freigabe der Geraetenamen (siehe audio_routing.py) -
|
||||
nur solange mindestens ein Tab ein eigenes Geraet nutzt."""
|
||||
sync_audio_permissions(get_shared_profile(), any(self._audio_outputs()))
|
||||
sync_audio_permissions((self._youtube_tab, self._music_tab), any(self._audio_outputs()))
|
||||
|
||||
def _on_tab_load_finished(self, _ok: bool) -> None:
|
||||
if any(self._audio_outputs()):
|
||||
|
||||
+22
-6
@@ -54,16 +54,19 @@ from PySide6.QtCore import QThread, Signal
|
||||
from . import __version__ as CURRENT_VERSION
|
||||
|
||||
GITHUB_REPO = "fojadrachi/Playtube"
|
||||
_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
||||
_USER_AGENT = "Playtube-Updater"
|
||||
# Edge-Variante: eigene Releases mit Tag "vX.Y.Z-edge" (als Vorabversion markiert, damit die
|
||||
# Qt-Version sie nie als "neueste Version" sieht) - deshalb die Liste statt /releases/latest.
|
||||
_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=30"
|
||||
_EDGE_TAG_SUFFIX = "-edge"
|
||||
_USER_AGENT = "PlaytubeEdge-Updater"
|
||||
|
||||
_LINUX_BINARY_NAME = "Playtube"
|
||||
_WINDOWS_BINARY_NAME = "Playtube.exe"
|
||||
_LINUX_BINARY_NAME = "PlaytubeEdge"
|
||||
_WINDOWS_BINARY_NAME = "PlaytubeEdge.exe"
|
||||
|
||||
# Muss mit "AppId" in packaging/playtube.iss uebereinstimmen. Unter dieser ID legt Inno
|
||||
# Setup den Deinstallations-Eintrag ("Apps & Features") an - nach einem Patch-Update
|
||||
# wird dort die angezeigte Versionsnummer nachgezogen (siehe _install_patch_windows).
|
||||
_INNO_APP_ID = "{87FBA502-2B84-4C42-A66B-2F03F490912D}"
|
||||
_INNO_APP_ID = "{4F0F3698-DAF3-4028-B0A4-896D31292C28}"
|
||||
|
||||
_STAGING_PREFIX = "playtube_update_"
|
||||
|
||||
@@ -105,9 +108,18 @@ def fetch_latest_release() -> dict[str, Any] | None:
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
releases = json.loads(resp.read().decode("utf-8"))
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(releases, list):
|
||||
return None
|
||||
# Neueste Edge-Version = hoechste Versionsnummer unter den "-edge"-Releases (ohne Entwuerfe).
|
||||
edge_releases = [
|
||||
release for release in releases
|
||||
if isinstance(release, dict) and not release.get("draft")
|
||||
and str(release.get("tag_name", "")).endswith(_EDGE_TAG_SUFFIX)
|
||||
]
|
||||
return max(edge_releases, key=lambda r: _parse_version(str(r.get("tag_name", ""))), default=None)
|
||||
|
||||
|
||||
def is_installed_via_setup() -> bool:
|
||||
@@ -125,6 +137,10 @@ def _asset_kind(name: str) -> str | None:
|
||||
*.play (unsere eigene Dateiendung, technisch ein ganz normales .zip), Voll -> *.zip.
|
||||
Linux: *.tar.gz, "patch" im Namen kennzeichnet das Patch-Paket."""
|
||||
name = name.lower()
|
||||
# Zweite Absicherung neben dem "-edge"-Tag: nur Dateien der Edge-Variante kommen in Frage,
|
||||
# nie ein Paket der Qt-Version ("Playtube-...").
|
||||
if "playtubeedge" not in name:
|
||||
return None
|
||||
if sys.platform == "win32":
|
||||
if name.endswith(".exe") and "setup" in name:
|
||||
return _KIND_SETUP
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Laedt Microsoft Edge WebView2 (per pythonnet / .NET Framework) fuer den Browser-Tab.
|
||||
|
||||
Die DLLs (Microsoft.Web.WebView2.Core/WinForms, WebView2Loader) liegen im Projektordner
|
||||
vendor/webview2 (offizielles NuGet-Paket "Microsoft.Web.WebView2"). Die eigentliche
|
||||
Browser-Engine ist die auf Windows 10/11 vorinstallierte "WebView2 Runtime" (Edge) - sie
|
||||
bringt AAC/H.264 mit, die in QtWebEngine fehlen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
class WebView2Unavailable(RuntimeError):
|
||||
"""WebView2 konnte nicht geladen werden (DLLs oder Runtime fehlen)."""
|
||||
|
||||
|
||||
def _vendor_dir() -> Path:
|
||||
base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent))
|
||||
return base / "vendor" / "webview2"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load() -> SimpleNamespace:
|
||||
"""Laedt .NET + WebView2 einmalig und gibt die benoetigten Klassen zurueck."""
|
||||
vendor = _vendor_dir()
|
||||
core_dll = vendor / "Microsoft.Web.WebView2.Core.dll"
|
||||
winforms_dll = vendor / "Microsoft.Web.WebView2.WinForms.dll"
|
||||
if not core_dll.exists() or not winforms_dll.exists():
|
||||
raise WebView2Unavailable(f"WebView2-DLLs fehlen in {vendor}")
|
||||
|
||||
# WebView2Loader.dll (nativ) wird ueber den Suchpfad bzw. runtimes/win-x64/native gefunden.
|
||||
os.environ["PATH"] = f"{vendor}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||
try:
|
||||
from pythonnet import load as load_runtime
|
||||
|
||||
load_runtime("netfx")
|
||||
import clr
|
||||
|
||||
clr.AddReference("System.Windows.Forms")
|
||||
clr.AddReference("System.Drawing")
|
||||
clr.AddReference(str(core_dll))
|
||||
clr.AddReference(str(winforms_dll))
|
||||
|
||||
from Microsoft.Web.WebView2.Core import (
|
||||
CoreWebView2PermissionKind,
|
||||
CoreWebView2PermissionState,
|
||||
)
|
||||
from Microsoft.Web.WebView2.WinForms import CoreWebView2CreationProperties, WebView2
|
||||
except Exception as error: # pythonnet wirft je nach Fehler unterschiedliche Typen
|
||||
raise WebView2Unavailable(f"WebView2 konnte nicht geladen werden: {error}") from error
|
||||
|
||||
return SimpleNamespace(
|
||||
WebView2=WebView2,
|
||||
CreationProperties=CoreWebView2CreationProperties,
|
||||
PermissionKind=CoreWebView2PermissionKind,
|
||||
PermissionState=CoreWebView2PermissionState,
|
||||
)
|
||||
Reference in New Issue
Block a user