- PySide6/QtWebEngine mit persistentem Login-Profil (YouTube + YouTube Music Tabs) - Google-Login-Fix per Sec-CH-UA-Headern + Chrome-JS-Shim - Discord Rich Presence (eigener Thread, Titel/Fortschritt/Buttons) - Eigenes Branding (Taskmanager/Taskleiste) per AppUserModelID + PyInstaller-Packaging - Auto-Update ueber GitHub Releases (fojadrachi/Playtube) Co-Authored-By: Claude Sonnet 5 <[email protected]>
286 lines
11 KiB
Python
286 lines
11 KiB
Python
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 .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._tabs.addTab(self._youtube_tab, "YouTube")
|
||
self._tabs.addTab(self._music_tab, "YouTube Music")
|
||
self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1)
|
||
|
||
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
|
||
discord_cfg = 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()
|
||
|
||
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._current_tab().back())
|
||
toolbar.addAction(back_action)
|
||
|
||
forward_action = QAction("▶", self)
|
||
forward_action.triggered.connect(lambda: self._current_tab().forward())
|
||
toolbar.addAction(forward_action)
|
||
|
||
reload_action = QAction("⟳", self)
|
||
reload_action.triggered.connect(lambda: self._current_tab().reload())
|
||
toolbar.addAction(reload_action)
|
||
|
||
home_action = QAction("⌂", self)
|
||
home_action.triggered.connect(lambda: self._current_tab().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._current_tab().toggle_playback())
|
||
|
||
next_action = menu.addAction("Nächster Titel")
|
||
next_action.triggered.connect(lambda: self._current_tab().next_track())
|
||
|
||
prev_action = menu.addAction("Vorheriger Titel")
|
||
prev_action.triggered.connect(lambda: self._current_tab().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) -> BrowserTab:
|
||
return self._tabs.currentWidget()
|
||
|
||
def _navigate_to_url_bar(self) -> None:
|
||
text = self._url_bar.text().strip()
|
||
if not text:
|
||
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)
|
||
self._current_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:
|
||
self._url_bar.setText(self._current_tab().url().toString())
|
||
|
||
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 _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:
|
||
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)
|