From 277fa45fd3d6bd80842660460ce978a01d286ced Mon Sep 17 00:00:00 2001 From: Sam Nystrom Date: Wed, 16 Aug 2023 16:26:05 -0400 Subject: Rewrite to Perl for maintainability Hare is experimental, unstable, and low-level, and I want to spend as little effort as possible maintaining this program. --- .gitignore | 1 - main.ha | 403 -------------------------------------------------------- srtplay | 157 ++++++++++++++++++++++ vendor/hare-vt | 1 - vendor/hare-xml | 1 - 5 files changed, 157 insertions(+), 406 deletions(-) delete mode 100644 main.ha create mode 100755 srtplay delete mode 160000 vendor/hare-vt delete mode 160000 vendor/hare-xml diff --git a/.gitignore b/.gitignore index 9d2c5cb..172332b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -/srtplay /srtplay.1 diff --git a/main.ha b/main.ha deleted file mode 100644 index 0994b14..0000000 --- a/main.ha +++ /dev/null @@ -1,403 +0,0 @@ -// 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} / {:02}:{:02}:{:02}{}\r\n", - state.elapsed / time::HOUR, - state.elapsed / time::MINUTE % 60, - state.elapsed / time::SECOND % 60, - state.length / time::HOUR, - state.length / time::MINUTE % 60, - state.length / 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; -}; diff --git a/srtplay b/srtplay new file mode 100755 index 0000000..9c181ff --- /dev/null +++ b/srtplay @@ -0,0 +1,157 @@ +#!/usr/bin/env perl + +use 5.038; +use utf8; + +use Time::HiRes qw(time sleep); +use IO::Poll; +use Term::ReadKey; +use XML::Parser; + +binmode STDOUT, ':utf8'; + +if (@ARGV != 1) { + say 'Usage: ' . $0 =~ s#.*/##r . ' '; + exit 1; +} + +my @subtitles = (); + +my $bold = 0; +my $italic = 0; +my $underline = 0; +my $parsed = ''; +my $xml_parser = XML::Parser->new(Handlers => { + Start => sub ($expat, $element, @attrs) { + if ($element eq 'b') { + $parsed .= "\x1b[1m" if $bold == 0; + $bold += 1; + } elsif ($element eq 'i') { + $parsed .= "\x1b[3m" if $italic == 0; + $italic += 1; + } elsif ($element eq 'u') { + $parsed .= "\x1b[4m" if $underline == 0; + $underline += 1; + } + }, + Char => sub ($expat, $string) { + $parsed .= $string; + }, + End => sub ($expat, $element) { + if ($element eq 'b') { + $parsed .= "\x1b[22m" if $bold == 1; + $bold -= 1; + } elsif ($element eq 'i') { + $parsed .= "\x1b[23m" if $italic == 1; + $italic -= 1; + } elsif ($element eq 'u') { + $parsed .= "\x1b[24m" if $underline == 1; + $underline -= 1; + } + }, +}); + +do { + my $subtitle = { text => '' }; + my $state = 'index'; + + open(my $file, '<', $ARGV[0]) or die $!; + while (<$file>) { + chomp; + if ($state eq 'index') { + $subtitle->{index} = int($_); + $state = 'time'; + } elsif ($state eq 'time') { + unless (/^(\d+):(\d+):(\d+),(\d+) --> (\d+):(\d+):(\d+),(\d+)$/) { + die "Syntax error"; + } + $subtitle->{start} = $1 * 3600 + $2 * 60 + $3 + $4 / 1000; + $subtitle->{end} = $5 * 3600 + $6 * 60 + $7 + $8 / 1000; + if ($subtitle->{end} < $subtitle->{start}) { + die "Syntax error"; + } + $state = 'text'; + } elsif ($state eq 'text') { + if (length > 0) { + $subtitle->{text} .= $_ . "\n"; + next; + } + $xml_parser->parse('' . $subtitle->{text} . ''); + $subtitle->{text} = $parsed; + $parsed = ''; + push @subtitles, $subtitle; + $subtitle = { text => '' }; + $state = 'index'; + } + } +}; + +my $now = time; +my $start = $now; +my $elapsed = 0; +my $paused = 0; +my $length = 0; +foreach my $subtitle (@subtitles) { + $length = $subtitle->{end} if $subtitle->{end} > $length; +} +my $poll = IO::Poll->new(); +$poll->mask(STDIN => POLLIN); + +ReadMode 3; + +while (1) { + $now = time unless $paused; + $elapsed = $now - $start; + last if $elapsed > $length; + + print "\e[2J\e[H"; # clear screen and move cursor to top left + printf("%02d:%02d:%02d / %02d:%02d:%02d%s\n", + int($elapsed / 3600), + int($elapsed / 60 % 60), + int($elapsed % 60), + int($length / 3600), + int($length / 60 % 60), + int($length % 60), + $paused ? ' (PAUSED)' : '', + ); + + my $timeout = $length; + foreach my $sub (@subtitles) { + if ($sub->{start} > $elapsed && $sub->{start} < $timeout) { + $timeout = $sub->{start}; + } + if ($sub->{end} > $elapsed && $sub->{end} < $timeout) { + $timeout = $sub->{end}; + } + if ($sub->{start} < $elapsed && $elapsed <= $sub->{end}) { + print $sub->{text}; + } + } + $timeout -= $elapsed; + my $next_second = 1 - $elapsed % 1; + $timeout = $next_second if $timeout > $next_second; + $timeout = $length if $paused; + + next unless $poll->poll($timeout) > 0; + next unless defined(my $key = ReadKey(-1)); + if ($key eq 'q') { + last; + } elsif ($key eq 'j') { + $start += 10; + $start = $now if $now - $start < 0; + } elsif ($key eq 'l') { + $start -= 10; + $start = $now if $now - $start < 0; + } elsif ($key eq 'k' || $key eq ' ') { + if ($paused) { + my $time = time; + $start += $time - $now; + $now = $time; + } + $paused = !$paused; + } +} + +END { + ReadMode 0; +} diff --git a/vendor/hare-vt b/vendor/hare-vt deleted file mode 160000 index c5bb9f7..0000000 --- a/vendor/hare-vt +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c5bb9f71bdfcc09b4d2fa422c49c42155ec135b9 diff --git a/vendor/hare-xml b/vendor/hare-xml deleted file mode 160000 index 71def5a..0000000 --- a/vendor/hare-xml +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 71def5afd20068c9508fc63626c974949f1b0d5c -- cgit v1.2.3