audium 2.0.0

A terminal music app
use std::time::Duration;

#[derive(Debug, Clone)]
pub struct LyricLine {
    pub time_ms: Option<u64>,
    pub text: String,
}

/// Parses raw LRC or plain text into lyric lines.
///
/// Any `[mm:ss.xx]` lines are extracted and sorted by time, skipping metadata
/// tags like `[ti:...]`. With no timed lines, every non-empty line comes back
/// with `time_ms: None`.
pub fn parse_lrc(raw: &str) -> Vec<LyricLine> {
    let mut timed: Vec<LyricLine> = Vec::new();
    let mut plain: Vec<LyricLine> = Vec::new();

    for line in raw.lines() {
        let line = line.trim();
        if let Some(ll) = try_timed(line) {
            timed.push(ll);
        } else if !line.starts_with('[') {
            plain.push(LyricLine {
                time_ms: None,
                text: line.to_string(),
            });
        }
    }

    if timed.is_empty() {
        plain
    } else {
        timed.sort_by_key(|l| l.time_ms);
        timed
    }
}

fn try_timed(line: &str) -> Option<LyricLine> {
    if !line.starts_with('[') {
        return None;
    }
    let close = line.find(']')?;
    let tag = &line[1..close];
    let text = line[close + 1..].trim().to_string();

    // a metadata tag does not start with a digit, e.g. [ti:Title]
    if !tag.starts_with(|c: char| c.is_ascii_digit()) {
        return None;
    }

    let colon = tag.find(':')?;
    let mm: u64 = tag[..colon].parse().ok()?;
    let frac = &tag[colon + 1..];

    let ms = if let Some(dot) = frac.find('.') {
        let ss: u64 = frac[..dot].parse().ok()?;
        let sub = &frac[dot + 1..];
        let sub_ms: u64 = match sub.len() {
            1 => sub.parse::<u64>().ok()? * 100,
            2 => sub.parse::<u64>().ok()? * 10,
            _ => sub.parse::<u64>().ok()?,
        };
        mm * 60_000 + ss * 1_000 + sub_ms
    } else {
        let ss: u64 = frac.parse().ok()?;
        mm * 60_000 + ss * 1_000
    };

    Some(LyricLine {
        time_ms: Some(ms),
        text,
    })
}

/// Returns the index of the last timed line whose timestamp <= `elapsed`.
pub fn active_idx(lines: &[LyricLine], elapsed: Duration) -> Option<usize> {
    let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
    lines
        .iter()
        .enumerate()
        .filter_map(|(i, l)| l.time_ms.map(|t| (i, t)))
        .rfind(|(_, t)| *t <= elapsed_ms)
        .map(|(i, _)| i)
}