branch: elpa/subed commit d8bf4ed5a7f5a48d20116e46e1a5eff7265800f1 Author: Random User <rnd...@posteo.de> Commit: Random User <rnd...@posteo.de>
Initial commit --- README.org | 61 ++++++ subed/subed-config.el | 226 ++++++++++++++++++++++ subed/subed-mpv.el | 292 +++++++++++++++++++++++++++++ subed/subed-srt.el | 397 +++++++++++++++++++++++++++++++++++++++ subed/subed.el | 447 +++++++++++++++++++++++++++++++++++++++++++ tests/test-subed-mpv.el | 139 ++++++++++++++ tests/test-subed-srt.el | 489 ++++++++++++++++++++++++++++++++++++++++++++++++ tests/test-subed.el | 96 ++++++++++ 8 files changed, 2147 insertions(+) diff --git a/README.org b/README.org new file mode 100644 index 0000000..0c03b67 --- /dev/null +++ b/README.org @@ -0,0 +1,61 @@ +* subed +subed is an Emacs major mode for editing subtitles while playing the +corresponding video with [[https://mpv.io/][mpv]]. At the moment, the only supported format is +SubRip ( ~.srt~). + +subed is still alpha software. Expect it to kill your Emacs session. + +** Features + - Quickly jump to next (~M-n~) and previous (~M-p~) subtitle text. + - Quickly jump to the beginning (~C-M-a~) and end (~C-M-e~) of the current + subtitle's text. + - Insert (~M-i~) and kill (~M-k~) subtitles. + - Adjust subtitle start (~M-[~ / ~M-]~) and stop (~M-{~ / ~M-}~) time stamps. + - When a subtitle time is adjusted, jump to its start time in mpv (~C-x /~ to + toggle). + - Sort and re-number subtitles (~M-s~). + - Open videos with ~C-x C-v~ or automatically when entering subed-mode if the + video file is named like the subtitle file but with a video extension + (e.g. ~.mkv~ or ~.avi~). + - Every time you safe (~C-x s~), the subtitles automatically sorted and + re-numbered and then reloaded in mpv. + - Synchronize point and playback position: + - mpv jumps automatically to the position of the subtitle on point as point + moves between subtitles (~C-x ,~ to toggle). + - Point is automatically moved as the video is playing so that point is + always on the relevant subtitle (~C-x .~ to toggle). + - Loop over the subtitle on point in mpv (~C-x l~). + - Automatically pause or slow down playback in mpv while you are typing (~C-x + p~ to toggle). + +** Installation + For now, you have to install it manually. For example, copy ~subed/*.el~ to + ~~/.emacs.d/elisp/~ and add ~~/.emacs.d/elisp/~ to your ~load-path~. + + #+BEGIN_SRC elisp + (use-package subed + ;; Tell emacs where to find subed + :load-path "~/.emacs.d/elisp/" + :config + ;; Disable automatic movement of point + (add-hook 'subed-mode-hook 'subed-disable-sync-point-to-player) + ;; Break lines automatically while typing + (add-hook 'subed-mode-hook 'turn-on-auto-fill)) + ;; Break lines at 50 characters + (add-hook 'subed-mode-hook (lambda () (setq-local fill-column 50))) + #+END_SRC + +** License + subed 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 [[https://www.gnu.org/licenses/gpl-3.0.txt][GNU General Public License]] for more + details. + +#+STARTUP: showeverything +#+OPTIONS: num:nil +#+OPTIONS: ^:{} diff --git a/subed/subed-config.el b/subed/subed-config.el new file mode 100644 index 0000000..9b3ffe9 --- /dev/null +++ b/subed/subed-config.el @@ -0,0 +1,226 @@ +;;; subed-config.el --- Customization variables and hooks for subed + +;;; License: +;; +;; This file is not part of GNU Emacs. +;; +;; This 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, or (at your option) +;; any later version. +;; +;; This 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 GNU Emacs; see the file COPYING. If not, write to the +;; Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +;; Boston, MA 02110-1301, USA. + +;;; Code: + +;; Key bindings + +(defvar subed-mode-map + (let ((subed-mode-map (make-keymap))) + (define-key subed-mode-map (kbd "M-n") 'subed-forward-subtitle-text) + (define-key subed-mode-map (kbd "M-p") 'subed-backward-subtitle-text) + (define-key subed-mode-map (kbd "C-M-a") 'subed-move-to-subtitle-text) + (define-key subed-mode-map (kbd "C-M-e") 'subed-move-to-subtitle-end) + (define-key subed-mode-map (kbd "M-[") 'subed-decrease-start-time-100ms) + (define-key subed-mode-map (kbd "M-]") 'subed-increase-start-time-100ms) + (define-key subed-mode-map (kbd "M-{") 'subed-decrease-stop-time-100ms) + (define-key subed-mode-map (kbd "M-}") 'subed-increase-stop-time-100ms) + (define-key subed-mode-map (kbd "M-i") 'subed-subtitle-insert) + (define-key subed-mode-map (kbd "M-k") 'subed-subtitle-kill) + (define-key subed-mode-map (kbd "M-s") 'subed-sort) + (define-key subed-mode-map (kbd "C-x C-v") 'subed-mpv-find-video) + (define-key subed-mode-map (kbd "M-SPC") 'subed-mpv-toggle-pause) + (define-key subed-mode-map (kbd "C-x .") 'subed-toggle-sync-point-to-player) + (define-key subed-mode-map (kbd "C-x ,") 'subed-toggle-sync-player-to-point) + (define-key subed-mode-map (kbd "C-x p") 'subed-toggle-pause-while-typing) + (define-key subed-mode-map (kbd "C-x l") 'subed-toggle-subtitle-loop) + (define-key subed-mode-map (kbd "C-x /") 'subed-toggle-replay-adjusted-subtitle) + ;; (define-key subed-mode-map (kbd "C-x [") 'subed-copy-subtitle-start-time) + ;; (define-key subed-mode-map (kbd "C-x ]") 'subed-copy-subtitle-stop-time) + (define-key subed-mode-map (kbd "C-x d") 'subed-toggle-debugging) + subed-mode-map) + "Keymap for subed-mode") + + +;; Syntax highlighting + +(defface subed-srt-id-face + '((t (:foreground "sandybrown"))) + "Each subtitle's consecutive number") + +(defface subed-srt-time-face + '((t (:foreground "skyblue"))) + "Start and stop times of subtitles") + +(defface subed-srt-time-separator-face + '((t (:foreground "dimgray"))) + "Separator between the start and stop time (\" --> \")") + +(defface subed-srt-text-face + '((t (:foreground "brightyellow"))) + "Text of the subtitle") + + +;; Variables + +(defgroup subed nil + "Major mode for editing subtitles." + :group 'languages + :group 'hypermedia + :prefix "subed-") + +(defvar-local subed--debug-enabled nil + "Whether `subed-debug' prints to `subed-debugging-buffer'.") + +(defcustom subed-debug-buffer "*subed-debug*" + "Name of the buffer that contains debugging messages." + :type 'string + :group 'subed) + +(defcustom subed-mode-hook nil + "Functions to call when entering subed mode." + :type 'hook + :group 'subed) + +(defcustom subed-video-extensions '("mkv" "mp4" "webm" "avi" "ts") + "Video file name extensions." + :type 'list + :group 'subed) + +(defcustom subed-auto-find-video t + "Whether to open the video automatically when opening a subtitle file. +The corresponding video is found by replacing the file extension +of `buffer-file-name' with those in `subed-video-extensions'. +The first existing file is then passed to `subed-open-video'." + :type 'boolean + :group 'subed) + + +(defvar subed-subtitle-time-adjusted-hook () + "Functions to call when a subtitle's start or stop time has +changed.") + + +(defcustom subed-playback-speed-while-typing 0.3 + "Video playback speed while the user is editing the buffer. If +set to zero or smaller, playback is paused." + :type 'float + :group 'subed) + +(defcustom subed-playback-speed-while-not-typing 1.0 + "Video playback speed while the user is not editing the +buffer." + :type 'float + :group 'subed) + +(defcustom subed-unpause-after-typing-delay 1.0 + "Number of seconds to wait after typing stopped before +unpausing the player." + :type 'float + :group 'subed) + +(defvar-local subed--unpause-after-typing-timer nil + "Timer that waits before unpausing the player after the user +typed something.") + +(defvar-local subed--player-is-auto-paused nil + "Whether the player was paused by the user or automatically.") + + +(defcustom subed-loop-seconds-before 0 + "When looping over a single subtitle, start the loop this many +seconds before the subtitle starts." + :type 'float + :group 'subed) + +(defcustom subed-loop-seconds-after 0 + "When looping over a single subtitle, end the loop this many +seconds after the subtitle stop." + :type 'float + :group 'subed) + +(defvar-local subed--subtitle-loop-start nil + "Start position of loop in player in milliseconds.") + +(defvar-local subed--subtitle-loop-stop nil + "Stop position of loop in player in milliseconds.") + + +(defcustom subed-point-sync-delay-after-motion 1.0 + "Number of seconds the player can't adjust point after point +was moved by the user." + :type 'float + :group 'subed) + +(defvar-local subed--point-sync-delay-after-motion-timer nil + "Timer that waits before re-adding +`subed--sync-point-to-player' after temporarily removing it.") + +(defvar-local subed--point-was-synced nil + "When temporarily disabling point-to-player sync, this variable +remembers whether it was originally enabled by the user.") + + +(defcustom subed-mpv-socket "/tmp/mpv-socket" + "Path to Unix IPC socket that is passed to mpv --input-ipc-server." + :type 'file + :group 'subed) + +(defcustom subed-mpv-executable "mpv" + "Path or filename of mpv executable." + :type 'file + :group 'subed) + +(defcustom subed-mpv-arguments '("--osd-level" "2" "--osd-fractions") + "Additional arguments for \"mpv\". +The options --input-ipc-server=SRTEDIT-MPV-SOCKET and --idle are +hardcoded." + :type '(repeat string) + :group 'subed) + + +;; Hooks + +(defvar-local subed-point-motion-hook nil + "Functions to call after point changed.") + +(defvar-local subed-subtitle-motion-hook nil + "Functions to call after current subtitle changed.") + +(defvar-local subed--status-point 1 + "Keeps track of `(point)' to detect changes.") + +(defvar-local subed--status-subtitle-id 1 + "Keeps track of `(subed--subtitle-id)' to detect changes.") + +(defun subed--post-command-handler () + "Detect point motion and user entering text and signal hooks." + ;; Check for point motion first; skip checking for other changes if it didn't + (let ((new-point (point))) + (when (and new-point subed--status-point + (not (= new-point subed--status-point))) + + ;; If point is synced to playback position, temporarily disable that to + ;; prevent race conditions that make the cursor doesn't move unexpectedly. + (subed-disable-sync-point-to-player-temporarily) + + (setq subed--status-point new-point) + ;; Signal point motion + (run-hooks 'subed-point-motion-hook) + (let ((new-sub-id (subed--subtitle-id))) + (when (and new-sub-id subed--status-subtitle-id + (not (= subed--status-subtitle-id new-sub-id))) + (setq subed--status-subtitle-id new-sub-id) + ;; Signal motion between subtitles + (run-hooks 'subed-subtitle-motion-hook)))))) + +(provide 'subed-config) +;;; subed-config.el ends here diff --git a/subed/subed-mpv.el b/subed/subed-mpv.el new file mode 100644 index 0000000..e1aab24 --- /dev/null +++ b/subed/subed-mpv.el @@ -0,0 +1,292 @@ +;;; subed-mpv.el --- mpv integration for subed + +;;; License: +;; +;; This file is not part of GNU Emacs. +;; +;; This 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, or (at your option) +;; any later version. +;; +;; This 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 GNU Emacs; see the file COPYING. If not, write to the +;; Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +;; Boston, MA 02110-1301, USA. + +;;; Commentary: +;; +;; Based on: +;; https://github.com/mk-fg/emacs-setup/blob/master/extz/emms-player-mpv.el + +;;; Code: + +(require 'json) + +(defvar-local subed-mpv-is-playing nil + "Whether mpv is currently playing or paused.") + +(defvar-local subed-mpv-playback-speed nil + "How fast mpv is playing the video. +1.0 is normal speed, 0.5 is half speed, etc.") + +(defvar-local subed-mpv-playback-position nil + "Current playback position in milliseconds.") + +(defvar-local subed-mpv-playback-position-hook nil + "Functions to call when mpv changes playback position.") + +(defvar-local subed-mpv--server-proc nil + "Running mpv process.") + +(defvar-local subed-mpv--client-proc nil + "IPC socket process that communicates over `subed-mpv-socket'.") + +(defvar-local subed-mpv--client-buffer " *subed-mpv-buffer*" + "Buffer for JSON responses from server.") + +(defconst subed-mpv--client-test-request + (json-encode (list :command '(get_property mpv-version))) + "Request as a string to send to check whether IPC connection is working.") + +(defconst subed-mpv--retry-delays + ;; Sums up to 5 seconds in total before failing + '(0.1 0.1 0.1 0.1 0.2 0.2 0.3 0.4 0.5 0.5 0.5 0.5 0.5 0.5 0.5) + "List of delays between attemps to connect to `subed-mpv-socket'.") + +(defvar-local subed-mpv--client-command-queue nil + "Commands to call when connection to `subed-mpv-socket' is established.") + + +;;; Server (mpv process that provides an IPC socket) + +(defun subed-mpv--server-start (&rest args) + "Run mpv in JSON IPC mode." + (subed-mpv--server-stop) + (let ((argv (append (list subed-mpv-executable + (format "--input-ipc-server=%s" subed-mpv-socket) + "--idle") + args))) + (subed-debug "Running %s" argv) + (condition-case err + (setq subed-mpv--server-proc (make-process :command argv + :name "subed-mpv-server" + :buffer nil + :noquery t)) + (error + (error "%s" (mapconcat 'identity (cdr (cdr err)) ": ")))))) + +(defun subed-mpv--server-stop () + "Kill a running mpv process." + (when (and subed-mpv--server-proc (process-live-p subed-mpv--server-proc)) + (delete-process subed-mpv--server-proc) + (subed-debug "Killed mpv process")) + (setq subed-mpv--server-proc nil)) + +(defun subed-mpv--server-started-p () + "Whether `subed-mpv--server-proc' is a running process." + (if subed-mpv--server-proc t nil)) + + +;;; Client (elisp process that connects to server's IPC socket) + +(defun subed-mpv--client-connect (delays) + "Try to connect to `subed-mpv-socket'. +If a connection attempt fails, wait (car delays) seconds and try +again, passing (cdr delays)." + (subed-debug "Attempting to connect to IPC socket: %s" subed-mpv-socket) + (subed-mpv--client-disconnect) + ;; NOTE: make-network-process doesn't fail when the socket file doesn't exist + (let ((proc (make-network-process :name "subed-mpv-client" + :family 'local + :service subed-mpv-socket + :coding '(utf-8 . utf-8) + :buffer (get-buffer-create subed-mpv--client-buffer) + :filter #'subed-mpv--client-filter + :noquery t + :nowait t))) + ;; Test connection by sending a test request + (condition-case err + (progn + (process-send-string proc (concat subed-mpv--client-test-request "\n")) + (subed-debug "Connected to %s (%s)" proc (process-status proc)) + (setq subed-mpv--client-proc proc)) + (error + (if delays + (progn + (subed-debug "Failed to connect (trying again in %s seconds)" (car delays)) + (run-at-time (car delays) nil #'subed-mpv--client-connect (cdr delays))) + (progn + (subed-debug "Connection failed: %s" err)))))) + ;; Run commands that were sent while the connection wasn't up yet + (when (subed-mpv--client-connected-p) + (while subed-mpv--client-command-queue + (let ((cmd (pop subed-mpv--client-command-queue))) + (subed-debug "Running queued command: %s" cmd) + (apply 'subed-mpv--client-send (list cmd)))))) + +(defun subed-mpv--client-disconnect () + "Close connection to mpv process, if there is one." + (when (subed-mpv--client-connected-p) + (delete-process subed-mpv--client-proc) + (subed-debug "Closed connection to mpv process")) + (setq subed-mpv--client-proc nil + subed-mpv-is-playing nil + subed-mpv-playback-position nil)) + +(defun subed-mpv--client-connected-p () + "Whether the server connection has been established and tested successfully." + (if subed-mpv--client-proc t nil)) + +(defun subed-mpv--client-send (cmd) + "Send JSON IPC command. +If we're not connected yet but the server has been started, add +CMD to `subed-mpv--client-command-queue' which is evaluated by +`subed-mpv--client-connect' when the connection is up." + (if (subed-mpv--client-connected-p) + (let ((request-data (concat (json-encode (list :command cmd))))) + (subed-debug "Sending request: %s" request-data) + (condition-case err + (process-send-string subed-mpv--client-proc (concat request-data "\n")) + (error + (subed-mpv-kill) + (error "Unable to send commands via %s: %s" subed-mpv-socket (cdr err)))) + t) + (when (subed-mpv--server-started-p) + (subed-debug "Queueing command: %s" cmd) + (setq subed-mpv--client-command-queue (append subed-mpv--client-command-queue (list cmd))) + t))) + +(defun subed-mpv--client-filter (proc response) + "Handle response from the server." + ;; JSON-STRING contains zero or more lines with JSON encoded objects, e.g.: + ;; {"data":"mpv 0.29.1","error":"success"} + ;; {"data":null,"request_id":1,"error":"success"} + ;; {"event":"start-file"}{"event":"tracks-changed"} + ;; JSON-STRING cal also contain incomplete JSON at the end, + ;; e.g. `{"error":"succ'. Therefore we maintain a buffer and process only + ;; complete lines. + (when (buffer-live-p (process-buffer proc)) + (let ((orig-buffer (current-buffer))) + (with-current-buffer (process-buffer proc) + ;; Insert new response where previous response ended + (let ((moving (= (point) (process-mark proc)))) + (save-excursion + (goto-char (process-mark proc)) + (insert response) + (set-marker (process-mark proc) (point))) + (if moving (goto-char (process-mark proc)))) + ;; Process and remove all complete lines of JSON + (let ((p0 (point-min))) + (while (progn (goto-char p0) + (end-of-line) + (equal (following-char) ?\n)) + (let* ((p1 (point)) + (line (buffer-substring p0 p1))) + (delete-region p0 (+ p1 1)) + ;; Return context to the subtitle file buffer because we're using + ;; buffer-local variables to store player state. + (with-current-buffer orig-buffer + (subed-mpv--client-handle-json line))))))))) + +(defun subed-mpv--client-handle-json (json-string) + "Process a single JSON object from the server." + (let* ((json-data (condition-case err + (json-read-from-string json-string) + (error + (subed-debug "Unable to parse JSON response:\n%S" json-string)))) + (event (alist-get 'event json-data))) + (when event + (subed-mpv--handle-event json-data)))) + +(defun subed-mpv--handle-event (json-data) + "Handler for relevant mpv events. +See \"List of events\" in mpv(1)." + (let ((event (alist-get 'event json-data))) + (pcase event + ("property-change" + (when (string= (alist-get 'name json-data) "time-pos") + (let ((pos-msecs (* 1000 (alist-get 'data json-data)))) + (setq subed-mpv-playback-position pos-msecs) + (run-hook-with-args 'subed-mpv-playback-position-hook pos-msecs)))) + ((or "unpause" "file-loaded") + (setq subed-mpv-is-playing t) + (subed-debug "Playing status changed: playing=%s" subed-mpv-is-playing)) + ((or "pause" "end-file" "shutdown" "idle") + (setq subed-mpv-is-playing nil) + (subed-debug "Playing status changed: playing=%s" subed-mpv-is-playing))))) + + +;;; High-level functions + +(defun subed-mpv-pause () + "Stop playback." + (interactive) + (when (eq subed-mpv-is-playing t) + (when (subed-mpv--client-send `(set_property pause yes)) + (subed-mpv--handle-event '((event . "pause")))))) + +(defun subed-mpv-unpause () + "Start playback." + (interactive) + (when (eq subed-mpv-is-playing nil) + (when (subed-mpv--client-send `(set_property pause no)) + (subed-mpv--handle-event '((event . "unpause")))))) + +(defun subed-mpv-toggle-pause () + "Start or stop playback." + (interactive) + (if subed-mpv-is-playing (subed-mpv-pause) (subed-mpv-unpause))) + +(defun subed-mpv-playback-speed (factor) + "Play video slower (FACTOR < 1) or faster (FACTOR > 1)." + (interactive) + (when (not (eq subed-mpv-playback-speed factor)) + (when (subed-mpv--client-send `(set_property speed ,factor)) + (setq subed-mpv-playback-speed factor)))) + +(defun subed-mpv-seek (msec) + "Move playback position SEC seconds relative to current position." + (subed-mpv--client-send `(seek ,(/ msec 1000.0) relative+exact))) + +(defun subed-mpv-jump (msec) + "Move playback position to absolute position SEC seconds." + (subed-mpv--client-send `(seek ,(/ msec 1000.0) absolute+exact))) + +(defun subed-mpv-reload-subtitles () + "Reload subtitle file from disk." + (subed-mpv--client-send '(sub-reload))) + +(defun subed-mpv--is-video-file-p (filename) + "Return whether FILENAME is a video file or directory." + (and (not (or (string= filename ".") (string= filename ".."))) + (let ((filepath (expand-file-name filename))) + (or (file-directory-p filepath) + (member (file-name-extension filename) subed-video-extensions))))) + +(defun subed-mpv-find-video (file) + "Open video file in mpv. +Video files are expected to have any of the extensions listed in +`subed-video-extensions'." + (interactive (list (read-file-name "Find video: " nil nil t nil 'subed-mpv--is-video-file-p))) + (let ((filepath (expand-file-name file))) + (when (apply 'subed-mpv--server-start subed-mpv-arguments) + (subed-debug "Opening video file: %s" filepath) + (subed-mpv--client-connect subed-mpv--retry-delays) + (subed-mpv--client-send `(loadfile ,filepath replace)) + (subed-mpv--client-send `(sub-add ,(buffer-file-name) select)) + (subed-mpv--client-send `(observe_property 1 time-pos)) + (subed-mpv-playback-speed subed-playback-speed-while-not-typing)))) + +(defun subed-mpv-kill () + "Close connection to mpv process and kill the process." + (subed-mpv--client-disconnect) + (subed-mpv--server-stop)) + +(provide 'subed-mpv) +;;; subed-mpv.el ends here diff --git a/subed/subed-srt.el b/subed/subed-srt.el new file mode 100644 index 0000000..272750f --- /dev/null +++ b/subed/subed-srt.el @@ -0,0 +1,397 @@ +;;; subed-srt.el --- SubRip/srt implementation for subed + +;;; License: +;; +;; This file is not part of GNU Emacs. +;; +;; This 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, or (at your option) +;; any later version. +;; +;; This 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 GNU Emacs; see the file COPYING. If not, write to the +;; Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +;; Boston, MA 02110-1301, USA. + +;;; Code: + +;;; Syntax highlighting + +(defconst subed-srt-font-lock-keywords + (list + '("^[0-9]+$" . 'subed-srt-id-face) + '("[0-9]+:[0-9]+:[0-9]+,[0-9]+" . 'subed-srt-time-face) + '(",[0-9]+ \\(-->\\) [0-9]+:" 1 'subed-srt-time-separator-face t) + '("^.*$" . 'subed-srt-text-face)) + "Highlighting expressions for subed-mode") + + +;;; Parsing + +(defconst subed-srt--regexp-timestamp "\\([0-9]+\\):\\([0-9]+\\):\\([0-9]+\\),\\([0-9]+\\)") +(defconst subed-srt--regexp-duration (concat subed-srt--regexp-timestamp "[ ]+\\(-->\\)[ ]+" + subed-srt--regexp-timestamp)) +(defconst subed-srt--regexp-separator "\\([[:blank:]]*\n\\)+[[:blank:]]*\n") +(defconst subed-srt--length-timestamp 12) ;; String length of "01:45:32,091" + +(defun subed-srt--timestamp-to-msecs (time-string) + "Find HH:MM:SS,MS pattern in TIME-STRING and convert it to milliseconds. +Return nil if TIME-STRING doesn't match the pattern." + (when (string-match subed-srt--regexp-timestamp time-string) + (let ((hours (string-to-number (match-string 1 time-string))) + (mins (string-to-number (match-string 2 time-string))) + (secs (string-to-number (match-string 3 time-string))) + (msecs (string-to-number (match-string 4 time-string)))) + (+ (* (truncate hours) 3600000) + (* (truncate mins) 60000) + (* (truncate secs) 1000) + (truncate msecs))))) + +(defun subed-srt--msecs-to-timestamp (msecs) + "Convert MSECS to string in the format HH:MM:SS,MS." + (concat (format-seconds "%02h:%02m:%02s" (/ msecs 1000)) + "," (format "%03d" (mod msecs 1000)))) + +(defun subed-srt--subtitle-id () + "Return the ID of subtitle at point or nil if there is no ID." + (save-excursion + (when (subed-srt-move-to-subtitle-id) + (string-to-number (current-word))))) + +(defun subed-srt--subtitle-id-at-msecs (msecs) + "Return the ID of the subtitle at MSECS milliseconds. +If MSECS is between subtitles, return the subtitle that starts +after MSECS if there is one and its start time is >= MSECS + +1000. Otherwise return the closest subtitle before MSECS." + (save-excursion + (goto-char (point-min)) + (let* ((secs (/ msecs 1000)) + (only-hours (truncate (/ secs 3600))) + (only-mins (truncate (/ (- secs (* only-hours 3600)) 60)))) + ;; Move to first subtitle in the relevant hour + (when (re-search-forward (format "\\(\n\n\\|\\`\\)[0-9]+\n%02d:" only-hours) nil t) + (beginning-of-line) + ;; Move to first subtitle in the relevant hour and minute + (re-search-forward (format "\\(\n\n\\|\\`\\)[0-9]+\n%02d:%02d" only-hours only-mins) nil t))) + ;; Move to first subtitle that starts at or after MSECS + (catch 'last-subtitle-reached + (while (<= (subed-srt--subtitle-msecs-start) msecs) + (unless (subed-srt-forward-subtitle-id) + (throw 'last-subtitle-reached nil)))) + ;; Move back to previous subtitle if start of current subtitle is in the + ;; future (i.e. MSECS is between subtitles) + (when (> (subed-srt--subtitle-msecs-start) msecs) + (subed-srt-backward-subtitle-id)) + (subed-srt--subtitle-id))) + +(defun subed-srt--subtitle-msecs-start (&optional sub-id) + "Subtitle start time in milliseconds." + (let ((timestamp (save-excursion + (subed-srt-move-to-subtitle-time-start sub-id) + (buffer-substring (point) (+ (point) subed-srt--length-timestamp))))) + (subed-srt--timestamp-to-msecs timestamp))) + +(defun subed-srt--subtitle-msecs-stop (&optional sub-id) + "Subtitle stop time in milliseconds." + (let ((timestamp (save-excursion + (subed-srt-move-to-subtitle-time-stop sub-id) + (buffer-substring (point) (+ (point) subed-srt--length-timestamp))))) + (subed-srt--timestamp-to-msecs timestamp))) + +(defun subed-srt--subtitle-relative-point () + "Point relative to subtitle's ID, i.e. point within subtitle." + (let ((start-point (save-excursion + (progn (subed-srt-move-to-subtitle-id) + (point))))) + (- (point) start-point))) + + +;;; Traversing + +(defun subed-srt-move-to-subtitle-id (&optional sub-id) + "Move to the ID of a subtitle and return point. +If SUBTITLE-ID is not given, focus the current subtitle's ID. +Return point or nil if no subtitle ID could be found." + (interactive) + (if sub-id + (progn + ;; Start on the first ID and search forward for a line that contains + ;; only the ID, preceded by one or more blank lines. + (save-excursion + (goto-char (point-min)) + (setq regex (format "\\(\\([[:blank:]]*\n\\)+[[:blank:]]*\n\\|\\`\\)%d$" sub-id)) + (setq match-found (re-search-forward regex nil t))) + (when match-found + (goto-char (match-end 0)) + (beginning-of-line) + (point))) + (progn + ;; Find one or more blank lines. + (re-search-forward "\\([[:blank:]]*\n\\)+" nil t) + ;; Find two or more blank lines or the beginning of the buffer, followed + ;; by line composed of only digits. + (re-search-backward (concat "\\(" subed-srt--regexp-separator "\\|\\`\\)[0-9]+$") nil t) + (goto-char (match-end 0)) + (beginning-of-line) + (when (looking-at "^\\([0-9]+\\)$") + (point))))) + +(defun subed-srt-move-to-subtitle-time-start (&optional sub-id) + "Move point to subtitle's start time. +Return point or nil if no start time could be found." + (interactive) + (when (subed-srt-move-to-subtitle-id sub-id) + (forward-line) + (when (looking-at subed-srt--regexp-timestamp) + (point)))) + +(defun subed-srt-move-to-subtitle-time-stop (&optional sub-id) + "Move point to subtitle's stop time. +Return point or nil if no stop time could be found." + (interactive) + (when (subed-srt-move-to-subtitle-id sub-id) + (search-forward " --> " nil t) + (when (looking-at subed-srt--regexp-timestamp) + (point)))) + +(defun subed-srt-move-to-subtitle-text (&optional sub-id) + "Move point on the first character of subtitle's text. +Return point." + (interactive) + (when (subed-srt-move-to-subtitle-id sub-id) + (forward-line 2) + (point))) + +(defun subed-srt-move-to-subtitle-id-at-msecs (msecs) + "Move point to the ID of the subtitle that is playing at MSECS. +Return point or nil if point is still on the same subtitle. +See also `subed-srt--subtitle-id-at-msecs'." + (let ((current-sub-id (subed-srt--subtitle-id)) + (target-sub-id (subed-srt--subtitle-id-at-msecs msecs))) + (when (and target-sub-id current-sub-id (not (= target-sub-id current-sub-id))) + (subed-srt-move-to-subtitle-id target-sub-id)))) + +(defun subed-srt-move-to-subtitle-text-at-msecs (msecs) + "Move point to the text of the subtitle that is playing at MSECS. +Return point or nil if point is still on the same subtitle. +See also `subed-srt--subtitle-id-at-msecs'." + (when (subed-srt-move-to-subtitle-id-at-msecs msecs) + (subed-srt-move-to-subtitle-text))) + +(defun subed-srt-move-to-subtitle-end (&optional sub-id) + "Move point after the last character of the subtitle's text. +Return point unless point did not change." + (interactive) + (when (not (looking-at "\\([[:blank:]]*\n\\)*\\'")) + (subed-srt-move-to-subtitle-text sub-id) + (re-search-forward (concat "\\(" subed-srt--regexp-separator "\\|\\([[:blank:]]*\n\\)+\\'\\)") nil t) + (goto-char (match-beginning 0)))) + +(defun subed-srt-forward-subtitle-id () + "Move point to next subtitle's ID. +Return new point or nil if point didn't change (e.g. if called on +the last subtitle)." + (interactive) + (when (re-search-forward (concat subed-srt--regexp-separator "[[:alnum:]]") nil t) + (subed-srt-move-to-subtitle-id))) + +(defun subed-srt-backward-subtitle-id () + "Move point to previous subtitle's ID. +Return new point or nil if point didn't change (e.g. if called on +the first subtitle)." + (interactive) + (when (re-search-backward subed-srt--regexp-separator nil t) + (subed-srt-move-to-subtitle-id))) + +(defun subed-srt-forward-subtitle-text () + "Move point to next subtitle's text. +Return new point" + (interactive) + (subed-srt-forward-subtitle-id) + (subed-srt-move-to-subtitle-text)) + +(defun subed-srt-backward-subtitle-text () + "Move point to previous subtitle's text" + (interactive) + (subed-srt-backward-subtitle-id) + (subed-srt-move-to-subtitle-text)) + +(defun subed-srt-forward-subtitle-time-start () + "Move point to next subtitle's start time." + (interactive) + (subed-srt-forward-subtitle-id) + (subed-srt-move-to-subtitle-time-start)) + +(defun subed-srt-backward-subtitle-time-start () + "Move point to previous subtitle's start time." + (interactive) + (subed-srt-backward-subtitle-id) + (subed-srt-move-to-subtitle-time-start)) + +(defun subed-srt-forward-subtitle-time-stop () + "Move point to next subtitle's stop time." + (interactive) + (subed-srt-forward-subtitle-id) + (subed-srt-move-to-subtitle-time-stop)) + +(defun subed-srt-backward-subtitle-time-stop () + "Move point to previous subtitle's stop time." + (interactive) + (subed-srt-backward-subtitle-id) + (subed-srt-move-to-subtitle-time-stop)) + + +;;; Manipulation + +(defun subed-srt--adjust-subtitle-start-relative (msecs) + "Add MSECS milliseconds to start time (use negative value to subtract)." + (let ((msecs-new (+ (subed-srt--subtitle-msecs-start) msecs))) + (save-excursion + (subed-srt-move-to-subtitle-time-start) + (delete-region (point) (+ (point) subed-srt--length-timestamp)) + (insert (subed-srt--msecs-to-timestamp msecs-new))) + (when subed-subtitle-time-adjusted-hook + (let ((sub-id (subed-srt--subtitle-id))) + (run-hook-with-args 'subed-subtitle-time-adjusted-hook sub-id msecs-new))))) + +(defun subed-srt--adjust-subtitle-stop-relative (msecs) + "Add MSECS milliseconds to stop time (use negative value to subtract)." + (let ((msecs-new (+ (subed-srt--subtitle-msecs-stop) msecs))) + (save-excursion + (subed-srt-move-to-subtitle-time-stop) + (delete-region (point) (+ (point) subed-srt--length-timestamp)) + (insert (subed-srt--msecs-to-timestamp msecs-new))) + (when subed-subtitle-time-adjusted-hook + (let ((sub-id (subed-srt--subtitle-id))) + (run-hook-with-args 'subed-subtitle-time-adjusted-hook sub-id msecs-new))))) + +(defun subed-srt-increase-start-time-100ms () + "Add 100 milliseconds to start time of current subtitle." + (interactive) + (subed-srt--adjust-subtitle-start-relative 100)) + +(defun subed-srt-decrease-start-time-100ms () + "Subtract 100 milliseconds from start time of current subtitle." + (interactive) + (subed-srt--adjust-subtitle-start-relative -100)) + +(defun subed-srt-increase-stop-time-100ms () + "Add 100 milliseconds to stop time of current subtitle." + (interactive) + (subed-srt--adjust-subtitle-stop-relative 100)) + +(defun subed-srt-decrease-stop-time-100ms () + "Subtract 100 milliseconds from stop time of current subtitle." + (interactive) + (subed-srt--adjust-subtitle-stop-relative -100)) + +;; TODO: Write tests +;; TODO: Implement support for prefix argument to +;; - insert n subtitles with C-u n M-i. +;; - insert 1 subtitle before the current one with C-u M-i. +(defun subed-srt-subtitle-insert () + "Insert a subtitle after the current." + (interactive) + (let ((start-time (+ (subed-srt--subtitle-msecs-stop) 100)) + (stop-time (- (save-excursion + (subed-srt-forward-subtitle-id) + (subed-srt--subtitle-msecs-start)) 100))) + (subed-srt-forward-subtitle-id) + (insert (format "1\n%s --> %s\n\n\n" + (subed-srt--msecs-to-timestamp start-time) + (subed-srt--msecs-to-timestamp stop-time)))) + (previous-line 2)) + +;; TODO: Implement support for prefix argument to +;; kill n subtitles with C-u n M-k. +(defun subed-srt-subtitle-kill () + "Remove subtitle at point." + (interactive) + (let ((beg (save-excursion + (subed-srt-move-to-subtitle-id) + (point))) + (end (save-excursion + (subed-srt-move-to-subtitle-id) + (when (subed-srt-forward-subtitle-id) + (point))))) + (if (not end) + (progn + (let ((beg (save-excursion + (goto-char beg) + (subed-srt-backward-subtitle-text) + (subed-srt-move-to-subtitle-end) + (1+ (point)))) + (end (save-excursion + (goto-char (point-max))))) + (delete-region beg end))) + (progn + (delete-region beg end))))) + + +;;; Maintenance + +(defun subed-srt--regenerate-ids () + "Ensure subtitle IDs start at 1 and are incremented by 1 for +each subtitle." + (save-excursion + (goto-char (point-min)) + (let ((id 1)) + (while (looking-at "^[0-9]+$") + (kill-word 1) + (insert (format "%d" id)) + (setq id (1+ id)) + (subed-srt-forward-subtitle-id))))) + +(defun subed-srt-sanitize () + "Remove surplus newlines and whitespace" + (interactive) + (subed--save-excursion + ;; Remove trailing whitespace from lines and empty lines from end of buffer + (delete-trailing-whitespace (point-min) nil) + + ;; Remove leading whitespace lines + (goto-char (point-min)) + (while (re-search-forward "^[[:blank:]]+" nil t) + (replace-match "")) + + ;; Remove excessive newlines between subtitles + (goto-char (point-min)) + (while (re-search-forward subed-srt--regexp-separator nil t) + (replace-match "\n\n")) + + ;; Remove any newlines from beginning of buffer + (goto-char (point-min)) + (while (re-search-forward "\\`\n+" nil t) + (replace-match "")) + + ;; Ensure single newline at end of buffer + (goto-char (point-max)) + (when (not (looking-back "\n")) + (insert "\n")) + )) + +(defun subed-srt-sort () + "Sanitize, then sort subtitles by start time and re-number them." + (interactive) + (subed-srt-sanitize) + (subed--save-excursion + (goto-char (point-min)) + (sort-subr nil + ;; nextrecfun (move to next record/subtitle or to end-of-buffer + ;; if there are no more records) + (lambda () (unless (subed-srt-forward-subtitle-id) + (goto-char (point-max)))) + ;; endrecfun (move to end of current record/subtitle) + 'subed-srt-move-to-subtitle-end + ;; startkeyfun (return sort value of current record/subtitle) + 'subed-srt--subtitle-msecs-start)) + (subed-srt--regenerate-ids)) + +(provide 'subed-srt) +;;; subed-srt.el ends here diff --git a/subed/subed.el b/subed/subed.el new file mode 100644 index 0000000..e14c1e4 --- /dev/null +++ b/subed/subed.el @@ -0,0 +1,447 @@ +;;; subed.el --- A major mode for editing SubRip (srt) subtitles + +;;; License: +;; +;; This file is not part of GNU Emacs. +;; +;; This 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, or (at your option) +;; any later version. +;; +;; This 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 GNU Emacs; see the file COPYING. If not, write to the +;; Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +;; Boston, MA 02110-1301, USA. +;; +;; +;; See README.org or https://github.com/rndusr/subed for more information. +;; +;; +;;; Code: + +(add-to-list 'auto-mode-alist '("\\.srt$" . subed-mode)) + +(require 'subed-config) +(require 'subed-srt) +(require 'subed-mpv) + +;; Abstraction layer to allow support for other subtitle formats +(set 'subed-font-lock-keywords 'subed-srt-font-lock-keywords) + +(fset 'subed--subtitle-id 'subed-srt--subtitle-id) +(fset 'subed--subtitle-msecs-start 'subed-srt--subtitle-msecs-start) +(fset 'subed--subtitle-msecs-stop 'subed-srt--subtitle-msecs-stop) +(fset 'subed--subtitle-relative-point 'subed-srt--subtitle-relative-point) + +(fset 'subed-move-to-subtitle-id 'subed-srt-move-to-subtitle-id) +(fset 'subed-move-to-subtitle-text-at-msecs 'subed-srt-move-to-subtitle-text-at-msecs) +(fset 'subed-move-to-subtitle-text 'subed-srt-move-to-subtitle-text) +(fset 'subed-move-to-subtitle-end 'subed-srt-move-to-subtitle-end) + +(fset 'subed-forward-subtitle-id 'subed-srt-forward-subtitle-id) +(fset 'subed-backward-subtitle-id 'subed-srt-backward-subtitle-id) +(fset 'subed-forward-subtitle-text 'subed-srt-forward-subtitle-text) +(fset 'subed-backward-subtitle-text 'subed-srt-backward-subtitle-text) +(fset 'subed-forward-subtitle-time-start 'subed-srt-forward-subtitle-time-start) +(fset 'subed-backward-subtitle-time-start 'subed-srt-backward-subtitle-time-start) +(fset 'subed-forward-subtitle-time-stop 'subed-srt-forward-subtitle-time-stop) +(fset 'subed-backward-subtitle-time-stop 'subed-srt-backward-subtitle-time-stop) + +(fset 'subed-increase-start-time-100ms 'subed-srt-increase-start-time-100ms) +(fset 'subed-decrease-start-time-100ms 'subed-srt-decrease-start-time-100ms) +(fset 'subed-increase-stop-time-100ms 'subed-srt-increase-stop-time-100ms) +(fset 'subed-decrease-stop-time-100ms 'subed-srt-decrease-stop-time-100ms) + +(fset 'subed-subtitle-insert 'subed-srt-subtitle-insert) +(fset 'subed-subtitle-kill 'subed-srt-subtitle-kill) +(fset 'subed-sanitize 'subed-srt-sanitize) +(fset 'subed-sort 'subed-srt-sort) + + +;;; Debugging + +(defun subed-enable-debugging () + "Hide debugging messages and set `debug-on-error' to `nil'." + (interactive) + (unless subed--debug-enabled + (setq subed--debug-enabled t + debug-on-error t) + (let ((debug-buffer (get-buffer-create subed-debug-buffer)) + (debug-window (split-window-right 50))) + (set-window-buffer debug-window debug-buffer) + (with-current-buffer debug-buffer + (buffer-disable-undo) + (setq-local buffer-read-only t))) + (add-hook 'kill-buffer-hook (lambda () + (kill-buffer subed-debug-buffer) + (delete-window (get-buffer-window subed-debug-buffer))) + :append :local) + (message "Enabled debugging messages"))) + +(defun subed-disable-debugging () + "Display debugging messages in separate window and set +`debug-on-error' to `t'." + (interactive) + (when subed--debug-enabled + (setq subed--debug-enabled nil + debug-on-error nil) + (delete-window (get-buffer-window subed-debug-buffer)) + (message "Disabled debugging messages"))) + +(defun subed-toggle-debugging () + "Display or hide debugging messages in separate window and set +`debug-on-error' to `t' or `nil'." + (interactive) + (if subed--debug-enabled + (subed-disable-debugging) + (subed-enable-debugging))) + +(defun subed-debug (format-string &rest args) + "Display message in debugging buffer if debugging is enabled." + (when subed--debug-enabled + (with-current-buffer (get-buffer-create subed-debug-buffer) + (setq-local buffer-read-only nil) + (insert (apply 'format (concat format-string "\n") args)) + (setq-local buffer-read-only t) + (let ((debug-window (get-buffer-window subed-debug-buffer))) + (set-window-point debug-window (goto-char (point-max))))))) + + +;;; Replay time-adjusted subtitle +(defun subed-replay-adjusted-subtitle-p () + "Whether adjusting a subtitle's start/stop time causes the +player to jump to the subtitle's start position." + (member 'subed--replay-adjusted-subtitle subed-subtitle-time-adjusted-hook)) + +(defun subed-enable-replay-adjusted-subtitle () + "Automatically replay a subtitle when its start/stop time is adjusted." + (interactive) + (unless (subed-replay-adjusted-subtitle-p) + (add-hook 'subed-subtitle-time-adjusted-hook 'subed--replay-adjusted-subtitle :append :local) + (subed-debug "Enabled replaying adjusted subtitle: %s" subed-subtitle-time-adjusted-hook) + (message "Enabled replaying adjusted subtitle"))) + +(defun subed-disable-replay-adjusted-subtitle () + "Do not replay a subtitle automatically when its start/stop time is adjusted." + (interactive) + (when (subed-replay-adjusted-subtitle-p) + (remove-hook 'subed-subtitle-time-adjusted-hook 'subed--replay-adjusted-subtitle :local) + (subed-debug "Disabled replaying adjusted subtitle: %s" subed-subtitle-time-adjusted-hook) + (message "Disabled replaying adjusted subtitle"))) + +(defun subed-toggle-replay-adjusted-subtitle () + "Enable or disable automatic replaying of subtitle when its +start/stop time is adjusted." + (interactive) + (if (subed-replay-adjusted-subtitle-p) + (subed-disable-replay-adjusted-subtitle) + (subed-enable-replay-adjusted-subtitle))) + +(defun subed--replay-adjusted-subtitle (sub-id msecs-start) + "Move point to currently playing subtitle." + (subed-mpv-jump msecs-start) + (subed-debug "Replaying subtitle at: %s" (subed-srt--msecs-to-timestamp msecs-start))) + + +;;; Sync point-to-player + +(defun subed-sync-point-to-player-p () + "Whether point is automatically moved to currently playing subtitle." + (member 'subed--sync-point-to-player subed-mpv-playback-position-hook)) + +(defun subed-enable-sync-point-to-player () + "Automatically move point to the currently playing subtitle." + (interactive) + (unless (subed-sync-point-to-player-p) + (add-hook 'subed-mpv-playback-position-hook 'subed--sync-point-to-player :append :local) + (subed-debug "Enabled syncing point to playback position: %s" subed-mpv-playback-position-hook) + (message "Enabled syncing point to playback position"))) + +(defun subed-disable-sync-point-to-player () + "Do not move point automatically to the currently playing +subtitle." + (interactive) + (when (subed-sync-point-to-player-p) + (remove-hook 'subed-mpv-playback-position-hook 'subed--sync-point-to-player :local) + (subed-debug "Disabled syncing point to playback position: %s" subed-mpv-playback-position-hook) + (message "Disabled syncing point to playback position"))) + +(defun subed-toggle-sync-point-to-player () + "Enable or disable moving point automatically to the currently +playing subtitle." + (interactive) + (if (subed-sync-point-to-player-p) + (subed-disable-sync-point-to-player) + (subed-enable-sync-point-to-player))) + +(defun subed--sync-point-to-player (msecs) + "Move point to currently playing subtitle." + (when (subed-move-to-subtitle-text-at-msecs msecs) + (subed-debug "Synchronized point to playback position: %s -> #%s" + (subed-srt--msecs-to-timestamp msecs) (subed--subtitle-id)) + ;; post-command-hook is not triggered because we didn't move interactively. + ;; But there's not really a difference, e.g. the minor mode `hl-line' breaks + ;; unless we call its post-command function, so we do it manually. + ;; It's also important NOT to call our own post-command function because + ;; that causes player-to-point syncing, which would get hairy. + (remove-hook 'post-command-hook 'subed--post-command-handler) + (run-hooks 'post-command-hook) + (add-hook 'post-command-hook 'subed--post-command-handler :append :local))) + +(defun subed-disable-sync-point-to-player-temporarily () + "If point is synced to playback position, temporarily disable +that for `subed-point-sync-delay-after-motion' seconds." + (if subed--point-sync-delay-after-motion-timer + (progn + (subed-debug "Cancelling old timer (should be nil: %s)" (subed-sync-point-to-player-p)) + (cancel-timer subed--point-sync-delay-after-motion-timer)) + (progn + (setq subed--point-was-synced (subed-sync-point-to-player-p)) + (subed-debug "Remembering whether point was originally synced: %s" subed--point-was-synced))) + + (when subed--point-was-synced + (subed-debug "Temporarily disabling point-to-player syncing (should be t: %s)" + (subed-sync-point-to-player-p)) + (subed-disable-sync-point-to-player)) + + (when subed--point-was-synced + (subed-debug "Re-enabling point-to-player syncing in %s seconds" subed-point-sync-delay-after-motion) + (setq subed--point-sync-delay-after-motion-timer + (run-at-time subed-point-sync-delay-after-motion nil + (lambda () + (setq subed--point-sync-delay-after-motion-timer nil) + (subed-enable-sync-point-to-player) + (subed-debug "Re-added: %s" subed-mpv-playback-position-hook)))))) + + +;;; Sync player-to-point + +(defun subed-sync-player-to-point-p () + "Whether playback position is automatically adjusted to +subtitle at point." + (member 'subed--sync-player-to-point subed-subtitle-motion-hook)) + +(defun subed-enable-sync-player-to-point () + "Automatically seek player to subtitle at point." + (interactive) + (unless (subed-sync-player-to-point-p) + (subed--sync-player-to-point) + (add-hook 'subed-subtitle-motion-hook 'subed--sync-player-to-point :append :local) + (subed-debug "Enabled syncing playback position to point: %s" subed-subtitle-motion-hook) + (message "Enabled syncing playback position to point"))) + +(defun subed-disable-sync-player-to-point () + "Do not automatically seek player to subtitle at point." + (interactive) + (when (subed-sync-player-to-point-p) + (remove-hook 'subed-subtitle-motion-hook 'subed--sync-player-to-point :local) + (subed-debug "Disabled syncing playback position to point: %s" subed-subtitle-motion-hook) + (message "Disabled syncing playback position to point"))) + +(defun subed-toggle-sync-player-to-point () + "Enable or disable automatically seeking player to subtitle at point." + (interactive) + (if (subed-sync-player-to-point-p) + (subed-disable-sync-player-to-point) + (subed-enable-sync-player-to-point))) + +(defun subed--sync-player-to-point () + "Seek player to currently focused subtitle." + (subed-debug "Seeking player to subtitle at point %s" (point)) + (let ((cur-sub-start (subed--subtitle-msecs-start)) + (cur-sub-stop (subed--subtitle-msecs-stop))) + (when (and subed-mpv-playback-position cur-sub-start cur-sub-stop + (or (< subed-mpv-playback-position cur-sub-start) + (> subed-mpv-playback-position cur-sub-stop))) + (subed-mpv-jump cur-sub-start) + (subed-debug "Synchronized playback position to point: #%s -> %s" + (subed--subtitle-id) cur-sub-start)))) + + +;;; Loop over single subtitle + +(defun subed-subtitle-loop-p () + "Whether player is rewinded to start of current subtitle every +time it reaches the subtitle's stop time." + (or subed--subtitle-loop-start subed--subtitle-loop-stop)) + +(defun subed-toggle-subtitle-loop () + "Enable or disable looping in player over currently focused +subtitle." + (interactive) + (if (subed-subtitle-loop-p) + (progn + (remove-hook 'subed-mpv-playback-position-hook 'subed--ensure-subtitle-loop :local) + (remove-hook 'subed-subtitle-motion-hook 'subed--set-subtitle-loop :local) + (setq subed--subtitle-loop-start nil + subed--subtitle-loop-stop nil) + (subed-debug "Disabling loop: %s - %s" subed--subtitle-loop-start subed--subtitle-loop-stop)) + (progn + (subed--set-subtitle-loop (subed--subtitle-id)) + (add-hook 'subed-mpv-playback-position-hook 'subed--ensure-subtitle-loop :append :local) + (add-hook 'subed-subtitle-motion-hook 'subed--set-subtitle-loop :append :local) + (subed-debug "Enabling loop: %s - %s" subed--subtitle-loop-start subed--subtitle-loop-stop)))) + +(defun subed--set-subtitle-loop (&optional sub-id) + "Set loop positions to start/stop time of SUB-ID or current subtitle." + (setq subed--subtitle-loop-start (- (subed--subtitle-msecs-start sub-id) + (* subed-loop-seconds-before 1000)) + subed--subtitle-loop-stop (+ (subed--subtitle-msecs-stop sub-id) + (* subed-loop-seconds-after 1000))) + (subed-debug "Set loop: %s - %s" + (subed-srt--msecs-to-timestamp subed--subtitle-loop-start) + (subed-srt--msecs-to-timestamp subed--subtitle-loop-stop))) + +(defun subed--ensure-subtitle-loop (cur-msecs) + "Seek back to `subed--subtitle-loop-start' if player is after +`subed--subtitle-loop-stop'." + (when (and subed--subtitle-loop-start subed--subtitle-loop-stop + subed-mpv-is-playing) + (when (or (< cur-msecs subed--subtitle-loop-start) + (> cur-msecs subed--subtitle-loop-stop)) + (subed-debug "%s -> Looping over %s - %s" + (subed-srt--msecs-to-timestamp cur-msecs) + (subed-srt--msecs-to-timestamp subed--subtitle-loop-start) + (subed-srt--msecs-to-timestamp subed--subtitle-loop-stop)) + (subed-mpv-jump subed--subtitle-loop-start)))) + + +;;; Pause player while the user is editing + +(defun subed-pause-while-typing-p () + "Whether player is automatically paused or slowed down while +the user is editing the buffer. +See `subed-playback-speed-while-typing' and +`subed-playback-speed-while-not-typing'." + (member 'subed--pause-while-typing after-change-functions)) + +(defun subed-enable-pause-while-typing () + "Automatically pause player while the user is editing the +buffer for `subed-unpause-after-typing-delay' seconds." + (unless (subed-pause-while-typing-p) + (add-hook 'after-change-functions 'subed--pause-while-typing :append :local) + (if (>= 0 subed-playback-speed-while-typing) + (message "Pausing playback when during editing actions") + (message "Slowing down playback to %s during editing actions" subed-playback-speed-while-typing)))) + +(defun subed-disable-pause-while-typing () + "Do not automatically pause player while the user is editing +the buffer." + (when (subed-pause-while-typing-p) + (remove-hook 'after-change-functions 'subed--pause-while-typing :local) + (message "Not pausing or slowing down playback during editing actions"))) + +(defun subed-toggle-pause-while-typing () + "Enable or disable auto-pausing while the user is editing the +buffer." + (interactive) + (if (subed-pause-while-typing-p) + (subed-disable-pause-while-typing) + (subed-enable-pause-while-typing))) + +(defun subed--pause-while-typing (&rest args) + "Pause or slow down playback for `subed-unpause-after-typing-delay' seconds." + (when subed--unpause-after-typing-timer + (cancel-timer subed--unpause-after-typing-timer)) + + (when (or subed-mpv-is-playing subed--player-is-auto-paused) + (if (>= 0 subed-playback-speed-while-typing) + ;; Pause playback + (progn + (subed-mpv-pause) + (setq subed--player-is-auto-paused t) + (setq subed--unpause-after-typing-timer + (run-at-time subed-unpause-after-typing-delay nil + (lambda () + (setq subed--player-is-auto-paused nil) + (subed-mpv-unpause))))) + ;; Slow down playback + (progn + (subed-mpv-playback-speed subed-playback-speed-while-typing) + (setq subed--player-is-auto-paused t) + (setq subed--unpause-after-typing-timer + (run-at-time subed-unpause-after-typing-delay nil + (lambda () + (setq subed--player-is-auto-paused nil) + (subed-mpv-playback-speed subed-playback-speed-while-not-typing)))))))) + + +;;; Stuff + +(defmacro subed--save-excursion (&rest body) + "Restore relative point within current subtitle after executing BODY. +This also works if the buffer changes as long the subtitle IDs +don't change." + `(let ((sub-id (subed--subtitle-id)) + (sub-pos (subed--subtitle-relative-point))) + (progn ,@body) + (subed-move-to-subtitle-id sub-id) + ;; Subtitle text may have changed and we may not be able to move to the + ;; exact original position + (condition-case nil + (forward-char sub-pos) + ('beginning-of-buffer nil) + ('end-of-buffer nil)))) + +(defun subed-guess-video-file () + "Return path to video if replacing the buffer file name's +extension with members of `subed-video-extensions' yields an +existing file." + (catch 'found-videofile + (let ((file-base (file-name-sans-extension (buffer-file-name)))) + (dolist (extension subed-video-extensions) + (let ((file-video (format "%s.%s" file-base extension))) + (when (file-exists-p file-video) + (throw 'found-videofile file-video))))))) + + +(defun subed-mode () + "Major mode for editing subtitles. + +Key bindings: +\\{subed-mode-map}" + (interactive) + + ;; Buffer-local variables + (kill-all-local-variables) + (setq-local font-lock-defaults '(subed-font-lock-keywords)) + (setq-local paragraph-start "^[[:alnum:]\n]+") + (setq-local paragraph-separate "\n\n") + + ;; Keybindings + (use-local-map subed-mode-map) + + ;; Provide point-motion and subtitle-motion hooks + (add-hook 'post-command-hook 'subed--post-command-handler :append :local) + + ;; Sort and reload subtitles in player on C-x C-s + (add-hook 'before-save-hook 'subed-sort :append :local) + (add-hook 'after-save-hook 'subed-mpv-reload-subtitles :append :local) + + ;; Close player when buffer is killed + (add-hook 'kill-buffer-hook 'subed-mpv-kill :append :local) + + ;; Auto-open relevant video file + (when subed-auto-find-video + (let ((video-file (subed-guess-video-file))) + (when video-file + (subed-debug "Auto-discovered video file: %s" video-file) + (subed-mpv-find-video video-file)))) + + (subed-enable-pause-while-typing) + (subed-enable-sync-point-to-player) + (subed-enable-sync-player-to-point) + (subed-enable-replay-adjusted-subtitle) + + (setq major-mode 'subed-mode + mode-name "SubEd") + (run-mode-hooks 'subed-mode-hook)) + +(provide 'subed) +;;; subed.el ends here diff --git a/tests/test-subed-mpv.el b/tests/test-subed-mpv.el new file mode 100644 index 0000000..1d39b25 --- /dev/null +++ b/tests/test-subed-mpv.el @@ -0,0 +1,139 @@ +(add-to-list 'load-path "./subed") +(require 'subed) + +(describe "Starting mpv" + (it "passes arguments to make-process." + (spy-on 'make-process) + (subed-mpv--server-start "foo" "--bar") + (expect 'make-process :to-have-been-called-with + :command (list subed-mpv-executable + (format "--input-ipc-server=%s" subed-mpv-socket) + "--idle" "foo" "--bar") + :name "subed-mpv-server" :buffer nil :noquery t)) + (it "sets subed-mpv--server-proc on success." + (spy-on 'make-process :and-return-value "mock process") + (subed-mpv--server-start) + (expect subed-mpv--server-proc :to-equal "mock process")) + (it "signals error on failure." + (spy-on 'make-process :and-throw-error 'error) + (expect (subed-mpv--server-start) :to-throw 'error)) + ) + +(describe "Stopping mpv" + (before-each + (setq subed-mpv--server-proc "mock running mpv process") + (spy-on 'process-live-p :and-return-value t) + (spy-on 'delete-process)) + (it "kills the mpv process." + (subed-mpv--server-stop) + (expect 'delete-process :to-have-been-called-with "mock running mpv process")) + (it "resets subed-mpv--server-proc." + (expect subed-mpv--server-proc :not :to-be nil) + (subed-mpv--server-stop) + (expect subed-mpv--server-proc :to-be nil)) + ) + +(describe "Connecting" + (before-each + (spy-on 'delete-process)) + (it "resets global status variables." + (spy-on 'subed-mpv--client-connected-p :and-return-value t) + (spy-on 'make-network-process :and-return-value "mock client process") + (spy-on 'process-send-string) + (spy-on 'subed-mpv--client-send) + (setq subed-mpv--client-proc "foo" + subed-mpv-is-playing "baz" + subed-mpv--client-command-queue '(foo bar baz)) + (subed-mpv--client-connect '(0 0 0)) + (expect subed-mpv--client-proc :to-equal "mock client process") + (expect subed-mpv-is-playing :to-be nil) + (expect subed-mpv--client-command-queue :to-be nil)) + (it "correctly calls make-network-process." + (spy-on 'make-network-process) + (spy-on 'process-send-string) + (subed-mpv--client-connect '(0 0 0)) + (expect 'make-network-process :to-have-been-called-with + :name "subed-mpv-client" + :family 'local + :service subed-mpv-socket + :coding '(utf-8 . utf-8) + :buffer (get-buffer-create subed-mpv--client-buffer) + :filter #'subed-mpv--client-filter + :noquery t + :nowait t)) + (describe "tests the connection" + (it "and sets subed-mpv--client-proc if the test succeeds." + (spy-on 'make-network-process :and-return-value "mock client process") + (spy-on 'process-send-string) + (subed-mpv--client-connect '(0 0 0)) + (expect 'process-send-string :to-have-been-called-with + "mock client process" (concat subed-mpv--client-test-request "\n")) + (expect subed-mpv--client-proc :to-equal "mock client process")) + (it "and resets subed-mpv--client-proc if the test fails." + (spy-on 'make-network-process :and-return-value "mock client process") + (spy-on 'process-send-string :and-throw-error 'error) + (setq subed-mpv--client-proc "foo") + (subed-mpv--client-connect '(0 0 0)) + (expect subed-mpv--client-proc :to-be nil)) + (it "and tries again if the test fails." + (spy-on 'make-network-process :and-return-value "mock client process") + (spy-on 'process-send-string :and-throw-error 'error) + (subed-mpv--client-connect '(0 0 0)) + ;; FIXME: This seems to be a bug: + ;; https://github.com/jorgenschaefer/emacs-buttercup/issues/139 + ;; (expect 'process-send-string :to-have-been-called-times 3) + (expect subed-mpv--client-proc :to-be nil)) + ) + (it "sends queued commands and empties the queue." + (spy-on 'make-network-process :and-return-value "mock client process") + (spy-on 'process-send-string) + (spy-on 'subed-mpv--client-send) + (setq subed-mpv--client-command-queue '(foo bar baz)) + (subed-mpv--client-connect '(0 0 0)) + (expect 'subed-mpv--client-send :to-have-been-called-with 'foo) + (expect 'subed-mpv--client-send :to-have-been-called-with 'bar) + (expect 'subed-mpv--client-send :to-have-been-called-with 'baz) + (expect subed-mpv--client-command-queue :to-be nil)) + ) + +(describe "Sending command" + (before-each + (spy-on 'delete-process) + (setq subed-mpv--client-command-queue nil)) + (describe "when mpv process is not running" + (before-each + (spy-on 'subed-mpv--server-started-p :and-return-value nil)) + (it "is not queued if not connected." + (spy-on 'subed-mpv--client-connected-p :and-return-value nil) + (subed-mpv--client-send '(do this thing)) + (expect subed-mpv--client-command-queue :to-be nil)) + ) + (describe "when mpv process is running" + (before-each + (spy-on 'subed-mpv--server-started-p :and-return-value t)) + (it "is queued if not connected." + (spy-on 'subed-mpv--client-connected-p :and-return-value nil) + (subed-mpv--client-send '(do this thing)) + (expect subed-mpv--client-command-queue :to-equal '((do this thing))) + (subed-mpv--client-send '(do something else)) + (expect subed-mpv--client-command-queue :to-equal '((do this thing) + (do something else)))) + (it "sends command if connected." + (spy-on 'subed-mpv--client-connected-p :and-return-value t) + (spy-on 'process-send-string) + (setq subed-mpv--client-proc "mock client process") + (subed-mpv--client-send '(do this thing)) + (expect 'process-send-string :to-have-been-called-with + "mock client process" + (concat (json-encode (list :command '(do this thing))) "\n")) + (expect subed-mpv--client-command-queue :to-equal nil)) + (it "disconnects if sending fails even though we're connected." + (spy-on 'subed-mpv--client-connected-p :and-return-value t) + (spy-on 'subed-mpv--client-disconnect) + (spy-on 'process-send-string :and-throw-error 'error) + (expect (subed-mpv--client-send '(do this thing)) :to-throw 'error) + (expect 'subed-mpv--client-disconnect :to-have-been-called-times 1) + (expect subed-mpv--client-command-queue :to-equal nil)) + ) + ) + diff --git a/tests/test-subed-srt.el b/tests/test-subed-srt.el new file mode 100644 index 0000000..7f57ba8 --- /dev/null +++ b/tests/test-subed-srt.el @@ -0,0 +1,489 @@ +(add-to-list 'load-path "./subed") +(require 'subed) + +(defvar mock-srt-data + "1 +00:01:01,000 --> 00:01:05,123 +Foo. + +2 +00:02:02,234 --> 00:02:10,345 +Bar. + +3 +00:03:03,456 --> 00:03:15,567 +Baz. +") + +(describe "Getting" + (describe "the subtitle ID" + (it "returns the subtitle ID if possible." + (with-temp-buffer + (insert mock-srt-data) + (subed-srt-move-to-subtitle-text 2) + (expect (subed-srt--subtitle-id) :to-equal 2))) + (it "returns nil if no subtitle ID can be found." + (with-temp-buffer + (expect (subed-srt--subtitle-id) :to-equal nil))) + ) + (describe "the subtitle ID at playback time" + (it "returns subtitle ID if time is equal to start time." + (with-temp-buffer + (insert mock-srt-data) + (cl-loop for target-id from 1 to 3 do + (let ((msecs (subed-srt--subtitle-msecs-start target-id))) + (cl-loop for outset-id from 1 to 3 do + (progn + (subed-srt-move-to-subtitle-id outset-id) + (expect (subed-srt--subtitle-id-at-msecs msecs) :to-equal target-id))))))) + (it "returns subtitle ID if time is equal to stop time." + (with-temp-buffer + (insert mock-srt-data) + (cl-loop for target-id from 1 to 3 do + (let ((msecs (subed-srt--subtitle-msecs-stop target-id))) + (cl-loop for outset-id from 1 to 3 do + (progn + (subed-srt-move-to-subtitle-id outset-id) + (expect (subed-srt--subtitle-id-at-msecs msecs) :to-equal target-id))))))) + (it "returns subtitle ID if time is between start and stop time." + (with-temp-buffer + (insert mock-srt-data) + (cl-loop for target-id from 1 to 3 do + (let ((msecs (+ 1 (subed-srt--subtitle-msecs-start target-id)))) + (cl-loop for outset-id from 1 to 3 do + (progn + (subed-srt-move-to-subtitle-id outset-id) + (expect (subed-srt--subtitle-id-at-msecs msecs) :to-equal target-id))))))) + (it "returns first subtitle ID if time is before the first subtitle's start time." + (with-temp-buffer + (insert mock-srt-data) + (let ((msecs (- (save-excursion + (goto-char (point-min)) + (subed-srt--subtitle-msecs-start)) 1))) + (cl-loop for outset-id from 1 to 3 do + (progn + (subed-srt-move-to-subtitle-id outset-id) + (expect (subed-srt--subtitle-id-at-msecs msecs) :to-equal 1)))))) + (it "returns last subtitle ID if time is after last subtitle's start time." + (with-temp-buffer + (insert mock-srt-data) + (let ((msecs (+ (save-excursion + (goto-char (point-max)) + (subed-srt--subtitle-msecs-stop)) 1))) + (cl-loop for outset-id from 1 to 3 do + (progn + (subed-srt-move-to-subtitle-id outset-id) + (expect (subed-srt--subtitle-id-at-msecs msecs) :to-equal 3)))))) + (it "returns previous subtitle ID when time is between subtitles" + (with-temp-buffer + (insert mock-srt-data) + (cl-loop for target-id from 1 to 2 do + (let ((msecs (+ (subed-srt--subtitle-msecs-stop target-id) 1))) + (cl-loop for outset-id from 1 to 3 do + (progn + (subed-srt-move-to-subtitle-id outset-id) + (expect (subed-srt--subtitle-id-at-msecs msecs) :to-equal target-id)))) + + (let ((msecs (- (subed-srt--subtitle-msecs-start (+ target-id 1)) 1))) + (cl-loop for outset-id from 1 to 3 do + (progn + (subed-srt-move-to-subtitle-id outset-id) + (expect (subed-srt--subtitle-id-at-msecs msecs) :to-equal target-id))))))) + ) + ) + + +(describe "Adjusting subtitle start/stop time" + :var (subed-subtitle-time-adjusted-hook) + (it "runs the appropriate hook." + (let ((foo (setf (symbol-function 'foo) (lambda (sub-id msecs) ())))) + (spy-on 'foo) + (add-hook 'subed-subtitle-time-adjusted-hook 'foo) + (with-temp-buffer + (insert mock-srt-data) + (subed-srt-increase-start-time-100ms) + (expect 'foo :to-have-been-called-with 3 183556) + (expect 'foo :to-have-been-called-times 1) + (subed-srt-move-to-subtitle-id 1) + (subed-srt-increase-stop-time-100ms) + (expect 'foo :to-have-been-called-with 1 65223) + (expect 'foo :to-have-been-called-times 2) + (subed-srt-move-to-subtitle-end 2) + (subed-srt-decrease-start-time-100ms) + (expect 'foo :to-have-been-called-with 2 122134) + (expect 'foo :to-have-been-called-times 3) + (subed-srt-move-to-subtitle-text 3) + (subed-srt-decrease-stop-time-100ms) + (expect 'foo :to-have-been-called-with 3 195467) + (expect 'foo :to-have-been-called-times 4)))) + (it "adjusts the start/stop time." + (with-temp-buffer + (insert mock-srt-data) + (subed-srt-move-to-subtitle-id 1) + (subed-srt-increase-start-time-100ms) + (expect (save-excursion (subed-srt-move-to-subtitle-time-start) + (thing-at-point 'line)) :to-equal "00:01:01,100 --> 00:01:05,123\n") + (subed-srt-decrease-start-time-100ms) + (subed-srt-decrease-start-time-100ms) + (expect (save-excursion (subed-srt-move-to-subtitle-time-start) + (thing-at-point 'line)) :to-equal "00:01:00,900 --> 00:01:05,123\n") + (subed-srt-increase-stop-time-100ms) + (subed-srt-increase-stop-time-100ms) + (expect (save-excursion (subed-srt-move-to-subtitle-time-start) + (thing-at-point 'line)) :to-equal "00:01:00,900 --> 00:01:05,323\n") + (subed-srt-decrease-stop-time-100ms) + (expect (save-excursion (subed-srt-move-to-subtitle-time-start) + (thing-at-point 'line)) :to-equal "00:01:00,900 --> 00:01:05,223\n"))) + ) + + +(describe "Moving" + (describe "to current subtitle ID" + (it "returns ID's point when point is already on the ID." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (expect (thing-at-point 'word) :to-equal "1") + (expect (subed-srt-move-to-subtitle-id) :to-equal 1) + (expect (thing-at-point 'word) :to-equal "1"))) + (it "returns ID's point when point is on the duration." + (with-temp-buffer + (insert mock-srt-data) + (search-backward ",234") + (expect (thing-at-point 'word) :to-equal "02") + (expect (subed-srt-move-to-subtitle-id) :to-equal 39) + (expect (thing-at-point 'word) :to-equal "2"))) + (it "returns ID's point when point is on the text." + (with-temp-buffer + (insert mock-srt-data) + (search-backward "Baz.") + (expect (thing-at-point 'word) :to-equal "Baz") + (expect (subed-srt-move-to-subtitle-id) :to-equal 77) + (expect (thing-at-point 'word) :to-equal "3"))) + (it "returns ID's point when point is after the text." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (search-forward "Bar.\n") + (expect (thing-at-point 'line) :to-equal "\n") + (expect (subed-srt-move-to-subtitle-id) :to-equal 39) + (expect (thing-at-point 'word) :to-equal "2"))) + (it "returns nil if buffer is empty." + (with-temp-buffer + (expect (buffer-string) :to-equal "") + (expect (subed-srt-move-to-subtitle-id) :to-equal nil))) + ) + (describe "to specific subtitle ID" + (it "returns ID's point if wanted ID exists." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-max)) + (expect (subed-srt-move-to-subtitle-id 2) :to-equal 39) + (expect (thing-at-point 'word) :to-equal "2") + (expect (subed-srt-move-to-subtitle-id 1) :to-equal 1) + (expect (thing-at-point 'word) :to-equal "1") + (expect (subed-srt-move-to-subtitle-id 3) :to-equal 77) + (expect (thing-at-point 'word) :to-equal "3"))) + (it "returns nil and does not move if wanted ID does not exists." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (search-forward "Foo") + (setq stored-point (point)) + (expect (subed-srt-move-to-subtitle-id 4) :to-equal nil) + (expect stored-point :to-equal (point)))) + ) + (describe "to subtitle ID at specific time" + (it "returns ID's point if point changed." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-max)) + (spy-on 'subed-srt--subtitle-id-at-msecs :and-return-value (point-min)) + (expect (subed-srt-move-to-subtitle-id-at-msecs 123456) :to-equal (point-min)) + (expect (point) :to-equal (point-min)) + (expect 'subed-srt--subtitle-id-at-msecs :to-have-been-called-with 123456) + (expect 'subed-srt--subtitle-id-at-msecs :to-have-been-called-times 1))) + (it "returns nil if point didn't change." + (with-temp-buffer + (insert mock-srt-data) + (goto-char 75) + (spy-on 'subed-srt--subtitle-id-at-msecs :and-return-value 75) + (expect (subed-srt-move-to-subtitle-id-at-msecs 123456) :to-equal nil) + (expect (point) :to-equal 75) + (expect 'subed-srt--subtitle-id-at-msecs :to-have-been-called-with 123456) + (expect 'subed-srt--subtitle-id-at-msecs :to-have-been-called-times 1))) + ) + (describe "to subtitle start time" + (it "returns start time's point if movement was successful." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (expect (subed-srt-move-to-subtitle-time-start) :to-equal 3) + (expect (buffer-substring (point) (+ (point) subed-srt--length-timestamp)) :to-equal "00:01:01,000") + (re-search-forward "\n\n") + (expect (subed-srt-move-to-subtitle-time-start) :to-equal 41) + (expect (buffer-substring (point) (+ (point) subed-srt--length-timestamp)) :to-equal "00:02:02,234") + (re-search-forward "\n\n") + (expect (subed-srt-move-to-subtitle-time-start) :to-equal 79) + (expect (buffer-substring (point) (+ (point) subed-srt--length-timestamp)) :to-equal "00:03:03,456"))) + (it "returns nil if movement failed." + (with-temp-buffer + (expect (subed-srt-move-to-subtitle-time-start) :to-equal nil))) + ) + (describe "to subtitle stop time" + (it "returns stop time's point if movement was successful." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (expect (subed-srt-move-to-subtitle-time-stop) :to-equal 20) + (expect (buffer-substring (point) (+ (point) subed-srt--length-timestamp)) :to-equal "00:01:05,123") + (re-search-forward "\n\n") + (expect (subed-srt-move-to-subtitle-time-stop) :to-equal 58) + (expect (buffer-substring (point) (+ (point) subed-srt--length-timestamp)) :to-equal "00:02:10,345") + (re-search-forward "\n\n") + (expect (subed-srt-move-to-subtitle-time-stop) :to-equal 96) + (expect (buffer-substring (point) (+ (point) subed-srt--length-timestamp)) :to-equal "00:03:15,567"))) + (it "returns nil if movement failed." + (with-temp-buffer + (expect (subed-srt-move-to-subtitle-time-stop) :to-equal nil))) + ) + (describe "to subtitle text" + (it "returns subtitle text's point if movement was successful." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (expect (subed-srt-move-to-subtitle-text) :to-equal 33) + (expect (point) :to-equal (save-excursion (goto-char (point-max)) (search-backward "Foo."))) + (re-search-forward "\n\n") + (expect (subed-srt-move-to-subtitle-text) :to-equal 71) + (expect (point) :to-equal (save-excursion (goto-char (point-max)) (search-backward "Bar."))) + (re-search-forward "\n\n") + (expect (subed-srt-move-to-subtitle-text) :to-equal 109) + (expect (point) :to-equal (save-excursion (goto-char (point-max)) (search-backward "Baz."))) + + )) + (it "returns nil if movement failed." + (with-temp-buffer + (expect (subed-srt-move-to-subtitle-time-stop) :to-equal nil))) + ) + (describe "to end of subtitle text" + (it "returns end of subtitle text's point if movement was successful." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (expect (subed-srt-move-to-subtitle-end) :to-be 37) + (expect (looking-back "^Foo.$") :to-be t) + (forward-char 2) + (expect (subed-srt-move-to-subtitle-end) :to-be 75) + (expect (looking-back "^Bar.$") :to-be t) + (forward-char 2) + (expect (subed-srt-move-to-subtitle-end) :to-be 113) + (expect (looking-back "^Baz.$") :to-be t) + (goto-char (point-max)) + (backward-char 2) + (expect (subed-srt-move-to-subtitle-end) :to-be 113) + (expect (looking-back "^Baz.$") :to-be t) + )) + (it "returns nil if movement failed." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-max)) + (expect (subed-srt-move-to-subtitle-end) :to-be nil) + (expect (looking-back "^Baz.$") :to-be nil) + (backward-char 1) + (expect (subed-srt-move-to-subtitle-end) :to-be nil) + (expect (looking-back "^Baz.$") :to-be t))) + ) + (describe "to next subtitle ID" + (it "returns subtitle ID's point when it moved." + (with-temp-buffer + (insert mock-srt-data) + (subed-srt-move-to-subtitle-id 2) + (expect (thing-at-point 'word) :to-equal "2") + (expect (subed-srt-forward-subtitle-id) :to-be 77) + (expect (thing-at-point 'word) :to-equal "3"))) + (it "returns nil and doesn't move when point is on the last subtitle and there are trailing lines." + (with-temp-buffer + (insert (concat mock-srt-data "\n\n")) + (subed-srt-move-to-subtitle-text 3) + (expect (thing-at-point 'word) :to-equal "Baz") + (expect (subed-srt-forward-subtitle-id) :to-be nil) + (expect (thing-at-point 'word) :to-equal "Baz"))) + ) + ) + + +(describe "Killing a subtitle" + (it "removes it when it is the first one." + (with-temp-buffer + (insert mock-srt-data) + (subed-srt-move-to-subtitle-text 1) + (subed-srt-subtitle-kill) + (expect (buffer-string) :to-equal (concat "2\n" + "00:02:02,234 --> 00:02:10,345\n" + "Bar.\n" + "\n" + "3\n" + "00:03:03,456 --> 00:03:15,567\n" + "Baz.\n")))) + (it "removes it when it is in the middle." + (with-temp-buffer + (insert mock-srt-data) + (subed-srt-move-to-subtitle-text 2) + (subed-srt-subtitle-kill) + (expect (buffer-string) :to-equal (concat "1\n" + "00:01:01,000 --> 00:01:05,123\n" + "Foo.\n" + "\n" + "3\n" + "00:03:03,456 --> 00:03:15,567\n" + "Baz.\n")))) + (it "removes it when it is the last one." + (with-temp-buffer + (insert mock-srt-data) + (subed-srt-move-to-subtitle-text 3) + (subed-srt-subtitle-kill) + (expect (buffer-string) :to-equal (concat "1\n" + "00:01:01,000 --> 00:01:05,123\n" + "Foo.\n" + "\n" + "2\n" + "00:02:02,234 --> 00:02:10,345\n" + "Bar.\n")))) + (it "removes the previous subtitle when point is right above an ID." + (with-temp-buffer + (insert mock-srt-data) + (subed-srt-move-to-subtitle-id 3) + (backward-char) + (expect (looking-at "^\n3\n") :to-be t) + (subed-srt-subtitle-kill) + (expect (buffer-string) :to-equal (concat "1\n" + "00:01:01,000 --> 00:01:05,123\n" + "Foo.\n" + "\n" + "3\n" + "00:03:03,456 --> 00:03:15,567\n" + "Baz.\n")))) + ) + + +(describe "Sanitizing" + (it "removes trailing tabs and spaces from all lines." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (while (re-search-forward "\n" nil t) + (replace-match " \n")) + (expect (buffer-string) :not :to-equal mock-srt-data) + (subed-srt-sanitize) + (expect (buffer-string) :to-equal mock-srt-data)) + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (while (re-search-forward "\n" nil t) + (replace-match "\t\n")) + (expect (buffer-string) :not :to-equal mock-srt-data) + (subed-srt-sanitize) + (expect (buffer-string) :to-equal mock-srt-data))) + (it "removes leading tabs and spaces from all lines." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (while (re-search-forward "\n" nil t) + (replace-match "\n ")) + (expect (buffer-string) :not :to-equal mock-srt-data) + (subed-srt-sanitize) + (expect (buffer-string) :to-equal mock-srt-data)) + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (while (re-search-forward "\n" nil t) + (replace-match "\n\t")) + (expect (buffer-string) :not :to-equal mock-srt-data) + (subed-srt-sanitize) + (expect (buffer-string) :to-equal mock-srt-data)) + ) + (it "removes excessive newlines between subtitles." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (while (re-search-forward "\n\n" nil t) + (replace-match "\n \n \t \t\t \n\n \n \n \t\n")) + (expect (buffer-string) :not :to-equal mock-srt-data) + (subed-srt-sanitize) + (expect (buffer-string) :to-equal mock-srt-data))) + (it "removes empty lines from beginning of buffer." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (insert " \n\t\n") + (expect (buffer-string) :not :to-equal mock-srt-data) + (subed-srt-sanitize) + (expect (buffer-string) :to-equal mock-srt-data))) + (it "removes empty lines from end of buffer." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-max)) + (insert " \n\t\n\n") + (expect (buffer-string) :not :to-equal mock-srt-data) + (subed-srt-sanitize) + (expect (buffer-string) :to-equal mock-srt-data))) + (it "ensures a single newline after the last subtitle." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-max)) + (delete-backward-char 1) + (expect (buffer-string) :not :to-equal mock-srt-data) + (subed-srt-sanitize) + (expect (buffer-string) :to-equal mock-srt-data))) + ) + +(describe "Renumbering" + (it "ensures consecutive subtitle IDs." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (while (looking-at "^[0-9]$") + (replace-match "123")) + (expect (buffer-string) :not :to-equal mock-srt-data) + (subed-srt--regenerate-ids) + (expect (buffer-string) :to-equal mock-srt-data)))) + +(describe "Sorting" + (it "ensures subtitles are ordered by start time." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (re-search-forward "01:01") + (replace-match "12:01") + (goto-char (point-min)) + (re-search-forward "02:02") + (replace-match "10:02") + (goto-char (point-min)) + (re-search-forward "03:03") + (replace-match "11:03") + (subed-srt-sort) + (expect (buffer-string) :to-equal + (concat + "1\n" + "00:10:02,234 --> 00:02:10,345\n" + "Bar.\n" + "\n" + "2\n" + "00:11:03,456 --> 00:03:15,567\n" + "Baz.\n" + "\n" + "3\n" + "00:12:01,000 --> 00:01:05,123\n" + "Foo.\n")))) + (it "preserves point in the current subtitle." + (with-temp-buffer + (insert mock-srt-data) + (goto-char (point-min)) + (re-search-forward "01:01") + (replace-match "12:01") + (search-forward "\n") + (expect (current-word) :to-equal "Foo") + (subed-srt-sort) + (expect (current-word) :to-equal "Foo"))) + ) diff --git a/tests/test-subed.el b/tests/test-subed.el new file mode 100644 index 0000000..b4b450e --- /dev/null +++ b/tests/test-subed.el @@ -0,0 +1,96 @@ +(add-to-list 'load-path "./subed") +(require 'subed) + +(describe "Syncing player to point" + :var (subed-mpv-playback-position) + (before-each + (setq subed-mpv-playback-position 0) + (spy-on 'subed--subtitle-msecs-start :and-return-value 5000) + (spy-on 'subed--subtitle-msecs-stop :and-return-value 6500) + (spy-on 'subed-mpv-jump) + (spy-on 'subed-disable-sync-point-to-player-temporarily)) + (it "does not seek player if point is on current subtitle." + (setq subed-mpv-playback-position 5000) + (subed--sync-player-to-point) + (expect 'subed-mpv-jump :not :to-have-been-called) + (setq subed-mpv-playback-position 6500) + (subed--sync-player-to-point) + (expect 'subed-mpv-jump :not :to-have-been-called)) + (it "seeks player if point is on future subtitle." + (setq subed-mpv-playback-position 6501) + (subed--sync-player-to-point) + (expect 'subed-mpv-jump :to-have-been-called-with 5000)) + (it "seeks player if point is on past subtitle." + (setq subed-mpv-playback-position 4999) + (subed--sync-player-to-point) + (expect 'subed-mpv-jump :to-have-been-called-with 5000)) + ) + +(describe "Syncing point to player" + :var (subed-mpv-playback-position) + (before-each + (setq subed-mpv-playback-position 0) + (spy-on 'subed--subtitle-msecs-start :and-return-value 5000) + (spy-on 'subed--subtitle-msecs-stop :and-return-value 6500) + (spy-on 'subed-mpv-jump)) + (it "does not seek player if point is on current subtitle." + (setq subed-mpv-playback-position 5000) + (subed--sync-player-to-point) + (expect 'subed-mpv-jump :not :to-have-been-called) + (setq subed-mpv-playback-position 6500) + (subed--sync-player-to-point) + (expect 'subed-mpv-jump :not :to-have-been-called)) + (it "seeks player if point is on future subtitle." + (setq subed-mpv-playback-position 6501) + (subed--sync-player-to-point) + (expect 'subed-mpv-jump :to-have-been-called-with 5000)) + (it "seeks player if point is on past subtitle." + (setq subed-mpv-playback-position 4999) + (subed--sync-player-to-point) + (expect 'subed-mpv-jump :to-have-been-called-with 5000)) + ) + +(describe "Temporarily disabling point-to-player syncing" + (before-each + (spy-on 'subed-disable-sync-point-to-player)) + (describe "when point-to-player syncing is disabled" + (before-each + (spy-on 'subed-sync-point-to-player-p :and-return-value nil) + (spy-on 'run-at-time)) + (it "does not disable point-to-player syncing." + (subed-disable-sync-point-to-player-temporarily) + (expect 'subed-disable-sync-point-to-player :not :to-have-been-called)) + (it "does not schedule re-enabling of point-to-player syncing." + (subed-disable-sync-point-to-player-temporarily) + (expect 'run-at-time :not :to-have-been-called) + (expect subed--point-sync-delay-after-motion-timer :to-be nil)) + ) + (describe "when point-to-player syncing is enabled" + :var (subed--point-sync-delay-after-motion-timer) + (before-each + (spy-on 'subed-sync-point-to-player-p :and-return-value t) + (spy-on 'run-at-time :and-return-value "mock timer") + (spy-on 'cancel-timer) + (setq subed--point-sync-delay-after-motion-timer nil)) + (it "disables point-to-player syncing." + (subed-disable-sync-point-to-player-temporarily) + (expect 'subed-disable-sync-point-to-player :to-have-been-called)) + (it "schedules re-enabling of point-to-player syncing." + (subed-disable-sync-point-to-player-temporarily) + (expect 'run-at-time :to-have-been-called-with + subed-point-sync-delay-after-motion nil + (lambda () + (setq subed--point-sync-delay-after-motion-timer nil) + (subed-enable-sync-point-to-player) + (subed-debug "Re-added: %s" subed-mpv-playback-position-hook)))) + (it "cancels previously scheduled re-enabling of point-to-player syncing." + (subed-disable-sync-point-to-player-temporarily) + (expect 'cancel-timer :not :to-have-been-called-with "mock timer") + (subed-disable-sync-point-to-player-temporarily) + (expect 'cancel-timer :to-have-been-called-with "mock timer") + (expect 'cancel-timer :to-have-been-called-times 1) + (subed-disable-sync-point-to-player-temporarily) + (expect 'cancel-timer :to-have-been-called-with "mock timer") + (expect 'cancel-timer :to-have-been-called-times 2)) + ) + )