// SPDX-FileCopyrightText: Sam Nystrom // SPDX-License-Identifier: GPL-3.0-only use bufio; use encoding::utf8; use getopt; use io; use format::xml; use fmt; use fs; use os; use strconv; use strings; use strio; use time; use unix::poll; use unix::tty; type subtitle = struct { index: uint, start: time::duration, end: time::duration, text: []text, }; type text = (str | setbold | setitalic | setunderline | setcolor); type setbold = bool; type setitalic = bool; type setunderline = bool; type setcolor = str; export fn main() void = { let help: []getopt::help = [ "Play a .srt subtitle file", "", ]; let cmd = getopt::parse(os::args, help...); defer getopt::finish(&cmd); if (len(cmd.args) != 1) { getopt::printusage(os::stderr, os::args[0], help)!; os::exit(1); }; let path = cmd.args[0]; let mode = os::stat(path)!.mode; if (mode & fs::mode::DIR != 0) { fmt::fatalf("Error: '{}' is a directory\n", path); }; let file = match (os::open(path)) { case let f: io::file => yield f; case let err: fs::error => fmt::fatalf("Error reading '{}': {}\n", path, fs::strerror(err)); }; let subtitles = parse_srt(file); defer free(subtitles); defer for (let i = 0z; i < len(subtitles); i += 1) { free_text(subtitles[i].text); }; fmt::fprint(os::stdout_file, "\x1b[?25l")!; defer fmt::fprint(os::stdout_file, "\x1b[?25h")!; let termios = tty::termios_query(os::stdin_file)!; defer tty::termios_restore(&termios); tty::makeraw(&termios)!; let pollfds = [ poll::pollfd { fd = os::stdin_file, events = poll::event::POLLIN, ... }, ]; let end: time::duration = 0; for (let i = 0z; i < len(subtitles); i += 1) { if (subtitles[i].end > end) { end = subtitles[i].end; }; }; let start = time::now(time::clock::REALTIME); let elapsed: time::duration = 0; let pause_start: (time::instant | void) = void; for (true) { match (pause_start) { case void => let now = time::now(time::clock::REALTIME); elapsed = time::diff(start, now); case let inst: time::instant => elapsed = time::diff(start, inst); }; if (elapsed > end) break; fmt::printfln("\x1b[2J\x1b[;H{:02}:{:02}:{:02}{}\r", elapsed / time::HOUR, elapsed / time::MINUTE % 60, elapsed / time::SECOND % 60, if (pause_start is time::instant) " (PAUSED)" else "", )!; let timeout = end; for (let i = 0z; i < len(subtitles); i += 1) { let seg = subtitles[i]; if (seg.start > elapsed && seg.start < timeout) { timeout = seg.start; }; if (seg.end > elapsed && seg.end < timeout) { timeout = seg.end; }; if (seg.start <= elapsed && elapsed <= seg.end) { print_subtitle(os::stdout, seg.text)!; }; }; timeout -= elapsed; let next_second = time::SECOND - elapsed % time::SECOND; if (timeout > next_second) { timeout = next_second; }; if (pause_start is time::instant) { timeout = poll::INDEF; }; match (poll::poll(pollfds, timeout)) { case uint => void; case let err: poll::error => fmt::errorln("Error polling for user input:", poll::strerror(err))!; }; if (pollfds[0].revents & poll::event::POLLIN == 0) { continue; }; static let buf: [os::BUFSIZ]u8 = [0...]; let input = match (io::read(os::stdin_file, buf)) { case let x: size => yield buf[..x]; case io::EOF => break; case let err: io::error => fmt::errorln("Error reading user input:", io::strerror(err))!; continue; }; let quit = false; for (let i = 0z; i < len(input); i += 1) { switch (input[i]) { case 'q' => quit = true; case 'j' => start = time::add(start, time::SECOND * 10); case 'l' => start = time::add(start, -time::SECOND * 10); case 'k' => let now = time::now(time::clock::REALTIME); match (pause_start) { case void => pause_start = now; case let inst: time::instant => pause_start = void; start = time::add(start, time::diff(inst, now)); }; case => void; }; }; if (quit) break; }; }; type state = enum { INDEX, TIMECODE, TEXT, }; fn parse_srt(file: io::handle) []subtitle = { let subtitles: []subtitle = []; let current = subtitle { ... }; let content = bufio::dynamic(io::mode::RDWR); defer io::close(&content)!; io::writeall(&content, strings::toutf8("\n\n"))!; let state = state::INDEX; for (let nr = 0; true; nr += 1) { let line = match (bufio::scanline(file)!) { case let line: []u8 => yield match (strings::fromutf8(line)) { case let line: str => yield line; case utf8::invalid => fmt::fatal("Error: invalid UTF-8"); }; case io::EOF => break; }; defer free(line); switch (state) { case state::INDEX => match (strconv::stou(line)) { case let index: uint => current.index = index; case => fmt::fatalf("Error on line {}: expected uint, found '{}'\n", nr, line); }; state = state::TIMECODE; case state::TIMECODE => match (parse_timecode(line)) { case let times: (time::duration, time::duration) => current.start = times.0; current.end = times.1; case => fmt::fatalf("Error on line {}: invalid timecode syntax\n", nr); }; state = state::TEXT; case state::TEXT => if (len(line) > 0) { io::writeall(&content, strings::toutf8(line))!; strio::appendrune(&content, '\n')!; continue; }; io::writeall(&content, strings::toutf8(""))!; content.pos = 0; current.text = match (parse_text(&content)) { case let t: []text => yield t; case let err: io::error => fmt::fatal("Error parsing subtitles:", io::strerror(err)); case let err: xml::error => fmt::fatal("Error parsing subtitles:", xml::strerror(err)); }; append(subtitles, current); current = subtitle { ... }; bufio::reset(&content); io::writeall(&content, strings::toutf8("\n\n"))!; state = state::INDEX; }; }; return subtitles; }; fn parse_timecode(timecode: str) ((time::duration, time::duration) | strconv::invalid | strconv::overflow) = { let (start, end) = strings::cut(timecode, " --> "); let start = parse_time(start)?; let end = parse_time(end)?; return (start, end); }; fn parse_time(time: str) (time::duration | strconv::invalid | strconv::overflow) = { let dur: time::duration = 0; let (time, ms) = strings::cut(time, ","); dur += strconv::stoi64(ms)? * time::MILLISECOND; let (hrs, time) = strings::cut(time, ":"); dur += strconv::stoi64(hrs)? * time::HOUR; let (mins, secs) = strings::cut(time, ":"); dur += strconv::stoi64(mins)? * time::MINUTE; dur += strconv::stoi64(secs)? * time::SECOND; return dur; }; fn parse_text(in: io::handle) ([]text | io::error | xml::error) = { let parser = xml::parse(in)?; defer xml::parser_free(parser); let text: []text = []; let bold = false; let italic = false; let underline = false; let colorstack: []str = []; defer strings::freeall(colorstack); for (true) { let tok = match (xml::scan(parser)?) { case let tok: xml::token => yield tok; case void => break; }; match (tok) { case let start: xml::elementstart => switch (start) { case "b" => if (!bold) { append(text, true: setbold); bold = true; }; case "i" => if (!italic) { append(text, true: setitalic); italic = true; }; case "u" => if (!underline) { append(text, true: setunderline); underline = true; }; case "font" => append(text, "": setcolor); case => void; }; case let end: xml::elementend => switch (end) { case "b" => if (bold) { append(text, false: setbold); bold = false; }; case "i" => if (italic) { append(text, false: setitalic); italic = false; }; case "u" => if (underline) { append(text, false: setunderline); underline = false; }; case "font" => if (len(colorstack) > 0) { append(text, colorstack[len(colorstack) - 1]: setcolor); delete(colorstack[len(colorstack) - 1]); }; case => void; }; case let attr: xml::attribute => if (attr.0 == "color" && text[len(text) - 1] is setcolor) { text[len(text) - 1] = strings::dup(attr.1): setcolor; append(colorstack, strings::dup(attr.1)); }; case let t: xml::text => // Necessary because of raw mode append(text, strings::replace(t, "\n", "\r\n")); }; }; return text; }; fn print_subtitle(out: io::handle, text: []text) (void | io::error) = { for (let i = 0z; i < len(text); i += 1) { let output = match (text[i]) { case let s: str => yield s; case let bold: setbold => yield if (bold) "\x1b[1m" else "\x1b[22m"; case let italic: setitalic => yield if (italic) "\x1b[3m" else "\x1b[23m"; case let underline: setunderline => yield if (underline) "\x1b[4m" else "\x1b[24m"; case let color: setcolor => // TODO: implement this // fmt::fprintf(out, "\x1b[38;2;{};{};{}m", r, g, b)?; continue; }; io::writeall(out, strings::toutf8(output))?; }; }; fn free_text(text: []text) void = { for (let i = 0z; i < len(text); i += 1) { match (text[i]) { case let s: str => free(s); case => void; }; }; free(text); };