Playtube: eigenstaendiger YouTube/YouTube-Music-Player mit Discord RPC und Auto-Update
- 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]>
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user