Skip to main content

oxide_browser/
subtitle.rs

1//! Minimal SRT and WebVTT cue lists for host-side caption rendering.
2
3#[derive(Clone, Debug)]
4pub struct SubtitleCue {
5    pub start_ms: u64,
6    pub end_ms: u64,
7    pub text: String,
8}
9
10fn parse_timestamp(s: &str) -> Option<u64> {
11    let s = s.trim();
12    // 00:00:01,234 or 00:00:01.234
13    let (hms, ms_part) = if let Some(i) = s.rfind([',', '.']) {
14        (&s[..i], &s[i + 1..])
15    } else {
16        return None;
17    };
18    let parts: Vec<&str> = hms.split(':').collect();
19    if parts.len() != 3 {
20        return None;
21    }
22    let h: u64 = parts[0].parse().ok()?;
23    let m: u64 = parts[1].parse().ok()?;
24    let sec: u64 = parts[2].parse().ok()?;
25    let ms: u64 = ms_part.parse().ok()?;
26    Some(((h * 60 + m) * 60 + sec) * 1000 + ms)
27}
28
29/// Parse SubRip (`.srt`) text into cues.
30pub fn parse_srt(data: &str) -> Vec<SubtitleCue> {
31    let mut out = Vec::new();
32    let blocks: Vec<&str> = data.split("\n\n").collect();
33    for block in blocks {
34        let lines: Vec<&str> = block.lines().filter(|l| !l.trim().is_empty()).collect();
35        if lines.len() < 2 {
36            continue;
37        }
38        let mut i = 0;
39        if lines[0].trim().chars().all(|c| c.is_ascii_digit()) {
40            i = 1;
41        }
42        if i >= lines.len() {
43            continue;
44        }
45        let time_line = lines[i];
46        let Some(arrow) = time_line.find("-->") else {
47            continue;
48        };
49        let left = time_line[..arrow].trim();
50        let right = time_line[arrow + 3..].trim();
51        // optional cue settings after end time
52        let right = right.split_whitespace().next().unwrap_or(right);
53        let Some(start) = parse_timestamp(left) else {
54            continue;
55        };
56        let Some(end) = parse_timestamp(right) else {
57            continue;
58        };
59        let text = lines[i + 1..].join("\n");
60        if text.is_empty() {
61            continue;
62        }
63        out.push(SubtitleCue {
64            start_ms: start,
65            end_ms: end,
66            text,
67        });
68    }
69    out
70}
71
72/// Parse WebVTT (`.vtt`) into cues (ignores styles and notes).
73pub fn parse_vtt(data: &str) -> Vec<SubtitleCue> {
74    let mut out = Vec::new();
75    let mut lines = data.lines().peekable();
76    if let Some(first) = lines.peek() {
77        if first.trim().eq_ignore_ascii_case("WEBVTT") {
78            lines.next();
79        }
80    }
81    let mut buf: Vec<String> = Vec::new();
82    for line in lines {
83        let line = line.trim_end();
84        if line.is_empty() {
85            if !buf.is_empty() {
86                if let Some(cue) = parse_vtt_block(&buf) {
87                    out.push(cue);
88                }
89                buf.clear();
90            }
91            continue;
92        }
93        buf.push(line.to_string());
94    }
95    if !buf.is_empty() {
96        if let Some(cue) = parse_vtt_block(&buf) {
97            out.push(cue);
98        }
99    }
100    out
101}
102
103fn parse_vtt_block(lines: &[String]) -> Option<SubtitleCue> {
104    if lines.is_empty() {
105        return None;
106    }
107    let mut i = 0;
108    let time_line = if lines[0].contains("-->") {
109        &lines[0]
110    } else if lines.len() >= 2 && lines[1].contains("-->") {
111        i = 1;
112        &lines[1]
113    } else {
114        return None;
115    };
116    let arrow = time_line.find("-->")?;
117    let left = time_line[..arrow].trim();
118    let right = time_line[arrow + 3..].trim();
119    let right = right.split_whitespace().next()?;
120    let start = parse_timestamp(left)?;
121    let end = parse_timestamp(right)?;
122    let text = lines[i + 1..].join("\n");
123    if text.is_empty() {
124        return None;
125    }
126    Some(SubtitleCue {
127        start_ms: start,
128        end_ms: end,
129        text,
130    })
131}
132
133/// Active subtitle text at `t_ms`, if any.
134pub fn cue_text_at(cues: &[SubtitleCue], t_ms: u64) -> Option<&str> {
135    cues.iter()
136        .find(|c| t_ms >= c.start_ms && t_ms <= c.end_ms)
137        .map(|c| c.text.as_str())
138}