diff options
| -rw-r--r-- | modules/external-open.el | 73 | ||||
| -rw-r--r-- | tests/test-external-open-commands.el | 85 |
2 files changed, 142 insertions, 16 deletions
diff --git a/modules/external-open.el b/modules/external-open.el index 22e56a290..811c32c28 100644 --- a/modules/external-open.el +++ b/modules/external-open.el @@ -42,15 +42,33 @@ "Open certain files with the OS default handler." :group 'files) -(defcustom default-open-extensions - '( - ;; Video - "\\.3g2\\'" "\\.3gp\\'" "\\.asf\\'" "\\.avi\\'" "\\.divx\\'" "\\.dv\\'" +(defcustom cj/video-extensions + '("\\.3g2\\'" "\\.3gp\\'" "\\.asf\\'" "\\.avi\\'" "\\.divx\\'" "\\.dv\\'" "\\.f4v\\'" "\\.flv\\'" "\\.m1v\\'" "\\.m2ts\\'" "\\.m2v\\'" "\\.m4v\\'" "\\.mkv\\'" "\\.mov\\'" "\\.mpe\\'" "\\.mpeg\\'" "\\.mpg\\'" "\\.mp4\\'" "\\.mts\\'" "\\.ogv\\'" "\\.rm\\'" "\\.rmvb\\'" "\\.vob\\'" - "\\.webm\\'" "\\.wmv\\'" + "\\.webm\\'" "\\.wmv\\'") + "Regexps matching video files opened in a looping player. +These route through `cj/open-video-looping' (mpv --loop-file=inf by default) +instead of the OS default handler, so a video opened from dirvish plays on +repeat." + :type '(repeat (regexp :tag "Video extension regexp")) + :group 'external-open) + +(defcustom cj/video-open-command "mpv" + "Player command used to open local video files on repeat. +Launched detached from Emacs with `cj/video-open-args' before the file name." + :type 'string + :group 'external-open) + +(defcustom cj/video-open-args '("--loop-file=inf") + "Arguments passed to `cj/video-open-command' before the file name. +Defaults to mpv's infinite single-file loop so the video plays on repeat." + :type '(repeat string) + :group 'external-open) +(defcustom default-open-extensions + '( ;; Audio "\\.aac\\'" "\\.ac3\\'" "\\.aif\\'" "\\.aifc\\'" "\\.aiff\\'" "\\.alac\\'" "\\.amr\\'" "\\.ape\\'" "\\.caf\\'" @@ -142,18 +160,49 @@ Logs output and exit code to buffer *external-open.log*." nil 0))))) +;; -------------------------- Open Videos On Repeat ---------------------------- + +(defun cj/--video-file-p (file) + "Return non-nil when FILE matches a regexp in `cj/video-extensions'." + (and (stringp file) + (let ((case-fold-search t)) + (cl-some (lambda (re) (string-match-p re file)) cj/video-extensions)))) + +(defun cj/--video-open-arglist (file) + "Return the argument list to play FILE on repeat: `cj/video-open-args' + FILE." + (append cj/video-open-args (list file))) + +(defun cj/open-video-looping (&optional filename) + "Open FILENAME (or the file at point) in a looping video player, detached. +Uses `cj/video-open-command' and `cj/video-open-args' (mpv --loop-file=inf by +default) so the video plays on repeat. Launched asynchronously so it never +blocks Emacs." + (interactive) + (let* ((file (expand-file-name + (or (cj/file-from-context filename) + (user-error "No file associated with this buffer")))) + (args (cj/--video-open-arglist file))) + (if (env-windows-p) + (w32-shell-execute "open" cj/video-open-command + (mapconcat (lambda (a) (format "\"%s\"" a)) args " ")) + (apply #'call-process cj/video-open-command nil 0 nil args)))) + ;; -------------------- Open Files With Default File Handler ------------------- (defun cj/find-file-auto (orig-fun &rest args) - "If file has an extension in `default-open-extensions', open externally. -Else call ORIG-FUN with ARGS." + "Open FILE externally based on its extension, else call ORIG-FUN with ARGS. +A video (`cj/video-extensions') opens in a looping player; any other extension +in `default-open-extensions' opens with the OS default handler." (let* ((file (car args)) (case-fold-search t)) - (if (and (stringp file) - (cl-some (lambda (re) (string-match-p re file)) - default-open-extensions)) - (cj/xdg-open file) - (apply orig-fun args)))) + (cond + ((cj/--video-file-p file) + (cj/open-video-looping file)) + ((and (stringp file) + (cl-some (lambda (re) (string-match-p re file)) + default-open-extensions)) + (cj/xdg-open file)) + (t (apply orig-fun args))))) (defun cj/external-open-install-advice () "Install the `cj/find-file-auto' advice on `find-file'. diff --git a/tests/test-external-open-commands.el b/tests/test-external-open-commands.el index c0c83a340..3d8adc15e 100644 --- a/tests/test-external-open-commands.el +++ b/tests/test-external-open-commands.el @@ -81,8 +81,9 @@ ;;; cj/find-file-auto (ert-deftest test-external-open-find-file-auto-routes-media-externally () - "Normal: a `.mp4' filename (in `default-open-extensions') triggers -`cj/xdg-open' instead of the original `find-file'." + "Normal: a non-video external extension (`.docx', in +`default-open-extensions') triggers `cj/xdg-open' instead of the original +`find-file'." (let ((opened nil) (orig-called nil)) (cl-letf (((symbol-function 'cj/xdg-open) @@ -90,8 +91,23 @@ ;; orig-fun replacement -- shouldn't run for a routed extension. ((symbol-function 'cj/find-file-auto--orig-stub) (lambda (&rest _) (setq orig-called t)))) - (cj/find-file-auto #'cj/find-file-auto--orig-stub "/tmp/video.mp4")) - (should (equal opened "/tmp/video.mp4")) + (cj/find-file-auto #'cj/find-file-auto--orig-stub "/tmp/report.docx")) + (should (equal opened "/tmp/report.docx")) + (should-not orig-called))) + +(ert-deftest test-external-open-find-file-auto-routes-video-to-looping-player () + "Normal: a video filename triggers `cj/open-video-looping', not `cj/xdg-open' +or the original `find-file'." + (let ((looped nil) (xdg nil) (orig-called nil)) + (cl-letf (((symbol-function 'cj/open-video-looping) + (lambda (file) (setq looped file))) + ((symbol-function 'cj/xdg-open) + (lambda (_) (setq xdg t))) + ((symbol-function 'cj/find-file-auto--orig-stub) + (lambda (&rest _) (setq orig-called t)))) + (cj/find-file-auto #'cj/find-file-auto--orig-stub "/tmp/clip.mp4")) + (should (equal looped "/tmp/clip.mp4")) + (should-not xdg) (should-not orig-called))) (ert-deftest test-external-open-find-file-auto-passes-through-text-files () @@ -116,5 +132,66 @@ (cj/find-file-auto #'cj/find-file-auto--orig-stub nil)) (should orig-called))) +;;; cj/--video-file-p + +(ert-deftest test-external-open-video-file-p-matches-video () + "Normal: common video extensions match, case-insensitively." + (should (cj/--video-file-p "/tmp/a.mp4")) + (should (cj/--video-file-p "/tmp/a.mkv")) + (should (cj/--video-file-p "/tmp/a.webm")) + (should (cj/--video-file-p "/tmp/A.MP4"))) + +(ert-deftest test-external-open-video-file-p-rejects-non-video () + "Boundary: audio, docs, and nil do not match." + (should-not (cj/--video-file-p "/tmp/a.mp3")) + (should-not (cj/--video-file-p "/tmp/a.txt")) + (should-not (cj/--video-file-p "/tmp/a.docx")) + (should-not (cj/--video-file-p nil))) + +;;; cj/--video-open-arglist + +(ert-deftest test-external-open-video-arglist-appends-file-after-args () + "Normal: the player args precede the file in the argument list." + (let ((cj/video-open-args '("--loop-file=inf"))) + (should (equal (cj/--video-open-arglist "/tmp/a.mp4") + '("--loop-file=inf" "/tmp/a.mp4"))))) + +(ert-deftest test-external-open-video-arglist-respects-custom-args () + "Boundary: custom args are honored; empty args yields just the file." + (let ((cj/video-open-args '("--loop=inf" "--mute=yes"))) + (should (equal (cj/--video-open-arglist "/tmp/a.mkv") + '("--loop=inf" "--mute=yes" "/tmp/a.mkv")))) + (let ((cj/video-open-args nil)) + (should (equal (cj/--video-open-arglist "/tmp/a.mkv") '("/tmp/a.mkv"))))) + +;;; cj/open-video-looping + +(ert-deftest test-external-open-video-looping-calls-player-with-loop-args () + "Normal: posix path calls the player with loop args + file, async (no wait)." + (let ((tmp (make-temp-file "test-ext-video-" nil ".mp4")) + (call nil)) + (unwind-protect + (cl-letf (((symbol-function 'env-windows-p) (lambda () nil)) + ((symbol-function 'call-process) + (lambda (prog _infile dest _disp &rest args) + (setq call (list prog dest args)) + 0))) + (let ((cj/video-open-command "mpv") + (cj/video-open-args '("--loop-file=inf"))) + (cj/open-video-looping tmp))) + (delete-file tmp)) + (should (equal (nth 0 call) "mpv")) + (should (equal (nth 1 call) 0)) ; async destination: don't wait + (should (member "--loop-file=inf" (nth 2 call))) + (should (cl-find-if (lambda (a) (and (stringp a) + (string-match-p "\\.mp4\\'" a))) + (nth 2 call))))) + +(ert-deftest test-external-open-video-looping-errors-when-no-file () + "Error: a buffer with no associated file signals user-error." + (with-temp-buffer + (cl-letf (((symbol-function 'cj/file-from-context) (lambda (_) nil))) + (should-error (cj/open-video-looping) :type 'user-error)))) + (provide 'test-external-open-commands) ;;; test-external-open-commands.el ends here |
