pub const MAX_CHAPTERS: usize = 500;
pub const BACK_WITHIN: f64 = 5.0;
#[derive(Clone, Debug, PartialEq)]
pub struct Chapter {
pub start: f64,
pub title: String,
}
pub fn tidy(mut chapters: Vec<Chapter>, length: Option<f64>) -> Vec<Chapter> {
chapters
.retain(|c| c.start.is_finite() && c.start >= 0.0 && length.is_none_or(|l| c.start < l));
chapters.sort_by(|a, b| a.start.total_cmp(&b.start));
chapters.dedup_by(|a, b| a.start == b.start);
chapters.truncate(MAX_CHAPTERS);
chapters
}
pub fn current(chapters: &[Chapter], at: f64) -> Option<usize> {
chapters.iter().rposition(|c| c.start <= at)
}
pub fn previous(chapters: &[Chapter], at: f64) -> Option<f64> {
let i = current(chapters, at)?;
if at - chapters[i].start > BACK_WITHIN || i == 0 {
return Some(chapters[i].start);
}
Some(chapters[i - 1].start)
}
pub fn next(chapters: &[Chapter], at: f64) -> Option<f64> {
chapters.iter().map(|c| c.start).find(|&start| start > at)
}
pub fn parse_time(text: &str, at: f64, length: Option<f64>) -> Option<f64> {
let text = text.trim();
if let Some(percent) = text.strip_suffix('%') {
let percent: f64 = percent.trim().parse().ok()?;
return (0.0..=100.0)
.contains(&percent)
.then_some(length? * percent / 100.0);
}
let (sign, clock) = match text.as_bytes().first()? {
b'+' => (1.0, &text[1..]),
b'-' => (-1.0, &text[1..]),
_ => (0.0, text),
};
let parts: Vec<&str> = clock.trim().split(':').collect();
if parts.len() > 3 {
return None;
}
let mut seconds = 0.0;
for (i, part) in parts.iter().enumerate() {
let last = i == parts.len() - 1;
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
let ok = match part.split_once('.') {
Some((whole, fraction)) => last && digits(whole) && digits(fraction),
None => digits(part),
};
if !ok {
return None;
}
let value: f64 = part.parse().ok()?;
if i > 0 && value >= 60.0 {
return None;
}
seconds = seconds * 60.0 + value;
}
let time = if sign == 0.0 {
seconds
} else {
at + sign * seconds
};
time.is_finite().then_some(time.max(0.0))
}
#[cfg(test)]
mod tests {
use super::*;
fn chapters(starts: &[f64]) -> Vec<Chapter> {
starts
.iter()
.map(|&start| Chapter {
start,
title: format!("at {start}"),
})
.collect()
}
#[test]
fn chapters_are_kept_in_order_once_each_and_within_the_video() {
let kept = tidy(
chapters(&[
300.0,
0.0,
f64::NAN,
-5.0,
120.0,
120.0,
900.0,
f64::INFINITY,
]),
Some(600.0),
);
let starts: Vec<f64> = kept.iter().map(|c| c.start).collect();
assert_eq!(starts, [0.0, 120.0, 300.0]);
let many = chapters(&(0..2000).map(f64::from).collect::<Vec<_>>());
assert_eq!(tidy(many, None).len(), MAX_CHAPTERS);
}
#[test]
fn the_chapter_keys_go_where_youtube_and_mpv_go() {
let list = chapters(&[0.0, 60.0, 180.0]);
assert_eq!(current(&list, 0.0), Some(0));
assert_eq!(current(&list, 59.9), Some(0));
assert_eq!(current(&list, 60.0), Some(1));
assert_eq!(current(&list, 999.0), Some(2));
assert_eq!(current(&chapters(&[10.0]), 5.0), None, "before the first");
assert_eq!(next(&list, 30.0), Some(60.0));
assert_eq!(next(&list, 60.0), Some(180.0));
assert_eq!(next(&list, 200.0), None, "the last one");
assert_eq!(previous(&list, 100.0), Some(60.0));
assert_eq!(previous(&list, 62.0), Some(0.0));
assert_eq!(previous(&list, 2.0), Some(0.0), "the first stays");
assert_eq!(previous(&[], 50.0), None);
}
#[test]
fn a_typed_time_can_be_a_clock_a_step_or_a_share() {
let t = |text: &str| parse_time(text, 100.0, Some(600.0));
assert_eq!(t("90"), Some(90.0));
assert_eq!(t("1:23"), Some(83.0));
assert_eq!(t(" 1:02:03 "), Some(3723.0));
assert_eq!(t("90:00"), Some(5400.0), "the first part may be big");
assert_eq!(t("0:05.5"), Some(5.5));
assert_eq!(t("+30"), Some(130.0));
assert_eq!(t("-1:00"), Some(40.0));
assert_eq!(t("-5:00"), Some(0.0), "not before the start");
assert_eq!(t("50%"), Some(300.0));
assert_eq!(t("100%"), Some(600.0));
assert_eq!(parse_time("50%", 0.0, None), None, "no length to share");
for bad in [
"", "+", "1:60", "1::2", "1:2:3:4", "1.5:00", "abc", "1e3", "150%", "-5%", ".5", "1:",
"NaN", "inf", "+-3",
] {
assert_eq!(t(bad), None, "{bad:?}");
}
}
}