├── .gitignore ├── README.org └── emp.el /.gitignore: -------------------------------------------------------------------------------- 1 | *.elc 2 | \#*# 3 | .#* 4 | *~ 5 | -------------------------------------------------------------------------------- /README.org: -------------------------------------------------------------------------------- 1 | * EMP : Emacs MPV integration 2 | 3 | -------------------------------------------------------------------------------- /emp.el: -------------------------------------------------------------------------------- 1 | ;;; emp.el --- Simple MPV interface library -*- lexical-binding: t; -*- 2 | 3 | ;;; Commentary: 4 | ;; 5 | (eval-when-compile (require 'subr-x)) 6 | (require 'thingatpt) 7 | 8 | ;;; Code: 9 | (dolist (executable '("socat" "mpv")) 10 | (unless (executable-find executable) 11 | (user-error "Executable %S not found on PATH" executable))) 12 | 13 | (defgroup emp nil 14 | "Simple MPV interface." 15 | :prefix "emp-" 16 | :group 'external) 17 | 18 | (defvar emp--players (make-hash-table :test 'equal) "Table of mpv processes.") 19 | (defvar emp--player-count 0 "Internal counter to keep track of proccesses.") 20 | (defvar emp--selected-players nil "The current MPV context.") 21 | (defvar emp--socket-dir (let ((name (file-name-as-directory 22 | (expand-file-name "emp" (temporary-file-directory))))) 23 | (make-directory name 'parents) 24 | name) 25 | "The directory for storing socket files.") 26 | 27 | (defcustom emp-start-poll-timeout 5 28 | "Freshly strated MPV processes may not recieve commands immediately. 29 | `emp-start' will wait to return by repeatedly sending a test command until MPV 30 | responds. This limit prevents infinite loops if MPV is not responding for 31 | another reason. It is the maximum of seconds we will poll before giving up and 32 | throwing an error." 33 | :type (or 'interger 'float)) 34 | 35 | (defcustom emp-video-directory "~/Videos/" 36 | "Default directory to search for videos when using `emp-open-file'." 37 | :type 'directory) 38 | 39 | (defcustom emp-players-file "/tmp/emp-players.el" 40 | "Where to save the emp players." 41 | :type 'file) 42 | 43 | (defun emp-save-players () 44 | "Save `emp--players' to disk." 45 | (interactive) 46 | (with-temp-buffer 47 | (let (print-level print-length) 48 | (insert (pp-to-string emp--players)) 49 | (write-region (point-min) (point-max) emp-players-file)))) 50 | 51 | (defun emp--read-file (file) 52 | "Read FILE into an elisp object." 53 | ;;@FIX: we need to be more robust here. 54 | (ignore-errors 55 | (read (with-temp-buffer 56 | (insert-file-contents file) 57 | (buffer-string))))) 58 | 59 | (defun emp--detect-players () 60 | "Scan `emp--socket-dir' and reattach to any sockets found." 61 | (dolist (file (cl-remove-if (lambda (file) (member file '("." ".."))) 62 | (directory-files emp--socket-dir))) 63 | (puthash file (list :name file :socket (expand-file-name file emp--socket-dir)) 64 | emp--players))) 65 | 66 | ;;;###autoload 67 | (defun emp-load-players () 68 | "Load players from `emp--players-file'." 69 | (interactive) 70 | (setq emp--players (emp--read-file emp-players-file))) 71 | 72 | (defun emp--ensure-list (obj) 73 | "If OBJ is not a list of players, make it one. 74 | This is to deal with a single player. 75 | Normalizes `completing-read-multiple' and `completing-read' results." 76 | (cond 77 | ((stringp obj) (list obj)) 78 | ((keywordp (car obj)) (list obj)) 79 | (t obj))) 80 | 81 | (defun emp-send-command (players &rest command) 82 | "Send PLAYERS COMMAND." 83 | (or (consp players) (signal 'wrong-type-argument `(cons ,players))) 84 | (mapcar (lambda (player) 85 | (when-let ((socket (plist-get player :socket)) 86 | (message (concat (json-serialize (list :command (apply #'vector command))) 87 | "\n")) 88 | (result (with-temp-buffer 89 | (call-process-region message nil "socat" nil t t "-" socket) 90 | (buffer-string)))) 91 | ;; if the process is unresponsive, return nil 92 | (unless (string-empty-p result) 93 | (if (string-match-p "Connection Refused" result) 94 | (let ((socket (plist-get player :socket))) 95 | (delete-file socket) 96 | (apply #'emp-send-command (emp--start (plist-get player :name)) command)) 97 | (json-parse-string result 98 | :object-type 'plist 99 | :array-type 'list))))) 100 | players)) 101 | 102 | (defun emp--start (&optional name socket) 103 | "Start an IPC enabled MPV process named NAME. 104 | If NAME is nil, autogenerate a numeric one. 105 | If SOCKET is provided, use that socket file instead of creating a new one." 106 | (let* ((name (or name (format "emp%d" (setq emp--player-count (1+ emp--player-count))))) 107 | (socket (or socket (let ((temporary-file-directory emp--socket-dir)) 108 | (make-temp-file (format "%s-" name) nil ".emp")))) 109 | (player (list :socket socket :name name))) 110 | (make-process :name name 111 | :command (list "bash" "-c" 112 | (string-join 113 | (list "nohup" 114 | "mpv" 115 | "--idle=yes" 116 | "--no-terminal" 117 | ;;maybe not 118 | ;;"--keep-open=yes" 119 | "--force-window" 120 | (format "--input-ipc-server=%s" 121 | (shell-quote-argument socket)) 122 | "&> /dev/null") 123 | " ")) 124 | :noquery t) 125 | (with-timeout (emp-start-poll-timeout (error "Unable to start MPV")) 126 | (while (null (car (emp-send-command (list player) "emp-test"))) 127 | (sleep-for 0.1))) 128 | (list (puthash name player emp--players)))) 129 | 130 | (defun emp--parse-time-string (time-string) 131 | "Convert TIME-STRING into list of form: 132 | \\(milliseconds seconds minutes hours)." 133 | (let ((components 134 | (nreverse 135 | (flatten-tree 136 | (mapcar (lambda (component) (split-string component "\\.")) 137 | (split-string time-string ":")))))) 138 | (setq components 139 | (if (string-match "\\(?:\\.\\([[:digit:]]+\\)\\)" time-string) 140 | (push (* (string-to-number (concat "0." (match-string 1 time-string))) 141 | 1000) 142 | (cdr components)) 143 | (push 0 components))) 144 | (unless (= (seq-reduce (lambda (acc char) 145 | (+ acc (if (string= char ":") 1 0))) 146 | (split-string time-string "" 'omit-nulls) 147 | 0) 148 | 2) 149 | (setq components (append components '(0)))) 150 | (mapcar (lambda (component) (if (stringp component) 151 | (string-to-number component) 152 | component)) 153 | components))) 154 | 155 | (defun emp--time-string-to-ms (time) 156 | "Convert TIME to ms." 157 | (let ((places '(1 1000 60000 3600000)) 158 | (index 0)) 159 | (truncate 160 | (apply #'+ 161 | (mapcar 162 | (lambda (unit) 163 | (prog1 (* unit (nth index places)) (cl-incf index))) 164 | (emp--parse-time-string time)))))) 165 | 166 | (defun emp--compact-time-formatter (h m s ms) 167 | "Return shortest time string from H M S MS." 168 | (concat 169 | (cond ((> h 0) (format "%d:%02d:%02d" h m s)) 170 | ((> m 0) (format "%d:%02d" m s)) 171 | (t (format "%d" s))) 172 | (when (> ms 0) (format ".%03d" ms)))) 173 | 174 | (defun emp--format-ms (n) 175 | "Format N milliseconds as timestamp. 176 | If FORMATTER is non-nil, use that format function instead. 177 | It is called with hours, minutes, seconds, milliseconds." 178 | (let* ((milliseconds (mod n 1000)) 179 | (n (/ n 1000)) 180 | (seconds (mod n 60)) 181 | (minutes (mod (/ n 60) 60)) 182 | ;; Don't use mod here because we don't care about 183 | ;; diving any farther than "hours" 184 | ;; using mod to check would truncate the hours 185 | ;; in cases where hours % 60 = 0 186 | (hours (/ n (* 60 60)))) 187 | (emp--compact-time-formatter hours minutes seconds milliseconds))) 188 | 189 | (defun emp--update-player-metadata (players) 190 | "Update PLAYERS metadata." 191 | (dolist (player players) 192 | (let ((name (plist-get player :name))) 193 | (puthash name 194 | (plist-put player 195 | :title 196 | (plist-get (car (emp-send-command (list player) "get_property" "media-title")) 197 | :data)) 198 | emp--players) 199 | (puthash name 200 | (plist-put player 201 | :playback-time 202 | (ignore-errors 203 | (emp--format-ms 204 | (* 1000 205 | (plist-get (car (emp-send-command (list player) 206 | "get_property" "playback-time")) 207 | :data))))) 208 | emp--players)))) 209 | 210 | (defun emp--player-selection-info (player) 211 | "Return a string with metadata info for a PLAYER." 212 | (string-join (list (plist-get player :name) 213 | (plist-get player :title) 214 | (plist-get player :playback-time)) 215 | " ")) 216 | 217 | (defun emp-players (&optional all) 218 | "Return the list of currently selected players. 219 | If ALL is non-nil, return all players. 220 | If only one player is started, return a list containing that. 221 | If more than one player is started, but none is selected, prompt the user 222 | For the players. 223 | Note the results are always contained in a list even if one player is returned." 224 | (if all 225 | (hash-table-values emp--players) 226 | (condition-case _ 227 | (emp--select-players "Select Players: ") 228 | ((user-error) nil)))) 229 | 230 | (defun emp--select-players (&optional prompt) 231 | "PROMPT for players if more than one, else return player list. 232 | If `emp--selected-players' is non-nil, return that player list instead." 233 | (cond 234 | ((zerop (hash-table-count emp--players)) (user-error "No MPV process running")) 235 | (emp--selected-players emp--selected-players) 236 | ((= (hash-table-count emp--players) 1) 237 | (hash-table-values emp--players)) 238 | (t 239 | (emp--update-player-metadata (emp-players 'all)) 240 | (emp--ensure-list 241 | (let ((candidates (mapcar (lambda (player) 242 | (cons (emp--player-selection-info player) player)) 243 | (hash-table-values emp--players)))) 244 | (mapcar (lambda (selection) (alist-get selection candidates nil nil #'string=)) 245 | (emp--ensure-list 246 | (funcall (if current-prefix-arg #'completing-read-multiple #'completing-read) 247 | (or prompt "Select Player: ") 248 | candidates 249 | nil 'require-match)))))))) 250 | 251 | (defun emp-set-context (players) 252 | "Set the MPV PLAYERS context. 253 | Commands that usually prompt when multiple players are running 254 | will only use the value of `emp--selected-players'." 255 | (interactive (list (let ((emp--selected-players nil)) 256 | (emp--select-players "MPV context: ")))) 257 | (setq emp--selected-players (if (or (null players) 258 | (equal players '(nil))) 259 | nil 260 | players))) 261 | 262 | (defun emp-kill (players) 263 | "Kill PLAYERS. 264 | Prompt if PLAYERS is nil and more than one process is running." 265 | (interactive (list (emp--select-players "Kill MPV process(es): "))) 266 | (dolist (player (emp--ensure-list players)) 267 | (ignore-errors 268 | (kill-process (plist-get player :process))) 269 | (emp-send-command (list player) "quit-watch-later") 270 | (when-let ((socket (plist-get player :socket))) 271 | (when (file-exists-p socket) (delete-file socket))) 272 | (when (eq (car emp--selected-players) player) 273 | (setq emp--selected-players nil)) 274 | (remhash (plist-get player :name) emp--players))) 275 | 276 | (defun emp-get-property (property) 277 | "Return PROPERTY data for PLAYERS." 278 | (emp-send-command (emp-players) "get_property" property)) 279 | 280 | (defun emp-set-property (property val) 281 | "Set PROPERTY to VAL for seledcted players." 282 | (emp-send-command (emp-players) "set_property" property val)) 283 | 284 | ;;;###autoload 285 | (defun emp-start (&optional name) 286 | "Start an MPV process named NAME. 287 | If NAME is nil, automatically generate process name." 288 | (interactive "MName: ") 289 | (emp--start name)) 290 | 291 | ;;;###autoload 292 | (defun emp-open-url (url) 293 | "Open URL with selected players." 294 | (interactive "sURL: ") 295 | (emp-send-command (or (emp-players) (emp--start)) "loadfile" url "append-play")) 296 | 297 | ;;;###autoload 298 | (defun emp-open-url-at-point () 299 | "Play URL at point." 300 | (interactive) 301 | (if-let ((url (thing-at-point-url-at-point))) 302 | (emp-open-url url) 303 | (user-error "Point not on a recognized URL"))) 304 | 305 | ;;;###autoload 306 | (defun emp-open-file (file) 307 | "Play FILE with currently selected players. 308 | If called interactively, prompt for one relative to `emp-video-directory'. 309 | When called with \\[universal-argument], prompt relative to `default-directory'. 310 | If no players are started, start one and use that." 311 | (interactive (list (read-file-name 312 | "Play media: " 313 | (ignore-errors (file-name-as-directory 314 | (unless current-prefix-arg emp-video-directory)))))) 315 | (emp-send-command (or (emp-players) (emp--start)) 316 | "loadfile" (expand-file-name file) "append-play")) 317 | 318 | ;;;###autoload 319 | (defun emp-open () 320 | "Open FILE-OR-URL. 321 | If point is on a URL delegate to `emp-open-url', else `emp-open-file'." 322 | (interactive) 323 | (call-interactively (if (thing-at-point-url-at-point) 324 | #'emp-open-url-at-point 325 | #'emp-open-file))) 326 | 327 | (defun emp-pause () 328 | "Cycle pause for currently selected players." 329 | (interactive) 330 | (emp-send-command (emp-players) "osd-msg-bar" "cycle" "pause")) 331 | 332 | (defun emp-fullscreen () 333 | "Cycle fullscren for currently selected players." 334 | (interactive) 335 | (emp-send-command (emp-players) "osd-msg-bar" "cycle" "fullscreen")) 336 | 337 | (defun emp-playlist-next () 338 | "Got to next entry in the selected players' playlists." 339 | (interactive) 340 | (emp-send-command (emp-players) "osd-msg-bar" "playlist-next")) 341 | 342 | (defun emp-playlist-prev () 343 | "Got to next entry in the selected players' playlists." 344 | (interactive) 345 | (emp-send-command (emp-players) "osd-msg-bar" "playlist-prev")) 346 | 347 | (defun emp-frame-step () 348 | "Play one frame, then pause. Does nothing with audio-only playback." 349 | (interactive) 350 | (emp-send-command (emp-players) "osd-msg-bar" "frame-step")) 351 | 352 | (defun emp-frame-back-step () 353 | "Go back one frame, then pause. Does nothing with audio-only playback." 354 | (interactive) 355 | (emp-send-command (emp-players) "osd-msg-bar" "frame-back-step")) 356 | 357 | (defun emp--read-playlist () 358 | "Select player's playlist entry." 359 | (let* ((count -1) 360 | (entries (mapcar (lambda (data) (cons (plist-get data :filename) (setq count (1+ count)))) 361 | (plist-get (car (emp-get-property "playlist")) :data))) 362 | (selection (completing-read "Playlist entry: " entries nil 'require-match))) 363 | (when selection (alist-get selection entries nil nil 'equal)))) 364 | 365 | (defun emp-playlist-play (&optional index) 366 | "Start (or restart) playback of given playlist INDEX. 367 | Indices are 0 indexed and may optionally be any of the following: 368 | - `current` : the current playlist entry. 369 | - `none` : Stop playback." 370 | (interactive (list (emp--read-playlist))) 371 | (emp-send-command (emp-players) "playlist-play-index" (number-to-string (truncate index)))) 372 | 373 | ;;@TODO: this (or maybe a emp-seek-read command) should interpret relative, 374 | ;;absolute and percent based notation. 375 | ;;.e.g. 376 | ;; seeking relative: -1:00 or +2:00 377 | ;; seeking absolute: 2:22 378 | ;; seeking percentage: %50 or 50% 379 | (defun emp-seek (time) 380 | "Seek to TIME. 381 | When called with a numeric prefix argument, seek relative that many seconds. 382 | Else, TIME should be a string of the following form (sections in brackets are 383 | optional): 384 | [H:][MM:][S][.MS] 385 | 386 | And may be prefix/suffixed in any of the folloing ways: 387 | - N% seek to N percent of the file. 388 | - +N seek forward N seconds. 389 | - -N seek backward N seconds. 390 | - N seek to the asbolute time denoted by N." 391 | (interactive (list (or current-prefix-arg (read-string "Seek: ")))) 392 | (let ((players (emp-players))) 393 | (unless players (user-error "No players selected")) 394 | (if current-prefix-arg 395 | (emp-send-command players "osd-msg-bar" "seek" 396 | (number-to-string (truncate time)) "relative+exact") 397 | (let* ((relative-positive (string-prefix-p "+" time)) 398 | (relative-negative (string-prefix-p "-" time)) 399 | (percentage (string-suffix-p "%" time)) 400 | (time (cond 401 | (relative-positive (/ (emp--time-string-to-ms 402 | (substring time 1)) 403 | 1000)) 404 | (relative-negative (* (/ (emp--time-string-to-ms 405 | (substring time 1)) 406 | 1000) 407 | -1)) 408 | (percentage (string-to-number (substring time nil -1))) 409 | (t (/ (emp--time-string-to-ms time) 1000))))) 410 | (emp-send-command players "osd-msg-bar" "seek" 411 | (number-to-string time) 412 | (concat 413 | (cond 414 | ((or relative-positive relative-negative) 415 | "relative") 416 | (percentage "absolute-percent") 417 | (t "absolute")) 418 | "+exact")))))) 419 | 420 | (defun emp-revert-seek () 421 | "Undo the last seek." 422 | (interactive) 423 | (emp-send-command (emp-players) "osd-msg-bar" "revert-seek")) 424 | 425 | (defun emp-playback-time () 426 | "Return current playback time." 427 | (let ((time (plist-get (car (emp-get-property "playback-time")) :data))) 428 | (emp--format-ms (truncate (* 1000 time))))) 429 | 430 | (defun emp-insert-playback-time () 431 | "Insert current playback time." 432 | (interactive) 433 | (insert (emp-playback-time))) 434 | 435 | (defun emp-speed-set (factor) 436 | "Set playback speed to FACTOR. 437 | If called interactively with \\[universal-argument] reset speed to 1." 438 | (interactive (list (if current-prefix-arg 1 (read-number "Factor: ")))) 439 | (emp-set-property "speed" (abs factor))) 440 | 441 | (defun emp-seek-absolute (time) 442 | "Seek to absolute TIME." 443 | (interactive "MTime: ") 444 | (emp-send-command (emp-players) "osd-msg-bar" "seek" 445 | (/ (emp--time-string-to-ms time) 1000) "absolute")) 446 | 447 | (defun emp-cycle-osd () 448 | "Cycle the osd-level." 449 | (interactive) 450 | (emp-send-command (emp-players) "no-osd" "cycle-values" "osd-level" "3" "1")) 451 | 452 | (defun emp-playlist-reverse () 453 | "Reverse player's playlist." 454 | (interactive) 455 | (dotimes (n (plist-get (car (emp-get-property "playlist-count")) :data)) 456 | (emp-send-command (emp-players) "playlist-move" (1+ n) 0))) 457 | 458 | (provide 'emp) 459 | 460 | ;;; emp.el ends here 461 | --------------------------------------------------------------------------------