Audioausgabe pro Tab: YouTube und YouTube Musik auf verschiedene Geraete legen

In den Einstellungen (Gruppe "Audioausgabe", Felder "YouTube" und "YouTube Musik") laesst
sich getrennt waehlen, ueber welches Ausgabegeraet der Ton jedes Tabs laeuft - z.B. auf
getrennte Sonar-Kanaele. Die Auswahl gilt sofort, auch fuer bereits geoeffnete Seiten.

Warum ueber die Seite: QtWebEngine spielt beide Tabs ueber einen gemeinsamen Audio-Prozess
ab, Windows kann sie daher nicht einzeln routen. Chromium kann aber pro Medienelement ein
Geraet waehlen (setSinkId).

- audio_routing.py (neu): Router-Skript (findet das Geraet ueber seinen Namen, kennt den
  Hardware-ID-Suffix von Chromium, behandelt dynamisch erzeugte Elemente, Zielwechsel,
  Zuruecksetzen und fehlende Geraete), Geraeteliste (QMediaDevices), Freigabe der
  Geraetenamen (Chromium blendet sie ohne Mikrofon-Berechtigung aus)
- Freigabe nur solange ein Tab ein eigenes Geraet nutzt, nur fuer die beiden YouTube-
  Seiten, nur fuer die laufende Sitzung (Qt speichert sie nicht) - bei jedem Start und nach
  jedem Seitenaufbau neu erteilt, weil sie auf der YouTube-Startseite sonst nicht greift
- browser: BrowserTab.set_audio_output (Skript pro Seite; ohne Auswahl wird die Seite gar
  nicht angefasst)
- settings_tab: neue Gruppe, Hot-Plug-Aktualisierung, fehlendes Geraet bleibt als "nicht
  verfuegbar" gespeichert, Discord-Anzeige nennt das bearbeitete Feld
