use std::fmt::Write;
use lunar_lib::iterator_ext::IteratorExtensions;
use rgb::Rgb;
use serde::{Deserialize, Serialize};
use crate::lyrics::{
LyricFormat,
synced_lyrics::{LyricParseError, LyricSpan},
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LyricLine {
pub start_us: u32,
pub vocals: Vec<LyricSpan>,
pub vocalist: Option<usize>,
}
pub struct ClassifiedSpans<'a> {
pub said: &'a [LyricSpan],
pub current: Option<(&'a LyricSpan, f32)>,
pub unsaid: &'a [LyricSpan],
}
impl LyricLine {
#[must_use]
pub fn line_end_us(&self, lines: &[LyricLine], duration_us: u32) -> u32 {
let pos = lines
.iter()
.position(|l| std::ptr::eq(self, l))
.expect("self (LyricLine) is not contained in the slice");
lines.get(pos + 1).map_or(duration_us, |l| l.start_us)
}
#[must_use]
pub fn time_spans<'a>(
&'a self,
current_us: u32,
lines: &[LyricLine],
duration_us: u32,
) -> ClassifiedSpans<'a> {
let line_end = self.line_end_us(lines, duration_us);
let sung_end = self
.vocals
.iter()
.take_while(|span| current_us >= span.span_end_us(&self.vocals, line_end))
.count();
let current = self.vocals.get(sung_end).and_then(|span| {
if current_us < span.start_us {
return None;
}
let span_end = span.span_end_us(&self.vocals, line_end);
let progress = (current_us - span.start_us) as f32 / (span_end - span.start_us) as f32;
Some((span, progress))
});
let unsung_start = sung_end + usize::from(current.is_some());
ClassifiedSpans {
said: &self.vocals[..sung_end],
current,
unsaid: &self.vocals[unsung_start..],
}
}
}
impl LyricLine {
fn timestamp_string(&self) -> String {
let mm = self.start_us / 60_000_000;
let ss = (self.start_us / 1_000_000) % 60;
let ms = (self.start_us / 10_000) % 100;
format!("[{mm:02}:{ss:02}.{ms:02}]")
}
#[must_use]
pub fn to_lrc_string(&self, a2: bool) -> String {
let mut buf = self.timestamp_string();
for span in &self.vocals {
if a2 && span.vocals.len() > 1 {
buf.push_str(&span.timestamp_string());
}
buf.push_str(&span.vocals);
}
buf
}
pub(crate) fn to_lyrics(&self, format: LyricFormat) -> String {
let mut buf = String::new();
if self.vocals.is_empty() {
return String::new();
}
match format {
LyricFormat::Plain => {
for span in &self.vocals {
buf.push_str(&span.vocals);
}
}
LyricFormat::Lrc { a2 } => {
write!(buf, "{} ", self.timestamp_string()).unwrap();
let span_count = self.vocals.len();
for span in &self.vocals {
if a2 && span_count > 1 {
buf.push_str(&span.timestamp_string());
}
buf.push_str(&span.vocals);
}
}
LyricFormat::SeleneLrc => {
let vocalist = self.vocalist.map_or(String::new(), |v| format!("[{v}]"));
write!(buf, "{}{vocalist} ", self.timestamp_string()).unwrap();
let span_count = self.vocals.len();
for span in &self.vocals {
if span_count > 1 {
buf.push_str(&span.timestamp_string());
}
buf.push_str(&span.vocals);
}
}
}
buf
}
}
pub enum RawLyricLine {
Line(LyricLine),
Break,
Offset(u32),
Vocalist(String, Option<Rgb<u8>>),
By(String),
Tool(String),
Ignored,
}
impl RawLyricLine {
pub fn from_lyric_line(line: &str) -> Result<Self, LyricParseError> {
let line = line.trim();
if line.is_empty() {
return Ok(RawLyricLine::Break);
}
if !line.starts_with('[') {
return Err(LyricParseError::CorruptLineTimestamp(line.to_owned()));
}
let close = line
.find(']')
.ok_or(LyricParseError::CorruptLineTimestamp(line.to_owned()))?;
let Some(start_us) = line[1..close].split_once(':').and_then(|(mins, secs)| {
let mins = mins.parse::<usize>().ok()?;
let secs = secs.parse::<f32>().ok()?;
Some(((mins as f32 * 60.0 + secs) * 1_000_000.0) as u32)
}) else {
if let Some(tag) = line.strip_prefix("[").and_then(|s| s.strip_suffix(']')) {
let parts = tag.split(':').to_vec();
return match parts[0].to_ascii_lowercase().as_str() {
"tool" | "re" => return Ok(RawLyricLine::Tool(parts[1..].join(":"))),
"by" => return Ok(RawLyricLine::By(parts[1..].join(":"))),
"vocalist" => {
let vocalist = parts
.get(1)
.ok_or(LyricParseError::CorruptLineTag(line.to_owned()))?;
let Some(color_str) = parts.get(2) else {
return Ok(RawLyricLine::Vocalist(vocalist.to_string(), None));
};
let color_str = color_str.strip_prefix('#').unwrap_or(*color_str);
if color_str.len() != 6 {
return Err(LyricParseError::CorruptLineTag(line.to_owned()));
}
let r = u8::from_str_radix(&color_str[0..2], 16)
.map_err(|_| LyricParseError::CorruptLineTag(line.to_owned()))?;
let g = u8::from_str_radix(&color_str[2..4], 16)
.map_err(|_| LyricParseError::CorruptLineTag(line.to_owned()))?;
let b = u8::from_str_radix(&color_str[4..6], 16)
.map_err(|_| LyricParseError::CorruptLineTag(line.to_owned()))?;
let color = Rgb { r, g, b };
return Ok(RawLyricLine::Vocalist(vocalist.to_string(), Some(color)));
}
_ => Ok(RawLyricLine::Ignored),
};
}
return Err(LyricParseError::CorruptLineTimestamp(line.to_owned()));
};
let vocal_str = line[close + 1..].trim();
let (vocals, vocalist) = if vocal_str.starts_with('[')
&& let Some(end_pos) = vocal_str.find(']')
{
let vocalist = vocal_str[1..end_pos]
.parse::<usize>()
.map_err(|_| LyricParseError::InvalidLineVocalists(line.to_owned()))?;
let vocals = LyricSpan::from_lyric_str(vocal_str[end_pos + 1..].trim(), start_us)?;
(vocals, Some(vocalist))
} else {
(
LyricSpan::from_lyric_str(line[close + 1..].trim(), start_us)?,
None,
)
};
Ok(RawLyricLine::Line(LyricLine {
start_us,
vocals,
vocalist,
}))
}
}