// srtplay - play .srt subtitle files in a TUI // Copyright (c) 2023 Sam Nystrom // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . 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; use vt; type state = struct { subtitles: []subtitle, start: time::instant, now: time::instant, length: time::duration, elapsed: time::duration, paused: bool, term: *vt::term, }; type subtitle = struct { index: uint, start: time::duration, end: time::duration, text: vt::styled, }; 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); io::close(file)!; defer free(subtitles); defer for (let i = 0z; i < len(subtitles); i += 1) { let text = subtitles[i].text; for (let j = 0z; j < len(text.args); j += 1) { free((text.args[j] as vt::styled).args[0] as str); }; free(text.args); }; let now = time::now(time::clock::REALTIME); let state = state { start = now, now = now, term = vt::open(), subtitles = subtitles, ... }; defer vt::close(state.term); vt::disablecur(state.term)!; for (let i = 0z; i < len(subtitles); i += 1) { if (subtitles[i].end > state.length) { state.length = subtitles[i].end; }; }; match (run(&state)) { case void => void; case let err: vt::error => fmt::errorln("Error:", vt::strerror(err))!; }; }; fn run(state: *state) (void | vt::error) = { for (true) { if (!state.paused) { state.now = time::now(time::clock::REALTIME); }; state.elapsed = time::diff(state.start, state.now); if (state.elapsed > state.length) break; vt::clear(state.term)?; let time_text = fmt::asprintf( "{:02}:{:02}:{:02}{}\r\n", state.elapsed / time::HOUR, state.elapsed / time::MINUTE % 60, state.elapsed / time::SECOND % 60, if (state.paused) " (PAUSED)" else "", ); defer free(time_text); vt::print(state.term, time_text)?; let timeout = state.length; for (let i = 0z; i < len(state.subtitles); i += 1) { let sub = state.subtitles[i]; if (sub.start > state.elapsed && sub.start < timeout) { timeout = sub.start; }; if (sub.end > state.elapsed && sub.end < timeout) { timeout = sub.end; }; if (sub.start <= state.elapsed && state.elapsed <= sub.end) { vt::print(state.term, sub.text)?; }; }; timeout -= state.elapsed; let next_second = time::SECOND - state.elapsed % time::SECOND; if (timeout > next_second) { timeout = next_second; }; if (state.paused) { timeout = poll::INDEF; }; let pollfds = [poll::pollfd { fd = os::stdin_file, events = poll::event::POLLIN | poll::event::POLLHUP, ... }]; if (poll::poll(pollfds, timeout)? == 0) continue; let ev = match (vt::pollevent(state.term)?) { case void => continue; case let ev: vt::event => yield ev; case io::EOF => break; }; match (ev.value) { case let key: rune => switch (key) { case 'q' => break; case 'c' => if (ev.mods & vt::modflag::CTRL != 0) break; case 'j' => fast_forward(state, -time::SECOND * 10); case 'l' => fast_forward(state, time::SECOND * 10); case 'k' => pause(state); case ' ' => pause(state); case => void; }; case let key: vt::specialkey => switch (key) { case vt::specialkey::LEFT => fast_forward(state, -time::SECOND * 5); case vt::specialkey::RIGHT => fast_forward(state, time::SECOND * 5); }; case vt::functionkey => void; }; }; }; fn fast_forward(state: *state, dur: time::duration) void = { state.start = time::add(state.start, -dur); if (time::diff(state.start, state.now) < 0) { state.start = state.now; }; }; fn pause(state: *state) void = { if (state.paused) { let now = time::now(time::clock::REALTIME); state.start = time::add(state.start, time::diff(state.now, now)); state.now = now; }; state.paused = !state.paused; }; type parser_state = enum { INDEX, TIMECODE, TEXT, }; def XML_PROLOG: str = "\n\n"; fn parse_srt(file: io::handle) []subtitle = { let subtitles: []subtitle = []; let current = subtitle { text = vt::styled { pen = vt::defaultpen, ... }, ... }; let content = strio::dynamic(); defer io::close(&content)!; strio::concat(&content, XML_PROLOG)!; let state = parser_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 parser_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 = parser_state::TIMECODE; case parser_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 = parser_state::TEXT; case parser_state::TEXT => if (len(line) > 0) { strio::concat(&content, line, "\n")!; continue; }; strio::concat(&content, "")!; let content_str = strio::string(&content); let buf = bufio::fixed(strings::toutf8(content_str), io::mode::READ); current.text = match (parse_text(&buf)) { case let text: vt::styled => yield text; case => let content = strings::trimprefix(content_str, XML_PROLOG); let content = strings::trimsuffix(content, ""); let text = vt::styled { pen = vt::defaultpen, args = [strings::replace(content, "\n", "\r\n")], }; yield text; }; append(subtitles, current); current = subtitle { text = vt::styled { pen = vt::defaultpen, ... }, ... }; strio::reset(&content); strio::concat(&content, XML_PROLOG)!; state = parser_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) (vt::styled | io::error | xml::error) = { let parser = xml::parse(in)?; defer xml::parser_free(parser); let text = vt::styled { pen = vt::defaultpen, ... }; let pen = vt::defaultpen; let bold = 0; let italic = 0; let underline = 0; 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 == 0) { pen.style |= vt::style::BOLD; }; bold += 1; case "i" => if (italic == 0) { pen.style |= vt::style::ITALIC; }; italic += 1; case "u" => if (underline == 0) { pen.style |= vt::style::ULINE; }; underline += 1; case => void; }; case let end: xml::elementend => switch (end) { case "b" => if (bold == 1) { pen.style &= ~vt::style::BOLD; }; bold -= 1; case "i" => if (italic == 1) { pen.style &= ~vt::style::ITALIC; }; italic -= 1; case "u" => if (underline == 1) { pen.style &= ~vt::style::ULINE; }; underline -= 1; case => void; }; case xml::attribute => void; case let t: xml::text => let styled = vt::styled { pen = pen, args = alloc([strings::replace(t, "\n", "\r\n")]), }; append(text.args, styled); }; }; return text; };