- config: audio.youtube_output / audio.music_output (leer = Systemstandard)
- README: Abschnitt inkl. Hinweis, dass Windows weiter einen Eintrag "Playtube" zeigt
This commit is contained in:
2026-09-19 21:14:36 +02:00
parent d28b5d05da
commit 3f3f86f597
6 changed files with 302 additions and 1 deletions
+29
View File
@@ -14,6 +14,8 @@ Erweiterung) mit:
Feldname, nie der Inhalt. Das folgt der Option „Status anzeigen, wenn gerade nichts
läuft“ (aus = auch kein Einstellungs-Status). Discord übernimmt Änderungen höchstens
alle 15 Sekunden, die Anzeige hinkt dem Klicken also etwas hinterher.
- **Audioausgabe pro Tab** – YouTube und YouTube Musik lassen sich in den Einstellungen
auf verschiedene Ausgabegeräte legen (z.B. getrennte Sonar-Kanäle), siehe unten.
- **System-Tray** – Fenster schliessen minimiert nur (Musik läuft weiter), Rechtsklick
aufs Tray-Icon zum Beenden, Play/Pause/Skip direkt aus dem Menü.
- **Eigener Name** – erscheint als "Playtube" im Taskmanager, Fenstertitel, Alt-Tab
@@ -68,6 +70,33 @@ sie ändern oder eine eigene Anwendung nutzen willst:
In `config.json` lassen sich zudem `update_interval_seconds` (Mindestabstand zwischen
Updates) und `show_idle_presence` (Status anzeigen, wenn gerade nichts läuft) anpassen.
## Audioausgabe pro Tab (YouTube / YouTube Musik)
Im **Einstellungen-Tab** legst du unter „Audioausgabe“ getrennt fest, über welches
Ausgabegerät der Ton von **YouTube** und von **YouTube Musik** läuft (Standard:
Systemstandard). So kannst du z.B. YouTube auf „Sonar - Media“ und YouTube Musik auf
„Sonar - Aux“ legen und beide getrennt regeln. Nach „Speichern“ gilt die Auswahl sofort,
auch für bereits geöffnete Seiten (kein Neuladen nötig).
Wichtig zu wissen:
- **Windows zeigt weiterhin einen Eintrag „Playtube“.** Chromium (QtWebEngine) spielt den
Ton beider Tabs über *einen* gemeinsamen Audio-Prozess ab, deshalb kann Windows (auch
„App-Lautstärke und Geräteeinstellungen“) die Tabs nicht einzeln benennen oder routen.
Getrennt wird stattdessen über das **Gerät** - in Sonar & Co. also über den gewählten
Kanal/das gewählte Gerät.
- **Freigabe der Gerätenamen:** Chromium blendet Gerätenamen aus, solange die Seite keine
Mikrofon-Berechtigung hat. Damit die Seite das gewählte Gerät findet, erteilt Playtube
YouTube/YouTube Musik diese Freigabe - nur, solange mindestens ein Tab ein eigenes Gerät
nutzt, nur für diese beiden Seiten und nur für die laufende Sitzung (sie wird nicht
gespeichert und bei jedem Start neu erteilt). Es wird nichts aufgenommen.
- Ist das gewählte Gerät gerade nicht angeschlossen, bleibt die Auswahl (als „nicht
verfügbar“) gespeichert und der Ton läuft solange über den Systemstandard.
- Technik: [playtube/audio_routing.py](playtube/audio_routing.py) legt per
`HTMLMediaElement.setSinkId` die Medienelemente der Seite auf das Gerät (Suche über den
Gerätenamen). In der Konfiguration stehen die Namen unter `audio.youtube_output` und
`audio.music_output` (leer = Systemstandard).
## App als eigenständige Playtube.exe packen
Für die volle Taskmanager-/Audiomixer-Markierung wird die App als eigene .exe gebaut
+151
View File
@@ -0,0 +1,151 @@
"""Audio-Ausgabe pro Tab: YouTube und YouTube Musik koennen ueber verschiedene
Ausgabegeraete laufen (z.B. verschiedene Sonar-Kanaele), einstellbar im Einstellungen-Tab.
Warum ueber die Seite und nicht ueber Windows: QtWebEngine (Chromium) spielt den Ton beider
Tabs ueber EINEN gemeinsamen Audio-Prozess ab - Windows kann die Tabs deshalb nicht einzeln
routen (auch nicht "App-Lautstaerke und Geraeteeinstellungen"). Chromium selbst kann aber pro
Medienelement ein Ausgabegeraet waehlen (HTMLMediaElement.setSinkId). Dafuer wird in jeden
Tab ein kleines Skript eingeschleust (build_router_script), das die <video>/<audio>-Elemente
der Seite auf das gewaehlte Geraet legt.
Geraete werden im Skript ueber ihren NAMEN gefunden (Chromium vergibt pro Website eigene
Geraete-IDs, die man von aussen nicht kennt). Chromium blendet Geraetenamen aber aus, solange
die Seite keine Mikrofon-Berechtigung hat - deshalb erteilt Playtube diese Berechtigung fuer
YouTube/YouTube Musik, allerdings nur, solange mindestens ein Tab ein eigenes Geraet nutzt
(sync_audio_permissions). Zum Aufnehmen wird nichts geoeffnet; die Berechtigung dient nur dazu,
dass die Seite die Geraetenamen sehen darf.
Verhalten der Berechtigung (per Test in QtWebEngine 6.11 ermittelt): Sie gilt nur fuer die
laufende Sitzung - Qt schreibt Mikrofon-Freigaben nicht auf die Festplatte. Sie muss also bei
JEDEM Programmstart neu erteilt werden (MainWindow tut das), und wer die Funktion ausschaltet,
hat nach dem naechsten Start keine Freigabe mehr. Innerhalb einer Sitzung bleiben bereits
sichtbar gewordene Geraetenamen bis zum Neustart sichtbar, auch nach dem Entziehen.
Wichtig: Auf der YouTube-Startseite (www.youtube.com/) greift eine Freigabe, die NUR VOR dem
Seitenaufbau erteilt wurde, nicht - eine danach erteilte schon (auf music.youtube.com und
anderen Seiten reicht die Freigabe vorher). Deshalb erteilt MainWindow sie nach jedem
Seitenaufbau erneut; der Router (build_router_script) wiederholt die Geraetesuche alle 2,5 s
und findet die Namen dann von selbst - ohne Neuladen der Seite.
"""
from __future__ import annotations
import json
from PySide6.QtCore import QUrl
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"
def list_output_devices() -> list[str]:
"""Namen aller aktuell vorhandenen Ausgabegeraete (Reihenfolge des Systems, ohne
Doppelte). Diese Namen speichert die Konfiguration und sucht das Skript in der Seite."""
names: list[str] = []
for device in QMediaDevices.audioOutputs():
name = device.description()
if name and name not in names:
names.append(name)
return names
def sync_audio_permissions(profile: QWebEngineProfile, wanted: bool) -> None:
"""Erteilt (wanted=True) bzw. entzieht (wanted=False) die Audiogeraete-Berechtigung fuer
YouTube/YouTube Musik - damit die Seite die Namen der Ausgabegeraete sehen kann (siehe
Moduldoku). Gilt nur fuer die laufende Sitzung, deshalb bei jedem Start aufrufen. Der
Status wird von Qt fuer Mikrofon-Freigaben nicht zuverlaessig gemeldet (bleibt "Ask"),
daher wird hier einfach jedes Mal erteilt bzw. zurueckgesetzt."""
permission_type = QWebEnginePermission.PermissionType.MediaAudioCapture
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.
# Leerer Name = Systemstandard. Mehrfaches Ausfuehren ist sicher: ist der Router schon
# installiert, wird nur das Ziel gewechselt.
_ROUTER_TEMPLATE = r"""
(function () {
var TARGET = __TARGET__;
if (window.__playtubeAudio) { window.__playtubeAudio.setTarget(TARGET); return; }
var SPECIAL = { "default": 1, "communications": 1 }; // virtuelle Eintraege, keine echten Geraete
var HW_SUFFIX = /^ \([0-9a-f]{4}:[0-9a-f]{4}\)$/i; // Chromium haengt bei manchen Geraeten die Hardware-ID an
var state = { target: TARGET, deviceId: "", resolved: false, tries: 0, timer: null, applied: 0, lastError: "", labels: 0 };
function matches(label, wanted) {
if (label === wanted) return true;
return label.indexOf(wanted) === 0 && HW_SUFFIX.test(label.slice(wanted.length));
}
async function resolveDevice() {
if (!state.target) { state.deviceId = ""; state.resolved = true; return; }
try {
var list = await navigator.mediaDevices.enumerateDevices();
var found = null, labelled = 0;
for (var i = 0; i < list.length; i++) {
var d = list[i];
if (d.kind !== "audiooutput" || !d.deviceId || SPECIAL[d.deviceId]) continue;
if (d.label) labelled++;
if (!found && d.label && matches(d.label, state.target)) found = d;
}
state.labels = labelled;
if (found) { state.deviceId = found.deviceId; state.resolved = true; }
else if (labelled > 0) { state.deviceId = ""; state.resolved = true; } // Namen sichtbar, Geraet aber weg -> Standard
else { state.resolved = false; } // Namen noch ausgeblendet (Berechtigung fehlt noch)
} catch (e) { state.lastError = String(e && e.name || e); state.resolved = false; }
}
async function applyTo(el) {
if (!el || typeof el.setSinkId !== "function") return;
if (el.sinkId === state.deviceId) return;
try { await el.setSinkId(state.deviceId); state.applied++; }
catch (e) { state.lastError = String(e && e.name || e); }
}
function applyAll() {
var els = document.querySelectorAll("video, audio");
for (var i = 0; i < els.length; i++) applyTo(els[i]);
}
async function refresh() {
await resolveDevice();
applyAll();
if (!state.resolved && state.tries++ < 40) {
clearTimeout(state.timer);
state.timer = setTimeout(refresh, 2500);
}
}
// Neue/abspielende Medienelemente sofort auf das Ziel legen (YouTube erzeugt sie dynamisch).
var onMedia = function (e) { applyTo(e.target); };
document.addEventListener("play", onMedia, true);
document.addEventListener("loadstart", onMedia, true);
var pending = null;
new MutationObserver(function () {
if (pending) return;
pending = setTimeout(function () { pending = null; applyAll(); }, 200);
}).observe(document.documentElement, { childList: true, subtree: true });
try { navigator.mediaDevices.addEventListener("devicechange", refresh); } catch (e) {}
window.__playtubeAudio = {
setTarget: function (t) { state.target = t; state.tries = 0; state.resolved = false; refresh(); },
status: function () { return { target: state.target, deviceId: state.deviceId, resolved: state.resolved, applied: state.applied, labels: state.labels, lastError: state.lastError }; }
};
refresh();
})();
"""
def build_router_script(device_name: str) -> str:
"""JavaScript, das die Medienelemente einer Seite auf das Ausgabegeraet `device_name`
legt (leer = Systemstandard)."""
return _ROUTER_TEMPLATE.replace("__TARGET__", json.dumps(device_name or ""))
+29
View File
@@ -17,6 +17,8 @@ from PySide6.QtWebEngineCore import (
)
from PySide6.QtWebEngineWidgets import QWebEngineView
from .audio_routing import SCRIPT_NAME as AUDIO_SCRIPT_NAME
from .audio_routing import build_router_script
from .chrome_shim import CHROME_FULL, CHROME_MAJOR, CHROME_SHIM_JS
from .config import profile_dir
from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PLAYBACK_JS
@@ -116,6 +118,7 @@ class BrowserTab(QWebEngineView):
def __init__(self, home_url: str, parent=None):
super().__init__(parent)
self._home_url = home_url
self._audio_output_set = False # war schon ein eigenes Ausgabegeraet gewaehlt? (siehe set_audio_output)
page = QWebEnginePage(get_shared_profile(), self)
self.setPage(page)
@@ -142,6 +145,32 @@ class BrowserTab(QWebEngineView):
def go_home(self) -> None:
self.load(QUrl(self._home_url))
def set_audio_output(self, device_name: str) -> None:
"""Legt den Ton dieses Tabs auf das Ausgabegeraet `device_name` (leer =
Systemstandard), siehe audio_routing.py. Wirkt sofort auf die geoeffnete Seite und
bleibt bei Seitenwechseln/Neuladen erhalten (Skript pro Seite)."""
page = self.page()
scripts = page.scripts()
for old in scripts.find(AUDIO_SCRIPT_NAME):
scripts.remove(old)
source = build_router_script(device_name)
if device_name:
script = QWebEngineScript()
script.setName(AUDIO_SCRIPT_NAME)
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)
script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
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:
page.runJavaScript(source)
self._audio_output_set = bool(device_name)
def toggle_playback(self) -> None:
self.page().runJavaScript(TOGGLE_PLAYBACK_JS)
+6
View File
@@ -32,6 +32,12 @@ DEFAULT_CONFIG: dict[str, Any] = {
"enabled": True,
"check_interval_hours": 6,
},
# Ausgabegeraet pro Tab (Name des Geraets, wie in den Einstellungen gewaehlt; leer =
# Systemstandard) - siehe playtube/audio_routing.py.
"audio": {
"youtube_output": "",
"music_output": "",
},
"start_tab": "youtube", # "youtube" oder "music"
"home_youtube": "https://www.youtube.com/",
"home_music": "https://music.youtube.com/",
+35 -1
View File
@@ -20,7 +20,8 @@ from PySide6.QtWidgets import (
)
from . import __version__ as APP_VERSION
from .browser import BrowserTab
from .audio_routing import sync_audio_permissions
from .browser import BrowserTab, get_shared_profile
from .config import APP_NAME
from .discord_rpc import DiscordRPCWorker
from .settings_tab import SettingsTab
@@ -54,13 +55,20 @@ class MainWindow(QMainWindow):
self._tabs.setDocumentMode(True)
self.setCentralWidget(self._tabs)
self._sync_audio_permission()
self._youtube_tab = BrowserTab(config["home_youtube"], self)
self._music_tab = BrowserTab(config["home_music"], self)
# Zusaetzlich nach JEDEM Seitenaufbau erneut erteilen: eine nur VOR dem Laden erteilte
# Freigabe greift auf der YouTube-Startseite nicht (per Test ermittelt), eine danach
# erteilte schon (siehe audio_routing.py).
for tab in (self._youtube_tab, self._music_tab):
tab.loadFinished.connect(self._on_tab_load_finished)
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._apply_audio_routing()
self._settings_tab.settingsSaved.connect(self._on_settings_saved)
self._settings_tab.activityChanged.connect(lambda _activity: self._update_discord_presence())
self._settings_tab.checkUpdatesRequested.connect(self._check_for_updates)
@@ -232,8 +240,34 @@ class MainWindow(QMainWindow):
)
self._rpc_worker.start()
def _audio_outputs(self) -> tuple[str, str]:
"""(Geraet fuer YouTube, Geraet fuer YouTube Musik) aus der Konfiguration; leer =
Systemstandard."""
audio_cfg = self._config.get("audio", {})
return (
str(audio_cfg.get("youtube_output", "") or ""),
str(audio_cfg.get("music_output", "") or ""),
)
def _sync_audio_permission(self) -> None:
"""Erteilt bzw. entzieht die Freigabe der Geraetenamen (siehe audio_routing.py) -
nur solange mindestens ein Tab ein eigenes Geraet nutzt."""
sync_audio_permissions(get_shared_profile(), any(self._audio_outputs()))
def _on_tab_load_finished(self, _ok: bool) -> None:
if any(self._audio_outputs()):
self._sync_audio_permission()
def _apply_audio_routing(self) -> None:
"""Wendet die in den Einstellungen gewaehlten Ausgabegeraete pro Tab an."""
youtube_output, music_output = self._audio_outputs()
self._youtube_tab.set_audio_output(youtube_output)
self._music_tab.set_audio_output(music_output)
def _on_settings_saved(self, new_config: dict) -> None:
self._config = new_config
self._sync_audio_permission()
self._apply_audio_routing()
if self._rpc_worker is not None:
self._rpc_worker.stop()
+52
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any
from PySide6.QtCore import Signal
from PySide6.QtMultimedia import QMediaDevices
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
@@ -23,6 +24,7 @@ from PySide6.QtWidgets import (
)
from . import __version__ as APP_VERSION
from .audio_routing import SYSTEM_DEFAULT_LABEL, list_output_devices
from .config import APP_NAME, save_config
from .updater import GITHUB_REPO
@@ -136,6 +138,31 @@ class SettingsTab(QWidget):
outer.addWidget(update_box)
audio_cfg = config.get("audio", {})
audio_box = QGroupBox("Audioausgabe")
audio_form = QFormLayout(audio_box)
self._youtube_output = QComboBox()
self._music_output = QComboBox()
audio_form.addRow("YouTube:", self._youtube_output)
audio_form.addRow("YouTube Musik:", self._music_output)
audio_hint = QLabel(
"Legt den Ton von YouTube und YouTube Musik auf verschiedene Ausgabegeräte, z. B. "
"auf getrennte Sonar-Kanäle. Windows zeigt beide unter „Playtube“ - getrennt wird "
"über das Gerät. Damit die Seite das Gerät finden kann, erlaubt Playtube ihr dafür "
"den Zugriff auf die Audiogeräte-Namen (es wird nichts aufgenommen); die Freigabe "
"gilt nur, solange hier ein eigenes Gerät gewählt ist."
)
audio_hint.setWordWrap(True)
audio_hint.setStyleSheet(_LIGHT_TEXT_STYLE)
audio_form.addRow(audio_hint)
outer.addWidget(audio_box)
# QMediaDevices meldet, wenn Geraete ein-/ausgesteckt werden -> Listen aktuell halten.
self._media_devices = QMediaDevices(self)
self._fill_output_combo(self._youtube_output, str(audio_cfg.get("youtube_output", "") or ""))
self._fill_output_combo(self._music_output, str(audio_cfg.get("music_output", "") or ""))
self._media_devices.audioOutputsChanged.connect(self._reload_output_devices)
general_box = QGroupBox("Allgemein")
general_form = QFormLayout(general_box)
self._start_tab = QComboBox()
@@ -168,6 +195,8 @@ class SettingsTab(QWidget):
self._show_idle: "Bearbeitet: Discord Rich Presence › Status im Leerlauf",
self._updates_enabled: "Bearbeitet: Automatische Updates › Aktiviert",
self._check_interval: "Bearbeitet: Automatische Updates › Prüfintervall",
self._youtube_output: "Bearbeitet: Audioausgabe › YouTube",
self._music_output: "Bearbeitet: Audioausgabe › YouTube Musik",
self._check_updates_btn: "Sucht nach Updates",
self._install_update_btn: "Installiert ein Update",
self._start_tab: "Bearbeitet: Allgemein › Beim Start öffnen",
@@ -178,6 +207,25 @@ class SettingsTab(QWidget):
if app is not None:
app.focusChanged.connect(self._on_focus_changed)
def _fill_output_combo(self, combo: QComboBox, saved: str) -> None:
"""Fuellt eine Ausgabe-Auswahl: "Systemstandard" + alle vorhandenen Geraete. Ist das
gespeicherte Geraet gerade nicht angeschlossen, bleibt es (als "nicht verfuegbar")
ausgewaehlt, statt die Einstellung stillschweigend zu verlieren."""
combo.blockSignals(True)
combo.clear()
combo.addItem(SYSTEM_DEFAULT_LABEL, "")
names = list_output_devices()
for name in names:
combo.addItem(name, name)
if saved and saved not in names:
combo.addItem(f"{saved} (nicht verfügbar)", saved)
combo.setCurrentIndex(max(0, combo.findData(saved)))
combo.blockSignals(False)
def _reload_output_devices(self) -> None:
for combo in (self._youtube_output, self._music_output):
self._fill_output_combo(combo, combo.currentData() or "")
def current_activity(self) -> str | None:
"""Zuletzt bearbeitetes Feld (None, solange noch kein Feld angeklickt wurde)."""
return self._current_activity
@@ -207,6 +255,10 @@ class SettingsTab(QWidget):
self._config["updates"]["enabled"] = self._updates_enabled.isChecked()
self._config["updates"]["check_interval_hours"] = self._check_interval.value()
self._config.setdefault("audio", {})
self._config["audio"]["youtube_output"] = self._youtube_output.currentData() or ""
self._config["audio"]["music_output"] = self._music_output.currentData() or ""
self._config["start_tab"] = self._start_tab.currentData()
save_config(self._config)