"""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('4–16 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