use std::time::Instant;
use crate::format::{display_width, format_ms, truncate};
use crate::theme;
pub struct NowPlaying {
pub is_playing: bool,
pub title: String,
pub artists: String,
pub album: Option<String>,
pub progress_ms: u128,
pub duration_ms: u128,
pub device: String,
pub volume: Option<u8>,
pub track_uri: Option<String>,
pub album_image_url: Option<String>,
pub fetched_at: Instant,
}
pub fn interpolate_progress(
base_ms: u128,
elapsed_ms: u128,
duration_ms: u128,
playing: bool,
) -> u128 {
if !playing {
return base_ms;
}
let advanced = base_ms.saturating_add(elapsed_ms);
if duration_ms == 0 {
advanced
} else {
advanced.min(duration_ms)
}
}
pub fn progress_ratio(progress_ms: u128, duration_ms: u128) -> f64 {
if duration_ms == 0 {
return 0.0;
}
(progress_ms as f64 / duration_ms as f64).clamp(0.0, 1.0)
}
pub struct RenderLines {
pub state: String,
pub title: String,
pub artist: String,
pub album: String,
pub ratio: f64,
pub progress_label: String,
pub device: String,
}
fn saved_marker(saved: Option<bool>) -> String {
match saved {
Some(true) => format!(" {} Saved", theme::HEART),
Some(false) => format!(" {} Not saved", theme::HEART_O),
None => String::new(),
}
}
pub fn render_lines(
now: Option<&NowPlaying>,
elapsed_ms: u128,
width: usize,
saved: Option<bool>,
) -> RenderLines {
let line = |label: &str, value: &str| -> String {
format!(
"{label}{}",
truncate(value, width.saturating_sub(display_width(label)))
)
};
let Some(n) = now else {
return RenderLines {
state: "Nothing is playing".to_string(),
title: " (press p to resume / `spotterm play` to start)".to_string(),
artist: String::new(),
album: String::new(),
ratio: 0.0,
progress_label: "-".to_string(),
device: String::new(),
};
};
let prog = interpolate_progress(n.progress_ms, elapsed_ms, n.duration_ms, n.is_playing);
let head = if n.is_playing {
format!("{} Playing", theme::PLAY)
} else {
format!("{} Paused", theme::PAUSE)
};
let vol = n
.volume
.map(|v| format!("{v}%"))
.unwrap_or_else(|| "-".to_string());
RenderLines {
state: format!("{head}{}", saved_marker(saved)),
title: line(&format!("{} ", theme::MUSIC), &n.title),
artist: line(&format!("{} ", theme::ARTIST), &n.artists),
album: n
.album
.as_deref()
.map(|a| line(&format!("{} ", theme::ALBUM), a))
.unwrap_or_default(),
ratio: progress_ratio(prog, n.duration_ms),
progress_label: format!("{} / {}", format_ms(prog), format_ms(n.duration_ms)),
device: format!("{} {} (vol {vol})", theme::VOLUME, n.device),
}
}
pub fn seek_target(current_ms: u128, duration_ms: u128, delta_ms: i64) -> u128 {
let target = if delta_ms >= 0 {
current_ms.saturating_add(delta_ms as u128)
} else {
current_ms.saturating_sub(delta_ms.unsigned_abs() as u128)
};
if duration_ms == 0 {
target
} else {
target.min(duration_ms)
}
}
pub fn search_hint(is_input: bool, results_len: usize) -> String {
if is_input {
"Enter to search / Esc to go back".to_string()
} else {
format!("{results_len} results — ↑↓ select / Enter play / Esc edit query")
}
}
pub fn search_row(name: &str, artists: &str, width: usize) -> String {
let text = if artists.is_empty() {
name.to_string()
} else {
format!("{name} — {artists}")
};
truncate(&text, width.saturating_sub(2))
}
pub fn device_row(
name: &str,
type_label: &str,
vol: Option<u32>,
is_active: bool,
is_restricted: bool,
width: usize,
) -> String {
let mark = if is_active { "●" } else { "○" };
let vol_s = match vol {
Some(v) => format!("vol {v}%"),
None => "vol -".to_string(),
};
let mut text = format!("{mark} {name} [{type_label}] {vol_s}");
if is_active {
text.push_str(" (active)");
}
if is_restricted {
text.push_str(" (restricted)");
}
truncate(&text, width.saturating_sub(2))
}
pub fn help_entries() -> &'static [(&'static str, &'static str)] {
&[
("space", "play / pause"),
("n / p", "next / previous track"),
("← / →", "seek 5s (back / forward)"),
("+ / -", "volume ±5"),
("s", "save / unsave the current track"),
("/", "search and play"),
("2", "browse library"),
("d", "select device"),
("r", "refresh (resume auto-refresh)"),
("?", "this help"),
("q / Esc", "quit"),
("Ctrl-C", "quit (from any screen)"),
]
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StatusKind {
Warn,
Ok,
Info,
}
pub fn status_kind(s: &str) -> StatusKind {
let trimmed = s.trim_start();
if trimmed.starts_with(theme::WARN) {
StatusKind::Warn
} else if theme::OK_PREFIXES.iter().any(|p| trimmed.starts_with(p)) {
StatusKind::Ok
} else {
StatusKind::Info
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interpolate_advances_only_while_playing() {
assert_eq!(interpolate_progress(10_000, 3_000, 200_000, true), 13_000);
assert_eq!(interpolate_progress(10_000, 3_000, 200_000, false), 10_000);
}
#[test]
fn interpolate_clamps_to_duration() {
assert_eq!(
interpolate_progress(195_000, 10_000, 200_000, true),
200_000
);
assert_eq!(interpolate_progress(195_000, 10_000, 0, true), 205_000);
}
#[test]
fn progress_ratio_bounds() {
assert_eq!(progress_ratio(0, 200_000), 0.0);
assert_eq!(progress_ratio(100_000, 200_000), 0.5);
assert_eq!(progress_ratio(200_000, 200_000), 1.0);
assert_eq!(progress_ratio(50_000, 0), 0.0);
assert_eq!(progress_ratio(250_000, 200_000), 1.0);
}
fn sample(is_playing: bool) -> NowPlaying {
NowPlaying {
is_playing,
title: "Song".to_string(),
artists: "Artist".to_string(),
album: Some("Album".to_string()),
progress_ms: 60_000,
duration_ms: 180_000,
device: "MacBook Pro".to_string(),
volume: Some(40),
track_uri: Some("spotify:track:xxxx".to_string()),
album_image_url: None,
fetched_at: std::time::Instant::now(),
}
}
#[test]
fn render_lines_shows_track_and_progress() {
let n = sample(true);
let out = render_lines(Some(&n), 0, 80, None);
assert_eq!(out.state, format!("{} Playing", theme::PLAY));
assert!(out.title.contains("Song"));
assert!(out.artist.contains("Artist"));
assert_eq!(out.progress_label, "1:00 / 3:00");
assert_eq!(out.ratio, 60_000.0 / 180_000.0);
assert!(out.device.contains("MacBook Pro"));
assert!(out.device.contains("40%"));
}
#[test]
fn render_lines_prefixes_info_lines_with_icons() {
let n = sample(true);
let out = render_lines(Some(&n), 0, 80, None);
assert!(
out.title.starts_with(theme::MUSIC),
"title needs music icon"
);
assert!(
out.artist.starts_with(theme::ARTIST),
"artist needs artist icon"
);
assert!(
out.album.starts_with(theme::ALBUM),
"album needs album icon"
);
assert!(
out.device.starts_with(theme::VOLUME),
"device needs volume icon"
);
}
#[test]
fn render_lines_empty_state_when_nothing_playing() {
let out = render_lines(None, 0, 80, None);
assert_eq!(out.state, "Nothing is playing");
assert!(out.artist.is_empty());
assert_eq!(out.ratio, 0.0);
}
#[test]
fn render_lines_shows_saved_marker() {
let n = sample(true);
assert!(
render_lines(Some(&n), 0, 80, Some(true))
.state
.contains(theme::HEART)
);
assert!(
render_lines(Some(&n), 0, 80, Some(false))
.state
.contains(theme::HEART_O)
);
let unknown = render_lines(Some(&n), 0, 80, None).state;
assert!(!unknown.contains(theme::HEART) && !unknown.contains(theme::HEART_O));
}
#[test]
fn seek_target_advances_and_rewinds() {
assert_eq!(seek_target(60_000, 180_000, 5_000), 65_000);
assert_eq!(seek_target(60_000, 180_000, -5_000), 55_000);
}
#[test]
fn seek_target_clamps_bounds() {
assert_eq!(seek_target(3_000, 180_000, -5_000), 0);
assert_eq!(seek_target(178_000, 180_000, 5_000), 180_000);
assert_eq!(seek_target(178_000, 0, 5_000), 183_000);
}
#[test]
fn search_row_joins_name_and_artists() {
assert_eq!(search_row("Song", "Artist", 80), "Song — Artist");
assert_eq!(search_row("Song", "", 80), "Song");
}
#[test]
fn search_row_truncates_with_symbol_margin() {
let out = search_row("abcdefghij", "", 10);
assert_eq!(out.chars().count(), 8);
assert!(out.ends_with('…'));
}
#[test]
fn search_hint_varies_by_phase() {
assert!(search_hint(true, 0).contains("Enter to search"));
let results = search_hint(false, 3);
assert!(results.starts_with("3 results"));
assert!(results.contains("Enter play"));
}
#[test]
fn device_row_active_marks_and_notes() {
let out = device_row("MacBook Pro", "Computer", Some(65), true, false, 80);
assert!(out.starts_with("● MacBook Pro [Computer]"));
assert!(out.contains("vol 65%"));
assert!(out.contains("(active)"));
}
#[test]
fn device_row_inactive_without_volume() {
let out = device_row("Speaker", "Speaker", None, false, false, 80);
assert!(out.starts_with("○ Speaker [Speaker]"));
assert!(out.contains("vol -"));
assert!(!out.contains("(active)"));
}
#[test]
fn device_row_restricted_is_annotated() {
let out = device_row("TV", "Tv", Some(40), false, true, 80);
assert!(out.starts_with("○ TV [Tv]"));
assert!(out.contains("(restricted)"));
}
#[test]
fn device_row_truncates_with_symbol_margin() {
let out = device_row("abcdefghij", "X", None, false, false, 10);
assert!(crate::format::display_width(&out) <= 8);
assert!(out.ends_with('…'));
}
#[test]
fn help_entries_cover_all_keys() {
let keys: Vec<&str> = help_entries().iter().map(|(k, _)| *k).collect();
for k in [
"space",
"n / p",
"← / →",
"+ / -",
"s",
"/",
"2",
"d",
"r",
"?",
"q / Esc",
] {
assert!(keys.contains(&k), "help is missing {k}");
}
assert!(help_entries().iter().all(|(_, desc)| !desc.is_empty()));
}
#[test]
fn status_kind_classifies() {
assert_eq!(
status_kind(&format!("{} refresh failed: x", theme::WARN)),
StatusKind::Warn
);
assert_eq!(
status_kind(&format!("{} play", theme::PLAY)),
StatusKind::Ok
);
assert_eq!(
status_kind(&format!("{} saved to your library", theme::HEART)),
StatusKind::Ok
);
assert_eq!(
status_kind(&format!("{} seek 1:23", theme::SEEK)),
StatusKind::Ok
);
assert_eq!(status_kind("starting…"), StatusKind::Info);
}
#[test]
fn status_kind_recognizes_every_theme_prefix() {
for icon in theme::OK_PREFIXES {
assert_eq!(
status_kind(&format!("{icon} action done")),
StatusKind::Ok,
"ok prefix {icon:?} must classify as Ok"
);
}
assert_eq!(
status_kind(&format!("{} boom", theme::WARN)),
StatusKind::Warn
);
}
}