Files
Playtube/playtube/mainwindow.py
T
FojadrachiandClaude Sonnet 5 0e6b580018 Einstellungen-Tab, plattformuebergreifendes Update/Branding, CI-Release-Pipeline
- Settings-Tab in der App (Discord/Update/Start-Tab live editierbar, kein config.json-Handbearbeiten noetig)
- updater.py: plattformabhaengige Asset-Auswahl + Installation (Windows .zip/robocopy, Linux .tar.gz/Shell-Skript)
- browser.py/chrome_shim.py: Sec-CH-UA + navigator.userAgentData jetzt je nach sys.platform (Windows/Linux/macOS)
- app_id.py: robustere Suche nach umbenanntem QtWebEngine-Hilfsprozess (rglob statt fixer Pfade)
- packaging/playtube.spec: icon/version nur unter Windows setzen (Linux-Build faehig)
- packaging/install-linux.sh: Installationsskript fuer native Linux-Version (Desktop-Eintrag, Icon, PATH)
- .github/workflows/release.yml: baut bei Tag-Push automatisch Windows-.exe UND Linux-Binary und veroeffentlicht beide als GitHub Release

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 19:34:45 +02:00

325 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Any
from PySide6.QtCore import QTimer, QUrl, Qt
from PySide6.QtGui import QAction, QIcon
from PySide6.QtWidgets import (
QLineEdit,
QMainWindow,
QMenu,
QMessageBox,
QSystemTrayIcon,
QTabWidget,
QToolBar,
QWidget,
)
from . import __version__ as APP_VERSION
from .browser import BrowserTab
from .config import APP_NAME
from .discord_rpc import DiscordRPCWorker
from .settings_tab import SettingsTab
from .updater import UpdateChecker, UpdateInstaller
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
class MainWindow(QMainWindow):
def __init__(self, config: dict[str, Any]):
super().__init__()
self._config = config
self.setWindowTitle(APP_NAME)
self.resize(config["window"]["width"], config["window"]["height"])
icon_path = ASSETS_DIR / "icon.ico"
self._app_icon = QIcon(str(icon_path)) if icon_path.exists() else QIcon()
if not self._app_icon.isNull():
self.setWindowIcon(self._app_icon)
self._tabs = QTabWidget(self)
self._tabs.setDocumentMode(True)
self.setCentralWidget(self._tabs)
self._youtube_tab = BrowserTab(config["home_youtube"], self)
self._music_tab = BrowserTab(config["home_music"], self)
self._settings_tab = SettingsTab(config, self)
self._tabs.addTab(self._youtube_tab, "YouTube")
self._tabs.addTab(self._music_tab, "YouTube Music")
self._tabs.addTab(self._settings_tab, "Einstellungen")
self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1)
self._settings_tab.settingsSaved.connect(self._on_settings_saved)
self._youtube_tab.mediaInfoChanged.connect(self._on_media_info)
self._music_tab.mediaInfoChanged.connect(self._on_media_info)
self._latest_media: dict[int, dict] = {}
self._build_toolbar()
self._build_tray()
self._rpc_worker: DiscordRPCWorker | None = None
self._start_discord_worker()
self._update_checker: UpdateChecker | None = None
self._update_installer: UpdateInstaller | None = None
self._pending_update_version: str | None = None
self._setup_auto_update()
# ------------------------------------------------------------------ UI
def _build_toolbar(self) -> None:
toolbar = QToolBar("Navigation", self)
toolbar.setMovable(False)
self.addToolBar(toolbar)
back_action = QAction("◀", self)
back_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.back()))
toolbar.addAction(back_action)
forward_action = QAction("▶", self)
forward_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.forward()))
toolbar.addAction(forward_action)
reload_action = QAction("⟳", self)
reload_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.reload()))
toolbar.addAction(reload_action)
home_action = QAction("⌂", self)
home_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.go_home()))
toolbar.addAction(home_action)
toolbar.addSeparator()
self._url_bar = QLineEdit(self)
self._url_bar.setPlaceholderText("Suche oder URL eingeben …")
self._url_bar.returnPressed.connect(self._navigate_to_url_bar)
toolbar.addWidget(self._url_bar)
self._tabs.currentChanged.connect(self._on_tab_changed)
self._youtube_tab.urlChanged.connect(lambda u: self._sync_url_bar(self._youtube_tab, u))
self._music_tab.urlChanged.connect(lambda u: self._sync_url_bar(self._music_tab, u))
def _build_tray(self) -> None:
self._tray = QSystemTrayIcon(self)
if not self._app_icon.isNull():
self._tray.setIcon(self._app_icon)
self._tray.setToolTip(APP_NAME)
menu = QMenu()
show_action = menu.addAction("Anzeigen")
show_action.triggered.connect(self._show_and_raise)
play_pause_action = menu.addAction("Wiedergabe umschalten")
play_pause_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.toggle_playback()))
next_action = menu.addAction("Nächster Titel")
next_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.next_track()))
prev_action = menu.addAction("Vorheriger Titel")
prev_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.previous_track()))
menu.addSeparator()
quit_action = menu.addAction("Beenden")
quit_action.triggered.connect(self._quit)
self._tray.setContextMenu(menu)
self._tray.activated.connect(self._on_tray_activated)
self._tray.show()
# --------------------------------------------------------------- Slots
def _current_tab(self) -> QWidget:
return self._tabs.currentWidget()
def _current_browser_tab(self) -> BrowserTab | None:
tab = self._tabs.currentWidget()
return tab if isinstance(tab, BrowserTab) else None
def _with_browser_tab(self, fn) -> None:
"""Fuehrt fn(tab) nur aus, wenn der aktive Tab ein Browser-Tab ist (nicht
die Einstellungen)."""
tab = self._current_browser_tab()
if tab is not None:
fn(tab)
def _navigate_to_url_bar(self) -> None:
text = self._url_bar.text().strip()
tab = self._current_browser_tab()
if not text or tab is None:
return
if " " in text or ("." not in text and "://" not in text):
url = QUrl("https://www.google.com/search?q=" + QUrl.toPercentEncoding(text).data().decode())
else:
url = QUrl.fromUserInput(text)
tab.setUrl(url)
def _sync_url_bar(self, tab: BrowserTab, url: QUrl) -> None:
if self._current_tab() is tab:
self._url_bar.setText(url.toString())
def _on_tab_changed(self, index: int) -> None:
tab = self._current_browser_tab()
self._url_bar.setEnabled(tab is not None)
self._url_bar.setText(tab.url().toString() if tab is not None else "")
def _on_media_info(self, info: dict) -> None:
sender = self.sender()
self._latest_media[id(sender)] = info
# Update Fenstertitel + Discord: bevorzugt der Tab, der gerade wirklich
# abspielt, sonst der aktuell sichtbare Tab.
playing_info = next((i for i in self._latest_media.values() if i.get("playing")), None)
active_info = playing_info or self._latest_media.get(id(self._current_tab()))
if active_info and active_info.get("title"):
self.setWindowTitle(f"{active_info['title']} – {APP_NAME}")
else:
self.setWindowTitle(APP_NAME)
if self._rpc_worker is not None:
if active_info and active_info.get("hasVideo"):
self._rpc_worker.submit_media_info(active_info)
else:
self._rpc_worker.submit_media_info(None)
def _start_discord_worker(self) -> None:
discord_cfg = self._config.get("discord", {})
if discord_cfg.get("enabled") and discord_cfg.get("client_id"):
self._rpc_worker = DiscordRPCWorker(
client_id=str(discord_cfg["client_id"]),
interval=discord_cfg.get("update_interval_seconds", 15),
show_idle=discord_cfg.get("show_idle_presence", True),
)
self._rpc_worker.start()
def _on_settings_saved(self, new_config: dict) -> None:
self._config = new_config
if self._rpc_worker is not None:
self._rpc_worker.stop()
self._rpc_worker.wait(2000)
self._rpc_worker = None
self._start_discord_worker()
self._setup_auto_update()
def _on_tray_activated(self, reason) -> None:
if reason == QSystemTrayIcon.ActivationReason.Trigger:
self._show_and_raise()
def _show_and_raise(self) -> None:
self.showNormal()
self.raise_()
self.activateWindow()
def _quit(self) -> None:
if self._rpc_worker is not None:
self._rpc_worker.stop()
self._rpc_worker.wait(2000)
self._tray.hide()
from PySide6.QtWidgets import QApplication
QApplication.quit()
# -------------------------------------------------------------- Events
def closeEvent(self, event) -> None:
# Beim Schliessen in den Tray minimieren statt beenden (Musik laeuft weiter).
event.ignore()
self.hide()
self._tray.showMessage(
APP_NAME,
f"{APP_NAME} läuft im Hintergrund weiter. Rechtsklick auf das Tray-Icon zum Beenden.",
self._app_icon if not self._app_icon.isNull() else QSystemTrayIcon.MessageIcon.Information,
3000,
)
# -------------------------------------------------------------- Auto-Update
def _setup_auto_update(self) -> None:
"""(Re-)Konfiguriert die Auto-Update-Pruefung - sicher mehrfach aufrufbar,
z.B. nach Aenderungen im Einstellungen-Tab."""
if getattr(self, "_update_timer", None) is not None:
self._update_timer.stop()
self._update_timer.deleteLater()
self._update_timer = None
updates_cfg = self._config.get("updates", {})
if not updates_cfg.get("enabled", True):
return
self._check_for_updates()
interval_ms = max(1, int(updates_cfg.get("check_interval_hours", 6))) * 60 * 60 * 1000
self._update_timer = QTimer(self)
self._update_timer.setInterval(interval_ms)
self._update_timer.timeout.connect(self._check_for_updates)
self._update_timer.start()
def _check_for_updates(self) -> None:
if self._update_checker is not None and self._update_checker.isRunning():
return
self._update_checker = UpdateChecker(self)
self._update_checker.updateAvailable.connect(self._on_update_available)
self._update_checker.start()
def _on_update_available(self, version: str, notes: str, download_url: str) -> None:
if self._pending_update_version == version:
return # Nutzer hat diese Version schon abgelehnt/wird schon gefragt
self._pending_update_version = version
self._show_and_raise()
notes_preview = (notes[:400] + "…") if len(notes) > 400 else notes
text = f"Eine neue Version von {APP_NAME} ist verfügbar: {version}\n(aktuell installiert: {APP_VERSION})"
if notes_preview:
text += f"\n\n{notes_preview}"
text += "\n\nJetzt installieren?"
answer = QMessageBox.question(
self,
f"{APP_NAME}-Update verfügbar",
text,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes,
)
if answer == QMessageBox.StandardButton.Yes:
self._start_update_install(download_url)
def _start_update_install(self, download_url: str) -> None:
self._tray.setToolTip(f"{APP_NAME} – Update wird installiert …")
self._update_installer = UpdateInstaller(download_url, self)
self._update_installer.progress.connect(
lambda msg: self._tray.setToolTip(f"{APP_NAME} – {msg}")
)
self._update_installer.finished_ok.connect(self._on_update_finished)
self._update_installer.failed.connect(self._on_update_failed)
self._update_installer.start()
def _on_update_finished(self) -> None:
if getattr(sys, "frozen", False):
# Ein Hintergrund-Skript wartet bereits darauf, dass dieser Prozess
# beendet wird, tauscht dann die Dateien aus und startet die App neu.
self._quit()
else:
QMessageBox.information(
self,
APP_NAME,
"Update installiert. Die App wird jetzt neu gestartet.",
)
self._restart_dev_process()
def _on_update_failed(self, error: str) -> None:
self._tray.setToolTip(APP_NAME)
QMessageBox.warning(self, f"{APP_NAME}-Update fehlgeschlagen", error)
def _restart_dev_process(self) -> None:
if self._rpc_worker is not None:
self._rpc_worker.stop()
self._rpc_worker.wait(2000)
python = sys.executable
os.execv(python, [python] + sys.argv)