Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aad5f2dfd3 | ||
|
|
a77d288eb9 | ||
|
|
f544736c66 | ||
|
|
a873f49587 | ||
|
|
2d1dfedacf | ||
|
|
3ddff796ad | ||
|
|
04a12c2426 | ||
|
|
cb1e13b0fc |
@@ -5,7 +5,9 @@ Erweiterung) mit:
|
|||||||
|
|
||||||
- **Zwei Tabs** – YouTube und YouTube Music laufen parallel, Musik spielt im
|
- **Zwei Tabs** – YouTube und YouTube Music laufen parallel, Musik spielt im
|
||||||
Hintergrund weiter wenn du zu Videos wechselst.
|
Hintergrund weiter wenn du zu Videos wechselst.
|
||||||
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`), einmal bei
|
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`, im
|
||||||
|
Entwicklungsmodus `%APPDATA%\PlaytubeDev` – bewusst getrennt, damit sich lokale
|
||||||
|
Test-Builds nie mit einer installierten Version in die Quere kommen), einmal bei
|
||||||
Google anmelden reicht fuer beide Dienste.
|
Google anmelden reicht fuer beide Dienste.
|
||||||
- **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und
|
- **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und
|
||||||
einen Link-Button in deinem Discord-Profil, sobald etwas laeuft.
|
einen Link-Button in deinem Discord-Profil, sobald etwas laeuft.
|
||||||
@@ -133,7 +135,8 @@ QtWebEngine benoetigt unter Linux ein paar System-Bibliotheken (auf Debian/Ubunt
|
|||||||
das eingebettete Chromium (QtWebEngine) nicht als unsicheres WebView erkennt. Sollte
|
das eingebettete Chromium (QtWebEngine) nicht als unsicheres WebView erkennt. Sollte
|
||||||
die Meldung dennoch erscheinen: alle Playtube-Fenster/-Prozesse schliessen und neu
|
die Meldung dennoch erscheinen: alle Playtube-Fenster/-Prozesse schliessen und neu
|
||||||
starten (die Header greifen erst ab dem naechsten Prozessstart), notfalls einmal den
|
starten (die Header greifen erst ab dem naechsten Prozessstart), notfalls einmal den
|
||||||
Profilordner `%APPDATA%\Playtube\webprofile` loeschen und neu anmelden.
|
Profilordner `%APPDATA%\Playtube\webprofile` (bzw. `PlaytubeDev` im
|
||||||
|
Entwicklungsmodus) loeschen und neu anmelden.
|
||||||
- 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von
|
- 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von
|
||||||
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten funktionieren normal).
|
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten funktionieren normal).
|
||||||
- Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py`
|
- Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py`
|
||||||
|
|||||||
@@ -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"
|
||||||
__version__ = "1.0.0"
|
__version__ = "2.0.0"
|
||||||
|
|||||||
+19
-2
@@ -3,6 +3,8 @@ Profil (eigener Datenordner, kein System-Browser-Profil) und periodischem Ausles
|
|||||||
der aktuellen Wiedergabe fuer Discord Rich Presence."""
|
der aktuellen Wiedergabe fuer Discord Rich Presence."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from PySide6.QtCore import QTimer, Signal, QUrl
|
from PySide6.QtCore import QTimer, Signal, QUrl
|
||||||
@@ -162,5 +164,20 @@ class BrowserTab(QWebEngineView):
|
|||||||
self.page().runJavaScript(MEDIA_PROBE_JS, self._on_media_probe_result)
|
self.page().runJavaScript(MEDIA_PROBE_JS, self._on_media_probe_result)
|
||||||
|
|
||||||
def _on_media_probe_result(self, result) -> None:
|
def _on_media_probe_result(self, result) -> None:
|
||||||
if isinstance(result, dict):
|
# runJavaScript() liefert JS-Objekte ueber diese Bruecke nicht zuverlaessig
|
||||||
self.mediaInfoChanged.emit(result)
|
# 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
|
||||||
|
|
||||||
|
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||||
|
print(f"[media-probe:{self._home_url}] roh={result!r} geparst={info!r}", flush=True)
|
||||||
|
|
||||||
|
if isinstance(info, dict):
|
||||||
|
self.mediaInfoChanged.emit(info)
|
||||||
|
|||||||
+12
-3
@@ -10,12 +10,19 @@ from typing import Any
|
|||||||
APP_NAME = "Playtube"
|
APP_NAME = "Playtube"
|
||||||
APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID
|
APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID
|
||||||
|
|
||||||
|
# Der Entwicklungsmodus (python main.py) benutzt einen eigenen APPDATA-Ordner
|
||||||
|
# ("PlaytubeDev" statt "Playtube"), damit Login-Profil und Config sich NIE mit einer
|
||||||
|
# gepackten/installierten Playtube.exe ueberschneiden (frueher fuehrte das dazu, dass
|
||||||
|
# ein lokaler Test-Build und die echte Installation sich dieselbe config.json bzw.
|
||||||
|
# denselben Browser-Profil-Lock geteilt haben).
|
||||||
|
_DATA_DIR_NAME = APP_NAME if getattr(sys, "frozen", False) else f"{APP_NAME}Dev"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict[str, Any] = {
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
"app_name": APP_NAME,
|
"app_name": APP_NAME,
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
# Deine Discord Application Client-ID (discord.com/developers/applications).
|
# Deine Discord Application Client-ID (discord.com/developers/applications).
|
||||||
"client_id": "1544058029190938774",
|
"client_id": "1548023494976086127",
|
||||||
"update_interval_seconds": 15,
|
"update_interval_seconds": 15,
|
||||||
# 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,
|
||||||
@@ -32,9 +39,11 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||||||
|
|
||||||
|
|
||||||
def _app_data_dir() -> Path:
|
def _app_data_dir() -> Path:
|
||||||
"""Ordner fuer persistente Daten (Login-Profil, Config) - auch im gepackten .exe stabil."""
|
"""Ordner fuer persistente Daten (Login-Profil, Config) - auch im gepackten .exe
|
||||||
|
stabil. Entwicklungsmodus und gepackte .exe nutzen bewusst unterschiedliche
|
||||||
|
Ordner (siehe _DATA_DIR_NAME)."""
|
||||||
base = os.environ.get("APPDATA") or str(Path.home())
|
base = os.environ.get("APPDATA") or str(Path.home())
|
||||||
d = Path(base) / APP_NAME
|
d = Path(base) / _DATA_DIR_NAME
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|||||||
+48
-5
@@ -7,7 +7,9 @@ versucht, ohne die App zu beeintraechtigen.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import queue
|
import queue
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -15,6 +17,11 @@ from PySide6.QtCore import QThread
|
|||||||
|
|
||||||
from .config import APP_NAME
|
from .config import APP_NAME
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pypresence.types import ActivityType
|
||||||
|
except ImportError: # pypresence fehlt -> Discord-Feature bleibt einfach aus
|
||||||
|
ActivityType = None
|
||||||
|
|
||||||
# Asset-Keys, die (optional) unter discord.com/developers/applications -> Rich
|
# Asset-Keys, die (optional) unter discord.com/developers/applications -> Rich
|
||||||
# Presence -> Art Assets hochgeladen werden koennen. Fehlen sie, zeigt Discord
|
# Presence -> Art Assets hochgeladen werden koennen. Fehlen sie, zeigt Discord
|
||||||
# einfach kein Bild an - es gibt keinen Fehler.
|
# einfach kein Bild an - es gibt keinen Fehler.
|
||||||
@@ -36,6 +43,17 @@ def _truncate(text: str | None, limit: int = 128, fallback: str = "") -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
_THUMBNAIL_SIZE_RE = re.compile(r"=w\d+-h\d+(-[a-z0-9-]*)?$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _upsize_thumbnail(url: str | None) -> str | None:
|
||||||
|
"""YouTube-Music-Player-Bar-Thumbnails kommen sehr klein (z.B. '=w60-h60-l90-rj').
|
||||||
|
Fuer eine scharfe Darstellung in Discord die Groesse im URL-Suffix hochsetzen."""
|
||||||
|
if not url:
|
||||||
|
return url
|
||||||
|
return _THUMBNAIL_SIZE_RE.sub("=w544-h544-l90-rj", url)
|
||||||
|
|
||||||
|
|
||||||
def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str, Any]:
|
def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str, Any]:
|
||||||
"""Baut das update()-Payload fuer pypresence aus den vom Browser-Tab gelieferten
|
"""Baut das update()-Payload fuer pypresence aus den vom Browser-Tab gelieferten
|
||||||
Medien-Informationen."""
|
Medien-Informationen."""
|
||||||
@@ -45,10 +63,24 @@ def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str
|
|||||||
default_state = "YouTube Music" if is_music else "YouTube"
|
default_state = "YouTube Music" if is_music else "YouTube"
|
||||||
state = _truncate(info.get("subtitle"), fallback=default_state)
|
state = _truncate(info.get("subtitle"), fallback=default_state)
|
||||||
|
|
||||||
|
thumbnail = info.get("thumbnail")
|
||||||
|
if isinstance(thumbnail, str) and thumbnail.startswith("http"):
|
||||||
|
# Discord akzeptiert fuer large_image auch direkte externe Bild-URLs (nicht nur
|
||||||
|
# vorab hochgeladene Asset-Keys) - so zeigt Discord das echte Video-/Cover-Bild
|
||||||
|
# statt eines statischen Logos.
|
||||||
|
large_image = _upsize_thumbnail(thumbnail) if is_music else thumbnail
|
||||||
|
else:
|
||||||
|
large_image = ASSET_MUSIC if is_music else ASSET_YOUTUBE
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
|
# LISTENING = Hoert, WATCHING = Schaut - statt des Default-Typs PLAYING
|
||||||
|
# (Spielt). Muss ein ActivityType-Enum-Member sein, kein rohes int - pypresence
|
||||||
|
# ruft intern .value darauf auf.
|
||||||
|
"activity_type": ActivityType.LISTENING if is_music else ActivityType.WATCHING,
|
||||||
|
"instance": False,
|
||||||
"details": title,
|
"details": title,
|
||||||
"state": state,
|
"state": state,
|
||||||
"large_image": ASSET_MUSIC if is_music else ASSET_YOUTUBE,
|
"large_image": large_image,
|
||||||
"large_text": "YouTube Music" if is_music else "YouTube",
|
"large_text": "YouTube Music" if is_music else "YouTube",
|
||||||
"small_image": ASSET_PLAY if playing else ASSET_PAUSE,
|
"small_image": ASSET_PLAY if playing else ASSET_PAUSE,
|
||||||
"small_text": "Spielt" if playing else "Pausiert",
|
"small_text": "Spielt" if playing else "Pausiert",
|
||||||
@@ -156,8 +188,12 @@ class DiscordRPCWorker(QThread):
|
|||||||
self._presence = Presence(self._client_id)
|
self._presence = Presence(self._client_id)
|
||||||
self._presence.connect()
|
self._presence.connect()
|
||||||
self._connected = True
|
self._connected = True
|
||||||
except Exception:
|
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||||
|
print("[discord-rpc] verbunden", flush=True)
|
||||||
|
except Exception as exc:
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||||
|
print(f"[discord-rpc] Verbindung fehlgeschlagen: {exc!r}", flush=True)
|
||||||
|
|
||||||
def _send(self, item) -> None:
|
def _send(self, item) -> None:
|
||||||
if self._presence is None:
|
if self._presence is None:
|
||||||
@@ -165,14 +201,21 @@ class DiscordRPCWorker(QThread):
|
|||||||
try:
|
try:
|
||||||
if item is _IDLE_SENTINEL:
|
if item is _IDLE_SENTINEL:
|
||||||
if self._show_idle:
|
if self._show_idle:
|
||||||
self._presence.update(**build_idle_payload(self._session_start))
|
payload = build_idle_payload(self._session_start)
|
||||||
|
self._presence.update(**payload)
|
||||||
else:
|
else:
|
||||||
|
payload = None
|
||||||
self._presence.clear()
|
self._presence.clear()
|
||||||
else:
|
else:
|
||||||
self._presence.update(**build_presence_payload(item, self._session_start))
|
payload = build_presence_payload(item, self._session_start)
|
||||||
except Exception:
|
self._presence.update(**payload)
|
||||||
|
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||||
|
print(f"[discord-rpc] gesendet: {payload!r}", flush=True)
|
||||||
|
except Exception as exc:
|
||||||
# Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden.
|
# Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden.
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||||
|
print(f"[discord-rpc] Senden fehlgeschlagen: {exc!r}", flush=True)
|
||||||
|
|
||||||
def _cleanup(self) -> None:
|
def _cleanup(self) -> None:
|
||||||
if self._presence is not None:
|
if self._presence is not None:
|
||||||
|
|||||||
+32
-5
@@ -7,15 +7,37 @@ MEDIA_PROBE_JS = r"""
|
|||||||
var el = document.querySelector(sel);
|
var el = document.querySelector(sel);
|
||||||
return el ? el.textContent.trim() : null;
|
return el ? el.textContent.trim() : null;
|
||||||
}
|
}
|
||||||
|
// Wichtig: getAttribute('src') statt .src - bei einem noch nicht (nach-)geladenen
|
||||||
|
// <img> (leeres/fehlendes src-Attribut, z.B. waehrend Lazy-Loading) loest die .src
|
||||||
|
// Property faelschlich auf die aktuelle Seiten-URL auf statt null/"" zu liefern.
|
||||||
|
function imgSrc(sel) {
|
||||||
|
var el = document.querySelector(sel);
|
||||||
|
var raw = el ? el.getAttribute('src') : null;
|
||||||
|
return (raw && /^https?:\/\//i.test(raw)) ? raw : null;
|
||||||
|
}
|
||||||
|
// Video-ID aus der URL (?v=... Parameter) - damit laesst sich die Thumbnail-URL
|
||||||
|
// ueber YouTubes CDN immer zuverlaessig selbst bauen, unabhaengig davon, ob
|
||||||
|
// gerade ein passendes <img>/<link> im DOM zu finden ist (das <link
|
||||||
|
// rel="image_src">, auf das sich der Code frueher verlassen hat, fehlt auf
|
||||||
|
// vielen aktuellen YouTube-Seiten schlicht).
|
||||||
|
function videoIdFromUrl(url) {
|
||||||
|
try {
|
||||||
|
return new URL(url).searchParams.get('v');
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
function ytThumbnailUrl(videoId) {
|
||||||
|
return videoId ? ('https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg') : null;
|
||||||
|
}
|
||||||
|
|
||||||
var video = document.querySelector('video');
|
var video = document.querySelector('video');
|
||||||
var isMusic = location.hostname.indexOf('music.youtube.com') !== -1;
|
var isMusic = location.hostname.indexOf('music.youtube.com') !== -1;
|
||||||
|
var videoId = videoIdFromUrl(location.href);
|
||||||
var title = null, subtitle = null, thumbnail = null;
|
var title = null, subtitle = null, thumbnail = null;
|
||||||
|
|
||||||
if (isMusic) {
|
if (isMusic) {
|
||||||
title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title');
|
title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title');
|
||||||
subtitle = txt('.byline.ytmusic-player-bar') || txt('ytmusic-player-bar .byline');
|
subtitle = txt('.byline.ytmusic-player-bar') || txt('ytmusic-player-bar .byline');
|
||||||
var imgM = document.querySelector('ytmusic-player-bar img, .image.ytmusic-player-bar img');
|
thumbnail = imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img') || ytThumbnailUrl(videoId);
|
||||||
thumbnail = imgM ? imgM.src : null;
|
|
||||||
} else {
|
} else {
|
||||||
var t = document.title.replace(/ - YouTube$/, '');
|
var t = document.title.replace(/ - YouTube$/, '');
|
||||||
title = t || null;
|
title = t || null;
|
||||||
@@ -24,10 +46,15 @@ MEDIA_PROBE_JS = r"""
|
|||||||
|| txt('#channel-name a')
|
|| txt('#channel-name a')
|
||||||
|| txt('ytd-channel-name#channel-name a');
|
|| txt('ytd-channel-name#channel-name a');
|
||||||
var imgY = document.querySelector('link[rel="image_src"]');
|
var imgY = document.querySelector('link[rel="image_src"]');
|
||||||
thumbnail = imgY ? imgY.href : null;
|
thumbnail = (imgY ? imgY.href : null) || ytThumbnailUrl(videoId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// WICHTIG: QtWebEngine's runJavaScript()-Bruecke liefert bei einem direkt
|
||||||
|
// zurueckgegebenen JS-Objekt zuverlaessig nur einen leeren String statt des
|
||||||
|
// Objekts (Zahlen/Strings funktionieren, Objekte nicht) - deshalb hier als
|
||||||
|
// JSON-String zurueckgeben und in Python mit json.loads() wieder parsen
|
||||||
|
// (siehe BrowserTab._on_media_probe_result in browser.py).
|
||||||
|
return JSON.stringify({
|
||||||
isMusic: isMusic,
|
isMusic: isMusic,
|
||||||
title: title,
|
title: title,
|
||||||
subtitle: subtitle,
|
subtitle: subtitle,
|
||||||
@@ -37,7 +64,7 @@ MEDIA_PROBE_JS = r"""
|
|||||||
currentTime: video ? video.currentTime : 0,
|
currentTime: video ? video.currentTime : 0,
|
||||||
duration: (video && isFinite(video.duration)) ? video.duration : 0,
|
duration: (video && isFinite(video.duration)) ? video.duration : 0,
|
||||||
hasVideo: !!video
|
hasVideo: !!video
|
||||||
};
|
});
|
||||||
})();
|
})();
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user