crabidy/cbd-tui/src/app/now_playing.rs

389 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Line<'static>> {
(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<Duration>,
modifiers: QueueModifiers,
position: Option<Duration>,
track: Option<Track>,
/// Latest frequency-spectrum bars (architecture/spectrum.md), empty
/// until the first frame arrives.
spectrum: Vec<f32>,
/// 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<Track>) {
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<f32>) {
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<String> {
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::<String>()
})
.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<String> = 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<char> = 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));
}
}