389 lines
14 KiB
Rust
389 lines
14 KiB
Rust
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));
|
||
}
|
||
}
|