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,14 +1,19 @@
|
|||||||
name: Release
|
name: Release (Edge)
|
||||||
|
|
||||||
# Baut Playtube fuer Windows und Linux und veroeffentlicht beide als Assets an einem
|
# Baut Playtube Edge (Microsoft-Edge-Engine / WebView2) fuer Windows und veroeffentlicht Setup
|
||||||
# GitHub Release, sobald ein Tag im Format "vX.Y.Z" gepusht wird (z.B. durch
|
# und ZIP an einem GitHub Release, sobald ein Tag im Format "vX.Y.Z-edge" gepusht wird.
|
||||||
# packaging/release.ps1). Playtube.exe/Playtube auf den Nutzer-Rechnern erkennt neue
|
#
|
||||||
# Releases danach automatisch (siehe playtube/updater.py) und bietet die Installation an.
|
# WICHTIG:
|
||||||
|
# - Nur Tags mit dem Suffix "-edge" loesen diesen Workflow aus - die normale (Qt-)Playtube
|
||||||
|
# baut aus dem Branch "main" mit Tags "vX.Y.Z" und bleibt davon unberuehrt.
|
||||||
|
# - Das Release wird als VORABVERSION veroeffentlicht und nie als "latest" markiert. Die Qt-
|
||||||
|
# Version fragt "/releases/latest" ab und sieht dieses Release dadurch nie; der Updater der
|
||||||
|
# Edge-Version sucht seinerseits nur nach Tags "...-edge" (siehe playtube/updater.py).
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*.*.*"
|
- "v*-edge"
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -30,14 +35,10 @@ jobs:
|
|||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
pip install pyinstaller
|
pip install pyinstaller
|
||||||
|
|
||||||
- name: Playtube.exe bauen
|
- name: PlaytubeEdge.exe bauen
|
||||||
run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
|
run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
|
||||||
|
|
||||||
- name: Setup-Installer bauen (Inno Setup)
|
- name: Setup-Installer bauen (Inno Setup)
|
||||||
# Der Installer ist der empfohlene Download fuer Neueinsteiger UND das, was
|
|
||||||
# Playtube fuer volle Updates benutzt (siehe playtube/updater.py): eine einzige
|
|
||||||
# Installation, die jedes Update an Ort und Stelle ueberschreibt (feste AppId in
|
|
||||||
# packaging/playtube.iss) - keine parallelen Versionsordner mehr.
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$version = (Select-String -Path playtube\__init__.py -Pattern '__version__\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value
|
$version = (Select-String -Path playtube\__init__.py -Pattern '__version__\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value
|
||||||
@@ -48,85 +49,18 @@ jobs:
|
|||||||
& $iscc "/DAppVersion=$version" packaging\playtube.iss
|
& $iscc "/DAppVersion=$version" packaging\playtube.iss
|
||||||
if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." }
|
if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." }
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- name: Als .zip packen (portabel)
|
||||||
with:
|
run: Compress-Archive -Path dist\PlaytubeEdge -DestinationPath PlaytubeEdge-${{ github.ref_name }}-win64.zip
|
||||||
name: windows-setup
|
|
||||||
path: dist/installer/Playtube-Setup-v*.exe
|
|
||||||
|
|
||||||
- name: Als .zip packen (volles Paket, portabel)
|
|
||||||
run: Compress-Archive -Path dist\Playtube -DestinationPath Playtube-${{ github.ref_name }}-win64.zip
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows-build
|
name: windows-edge
|
||||||
path: Playtube-${{ github.ref_name }}-win64.zip
|
path: |
|
||||||
|
dist/installer/PlaytubeEdge-Setup-v*.exe
|
||||||
- name: Patch-Paket packen (nur Playtube.exe, fuer schnelle Updates)
|
PlaytubeEdge-*-win64.zip
|
||||||
# .play statt .zip: eigene Dateiendung, die Playtube nach dem ersten Start als
|
|
||||||
# Windows-Dateizuordnung registriert (siehe shortcuts.py) - ein manuell
|
|
||||||
# heruntergeladenes Patch laesst sich so auch per Doppelklick installieren.
|
|
||||||
# Technisch bleibt es ein ganz normales ZIP-Archiv.
|
|
||||||
# Die PySide6-Version steht im Dateinamen: hat sich die Laufzeit gegenueber der
|
|
||||||
# installierten App geaendert, ist ein reines .exe-Patch nicht mehr kompatibel und
|
|
||||||
# der Updater nimmt stattdessen den Setup-Installer (siehe _patch_is_compatible).
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$pyside = python -c "import PySide6; print(PySide6.__version__)"
|
|
||||||
$name = "Playtube-${{ github.ref_name }}-win64-pyside${pyside}-patch"
|
|
||||||
Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath "$name.zip"
|
|
||||||
Rename-Item -Path "$name.zip" -NewName "$name.play"
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: windows-patch
|
|
||||||
path: Playtube-*-win64-pyside*-patch.play
|
|
||||||
|
|
||||||
build-linux:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Qt-WebEngine-Laufzeitabhaengigkeiten installieren
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y \
|
|
||||||
libxkbcommon0 libegl1 libopengl0 libnss3 libnspr4 \
|
|
||||||
libxcomposite1 libxdamage1 libxrandr2 libxtst6 libxcursor1 \
|
|
||||||
libgbm1 libasound2t64 libatk-bridge2.0-0 libatk1.0-0 libcups2 \
|
|
||||||
libdbus-1-3 libdrm2 libpango-1.0-0 libpangocairo-1.0-0 \
|
|
||||||
libxfixes3 libxi6 libxext6 fonts-liberation
|
|
||||||
|
|
||||||
- name: Abhaengigkeiten installieren
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install pyinstaller
|
|
||||||
|
|
||||||
- name: Playtube-Binary bauen
|
|
||||||
run: pyinstaller packaging/playtube.spec --noconfirm --distpath dist --workpath build
|
|
||||||
|
|
||||||
- name: Als .tar.gz packen (volles Paket)
|
|
||||||
run: tar -C dist -czf Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz Playtube
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: linux-build
|
|
||||||
path: Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz
|
|
||||||
|
|
||||||
- name: Patch-Paket packen (nur Playtube-Binary, fuer schnelle Updates)
|
|
||||||
run: tar -C dist/Playtube -czf Playtube-${{ github.ref_name }}-linux-x86_64-patch.tar.gz Playtube
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: linux-patch
|
|
||||||
path: Playtube-${{ github.ref_name }}-linux-x86_64-patch.tar.gz
|
|
||||||
|
|
||||||
publish-release:
|
publish-release:
|
||||||
needs: [build-windows, build-linux]
|
needs: [build-windows]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
@@ -134,8 +68,31 @@ jobs:
|
|||||||
path: artifacts
|
path: artifacts
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: GitHub Release veroeffentlichen
|
- name: GitHub Release veroeffentlichen (Vorabversion)
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
name: Playtube Edge ${{ github.ref_name }}
|
||||||
files: artifacts/*
|
files: artifacts/*
|
||||||
generate_release_notes: true
|
prerelease: true
|
||||||
|
make_latest: "false"
|
||||||
|
body: |
|
||||||
|
## Playtube Edge - die Version fuer eigene (hochgeladene) Musik
|
||||||
|
|
||||||
|
**Diese Version brauchst du, wenn du selbst hochgeladene Songs in YouTube Music hoeren willst.**
|
||||||
|
Die normale Playtube-Version kann sie nicht abspielen ("Dieses Videoformat wird nicht
|
||||||
|
unterstuetzt"), weil ihre eingebaute Browser-Engine (QtWebEngine) kein AAC und kein H.264
|
||||||
|
kann. Playtube Edge benutzt stattdessen die Microsoft-Edge-Engine (WebView2) - damit
|
||||||
|
laufen auch hochgeladene Titel.
|
||||||
|
|
||||||
|
### Installieren
|
||||||
|
- Download: **PlaytubeEdge-Setup-vX.Y.Z.exe** (Setup, empfohlen) oder die ZIP-Datei (portabel).
|
||||||
|
- Voraussetzung: Windows 10/11 mit "WebView2 Runtime" (auf Windows 11 schon vorhanden,
|
||||||
|
sonst kostenlos bei Microsoft).
|
||||||
|
- Playtube Edge laeuft **neben** der normalen Playtube (eigener Ordner, eigene Einstellungen).
|
||||||
|
Beim ersten Start musst du dich in YouTube/YouTube Music einmal neu anmelden.
|
||||||
|
Lass am besten nur eine der beiden Versionen gleichzeitig spielen.
|
||||||
|
- Updates: Playtube Edge aktualisiert sich nur ueber diese "Edge"-Releases - nie ueber die
|
||||||
|
normale Playtube.
|
||||||
|
|
||||||
|
Wer keine eigenen Uploads abspielt, braucht diese Version nicht - die normale Playtube
|
||||||
|
reicht dann.
|
||||||
|
|||||||
@@ -4,4 +4,8 @@ __pycache__/
|
|||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
run*.log
|
run*.log
|
||||||
|
*_run.log
|
||||||
|
build_edge.log
|
||||||
|
.claude/
|
||||||
|
.pytest_cache/
|
||||||
test_login*.log
|
test_login*.log
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
# Playtube
|
# Playtube Edge
|
||||||
|
|
||||||
|
> **Das ist die „Edge“-Variante von Playtube (Branch `edge`).**
|
||||||
|
> **Diese Version brauchst du, wenn du selbst hochgeladene Musik in YouTube Music hören
|
||||||
|
> willst.** Die normale Playtube (Branch `main`) zeigt bei eigenen Uploads „Dieses
|
||||||
|
> Videoformat wird nicht unterstützt“, weil ihre Browser-Engine (QtWebEngine) kein AAC und
|
||||||
|
> kein H.264 abspielen kann. Playtube Edge rendert stattdessen mit der **Microsoft-Edge-Engine
|
||||||
|
> (WebView2)** – damit laufen auch Uploads.
|
||||||
|
>
|
||||||
|
> - Download: [Releases](https://github.com/fojadrachi/Playtube/releases) → Eintrag „Playtube
|
||||||
|
> Edge vX.Y.Z-edge“ → `PlaytubeEdge-Setup-vX.Y.Z.exe`. Voraussetzung: Windows 10/11 mit
|
||||||
|
> WebView2-Runtime (Windows 11: vorinstalliert).
|
||||||
|
> - Läuft **neben** der normalen Playtube (eigener Ordner `%APPDATA%\PlaytubeEdge`, eigenes
|
||||||
|
> Login – beim ersten Start einmal bei Google anmelden).
|
||||||
|
> - Updates kommen nur über die `-edge`-Releases (die normale Playtube sieht sie nie).
|
||||||
|
> - Entwicklung: `pip install -r requirements.txt`, dann `python main.py`. Die
|
||||||
|
> WebView2-DLLs liegen in `vendor/webview2` (Microsoft, NuGet `Microsoft.Web.WebView2`).
|
||||||
|
>
|
||||||
|
> Der Rest dieser README beschreibt die gemeinsame Basis mit der normalen Playtube; wo dort
|
||||||
|
> „QtWebEngine“ steht, ist in dieser Variante WebView2 gemeint.
|
||||||
|
|
||||||
Eigenständige Desktop-App für YouTube & YouTube Music (kein Browser-Fenster, keine
|
Eigenständige Desktop-App für YouTube & YouTube Music (kein Browser-Fenster, keine
|
||||||
Erweiterung) mit:
|
Erweiterung) mit:
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ 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.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.remote_control import PIPE_PREFIX, RemoteControlServer # noqa: E402
|
||||||
from playtube.single_instance import SingleInstanceGuard # noqa: E402
|
from playtube.single_instance import SingleInstanceGuard # noqa: E402
|
||||||
from playtube.shortcuts import ( # noqa: E402
|
from playtube.shortcuts import ensure_start_menu_shortcut # noqa: E402
|
||||||
ensure_play_file_association,
|
|
||||||
ensure_start_menu_shortcut,
|
|
||||||
)
|
|
||||||
from playtube.updater import cleanup_old_staging # noqa: E402
|
from playtube.updater import cleanup_old_staging # noqa: E402
|
||||||
|
|
||||||
app_id.set_app_user_model_id()
|
app_id.set_app_user_model_id()
|
||||||
@@ -26,12 +23,6 @@ app_id.configure_webengine_process_path()
|
|||||||
# High-DPI, sauberes GPU-Verhalten und (in Kombination mit den Sec-CH-UA-Headern in
|
# High-DPI, sauberes GPU-Verhalten und (in Kombination mit den Sec-CH-UA-Headern in
|
||||||
# playtube/browser.py) ein moeglichst "normales" Chrome-Fingerprint, damit Google-Login
|
# playtube/browser.py) ein moeglichst "normales" Chrome-Fingerprint, damit Google-Login
|
||||||
# das eingebettete QtWebEngine nicht als Embedded-WebView blockiert.
|
# das eingebettete QtWebEngine nicht als Embedded-WebView blockiert.
|
||||||
os.environ.setdefault(
|
|
||||||
"QTWEBENGINE_CHROMIUM_FLAGS",
|
|
||||||
"--disable-features=WinRetrieveSuggestionsOnlyOnDemand "
|
|
||||||
"--disable-blink-features=AutomationControlled",
|
|
||||||
)
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt # noqa: E402
|
from PySide6.QtCore import Qt # noqa: E402
|
||||||
from PySide6.QtGui import QIcon # noqa: E402
|
from PySide6.QtGui import QIcon # noqa: E402
|
||||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||||
@@ -78,7 +69,7 @@ def main() -> int:
|
|||||||
# komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird.
|
# komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird.
|
||||||
clear_cache_on_update(__version__)
|
clear_cache_on_update(__version__)
|
||||||
ensure_start_menu_shortcut(APP_NAME)
|
ensure_start_menu_shortcut(APP_NAME)
|
||||||
ensure_play_file_association(APP_NAME)
|
# Keine ".play"-Dateizuordnung: die gehoert der Qt-Version von Playtube (Edge nutzt nur Setup-Updates).
|
||||||
|
|
||||||
window = MainWindow(config)
|
window = MainWindow(config)
|
||||||
window.show()
|
window.show()
|
||||||
|
|||||||
+7
-56
@@ -1,19 +1,11 @@
|
|||||||
# Baut Playtube.exe mit PyInstaller und sorgt dafuer, dass auch der QtWebEngine-
|
# Baut PlaytubeEdge.exe (Playtube mit Microsoft-Edge-Engine / WebView2) mit PyInstaller und
|
||||||
# Hilfsprozess (der eigentlich den Ton ausgibt) den Namen "Playtube" traegt, damit er
|
# danach den Setup-Installer (Inno Setup 6).
|
||||||
# im Taskmanager und - so weit von der jeweiligen Windows-Version respektiert - im
|
|
||||||
# Lautstaerkemixer nicht als "QtWebEngineProcess" auftaucht.
|
|
||||||
#
|
#
|
||||||
# Aufruf (im Projekt-Root, mit aktivierter venv):
|
# Aufruf (im Projekt-Root, mit aktivierter venv):
|
||||||
# powershell -ExecutionPolicy Bypass -File packaging\build.ps1
|
# powershell -ExecutionPolicy Bypass -File packaging\build.ps1
|
||||||
#
|
|
||||||
# Optional: rcedit (https://github.com/electron/rcedit) im PATH oder unter
|
|
||||||
# packaging\rcedit.exe ablegen, dann werden Icon + Versionsinfo des umbenannten
|
|
||||||
# Hilfsprozesses ebenfalls auf "Playtube" gesetzt (sonst bleibt intern "QtWebEngineProcess"
|
|
||||||
# stehen, nur der Dateiname aendert sich - Windows zeigt dann meist trotzdem den
|
|
||||||
# Dateinamen "PlaytubeHelper" an).
|
|
||||||
|
|
||||||
param(
|
param(
|
||||||
# Ueberspringt den Bau des Setup-Installers (Playtube-Setup-vX.Y.Z.exe, benoetigt Inno Setup 6).
|
# Ueberspringt den Bau des Setup-Installers (PlaytubeEdge-Setup-vX.Y.Z.exe, benoetigt Inno Setup 6).
|
||||||
[switch]$SkipInstaller
|
[switch]$SkipInstaller
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,53 +13,13 @@ $ErrorActionPreference = "Stop"
|
|||||||
$root = Split-Path -Parent $PSScriptRoot
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
Set-Location $root
|
Set-Location $root
|
||||||
|
|
||||||
Write-Host "==> Baue Playtube.exe mit PyInstaller ..." -ForegroundColor Cyan
|
Write-Host "==> Baue PlaytubeEdge.exe mit PyInstaller ..." -ForegroundColor Cyan
|
||||||
pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
|
pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
|
||||||
if ($LASTEXITCODE -ne 0) { throw "PyInstaller-Build fehlgeschlagen." }
|
if ($LASTEXITCODE -ne 0) { throw "PyInstaller-Build fehlgeschlagen." }
|
||||||
|
Write-Host "Fertig! Die App liegt unter dist\PlaytubeEdge\PlaytubeEdge.exe" -ForegroundColor Green
|
||||||
$distDir = Join-Path $root "dist\Playtube"
|
|
||||||
$internalDir = Join-Path $distDir "_internal"
|
|
||||||
$searchRoot = if (Test-Path $internalDir) { $internalDir } else { $distDir }
|
|
||||||
|
|
||||||
$engineProcess = Get-ChildItem -Path $searchRoot -Recurse -Filter "QtWebEngineProcess.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
||||||
if (-not $engineProcess) {
|
|
||||||
Write-Warning "QtWebEngineProcess.exe wurde im Build nicht gefunden - Audio-/Taskmanager-Branding des Hilfsprozesses wird uebersprungen."
|
|
||||||
} else {
|
|
||||||
$helperPath = Join-Path $engineProcess.Directory.FullName "PlaytubeHelper.exe"
|
|
||||||
Copy-Item $engineProcess.FullName $helperPath -Force
|
|
||||||
Write-Host "==> Hilfsprozess kopiert nach $helperPath" -ForegroundColor Cyan
|
|
||||||
|
|
||||||
$rcedit = Get-Command rcedit -ErrorAction SilentlyContinue
|
|
||||||
$rceditLocal = Join-Path $PSScriptRoot "rcedit.exe"
|
|
||||||
if ($rcedit) {
|
|
||||||
$rceditExe = $rcedit.Source
|
|
||||||
} elseif (Test-Path $rceditLocal) {
|
|
||||||
$rceditExe = $rceditLocal
|
|
||||||
} else {
|
|
||||||
$rceditExe = $null
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($rceditExe) {
|
|
||||||
Write-Host "==> Patche Icon/Versionsinfo des Hilfsprozesses mit rcedit ..." -ForegroundColor Cyan
|
|
||||||
& $rceditExe $helperPath --set-icon "$root\assets\icon.ico"
|
|
||||||
& $rceditExe $helperPath --set-version-string "FileDescription" "Playtube"
|
|
||||||
& $rceditExe $helperPath --set-version-string "ProductName" "Playtube"
|
|
||||||
& $rceditExe $helperPath --set-version-string "CompanyName" "Playtube"
|
|
||||||
& $rceditExe $helperPath --set-version-string "OriginalFilename" "PlaytubeHelper.exe"
|
|
||||||
} else {
|
|
||||||
Write-Host "Hinweis: rcedit nicht gefunden - Hilfsprozess heisst jetzt 'PlaytubeHelper.exe'," -ForegroundColor Yellow
|
|
||||||
Write-Host "seine interne Versionsinfo/Icon bleibt aber 'QtWebEngineProcess'. Fuer volles" -ForegroundColor Yellow
|
|
||||||
Write-Host "Branding: rcedit von https://github.com/electron/rcedit/releases laden und" -ForegroundColor Yellow
|
|
||||||
Write-Host "als packaging\rcedit.exe ablegen, dann dieses Skript erneut ausfuehren." -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Fertig! Die App liegt unter dist\Playtube\Playtube.exe" -ForegroundColor Green
|
|
||||||
|
|
||||||
# --- Setup-Installer (Inno Setup) ------------------------------------------------------
|
# --- Setup-Installer (Inno Setup) ------------------------------------------------------
|
||||||
# Baut aus dist\Playtube den Installer dist\installer\Playtube-Setup-vX.Y.Z.exe. Ein und
|
# Ein und derselbe Installer richtet Playtube Edge erstmalig ein UND aktualisiert spaeter eine
|
||||||
# derselbe Installer richtet Playtube erstmalig ein UND aktualisiert spaeter eine
|
|
||||||
# vorhandene Installation an Ort und Stelle (feste AppId) - es gibt nie mehrere Versionen.
|
# vorhandene Installation an Ort und Stelle (feste AppId) - es gibt nie mehrere Versionen.
|
||||||
if (-not $SkipInstaller) {
|
if (-not $SkipInstaller) {
|
||||||
$versionLine = Select-String -Path (Join-Path $root "playtube\__init__.py") -Pattern '__version__\s*=\s*"([^"]+)"' | Select-Object -First 1
|
$versionLine = Select-String -Path (Join-Path $root "playtube\__init__.py") -Pattern '__version__\s*=\s*"([^"]+)"' | Select-Object -First 1
|
||||||
@@ -86,12 +38,11 @@ if (-not $SkipInstaller) {
|
|||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Hinweis: Inno Setup 6 nicht gefunden - Setup-Installer wird uebersprungen." -ForegroundColor Yellow
|
Write-Host "Hinweis: Inno Setup 6 nicht gefunden - Setup-Installer wird uebersprungen." -ForegroundColor Yellow
|
||||||
Write-Host "Installieren: winget install --id JRSoftware.InnoSetup -e" -ForegroundColor Yellow
|
Write-Host "Installieren: winget install --id JRSoftware.InnoSetup -e" -ForegroundColor Yellow
|
||||||
Write-Host "(oder mit -SkipInstaller diesen Schritt bewusst auslassen)" -ForegroundColor Yellow
|
|
||||||
} else {
|
} else {
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "==> Baue Setup-Installer (Version $version) mit Inno Setup ..." -ForegroundColor Cyan
|
Write-Host "==> Baue Setup-Installer (Version $version) mit Inno Setup ..." -ForegroundColor Cyan
|
||||||
& $iscc "/DAppVersion=$version" (Join-Path $PSScriptRoot "playtube.iss")
|
& $iscc "/DAppVersion=$version" (Join-Path $PSScriptRoot "playtube.iss")
|
||||||
if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." }
|
if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." }
|
||||||
Write-Host "Fertig! Installer: dist\installer\Playtube-Setup-v$version.exe" -ForegroundColor Green
|
Write-Host "Fertig! Installer: dist\installer\PlaytubeEdge-Setup-v$version.exe" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-51
@@ -1,18 +1,19 @@
|
|||||||
; Inno-Setup-Skript fuer den Playtube-Windows-Installer (Playtube-Setup-vX.Y.Z.exe).
|
; Inno-Setup-Skript fuer den Playtube-Edge-Windows-Installer (PlaytubeEdge-Setup-vX.Y.Z.exe).
|
||||||
;
|
;
|
||||||
; Bauen (Inno Setup 6.3+ noetig, https://jrsoftware.org/isinfo.php):
|
; Bauen (Inno Setup 6.3+ noetig, https://jrsoftware.org/isinfo.php):
|
||||||
; ISCC.exe /DAppVersion=2.4.0 packaging\playtube.iss
|
; ISCC.exe /DAppVersion=1.0.0 packaging\playtube.iss
|
||||||
; Voraussetzung: dist\Playtube\ existiert bereits (PyInstaller-Build, siehe build.ps1).
|
; Voraussetzung: dist\PlaytubeEdge\ existiert bereits (PyInstaller-Build, siehe build.ps1).
|
||||||
; Ergebnis: dist\installer\Playtube-Setup-v2.4.0.exe
|
; Ergebnis: dist\installer\PlaytubeEdge-Setup-v1.0.0.exe
|
||||||
;
|
;
|
||||||
; Wichtig fuer "es soll nie mehrere Versionen geben":
|
; Playtube Edge ist bewusst eine EIGENE Anwendung neben der Qt-Version von Playtube:
|
||||||
; - Feste AppId (unten): jede neue Setup-Version erkennt darueber die vorhandene
|
; - Eigene AppId, eigener Installationsordner (%LOCALAPPDATA%\Programs\PlaytubeEdge), eigener
|
||||||
; Installation und ueberschreibt sie im SELBEN Ordner (statt eine zweite anzulegen).
|
; Datenordner (%APPDATA%\PlaytubeEdge), eigene Exe (PlaytubeEdge.exe). Beide Versionen koennen
|
||||||
; - Installiert pro Benutzer nach %LOCALAPPDATA%\Programs\Playtube (keine Admin-Rechte
|
; parallel installiert sein; keine ueberschreibt die andere.
|
||||||
; noetig, Ordner ist fuer Playtubes eigenen Updater beschreibbar).
|
; - Feste AppId: jede neue Edge-Setup-Version erkennt darueber die vorhandene Edge-Installation
|
||||||
; - Laufende Playtube-Prozesse werden vor dem Kopieren beendet, der alte Programmordner
|
; und ueberschreibt sie im SELBEN Ordner. Muss mit _INNO_APP_ID in playtube/updater.py
|
||||||
; (_internal) wird vor dem Kopieren geleert, damit keine Reste alter Versionen bleiben.
|
; uebereinstimmen.
|
||||||
; - Login/Einstellungen liegen in %APPDATA%\Playtube und bleiben bei Updates erhalten.
|
; - Keine ".play"-Dateizuordnung und kein Beenden von Playtube.exe / PlaytubeHelper.exe - das
|
||||||
|
; gehoert alles der Qt-Version.
|
||||||
;
|
;
|
||||||
; Hinweis zur Datei: bewusst reines ASCII (kein BOM), damit die Umlaute-Kodierung von
|
; Hinweis zur Datei: bewusst reines ASCII (kein BOM), damit die Umlaute-Kodierung von
|
||||||
; Inno Setup nicht ins Spiel kommt.
|
; Inno Setup nicht ins Spiel kommt.
|
||||||
@@ -21,14 +22,13 @@
|
|||||||
#define AppVersion "0.0.0"
|
#define AppVersion "0.0.0"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#define AppName "Playtube"
|
#define AppName "Playtube Edge"
|
||||||
#define AppExeName "Playtube.exe"
|
#define AppDirName "PlaytubeEdge"
|
||||||
#define AppHelperName "PlaytubeHelper.exe"
|
#define AppExeName "PlaytubeEdge.exe"
|
||||||
|
|
||||||
[Setup]
|
[Setup]
|
||||||
; NIEMALS aendern - identifiziert die Installation ueber alle Versionen hinweg. Muss mit
|
; NIEMALS aendern - identifiziert die Edge-Installation ueber alle Versionen hinweg.
|
||||||
; _INNO_APP_ID in playtube/updater.py uebereinstimmen.
|
AppId={{4F0F3698-DAF3-4028-B0A4-896D31292C28}
|
||||||
AppId={{87FBA502-2B84-4C42-A66B-2F03F490912D}
|
|
||||||
AppName={#AppName}
|
AppName={#AppName}
|
||||||
AppVersion={#AppVersion}
|
AppVersion={#AppVersion}
|
||||||
AppVerName={#AppName} {#AppVersion}
|
AppVerName={#AppName} {#AppVersion}
|
||||||
@@ -37,22 +37,21 @@ AppPublisherURL=https://github.com/fojadrachi/Playtube
|
|||||||
AppSupportURL=https://github.com/fojadrachi/Playtube/issues
|
AppSupportURL=https://github.com/fojadrachi/Playtube/issues
|
||||||
AppUpdatesURL=https://github.com/fojadrachi/Playtube/releases
|
AppUpdatesURL=https://github.com/fojadrachi/Playtube/releases
|
||||||
VersionInfoVersion={#AppVersion}
|
VersionInfoVersion={#AppVersion}
|
||||||
DefaultDirName={localappdata}\Programs\{#AppName}
|
DefaultDirName={localappdata}\Programs\{#AppDirName}
|
||||||
DisableProgramGroupPage=yes
|
DisableProgramGroupPage=yes
|
||||||
PrivilegesRequired=lowest
|
PrivilegesRequired=lowest
|
||||||
ArchitecturesAllowed=x64compatible
|
ArchitecturesAllowed=x64compatible
|
||||||
ArchitecturesInstallIn64BitMode=x64compatible
|
ArchitecturesInstallIn64BitMode=x64compatible
|
||||||
OutputDir=..\dist\installer
|
OutputDir=..\dist\installer
|
||||||
OutputBaseFilename=Playtube-Setup-v{#AppVersion}
|
OutputBaseFilename=PlaytubeEdge-Setup-v{#AppVersion}
|
||||||
SetupIconFile=..\assets\icon.ico
|
SetupIconFile=..\assets\icon.ico
|
||||||
UninstallDisplayIcon={app}\{#AppExeName}
|
UninstallDisplayIcon={app}\{#AppExeName}
|
||||||
UninstallDisplayName={#AppName}
|
UninstallDisplayName={#AppName}
|
||||||
Compression=lzma2/max
|
Compression=lzma2/max
|
||||||
SolidCompression=yes
|
SolidCompression=yes
|
||||||
WizardStyle=modern
|
WizardStyle=modern
|
||||||
; Prozesse beenden wir selbst (siehe [Code]) - Playtube versteckt sich beim Schliessen des
|
; Prozesse beenden wir selbst (siehe [Code]) - Playtube Edge versteckt sich beim Schliessen des
|
||||||
; Fensters nur im Tray und reagiert daher nicht zuverlaessig auf die Aufforderung, sich zu
|
; Fensters nur im Tray und reagiert daher nicht zuverlaessig auf CloseApplications.
|
||||||
; schliessen, die Inno per CloseApplications senden wuerde.
|
|
||||||
CloseApplications=no
|
CloseApplications=no
|
||||||
RestartApplications=no
|
RestartApplications=no
|
||||||
|
|
||||||
@@ -69,27 +68,16 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{
|
|||||||
Type: filesandordirs; Name: "{app}\_internal"
|
Type: filesandordirs; Name: "{app}\_internal"
|
||||||
|
|
||||||
[Files]
|
[Files]
|
||||||
Source: "..\dist\Playtube\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
Source: "..\dist\PlaytubeEdge\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
|
|
||||||
[Icons]
|
[Icons]
|
||||||
; {autoprograms} = %APPDATA%\Microsoft\Windows\Start Menu\Programs - derselbe Pfad, den die
|
|
||||||
; App frueher selbst fuer eine portable Kopie angelegt hat; eine alte Verknuepfung, die
|
|
||||||
; noch auf einen Downloads-Ordner zeigt, wird dadurch ersetzt.
|
|
||||||
Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}"
|
Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}"
|
||||||
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
|
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
|
||||||
|
|
||||||
[Registry]
|
|
||||||
; Dateizuordnung fuer ".play" (Playtube-Patchdateien, siehe playtube/shortcuts.py).
|
|
||||||
Root: HKCU; Subkey: "Software\Classes\.play"; ValueType: string; ValueName: ""; ValueData: "{#AppName}.PatchFile"; Flags: uninsdeletekey
|
|
||||||
Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile"; ValueType: string; ValueName: ""; ValueData: "{#AppName}-Patchdatei"; Flags: uninsdeletekey
|
|
||||||
Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#AppExeName}"
|
|
||||||
Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#AppExeName}"" ""%1"""
|
|
||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
; Normale (sichtbare) Installation: Haekchen "Playtube jetzt starten" am Ende.
|
; Normale (sichtbare) Installation: Haekchen "Playtube Edge jetzt starten" am Ende.
|
||||||
Filename: "{app}\{#AppExeName}"; Description: "{#AppName} jetzt starten"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\{#AppExeName}"; Description: "{#AppName} jetzt starten"; Flags: nowait postinstall skipifsilent
|
||||||
; Stilles Update durch Playtubes eigenen Updater (/RELAUNCH=1): App danach automatisch
|
; Stilles Update durch den eigenen Updater (/RELAUNCH=1): App danach automatisch wieder starten.
|
||||||
; wieder starten.
|
|
||||||
Filename: "{app}\{#AppExeName}"; Flags: nowait; Check: ShouldRelaunch
|
Filename: "{app}\{#AppExeName}"; Flags: nowait; Check: ShouldRelaunch
|
||||||
|
|
||||||
[Code]
|
[Code]
|
||||||
@@ -107,10 +95,8 @@ procedure WaitForOldInstance();
|
|||||||
var
|
var
|
||||||
OldPid, ResultCode: Integer;
|
OldPid, ResultCode: Integer;
|
||||||
begin
|
begin
|
||||||
{ Playtubes Updater startet Setup aus der laufenden App heraus und uebergibt deren
|
{ Der Updater startet Setup aus der laufenden App heraus und uebergibt deren Prozess-ID
|
||||||
Prozess-ID (/WAITPID=...). Setup wartet, bis sich diese Instanz selbst beendet hat,
|
(/WAITPID=...). Setup wartet, bis sich diese Instanz selbst beendet hat (max. 20 s). }
|
||||||
statt sie hart abzuschiessen (max. 20 s). Ohne Angabe (Setup per Doppelklick) passiert
|
|
||||||
hier nichts. }
|
|
||||||
OldPid := StrToIntDef(ExpandConstant('{param:WAITPID|0}'), 0);
|
OldPid := StrToIntDef(ExpandConstant('{param:WAITPID|0}'), 0);
|
||||||
if OldPid > 0 then
|
if OldPid > 0 then
|
||||||
Exec(PowerShellPath(),
|
Exec(PowerShellPath(),
|
||||||
@@ -122,16 +108,13 @@ procedure StopLeftoverProcesses();
|
|||||||
var
|
var
|
||||||
ResultCode: Integer;
|
ResultCode: Integer;
|
||||||
begin
|
begin
|
||||||
{ WICHTIG: bewusst OHNE "/T" (Prozessbaum mitbeenden). Der Updater startet Setup aus
|
{ WICHTIG: bewusst OHNE "/T" - der Updater startet Setup aus PlaytubeEdge.exe heraus, Setup ist
|
||||||
Playtube.exe heraus - Setup ist also ein Kindprozess. Solange Playtube noch beendet
|
also ein Kindprozess; "/T" wuerde den Installer selbst mit abschiessen. Es wird nur
|
||||||
wird, wuerde "/T" den Installer selbst mit abschiessen, bevor er etwas installiert hat
|
PlaytubeEdge.exe beendet, NIE Playtube.exe der Qt-Version. }
|
||||||
(so ging frueher ein Update still verloren: kein Kopieren, kein Neustart). }
|
|
||||||
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppExeName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppExeName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppHelperName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
|
||||||
|
|
||||||
{ QtWebEngine-Kindprozesse, die nach dem Ende von Playtube noch aus dem
|
{ WebView2-Kindprozesse (msedgewebview2.exe), die noch aus dem Installationsordner laufen,
|
||||||
Installationsordner laufen, wuerden Dateien sperren - gezielt nach Pfad beenden
|
wuerden Dateien sperren - gezielt nach Pfad beenden. }
|
||||||
(nicht per Namen: QtWebEngineProcess.exe heisst auch bei anderen Qt-Programmen). }
|
|
||||||
if DirExists(ExpandConstant('{app}')) then
|
if DirExists(ExpandConstant('{app}')) then
|
||||||
Exec(PowerShellPath(),
|
Exec(PowerShellPath(),
|
||||||
'-NoProfile -NonInteractive -WindowStyle Hidden -Command "$d = ''' + ExpandConstant('{app}') + '\''; ' +
|
'-NoProfile -NonInteractive -WindowStyle Hidden -Command "$d = ''' + ExpandConstant('{app}') + '\''; ' +
|
||||||
@@ -161,8 +144,8 @@ begin
|
|||||||
if (CurUninstallStep = usPostUninstall) and (not UninstallSilent()) then
|
if (CurUninstallStep = usPostUninstall) and (not UninstallSilent()) then
|
||||||
begin
|
begin
|
||||||
if MsgBox('Sollen auch die Einstellungen und der gespeicherte Login von {#AppName} geloescht werden?' + #13#10 +
|
if MsgBox('Sollen auch die Einstellungen und der gespeicherte Login von {#AppName} geloescht werden?' + #13#10 +
|
||||||
'(Ordner: ' + ExpandConstant('{userappdata}\{#AppName}') + ')',
|
'(Ordner: ' + ExpandConstant('{userappdata}\{#AppDirName}') + ')',
|
||||||
mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then
|
mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then
|
||||||
DelTree(ExpandConstant('{userappdata}\{#AppName}'), True, True, True);
|
DelTree(ExpandConstant('{userappdata}\{#AppDirName}'), True, True, True);
|
||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|||||||
+15
-6
@@ -4,22 +4,31 @@
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PyInstaller.utils.hooks import collect_all
|
||||||
|
|
||||||
block_cipher = None
|
block_cipher = None
|
||||||
|
|
||||||
ROOT = Path(SPECPATH).resolve().parent
|
ROOT = Path(SPECPATH).resolve().parent
|
||||||
|
|
||||||
|
# Edge-Variante: pythonnet + clr_loader (bringen Python.Runtime.dll/ClrLoader.dll mit) und die
|
||||||
|
# Microsoft-WebView2-DLLs aus vendor/webview2 (siehe playtube/webview2_runtime.py).
|
||||||
|
pythonnet_datas, pythonnet_binaries, pythonnet_hidden = collect_all("pythonnet")
|
||||||
|
clr_datas, clr_binaries, clr_hidden = collect_all("clr_loader")
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
[str(ROOT / "main.py")],
|
[str(ROOT / "main.py")],
|
||||||
pathex=[str(ROOT)],
|
pathex=[str(ROOT)],
|
||||||
binaries=[],
|
binaries=pythonnet_binaries + clr_binaries,
|
||||||
datas=[
|
datas=[
|
||||||
(str(ROOT / "assets"), "assets"),
|
(str(ROOT / "assets"), "assets"),
|
||||||
(str(ROOT / "config.json"), "."),
|
(str(ROOT / "config.json"), "."),
|
||||||
],
|
(str(ROOT / "vendor" / "webview2"), "vendor/webview2"),
|
||||||
hiddenimports=["pypresence"],
|
] + pythonnet_datas + clr_datas,
|
||||||
|
hiddenimports=["pypresence", "clr"] + pythonnet_hidden + clr_hidden,
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[],
|
# Kein QtWebEngine mehr (WebView2 statt dessen) - haelt das Paket klein.
|
||||||
|
excludes=["PySide6.QtWebEngineCore", "PySide6.QtWebEngineWidgets", "PySide6.QtWebEngineQuick"],
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,7 +38,7 @@ pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
|||||||
# Linux/macOS gibt es diese Konzepte fuer ELF-Binaries nicht, deshalb nur dort setzen.
|
# Linux/macOS gibt es diese Konzepte fuer ELF-Binaries nicht, deshalb nur dort setzen.
|
||||||
exe_kwargs = dict(
|
exe_kwargs = dict(
|
||||||
exclude_binaries=True,
|
exclude_binaries=True,
|
||||||
name="Playtube",
|
name="PlaytubeEdge",
|
||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
@@ -49,5 +58,5 @@ coll = COLLECT(
|
|||||||
a.datas,
|
a.datas,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=False,
|
upx=False,
|
||||||
name="Playtube",
|
name="PlaytubeEdge",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ VSVersionInfo(
|
|||||||
StringTable(
|
StringTable(
|
||||||
u'040704B0',
|
u'040704B0',
|
||||||
[StringStruct(u'CompanyName', u'Playtube'),
|
[StringStruct(u'CompanyName', u'Playtube'),
|
||||||
StringStruct(u'FileDescription', u'Playtube'),
|
StringStruct(u'FileDescription', u'Playtube Edge'),
|
||||||
StringStruct(u'FileVersion', u'1.0.0.0'),
|
StringStruct(u'FileVersion', u'1.0.0.0'),
|
||||||
StringStruct(u'InternalName', u'Playtube'),
|
StringStruct(u'InternalName', u'PlaytubeEdge'),
|
||||||
StringStruct(u'LegalCopyright', u''),
|
StringStruct(u'LegalCopyright', u''),
|
||||||
StringStruct(u'OriginalFilename', u'Playtube.exe'),
|
StringStruct(u'OriginalFilename', u'PlaytubeEdge.exe'),
|
||||||
StringStruct(u'ProductName', u'Playtube'),
|
StringStruct(u'ProductName', u'Playtube Edge'),
|
||||||
StringStruct(u'ProductVersion', u'1.0.0.0')])
|
StringStruct(u'ProductVersion', u'1.0.0.0')])
|
||||||
]),
|
]),
|
||||||
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
|
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
|
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
|
||||||
|
|
||||||
__app_name__ = "Playtube"
|
__app_name__ = "Playtube Edge"
|
||||||
__version__ = "4.5.1"
|
__version__ = "1.0.0"
|
||||||
|
|||||||
@@ -30,15 +30,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from PySide6.QtCore import QUrl
|
|
||||||
from PySide6.QtMultimedia import QMediaDevices
|
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"
|
SYSTEM_DEFAULT_LABEL = "Systemstandard"
|
||||||
|
|
||||||
@@ -54,19 +46,13 @@ def list_output_devices() -> list[str]:
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
def sync_audio_permissions(profile: QWebEngineProfile, wanted: bool) -> None:
|
def sync_audio_permissions(tabs, wanted: bool) -> None:
|
||||||
"""Erteilt (wanted=True) bzw. entzieht (wanted=False) die Audiogeraete-Berechtigung fuer
|
"""Erlaubt (wanted=True) bzw. entzieht (wanted=False) den Browser-Tabs die
|
||||||
YouTube/YouTube Musik - damit die Seite die Namen der Ausgabegeraete sehen kann (siehe
|
Audiogeraete-Berechtigung fuer YouTube/YouTube Musik - damit die Seite die Namen der
|
||||||
Moduldoku). Gilt nur fuer die laufende Sitzung, deshalb bei jedem Start aufrufen. Der
|
Ausgabegeraete sehen kann (siehe Moduldoku). In der Edge-Variante setzt jeder Tab die
|
||||||
Status wird von Qt fuer Mikrofon-Freigaben nicht zuverlaessig gemeldet (bleibt "Ask"),
|
Freigabe selbst (WebView2-Profil, bleibt dort gespeichert)."""
|
||||||
daher wird hier einfach jedes Mal erteilt bzw. zurueckgesetzt."""
|
for tab in tabs:
|
||||||
permission_type = QWebEnginePermission.PermissionType.MediaAudioCapture
|
tab.set_device_names_allowed(wanted)
|
||||||
for origin in YOUTUBE_ORIGINS:
|
|
||||||
permission = profile.queryPermission(QUrl(origin), permission_type)
|
|
||||||
if wanted:
|
|
||||||
permission.grant()
|
|
||||||
else:
|
|
||||||
permission.reset()
|
|
||||||
|
|
||||||
|
|
||||||
# JavaScript-Vorlage; __TARGET__ wird durch den (JSON-kodierten) Geraetenamen ersetzt.
|
# 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-
|
"""Eingebetteter Browser-Tab (Microsoft Edge WebView2) fuer YouTube bzw. YouTube Music, mit
|
||||||
Profil (eigener Datenordner, kein System-Browser-Profil) und periodischem Auslesen
|
persistentem Login-Profil und periodischem Auslesen der aktuellen Wiedergabe fuer Discord Rich
|
||||||
der aktuellen Wiedergabe fuer Discord Rich Presence."""
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
from typing import Any, Callable
|
||||||
|
|
||||||
from PySide6.QtCore import QTimer, Signal, QUrl
|
from PySide6.QtCore import Qt, QTimer, QUrl, Signal
|
||||||
from PySide6.QtWebEngineCore import (
|
from PySide6.QtWidgets import QWidget
|
||||||
QWebEnginePage,
|
|
||||||
QWebEngineProfile,
|
|
||||||
QWebEngineScript,
|
|
||||||
QWebEngineSettings,
|
|
||||||
QWebEngineUrlRequestInterceptor,
|
|
||||||
)
|
|
||||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
|
||||||
|
|
||||||
from .audio_routing import SCRIPT_NAME as AUDIO_SCRIPT_NAME
|
from . import webview2_runtime
|
||||||
from .audio_routing import build_router_script
|
from .audio_routing import build_router_script
|
||||||
from .chrome_shim import CHROME_FULL, CHROME_MAJOR, CHROME_SHIM_JS
|
|
||||||
from .config import profile_dir
|
from .config import profile_dir
|
||||||
from .media_control import LIST_PLAYLISTS_JS, MUSIC_PLAYLIST_URL, build_control_js
|
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
|
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
|
# Nach einem Fernsteuerungsbefehl den Status kurz darauf neu auslesen (Seite braucht einen
|
||||||
# Moment, bis z.B. der neue Titel/Like-Status im DOM steht).
|
# Moment, bis z.B. der neue Titel/Like-Status im DOM steht).
|
||||||
_REFRESH_AFTER_COMMAND_MS = 300
|
_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
|
_user32 = ctypes.windll.user32 if os.name == "nt" else None
|
||||||
# Chromium-Version passt (siehe QWebEngineCore.qWebEngineChromiumVersion()). Legacy-
|
if _user32 is not None:
|
||||||
# User-Agent, die "Sec-CH-UA" Client-Hints (Header) UND navigator.userAgentData (JS,
|
# 64-Bit-sichere Signaturen (Fensterstil und Handles sind pointer-gross).
|
||||||
# siehe chrome_shim.py) muessen konsistent dieselbe Version + Marke ("Google Chrome")
|
_user32.GetWindowLongPtrW.restype = ctypes.c_ssize_t
|
||||||
# melden - sonst erkennt Google-Login den Browser als nicht vertrauenswuerdiges
|
_user32.GetWindowLongPtrW.argtypes = (ctypes.c_void_p, ctypes.c_int)
|
||||||
# Embedded-WebView und blockiert die Anmeldung mit "Dieser Browser oder diese App ist
|
_user32.SetWindowLongPtrW.restype = ctypes.c_ssize_t
|
||||||
# unter Umstaenden nicht sicher".
|
_user32.SetWindowLongPtrW.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_ssize_t)
|
||||||
_CHROME_VERSION = CHROME_FULL
|
_user32.SetParent.argtypes = (ctypes.c_void_p, ctypes.c_void_p)
|
||||||
_CHROME_MAJOR = CHROME_MAJOR
|
_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":
|
_GWL_STYLE = -16
|
||||||
_UA_PLATFORM_TOKEN = "Windows NT 10.0; Win64; x64"
|
_WS_POPUP = 0x80000000
|
||||||
_SEC_CH_UA_PLATFORM = b'"Windows"'
|
_WS_CAPTION = 0x00C00000
|
||||||
_SEC_CH_UA_PLATFORM_VERSION = b'"15.0.0"'
|
_WS_CHILD = 0x40000000
|
||||||
elif sys.platform.startswith("linux"):
|
_WS_VISIBLE = 0x10000000
|
||||||
_UA_PLATFORM_TOKEN = "X11; Linux x86_64"
|
_WS_CLIPSIBLINGS = 0x04000000
|
||||||
_SEC_CH_UA_PLATFORM = b'"Linux"'
|
_WS_CLIPCHILDREN = 0x02000000
|
||||||
_SEC_CH_UA_PLATFORM_VERSION = b'"6.0.0"'
|
_SW_SHOW = 5
|
||||||
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 "
|
def _decode_script_result(raw: Any) -> Any:
|
||||||
f"(KHTML, like Gecko) Chrome/{_CHROME_VERSION} Safari/537.36"
|
"""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();}})();"
|
||||||
)
|
)
|
||||||
|
|
||||||
_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")
|
|
||||||
|
|
||||||
|
class BrowserTab(QWidget):
|
||||||
class _ChromeBrandingInterceptor(QWebEngineUrlRequestInterceptor):
|
"""Ein WebView2-Tab mit Medien-Ueberwachung fuer Discord Rich Presence."""
|
||||||
"""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)
|
|
||||||
|
|
||||||
|
|
||||||
_shared_profile: QWebEngineProfile | None = None
|
|
||||||
_shared_interceptor: _ChromeBrandingInterceptor | None = None
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
|
||||||
|
|
||||||
mediaInfoChanged = Signal(dict)
|
mediaInfoChanged = Signal(dict)
|
||||||
titleUpdated = Signal(str)
|
titleUpdated = Signal(str)
|
||||||
|
loadFinished = Signal(bool)
|
||||||
|
urlChanged = Signal(QUrl)
|
||||||
|
|
||||||
def __init__(self, home_url: str, parent=None):
|
def __init__(self, home_url: str, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._home_url = home_url
|
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)
|
# Natives Fenster-Handle, damit das WebView2-Steuerelement als Kindfenster einhaengt.
|
||||||
self.setPage(page)
|
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
|
||||||
|
self.setMinimumSize(200, 150)
|
||||||
|
|
||||||
settings = page.settings()
|
runtime = webview2_runtime.load()
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
|
self._runtime = runtime
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, False)
|
properties = runtime.CreationProperties()
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.FullScreenSupportEnabled, True)
|
properties.UserDataFolder = str(profile_dir() / "webview2")
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, True)
|
properties.AdditionalBrowserArguments = _BROWSER_ARGUMENTS
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.ScreenCaptureEnabled, True)
|
properties.Language = "de-DE"
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True)
|
self._view = runtime.WebView2()
|
||||||
|
self._view.CreationProperties = properties
|
||||||
|
self._view.CoreWebView2InitializationCompleted += self._on_core_initialized
|
||||||
|
|
||||||
page.fullScreenRequested.connect(self._on_full_screen_requested)
|
self._task_timer = QTimer(self)
|
||||||
|
self._task_timer.setInterval(_TASK_POLL_MS)
|
||||||
self.load(QUrl(home_url))
|
self._task_timer.timeout.connect(self._drain_tasks)
|
||||||
|
self._task_timer.start()
|
||||||
|
|
||||||
self._poll_timer = QTimer(self)
|
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.timeout.connect(self._poll_media_state)
|
||||||
self._poll_timer.start()
|
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:
|
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:
|
def set_audio_output(self, device_name: str) -> None:
|
||||||
"""Legt den Ton dieses Tabs auf das Ausgabegeraet `device_name` (leer =
|
"""Legt den Ton dieses Tabs auf das Ausgabegeraet `device_name` (leer =
|
||||||
Systemstandard), siehe audio_routing.py. Wirkt sofort auf die geoeffnete Seite und
|
Systemstandard), siehe audio_routing.py. Wirkt sofort auf die geoeffnete Seite und
|
||||||
bleibt bei Seitenwechseln/Neuladen erhalten (Skript pro Seite)."""
|
bleibt bei Seitenwechseln erhalten (Skript pro Dokument)."""
|
||||||
page = self.page()
|
|
||||||
scripts = page.scripts()
|
|
||||||
for old in scripts.find(AUDIO_SCRIPT_NAME):
|
|
||||||
scripts.remove(old)
|
|
||||||
|
|
||||||
source = build_router_script(device_name)
|
source = build_router_script(device_name)
|
||||||
if device_name:
|
if self._core is None:
|
||||||
script = QWebEngineScript()
|
self._queued_router = source if device_name else None
|
||||||
script.setName(AUDIO_SCRIPT_NAME)
|
else:
|
||||||
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)
|
self._install_router(source if device_name else None)
|
||||||
script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
# Bereits geladene Seite sofort umstellen - auch zurueck auf den Systemstandard.
|
||||||
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:
|
if device_name or self._audio_output_set:
|
||||||
page.runJavaScript(source)
|
self._run_js(source)
|
||||||
self._audio_output_set = bool(device_name)
|
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:
|
def toggle_playback(self) -> None:
|
||||||
self.page().runJavaScript(TOGGLE_PLAYBACK_JS)
|
self._run_js(TOGGLE_PLAYBACK_JS)
|
||||||
|
|
||||||
def next_track(self) -> None:
|
def next_track(self) -> None:
|
||||||
self.page().runJavaScript(NEXT_TRACK_JS)
|
self._run_js(NEXT_TRACK_JS)
|
||||||
|
|
||||||
def previous_track(self) -> None:
|
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:
|
def run_media_command(self, command: str, value: float = 0) -> None:
|
||||||
"""Fuehrt einen Fernsteuerungsbefehl (siehe media_control.py) aus und liest den
|
"""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
|
Wiedergabestatus kurz danach neu aus, damit z.B. das Stream Dock den neuen Zustand
|
||||||
sofort statt erst beim naechsten 2-Sekunden-Poll sieht."""
|
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)
|
QTimer.singleShot(_REFRESH_AFTER_COMMAND_MS, self._poll_media_state)
|
||||||
|
|
||||||
def list_playlists(self, callback) -> None:
|
def list_playlists(self, callback) -> None:
|
||||||
"""Ruft callback(list | None) mit den Playlists aus der Seitenleiste auf."""
|
"""Ruft callback(list | None) mit den Playlists aus der Seitenleiste auf."""
|
||||||
|
if self._core is None:
|
||||||
def on_result(result) -> None:
|
|
||||||
try:
|
|
||||||
callback(json.loads(result) if isinstance(result, str) and result else None)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
callback(None)
|
callback(None)
|
||||||
|
return
|
||||||
self.page().runJavaScript(LIST_PLAYLISTS_JS, on_result)
|
self._run_js(LIST_PLAYLISTS_JS, lambda result: callback(result if isinstance(result, list) else None))
|
||||||
|
|
||||||
def play_playlist(self, playlist_id: str) -> None:
|
def play_playlist(self, playlist_id: str) -> None:
|
||||||
"""Oeffnet die Playlist (ID vorher in remote_control validiert); Autoplay startet
|
"""Oeffnet die Playlist (ID vorher in remote_control validiert); Autoplay startet
|
||||||
die Wiedergabe (PlaybackRequiresUserGesture ist aus)."""
|
die Wiedergabe."""
|
||||||
self.load(QUrl(MUSIC_PLAYLIST_URL.format(playlist_id=playlist_id)))
|
self.setUrl(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()
|
|
||||||
|
|
||||||
def _poll_media_state(self) -> None:
|
def _poll_media_state(self) -> None:
|
||||||
self.page().runJavaScript(MEDIA_PROBE_JS, self._on_media_probe_result)
|
self._run_js(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
|
|
||||||
|
|
||||||
|
def _on_media_probe_result(self, info: Any) -> None:
|
||||||
if os.environ.get("PLAYTUBE_DEBUG"):
|
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):
|
if isinstance(info, dict):
|
||||||
self.mediaInfoChanged.emit(info)
|
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 pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
APP_NAME = "Playtube"
|
APP_NAME = "Playtube Edge"
|
||||||
APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID
|
APP_AUMID = "Playtube.Edge.DesktopClient" # Windows AppUserModelID
|
||||||
|
|
||||||
# Der Entwicklungsmodus (python main.py) benutzt einen eigenen APPDATA-Ordner
|
# Der Entwicklungsmodus (python main.py) benutzt einen eigenen APPDATA-Ordner
|
||||||
# ("PlaytubeDev" statt "Playtube"), damit Login-Profil und Config sich NIE mit einer
|
# ("PlaytubeDev" statt "Playtube"), damit Login-Profil und Config sich NIE mit einer
|
||||||
# gepackten/installierten Playtube.exe ueberschneiden (frueher fuehrte das dazu, dass
|
# gepackten/installierten Playtube.exe ueberschneiden (frueher fuehrte das dazu, dass
|
||||||
# ein lokaler Test-Build und die echte Installation sich dieselbe config.json bzw.
|
# ein lokaler Test-Build und die echte Installation sich dieselbe config.json bzw.
|
||||||
# denselben Browser-Profil-Lock geteilt haben).
|
# 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] = {
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
"app_name": APP_NAME,
|
"app_name": APP_NAME,
|
||||||
@@ -28,6 +31,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||||||
# Wenn nichts laeuft: Idle-Status anzeigen statt Presence komplett zu leeren.
|
# Wenn nichts laeuft: Idle-Status anzeigen statt Presence komplett zu leeren.
|
||||||
"show_idle_presence": True,
|
"show_idle_presence": True,
|
||||||
},
|
},
|
||||||
|
# Die Edge-Variante aktualisiert sich nur ueber eigene Releases (Tag "...-edge", siehe updater.py).
|
||||||
"updates": {
|
"updates": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"check_interval_hours": 6,
|
"check_interval_hours": 6,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from . import __version__ as APP_VERSION
|
from . import __version__ as APP_VERSION
|
||||||
from .audio_routing import sync_audio_permissions
|
from .audio_routing import sync_audio_permissions
|
||||||
from .browser import BrowserTab, get_shared_profile
|
from .browser import BrowserTab
|
||||||
from .config import APP_NAME
|
from .config import APP_NAME
|
||||||
from .discord_rpc import DiscordRPCWorker
|
from .discord_rpc import DiscordRPCWorker
|
||||||
from .remote_control import build_state as build_remote_state
|
from .remote_control import build_state as build_remote_state
|
||||||
@@ -57,9 +57,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._tabs.setDocumentMode(True)
|
self._tabs.setDocumentMode(True)
|
||||||
self.setCentralWidget(self._tabs)
|
self.setCentralWidget(self._tabs)
|
||||||
|
|
||||||
self._sync_audio_permission()
|
|
||||||
self._youtube_tab = BrowserTab(config["home_youtube"], self)
|
self._youtube_tab = BrowserTab(config["home_youtube"], self)
|
||||||
self._music_tab = BrowserTab(config["home_music"], 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
|
# Zusaetzlich nach JEDEM Seitenaufbau erneut erteilen: eine nur VOR dem Laden erteilte
|
||||||
# Freigabe greift auf der YouTube-Startseite nicht (per Test ermittelt), eine danach
|
# Freigabe greift auf der YouTube-Startseite nicht (per Test ermittelt), eine danach
|
||||||
# erteilte schon (siehe audio_routing.py).
|
# erteilte schon (siehe audio_routing.py).
|
||||||
@@ -254,7 +254,7 @@ class MainWindow(QMainWindow):
|
|||||||
def _sync_audio_permission(self) -> None:
|
def _sync_audio_permission(self) -> None:
|
||||||
"""Erteilt bzw. entzieht die Freigabe der Geraetenamen (siehe audio_routing.py) -
|
"""Erteilt bzw. entzieht die Freigabe der Geraetenamen (siehe audio_routing.py) -
|
||||||
nur solange mindestens ein Tab ein eigenes Geraet nutzt."""
|
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:
|
def _on_tab_load_finished(self, _ok: bool) -> None:
|
||||||
if any(self._audio_outputs()):
|
if any(self._audio_outputs()):
|
||||||
|
|||||||
+22
-6
@@ -54,16 +54,19 @@ from PySide6.QtCore import QThread, Signal
|
|||||||
from . import __version__ as CURRENT_VERSION
|
from . import __version__ as CURRENT_VERSION
|
||||||
|
|
||||||
GITHUB_REPO = "fojadrachi/Playtube"
|
GITHUB_REPO = "fojadrachi/Playtube"
|
||||||
_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
# Edge-Variante: eigene Releases mit Tag "vX.Y.Z-edge" (als Vorabversion markiert, damit die
|
||||||
_USER_AGENT = "Playtube-Updater"
|
# 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"
|
_LINUX_BINARY_NAME = "PlaytubeEdge"
|
||||||
_WINDOWS_BINARY_NAME = "Playtube.exe"
|
_WINDOWS_BINARY_NAME = "PlaytubeEdge.exe"
|
||||||
|
|
||||||
# Muss mit "AppId" in packaging/playtube.iss uebereinstimmen. Unter dieser ID legt Inno
|
# Muss mit "AppId" in packaging/playtube.iss uebereinstimmen. Unter dieser ID legt Inno
|
||||||
# Setup den Deinstallations-Eintrag ("Apps & Features") an - nach einem Patch-Update
|
# Setup den Deinstallations-Eintrag ("Apps & Features") an - nach einem Patch-Update
|
||||||
# wird dort die angezeigte Versionsnummer nachgezogen (siehe _install_patch_windows).
|
# 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_"
|
_STAGING_PREFIX = "playtube_update_"
|
||||||
|
|
||||||
@@ -105,9 +108,18 @@ def fetch_latest_release() -> dict[str, Any] | None:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
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):
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError, ValueError):
|
||||||
return None
|
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:
|
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.
|
*.play (unsere eigene Dateiendung, technisch ein ganz normales .zip), Voll -> *.zip.
|
||||||
Linux: *.tar.gz, "patch" im Namen kennzeichnet das Patch-Paket."""
|
Linux: *.tar.gz, "patch" im Namen kennzeichnet das Patch-Paket."""
|
||||||
name = name.lower()
|
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 sys.platform == "win32":
|
||||||
if name.endswith(".exe") and "setup" in name:
|
if name.endswith(".exe") and "setup" in name:
|
||||||
return _KIND_SETUP
|
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,
|
||||||
|
)
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
PySide6>=6.10
|
PySide6>=6.10
|
||||||
pypresence>=4.3
|
pypresence>=4.3
|
||||||
|
pythonnet>=3.1
|
||||||
|
|||||||
Vendored
+27
@@ -0,0 +1,27 @@
|
|||||||
|
Copyright (C) Microsoft Corporation. All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are
|
||||||
|
met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
copyright notice, this list of conditions and the following disclaimer
|
||||||
|
in the documentation and/or other materials provided with the
|
||||||
|
distribution.
|
||||||
|
* The name of Microsoft Corporation, or the names of its contributors
|
||||||
|
may not be used to endorse or promote products derived from this
|
||||||
|
software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||||
|
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
BIN
Binary file not shown.
Binary file not shown.
Vendored
+85
@@ -0,0 +1,85 @@
|
|||||||
|
NOTICES AND INFORMATION
|
||||||
|
Do Not Translate or Localize
|
||||||
|
|
||||||
|
This software incorporates material from third parties. Microsoft makes certain
|
||||||
|
open source code available at https://3rdpartysource.microsoft.com, or you may
|
||||||
|
send a check or money order for US $5.00, including the product name, the open
|
||||||
|
source component name, and version number, to:
|
||||||
|
|
||||||
|
Source Code Compliance Team
|
||||||
|
Microsoft Corporation
|
||||||
|
One Microsoft Way
|
||||||
|
Redmond, WA 98052
|
||||||
|
USA
|
||||||
|
|
||||||
|
Notwithstanding any other terms, you may reverse engineer this software to the
|
||||||
|
extent required to debug changes to any libraries licensed under the GNU Lesser
|
||||||
|
General Public License.
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
Antlr3.Runtime 3.5.2-rc1 - BSD 3-Clause
|
||||||
|
|
||||||
|
[The "BSD license"]
|
||||||
|
Copyright (c) 2011 The ANTLR Project
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
|
||||||
|
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||||
|
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||||
|
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||||
|
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
||||||
|
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
---------------------------------------------------------
|
||||||
|
|
||||||
|
---------------------------------------------------------
|
||||||
|
|
||||||
|
StringTemplate4 4.0.9-rc1 - BSD 3-Clause
|
||||||
|
|
||||||
|
[The "BSD license"]
|
||||||
|
Copyright (c) 2011 The ANTLR Project
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
|
||||||
|
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||||
|
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||||
|
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||||
|
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
||||||
|
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
---------------------------------------------------------
|
||||||
Vendored
BIN
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user