dotemacs

My Emacs configuration
git clone git://git.entf.net/dotemacs
Log | Files | Refs | LICENSE

elfeed-tube-mpv.el (12491B)


      1 ;;; elfeed-tube-mpv.el --- Control mpv from Elfeed  -*- lexical-binding: t; -*-
      2 
      3 ;; Copyright (C) 2022  Karthik Chikmagalur
      4 
      5 ;; Author: Karthik Chikmagalur <karthikchikmagalur@gmail.com>
      6 ;; version: 0.10
      7 ;; Package-Version: 20220704.1952
      8 ;; Package-Commit: 5817c91f5b3b7159965aa73839d2a0a08fd952bd
      9 ;; Keywords: news, hypermedia
     10 ;; Package-Requires: ((emacs "27.1") (elfeed-tube "0.10") (mpv "0.2.0"))
     11 ;; URL: https://github.com/karthink/elfeed-tube
     12 
     13 ;; SPDX-License-Identifier: UNLICENSE
     14 
     15 ;; This file is NOT part of GNU Emacs.
     16 
     17 ;;; Commentary:
     18 ;;
     19 ;; This package provides integration with the mpv video player for `elfeed-tube'
     20 ;; entries, which see.
     21 ;;
     22 ;; With `elfeed-tube-mpv' loaded, clicking on a transcript segment in an Elfeed
     23 ;; Youtube video feed entry will launch mpv at that time, or seek to that point
     24 ;; if already playing.
     25 ;;
     26 ;; It defines two commands and a minor mode:
     27 ;;
     28 ;; - `elfeed-tube-mpv': Start an mpv session that is "connected" to an Elfeed
     29 ;; entry corresponding to a Youtube video. You can use this command to start
     30 ;; playback, or seek in mpv to a transcript segment, or enqueue a video in mpv
     31 ;; if one is already playing. Call with a prefix argument to spawn a new
     32 ;; instance of mpv instead.
     33 ;;
     34 ;; - `elfeed-tube-mpv-where': Jump in Emacs to the transcript position
     35 ;; corresponding to the current playback time in mpv.
     36 ;;
     37 ;; - `elfeed-tube-mpv-follow-mode': Follow along in the transcript in Emacs to
     38 ;; the video playback.
     39 ;;
     40 ;;; Code:
     41 (require 'pulse)
     42 (require 'elfeed-tube)
     43 (require 'mpv)
     44 
     45 (defcustom elfeed-tube-mpv-options
     46   '(;; "--ytdl-format=bestvideo[height<=?480]+bestaudio/best"
     47     "--cache=yes"
     48     ;; "--script-opts=osc-scalewindowed=2,osc-visibility=always"
     49     )
     50   "List of command line arguments to pass to mpv.
     51 
     52 If the mpv library is available, these are appended to
     53 `mpv-default-options'. Otherwise mpv is started with these options.
     54 
     55 Each element in this list is a string. Examples:
     56 - \"--cache=yes\"
     57 - \"--osc=no\""
     58   :group 'elfeed-tube
     59   :type '(repeat string))
     60 (defvar elfeed-tube-mpv--available-p
     61   (and (executable-find "mpv")
     62        (or (executable-find "youtube-dl")
     63            (executable-find "yt-dlp"))))
     64 (defvar-local elfeed-tube-mpv--follow-p nil)
     65 (defvar elfeed-tube-mpv--follow-timer nil)
     66 (defvar-local elfeed-tube-mpv--overlay nil)
     67 (defvar elfeed-tube-mpv-hook nil
     68   "Hook run before starting mpv playback in an elfeed-show buffer.
     69 
     70 Each function must accept one argument, the current Elfeed
     71 entry.")
     72 
     73 (let ((map elfeed-tube-captions-map))
     74   (define-key map (kbd "RET") #'elfeed-tube-mpv)
     75   (define-key map [mouse-1] (elfeed-tube-captions-browse-with
     76                              #'elfeed-tube-mpv))
     77   (define-key map (kbd "C-<down-mouse-1>")
     78     (elfeed-tube-captions-browse-with
     79      (lambda (pos) (elfeed-tube-mpv pos t)))))
     80 
     81 (setq-default
     82  elfeed-tube--captions-echo-message
     83  (defsubst elfeed-tube-mpv--echo-message (time)
     84    (format
     85     "  mouse-1: open at %s (mpv)
     86 C-mouse-1: open at %s (mpv, new instance)
     87   mouse-2: open at %s (web browser)"
     88     time time time)))
     89 
     90 (defsubst elfeed-tube-mpv--check-path (video-url)
     91   "Check if currently playing mpv video matches VIDEO-URL."
     92   (condition-case nil
     93       (apply #'string=
     94              (mapcar
     95               (lambda (s)
     96                 (replace-regexp-in-string
     97                  "&t=[0-9.]*" "" s))
     98               (list (mpv-get-property "path")
     99                     video-url)))
    100     ('error nil)))
    101 
    102 (defsubst elfeed-tube-mpv--set-timer (entry)
    103   "Start mpv position update timer for ENTRY."
    104   (setq elfeed-tube-mpv--follow-timer
    105         (run-with-timer
    106          4 1.5 #'elfeed-tube-mpv--follow entry)))
    107 
    108 (defsubst elfeed-tube-mpv--overlay-clear ()
    109   "Clear mpv position overlay."
    110   (progn (when (timerp elfeed-tube-mpv--follow-timer)
    111            (cancel-timer elfeed-tube-mpv--follow-timer))
    112          (when (overlayp elfeed-tube-mpv--overlay)
    113            (delete-overlay elfeed-tube-mpv--overlay))))
    114 
    115 (defun elfeed-tube-mpv (pos &optional arg)
    116   "Start or seek an mpv session connected to an Elfeed entry.
    117 
    118 Call this command with point POS on an Elfeed entry in an Elfeed
    119 Search buffer, or anywhere in an Elfeed Entry, to play the
    120 corresponding video. When called with point in a transcript
    121 segment, seek here or start a new session as appropriate. If a
    122 connected mpv session for a different video is already running
    123 enqueue this video instead.
    124 
    125 With prefix argument ARG always start a new, unnconnected mpv
    126 session."
    127   (interactive (list (point)
    128                      current-prefix-arg))
    129   (if (not elfeed-tube-mpv--available-p)
    130       (message "Could not find mpv + youtube-dl/yt-dlp in PATH.")
    131     (when-let* ((time (or (get-text-property pos 'timestamp) 0))
    132                 (entry (or elfeed-show-entry
    133                            (elfeed-search-selected 'ignore-region)))
    134                 (video-id (elfeed-tube--get-video-id entry))
    135                 (video-url (concat "https://youtube.com/watch?v="
    136                                    video-id
    137                                    "&t="
    138                                    (number-to-string (floor time))))
    139                 (args (append elfeed-tube-mpv-options (list video-url))))
    140       (run-hook-with-args 'elfeed-tube-mpv-hook entry)
    141       ;; (pulse-momentary-highlight-one-line)
    142       (if (and (not arg) (require 'mpv nil t))
    143           (if (mpv-live-p)
    144               (if (elfeed-tube-mpv--check-path video-url)
    145                   (unless (= 0 time)
    146                     (mpv-seek time))
    147                 (mpv--enqueue `("loadfile" ,video-url "append")
    148                               #'ignore)
    149                 (message "Added to playlist: %s"
    150                          (elfeed-entry-title entry)))
    151             (apply #'mpv-start args)
    152             (message
    153              (concat "Starting mpv: "
    154                      (propertize "Connected to Elfeed ✓"
    155                                  'face 'success)))
    156             (when elfeed-tube-mpv--follow-p
    157               (elfeed-tube-mpv--set-timer entry)))
    158         (apply #'start-process
    159                (concat "elfeed-tube-mpv-"
    160                        (elfeed-tube--get-video-id elfeed-show-entry))
    161                nil "mpv" args)
    162         (message (concat "Starting new mpv instance: "
    163                          (propertize "Not connected to Elfeed ❌"
    164                                      'face 'error)))))))
    165 
    166 (defun elfeed-tube-mpv--follow (entry-playing)
    167   "Folllow the ENTRY-PLAYING in mpv in Emacs.
    168 
    169 This function is intended to be run on a timer when
    170 `elfeed-tube-mpv-follow-mode' is active."
    171   (if (not (mpv-live-p))
    172       (elfeed-tube-mpv--overlay-clear)
    173     (when-let ((entry-buf (get-buffer
    174                            (elfeed-show--buffer-name
    175                             entry-playing))))
    176       (when (and (or (derived-mode-p 'elfeed-show-mode)
    177 		     (window-live-p (get-buffer-window entry-buf)))
    178 		 (elfeed-tube--same-entry-p
    179                   (buffer-local-value 'elfeed-show-entry entry-buf)
    180 		  entry-playing)
    181 		 (eq (mpv-get-property "pause")
    182 		     json-false))
    183 	(condition-case nil
    184 	    (when-let ((mpv-time (mpv-get-property "time-pos")))
    185 	      (with-current-buffer entry-buf
    186 
    187 		;; Create overlay
    188 		(unless (overlayp elfeed-tube-mpv--overlay)
    189 		  (save-excursion
    190 		    (goto-char (point-min))
    191 		    (text-property-search-forward
    192 		     'timestamp)
    193 		    (setq elfeed-tube-mpv--overlay
    194 			  (make-overlay (point) (point)))
    195 		    (overlay-put elfeed-tube-mpv--overlay
    196 				 'face '(:inverse-video t))))
    197 		
    198                 ;; Handle narrowed buffers
    199                 (when (buffer-narrowed-p)
    200                   (save-excursion
    201                     (let ((min (point-min))
    202                           (max (point-max))
    203                           beg end)
    204                       (goto-char min)
    205                       (setq beg (prop-match-value
    206                                  (text-property-search-forward
    207                                   'timestamp)))
    208                       (goto-char max)
    209                       (widen)
    210                       (setq end (prop-match-value
    211                                  (text-property-search-forward
    212                                   'timestamp)))
    213                       (narrow-to-region min max)
    214                       (cond
    215                        ((and beg (< mpv-time beg))
    216                         (mpv-set-property "time-pos" (1- beg)))
    217                        ((and end (> mpv-time end))
    218                         (mpv-set-property "time-pos" (1+ end))
    219                         (mpv-set-property "pause" t))))))
    220                 
    221                 ;; Update overlay
    222                 (when-let ((next (elfeed-tube-mpv--where-internal mpv-time)))
    223                   (goto-char next)
    224                   (move-overlay elfeed-tube-mpv--overlay
    225 				(save-excursion (beginning-of-visual-line) (point))
    226 				(save-excursion (end-of-visual-line) (point))))))
    227 	  ('error nil))))))
    228 
    229 (defun elfeed-tube-mpv--where-internal (mpv-time)
    230   "Return the point in the Elfeed buffer that corresponds to time MPV-TIME."
    231   (save-excursion
    232       (while (not (get-text-property (point) 'timestamp))
    233         (goto-char (or (previous-single-property-change
    234 		        (point) 'timestamp)
    235 		       (next-single-property-change
    236 		        (point) 'timestamp))))
    237 
    238       (if (> (get-text-property (point) 'timestamp)
    239 	     mpv-time)
    240 	  (let ((match (text-property-search-backward
    241 		        'timestamp mpv-time
    242 		        (lambda (mpv cur)
    243 			  (< (or cur
    244 			         (get-text-property
    245 				  (1+ (point))
    246 				  'timestamp))
    247 			     (- mpv 1))))))
    248 	    (goto-char (prop-match-end match))
    249 	    (text-property-search-forward 'timestamp)
    250 	    (min (1+ (point)) (point-max)))
    251         (let ((match (text-property-search-forward
    252 		      'timestamp mpv-time
    253 		      (lambda (mpv cur) (if cur (> cur (- mpv 1)))))))
    254           (prop-match-beginning match)))))
    255 
    256 (defun elfeed-tube-mpv-where ()
    257   "Jump to the current mpv position in a video transcript."
    258   (interactive)
    259   (cond
    260    ((not (featurep 'mpv))
    261     (message "mpv-where requires the mpv package. You can install it with M-x `package-install' RET mpv RET."))
    262    ((not (and (derived-mode-p 'elfeed-show-mode)
    263               (elfeed-tube--youtube-p elfeed-show-entry)))
    264     (message "Not in an elfeed-show buffer for a Youtube video!"))
    265    ((not (mpv-live-p))
    266     (message "No running instance of mpv is connected to Emacs."))
    267    ((or (previous-single-property-change
    268 	 (point) 'timestamp)
    269 	(next-single-property-change
    270 	 (point) 'timestamp))
    271     (goto-char (elfeed-tube-mpv--where-internal
    272                 (mpv-get-property "time-pos")))
    273     (let ((pulse-delay 0.08)
    274           (pulse-iterations 16))
    275       (pulse-momentary-highlight-one-line)))
    276    (t (message "Transcript location not found in buffer."))))
    277 
    278 (define-minor-mode elfeed-tube-mpv-follow-mode
    279   "Follow along with mpv in elfeed-show buffers.
    280 
    281 This appliies to Youtube feed entries in Elfeed. When the video
    282 player mpv is started from this buffer (from any location in the
    283 transcript), turning on this minor-mode will cause the cursor to
    284 track the currently playing segment in mpv. You can still click
    285 anywhere in the transcript to seek to that point in the video."
    286   :global nil
    287   :version "0.10"
    288   :lighter " (-->)"
    289   :keymap (let ((map (make-sparse-keymap)))
    290             (prog1 map
    291               (define-key map " " #'mpv-pause)))
    292   :group 'elfeed-tube
    293   (if elfeed-tube-mpv-follow-mode
    294       (cond
    295        
    296        ((not (require 'mpv nil t))
    297         (message "mpv-follow-mode requires the mpv package. You can install it with M-x `package-install' RET mpv RET.")
    298         (elfeed-tube-mpv-follow-mode -1))
    299        
    300        ((not (derived-mode-p 'elfeed-show-mode))
    301         (message "mpv-follow-mode only works in elfeed-show buffers.")
    302         (elfeed-tube-mpv-follow-mode -1))
    303        
    304        (t (if-let* ((entry elfeed-show-entry)
    305                     (video-id (elfeed-tube--get-video-id entry))
    306                     (video-url
    307                      (concat "https://youtube.com/watch?v="
    308                              video-id)))
    309               (if (and (mpv-live-p) (elfeed-tube-mpv--check-path video-url))
    310                   (elfeed-tube-mpv--set-timer entry)
    311                 (setq-local elfeed-tube-mpv--follow-p t))
    312             (message "Not a youtube video buffer!")
    313             (elfeed-tube-mpv-follow-mode -1))))
    314     
    315     (setq-local elfeed-tube-mpv--follow-p nil)
    316     (when (timerp elfeed-tube-mpv--follow-timer)
    317       (cancel-timer elfeed-tube-mpv--follow-timer))
    318     (elfeed-tube-mpv--overlay-clear)))
    319 
    320 (provide 'elfeed-tube-mpv)
    321 ;;; elfeed-tube-mpv.el ends here