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:
@@ -14,6 +14,8 @@ Erweiterung) mit:
|
|||||||
Feldname, nie der Inhalt. Das folgt der Option „Status anzeigen, wenn gerade nichts
|
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
|
läuft“ (aus = auch kein Einstellungs-Status). Discord übernimmt Änderungen höchstens
|
||||||
alle 15 Sekunden, die Anzeige hinkt dem Klicken also etwas hinterher.
|
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
|
- **System-Tray** – Fenster schliessen minimiert nur (Musik läuft weiter), Rechtsklick
|
||||||
aufs Tray-Icon zum Beenden, Play/Pause/Skip direkt aus dem Menü.
|
aufs Tray-Icon zum Beenden, Play/Pause/Skip direkt aus dem Menü.
|
||||||
- **Eigener Name** – erscheint als "Playtube" im Taskmanager, Fenstertitel, Alt-Tab
|
- **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
|
In `config.json` lassen sich zudem `update_interval_seconds` (Mindestabstand zwischen
|
||||||
Updates) und `show_idle_presence` (Status anzeigen, wenn gerade nichts läuft) anpassen.
|
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
|
## App als eigenständige Playtube.exe packen
|
||||||
|
|
||||||
Für die volle Taskmanager-/Audiomixer-Markierung wird die App als eigene .exe gebaut
|
Für die volle Taskmanager-/Audiomixer-Markierung wird die App als eigene .exe gebaut
|
||||||
|
|||||||
@@ -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 ""))
|
||||||
@@ -17,6 +17,8 @@ from PySide6.QtWebEngineCore import (
|
|||||||
)
|
)
|
||||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
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 .chrome_shim import CHROME_FULL, CHROME_MAJOR, CHROME_SHIM_JS
|
||||||
from .config import profile_dir
|
from .config import profile_dir
|
||||||
from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PLAYBACK_JS
|
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):
|
def __init__(self, home_url: str, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._home_url = home_url
|
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)
|
page = QWebEnginePage(get_shared_profile(), self)
|
||||||
self.setPage(page)
|
self.setPage(page)
|
||||||
@@ -142,6 +145,32 @@ class BrowserTab(QWebEngineView):
|
|||||||
def go_home(self) -> None:
|
def go_home(self) -> None:
|
||||||
self.load(QUrl(self._home_url))
|
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:
|
def toggle_playback(self) -> None:
|
||||||
self.page().runJavaScript(TOGGLE_PLAYBACK_JS)
|
self.page().runJavaScript(TOGGLE_PLAYBACK_JS)
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
"check_interval_hours": 6,
|
"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"
|
"start_tab": "youtube", # "youtube" oder "music"
|
||||||
"home_youtube": "https://www.youtube.com/",
|
"home_youtube": "https://www.youtube.com/",
|
||||||
"home_music": "https://music.youtube.com/",
|
"home_music": "https://music.youtube.com/",
|
||||||
|
|||||||
+35
-1
@@ -20,7 +20,8 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from . import __version__ as APP_VERSION
|
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 .config import APP_NAME
|
||||||
from .discord_rpc import DiscordRPCWorker
|
from .discord_rpc import DiscordRPCWorker
|
||||||
from .settings_tab import SettingsTab
|
from .settings_tab import SettingsTab
|
||||||
@@ -54,13 +55,20 @@ class MainWindow(QMainWindow):
|
|||||||
self._tabs.setDocumentMode(True)
|
self._tabs.setDocumentMode(True)
|
||||||
self.setCentralWidget(self._tabs)
|
self.setCentralWidget(self._tabs)
|
||||||
|
|
||||||
|
self._sync_audio_permission()
|
||||||
self._youtube_tab = BrowserTab(config["home_youtube"], self)
|
self._youtube_tab = BrowserTab(config["home_youtube"], self)
|
||||||
self._music_tab = BrowserTab(config["home_music"], 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._settings_tab = SettingsTab(config, self)
|
||||||
self._tabs.addTab(self._youtube_tab, "YouTube")
|
self._tabs.addTab(self._youtube_tab, "YouTube")
|
||||||
self._tabs.addTab(self._music_tab, "YouTube Music")
|
self._tabs.addTab(self._music_tab, "YouTube Music")
|
||||||
self._tabs.addTab(self._settings_tab, "Einstellungen")
|
self._tabs.addTab(self._settings_tab, "Einstellungen")
|
||||||
self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1)
|
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.settingsSaved.connect(self._on_settings_saved)
|
||||||
self._settings_tab.activityChanged.connect(lambda _activity: self._update_discord_presence())
|
self._settings_tab.activityChanged.connect(lambda _activity: self._update_discord_presence())
|
||||||
self._settings_tab.checkUpdatesRequested.connect(self._check_for_updates)
|
self._settings_tab.checkUpdatesRequested.connect(self._check_for_updates)
|
||||||
@@ -232,8 +240,34 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
self._rpc_worker.start()
|
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:
|
def _on_settings_saved(self, new_config: dict) -> None:
|
||||||
self._config = new_config
|
self._config = new_config
|
||||||
|
self._sync_audio_permission()
|
||||||
|
self._apply_audio_routing()
|
||||||
|
|
||||||
if self._rpc_worker is not None:
|
if self._rpc_worker is not None:
|
||||||
self._rpc_worker.stop()
|
self._rpc_worker.stop()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import Signal
|
from PySide6.QtCore import Signal
|
||||||
|
from PySide6.QtMultimedia import QMediaDevices
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
@@ -23,6 +24,7 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from . import __version__ as APP_VERSION
|
from . import __version__ as APP_VERSION
|
||||||
|
from .audio_routing import SYSTEM_DEFAULT_LABEL, list_output_devices
|
||||||
from .config import APP_NAME, save_config
|
from .config import APP_NAME, save_config
|
||||||
from .updater import GITHUB_REPO
|
from .updater import GITHUB_REPO
|
||||||
|
|
||||||
@@ -136,6 +138,31 @@ class SettingsTab(QWidget):
|
|||||||
|
|
||||||
outer.addWidget(update_box)
|
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_box = QGroupBox("Allgemein")
|
||||||
general_form = QFormLayout(general_box)
|
general_form = QFormLayout(general_box)
|
||||||
self._start_tab = QComboBox()
|
self._start_tab = QComboBox()
|
||||||
@@ -168,6 +195,8 @@ class SettingsTab(QWidget):
|
|||||||
self._show_idle: "Bearbeitet: Discord Rich Presence › Status im Leerlauf",
|
self._show_idle: "Bearbeitet: Discord Rich Presence › Status im Leerlauf",
|
||||||
self._updates_enabled: "Bearbeitet: Automatische Updates › Aktiviert",
|
self._updates_enabled: "Bearbeitet: Automatische Updates › Aktiviert",
|
||||||
self._check_interval: "Bearbeitet: Automatische Updates › Prüfintervall",
|
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._check_updates_btn: "Sucht nach Updates",
|
||||||
self._install_update_btn: "Installiert ein Update",
|
self._install_update_btn: "Installiert ein Update",
|
||||||
self._start_tab: "Bearbeitet: Allgemein › Beim Start öffnen",
|
self._start_tab: "Bearbeitet: Allgemein › Beim Start öffnen",
|
||||||
@@ -178,6 +207,25 @@ class SettingsTab(QWidget):
|
|||||||
if app is not None:
|
if app is not None:
|
||||||
app.focusChanged.connect(self._on_focus_changed)
|
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:
|
def current_activity(self) -> str | None:
|
||||||
"""Zuletzt bearbeitetes Feld (None, solange noch kein Feld angeklickt wurde)."""
|
"""Zuletzt bearbeitetes Feld (None, solange noch kein Feld angeklickt wurde)."""
|
||||||
return self._current_activity
|
return self._current_activity
|
||||||
@@ -207,6 +255,10 @@ class SettingsTab(QWidget):
|
|||||||
self._config["updates"]["enabled"] = self._updates_enabled.isChecked()
|
self._config["updates"]["enabled"] = self._updates_enabled.isChecked()
|
||||||
self._config["updates"]["check_interval_hours"] = self._check_interval.value()
|
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()
|
self._config["start_tab"] = self._start_tab.currentData()
|
||||||
|
|
||||||
save_config(self._config)
|
save_config(self._config)
|
||||||
|
|||||||
Reference in New Issue
Block a user