sat_audio_view.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. # -*- coding: utf-8 -*-
  2. """
  3. Satellite SubView: Audio (F6).
  4. ALSA/PulseAudio Lautstaerke- und Mute-Steuerung pro Satellite.
  5. Werte werden in der jeweiligen client_config.json persistiert.
  6. Bedient sich des SatelliteAudioRequest-Hops:
  7. Config-Tool ── ConfigSatelliteAudioRequest ──> Server
  8. Server ── SatelliteAudioRequest ──> Satellite
  9. Satellite ── SatelliteAudioResponse ──> Server
  10. Server ── ConfigSatelliteAudioResponse ──> Config-Tool
  11. Aenderungen die hier ausgeloest werden, laufen mit source="user"
  12. durch den VolumeHandler und werden daher in client_config.json
  13. gespeichert.
  14. """
  15. from __future__ import annotations
  16. from textual.app import ComposeResult
  17. from textual.containers import Vertical, Horizontal
  18. from textual.widgets import Static, Button
  19. from trixy_core.tui.views.base import MainView
  20. from trixy_core.tui.widgets.status_table import StatusTable
  21. VOLUME_STEP = 5
  22. class SatAudioView(MainView):
  23. """F6 im Satellite-Kontext: ALSA Lautstaerke + Mute."""
  24. TITLE = "Audio"
  25. VIEW_ID = "sat_audio"
  26. def __init__(self, **kwargs) -> None:
  27. super().__init__(**kwargs)
  28. self._sat_data: dict = {}
  29. self._status: object | None = None
  30. self._refresh_counter: int = 0
  31. # Lokale Speaker-Bearbeitung — wird beim Laden befuellt und
  32. # bei "Anwenden" an den Satellite gesendet.
  33. self._editable_speakers: list[dict] = []
  34. self._cursor: int = 0
  35. self._dirty: bool = False
  36. # Analog fuer Mikrofone
  37. self._editable_mics: list[dict] = []
  38. self._mic_cursor: int = 0
  39. self._mics_dirty: bool = False
  40. def set_satellite(self, sat_data: dict) -> None:
  41. self._sat_data = sat_data
  42. # === Layout ===
  43. def compose(self) -> ComposeResult:
  44. with Vertical(classes="section-box"):
  45. yield Static("[bold]Lautsprecher (Output)[/bold]", classes="section-header")
  46. yield StatusTable(id="sat-audio-output-info")
  47. with Horizontal(classes="button-row"):
  48. yield Button("− 5%", id="audio-out-down")
  49. yield Button("+ 5%", id="audio-out-up")
  50. yield Button("Mute", id="audio-out-mute")
  51. with Vertical(classes="section-box"):
  52. yield Static("[bold]Mikrofon (Input)[/bold]", classes="section-header")
  53. yield StatusTable(id="sat-audio-input-info")
  54. with Horizontal(classes="button-row"):
  55. yield Button("− 5%", id="audio-in-down")
  56. yield Button("+ 5%", id="audio-in-up")
  57. yield Button("Mute", id="audio-in-mute")
  58. with Vertical(classes="section-box"):
  59. yield Static("[bold]Lautsprecher-Verbund (Multi-Speaker)[/bold]", classes="section-header")
  60. yield Static("", id="sat-audio-speakers-header")
  61. yield StatusTable(id="sat-audio-speakers-list")
  62. with Horizontal(classes="button-row"):
  63. yield Button("Aktualisieren", id="audio-speakers-refresh")
  64. yield Button("Naechster", id="audio-speakers-cursor")
  65. yield Button("An/Aus", id="audio-speakers-toggle")
  66. yield Button("Vol −", id="audio-speakers-vol-down")
  67. yield Button("Vol +", id="audio-speakers-vol-up")
  68. yield Button("Anwenden", id="audio-speakers-apply")
  69. with Vertical(classes="section-box"):
  70. yield Static("[bold]Mikrofone (Multi-Mic Mix)[/bold]", classes="section-header")
  71. yield Static("", id="sat-audio-mics-header")
  72. yield StatusTable(id="sat-audio-mics-list")
  73. with Horizontal(classes="button-row"):
  74. yield Button("Aktualisieren", id="audio-mics-refresh")
  75. yield Button("Naechster", id="audio-mics-cursor")
  76. yield Button("An/Aus", id="audio-mics-toggle")
  77. yield Button("Anwenden", id="audio-mics-apply")
  78. with Vertical(classes="section-box"):
  79. yield Static("[bold]Hinweis[/bold]", classes="section-header")
  80. yield Static(
  81. "Aenderungen werden in client_config.json des Satellites "
  82. "gespeichert. Audio-Ducking veraendert nur die Live-"
  83. "Lautstaerke und stellt sie aus diesen Werten wieder her. "
  84. "Multi-Speaker erfordert PulseAudio oder PipeWire.",
  85. id="sat-audio-hint",
  86. )
  87. # === Lifecycle ===
  88. async def on_activate(self) -> None:
  89. await self._reload()
  90. async def refresh_data(self) -> None:
  91. self._refresh_counter += 1
  92. if self._refresh_counter % 5 == 0:
  93. await self._reload()
  94. # === Daten-Operationen ===
  95. def _satellite_id(self) -> str:
  96. return self._sat_data.get("satellite_id", "") if self._sat_data else ""
  97. async def _reload(self) -> None:
  98. sat_id = self._satellite_id()
  99. if not self.connection or not sat_id:
  100. return
  101. try:
  102. # Sinks laden — fuellt auch den Basis-Audio-Status
  103. sinks_response = await self.connection.request_satellite_audio(
  104. sat_id, action="list_pulse_sinks"
  105. )
  106. # Sources in einem zweiten Request — kommt aus einer anderen
  107. # Pulse-Operation und wird nicht beim list_pulse_sinks mitgeliefert
  108. sources_response = await self.connection.request_satellite_audio(
  109. sat_id, action="list_pulse_sources"
  110. )
  111. except Exception:
  112. return
  113. if sinks_response is None:
  114. return
  115. # Mergen: Basis aus Sinks-Response + Sources aus der zweiten
  116. self._status = sinks_response
  117. if sources_response is not None:
  118. self._status.pulse_sources = list(
  119. getattr(sources_response, "pulse_sources", []) or []
  120. )
  121. if not self._dirty:
  122. self._sync_editable_from_status()
  123. if not self._mics_dirty:
  124. self._sync_mics_from_status()
  125. self._update_display()
  126. def _sync_mics_from_status(self) -> None:
  127. """Baut die editierbare Mic-Liste aus dem Status auf.
  128. Analog zu _sync_editable_from_status: alle Pulse-Sources +
  129. konfigurierte aber offline Mics.
  130. """
  131. st = self._status
  132. if st is None:
  133. return
  134. configured = {
  135. m.get("device", ""): m
  136. for m in (getattr(st, "input_microphones", []) or [])
  137. }
  138. sources = list(getattr(st, "pulse_sources", []) or [])
  139. merged: list[dict] = []
  140. seen: set[str] = set()
  141. for src in sources:
  142. name = src.get("name", "")
  143. if not name:
  144. continue
  145. cfg = configured.get(name, {})
  146. merged.append({
  147. "device": name,
  148. "description": src.get("description", name),
  149. "enabled": bool(cfg.get("enabled", False)),
  150. "label": cfg.get("label", ""),
  151. })
  152. seen.add(name)
  153. # Konfigurierte Mics die aktuell offline sind
  154. for name, cfg in configured.items():
  155. if name in seen:
  156. continue
  157. merged.append({
  158. "device": name,
  159. "description": f"{name} [dim](offline)[/dim]",
  160. "enabled": bool(cfg.get("enabled", False)),
  161. "label": cfg.get("label", ""),
  162. })
  163. self._editable_mics = merged
  164. if self._mic_cursor >= len(merged):
  165. self._mic_cursor = 0
  166. def _sync_editable_from_status(self) -> None:
  167. """Baut die editierbare Speaker-Liste aus dem Status auf.
  168. Strategie: Alle Pulse-Sinks aufnehmen, mit den in der Config
  169. gespeicherten enabled/volume-Werten anreichern. So sieht der
  170. User immer das volle Set an verfuegbaren Sinks und kann sie
  171. an-/abwaehlen.
  172. """
  173. st = self._status
  174. if st is None:
  175. return
  176. configured = {
  177. sp.get("device", ""): sp
  178. for sp in (getattr(st, "output_speakers", []) or [])
  179. }
  180. sinks = list(getattr(st, "pulse_sinks", []) or [])
  181. merged: list[dict] = []
  182. seen: set[str] = set()
  183. for sink in sinks:
  184. name = sink.get("name", "")
  185. if not name:
  186. continue
  187. cfg = configured.get(name, {})
  188. merged.append({
  189. "device": name,
  190. "description": sink.get("description", name),
  191. "enabled": bool(cfg.get("enabled", False)),
  192. "volume": int(cfg.get("volume", 80)),
  193. })
  194. seen.add(name)
  195. # Konfigurierte Sinks die aktuell nicht im System sichtbar sind
  196. # trotzdem behalten (z.B. abgesteckter USB-Speaker)
  197. for name, cfg in configured.items():
  198. if name in seen:
  199. continue
  200. merged.append({
  201. "device": name,
  202. "description": f"{name} [dim](offline)[/dim]",
  203. "enabled": bool(cfg.get("enabled", False)),
  204. "volume": int(cfg.get("volume", 80)),
  205. })
  206. self._editable_speakers = merged
  207. if self._cursor >= len(merged):
  208. self._cursor = 0
  209. async def _send(
  210. self,
  211. action: str,
  212. target: str,
  213. volume: int = 0,
  214. muted: bool = False,
  215. ) -> None:
  216. sat_id = self._satellite_id()
  217. if not self.connection or not sat_id:
  218. return
  219. try:
  220. response = await self.connection.request_satellite_audio(
  221. sat_id,
  222. action=action,
  223. target=target,
  224. volume=volume,
  225. muted=muted,
  226. )
  227. except Exception:
  228. return
  229. if response is not None:
  230. self._status = response
  231. self._update_display()
  232. # === Anzeige ===
  233. def _update_display(self) -> None:
  234. st = self._status
  235. if st is None:
  236. return
  237. out_table = self.query_one("#sat-audio-output-info", StatusTable)
  238. if getattr(st, "output_available", False):
  239. volume = int(getattr(st, "output_volume", 0))
  240. muted = bool(getattr(st, "output_muted", False))
  241. mute_str = "[red]MUTE[/red]" if muted else "[green]aktiv[/green]"
  242. out_table.update_data([
  243. ("Mixer-Control", getattr(st, "output_control", "") or "?"),
  244. ("Lautstaerke", _bar(volume)),
  245. ("Status", mute_str),
  246. ])
  247. else:
  248. out_table.update_data([("Status", "[dim]Nicht verfuegbar[/dim]")])
  249. in_table = self.query_one("#sat-audio-input-info", StatusTable)
  250. if getattr(st, "input_available", False):
  251. volume = int(getattr(st, "input_volume", 0))
  252. muted = bool(getattr(st, "input_muted", False))
  253. mute_str = "[red]MUTE[/red]" if muted else "[green]aktiv[/green]"
  254. in_table.update_data([
  255. ("Mixer-Control", getattr(st, "input_control", "") or "?"),
  256. ("Lautstaerke", _bar(volume)),
  257. ("Status", mute_str),
  258. ])
  259. else:
  260. in_table.update_data([("Status", "[dim]Nicht verfuegbar[/dim]")])
  261. # Multi-Speaker
  262. header = self.query_one("#sat-audio-speakers-header", Static)
  263. speakers_table = self.query_one("#sat-audio-speakers-list", StatusTable)
  264. pulse_avail = bool(getattr(st, "pulse_available", False))
  265. active = bool(getattr(st, "combined_sink_active", False))
  266. if not pulse_avail:
  267. header.update(
  268. "[dim]PulseAudio/PipeWire nicht verfuegbar — Multi-Speaker "
  269. "wird auf diesem System nicht unterstuetzt.[/dim]"
  270. )
  271. speakers_table.update_data([])
  272. return
  273. enabled_count = sum(1 for sp in self._editable_speakers if sp["enabled"])
  274. status_parts = [
  275. f"Verfuegbar: [bold]{len(self._editable_speakers)}[/bold]",
  276. f"Aktiviert: [bold]{enabled_count}[/bold]",
  277. ]
  278. if active:
  279. status_parts.append("Combined-Sink: [green]aktiv[/green]")
  280. else:
  281. status_parts.append("Combined-Sink: [dim]inaktiv[/dim]")
  282. if self._dirty:
  283. status_parts.append("[yellow]ungespeicherte Aenderungen[/yellow]")
  284. header.update(" · ".join(status_parts))
  285. rows: list[tuple[str, str]] = []
  286. for idx, sp in enumerate(self._editable_speakers):
  287. cursor = "▶" if idx == self._cursor else " "
  288. check = "[green][x][/green]" if sp["enabled"] else "[ ]"
  289. label = f"{cursor} {check} {sp['description']}"
  290. value = _bar(int(sp["volume"]))
  291. rows.append((label, value))
  292. if not rows:
  293. rows = [("", "[dim]Keine Sinks gefunden[/dim]")]
  294. speakers_table.update_data(rows)
  295. # Multi-Mic
  296. mics_header = self.query_one("#sat-audio-mics-header", Static)
  297. mics_table = self.query_one("#sat-audio-mics-list", StatusTable)
  298. if not pulse_avail:
  299. mics_header.update(
  300. "[dim]PulseAudio/PipeWire nicht verfuegbar — Multi-Mic "
  301. "wird auf diesem System nicht unterstuetzt.[/dim]"
  302. )
  303. mics_table.update_data([])
  304. return
  305. mic_active = bool(getattr(st, "input_mix_active", False))
  306. mic_enabled_count = sum(1 for m in self._editable_mics if m["enabled"])
  307. mic_status_parts = [
  308. f"Verfuegbar: [bold]{len(self._editable_mics)}[/bold]",
  309. f"Aktiviert: [bold]{mic_enabled_count}[/bold]",
  310. ]
  311. if mic_active:
  312. mic_status_parts.append("Mix: [green]aktiv[/green]")
  313. elif mic_enabled_count >= 2:
  314. mic_status_parts.append("Mix: [yellow]inaktiv (noch nicht angewendet)[/yellow]")
  315. else:
  316. mic_status_parts.append("Mix: [dim]inaktiv (Single-Mic)[/dim]")
  317. if self._mics_dirty:
  318. mic_status_parts.append("[yellow]ungespeicherte Aenderungen[/yellow]")
  319. mics_header.update(" · ".join(mic_status_parts))
  320. mic_rows: list[tuple[str, str]] = []
  321. for idx, m in enumerate(self._editable_mics):
  322. cursor = "▶" if idx == self._mic_cursor else " "
  323. check = "[green][x][/green]" if m["enabled"] else "[ ]"
  324. label = f"{cursor} {check} {m['description']}"
  325. value = m.get("label", "") or "[dim]—[/dim]"
  326. mic_rows.append((label, value))
  327. if not mic_rows:
  328. mic_rows = [("", "[dim]Keine Mikrofone gefunden[/dim]")]
  329. mics_table.update_data(mic_rows)
  330. # === Button-Events ===
  331. async def on_button_pressed(self, event: Button.Pressed) -> None:
  332. bid = event.button.id or ""
  333. # === Multi-Speaker Buttons ===
  334. if bid.startswith("audio-speakers-"):
  335. await self._handle_speakers_button(bid)
  336. return
  337. # === Multi-Mic Buttons ===
  338. if bid.startswith("audio-mics-"):
  339. await self._handle_mics_button(bid)
  340. return
  341. st = self._status
  342. if st is None:
  343. return
  344. if bid.startswith("audio-out-"):
  345. target = "output"
  346. current = int(getattr(st, "output_volume", 0))
  347. currently_muted = bool(getattr(st, "output_muted", False))
  348. elif bid.startswith("audio-in-"):
  349. target = "input"
  350. current = int(getattr(st, "input_volume", 0))
  351. currently_muted = bool(getattr(st, "input_muted", False))
  352. else:
  353. return
  354. if bid.endswith("-up"):
  355. await self._send("set_volume", target, volume=min(100, current + VOLUME_STEP))
  356. elif bid.endswith("-down"):
  357. await self._send("set_volume", target, volume=max(0, current - VOLUME_STEP))
  358. elif bid.endswith("-mute"):
  359. await self._send("set_mute", target, muted=not currently_muted)
  360. async def _handle_speakers_button(self, bid: str) -> None:
  361. if bid == "audio-speakers-refresh":
  362. self._dirty = False
  363. await self._reload()
  364. return
  365. if not self._editable_speakers:
  366. return
  367. if bid == "audio-speakers-cursor":
  368. self._cursor = (self._cursor + 1) % len(self._editable_speakers)
  369. self._update_display()
  370. return
  371. if bid == "audio-speakers-toggle":
  372. sp = self._editable_speakers[self._cursor]
  373. sp["enabled"] = not sp["enabled"]
  374. self._dirty = True
  375. self._update_display()
  376. return
  377. if bid in ("audio-speakers-vol-up", "audio-speakers-vol-down"):
  378. sp = self._editable_speakers[self._cursor]
  379. step = VOLUME_STEP if bid.endswith("-up") else -VOLUME_STEP
  380. sp["volume"] = max(0, min(100, int(sp["volume"]) + step))
  381. self._dirty = True
  382. self._update_display()
  383. return
  384. if bid == "audio-speakers-apply":
  385. payload = [
  386. {
  387. "device": sp["device"],
  388. "enabled": bool(sp["enabled"]),
  389. "volume": int(sp["volume"]),
  390. }
  391. for sp in self._editable_speakers
  392. ]
  393. try:
  394. response = await self.connection.request_satellite_audio(
  395. self._satellite_id(),
  396. action="set_speakers",
  397. speakers=payload,
  398. )
  399. except Exception:
  400. return
  401. if response is not None:
  402. self._status = response
  403. self._dirty = False
  404. self._sync_editable_from_status()
  405. self._update_display()
  406. async def _handle_mics_button(self, bid: str) -> None:
  407. if bid == "audio-mics-refresh":
  408. self._mics_dirty = False
  409. await self._reload()
  410. return
  411. if not self._editable_mics:
  412. return
  413. if bid == "audio-mics-cursor":
  414. self._mic_cursor = (self._mic_cursor + 1) % len(self._editable_mics)
  415. self._update_display()
  416. return
  417. if bid == "audio-mics-toggle":
  418. m = self._editable_mics[self._mic_cursor]
  419. m["enabled"] = not m["enabled"]
  420. self._mics_dirty = True
  421. self._update_display()
  422. return
  423. if bid == "audio-mics-apply":
  424. payload = [
  425. {
  426. "device": m["device"],
  427. "enabled": bool(m["enabled"]),
  428. "label": m.get("label", ""),
  429. }
  430. for m in self._editable_mics
  431. ]
  432. try:
  433. response = await self.connection.request_satellite_audio(
  434. self._satellite_id(),
  435. action="set_microphones",
  436. microphones=payload,
  437. )
  438. except Exception:
  439. return
  440. if response is not None:
  441. self._status = response
  442. self._mics_dirty = False
  443. # Nach Apply frische Sources holen damit input_mix_active
  444. # korrekt angezeigt wird — das Apply alleine liefert nur den
  445. # Mix-Status, nicht die Quellen-Liste.
  446. await self._reload()
  447. def _bar(percent: int) -> str:
  448. """Rendert eine 20-Zeichen Lautstaerke-Bar mit Prozent-Angabe."""
  449. percent = max(0, min(100, percent))
  450. filled = int(round(percent / 5))
  451. bar = "█" * filled + "░" * (20 - filled)
  452. return f"[cyan]{bar}[/cyan] {percent:3d}%"