use std::{ops::Div, time::Duration}; use notify_rust::Notification; use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, BorderType, Borders, LineGauge, Paragraph, Wrap}, Frame, }; use super::{COLOR_PRIMARY, COLOR_SECONDARY}; /// Vertical block glyphs by eighths, index 0 = empty, 8 = full cell. const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; /// Renders `bins` as full-height vertical bars filling a `width`×`height` /// area: one `Line` per row, top row first. Each column maps to a bin; /// its level in `[0, 1]` fills from the bottom, using partial block /// glyphs for the topmost fractional cell. Pure, so it is unit-tested. fn spectrum_lines(bins: &[f32], width: usize, height: usize) -> Vec> { (0..height) .map(|row| { // Row 0 is the top; count cells up from the bottom. let from_bottom = height - 1 - row; let cells: String = (0..width) .map(|col| { let bin = if bins.is_empty() { 0 } else { (col * bins.len() / width.max(1)).min(bins.len() - 1) }; let level = bins.get(bin).copied().unwrap_or(0.0).clamp(0.0, 1.0); let total_eighths = (level * height as f32 * 8.0).round() as usize; let cell = total_eighths.saturating_sub(from_bottom * 8).min(8); BLOCKS[cell] }) .collect(); Line::from(Span::styled(cells, Style::default().fg(COLOR_PRIMARY))) }) .collect() } pub struct NowPlaying { play_state: PlayState, duration: Option, modifiers: QueueModifiers, position: Option, track: Option, /// Latest frequency-spectrum bars (architecture/spectrum.md), empty /// until the first frame arrives. spectrum: Vec, /// Whether to draw the spectrum row (config `spectrum`, default on). spectrum_enabled: bool, /// Whether the server output is muted. muted: bool, } impl Default for NowPlaying { fn default() -> Self { NowPlaying { play_state: PlayState::Unspecified, duration: None, modifiers: QueueModifiers::default(), position: None, track: None, spectrum: Vec::new(), spectrum_enabled: true, muted: false, } } } impl NowPlaying { pub fn update_play_state(&mut self, play_state: PlayState) { self.play_state = play_state; } pub fn update_position(&mut self, pos: TrackPosition) { self.position = Some(Duration::from_millis(pos.position.into())); self.duration = Some(Duration::from_millis(pos.duration.into())); } pub fn update_track(&mut self, active: Option) { if let Some(track) = &active { let body = if let Some(ref album) = track.album { format!( "{} by {}\n\n{} ({})", track.title, track.artist, album.title, // FIXME: get out year and format differently if it's missing album.release_date() ) } else { format!("{} by {}", track.title, track.artist,) }; // A missing notification daemon must not crash the TUI. // The explicit appname keeps notification-daemon rules // (e.g. mako `app-name=` criteria) stable even if the // binary is renamed or wrapped. if let Err(err) = Notification::new() .appname("crabidy") .summary("Now playing") .body(&body) .show() { tracing::debug!("could not show desktop notification: {err}"); } } self.track = active; } pub fn update_modifiers(&mut self, mods: &QueueModifiers) { self.modifiers = *mods; } /// Applies a spectrum frame from the server (already normalized). pub fn update_spectrum(&mut self, bins: Vec) { self.spectrum = bins; } /// Enables/disables the spectrum row (from client config). pub fn set_spectrum_enabled(&mut self, enabled: bool) { self.spectrum_enabled = enabled; } /// Reflects the server's mute state. pub fn update_mute(&mut self, muted: bool) { self.muted = muted; } pub fn render(&self, f: &mut Frame, area: Rect) { // With the spectrum on, the info block takes exactly the height // its content needs, the progress a fixed row, and the spectrum // fills whatever is left (`Min(0)`) — so both size themselves and // the bars are as tall as the pane allows. With it off, the info // block fills as before. let info_lines = if self.track.is_some() { 4 } else { 3 }; let info_height = info_lines + 2; // + top and bottom border let constraints = if self.spectrum_enabled { vec![ Constraint::Length(info_height), Constraint::Length(1), Constraint::Min(0), ] } else { vec![Constraint::Min(3), Constraint::Length(1)] }; let now_playing_layout = Layout::default() .direction(Direction::Vertical) .constraints(constraints) .split(area); let media_info_text = if let Some(track) = &self.track { let play_text = match self.play_state { PlayState::Loading => "▼", PlayState::Paused => "■", PlayState::Playing => "♫", _ => "", }; let album_text = match &track.album { 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)), Line::from(vec![ Span::styled( track.title.to_string(), Style::default().add_modifier(Modifier::BOLD), ), Span::raw(" by "), Span::styled( track.artist.to_string(), Style::default().add_modifier(Modifier::BOLD), ), ]), Line::from(Span::raw(album_text)), ] } else { vec![ Line::from(Span::raw("")), Line::from(Span::raw("")), Line::from(Span::raw("No track playing")), ] }; let media_info_p = Paragraph::new(media_info_text) .block( Block::default() .title("Now playing") .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(COLOR_SECONDARY)), ) .alignment(Alignment::Center) .wrap(Wrap { trim: true }); f.render_widget(media_info_p, now_playing_layout[0]); if let (Some(position), Some(duration), Some(_track)) = (self.position, self.duration, &self.track) { let pos = position.as_secs(); let dur = duration.as_secs(); let completion_size = if dur < 3600 { 12 } else { 15 }; let elapsed_layout = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Min(10), Constraint::Max(completion_size)]) .split(now_playing_layout[1]); // Clamped: the player's position can overrun a stale or wrong // duration (streams, hand-written track files), and // `LineGauge` panics on ratios outside 0..=1. let ratio = if duration.is_zero() { 0.0 } else { position .as_secs_f64() .div(duration.as_secs_f64()) .clamp(0.0, 1.0) }; let progress = LineGauge::default() .label("") .block(Block::default().borders(Borders::NONE)) .filled_style(Style::default().fg(COLOR_SECONDARY).bg(Color::Black)) .ratio(ratio); f.render_widget(progress, elapsed_layout[0]); let pos_min = (pos / 60) % 60; let pos_secs = pos % 60; let dur_min = (dur / 60) % 60; let dur_secs = dur % 60; let completion_text = if dur < 3600 { format!( "{:0>2}:{:0>2}/{:0>2}:{:0>2}", pos_min, pos_secs, dur_min, dur_secs, ) } else { let pos_hours = pos_secs / 60 / 60; let dur_hours = dur_secs / 60 / 60; format!( "{:0>1}:{:0>2}:{:0>2}/{:0>1}:{:0>2}:{:0>2}", pos_hours, pos_min, pos_secs, dur_hours, dur_min, dur_secs, ) }; let time_text = Span::raw(completion_text); let time_p = Paragraph::new(Line::from(time_text)); f.render_widget(time_p, elapsed_layout[1]); } // The spectrum: full-height accent bars filling the region left // below the progress. Columns stretch across the pane width // regardless of the server's bin count. if self.spectrum_enabled && !self.spectrum.is_empty() { let area = now_playing_layout[2]; let (width, height) = (area.width as usize, area.height as usize); if width > 0 && height > 0 { let lines = spectrum_lines(&self.spectrum, width, height); f.render_widget(Paragraph::new(lines), area); } } } } #[cfg(test)] mod tests { use super::*; use ratatui::{backend::TestBackend, Terminal}; /// A now-playing pane mid-track, built directly (no `update_track`, /// which fires a desktop notification). fn now_playing(position_ms: u32, duration_ms: u32) -> NowPlaying { NowPlaying { play_state: PlayState::Playing, duration: Some(Duration::from_millis(duration_ms.into())), modifiers: QueueModifiers::default(), position: Some(Duration::from_millis(position_ms.into())), track: Some(Track { path: "/fs/radio.cbd-track.toml".to_string(), artist: "artist".to_string(), title: "title".to_string(), duration: None, album: None, is_skipped: false, }), spectrum: Vec::new(), spectrum_enabled: true, muted: false, } } fn render(pane: &NowPlaying) { let backend = TestBackend::new(60, 12); let mut terminal = Terminal::new(backend).expect("test terminal"); terminal .draw(|f| pane.render(f, f.area())) .expect("draw must not panic"); } /// Renders and returns the buffer rows as strings. fn rendered_rows(pane: &NowPlaying) -> Vec { let backend = TestBackend::new(60, 12); let mut terminal = Terminal::new(backend).expect("test terminal"); terminal.draw(|f| pane.render(f, f.area())).expect("draw"); let buffer = terminal.backend().buffer().clone(); (0..buffer.area.height) .map(|y| { (0..buffer.area.width) .map(|x| buffer[(x, y)].symbol().to_string()) .collect::() }) .collect() } #[test] fn spectrum_lines_fill_full_height_columns() { // A full-level bin fills every row of its column with the full // block; a zero bin leaves every row blank. let lines = spectrum_lines(&[1.0, 0.0], 2, 4); assert_eq!(lines.len(), 4, "one line per row"); let text: Vec = lines .iter() .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) .collect(); // Column 0 (level 1.0) is full in every row; column 1 (0.0) empty. assert!(text.iter().all(|row| row.starts_with('█')), "{text:?}"); assert!(text.iter().all(|row| row.ends_with(' ')), "{text:?}"); } #[test] fn spectrum_lines_grow_from_the_bottom() { // Half level over 4 rows ≈ 16 eighths → the bottom two rows fill. let lines = spectrum_lines(&[0.5], 1, 4); let col: Vec = lines .iter() .map(|l| l.spans[0].content.chars().next().unwrap()) .collect(); // Top rows empty, bottom rows full — bars rise from the floor. assert_eq!(col[0], ' ', "top empty: {col:?}"); assert_eq!(col[3], '█', "bottom full: {col:?}"); } #[test] fn the_spectrum_renders_block_glyphs_when_enabled() { let mut pane = now_playing(10_000, 60_000); pane.update_spectrum(vec![1.0; 24]); let rows = rendered_rows(&pane); // Full-level bars fill several rows with the full block. let full_rows = rows.iter().filter(|r| r.contains('█')).count(); assert!(full_rows >= 2, "expected tall spectrum bars, got: {rows:?}"); } #[test] fn the_spectrum_row_is_hidden_when_disabled() { let mut pane = now_playing(10_000, 60_000); pane.set_spectrum_enabled(false); pane.update_spectrum(vec![1.0; 24]); let rows = rendered_rows(&pane); assert!( !rows.iter().any(|r| r.contains('█')), "disabled spectrum must not draw bars: {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. #[test] fn progress_gauge_survives_position_past_duration() { render(&now_playing(90_000, 60_000)); } #[test] fn progress_gauge_survives_a_zero_duration() { render(&now_playing(5_000, 0)); } }