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.
- nothing_app/__init__.py +2 -0
- nothing_app/application.py +244 -0
- nothing_app/bluetooth.py +212 -0
- nothing_app/data/__init__.py +0 -0
- nothing_app/data/com.something.x.omarchy.desktop +13 -0
- nothing_app/data/style.css +530 -0
- nothing_app/pages/__init__.py +0 -0
- nothing_app/pages/device.py +599 -0
- nothing_app/pages/home.py +210 -0
- nothing_app/profiles.py +41 -0
- nothing_app/protocol.py +650 -0
- nothing_app/splash.py +181 -0
- nothing_app/window.py +89 -0
- something_x_dev-1.2.3.dev1.dist-info/METADATA +201 -0
- something_x_dev-1.2.3.dev1.dist-info/RECORD +19 -0
- something_x_dev-1.2.3.dev1.dist-info/WHEEL +5 -0
- something_x_dev-1.2.3.dev1.dist-info/entry_points.txt +2 -0
- something_x_dev-1.2.3.dev1.dist-info/licenses/LICENSE +21 -0
- something_x_dev-1.2.3.dev1.dist-info/top_level.txt +1 -0
|
@@ -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()
|