something-x-dev 1.2.3.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,599 @@
1
+ import math
2
+ import re
3
+ import subprocess
4
+ import threading
5
+ import cairo
6
+ import gi
7
+
8
+ gi.require_version("Gtk", "4.0")
9
+ gi.require_version("Pango", "1.0")
10
+ gi.require_version("PangoCairo", "1.0")
11
+ from gi.repository import Gtk, GLib, PangoCairo
12
+
13
+ from ..bluetooth import BluetoothDevice, BluetoothManager
14
+
15
+
16
+ def _mono_font() -> str:
17
+ available = {f.get_name() for f in PangoCairo.FontMap.get_default().list_families()}
18
+ for name in ("JetBrainsMono", "Fira Mono", "DejaVu Sans Mono"):
19
+ if name in available:
20
+ return name
21
+ return "monospace"
22
+
23
+
24
+ _MONO = _mono_font()
25
+ from ..protocol import NothingDevice, ANCMode, EQ_PRESETS
26
+
27
+
28
+ def _find_bt_sink(address: str) -> str | None:
29
+ addr_key = address.replace(":", "_").lower()
30
+ try:
31
+ out = subprocess.run(
32
+ ["pactl", "list", "short", "sinks"],
33
+ capture_output=True,
34
+ text=True,
35
+ timeout=2,
36
+ ).stdout
37
+ for line in out.splitlines():
38
+ if addr_key in line.lower():
39
+ parts = line.split("\t")
40
+ if len(parts) >= 2:
41
+ return parts[1].strip()
42
+ except Exception:
43
+ pass
44
+ return None
45
+
46
+
47
+ def _get_sink_volume(address: str) -> int | None:
48
+ sink = _find_bt_sink(address)
49
+ if not sink:
50
+ return None
51
+ try:
52
+ out = subprocess.run(
53
+ ["pactl", "get-sink-volume", sink],
54
+ capture_output=True,
55
+ text=True,
56
+ timeout=2,
57
+ ).stdout
58
+ m = re.search(r"(\d+)%", out)
59
+ return int(m.group(1)) if m else None
60
+ except Exception:
61
+ return None
62
+
63
+
64
+ def _set_sink_volume(address: str, pct: int):
65
+ sink = _find_bt_sink(address)
66
+ if not sink:
67
+ return
68
+ try:
69
+ subprocess.run(
70
+ ["pactl", "set-sink-volume", sink, f"{pct}%"],
71
+ capture_output=True,
72
+ timeout=2,
73
+ )
74
+ except Exception:
75
+ pass
76
+
77
+
78
+ def _battery_color(pct: int) -> tuple[float, float, float]:
79
+ if pct < 0:
80
+ return (0.18, 0.18, 0.18)
81
+ if pct <= 20:
82
+ return (0.91, 0.32, 0.32)
83
+ if pct <= 50:
84
+ return (0.94, 0.75, 0.25)
85
+ return (0.56, 0.87, 0.45)
86
+
87
+
88
+ class EarbudVisual(Gtk.DrawingArea):
89
+ def __init__(self):
90
+ super().__init__()
91
+ self.set_size_request(340, 190)
92
+ self.set_draw_func(self._draw)
93
+ self._left = -1
94
+ self._right = -1
95
+ self._case = -1
96
+
97
+ def update(self, left: int, right: int, case: int):
98
+ self._left, self._right, self._case = left, right, case
99
+ self.queue_draw()
100
+
101
+ def _draw(self, _area, cr, width, height):
102
+ cx = width / 2
103
+ cy = height / 2 - 8
104
+ self._draw_bud(cr, cx - 92, cy, self._left, "L")
105
+ self._draw_bud(cr, cx + 92, cy, self._right, "R")
106
+ self._draw_case(cr, cx, cy + 54, self._case)
107
+
108
+ def _draw_bud(self, cr, cx, cy, pct, label):
109
+ R = 42
110
+ r = 29
111
+ bc = _battery_color(pct) if pct >= 0 else (0.18, 0.18, 0.18)
112
+
113
+ # outer diffuse glow (battery color)
114
+ for i in range(3):
115
+ rg = cairo.RadialGradient(cx, cy, R - 2, cx, cy, R + 14 + i * 8)
116
+ rg.add_color_stop_rgba(0, *bc, 0.10 - i * 0.025)
117
+ rg.add_color_stop_rgba(1, *bc, 0)
118
+ cr.set_source(rg)
119
+ cr.arc(cx, cy, R + 14 + i * 8, 0, 2 * math.pi)
120
+ cr.fill()
121
+
122
+ # body: radial gradient for sphere depth
123
+ body = cairo.RadialGradient(cx - R * 0.28, cy - R * 0.28, R * 0.08, cx, cy, R)
124
+ body.add_color_stop_rgba(0, 0.24, 0.24, 0.24, 1.0)
125
+ body.add_color_stop_rgba(0.7, 0.10, 0.10, 0.10, 1.0)
126
+ body.add_color_stop_rgba(1, 0.04, 0.04, 0.04, 1.0)
127
+ cr.set_source(body)
128
+ cr.arc(cx, cy, R, 0, 2 * math.pi)
129
+ cr.fill()
130
+
131
+ # battery track (dim full ring)
132
+ cr.set_source_rgba(1.0, 1.0, 1.0, 0.07)
133
+ cr.set_line_width(6)
134
+ cr.arc(cx, cy, R - 3, 0, 2 * math.pi)
135
+ cr.stroke()
136
+
137
+ # battery arc (colored progress)
138
+ if pct > 0:
139
+ cr.set_source_rgba(*bc, 1.0)
140
+ cr.set_line_width(6)
141
+ cr.set_line_cap(cairo.LineCap.ROUND)
142
+ cr.arc(cx, cy, R - 3, -math.pi / 2, -math.pi / 2 + (pct / 100) * 2 * math.pi)
143
+ cr.stroke()
144
+
145
+ # inner circle: radial gradient for glass depth
146
+ inner = cairo.RadialGradient(cx - r * 0.32, cy - r * 0.32, r * 0.06, cx, cy, r)
147
+ inner.add_color_stop_rgba(0, 0.17, 0.17, 0.17, 1.0)
148
+ inner.add_color_stop_rgba(1, 0.03, 0.03, 0.03, 1.0)
149
+ cr.set_source(inner)
150
+ cr.arc(cx, cy, r, 0, 2 * math.pi)
151
+ cr.fill()
152
+
153
+ # glass highlight arc (top-left crescent)
154
+ cr.set_source_rgba(1.0, 1.0, 1.0, 0.10)
155
+ cr.set_line_width(4)
156
+ cr.set_line_cap(cairo.LineCap.ROUND)
157
+ cr.arc(cx, cy, r - 4, math.pi * 1.05, math.pi * 1.72)
158
+ cr.stroke()
159
+
160
+ # percentage text
161
+ cr.set_source_rgba(1.0, 1.0, 1.0, 1.0 if pct >= 0 else 0.22)
162
+ cr.select_font_face(_MONO, cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD)
163
+ text = f"{pct}%" if pct >= 0 else "—"
164
+ cr.set_font_size(13 if pct >= 0 else 17)
165
+ te = cr.text_extents(text)
166
+ cr.move_to(cx - te.width / 2 - te.x_bearing, cy - te.height / 2 - te.y_bearing)
167
+ cr.show_text(text)
168
+
169
+ # L / R label below
170
+ cr.set_source_rgba(1.0, 1.0, 1.0, 0.20)
171
+ cr.select_font_face(_MONO, cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD)
172
+ cr.set_font_size(9)
173
+ te = cr.text_extents(label)
174
+ cr.move_to(cx - te.width / 2 - te.x_bearing, cy + R + 17)
175
+ cr.show_text(label)
176
+
177
+ def _draw_case(self, cr, cx, cy, pct):
178
+ R = 18
179
+ bc = _battery_color(pct) if pct >= 0 else (0.18, 0.18, 0.18)
180
+
181
+ # outer diffuse glow
182
+ rg = cairo.RadialGradient(cx, cy, R - 1, cx, cy, R + 14)
183
+ rg.add_color_stop_rgba(0, *bc, 0.09)
184
+ rg.add_color_stop_rgba(1, *bc, 0)
185
+ cr.set_source(rg)
186
+ cr.arc(cx, cy, R + 14, 0, 2 * math.pi)
187
+ cr.fill()
188
+
189
+ # body
190
+ body = cairo.RadialGradient(cx - R * 0.28, cy - R * 0.28, R * 0.08, cx, cy, R)
191
+ body.add_color_stop_rgba(0, 0.22, 0.22, 0.22, 1.0)
192
+ body.add_color_stop_rgba(1, 0.05, 0.05, 0.05, 1.0)
193
+ cr.set_source(body)
194
+ cr.arc(cx, cy, R, 0, 2 * math.pi)
195
+ cr.fill()
196
+
197
+ # battery track
198
+ cr.set_source_rgba(1.0, 1.0, 1.0, 0.07)
199
+ cr.set_line_width(4)
200
+ cr.arc(cx, cy, R - 2, 0, 2 * math.pi)
201
+ cr.stroke()
202
+
203
+ # battery arc
204
+ if pct > 0:
205
+ cr.set_source_rgba(*bc, 1.0)
206
+ cr.set_line_width(4)
207
+ cr.set_line_cap(cairo.LineCap.ROUND)
208
+ cr.arc(cx, cy, R - 2, -math.pi / 2, -math.pi / 2 + (pct / 100) * 2 * math.pi)
209
+ cr.stroke()
210
+
211
+ # glass highlight
212
+ cr.set_source_rgba(1.0, 1.0, 1.0, 0.09)
213
+ cr.set_line_width(3)
214
+ cr.set_line_cap(cairo.LineCap.ROUND)
215
+ cr.arc(cx, cy, R - 4, math.pi * 1.05, math.pi * 1.72)
216
+ cr.stroke()
217
+
218
+ # percentage text
219
+ cr.set_source_rgba(1.0, 1.0, 1.0, 0.72 if pct >= 0 else 0.20)
220
+ cr.select_font_face(_MONO, cairo.FontSlant.NORMAL, cairo.FontWeight.NORMAL)
221
+ cr.set_font_size(9)
222
+ text = f"{pct}%" if pct >= 0 else "—"
223
+ te = cr.text_extents(text)
224
+ cr.move_to(cx - te.width / 2 - te.x_bearing, cy - te.height / 2 - te.y_bearing)
225
+ cr.show_text(text)
226
+
227
+ # CASE label
228
+ cr.set_source_rgba(1.0, 1.0, 1.0, 0.18)
229
+ cr.select_font_face(_MONO, cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD)
230
+ cr.set_font_size(8)
231
+ te = cr.text_extents("CASE")
232
+ cr.move_to(cx - te.width / 2 - te.x_bearing, cy + R + 14)
233
+ cr.show_text("CASE")
234
+
235
+
236
+ def _section(label: str) -> Gtk.Label:
237
+ lbl = Gtk.Label(label=label)
238
+ lbl.add_css_class("section-label")
239
+ lbl.set_xalign(0)
240
+ return lbl
241
+
242
+
243
+ def _settings_row(title: str, subtitle: str = "", right_widget: Gtk.Widget | None = None) -> Gtk.Box:
244
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
245
+ row.add_css_class("settings-row")
246
+
247
+ text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
248
+ text.set_hexpand(True)
249
+
250
+ t = Gtk.Label(label=title)
251
+ t.add_css_class("settings-row-title")
252
+ t.set_xalign(0)
253
+ text.append(t)
254
+
255
+ if subtitle:
256
+ s = Gtk.Label(label=subtitle)
257
+ s.add_css_class("settings-row-subtitle")
258
+ s.set_xalign(0)
259
+ text.append(s)
260
+
261
+ row.append(text)
262
+ if right_widget:
263
+ row.append(right_widget)
264
+ return row
265
+
266
+
267
+ class DevicePage(Gtk.Box):
268
+ def __init__(self, bt_device: BluetoothDevice, bt_manager: BluetoothManager):
269
+ super().__init__(orientation=Gtk.Orientation.VERTICAL)
270
+ self._bt_device = bt_device
271
+ self._bt = bt_manager
272
+ self._nothing_dev: NothingDevice | None = None
273
+ self._anc_buttons: list[tuple[int, Gtk.Button]] = []
274
+ self._eq_buttons: list[tuple[str, Gtk.Button]] = []
275
+ self._updating_ui = False
276
+ self._vol_debounce_id: int | None = None
277
+ self._vol_handler: int | None = None
278
+ self._bt_conn_handler = bt_manager.connect("device-connected", self._on_bt_device_connected)
279
+ self._bt_disc_handler = bt_manager.connect("device-disconnected", self._on_bt_device_disconnected)
280
+ self._connect_retries = 0
281
+ self._connect_retry_id: int | None = None
282
+ self._build()
283
+ if bt_device.is_nothing:
284
+ self._connect_nothing()
285
+ if bt_device.connected:
286
+ GLib.timeout_add(800, self._query_volume)
287
+
288
+ def _build(self):
289
+ scroll = Gtk.ScrolledWindow()
290
+ scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
291
+ scroll.set_vexpand(True)
292
+
293
+ page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
294
+ page.add_css_class("nothing-page")
295
+ scroll.set_child(page)
296
+ self.append(scroll)
297
+
298
+ self._visual = EarbudVisual()
299
+ self._visual.set_margin_top(16)
300
+ self._visual.set_margin_bottom(8)
301
+ page.append(self._visual)
302
+
303
+ conn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
304
+ conn_box.set_halign(Gtk.Align.CENTER)
305
+ conn_box.set_margin_bottom(4)
306
+
307
+ self._conn_label = Gtk.Label()
308
+ conn_box.append(self._conn_label)
309
+ self._update_status_label()
310
+
311
+ if self._bt_device.battery is not None:
312
+ bat_lbl = Gtk.Label(label=f" {self._bt_device.battery}%")
313
+ bat_lbl.add_css_class("battery-pct")
314
+ conn_box.append(bat_lbl)
315
+
316
+ page.append(conn_box)
317
+
318
+ if self._bt_device.is_nothing:
319
+ self._build_nothing_controls(page)
320
+
321
+ disc_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
322
+ disc_row.set_halign(Gtk.Align.CENTER)
323
+ disc_row.set_margin_top(24)
324
+ disc_row.set_margin_bottom(8)
325
+
326
+ self._conn_btn = Gtk.Button()
327
+ self._update_conn_button()
328
+ self._conn_btn.connect("clicked", self._on_conn_btn_clicked)
329
+ disc_row.append(self._conn_btn)
330
+ page.append(disc_row)
331
+
332
+ def _build_nothing_controls(self, page: Gtk.Box):
333
+ page.append(_section("SOUND MODE"))
334
+
335
+ anc_outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
336
+ anc_outer.set_margin_bottom(4)
337
+ anc_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=2)
338
+ anc_container.add_css_class("anc-container")
339
+ anc_container.set_hexpand(True)
340
+
341
+ for mode, label in [
342
+ (ANCMode.OFF, "Off"),
343
+ (ANCMode.NOISE_CANCELLATION, "Noise Cancellation"),
344
+ (ANCMode.TRANSPARENCY, "Transparency"),
345
+ ]:
346
+ btn = Gtk.Button(label=label)
347
+ btn.add_css_class("anc-button")
348
+ btn.set_hexpand(True)
349
+ btn.connect("clicked", self._on_anc_clicked, mode)
350
+ anc_container.append(btn)
351
+ self._anc_buttons.append((mode, btn))
352
+
353
+ anc_outer.append(anc_container)
354
+ page.append(anc_outer)
355
+
356
+ page.append(_section("EQUALIZER"))
357
+
358
+ eq_flow = Gtk.FlowBox()
359
+ eq_flow.set_selection_mode(Gtk.SelectionMode.NONE)
360
+ eq_flow.set_column_spacing(8)
361
+ eq_flow.set_row_spacing(8)
362
+ eq_flow.set_max_children_per_line(4)
363
+ eq_flow.set_margin_bottom(4)
364
+
365
+ for preset in EQ_PRESETS:
366
+ btn = Gtk.Button(label=preset)
367
+ btn.add_css_class("eq-button")
368
+ btn.connect("clicked", self._on_eq_clicked, preset)
369
+ eq_flow.append(btn)
370
+ self._eq_buttons.append((preset, btn))
371
+
372
+ page.append(eq_flow)
373
+
374
+ page.append(_section("VOLUME"))
375
+
376
+ vol_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
377
+ vol_row.set_margin_bottom(4)
378
+
379
+ self._vol_scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 100, 1)
380
+ self._vol_scale.set_hexpand(True)
381
+ self._vol_scale.set_draw_value(False)
382
+ self._vol_scale.add_css_class("volume-slider")
383
+ self._vol_scale.set_value(70)
384
+ self._vol_handler = self._vol_scale.connect("value-changed", self._on_volume_changed)
385
+
386
+ self._vol_label = Gtk.Label(label="70%")
387
+ self._vol_label.add_css_class("volume-label")
388
+ self._vol_label.set_width_chars(4)
389
+ self._vol_label.set_xalign(1)
390
+
391
+ vol_row.append(self._vol_scale)
392
+ vol_row.append(self._vol_label)
393
+ page.append(vol_row)
394
+
395
+ page.append(_section("SETTINGS"))
396
+
397
+ settings_group = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
398
+ settings_group.add_css_class("settings-group")
399
+ settings_group.set_margin_bottom(4)
400
+
401
+ self._in_ear_switch = Gtk.Switch()
402
+ self._in_ear_switch.set_active(True)
403
+ self._in_ear_switch.set_valign(Gtk.Align.CENTER)
404
+ self._in_ear_switch.connect("state-set", self._on_in_ear_toggled)
405
+ settings_group.append(
406
+ _settings_row(
407
+ "In-Ear Detection",
408
+ "Pause when earbuds are removed",
409
+ self._in_ear_switch,
410
+ )
411
+ )
412
+
413
+ self._auto_pause_switch = Gtk.Switch()
414
+ self._auto_pause_switch.set_active(True)
415
+ self._auto_pause_switch.set_valign(Gtk.Align.CENTER)
416
+ settings_group.append(
417
+ _settings_row(
418
+ "Auto-Pause",
419
+ "Pause media on removal",
420
+ self._auto_pause_switch,
421
+ )
422
+ )
423
+
424
+ page.append(settings_group)
425
+
426
+ page.append(_section("DEVICE INFO"))
427
+
428
+ info_group = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
429
+ info_group.add_css_class("settings-group")
430
+ info_group.set_margin_bottom(4)
431
+
432
+ self._fw_label = Gtk.Label(label="—")
433
+ self._fw_label.add_css_class("info-value")
434
+ self._fw_label.set_xalign(1)
435
+ info_group.append(_settings_row("Firmware", right_widget=self._fw_label))
436
+
437
+ self._sn_label = Gtk.Label(label="—")
438
+ self._sn_label.add_css_class("info-value")
439
+ self._sn_label.set_xalign(1)
440
+ info_group.append(_settings_row("Serial Number", right_widget=self._sn_label))
441
+
442
+ addr_val = Gtk.Label(label=self._bt_device.address)
443
+ addr_val.add_css_class("info-value")
444
+ addr_val.set_xalign(1)
445
+ info_group.append(_settings_row("Address", right_widget=addr_val))
446
+
447
+ page.append(info_group)
448
+
449
+ self._sync_anc_ui(ANCMode.OFF)
450
+ self._sync_eq_ui("Balanced")
451
+
452
+ def _update_conn_button(self):
453
+ self._conn_btn.set_sensitive(True)
454
+ if self._bt_device.connected:
455
+ self._conn_btn.set_label("DISCONNECT")
456
+ self._conn_btn.remove_css_class("connect-button")
457
+ self._conn_btn.remove_css_class("connecting-button")
458
+ self._conn_btn.add_css_class("disconnect-button")
459
+ else:
460
+ self._conn_btn.set_label("CONNECT")
461
+ self._conn_btn.remove_css_class("disconnect-button")
462
+ self._conn_btn.remove_css_class("connecting-button")
463
+ self._conn_btn.add_css_class("connect-button")
464
+
465
+ def _update_status_label(self):
466
+ if self._bt_device.connected:
467
+ self._conn_label.set_label("● Connected")
468
+ self._conn_label.remove_css_class("status-disconnected")
469
+ self._conn_label.add_css_class("status-connected")
470
+ else:
471
+ self._conn_label.set_label("○ Disconnected")
472
+ self._conn_label.remove_css_class("status-connected")
473
+ self._conn_label.add_css_class("status-disconnected")
474
+
475
+ def cleanup(self):
476
+ if self._nothing_dev:
477
+ self._nothing_dev.disconnect_rfcomm()
478
+ self._bt.disconnect(self._bt_conn_handler)
479
+ self._bt.disconnect(self._bt_disc_handler)
480
+
481
+ def _connect_nothing(self):
482
+ self._nothing_dev = NothingDevice(self._bt_device.address)
483
+ self._nothing_dev.connect("state-changed", self._on_state_changed)
484
+ self._nothing_dev.connect("connected", self._on_rfcomm_connected)
485
+ self._nothing_dev.connect("disconnected", self._on_rfcomm_disconnected)
486
+ if self._bt_device.connected:
487
+ self._nothing_dev.connect_rfcomm()
488
+
489
+ def _on_state_changed(self, dev: NothingDevice):
490
+ state = dev.state
491
+ self._visual.update(state.left_battery, state.right_battery, state.case_battery)
492
+ self._sync_anc_ui(state.anc_mode)
493
+ self._sync_eq_ui(state.eq_preset)
494
+ self._updating_ui = True
495
+ if hasattr(self, "_in_ear_switch"):
496
+ self._in_ear_switch.set_active(state.in_ear_detection)
497
+ self._updating_ui = False
498
+ if hasattr(self, "_fw_label"):
499
+ self._fw_label.set_label(state.firmware_version or "—")
500
+ self._sn_label.set_label(state.serial_number or "—")
501
+
502
+ def _on_rfcomm_connected(self, _dev):
503
+ print(f"[device page] RFCOMM connected to {self._bt_device.name}")
504
+ GLib.timeout_add(800, self._query_volume)
505
+
506
+ def _on_rfcomm_disconnected(self, _dev):
507
+ print(f"[device page] RFCOMM disconnected from {self._bt_device.name}")
508
+
509
+ def _query_volume(self):
510
+ def _run():
511
+ pct = _get_sink_volume(self._bt_device.address)
512
+ if pct is not None:
513
+ GLib.idle_add(self._apply_vol_display, pct)
514
+
515
+ threading.Thread(target=_run, daemon=True).start()
516
+ return False
517
+
518
+ def _apply_vol_display(self, pct: int):
519
+ if not hasattr(self, "_vol_scale") or self._vol_handler is None:
520
+ return
521
+ self._vol_scale.handler_block(self._vol_handler)
522
+ self._vol_scale.set_value(pct)
523
+ self._vol_label.set_label(f"{pct}%")
524
+ self._vol_scale.handler_unblock(self._vol_handler)
525
+
526
+ def _on_volume_changed(self, scale: Gtk.Scale):
527
+ pct = int(scale.get_value())
528
+ self._vol_label.set_label(f"{pct}%")
529
+ if self._vol_debounce_id is not None:
530
+ GLib.source_remove(self._vol_debounce_id)
531
+ self._vol_debounce_id = GLib.timeout_add(150, self._do_set_volume, pct)
532
+
533
+ def _do_set_volume(self, pct: int):
534
+ self._vol_debounce_id = None
535
+ threading.Thread(target=_set_sink_volume, args=(self._bt_device.address, pct), daemon=True).start()
536
+ return False
537
+
538
+ def _sync_anc_ui(self, active_mode: int):
539
+ for mode, btn in self._anc_buttons:
540
+ if mode == active_mode:
541
+ btn.add_css_class("active")
542
+ else:
543
+ btn.remove_css_class("active")
544
+
545
+ def _sync_eq_ui(self, active_preset: str):
546
+ for preset, btn in self._eq_buttons:
547
+ if preset == active_preset:
548
+ btn.add_css_class("active")
549
+ else:
550
+ btn.remove_css_class("active")
551
+
552
+ def _on_anc_clicked(self, _btn, mode: int):
553
+ self._sync_anc_ui(mode)
554
+ if self._nothing_dev:
555
+ self._nothing_dev.set_anc_mode(mode)
556
+
557
+ def _on_eq_clicked(self, _btn, preset: str):
558
+ self._sync_eq_ui(preset)
559
+ if self._nothing_dev:
560
+ self._nothing_dev.set_eq_preset(preset)
561
+
562
+ def _on_in_ear_toggled(self, switch: Gtk.Switch, state: bool):
563
+ if self._updating_ui:
564
+ return False
565
+ if self._nothing_dev:
566
+ self._nothing_dev.set_in_ear_detection(state)
567
+ return False
568
+
569
+ def _on_conn_btn_clicked(self, _btn):
570
+ if self._bt_device.connected:
571
+ if self._nothing_dev:
572
+ self._nothing_dev.disconnect_rfcomm()
573
+ self._bt.disconnect_device(self._bt_device.path)
574
+ else:
575
+ self._conn_btn.set_label("CONNECTING…")
576
+ self._conn_btn.set_sensitive(False)
577
+ self._conn_btn.remove_css_class("connect-button")
578
+ self._conn_btn.add_css_class("connecting-button")
579
+ self._bt.connect_device(self._bt_device.path, on_error=self._on_connect_failed)
580
+
581
+ def _on_connect_failed(self):
582
+ self._update_conn_button()
583
+
584
+ def _on_bt_device_connected(self, _manager, path: str):
585
+ if path != self._bt_device.path:
586
+ return
587
+ self._update_conn_button()
588
+ self._update_status_label()
589
+ if self._nothing_dev:
590
+ from .. import profiles
591
+
592
+ profiles.set_last_device(self._bt_device.address)
593
+ self._nothing_dev.connect_rfcomm()
594
+
595
+ def _on_bt_device_disconnected(self, _manager, path: str):
596
+ if path != self._bt_device.path:
597
+ return
598
+ self._update_conn_button()
599
+ self._update_status_label()