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.