elfeed-tube-mpv.el (12488B)
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: 0.15 8 ;; Package-Commit: 7e1409e41628d61d8197ca248d910182ae4fc520 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--entry-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--entry-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--entry-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