|
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 |
+ |
//! The same mock-or-real detection as `net` applies: a machine with no
|
|
11 |
+ |
//! PipeWire still gets a usable screen.
|
|
12 |
+ |
|
|
13 |
+ |
use std::collections::HashMap;
|
|
14 |
+ |
|
|
15 |
+ |
use alloy_tui::{AlloyBlock, AlloyList, Cursor, Hint, Severity, Theme, hint, text};
|
|
16 |
+ |
use anyhow::{Context, Result};
|
|
17 |
+ |
use ratatui::Frame;
|
|
18 |
+ |
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
|
19 |
+ |
use ratatui::layout::Rect;
|
|
20 |
+ |
use ratatui::text::{Line, Span};
|
|
21 |
+ |
use serde::Deserialize;
|
|
22 |
+ |
|
|
23 |
+ |
use crate::cli::{CommandLog, Invocation};
|
|
24 |
+ |
use crate::shell::{Flow, View, block_title};
|
|
25 |
+ |
|
|
26 |
+ |
/// How much one keypress moves the volume. Matches the step swayosd uses for
|
|
27 |
+ |
/// the Fn keys, so the console and the hardware keys agree.
|
|
28 |
+ |
const VOLUME_STEP: u8 = 5;
|
|
29 |
+ |
|
|
30 |
+ |
/// PipeWire's unity volume — `pactl`'s raw values are relative to this, not to
|
|
31 |
+ |
/// 100. Reading `value_percent` instead would mean parsing a localized string
|
|
32 |
+ |
/// with a `%` glued to it.
|
|
33 |
+ |
const VOLUME_UNITY: u32 = 65536;
|
|
34 |
+ |
|
|
35 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
36 |
+ |
pub enum Direction {
|
|
37 |
+ |
Output,
|
|
38 |
+ |
Input,
|
|
39 |
+ |
}
|
|
40 |
+ |
|
|
41 |
+ |
impl Direction {
|
|
42 |
+ |
const fn label(self) -> &'static str {
|
|
43 |
+ |
match self {
|
|
44 |
+ |
Direction::Output => "output",
|
|
45 |
+ |
Direction::Input => "input",
|
|
46 |
+ |
}
|
|
47 |
+ |
}
|
|
48 |
+ |
|
|
49 |
+ |
/// The `pactl` noun for this direction. `pactl` spells its subcommands per
|
|
50 |
+ |
/// device class (`set-sink-volume`, `set-source-mute`), so every action
|
|
51 |
+ |
/// interpolates this.
|
|
52 |
+ |
const fn noun(self) -> &'static str {
|
|
53 |
+ |
match self {
|
|
54 |
+ |
Direction::Output => "sink",
|
|
55 |
+ |
Direction::Input => "source",
|
|
56 |
+ |
}
|
|
57 |
+ |
}
|
|
58 |
+ |
}
|
|
59 |
+ |
|
|
60 |
+ |
#[derive(Debug, Clone)]
|
|
61 |
+ |
pub struct Device {
|
|
62 |
+ |
pub name: String,
|
|
63 |
+ |
pub description: String,
|
|
64 |
+ |
pub direction: Direction,
|
|
65 |
+ |
pub volume: u8,
|
|
66 |
+ |
pub muted: bool,
|
|
67 |
+ |
pub is_default: bool,
|
|
68 |
+ |
}
|
|
69 |
+ |
|
|
70 |
+ |
impl Device {
|
|
71 |
+ |
fn severity(&self) -> Severity {
|
|
72 |
+ |
match (self.is_default, self.muted) {
|
|
73 |
+ |
(_, true) => Severity::Warn,
|
|
74 |
+ |
(true, false) => Severity::Healthy,
|
|
75 |
+ |
(false, false) => Severity::Info,
|
|
76 |
+ |
}
|
|
77 |
+ |
}
|
|
78 |
+ |
|
|
79 |
+ |
fn state_label(&self) -> &'static str {
|
|
80 |
+ |
match (self.is_default, self.muted) {
|
|
81 |
+ |
(true, true) => "default, muted",
|
|
82 |
+ |
(true, false) => "default",
|
|
83 |
+ |
(false, true) => "muted",
|
|
84 |
+ |
(false, false) => "",
|
|
85 |
+ |
}
|
|
86 |
+ |
}
|
|
87 |
+ |
}
|
|
88 |
+ |
|
|
89 |
+ |
pub trait Backend {
|
|
90 |
+ |
fn name(&self) -> &'static str;
|
|
91 |
+ |
fn list(&self, log: &mut CommandLog) -> Result<Vec<Device>>;
|
|
92 |
+ |
|
|
93 |
+ |
/// Set a device's volume, as a percentage of unity.
|
|
94 |
+ |
fn set_volume(&self, device: &Device, percent: u8, log: &mut CommandLog) -> Result<()>;
|
|
95 |
+ |
|
|
96 |
+ |
/// Toggle a device's mute.
|
|
97 |
+ |
fn toggle_mute(&self, device: &Device, log: &mut CommandLog) -> Result<()>;
|
|
98 |
+ |
|
|
99 |
+ |
/// Make a device the default for its direction.
|
|
100 |
+ |
fn set_default(&self, device: &Device, log: &mut CommandLog) -> Result<()>;
|
|
101 |
+ |
}
|
|
102 |
+ |
|
|
103 |
+ |
/// Pick a backend: `pactl` when it answers, the mock otherwise.
|
|
104 |
+ |
pub fn detect() -> Box<dyn Backend> {
|
|
105 |
+ |
if Invocation::new("pactl").arg("--version").probe() {
|
|
106 |
+ |
Box::new(PaCtl)
|
|
107 |
+ |
} else {
|
|
108 |
+ |
Box::new(Mock)
|
|
109 |
+ |
}
|
|
110 |
+ |
}
|
|
111 |
+ |
|
|
112 |
+ |
pub struct PaCtl;
|
|
113 |
+ |
|
|
114 |
+ |
impl Backend for PaCtl {
|
|
115 |
+ |
fn name(&self) -> &'static str {
|
|
116 |
+ |
"pactl"
|
|
117 |
+ |
}
|
|
118 |
+ |
|
|
119 |
+ |
fn list(&self, log: &mut CommandLog) -> Result<Vec<Device>> {
|
|
120 |
+ |
// Four invocations, all logged. That is more log noise than `net`'s
|
|
121 |
+ |
// single line, but `pactl` has no combined form and inventing one by
|
|
122 |
+ |
// hiding three of the four would break the pane's contract that what
|
|
123 |
+ |
// you see is what ran.
|
|
124 |
+ |
let sinks = Invocation::new("pactl")
|
|
125 |
+ |
.args(["-f", "json", "list", "sinks"])
|
|
126 |
+ |
.run(log)?;
|
|
127 |
+ |
let default_sink = Invocation::new("pactl").arg("get-default-sink").run(log)?;
|
|
128 |
+ |
let sources = Invocation::new("pactl")
|
|
129 |
+ |
.args(["-f", "json", "list", "sources"])
|
|
130 |
+ |
.run(log)?;
|
|
131 |
+ |
let default_source = Invocation::new("pactl").arg("get-default-source").run(log)?;
|
|
132 |
+ |
|
|
133 |
+ |
let mut devices = parse_devices(&sinks, Direction::Output, default_sink.trim())
|
|
134 |
+ |
.context("parsing sinks")?;
|
|
135 |
+ |
devices.extend(
|
|
136 |
+ |
parse_devices(&sources, Direction::Input, default_source.trim())
|
|
137 |
+ |
.context("parsing sources")?,
|
|
138 |
+ |
);
|
|
139 |
+ |
Ok(devices)
|
|
140 |
+ |
}
|
|
141 |
+ |
|
|
142 |
+ |
fn set_volume(&self, device: &Device, percent: u8, log: &mut CommandLog) -> Result<()> {
|
|
143 |
+ |
Invocation::new("pactl")
|
|
144 |
+ |
.arg(format!("set-{}-volume", device.direction.noun()))
|
|
145 |
+ |
.arg(&device.name)
|
|
146 |
+ |
.arg(format!("{percent}%"))
|
|
147 |
+ |
.run(log)
|
|
148 |
+ |
.map(drop)
|
|
149 |
+ |
}
|
|
150 |
+ |
|
|
151 |
+ |
fn toggle_mute(&self, device: &Device, log: &mut CommandLog) -> Result<()> {
|
|
152 |
+ |
Invocation::new("pactl")
|
|
153 |
+ |
.arg(format!("set-{}-mute", device.direction.noun()))
|
|
154 |
+ |
.arg(&device.name)
|
|
155 |
+ |
.arg("toggle")
|
|
156 |
+ |
.run(log)
|
|
157 |
+ |
.map(drop)
|
|
158 |
+ |
}
|
|
159 |
+ |
|
|
160 |
+ |
fn set_default(&self, device: &Device, log: &mut CommandLog) -> Result<()> {
|
|
161 |
+ |
Invocation::new("pactl")
|
|
162 |
+ |
.arg(format!("set-default-{}", device.direction.noun()))
|
|
163 |
+ |
.arg(&device.name)
|
|
164 |
+ |
.run(log)
|
|
165 |
+ |
.map(drop)
|
|
166 |
+ |
}
|
|
167 |
+ |
}
|
|
168 |
+ |
|
|
169 |
+ |
/// Fixed sample state, for machines without PipeWire.
|
|
170 |
+ |
pub struct Mock;
|
|
171 |
+ |
|
|
172 |
+ |
impl Backend for Mock {
|
|
173 |
+ |
fn name(&self) -> &'static str {
|
|
174 |
+ |
"mock"
|
|
175 |
+ |
}
|
|
176 |
+ |
|
|
177 |
+ |
fn list(&self, log: &mut CommandLog) -> Result<Vec<Device>> {
|
|
178 |
+ |
log.record("# no PipeWire; showing mock devices", Severity::Warn);
|
|
179 |
+ |
Ok(vec![
|
|
180 |
+ |
Device {
|
|
181 |
+ |
name: "alsa_output.analog-stereo".into(),
|
|
182 |
+ |
description: "Analog Stereo".into(),
|
|
183 |
+ |
direction: Direction::Output,
|
|
184 |
+ |
volume: 90,
|
|
185 |
+ |
muted: false,
|
|
186 |
+ |
is_default: true,
|
|
187 |
+ |
},
|
|
188 |
+ |
Device {
|
|
189 |
+ |
name: "alsa_output.hdmi-stereo".into(),
|
|
190 |
+ |
description: "HDMI Stereo".into(),
|
|
191 |
+ |
direction: Direction::Output,
|
|
192 |
+ |
volume: 100,
|
|
193 |
+ |
muted: true,
|
|
194 |
+ |
is_default: false,
|
|
195 |
+ |
},
|
|
196 |
+ |
Device {
|
|
197 |
+ |
name: "alsa_input.analog-stereo".into(),
|
|
198 |
+ |
description: "Analog Stereo Microphone".into(),
|
|
199 |
+ |
direction: Direction::Input,
|
|
200 |
+ |
volume: 75,
|
|
201 |
+ |
muted: false,
|
|
202 |
+ |
is_default: true,
|
|
203 |
+ |
},
|
|
204 |
+ |
])
|
|
205 |
+ |
}
|
|
206 |
+ |
|
|
207 |
+ |
// The mock is a display fixture, not a simulator. Mutating it would show
|
|
208 |
+ |
// the user a volume change that did not happen to any real device, which
|
|
209 |
+ |
// is worse than plainly declining.
|
|
210 |
+ |
fn set_volume(&self, _device: &Device, _percent: u8, log: &mut CommandLog) -> Result<()> {
|
|
211 |
+ |
log.record("# mock backend: volume unchanged", Severity::Warn);
|
|
212 |
+ |
Ok(())
|
|
213 |
+ |
}
|
|
214 |
+ |
|
|
215 |
+ |
fn toggle_mute(&self, _device: &Device, log: &mut CommandLog) -> Result<()> {
|
|
216 |
+ |
log.record("# mock backend: mute unchanged", Severity::Warn);
|
|
217 |
+ |
Ok(())
|
|
218 |
+ |
}
|
|
219 |
+ |
|
|
220 |
+ |
fn set_default(&self, _device: &Device, log: &mut CommandLog) -> Result<()> {
|
|
221 |
+ |
log.record("# mock backend: default unchanged", Severity::Warn);
|
|
222 |
+ |
Ok(())
|
|
223 |
+ |
}
|
|
224 |
+ |
}
|
|
225 |
+ |
|
|
226 |
+ |
// ---- pactl JSON ----
|
|
227 |
+ |
|
|
228 |
+ |
#[derive(Deserialize)]
|
|
229 |
+ |
struct PaDevice {
|
|
230 |
+ |
name: String,
|
|
231 |
+ |
#[serde(default)]
|
|
232 |
+ |
description: String,
|
|
233 |
+ |
#[serde(default)]
|
|
234 |
+ |
mute: bool,
|
|
235 |
+ |
#[serde(default)]
|
|
236 |
+ |
volume: HashMap<String, PaChannel>,
|
|
237 |
+ |
/// Field with two different meanings depending on which list it came from,
|
|
238 |
+ |
/// which is a trap worth spelling out.
|
|
239 |
+ |
///
|
|
240 |
+ |
/// On a **source** it names the sink this source is a monitor *of*, and is
|
|
241 |
+ |
/// empty for real capture hardware. On a **sink** it names the monitor
|
|
242 |
+ |
/// that sink *has*, and is non-empty for essentially every sink. So it
|
|
243 |
+ |
/// identifies monitors only among sources; applied to sinks it matches
|
|
244 |
+ |
/// almost all of them.
|
|
245 |
+ |
#[serde(default)]
|
|
246 |
+ |
monitor_source: String,
|
|
247 |
+ |
}
|
|
248 |
+ |
|
|
249 |
+ |
#[derive(Deserialize)]
|
|
250 |
+ |
struct PaChannel {
|
|
251 |
+ |
value: u32,
|
|
252 |
+ |
}
|
|
253 |
+ |
|
|
254 |
+ |
fn parse_devices(raw: &str, direction: Direction, default_name: &str) -> Result<Vec<Device>> {
|
|
255 |
+ |
let parsed: Vec<PaDevice> = serde_json::from_str(raw).context("pactl emitted invalid JSON")?;
|
|
256 |
+ |
|
|
257 |
+ |
Ok(parsed
|
|
258 |
+ |
.into_iter()
|
|
259 |
+ |
.filter(|device| !is_monitor(device, direction))
|
|
260 |
+ |
.map(|device| Device {
|
|
261 |
+ |
volume: channel_volume(&device.volume),
|
|
262 |
+ |
muted: device.mute,
|
|
263 |
+ |
is_default: device.name == default_name,
|
|
264 |
+ |
// A device with no description falls back to its node name, which
|
|
265 |
+ |
// is ugly but identifies the thing; an empty row does not.
|
|
266 |
+ |
description: if device.description.is_empty() {
|
|
267 |
+ |
device.name.clone()
|
|
268 |
+ |
} else {
|
|
269 |
+ |
device.description
|
|
270 |
+ |
},
|
|
271 |
+ |
name: device.name,
|
|
272 |
+ |
direction,
|
|
273 |
+ |
})
|
|
274 |
+ |
.collect())
|
|
275 |
+ |
}
|
|
276 |
+ |
|
|
277 |
+ |
/// Is this device a monitor (a loopback of an output rather than real capture
|
|
278 |
+ |
/// hardware)?
|
|
279 |
+ |
///
|
|
280 |
+ |
/// Only sources can be. `monitor_source` is populated on sinks too, with the
|
|
281 |
+ |
/// opposite meaning, so the direction check is what keeps this from matching
|
|
282 |
+ |
/// every output.
|
|
283 |
+ |
fn is_monitor(device: &PaDevice, direction: Direction) -> bool {
|
|
284 |
+ |
direction == Direction::Input && !device.monitor_source.is_empty()
|
|
285 |
+ |
}
|
|
286 |
+ |
|
|
287 |
+ |
/// Collapse a per-channel volume map to one number.
|
|
288 |
+ |
///
|
|
289 |
+ |
/// The loudest channel, not an average: a device with one channel at 0 is not
|
|
290 |
+ |
/// "half volume", and showing it as such would explain nothing about why the
|
|
291 |
+ |
/// audio sounds wrong. Channel order is not meaningful here, so the max is
|
|
292 |
+ |
/// also stable across the map's arbitrary iteration order.
|
|
293 |
+ |
fn channel_volume(channels: &HashMap<String, PaChannel>) -> u8 {
|
|
294 |
+ |
let raw = channels.values().map(|c| c.value).max().unwrap_or(0);
|
|
295 |
+ |
// Volumes can exceed unity (PipeWire allows boost); clamp so the display
|
|
296 |
+ |
// stays a percentage a user can reason about.
|
|
297 |
+ |
let percent = (u64::from(raw) * 100).div_ceil(u64::from(VOLUME_UNITY));
|
|
298 |
+ |
percent.min(100) as u8
|
|
299 |
+ |
}
|
|
300 |
+ |
|
|
301 |
+ |
/// The `alloy audio` screen.
|
|
302 |
+ |
pub struct AudioView {
|
|
303 |
+ |
backend: Box<dyn Backend>,
|
|
304 |
+ |
devices: Vec<Device>,
|
|
305 |
+ |
cursor: Cursor,
|
|
306 |
+ |
error: Option<String>,
|
|
307 |
+ |
}
|
|
308 |
+ |
|
|
309 |
+ |
impl AudioView {
|
|
310 |
+ |
pub fn new(log: &mut CommandLog) -> Self {
|
|
311 |
+ |
let mut view = Self {
|
|
312 |
+ |
backend: detect(),
|
|
313 |
+ |
devices: Vec::new(),
|
|
314 |
+ |
cursor: Cursor::new(),
|
|
315 |
+ |
error: None,
|
|
316 |
+ |
};
|
|
317 |
+ |
view.refresh(log);
|
|
318 |
+ |
view
|
|
319 |
+ |
}
|
|
320 |
+ |
|
|
321 |
+ |
fn refresh(&mut self, log: &mut CommandLog) {
|
|
322 |
+ |
match self.backend.list(log) {
|
|
323 |
+ |
Ok(devices) => {
|
|
324 |
+ |
self.devices = devices;
|
|
325 |
+ |
self.cursor.resize(self.devices.len());
|
|
326 |
+ |
self.error = None;
|
|
327 |
+ |
}
|
|
328 |
+ |
Err(err) => self.error = Some(err.to_string()),
|
|
329 |
+ |
}
|
|
330 |
+ |
}
|
|
331 |
+ |
|
|
332 |
+ |
fn selected(&self) -> Option<&Device> {
|
|
333 |
+ |
self.devices.get(self.cursor.selected()?)
|
|
334 |
+ |
}
|
|
335 |
+ |
|
|
336 |
+ |
/// Run an action against the selected device, then re-read state.
|
|
337 |
+ |
///
|
|
338 |
+ |
/// The re-read is what keeps the screen honest: `pactl` may clamp or round
|
|
339 |
+ |
/// what was asked for, so the displayed volume is always what the daemon
|
|
340 |
+ |
/// reports rather than what the console requested.
|
|
341 |
+ |
fn act(
|
|
342 |
+ |
&mut self,
|
|
343 |
+ |
log: &mut CommandLog,
|
|
344 |
+ |
action: impl FnOnce(&dyn Backend, &Device, &mut CommandLog) -> Result<()>,
|
|
345 |
+ |
) {
|
|
346 |
+ |
let Some(device) = self.selected().cloned() else {
|
|
347 |
+ |
return;
|
|
348 |
+ |
};
|
|
349 |
+ |
match action(self.backend.as_ref(), &device, log) {
|
|
350 |
+ |
Ok(()) => self.refresh(log),
|
|
351 |
+ |
Err(err) => self.error = Some(err.to_string()),
|
|
352 |
+ |
}
|
|
353 |
+ |
}
|
|
354 |
+ |
|
|
355 |
+ |
fn nudge_volume(&mut self, log: &mut CommandLog, delta: i16) {
|
|
356 |
+ |
let Some(device) = self.selected() else {
|
|
357 |
+ |
return;
|
|
358 |
+ |
};
|
|
359 |
+ |
// Clamped here rather than handed to pactl as a relative `+5%`, so the
|
|
360 |
+ |
// console never asks for a volume above unity. Boost is a real thing
|
|
361 |
+ |
// users want occasionally, but not from a key that repeats.
|
|
362 |
+ |
let target = (i16::from(device.volume) + delta).clamp(0, 100) as u8;
|
|
363 |
+ |
self.act(log, move |backend, device, log| {
|
|
364 |
+ |
backend.set_volume(device, target, log)
|
|
365 |
+ |
});
|
|
366 |
+ |
}
|
|
367 |
+ |
|
|
368 |
+ |
fn row<'a>(&self, theme: &Theme, device: &'a Device) -> Line<'a> {
|
|
369 |
+ |
let volume = if device.muted {
|
|
370 |
+ |
" --".to_string()
|
|
371 |
+ |
} else {
|
|
372 |
+ |
format!("{:>3}%", device.volume)
|
|
373 |
+ |
};
|
|
374 |
+ |
|
|
375 |
+ |
Line::from(vec![
|
|
376 |
+ |
text::bold(theme, format!("{:<32}", truncate(&device.description, 31))),
|
|
377 |
+ |
text::muted(theme, format!("{:<8}", device.direction.label())),
|
|
378 |
+ |
Span::styled(format!("{volume:<6}"), device.severity().style(theme)),
|
|
379 |
+ |
text::secondary(theme, device.state_label()),
|
|
380 |
+ |
])
|
|
381 |
+ |
}
|
|
382 |
+ |
}
|
|
383 |
+ |
|
|
384 |
+ |
/// Clip a description to `width` columns, marking the clip.
|
|
385 |
+ |
///
|
|
386 |
+ |
/// Counts `char`s rather than bytes: device descriptions carry non-ASCII
|
|
387 |
+ |
/// (a "Björn's Headset"), and slicing those by byte index panics.
|
|
388 |
+ |
fn truncate(text: &str, width: usize) -> String {
|
|
389 |
+ |
if text.chars().count() <= width {
|
|
390 |
+ |
return text.to_string();
|
|
391 |
+ |
}
|
|
392 |
+ |
let kept: String = text.chars().take(width.saturating_sub(1)).collect();
|
|
393 |
+ |
format!("{kept}…")
|
|
394 |
+ |
}
|
|
395 |
+ |
|
|
396 |
+ |
impl View for AudioView {
|
|
397 |
+ |
fn title(&self) -> String {
|
|
398 |
+ |
format!("audio ({})", self.backend.name())
|
|
399 |
+ |
}
|
|
400 |
+ |
|
|
401 |
+ |
fn hints(&self) -> Vec<Hint> {
|
|
402 |
+ |
vec![
|
|
403 |
+ |
hint("j/k", "select"),
|
|
404 |
+ |
hint("-/+", "volume"),
|
|
405 |
+ |
hint("m", "mute"),
|
|
406 |
+ |
hint("d", "default"),
|
|
407 |
+ |
hint("r", "refresh"),
|
|
408 |
+ |
]
|
|
409 |
+ |
}
|
|
410 |
+ |
|
|
411 |
+ |
fn status(&self) -> Option<(Severity, String)> {
|
|
412 |
+ |
self.error
|
|
413 |
+ |
.as_ref()
|
|
414 |
+ |
.map(|message| (Severity::Error, message.clone()))
|
|
415 |
+ |
}
|
|
416 |
+ |
|
|
417 |
+ |
fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
|
418 |
+ |
let block = AlloyBlock::new(theme)
|
|
419 |
+ |
.focused(true)
|
|
420 |
+ |
.build()
|
|
421 |
+ |
.title(block_title(&self.title()));
|
|
422 |
+ |
let inner = block.inner(area);
|
|
423 |
+ |
frame.render_widget(block, area);
|
|
424 |
+ |
|
|
425 |
+ |
if self.devices.is_empty() {
|
|
426 |
+ |
frame.render_widget(Line::from(text::muted(theme, "no audio devices")), inner);
|
|
427 |
+ |
return;
|
|
428 |
+ |
}
|
|
429 |
+ |
|
|
430 |
+ |
let rows: Vec<Line> = self
|
|
431 |
+ |
.devices
|
|
432 |
+ |
.iter()
|
|
433 |
+ |
.map(|device| self.row(theme, device))
|
|
434 |
+ |
.collect();
|
|
435 |
+ |
frame.render_widget(
|
|
436 |
+ |
AlloyList::new(theme, rows).selected(self.cursor.selected()),
|
|
437 |
+ |
inner,
|
|
438 |
+ |
);
|
|
439 |
+ |
}
|
|
440 |
+ |
|
|
441 |
+ |
fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
|
|
442 |
+ |
match key.code {
|
|
443 |
+ |
KeyCode::Char('j') | KeyCode::Down => self.cursor.next(),
|
|
444 |
+ |
KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(),
|
|
445 |
+ |
// `=` alongside `+` so the shifted key is not required.
|
|
446 |
+ |
KeyCode::Char('+') | KeyCode::Char('=') => {
|
|
447 |
+ |
self.nudge_volume(log, i16::from(VOLUME_STEP));
|
|
448 |
+ |
}
|
|
449 |
+ |
KeyCode::Char('-') => self.nudge_volume(log, -i16::from(VOLUME_STEP)),
|
|
450 |
+ |
KeyCode::Char('m') => self.act(log, |backend, device, log| {
|
|
451 |
+ |
backend.toggle_mute(device, log)
|
|
452 |
+ |
}),
|
|
453 |
+ |
KeyCode::Char('d') => self.act(log, |backend, device, log| {
|
|
454 |
+ |
backend.set_default(device, log)
|
|
455 |
+ |
}),
|
|
456 |
+ |
KeyCode::Char('r') => self.refresh(log),
|
|
457 |
+ |
_ => {}
|
|
458 |
+ |
}
|
|
459 |
+ |
Flow::Continue
|
|
460 |
+ |
}
|
|
461 |
+ |
}
|
|
462 |
+ |
|
|
463 |
+ |
#[cfg(test)]
|
|
464 |
+ |
mod tests {
|
|
465 |
+ |
use super::*;
|
|
466 |
+ |
|
|
467 |
+ |
// Captured from `pactl -f json list sinks` on PipeWire 1.5.84, with the
|
|
468 |
+ |
// enormous `properties` blob dropped (the parser ignores it) and a second
|
|
469 |
+ |
// sink added to give the list more than one row. Everything the parser
|
|
470 |
+ |
// reads is verbatim.
|
|
471 |
+ |
const SINKS: &str = r#"[
|
|
472 |
+ |
{"index":61,"state":"SUSPENDED","name":"alsa_output.pci-0000_c1_00.6.analog-stereo",
|
|
473 |
+ |
"description":"Family 17h/19h HD Audio Controller Analog Stereo","mute":false,
|
|
474 |
+ |
"volume":{"front-left":{"value":58980,"value_percent":"90%","db":"-2.75 dB"},
|
|
475 |
+ |
"front-right":{"value":58980,"value_percent":"90%","db":"-2.75 dB"}},
|
|
476 |
+ |
"monitor_source":"alsa_output.pci-0000_c1_00.6.analog-stereo.monitor"},
|
|
477 |
+ |
{"index":70,"state":"RUNNING","name":"alsa_output.hdmi-stereo",
|
|
478 |
+ |
"description":"HDMI Stereo","mute":true,
|
|
479 |
+ |
"volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"},
|
|
480 |
+ |
"front-right":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
|
|
481 |
+ |
"monitor_source":""}
|
|
482 |
+ |
]"#;
|
|
483 |
+ |
|
|
484 |
+ |
// Captured from `pactl -f json list sources`. The middle entry is a
|
|
485 |
+ |
// monitor, which is the case the filter exists for.
|
|
486 |
+ |
const SOURCES: &str = r#"[
|
|
487 |
+ |
{"index":60,"state":"SUSPENDED","name":"alsa_input.acp-pdm-mach.stereo-fallback",
|
|
488 |
+ |
"description":"ACP/ACP3X/ACP6x Audio Coprocessor Stereo","mute":false,
|
|
489 |
+ |
"volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"},
|
|
490 |
+ |
"front-right":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
|
|
491 |
+ |
"monitor_source":""},
|
|
492 |
+ |
{"index":61,"state":"SUSPENDED","name":"alsa_output.pci-0000_c1_00.6.analog-stereo.monitor",
|
|
493 |
+ |
"description":"Monitor of Family 17h/19h HD Audio Controller Analog Stereo","mute":false,
|
|
494 |
+ |
"volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
|
|
495 |
+ |
"monitor_source":"alsa_output.pci-0000_c1_00.6.analog-stereo"},
|
|
496 |
+ |
{"index":62,"state":"SUSPENDED","name":"alsa_input.pci-0000_c1_00.6.analog-stereo",
|
|
497 |
+ |
"description":"Family 17h/19h HD Audio Controller Analog Stereo","mute":false,
|
|
498 |
+ |
"volume":{"front-left":{"value":32768,"value_percent":"50%","db":"-18.06 dB"}},
|
|
499 |
+ |
"monitor_source":""}
|
|
500 |
+ |
]"#;
|