summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSam Nystrom <sam@samnystrom.dev>2023-08-16 16:26:05 -0400
committerSam Nystrom <sam@samnystrom.dev>2023-08-16 16:27:29 -0400
commit277fa45fd3d6bd80842660460ce978a01d286ced (patch)
tree601504ff93df4fe067cb94d67127e1f86ba09f1a
parent6b0441319f2ace9b473c75eecd9b809fb7cf57c6 (diff)
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.
-rw-r--r--.gitignore1
-rw-r--r--main.ha403
-rwxr-xr-xsrtplay157
m---------vendor/hare-vt0
m---------vendor/hare-xml0
5 files changed, 157 insertions, 404 deletions
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 <sam@samnystrom.dev>
-//
-// 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 <https://www.gnu.org/licenses/>.
-
-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",
- "<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 = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root>\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, "</root>")!;
- 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, "</root>");
- 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 . ' <file>';
+ 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('<root>' . $subtitle->{text} . '</root>');
+ $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
-Subproject c5bb9f71bdfcc09b4d2fa422c49c42155ec135b
diff --git a/vendor/hare-xml b/vendor/hare-xml
deleted file mode 160000
-Subproject 71def5afd20068c9508fc63626c974949f1b0d5