Skip to main content

max / alloy

32.0 KB · 949 lines History Blame Raw
1 //! `alloy audio` — a PipeWire front, via `pactl`.
2 //!
3 //! docs/CONSOLE.md names this a "wpctl / pactl front" and either would do, but
4 //! `wpctl status` renders a box-drawing tree meant for human eyes: parsing it
5 //! means guessing at indentation and glyphs that exist to look right, not to
6 //! be read back. `pactl -f json` is a stated contract. After the nmcli
7 //! escaping mistake, a format that documents itself is worth a `serde_json`
8 //! dependency.
9 //!
10 //! # Two panes
11 //!
12 //! Streams on the left, devices on the right, joined by a connector through
13 //! the gutter. A stream is an app or service moving audio; a device is the
14 //! hardware it moves through. Every stream is routed to exactly one device,
15 //! which is what makes the pairing drawable at all.
16 //!
17 //! On vocabulary: the left pane holds *streams*, not "sources". PulseAudio and
18 //! PipeWire already use `source` for a capture device, and this module's
19 //! `Direction::Input` means exactly that. Naming the app column "sources"
20 //! would put the word next to a logged `pactl move-sink-input` meaning
21 //! something else, and the log pane's whole job is teaching that vocabulary.
22 //!
23 //! Not a patchbay. PipeWire's real graph is port-level and many-to-many, and
24 //! even one playing stream produces eight `pw-link` entries on a stereo setup.
25 //! Routing a stream to a device covers what people actually want ("move this
26 //! to my headphones"); the general graph is Helvum's job.
27
28 use std::collections::HashMap;
29
30 use alloy_tui::{
31 AlloyBlock, AlloyConnector, AlloyList, Cursor, FocusRing, Hint, Severity, Theme, hint, layout,
32 list_row_y, text,
33 };
34 use anyhow::{Context, Result};
35 use ratatui::Frame;
36 use ratatui::crossterm::event::{KeyCode, KeyEvent};
37 use ratatui::layout::Rect;
38 use ratatui::text::{Line, Span};
39 use serde::Deserialize;
40
41 use crate::cli::{CommandLog, Invocation};
42 use crate::shell::{Flow, View, block_title};
43
44 /// How much one keypress moves the volume. Matches the step swayosd uses for
45 /// the Fn keys, so the console and the hardware keys agree.
46 const VOLUME_STEP: u8 = 5;
47
48 /// PipeWire's unity volume — `pactl`'s raw values are relative to this, not to
49 /// 100. Reading `value_percent` instead would mean parsing a localized string
50 /// with a `%` glued to it.
51 const VOLUME_UNITY: u32 = 65536;
52
53 /// Ticks between device re-reads. Streams come and go with applications and
54 /// are re-read every tick; devices change only when hardware is plugged in, so
55 /// polling them at the same rate would spend four `pactl` spawns a second to
56 /// learn nothing. `r` refreshes both immediately.
57 const DEVICE_POLL_TICKS: u64 = 10;
58
59 /// Pane indices for the focus ring.
60 const PANE_STREAMS: usize = 0;
61 const PANE_DEVICES: usize = 1;
62
63 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
64 pub(crate) enum Direction {
65 Output,
66 Input,
67 }
68
69 impl Direction {
70 const fn label(self) -> &'static str {
71 match self {
72 Direction::Output => "output",
73 Direction::Input => "input",
74 }
75 }
76
77 /// The `pactl` noun for a *device* in this direction.
78 const fn device_noun(self) -> &'static str {
79 match self {
80 Direction::Output => "sink",
81 Direction::Input => "source",
82 }
83 }
84
85 /// The `pactl` noun for a *stream* in this direction. Note the asymmetry:
86 /// a stream playing *out* is an input *to a sink*, so playback streams are
87 /// `sink-input` and capture streams are `source-output`.
88 const fn stream_noun(self) -> &'static str {
89 match self {
90 Direction::Output => "sink-input",
91 Direction::Input => "source-output",
92 }
93 }
94 }
95
96 #[derive(Debug, Clone)]
97 pub(crate) struct Device {
98 pub index: u32,
99 pub name: String,
100 pub description: String,
101 pub direction: Direction,
102 pub volume: u8,
103 pub muted: bool,
104 pub is_default: bool,
105 }
106
107 impl Device {
108 fn severity(&self) -> Severity {
109 match (self.is_default, self.muted) {
110 (_, true) => Severity::Warn,
111 (true, false) => Severity::Healthy,
112 (false, false) => Severity::Info,
113 }
114 }
115
116 fn state_label(&self) -> &'static str {
117 match (self.is_default, self.muted) {
118 (true, true) => "default, muted",
119 (true, false) => "default",
120 (false, true) => "muted",
121 (false, false) => "",
122 }
123 }
124 }
125
126 /// An application or service moving audio through a device.
127 #[derive(Debug, Clone)]
128 pub(crate) struct Stream {
129 pub index: u32,
130 /// Who is playing. `media.name` (what is playing) is deliberately not kept
131 /// alongside it: the streams pane is the narrower of the two and has no
132 /// column to spare, and `media.name` already serves as the last fallback
133 /// for this field when an app sets no name of its own.
134 pub app: String,
135 pub direction: Direction,
136 /// Index of the device this stream is routed to. The pairing the
137 /// connector draws.
138 pub device_index: u32,
139 pub volume: u8,
140 pub muted: bool,
141 /// Paused. PulseAudio's word, kept because it is what the CLI says.
142 pub corked: bool,
143 }
144
145 impl Stream {
146 fn severity(&self) -> Severity {
147 match (self.muted, self.corked) {
148 (true, _) => Severity::Warn,
149 (false, true) => Severity::Info,
150 (false, false) => Severity::Healthy,
151 }
152 }
153
154 fn state_label(&self) -> &'static str {
155 match (self.muted, self.corked) {
156 (true, true) => "muted, idle",
157 (true, false) => "muted",
158 (false, true) => "idle",
159 (false, false) => "playing",
160 }
161 }
162 }
163
164 /// What an action applies to. Devices and streams take the same verbs with
165 /// different `pactl` nouns and identifiers, so the backend takes one of these
166 /// rather than duplicating every method.
167 #[derive(Debug, Clone, Copy)]
168 pub(crate) enum Target<'a> {
169 Device(&'a Device),
170 Stream(&'a Stream),
171 }
172
173 impl Target<'_> {
174 /// The `pactl` noun, which is what its subcommands are built from
175 /// (`set-sink-volume`, `set-sink-input-mute`).
176 fn noun(&self) -> &'static str {
177 match self {
178 Target::Device(device) => device.direction.device_noun(),
179 Target::Stream(stream) => stream.direction.stream_noun(),
180 }
181 }
182
183 /// How `pactl` names this target. Devices go by name, which is stable and
184 /// readable in the log; streams go by index, which is all they have.
185 fn id(&self) -> String {
186 match self {
187 Target::Device(device) => device.name.clone(),
188 Target::Stream(stream) => stream.index.to_string(),
189 }
190 }
191
192 fn volume(&self) -> u8 {
193 match self {
194 Target::Device(device) => device.volume,
195 Target::Stream(stream) => stream.volume,
196 }
197 }
198 }
199
200 pub(crate) trait Backend {
201 fn name(&self) -> &'static str;
202 fn list_devices(&self, log: &mut CommandLog) -> Result<Vec<Device>>;
203 fn list_streams(&self, log: &mut CommandLog) -> Result<Vec<Stream>>;
204
205 /// The default output device on its own.
206 ///
207 /// For a caller that wants one number rather than the table: `alloy status
208 /// --bar` shows the volume of whatever is playing and nothing else, and
209 /// [`list_devices`](Self::list_devices) spends four `pactl` spawns to build
210 /// a table it would throw away. A backend that can answer more cheaply
211 /// overrides this; one that cannot pays the same as before.
212 fn default_output(&self, log: &mut CommandLog) -> Result<Option<Device>> {
213 Ok(self
214 .list_devices(log)?
215 .into_iter()
216 .find(|device| device.is_default && device.direction == Direction::Output))
217 }
218
219 /// Set a target's volume, as a percentage of unity.
220 fn set_volume(&self, target: Target<'_>, percent: u8, log: &mut CommandLog) -> Result<()>;
221
222 /// Toggle a target's mute.
223 fn toggle_mute(&self, target: Target<'_>, log: &mut CommandLog) -> Result<()>;
224
225 /// Make a device the default for its direction.
226 fn set_default(&self, device: &Device, log: &mut CommandLog) -> Result<()>;
227
228 /// Route a stream to a device. The pairing edit.
229 fn move_stream(&self, stream: &Stream, device: &Device, log: &mut CommandLog) -> Result<()>;
230 }
231
232 /// Pick a backend: `pactl` when it answers, the mock otherwise.
233 pub(crate) fn detect() -> Box<dyn Backend> {
234 if Invocation::new("pactl").arg("--version").probe() {
235 Box::new(PaCtl)
236 } else {
237 Box::new(Mock)
238 }
239 }
240
241 pub(crate) struct PaCtl;
242
243 impl Backend for PaCtl {
244 fn name(&self) -> &'static str {
245 "pactl"
246 }
247
248 fn list_devices(&self, log: &mut CommandLog) -> Result<Vec<Device>> {
249 let sinks = Invocation::new("pactl")
250 .args(["-f", "json", "list", "sinks"])
251 .run(log)?;
252 let default_sink = Invocation::new("pactl").arg("get-default-sink").run(log)?;
253 let sources = Invocation::new("pactl")
254 .args(["-f", "json", "list", "sources"])
255 .run(log)?;
256 let default_source = Invocation::new("pactl")
257 .arg("get-default-source")
258 .run(log)?;
259
260 let mut devices = parse_devices(&sinks, Direction::Output, default_sink.trim())
261 .context("parsing sinks")?;
262 devices.extend(
263 parse_devices(&sources, Direction::Input, default_source.trim())
264 .context("parsing sources")?,
265 );
266 Ok(devices)
267 }
268
269 /// Two spawns rather than the four [`Backend::list_devices`] makes: the
270 /// sources and the default source say nothing about an output device, and
271 /// the bar asks this question on every volume event.
272 fn default_output(&self, log: &mut CommandLog) -> Result<Option<Device>> {
273 let sinks = Invocation::new("pactl")
274 .args(["-f", "json", "list", "sinks"])
275 .run(log)?;
276 let default_sink = Invocation::new("pactl").arg("get-default-sink").run(log)?;
277 Ok(
278 parse_devices(&sinks, Direction::Output, default_sink.trim())
279 .context("parsing sinks")?
280 .into_iter()
281 .find(|device| device.is_default),
282 )
283 }
284
285 fn list_streams(&self, log: &mut CommandLog) -> Result<Vec<Stream>> {
286 let playback = Invocation::new("pactl")
287 .args(["-f", "json", "list", "sink-inputs"])
288 .run(log)?;
289 let capture = Invocation::new("pactl")
290 .args(["-f", "json", "list", "source-outputs"])
291 .run(log)?;
292
293 let mut streams =
294 parse_streams(&playback, Direction::Output).context("parsing sink-inputs")?;
295 streams
296 .extend(parse_streams(&capture, Direction::Input).context("parsing source-outputs")?);
297 Ok(streams)
298 }
299
300 fn set_volume(&self, target: Target<'_>, percent: u8, log: &mut CommandLog) -> Result<()> {
301 Invocation::new("pactl")
302 .arg(format!("set-{}-volume", target.noun()))
303 .arg(target.id())
304 .arg(format!("{percent}%"))
305 .run(log)
306 .map(drop)
307 }
308
309 fn toggle_mute(&self, target: Target<'_>, log: &mut CommandLog) -> Result<()> {
310 Invocation::new("pactl")
311 .arg(format!("set-{}-mute", target.noun()))
312 .arg(target.id())
313 .arg("toggle")
314 .run(log)
315 .map(drop)
316 }
317
318 fn set_default(&self, device: &Device, log: &mut CommandLog) -> Result<()> {
319 Invocation::new("pactl")
320 .arg(format!("set-default-{}", device.direction.device_noun()))
321 .arg(&device.name)
322 .run(log)
323 .map(drop)
324 }
325
326 fn move_stream(&self, stream: &Stream, device: &Device, log: &mut CommandLog) -> Result<()> {
327 Invocation::new("pactl")
328 .arg(format!("move-{}", stream.direction.stream_noun()))
329 .arg(stream.index.to_string())
330 .arg(&device.name)
331 .run(log)
332 .map(drop)
333 }
334 }
335
336 /// Fixed sample state, for machines without PipeWire.
337 pub(crate) struct Mock;
338
339 impl Backend for Mock {
340 fn name(&self) -> &'static str {
341 "mock"
342 }
343
344 fn list_devices(&self, log: &mut CommandLog) -> Result<Vec<Device>> {
345 log.record("# no PipeWire; showing mock devices", Severity::Warn);
346 Ok(vec![
347 Device {
348 index: 1,
349 name: "alsa_output.analog-stereo".into(),
350 description: "Analog Stereo".into(),
351 direction: Direction::Output,
352 volume: 90,
353 muted: false,
354 is_default: true,
355 },
356 Device {
357 index: 2,
358 name: "alsa_output.hdmi-stereo".into(),
359 description: "HDMI Stereo".into(),
360 direction: Direction::Output,
361 volume: 100,
362 muted: true,
363 is_default: false,
364 },
365 Device {
366 index: 3,
367 name: "alsa_input.analog-stereo".into(),
368 description: "Analog Stereo Microphone".into(),
369 direction: Direction::Input,
370 volume: 75,
371 muted: false,
372 is_default: true,
373 },
374 ])
375 }
376
377 fn list_streams(&self, _log: &mut CommandLog) -> Result<Vec<Stream>> {
378 Ok(vec![
379 Stream {
380 index: 100,
381 app: "firefox".into(),
382 direction: Direction::Output,
383 device_index: 1,
384 volume: 100,
385 muted: false,
386 corked: false,
387 },
388 Stream {
389 index: 101,
390 app: "mpv".into(),
391 direction: Direction::Output,
392 device_index: 2,
393 volume: 80,
394 muted: false,
395 corked: true,
396 },
397 ])
398 }
399
400 // The mock is a display fixture, not a simulator. Mutating it would show
401 // the user a change that did not happen to any real device, which is worse
402 // than plainly declining.
403 fn set_volume(&self, _target: Target<'_>, _percent: u8, log: &mut CommandLog) -> Result<()> {
404 log.record("# mock backend: volume unchanged", Severity::Warn);
405 Ok(())
406 }
407
408 fn toggle_mute(&self, _target: Target<'_>, log: &mut CommandLog) -> Result<()> {
409 log.record("# mock backend: mute unchanged", Severity::Warn);
410 Ok(())
411 }
412
413 fn set_default(&self, _device: &Device, log: &mut CommandLog) -> Result<()> {
414 log.record("# mock backend: default unchanged", Severity::Warn);
415 Ok(())
416 }
417
418 fn move_stream(&self, _stream: &Stream, _device: &Device, log: &mut CommandLog) -> Result<()> {
419 log.record("# mock backend: routing unchanged", Severity::Warn);
420 Ok(())
421 }
422 }
423
424 // ---- pactl JSON ----
425
426 #[derive(Deserialize)]
427 struct PaDevice {
428 index: u32,
429 name: String,
430 #[serde(default)]
431 description: String,
432 #[serde(default)]
433 mute: bool,
434 #[serde(default)]
435 volume: HashMap<String, PaChannel>,
436 /// Field with two different meanings depending on which list it came from,
437 /// which is a trap worth spelling out.
438 ///
439 /// On a **source** it names the sink this source is a monitor *of*, and is
440 /// empty for real capture hardware. On a **sink** it names the monitor
441 /// that sink *has*, and is non-empty for essentially every sink. So it
442 /// identifies monitors only among sources; applied to sinks it matches
443 /// almost all of them.
444 #[serde(default)]
445 monitor_source: String,
446 }
447
448 #[derive(Deserialize)]
449 struct PaStream {
450 index: u32,
451 /// Set on a `sink-input`: the sink it plays to.
452 #[serde(default)]
453 sink: Option<u32>,
454 /// Set on a `source-output`: the source it captures from.
455 #[serde(default)]
456 source: Option<u32>,
457 #[serde(default)]
458 mute: bool,
459 #[serde(default)]
460 corked: bool,
461 #[serde(default)]
462 volume: HashMap<String, PaChannel>,
463 #[serde(default)]
464 properties: HashMap<String, serde_json::Value>,
465 }
466
467 #[derive(Deserialize)]
468 struct PaChannel {
469 value: u32,
470 }
471
472 fn parse_devices(raw: &str, direction: Direction, default_name: &str) -> Result<Vec<Device>> {
473 let parsed: Vec<PaDevice> = serde_json::from_str(raw).context("pactl emitted invalid JSON")?;
474
475 Ok(parsed
476 .into_iter()
477 .filter(|device| !is_monitor(device, direction))
478 .map(|device| Device {
479 index: device.index,
480 volume: channel_volume(&device.volume),
481 muted: device.mute,
482 is_default: device.name == default_name,
483 // A device with no description falls back to its node name, which
484 // is ugly but identifies the thing; an empty row does not.
485 description: if device.description.is_empty() {
486 device.name.clone()
487 } else {
488 device.description
489 },
490 name: device.name,
491 direction,
492 })
493 .collect())
494 }
495
496 fn parse_streams(raw: &str, direction: Direction) -> Result<Vec<Stream>> {
497 let parsed: Vec<PaStream> = serde_json::from_str(raw).context("pactl emitted invalid JSON")?;
498
499 Ok(parsed
500 .into_iter()
501 .filter_map(|stream| {
502 // A stream with no device is one PulseAudio is still setting up or
503 // tearing down. It has nothing to pair with, and showing an
504 // unroutable row invites a keypress that cannot work.
505 let device_index = match direction {
506 Direction::Output => stream.sink,
507 Direction::Input => stream.source,
508 }?;
509 Some(Stream {
510 index: stream.index,
511 app: stream_app_name(&stream.properties),
512 direction,
513 device_index,
514 volume: channel_volume(&stream.volume),
515 muted: stream.mute,
516 corked: stream.corked,
517 })
518 })
519 .collect())
520 }
521
522 fn property(properties: &HashMap<String, serde_json::Value>, key: &str) -> Option<String> {
523 properties
524 .get(key)
525 .and_then(|value| value.as_str())
526 .map(str::to_string)
527 .filter(|s| !s.is_empty())
528 }
529
530 /// Best available human name for a stream.
531 ///
532 /// `application.name` is what an app sets for display and is right when
533 /// present. Failing that the binary name at least identifies the process.
534 /// `media.name` is the last resort because it describes what is playing rather
535 /// than who is playing it, and is often something like "playback".
536 fn stream_app_name(properties: &HashMap<String, serde_json::Value>) -> String {
537 property(properties, "application.name")
538 .or_else(|| property(properties, "application.process.binary"))
539 .or_else(|| property(properties, "media.name"))
540 .unwrap_or_else(|| "unknown".to_string())
541 }
542
543 /// Is this device a monitor (a loopback of an output rather than real capture
544 /// hardware)?
545 ///
546 /// Only sources can be. `monitor_source` is populated on sinks too, with the
547 /// opposite meaning, so the direction check is what keeps this from matching
548 /// every output.
549 fn is_monitor(device: &PaDevice, direction: Direction) -> bool {
550 direction == Direction::Input && !device.monitor_source.is_empty()
551 }
552
553 /// Collapse a per-channel volume map to one number.
554 ///
555 /// The loudest channel, not an average: a device with one channel at 0 is not
556 /// "half volume", and showing it as such would explain nothing about why the
557 /// audio sounds wrong. Channel order is not meaningful here, so the max is
558 /// also stable across the map's arbitrary iteration order.
559 fn channel_volume(channels: &HashMap<String, PaChannel>) -> u8 {
560 let raw = channels.values().map(|c| c.value).max().unwrap_or(0);
561 // Volumes can exceed unity (PipeWire allows boost); clamp so the display
562 // stays a percentage a user can reason about.
563 let percent = (u64::from(raw) * 100).div_ceil(u64::from(VOLUME_UNITY));
564 percent.min(100) as u8
565 }
566
567 /// The `alloy audio` screen.
568 pub(crate) struct AudioView {
569 backend: Box<dyn Backend>,
570 devices: Vec<Device>,
571 streams: Vec<Stream>,
572 focus: FocusRing,
573 stream_cursor: Cursor,
574 device_cursor: Cursor,
575 error: Option<String>,
576 ticks: u64,
577 }
578
579 impl AudioView {
580 pub(crate) fn new(log: &mut CommandLog) -> Self {
581 let mut view = Self {
582 backend: detect(),
583 devices: Vec::new(),
584 streams: Vec::new(),
585 focus: FocusRing::new(2),
586 stream_cursor: Cursor::new(),
587 device_cursor: Cursor::new(),
588 error: None,
589 ticks: 0,
590 };
591 view.refresh_devices(log);
592 view.refresh_streams(log);
593 view
594 }
595
596 // Neither refresh clears `error` on success, which is deliberate and not
597 // an oversight. Refreshes run on the background tick, so clearing there
598 // would wipe an error the user needs to read within a second of it
599 // appearing — a rejected route would flash and vanish before it could be
600 // read. Errors are cleared by the next keypress instead (see `handle`),
601 // which is the point at which the user has moved on.
602
603 fn refresh_devices(&mut self, log: &mut CommandLog) {
604 match self.backend.list_devices(log) {
605 Ok(devices) => {
606 self.devices = devices;
607 self.device_cursor.resize(self.devices.len());
608 }
609 Err(err) => self.error = Some(err.to_string()),
610 }
611 }
612
613 fn refresh_streams(&mut self, log: &mut CommandLog) {
614 match self.backend.list_streams(log) {
615 Ok(streams) => {
616 self.streams = streams;
617 self.stream_cursor.resize(self.streams.len());
618 }
619 Err(err) => self.error = Some(err.to_string()),
620 }
621 }
622
623 fn selected_stream(&self) -> Option<&Stream> {
624 self.streams.get(self.stream_cursor.selected()?)
625 }
626
627 fn selected_device(&self) -> Option<&Device> {
628 self.devices.get(self.device_cursor.selected()?)
629 }
630
631 /// Index into `devices` of the device the selected stream is routed to.
632 fn paired_device_index(&self) -> Option<usize> {
633 let stream = self.selected_stream()?;
634 self.devices
635 .iter()
636 .position(|device| device.index == stream.device_index)
637 }
638
639 /// The target the action keys apply to: whatever the focused pane has
640 /// selected.
641 fn target(&self) -> Option<Target<'_>> {
642 match self.focus.current() {
643 PANE_STREAMS => self.selected_stream().map(Target::Stream),
644 _ => self.selected_device().map(Target::Device),
645 }
646 }
647
648 /// Re-read state after an action.
649 ///
650 /// Quiet: the user asked for the action, not for the four-command re-read
651 /// that confirms it. Logging both buries the command that was actually
652 /// pressed for.
653 fn resync(&mut self, log: &mut CommandLog) {
654 log.quiet(|log| {
655 self.refresh_devices(log);
656 self.refresh_streams(log);
657 });
658 }
659
660 fn set_volume(&mut self, log: &mut CommandLog, delta: i16) {
661 let Some(target) = self.target() else {
662 return;
663 };
664 // Clamped here rather than handed to pactl as a relative `+5%`, so the
665 // console never asks for a volume above unity. Boost is a real thing
666 // users want occasionally, but not from a key that repeats.
667 let percent = (i16::from(target.volume()) + delta).clamp(0, 100) as u8;
668 let result = self.backend.set_volume(target, percent, log);
669 self.finish(result, log);
670 }
671
672 fn toggle_mute(&mut self, log: &mut CommandLog) {
673 let Some(target) = self.target() else {
674 return;
675 };
676 let result = self.backend.toggle_mute(target, log);
677 self.finish(result, log);
678 }
679
680 fn set_default(&mut self, log: &mut CommandLog) {
681 let Some(device) = self.selected_device() else {
682 return;
683 };
684 let result = self.backend.set_default(device, log);
685 self.finish(result, log);
686 }
687
688 /// Route the selected stream to the selected device — the pairing edit.
689 fn route(&mut self, log: &mut CommandLog) {
690 let (Some(stream), Some(device)) = (self.selected_stream(), self.selected_device()) else {
691 return;
692 };
693 // Directions have to agree: a playback stream cannot be routed to a
694 // microphone. pactl would reject it, but saying so here is clearer
695 // than surfacing its error.
696 if stream.direction != device.direction {
697 self.error = Some(format!(
698 "cannot route {} stream to {} device",
699 stream.direction.label(),
700 device.direction.label()
701 ));
702 return;
703 }
704 let result = self.backend.move_stream(stream, device, log);
705 self.finish(result, log);
706 }
707
708 /// Record an action's outcome and re-read state if it worked.
709 fn finish(&mut self, result: Result<()>, log: &mut CommandLog) {
710 match result {
711 Ok(()) => self.resync(log),
712 Err(err) => self.error = Some(err.to_string()),
713 }
714 }
715
716 fn stream_row<'a>(theme: &Theme, stream: &'a Stream) -> Line<'a> {
717 let volume = if stream.muted {
718 " --".to_string()
719 } else {
720 format!("{:>3}%", stream.volume)
721 };
722 Line::from(vec![
723 text::bold(theme, format!("{:<18}", truncate(&stream.app, 17))),
724 Span::styled(format!("{volume:<6}"), stream.severity().style(theme)),
725 text::muted(theme, stream.state_label()),
726 ])
727 }
728
729 fn device_row<'a>(theme: &Theme, device: &'a Device) -> Line<'a> {
730 let volume = if device.muted {
731 " --".to_string()
732 } else {
733 format!("{:>3}%", device.volume)
734 };
735 Line::from(vec![
736 text::bold(theme, format!("{:<22}", truncate(&device.description, 21))),
737 Span::styled(format!("{volume:<6}"), device.severity().style(theme)),
738 text::muted(theme, device.state_label()),
739 ])
740 }
741 }
742
743 /// Everything one pane needs to draw itself.
744 struct Pane<'a> {
745 title: &'a str,
746 focused: bool,
747 rows: Vec<Line<'a>>,
748 selected: Option<usize>,
749 /// Shown instead of the list when there are no rows. Both panes are
750 /// legitimately empty in normal use — nothing is playing, or there is no
751 /// hardware — so neither should render as a blank box.
752 empty_message: &'a str,
753 }
754
755 /// Draw one pane, returning its inner area so the connector can locate rows
756 /// inside it.
757 fn render_pane(frame: &mut Frame, area: Rect, theme: &Theme, pane: Pane<'_>) -> Rect {
758 let block = AlloyBlock::new(theme)
759 .focused(pane.focused)
760 .build()
761 .title(block_title(pane.title));
762 let inner = block.inner(area);
763 frame.render_widget(block, area);
764
765 if pane.rows.is_empty() {
766 frame.render_widget(Line::from(text::muted(theme, pane.empty_message)), inner);
767 } else {
768 frame.render_widget(
769 AlloyList::new(theme, pane.rows).selected(pane.selected),
770 inner,
771 );
772 }
773 inner
774 }
775
776 /// Clip a description to `width` columns, marking the clip.
777 ///
778 /// Counts `char`s rather than bytes: device descriptions carry non-ASCII
779 /// (a "Björn's Headset"), and slicing those by byte index panics.
780 fn truncate(text: &str, width: usize) -> String {
781 if text.chars().count() <= width {
782 return text.to_string();
783 }
784 let kept: String = text.chars().take(width.saturating_sub(1)).collect();
785 format!("{kept}")
786 }
787
788 impl View for AudioView {
789 fn title(&self) -> String {
790 format!("audio ({})", self.backend.name())
791 }
792
793 fn hints(&self) -> Vec<Hint> {
794 vec![
795 hint("Tab", "pane"),
796 hint("j/k", "select"),
797 hint("Enter", "route"),
798 hint("-/+", "volume"),
799 hint("m", "mute"),
800 hint("d", "default"),
801 hint("r", "refresh"),
802 ]
803 }
804
805 fn status(&self) -> Option<(Severity, String)> {
806 self.error
807 .as_ref()
808 .map(|message| (Severity::Error, message.clone()))
809 }
810
811 fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
812 let panes = layout::panes(area);
813
814 let stream_rows: Vec<Line> = self
815 .streams
816 .iter()
817 .map(|stream| Self::stream_row(theme, stream))
818 .collect();
819 let left = render_pane(
820 frame,
821 panes.left,
822 theme,
823 Pane {
824 title: "streams",
825 focused: self.focus.is_focused(PANE_STREAMS),
826 rows: stream_rows,
827 selected: self.stream_cursor.selected(),
828 empty_message: "nothing playing",
829 },
830 );
831
832 // A terminal too narrow for two panes shows streams alone. Devices are
833 // reachable by widening; a pair of six-column panes is reachable by
834 // nothing.
835 if panes.right.width == 0 {
836 return;
837 }
838
839 let device_rows: Vec<Line> = self
840 .devices
841 .iter()
842 .map(|device| Self::device_row(theme, device))
843 .collect();
844 let right = render_pane(
845 frame,
846 panes.right,
847 theme,
848 Pane {
849 title: "devices",
850 focused: self.focus.is_focused(PANE_DEVICES),
851 rows: device_rows,
852 selected: self.device_cursor.selected(),
853 empty_message: "no audio devices",
854 },
855 );
856
857 // The connector, for the selected stream only.
858 let (Some(stream_index), Some(device_index)) =
859 (self.stream_cursor.selected(), self.paired_device_index())
860 else {
861 return;
862 };
863 let from_y = list_row_y(
864 left,
865 self.streams.len(),
866 self.stream_cursor.selected(),
867 stream_index,
868 );
869 let to_y = list_row_y(
870 right,
871 self.devices.len(),
872 self.device_cursor.selected(),
873 device_index,
874 );
875 if let (Some(from_y), Some(to_y)) = (from_y, to_y) {
876 // The gutter spans the panes' inner rows, so the connector lines
877 // up with list rows rather than with the block borders.
878 let gutter = Rect {
879 y: left.y,
880 height: left.height,
881 ..panes.gutter
882 };
883 frame.render_widget(AlloyConnector::new(theme, from_y, to_y), gutter);
884 }
885 }
886
887 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
888 // Any keypress dismisses the previous error. Actions below may set a
889 // new one, which then survives until the user presses another key
890 // rather than until the next background tick.
891 self.error = None;
892
893 match alloy_tui::classify(key) {
894 alloy_tui::Action::NextFocus => {
895 self.focus.next();
896 return Flow::Continue;
897 }
898 alloy_tui::Action::PrevFocus => {
899 self.focus.prev();
900 return Flow::Continue;
901 }
902 alloy_tui::Action::Activate => {
903 self.route(log);
904 return Flow::Continue;
905 }
906 _ => {}
907 }
908
909 let cursor = if self.focus.is_focused(PANE_STREAMS) {
910 &mut self.stream_cursor
911 } else {
912 &mut self.device_cursor
913 };
914
915 match key.code {
916 KeyCode::Char('j') | KeyCode::Down => cursor.next(),
917 KeyCode::Char('k') | KeyCode::Up => cursor.prev(),
918 // `=` alongside `+` so the shifted key is not required.
919 KeyCode::Char('+' | '=') => {
920 self.set_volume(log, i16::from(VOLUME_STEP));
921 }
922 KeyCode::Char('-') => self.set_volume(log, -i16::from(VOLUME_STEP)),
923 KeyCode::Char('m') => self.toggle_mute(log),
924 KeyCode::Char('d') => self.set_default(log),
925 KeyCode::Char('r') => {
926 // An explicit refresh is a user action, so it logs.
927 self.refresh_devices(log);
928 self.refresh_streams(log);
929 }
930 _ => {}
931 }
932 Flow::Continue
933 }
934
935 fn tick(&mut self, log: &mut CommandLog) {
936 self.ticks += 1;
937 let poll_devices = self.ticks.is_multiple_of(DEVICE_POLL_TICKS);
938 log.quiet(|log| {
939 self.refresh_streams(log);
940 if poll_devices {
941 self.refresh_devices(log);
942 }
943 });
944 }
945 }
946
947 #[cfg(test)]
948 mod tests;
949