Add native Linux controls and tray menu for BTD 700

This commit is contained in:
Justin
2026-09-05 20:43:35 +02:00
commit 3579667c6e
25 changed files with 2047 additions and 0 deletions
+382
View File
@@ -0,0 +1,382 @@
"""Native GTK 4 / libadwaita controls and a persistent notification-area menu."""
import sys
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Adw, Gio, GLib, Gtk
from .integration import APP_ID, autostart_path, set_autostart
from .protocol import CODECS, MODES, STATES
from .worker import Worker
class Application(Adw.Application):
def __init__(self, *, background=False, demo=False, device=None, smoke_seconds=0):
super().__init__(application_id=APP_ID + ('.Demo' if demo else ''), flags=Gio.ApplicationFlags.DEFAULT_FLAGS)
self.background = background
self.demo = demo
self.device = device
self.smoke_seconds = smoke_seconds
self.status = None
self.window = None
self.tray = None
self.worker = None
self.busy = False
self.updating = False
self.dirty = False
self.form_submitted = False
self.last_error = ''
self.codec_values = []
self.transport_values = []
self.received_status = False
self.smoke_ok = False
def do_startup(self):
Adw.Application.do_startup(self)
self.hold()
self._build_window()
try:
from .tray import Tray
self.tray = Tray(self)
except (ImportError, ValueError, GLib.Error) as exc:
self.last_error = f'Infobereich nicht verfügbar: {exc}'
self.worker = Worker(self._on_status, demo=self.demo, device_path=self.device)
self.worker.start()
self.worker.commands.put(None)
if self.smoke_seconds:
GLib.timeout_add_seconds(self.smoke_seconds, self._finish_smoke)
def do_activate(self):
if not self.background or not self.window:
self.show_window()
self.background = False # A second launch opens the existing window.
GLib.timeout_add_seconds(4, self._ensure_accessible)
def do_shutdown(self):
if self.worker:
self.worker.stop()
self.worker.join(timeout=12)
if self.tray:
self.tray.close()
Adw.Application.do_shutdown(self)
def _ensure_accessible(self):
if not self.tray or not self.tray.available:
self.show_window()
return False
def _finish_smoke(self):
self.smoke_ok = self.received_status and bool(self.tray and self.tray.available)
print(f'GUI_SMOKE status={self.received_status} tray={bool(self.tray and self.tray.available)}', flush=True)
self.quit()
return False
def _build_window(self):
self.window = Adw.ApplicationWindow(application=self, title='BTD 700 Control',
default_width=620, default_height=800)
self.window.set_size_request(420, 420)
self.window.connect('close-request', self._close_window)
toolbar = Adw.ToolbarView()
header = Adw.HeaderBar()
header.set_title_widget(Adw.WindowTitle(title='BTD 700', subtitle='Linux Control' + (' · DEMO' if self.demo else '')))
refresh = Gtk.Button(icon_name='view-refresh-symbolic', tooltip_text='Status aktualisieren')
refresh.connect('clicked', lambda *_: self.worker.commands.put(None))
header.pack_start(refresh)
self.refresh_button = refresh
quit_button = Gtk.Button(icon_name='application-exit-symbolic', tooltip_text='App vollständig beenden')
quit_button.connect('clicked', lambda *_: self.quit())
header.pack_end(quit_button)
toolbar.add_top_bar(header)
self.toast = Adw.ToastOverlay()
scroll = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER)
clamp = Adw.Clamp(maximum_size=600, tightening_threshold=500)
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=22,
margin_top=24, margin_bottom=28, margin_start=20, margin_end=20)
clamp.set_child(content)
scroll.set_child(clamp)
self.toast.set_child(scroll)
toolbar.set_content(self.toast)
self.window.set_content(toolbar)
self.scroll = scroll
heading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
icon = Gtk.Image(icon_name='audio-headphones-symbolic', pixel_size=48)
heading.append(icon)
self.codec_label = Gtk.Label(label='BTD 700 verbinden')
self.codec_label.add_css_class('title-1')
heading.append(self.codec_label)
self.state_label = Gtk.Label(label='Suche nach deinem USB-Dongle …', wrap=True)
self.state_label.add_css_class('dim-label')
heading.append(self.state_label)
self.quality_label = Gtk.Label(label='')
self.quality_label.add_css_class('caption')
heading.append(self.quality_label)
content.append(heading)
self.error_label = Gtk.Label(wrap=True, xalign=0)
self.error_label.add_css_class('error')
self.error_label.set_visible(False)
content.append(self.error_label)
self.device_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=22, sensitive=False)
content.append(self.device_box)
mode_group = Adw.PreferencesGroup(title='Audiomodus', description='Der Modus wird direkt am Dongle umgeschaltet.')
mode_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0, homogeneous=True)
mode_box.add_css_class('linked')
self.mode_buttons = []
for mode, label in MODES.items():
button = Gtk.ToggleButton(label=label)
if self.mode_buttons:
button.set_group(self.mode_buttons[0])
button.connect('clicked', lambda b, v=mode: self._mode_clicked(b, v))
mode_box.append(button)
self.mode_buttons.append(button)
mode_group.add(mode_box)
self.device_box.append(mode_group)
audio = Adw.PreferencesGroup(title='Kopfhörer')
self.codec_row = Adw.ComboRow(title='Codec')
self.codec_row.connect('notify::selected', self._codec_selected)
audio.add(self.codec_row)
self.transport_row = Adw.ComboRow(title='Bluetooth-Transport')
self.transport_row.connect('notify::selected', self._transport_selected)
audio.add(self.transport_row)
self.connection_row = Adw.ActionRow(title='Verbindung')
self.connection_button = Gtk.Button(label='Verbinden', valign=Gtk.Align.CENTER)
self.connection_button.connect('clicked', lambda *_: self.perform('set_connection', self.status.state < 2) if self.status else None)
self.connection_row.add_suffix(self.connection_button)
audio.add(self.connection_row)
self.device_box.append(audio)
self.auracast_group = Adw.PreferencesGroup(title='Auracast',
description='Der Audiomodus „Auracast“ startet die Übertragung an mehrere Empfänger.')
self.name_entry = Adw.EntryRow(title='Name der Übertragung')
self.name_entry.connect('changed', self._form_changed)
self.auracast_group.add(self.name_entry)
self.public_row = Adw.SwitchRow(title='Öffentlich auffindbar',
subtitle='Übertragung in der Auracast-Suche anzeigen')
self.public_row.connect('notify::active', self._form_changed)
self.auracast_group.add(self.public_row)
self.encryption_row = Adw.SwitchRow(title='Passwortschutz')
self.encryption_row.connect('notify::active', self._form_changed)
self.auracast_group.add(self.encryption_row)
self.password_entry = Adw.PasswordEntryRow(title='Neues Passwort')
self.password_entry.set_tooltip_text('416 Buchstaben oder Ziffern. Leer lassen behält das bisherige Passwort.')
self.password_entry.connect('changed', self._password_changed)
self.auracast_group.add(self.password_entry)
self.broadcast_quality = Adw.ComboRow(title='Übertragungsqualität',
model=Gtk.StringList.new(['Standard · 16 kHz', 'Standard · 24 kHz', 'Hohe Qualität']))
self.broadcast_quality.connect('notify::selected', self._form_changed)
self.auracast_group.add(self.broadcast_quality)
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8, halign=Gtk.Align.END, margin_top=12)
self.discard_button = Gtk.Button(label='Verwerfen', sensitive=False)
self.discard_button.connect('clicked', self._discard)
self.save_button = Gtk.Button(label='Speichern', sensitive=False)
self.save_button.add_css_class('suggested-action')
self.save_button.connect('clicked', self._save_broadcast)
actions.append(self.discard_button)
actions.append(self.save_button)
self.auracast_group.add(actions)
hint = Gtk.Label(label='Passwort leer lassen, um das bisherige zu behalten.',
wrap=True, xalign=0, margin_top=8)
hint.add_css_class('caption')
hint.add_css_class('dim-label')
self.auracast_group.add(hint)
self.device_box.append(self.auracast_group)
integration = Adw.PreferencesGroup(title='App')
self.tray_row = Adw.ActionRow(title='Im Infobereich weiterlaufen', subtitle='Infobereich wird verbunden …')
integration.add(self.tray_row)
autostart = Adw.SwitchRow(title='Beim Anmelden starten', subtitle='Startet unauffällig im Infobereich',
active=autostart_path().exists())
autostart.set_sensitive(not self.demo)
autostart.connect('notify::active', self._autostart_changed)
integration.add(autostart)
content.append(integration)
self.firmware_label = Gtk.Label(label='Unabhängige Steuerungs-App · Keine Firmware-Updates', wrap=True)
self.firmware_label.add_css_class('caption')
self.firmware_label.add_css_class('dim-label')
content.append(self.firmware_label)
reset = Gtk.Button(label='Dongle auf Werkseinstellungen zurücksetzen …', halign=Gtk.Align.CENTER)
reset.add_css_class('flat')
reset.connect('clicked', lambda *_: self.confirm_reset())
self.reset_button = reset
content.append(reset)
def show_window(self, section=None):
self.window.present()
if section == 'auracast':
self.name_entry.grab_focus()
elif section == 'password':
self.password_entry.grab_focus()
def _close_window(self, *_):
if self.tray and self.tray.available:
self.window.set_focus(None)
self.window.set_visible(False)
else:
self.quit()
return True
def tray_changed(self):
available = bool(self.tray and self.tray.available)
self.tray_row.set_subtitle('Beim Schließen bleibt das Symbol in der oberen Leiste.' if available else
'Kein Infobereich verfügbar; Schließen beendet die App.')
def perform(self, method, *args, **kwargs):
if self.busy or not self.status:
return
self.refresh_button.grab_focus()
self.busy = True
self.last_error = ''
self.worker.submit(method, args, kwargs)
self._render()
def _on_status(self, status, error, completed):
self.status = status
self.received_status |= status is not None
if completed:
self.busy = False
self.last_error = error
if self.form_submitted:
if not error:
self.dirty = False
self.updating = True
self.password_entry.set_text('')
self.updating = False
self.form_submitted = False
self.toast.add_toast(Adw.Toast.new(error or 'Vom Dongle bestätigt'))
elif not status:
self.last_error = error
elif self.last_error.startswith(('Kein BTD 700', 'USB-Zugriff fehlt', '[Errno')):
self.last_error = ''
self._render()
return False
def _render(self):
self.updating = True
s = self.status
self.device_box.set_sensitive(s is not None and not self.busy)
self.reset_button.set_sensitive(s is not None and not self.busy)
self.error_label.set_label(self.last_error)
self.error_label.set_visible(bool(self.last_error))
if s:
self.codec_label.set_label('Auracast' if s.mode == 2 else s.codec_name)
self.state_label.set_label('Einstellung wird übernommen …' if self.busy else STATES.get(s.state, f'Status {s.state}'))
self.quality_label.set_label(s.quality)
for mode, button in enumerate(self.mode_buttons):
button.set_active(mode == s.mode)
button.set_sensitive(mode != 1 or s.gaming_allowed)
values = [v for v in CODECS if v & s.codecs]
if values != self.codec_values:
self.codec_values = values
self.codec_row.set_model(Gtk.StringList.new([CODECS[v] for v in values]))
self.codec_row.set_selected(next((i for i, v in enumerate(values) if v & s.codec), Gtk.INVALID_LIST_POSITION))
self.codec_row.set_sensitive(s.mode == 0 and s.state >= 2 and len(values) > 1)
self.codec_row.set_subtitle('Auswahl im Standard-Modus' if s.mode != 0 else 'Vom Dongle angebotene Codecs')
values = [3] + [v for v in (1, 2) if s.transports & v]
if s.transport in (1, 2) and s.transport not in values:
values.append(s.transport)
if values != self.transport_values:
self.transport_values = values
names = {3: 'Automatisch', 1: 'Bluetooth Classic', 2: 'LE Audio'}
self.transport_row.set_model(Gtk.StringList.new([names[v] for v in values]))
self.transport_row.set_selected(values.index(s.transport) if s.transport in values else Gtk.INVALID_LIST_POSITION)
self.transport_row.set_sensitive(s.mode == 0)
self.connection_row.set_subtitle(STATES.get(s.state, 'Unbekannt'))
self.connection_button.set_label('Trennen' if s.state >= 2 else 'Verbinden')
self.connection_button.set_sensitive(s.mode != 2)
if not self.dirty:
self.name_entry.set_text(s.broadcast_name)
self.public_row.set_active(bool(s.broadcast_public))
self.encryption_row.set_active(bool(s.broadcast_encrypted))
self.broadcast_quality.set_selected(s.broadcast_quality)
self.firmware_label.set_label(f'Firmware {s.firmware} · Unabhängige App · Keine Firmware-Updates')
else:
self.codec_label.set_label('BTD 700 verbinden')
self.state_label.set_label('Warte auf den USB-Dongle …')
self.quality_label.set_label('')
self.save_button.set_sensitive(self.dirty and not self.busy)
self.discard_button.set_sensitive(self.dirty and not self.busy)
self.updating = False
if self.tray:
self.tray.update(s, self.busy, self.last_error)
def _mode_clicked(self, button, mode):
if not self.updating and button.get_active() and self.status and mode != self.status.mode:
self.perform('set_mode', mode)
def _codec_selected(self, row, _):
i = row.get_selected()
if not self.updating and i < len(self.codec_values):
self.perform('set_codec', self.codec_values[i])
def _transport_selected(self, row, _):
i = row.get_selected()
if not self.updating and self.status and i < len(self.transport_values):
self.perform('set_mode', self.status.mode, transport=self.transport_values[i])
def _form_changed(self, *_):
if not self.updating:
self.dirty = True
self.save_button.set_sensitive(not self.busy)
self.discard_button.set_sensitive(not self.busy)
def _password_changed(self, *_):
if not self.updating and self.password_entry.get_text():
self.encryption_row.set_active(True)
self._form_changed()
def _discard(self, *_):
self.dirty = False
self.updating = True
self.password_entry.set_text('')
self.updating = False
self._render()
def _save_broadcast(self, *_):
if not self.status:
return
name = self.name_entry.get_text()
self.form_submitted = True
self.perform('set_broadcast',
name=name if name != self.status.broadcast_name else None,
password=self.password_entry.get_text() or None,
public=self.public_row.get_active(), quality=self.broadcast_quality.get_selected(),
encrypted=self.encryption_row.get_active())
def toggle_encryption(self):
if self.status and self.status.broadcast_encrypted:
self.perform('set_broadcast', encrypted=False)
else:
self.show_window('password')
self.encryption_row.set_active(True)
def confirm_reset(self):
self.show_window()
if not self.status or self.busy:
return
dialog = Adw.AlertDialog(heading='BTD 700 zurücksetzen?',
body='Alle Kopplungen und gespeicherten Einstellungen werden gelöscht. Danach musst du die Kopfhörer erneut koppeln.')
dialog.add_response('cancel', 'Abbrechen')
dialog.add_response('reset', 'Zurücksetzen')
dialog.set_response_appearance('reset', Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_default_response('cancel')
dialog.set_close_response('cancel')
dialog.connect('response', lambda _, response: self.perform('factory_reset', confirmed=True) if response == 'reset' else None)
dialog.present(self.window)
def _autostart_changed(self, row, _):
try:
set_autostart(row.get_active())
except OSError as exc:
self.last_error = f'Autostart konnte nicht gespeichert werden: {exc}'
self._render()
def run(**kwargs):
app = Application(**kwargs)
result = app.run([sys.argv[0]])
if kwargs.get('smoke_seconds') and not app.smoke_ok:
return 1
return result