tui: show the output level in the now-playing pane

The pane reports `Volume: 85%`, or `Volume: 85% (muted)`.

The display was the small half of this: the TUI was discarding the volume
it was already being sent (`StreamUpdate::Volume(_)` was a FIXME), and
`Init` dropped `volume` and `mute` as well — so the pane would have
started out wrong and corrected itself only once the user touched either
control. Both are wired now.

Muting keeps the level on screen rather than replacing it: the server
reports the level it would unmute to, which is the one the user is about
to adjust. That retires the old `, Muted` suffix.

The line now renders with no track loaded too — shuffle, repeat and
volume describe the server, and an idle player is exactly when you reach
for `K` blind.

Formatting is a pure function so its edges are tested: the wire carries a
float, so NaN and infinity read as `--` rather than `NaN%`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-27 09:07:32 +02:00
parent 1e8afd5f41
commit cc95e109fc
4 changed files with 136 additions and 9 deletions

View File

@ -45,6 +45,28 @@ fn spectrum_lines(bins: &[f32], width: usize, height: usize) -> Vec<Line<'static
.collect() .collect()
} }
/// Formats the server's output level for the status line: `1.0` reads as
/// `100%`, and since the server clamps to 1.1 the display can reach
/// `110%`. While muted the level is kept on screen — the server reports
/// the level it would unmute to, which is what the user wants to see —
/// with the mute called out rather than the number replaced.
///
/// A volume off the wire is a `float`, so it can arrive as NaN or
/// infinity from a wrong or malicious peer; that reads as `--` instead of
/// `NaN%`, and negative zero as `0%`.
fn format_volume(volume: f32, muted: bool) -> String {
let level = if volume.is_finite() {
format!("{:.0}%", (volume * 100.0).max(0.0))
} else {
"--".to_string()
};
if muted {
format!("{level} (muted)")
} else {
level
}
}
pub struct NowPlaying { pub struct NowPlaying {
play_state: PlayState, play_state: PlayState,
duration: Option<Duration>, duration: Option<Duration>,
@ -58,6 +80,9 @@ pub struct NowPlaying {
spectrum_enabled: bool, spectrum_enabled: bool,
/// Whether the server output is muted. /// Whether the server output is muted.
muted: bool, muted: bool,
/// The server's output level, `1.0` = 100%. This is the level playback
/// would resume at, so it stays meaningful while muted.
volume: f32,
} }
impl Default for NowPlaying { impl Default for NowPlaying {
@ -71,6 +96,7 @@ impl Default for NowPlaying {
spectrum: Vec::new(), spectrum: Vec::new(),
spectrum_enabled: true, spectrum_enabled: true,
muted: false, muted: false,
volume: 1.0,
} }
} }
} }
@ -109,6 +135,10 @@ impl NowPlaying {
pub fn update_mute(&mut self, muted: bool) { pub fn update_mute(&mut self, muted: bool) {
self.muted = muted; self.muted = muted;
} }
/// Reflects the server's output level (see [`format_volume`]).
pub fn update_volume(&mut self, volume: f32) {
self.volume = volume;
}
pub fn render(&self, f: &mut Frame, area: Rect) { pub fn render(&self, f: &mut Frame, area: Rect) {
// With the spectrum on, the info block takes exactly the height // With the spectrum on, the info block takes exactly the height
@ -132,6 +162,17 @@ impl NowPlaying {
.constraints(constraints) .constraints(constraints)
.split(area); .split(area);
// Shuffle, repeat and volume describe the *server*, not the track,
// so this line is drawn whether or not something is loaded — the
// volume you are about to press `K` on should be on screen before
// playback starts.
let mods = format!(
"Shuffle: {}, Repeat: {}, Volume: {}",
self.modifiers.shuffle,
self.modifiers.repeat,
format_volume(self.volume, self.muted),
);
let media_info_text = if let Some(track) = &self.track { let media_info_text = if let Some(track) = &self.track {
let play_text = match self.play_state { let play_text = match self.play_state {
PlayState::Loading => "", PlayState::Loading => "",
@ -143,12 +184,6 @@ impl NowPlaying {
Some(album) => album.title.to_string(), Some(album) => album.title.to_string(),
None => "No album".to_string(), None => "No album".to_string(),
}; };
let mods = format!(
"Shuffle: {}, Repeat: {}{}",
self.modifiers.shuffle,
self.modifiers.repeat,
if self.muted { ", Muted" } else { "" },
);
vec![ vec![
Line::from(Span::raw(mods)), Line::from(Span::raw(mods)),
Line::from(Span::raw(play_text)), Line::from(Span::raw(play_text)),
@ -167,7 +202,7 @@ impl NowPlaying {
] ]
} else { } else {
vec![ vec![
Line::from(Span::raw("")), Line::from(Span::raw(mods)),
Line::from(Span::raw("")), Line::from(Span::raw("")),
Line::from(Span::raw("No track playing")), Line::from(Span::raw("No track playing")),
] ]
@ -315,6 +350,7 @@ mod tests {
spectrum: Vec::new(), spectrum: Vec::new(),
spectrum_enabled: true, spectrum_enabled: true,
muted: false, muted: false,
volume: 1.0,
} }
} }
@ -404,6 +440,58 @@ mod tests {
assert!(rendered_rows(&pane).iter().any(|r| r.contains('█'))); assert!(rendered_rows(&pane).iter().any(|r| r.contains('█')));
} }
#[test]
fn the_volume_reads_as_a_percentage() {
assert_eq!(format_volume(1.0, false), "100%");
assert_eq!(format_volume(0.85, false), "85%");
assert_eq!(format_volume(0.0, false), "0%");
// The server clamps to 1.1, so the display tops out at 110%.
assert_eq!(format_volume(1.1, false), "110%");
}
/// Muting must not hide the level: the server reports the level it
/// would unmute to, which is what the user is about to adjust.
#[test]
fn a_muted_server_still_shows_its_level() {
assert_eq!(format_volume(0.4, true), "40% (muted)");
}
/// The wire carries a `float`, so NaN/infinity are reachable from a
/// wrong peer and must not render as `NaN%`.
#[test]
fn a_broken_volume_renders_as_unknown() {
assert_eq!(format_volume(f32::NAN, false), "--");
assert_eq!(format_volume(f32::INFINITY, false), "--");
assert_eq!(format_volume(f32::NEG_INFINITY, true), "-- (muted)");
// Negative zero would otherwise print as `-0%`.
assert_eq!(format_volume(-0.0, false), "0%");
}
#[test]
fn the_volume_is_on_screen_while_playing() {
let mut pane = now_playing(10_000, 60_000);
pane.update_volume(0.6);
let rows = rendered_rows(&pane);
assert!(
rows.iter().any(|r| r.contains("Volume: 60%")),
"expected the level in the status line: {rows:?}"
);
}
/// Volume describes the server, not the track, so it is visible before
/// anything is loaded — that is when you reach for `K` blind.
#[test]
fn the_volume_is_on_screen_without_a_track() {
let mut pane = NowPlaying::default();
pane.update_volume(0.25);
pane.update_mute(true);
let rows = rendered_rows(&pane);
assert!(
rows.iter().any(|r| r.contains("Volume: 25% (muted)")),
"expected the level with no track loaded: {rows:?}"
);
}
/// The position can overrun a stale or wrong duration (streams, /// The position can overrun a stale or wrong duration (streams,
/// hand-written track files); the gauge must clamp instead of hitting /// hand-written track files); the gauge must clamp instead of hitting
/// ratatui's `ratio should be between 0 and 1` panic. /// ratatui's `ratio should be between 0 and 1` panic.

View File

@ -243,6 +243,11 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled
if let Some(mods) = init_data.mods { if let Some(mods) = init_data.mods {
app.now_playing.update_modifiers(&mods); app.now_playing.update_modifiers(&mods);
} }
// Both were dropped here, so the pane showed a default
// volume and an unmuted server until the user first
// touched either — a display that starts out wrong.
app.now_playing.update_volume(init_data.volume);
app.now_playing.update_mute(init_data.mute);
} }
MessageToUi::Update(update) => match update { MessageToUi::Update(update) => match update {
StreamUpdate::Queue(queue) => { StreamUpdate::Queue(queue) => {
@ -262,7 +267,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled
app.now_playing.update_modifiers(&mods); app.now_playing.update_modifiers(&mods);
} }
StreamUpdate::Mute(muted) => app.now_playing.update_mute(muted), StreamUpdate::Mute(muted) => app.now_playing.update_mute(muted),
StreamUpdate::Volume(_) => { /* FIXME: implement */ } StreamUpdate::Volume(volume) => app.now_playing.update_volume(volume),
StreamUpdate::CaptureProgress(progress) => { StreamUpdate::CaptureProgress(progress) => {
app.captures.apply(progress); app.captures.apply(progress);
} }

View File

@ -20,7 +20,11 @@ The screen has two focusable panes side by side and a now-playing pane:
track highlighted. track highlighted.
- **Now playing** — the current track, a progress gauge, and the - **Now playing** — the current track, a progress gauge, and the
frequency-spectrum bars below it (see [Spectrum](#frequency-spectrum), frequency-spectrum bars below it (see [Spectrum](#frequency-spectrum),
fills the rest of the right column). fills the rest of the right column). Its top line reports the server's
shuffle, repeat and output level — `Volume: 85%`, or
`Volume: 85% (muted)`, which keeps the level you would unmute to on
screen. The level tops out at 110%, and it is shown even with nothing
loaded, since it belongs to the server rather than the track.
`Tab` cycles focus between the library and the queue; keys are routed to `Tab` cycles focus between the library and the queue; keys are routed to
whichever pane has focus (plus the global keys, which apply in either). whichever pane has focus (plus the global keys, which apply in either).

View File

@ -1512,3 +1512,33 @@ Also: enabling the web-sys `DomRect` feature, without which
that — `cbd-web`'s `mod app` is `#[cfg(target_arch = "wasm32")]`, so native that — `cbd-web`'s `mod app` is `#[cfg(target_arch = "wasm32")]`, so native
clippy never compiles it. Second time this session that the wasm build was the clippy never compiles it. Second time this session that the wasm build was the
only gate that would have caught a web-client mistake. only gate that would have caught a web-client mistake.
## TUI volume display (2026-07-27)
The now-playing pane now reports the output level — `Volume: 85%`, or
`Volume: 85% (muted)`.
The display was the small half of this. `cbd-tui` was **discarding** the volume
it was already being sent — `StreamUpdate::Volume(_)` was a
`/* FIXME: implement */`.
`Init` dropped `volume` *and* `mute` too, so even after wiring the stream the
pane would have started out wrong and only corrected itself once the user
touched either control. Both are wired now; the web client had been reading all
three fields all along.
Muting keeps the number on screen rather than replacing it, because the server
reports the level it would unmute to (`PlayerEngine::volume` returns
`pre_mute_volume` while muted) — that is the level the user is about to adjust,
so it is the useful one. This retires the old `, Muted` suffix.
The line is drawn whether or not a track is loaded — it was previously inside
the `if let Some(track)` branch. Shuffle, repeat and volume describe the
*server*, and an idle player is exactly when you reach for `K` blind.
Formatting is a pure function so its edges are unit-tested: the wire carries a
`float`, so NaN and infinity are reachable from a wrong peer and read as `--`
rather than `NaN%`, and negative zero reads as `0%`.
Noted, not fixed — the web volume slider is `max="1.5"` while the engine clamps
to `1.1`, so the top third of its travel silently snaps back. A one-character
fix in `cbd-web`, but it is the web client's bug, not this change's.