From cc95e109fc7b14ae6e8eef567ff2eb446b73b1f7 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 27 Jul 2026 09:07:32 +0200 Subject: [PATCH] tui: show the output level in the now-playing pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cbd-tui/src/app/now_playing.rs | 102 ++++++++++++++++++++++++++++++--- cbd-tui/src/lib.rs | 7 ++- docs/src/clients/tui.md | 6 +- plan/summary.md | 30 ++++++++++ 4 files changed, 136 insertions(+), 9 deletions(-) diff --git a/cbd-tui/src/app/now_playing.rs b/cbd-tui/src/app/now_playing.rs index 8cea081..789eff3 100644 --- a/cbd-tui/src/app/now_playing.rs +++ b/cbd-tui/src/app/now_playing.rs @@ -45,6 +45,28 @@ fn spectrum_lines(bins: &[f32], width: usize, height: usize) -> Vec 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 { play_state: PlayState, duration: Option, @@ -58,6 +80,9 @@ pub struct NowPlaying { spectrum_enabled: bool, /// Whether the server output is muted. 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 { @@ -71,6 +96,7 @@ impl Default for NowPlaying { spectrum: Vec::new(), spectrum_enabled: true, muted: false, + volume: 1.0, } } } @@ -109,6 +135,10 @@ impl NowPlaying { pub fn update_mute(&mut self, muted: bool) { 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) { // With the spectrum on, the info block takes exactly the height @@ -132,6 +162,17 @@ impl NowPlaying { .constraints(constraints) .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 play_text = match self.play_state { PlayState::Loading => "▼", @@ -143,12 +184,6 @@ impl NowPlaying { Some(album) => album.title.to_string(), None => "No album".to_string(), }; - let mods = format!( - "Shuffle: {}, Repeat: {}{}", - self.modifiers.shuffle, - self.modifiers.repeat, - if self.muted { ", Muted" } else { "" }, - ); vec![ Line::from(Span::raw(mods)), Line::from(Span::raw(play_text)), @@ -167,7 +202,7 @@ impl NowPlaying { ] } else { vec![ - Line::from(Span::raw("")), + Line::from(Span::raw(mods)), Line::from(Span::raw("")), Line::from(Span::raw("No track playing")), ] @@ -315,6 +350,7 @@ mod tests { spectrum: Vec::new(), spectrum_enabled: true, muted: false, + volume: 1.0, } } @@ -404,6 +440,58 @@ mod tests { 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, /// hand-written track files); the gauge must clamp instead of hitting /// ratatui's `ratio should be between 0 and 1` panic. diff --git a/cbd-tui/src/lib.rs b/cbd-tui/src/lib.rs index a4cdcc4..610efe0 100644 --- a/cbd-tui/src/lib.rs +++ b/cbd-tui/src/lib.rs @@ -243,6 +243,11 @@ fn run_ui(tx: Sender, rx: Receiver, spectrum_enabled if let Some(mods) = init_data.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 { StreamUpdate::Queue(queue) => { @@ -262,7 +267,7 @@ fn run_ui(tx: Sender, rx: Receiver, spectrum_enabled app.now_playing.update_modifiers(&mods); } 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) => { app.captures.apply(progress); } diff --git a/docs/src/clients/tui.md b/docs/src/clients/tui.md index 2ebb86e..2c4d43b 100644 --- a/docs/src/clients/tui.md +++ b/docs/src/clients/tui.md @@ -20,7 +20,11 @@ The screen has two focusable panes side by side and a now-playing pane: track highlighted. - **Now playing** — the current track, a progress gauge, and the 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 whichever pane has focus (plus the global keys, which apply in either). diff --git a/plan/summary.md b/plan/summary.md index 46ff13b..db6c364 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -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 clippy never compiles it. Second time this session that the wasm build was the 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.