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:
@@ -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 .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)
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user