use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Padding, Paragraph},
};
use super::layout::{Theme, format_duration, str_width};
use crate::app::{AppState, LoopMode};
use crate::library::Track;
use crate::numeric::{ratio_to_unit_count, ratio_to_whole_percent};
pub const PLAYER_BAR_H: u16 = 4;
const PAD_X: u16 = 2;
const VOL_CELLS: usize = 14;
const NARROW: u16 = 64;
const MIN_LEFT: u16 = 24;
pub fn render_player_bar(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let t = &state.theme;
let outer = Block::default()
.borders(Borders::TOP)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(t.subtle))
.padding(Padding::horizontal(PAD_X))
.style(t.apply_bg(Style::default()));
let inner = outer.inner(area);
frame.render_widget(outer, area);
if inner.width == 0 || inner.height < 3 {
return;
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
.split(inner);
let track = state.now_playing.and_then(|i| state.queue.get(i));
render_title_row(frame, rows[0], state, track, t);
render_meta_row(frame, rows[1], state, track, t);
render_scrubber_row(frame, rows[2], state, t);
}
fn render_title_row(
frame: &mut Frame<'_>,
row: Rect,
state: &AppState,
track: Option<&Track>,
t: &Theme,
) {
let title = track.map_or_else(
|| Span::styled("Nothing playing".to_string(), Style::default().fg(t.subtle)),
|tr| {
Span::styled(
tr.name.clone(),
Style::default().fg(t.text).add_modifier(Modifier::BOLD),
)
},
);
let full = transport_spans(state, true, t);
let (left, right) = if let (left, Some(area)) = split_row(row, span_width(&full)) {
(left, Some((area, full)))
} else {
let brief = transport_spans(state, false, t);
let (left, area) = split_row(row, span_width(&brief));
(left, area.map(|a| (a, brief)))
};
frame.render_widget(Paragraph::new(Line::from(vec![title])), left);
if let Some((area, spans)) = right {
frame.render_widget(Paragraph::new(Line::from(spans)).right_aligned(), area);
}
}
fn render_meta_row(
frame: &mut Frame<'_>,
row: Rect,
state: &AppState,
track: Option<&Track>,
t: &Theme,
) {
let g = t.glyphs();
let mut left: Vec<Span<'_>> = Vec::new();
if let Some(tr) = track {
if let Some(artist) = tr.artist.as_deref().filter(|a| !a.is_empty()) {
left.push(Span::styled(
artist.to_string(),
Style::default().fg(t.text_dim),
));
}
if let Some(album) = tr.album.as_deref().filter(|a| !a.is_empty()) {
if !left.is_empty() {
left.push(Span::styled(g.sep, Style::default().fg(t.subtle)));
}
left.push(Span::styled(
album.to_string(),
Style::default().fg(t.subtle),
));
}
}
let vol = volume_spans(state.player.volume, row.width, t);
let (left_area, right) = split_row(row, span_width(&vol));
frame.render_widget(Paragraph::new(Line::from(left)), left_area);
if let Some(right) = right {
frame.render_widget(Paragraph::new(Line::from(vol)).right_aligned(), right);
}
}
fn render_scrubber_row(frame: &mut Frame<'_>, row: Rect, state: &AppState, t: &Theme) {
let playing = state.now_playing.is_some();
let elapsed = if playing {
format_duration(state.elapsed().as_secs())
} else {
"0:00".to_string()
};
let total = state
.track_duration
.filter(|_| playing)
.map_or_else(|| "-:--".to_string(), |d| format_duration(d.as_secs()));
let cell = elapsed.chars().count().max(total.chars().count()).max(4);
let flank = cell + 2;
let track_w = usize::from(row.width).saturating_sub(flank * 2);
let mut spans = vec![Span::styled(
format!("{elapsed:>cell$} "),
Style::default().fg(t.text_dim),
)];
spans.extend(scrubber_spans(track_w, state.progress_ratio(), t));
spans.push(Span::styled(
format!(" {total:<cell$}"),
Style::default().fg(t.text_dim),
));
frame.render_widget(Paragraph::new(Line::from(spans)), row);
}
fn scrubber_spans(width: usize, ratio: f64, t: &Theme) -> Vec<Span<'static>> {
let g = t.glyphs();
if width == 0 {
return Vec::new();
}
let filled = ratio_to_unit_count(ratio, width);
let before = filled.saturating_sub(1);
let after = width.saturating_sub(before + 1);
let mut spans = Vec::with_capacity(3);
if before > 0 {
spans.push(Span::styled(
g.track.repeat(before),
Style::default().fg(t.accent),
));
}
spans.push(Span::styled(
g.thumb.to_string(),
Style::default().fg(t.text).add_modifier(Modifier::BOLD),
));
if after > 0 {
spans.push(Span::styled(
g.track.repeat(after),
Style::default().fg(t.subtle),
));
}
spans
}
fn transport_spans(state: &AppState, with_modes: bool, t: &Theme) -> Vec<Span<'static>> {
let g = t.glyphs();
let on = Style::default().fg(t.accent).add_modifier(Modifier::BOLD);
let playing = !state.player.is_paused;
let status = Span::styled(
if playing { "playing" } else { "paused" }.to_string(),
if playing {
on
} else {
Style::default().fg(t.danger).add_modifier(Modifier::BOLD)
},
);
if !with_modes {
return vec![status];
}
let speed = state.player.playback_speed;
let speed_changed = (speed - 1.0).abs() > 0.001;
let speed_value = format!("{speed:.2}{}", g.times);
let (loop_value, loop_on) = match state.loop_mode {
LoopMode::Off => ("off", false),
LoopMode::Queue => ("queue", true),
LoopMode::Track => ("track", true),
};
let mut spans = vec![status];
if speed_changed {
push_item(&mut spans, "speed", speed_value, t);
}
if loop_on {
push_item(&mut spans, "loop", loop_value.to_string(), t);
}
spans
}
fn push_item(spans: &mut Vec<Span<'static>>, label: &str, value: String, t: &Theme) {
spans.push(Span::styled(
format!(" {} ", t.glyphs().divider),
Style::default().fg(t.subtle),
));
spans.push(Span::styled(
format!("{label} "),
Style::default().fg(t.subtle),
));
spans.push(Span::styled(
value,
Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
));
}
fn volume_spans(vol: f32, row_width: u16, t: &Theme) -> Vec<Span<'static>> {
let pct = ratio_to_whole_percent(vol);
let label = Span::styled(format!(" {pct:>3}%"), Style::default().fg(t.text_dim));
if row_width < NARROW {
return vec![label];
}
let g = t.glyphs();
let filled = ratio_to_unit_count(f64::from(vol), VOL_CELLS);
vec![
Span::styled("volume ".to_string(), Style::default().fg(t.subtle)),
Span::styled(g.bar_fill.repeat(filled), Style::default().fg(t.accent)),
Span::styled(
g.bar_empty.repeat(VOL_CELLS - filled),
Style::default().fg(t.vol_empty),
),
label,
]
}
fn span_width(spans: &[Span<'_>]) -> u16 {
u16::try_from(spans.iter().map(|s| str_width(&s.content)).sum::<usize>()).unwrap_or(u16::MAX)
}
fn split_row(row: Rect, right_w: u16) -> (Rect, Option<Rect>) {
if right_w == 0 || row.width < right_w.saturating_add(MIN_LEFT) {
return (row, None);
}
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(right_w)])
.split(row);
(cols[0], Some(cols[1]))
}