diff options
author | Mike Kazantsev <mk.fraggod@gmail.com> | 2018-05-26 02:02:04 +0500 |
---|---|---|
committer | fraggod@malediction <mk.fraggod@gmail.com> | 2018-05-26 02:05:32 +0500 |
commit | 55c83369162231b31433cd404466e5641cd90353 (patch) | |
tree | d1614f672389863740098711e3262be223c9bd71 | |
parent | 40acbffce2d0a5341651df3495b1ccdf4832420f (diff) |
* lisp/emms-player-mpv.el: full rewrite with added json-rpc support
Rewritten from scratch, supporting both old oneshot mpv process operation
as well as long-running mpv --idle instances for newer versions.
-rw-r--r-- | AUTHORS | 1 | ||||
-rw-r--r-- | NEWS | 3 | ||||
-rw-r--r-- | lisp/emms-player-mpv.el | 755 |
3 files changed, 673 insertions, 86 deletions
@@ -34,6 +34,7 @@ Yoni (Johnathan) Rabkin <yrk@gnu.org> mathias.dahl <mathias.dahl> Rasmus Pank Roulund <emacs@pank.eu> ZHANG Weiyi <dochang@gmail.com> +Mike Kazantsev <mk.fraggod@gmail.com> The following is a list of people who contributed trivial patches, @@ -1,6 +1,7 @@ News since version 5.0 - + - Rewrite mpv backend to use long-running mpv instances controlled over + json-rpc protocol for newer versions. News since version 4.4 diff --git a/lisp/emms-player-mpv.el b/lisp/emms-player-mpv.el index 4229a63..74e215c 100644 --- a/lisp/emms-player-mpv.el +++ b/lisp/emms-player-mpv.el @@ -1,13 +1,8 @@ ;;; emms-player-mpv.el --- mpv support for EMMS - -;; Copyright (C) 2013-2018 ZHANG Weiyi -;; Copyright (C) 2014 Alex Kost -;; Copyright (C) 2018 stardiviner <numbchild@gmail.com> +;; ;; Copyright (C) 2018 Free Software Foundation, Inc. -;; Authors: ZHANG Weiyi <dochang@gmail.com>, -;; Alex Kost <alezost@gmail.com>, -;; stardiviner <numbchild@gmail.com> +;; Authors: Mike Kazantsev <mk.fraggod@gmail.com> ;; This file is part of EMMS. @@ -27,98 +22,688 @@ ;;; Commentary: -;; This provides a player that uses mpv. It supports pause and -;; seeking. See mpv manual for more. ;; -;; To load subtitles automatically, -;; put "`sub-auto=fuzzy"` in the mpv's config file. +;; This code provides EMMS backend for using mpv player. +;; +;; It works in one of two modes, depending on `emms-player-mpv-ipc-method' +;; customizable value or installed mpv version: +;; +;; - Using long-running mpv instance and JSON IPC interface to switch tracks +;; and receive player feedback/metadata - for mpv 0.7.0 2014-10-16 and later. +;; +;; - Starting new mpv instance for each track, using its exit +;; as "next track" signal and --input-file interface for pause/seek. +;; Used as a fallback for any older mpv versions (supported in all of them). +;; +;; In default configuration, mpv will read its configuration files +;; (see its manpage for locations), and can display window for +;; video, subtitles, album-art or audio visualization. ;; -;; To disable/enable the cover display when playing music, modify -;; `emms-player-mpv-parameters'. For example: (add-to-list -;; 'emms-player-mpv-parameters "--no-audio-display") Alternatively -;; you can also add "audio-display=no" to mpv's config file. +;; Useful `emms-player-mpv-parameters' tweaks: +;; +;; - Ignore config file(s): (add-to-list 'emms-player-mpv-parameters "--no-config") +;; - Disable vo window: (add-to-list 'emms-player-mpv-parameters "--vo=null") +;; - Show simple cqt visualizer window: +;; (add-to-list 'emms-player-mpv-parameters +;; "--lavfi-complex=[aid1]asplit[ao][a]; [a]showcqt[vo]") +;; +;; See "M-x customize-group emms-player-mpv" and mpv manpage for more options. +;; +;; See `emms-mpv-event-connect-hook' and `emms-mpv-event-functions', +;; as well as `emms-mpv-ipc-req-send' for handling more mpv events, +;; processing more playback info and metadata from it, as well as extending +;; control over its vast functionality. ;; -;; This file is based on `emms-player-mplayer.el'. It was originally hosted at -;; https://github.com/dochang/emms-player-mpv. ;;; Code: -(require 'emms-compat) -(require 'emms-player-simple) - -(defcustom emms-player-mpv-input-file - (expand-file-name (locate-user-emacs-file "emms-mpv-input-file")) - "The file to send command to mpv." - :type 'file - :group 'emms) - -(define-emms-simple-player mpv '(file url streamlist playlist) - (concat "\\`\\(https?\\|mms\\)://\\|" - (apply #'emms-player-simple-regexp - emms-player-base-format-list)) - "mpv" "--no-audio-display" "--quiet" "--really-quiet") - -(defadvice emms-player-mpv-start (around append-arguments activate) - (unless (file-exists-p emms-player-mpv-input-file) - (call-process "mkfifo" nil nil nil emms-player-mpv-input-file)) - (let* ((input-file (format "--input-file=%s" emms-player-mpv-input-file)) - (track-arg (let* ((track (ad-get-arg 0)) - (track-type (emms-track-get track 'type)) - (track-name (emms-track-name track))) - (if (memq track-type '(streamlist playlist)) - (format "--playlist=%s" track-name) - track-name))) - (process (apply 'start-process - emms-player-simple-process-name - nil - emms-player-mpv-command-name - (append emms-player-mpv-parameters - (list input-file track-arg))))) - (set-process-sentinel process 'emms-player-simple-sentinel)) - (emms-player-started emms-player-mpv)) - -(emms-player-set emms-player-mpv - 'pause - 'emms-player-mpv-pause) - -(emms-player-set emms-player-mpv - 'resume - 'emms-player-mpv-resume) - -(emms-player-set emms-player-mpv - 'seek - 'emms-player-mpv-seek) - -(emms-player-set emms-player-mpv - 'seek-to - 'emms-player-mpv-seek-to) - -(defun emms-player-mpv--format-command (fmt &rest args) - "Generate shell command to control mpv." - (let ((mpv-cmd (apply 'format fmt args))) - (format "echo %s > %s" - (shell-quote-argument mpv-cmd) - (shell-quote-argument emms-player-mpv-input-file)))) + +(require 'emms) +(require 'json) +(require 'cl-lib) + + +(defcustom emms-player-mpv + (emms-player + 'emms-player-mpv-start + 'emms-player-mpv-stop + 'emms-player-mpv-playable-p) + "*Parameters for mpv player." + :type '(cons symbol alist) + :group 'emms-player-mpv) + +(defcustom emms-player-mpv-command-name "mpv" + "mpv binary to use. Can be absolute path or just binary name." + :type 'file + :group 'emms-player-mpv) + +(defcustom emms-player-mpv-parameters + '("--quiet" "--really-quiet" "--no-audio-display") + "Extra command-line arguments for started mpv process(es). +Either a list of strings or function returning such list. +Extra arguments --idle and --input-file/--input-ipc-server +are added automatically, depending on mpv version. +Note that unless --no-config option is specified here, +mpv will also use options from its configuration files. +For mpv binary path, see `emms-player-mpv-command-name'." + :type '(choice (repeat :tag "List of mpv arguments" string) function) + :group 'emms-player-mpv) + +(defcustom emms-player-mpv-environment () + "List of extra environment variables (\"VAR=value\" strings) to pass on to mpv process. +These are added on top of `process-environment' by default. +Adding nil as an element to this list will discard emacs +`process-environment' and only pass variables that are specified in the list." + :type '(repeat (choice string (const :tag "Start from blank environment" nil))) + :group 'emms-player-mpv) + +(defcustom emms-player-mpv-ipc-method nil + "Switch for which IPC method to use with mpv. +Possible symbols: detect, ipc-server, unix-socket, file. +Defaults to nil value, which will cause `emms-mpv-ipc-detect' +to pick one based on mpv --version output. +Using JSON-IPC variants (ipc-server and unix-socket) enables +support for various feedback and metadata options from mpv." + :type '(choice + (const :tag "Auto-detect from mpv --version" nil) + (const :tag "Use --input-ipc-server JSON IPC (v0.17.0 2016-04-11)" ipc-server) + (const :tag "Use --input-unix-socket JSON IPC (v0.7.0 2014-10-16)" unix-socket) + (const :tag "Use --input-file FIFO (any mpv version)" file)) + :group 'emms-player-mpv) + +(defcustom emms-player-mpv-ipc-socket + (expand-file-name (locate-user-emacs-file "emms-mpv-ipc.sock")) + "Unix IPC socket or FIFO to use with mpv --input-* options, +depending on `emms-player-mpv-ipc-method' value and/or mpv version." + :type 'file + :group 'emms-player-mpv) + +(defvar emms-mpv-ipc-proc nil) ; to avoid warnings while keeping useful defs at the top + +(defcustom emms-player-mpv-update-duration t + "Update track duration when played by mpv. +Uses `emms-mpv-event-functions' hook." + :type 'boolean + :set (lambda (sym value) + (run-at-time 0.1 nil + (lambda (value) (if value + (add-hook 'emms-mpv-event-functions 'emms-mpv-info-duration-event-func) + (remove-hook 'emms-mpv-event-functions 'emms-mpv-info-duration-event-func))) + value)) + :group 'emms-player-mpv) + +(defcustom emms-player-mpv-update-metadata nil + "Update track info (artist, album, name, etc) from mpv events, when it is played. +This allows to dynamically update stream info from ICY tags, for example. +Uses `emms-mpv-event-connect-hook' and `emms-mpv-event-functions' hooks." + :type 'boolean + :set (lambda (sym value) + (run-at-time 0.1 nil + (lambda (value) (if value + (progn + (add-hook 'emms-mpv-event-connect-hook 'emms-mpv-info-meta-connect-func) + (add-hook 'emms-mpv-event-functions 'emms-mpv-info-meta-event-func) + (when (process-live-p emms-mpv-ipc-proc) (emms-mpv-info-meta-connect-func))) + (progn + (remove-hook 'emms-mpv-event-connect-hook 'emms-mpv-info-meta-connect-func) + (remove-hook 'emms-mpv-event-functions 'emms-mpv-info-meta-event-func)))) + value)) + :group 'emms-player-mpv) + + +(defvar emms-mpv-proc nil + "Running mpv process, controlled over --input-ipc-server/--input-file sockets.") + +(defvar emms-mpv-proc-kill-delay 5 + "Delay until SIGKILL gets sent to `emms-mpv-proc', +if it refuses to exit cleanly on `emms-mpv-proc-stop'.") + + +(defvar emms-mpv-ipc-proc nil + "Unix socket process that communicates with running `emms-mpv-proc' instance.") + +(defvar emms-mpv-ipc-buffer " *emms-mpv-ipc*" + "Buffer to associate with `emms-mpv-ipc-proc' socket/pipe process.") + +(defvar emms-mpv-ipc-connect-timer nil + "Timer for connection attempts to JSON IPC unix socket.") +(defvar emms-mpv-ipc-connect-delays + '(0.1 0.1 0.1 0.1 0.1 0.1 0.2 0.2 0.3 0.3 0.5 1.0 1.0 2.0) + "List of delays before initiating socket connection for new mpv process.") + +(defvar emms-mpv-ipc-connect-command nil + "JSON command for `emms-mpv-ipc-sentinel' to run as soon as it connects to mpv. +I.e. last command that either initiated connection or was used while connecting to mpv. +Set by `emms-player-mpv-start' and such, cleared once it gets sent by `emms-mpv-ipc-sentinel'.") + +(defvar emms-mpv-ipc-id 1 + "Auto-incremented value sent in JSON requests for request_id and observe_property id's. +Use `emms-mpv-ipc-id-get' to get and increment this value, instead of using it directly. +Wraps-around upon reaching `emms-mpv-ipc-id-max' (unlikely to ever happen).") + +(defvar emms-mpv-ipc-id-max (expt 2 30) + "Max value for `emms-mpv-ipc-id' to wrap around after. +Should be fine with both mpv and emacs, and probably never reached anyway.") + +(defvar emms-mpv-ipc-req-table nil + "Auto-initialized hash table of outstanding API req_ids to their handler funcs.") + + +(defvar emms-mpv-event-connect-hook nil + "Normal hook run right after establishing new JSON IPC +connection to mpv instance and before `emms-mpv-ipc-connect-command' if any. +Best place to send any observe_property, request_log_messages, enable_event commands. +Use `emms-mpv-ipc-id-get' to get unique id values for these. +See also `emms-mpv-event-functions'.") + +(defvar emms-mpv-event-functions nil + "List of functions to call for each event emitted from JSON IPC. +One argument is passed to each function - JSON line, +as sent by mpv and decoded by `json-read-from-string'. +See also `emms-mpv-event-connect-hook'.") + + +(defvar emms-mpv-stopped nil + "Non-nil if playback was stopped by call from emms. +Similar to `emms-player-stopped-p', but set for future async events, +to indicate that playback should stop instead of switching to next track.") + +(defvar emms-mpv-idle-timer (timer-create) + "Timer to delay `emms-player-stopped' when mpv unexpectedly goes idle.") + +(defvar emms-mpv-idle-delay 0.5 + "Delay before issuing `emms-player-stopped' when mpv unexpectedly goes idle.") + + +;; ----- helpers + +(defvar emms-mpv-debug nil + "Enable to print sent/received JSON lines and process +start/stop events to *Messages* buffer using `emms-mpv-debug-msg'.") + +(defvar emms-mpv-debug-ts-offset nil + "Timestamp offset for `emms-mpv-debug-msg'. +Set on first use, with intent to both shorten and obfuscate time in logs.") + +(defun emms-mpv-debug-trim (s) + (if (stringp s) + (replace-regexp-in-string "\\(^[ \t\n\r]+\\|[ \t\n\r]+$\\)" "" s t t) s)) + +(defun emms-mpv-debug-msg (tpl-or-msg &rest tpl-values) + "Print debug message to *Messages* if `emms-mpv-debug' is non-nil. +Message is only formatted if TPL-VALUES is non-empty. +Strips whitespace from start/end of TPL-OR-MSG and strings in TPL-VALUES." + (when emms-mpv-debug + (setq + tpl-or-msg (emms-mpv-debug-trim tpl-or-msg) + tpl-values (seq-map 'emms-mpv-debug-trim tpl-values)) + (unless tpl-values + (setq tpl-or-msg (replace-regexp-in-string "%" "%%" tpl-or-msg t t))) + (let ((ts (float-time))) + (unless emms-mpv-debug-ts-offset (setq emms-mpv-debug-ts-offset ts)) + (apply 'message + (concat "emms-mpv %.1f " tpl-or-msg) + (- ts emms-mpv-debug-ts-offset) tpl-values)))) + +(defun emms-mpv-ipc-fifo-p () + "Returns non-nil if --input-file fifo should be used. +Runs `emms-mpv-ipc-detect' to detect/set `emms-player-mpv-ipc-method' if necessary." + (unless emms-player-mpv-ipc-method + (setq emms-player-mpv-ipc-method + (emms-mpv-ipc-detect emms-player-mpv-command-name))) + (eq emms-player-mpv-ipc-method 'file)) + +(defun emms-mpv-ipc-detect (cmd) + "Run mpv --version and return symbol for best IPC method supported. +CMD should be either name of mpv binary to use or full path to it. +Return values correspond to `emms-player-mpv-ipc-method' options. +Error is signaled if mpv binary fails to run." + (with-temp-buffer + (let ((exit-code (call-process cmd nil '(t t) nil "--version"))) + (unless (zerop exit-code) + (insert (format "----- process exited with code %d -----" exit-code)) + (error (format "Failed to run mpv binary [%s]:\n%s" cmd (buffer-string)))) + (goto-char (point-min)) + (pcase + (if (re-search-forward "^mpv\\s-+\\(\\([0-9]+\\.?\\)+\\)" nil t 1) + (mapconcat (lambda (n) (format "%03d" n)) + (seq-map 'string-to-number + (split-string (match-string-no-properties 1) "\\." t)) ".") + "000.000.000") + ((pred (string> "000.006.999")) 'file) + ((pred (string> "000.016.999")) 'unix-socket) + (- 'ipc-server))))) + + +;; ----- mpv process + +(defun emms-mpv-proc-playing-p (&optional proc) + "Return whether playback in PROC or `emms-mpv-proc' is started, +which is distinct from 'start-command sent' and 'process is running' states. +Used to signal emms via `emms-player-started' and `emms-player-stopped' calls." + (let ((proc (or proc emms-mpv-proc))) + (if proc (process-get proc 'mpv-playing) nil))) + +(defun emms-mpv-proc-playing (state &optional proc) + "Set process mpv-playing state flag for `emms-mpv-proc-playing-p'." + (let ((proc (or proc emms-mpv-proc))) + (when proc (process-put proc 'mpv-playing state)))) + +(defun emms-mpv-proc-symbol-id (sym &optional proc) + "Get unique process-specific id integer for SYM or nil if it was already requested." + (let + ((proc (or proc emms-mpv-proc)) + (sym-id (intern (concat "mpv-sym-" (symbol-name sym))))) + (unless (process-get proc sym-id) + (let ((id (emms-mpv-ipc-id-get))) (process-put proc sym-id id) id)))) + +(defun emms-mpv-proc-init-fifo (path &optional mode) + "Create named pipe (fifo) socket for mpv --input-file PATH, if not exists already. +Optional MODE should be 12-bit octal integer, e.g. #o600 (safe default). +Signals error if mkfifo exits with non-zero code." + (let ((attrs (file-attributes path))) + (when ; remove non-fifo at the same path + (and attrs (not (string-prefix-p "p" (nth 8 attrs)))) + (delete-file path) (setq attrs nil)) + (unless attrs + (unless + (zerop (call-process "mkfifo" nil nil nil + (format "--mode=%o" (or mode #o600)) path)) + (error (format "Failed to run mkfifo for mpv --input-file path: %s" path)))))) + +(defun emms-mpv-proc-sentinel (proc ev) + (let + ((status (process-status proc)) + (playing (emms-mpv-proc-playing-p proc))) + (emms-mpv-debug-msg + "proc[%s]: %s (status=%s, playing=%s)" proc ev status playing) + (when (and (memq status '(exit signal)) playing) (emms-player-stopped)))) + +(defun emms-mpv-proc-init (&rest media-args) + "initialize new mpv process as `emms-mpv-proc'. +MEDIA-ARGS are used instead of --idle, if specified." + (emms-mpv-proc-stop) + (when (emms-mpv-ipc-fifo-p) + (emms-mpv-proc-init-fifo emms-player-mpv-ipc-socket)) + (let* + ((argv emms-player-mpv-parameters) + (argv (append + (list emms-player-mpv-command-name) + (if (functionp argv) (funcall argv) argv) + (list (format "--input-%s=%s" + emms-player-mpv-ipc-method emms-player-mpv-ipc-socket)) + (or media-args '("--idle")))) + (env emms-player-mpv-environment) + (process-environment (append + (unless (seq-some 'not env) process-environment) (seq-filter 'identity env)))) + (setq emms-mpv-proc + (make-process :name "emms-mpv" + :buffer nil :command argv :noquery t :sentinel 'emms-mpv-proc-sentinel)) + (emms-mpv-debug-msg "proc[%s]: start %s" emms-mpv-proc argv))) + +(defun emms-mpv-proc-stop () + "Stop running `emms-mpv-proc' instance via SIGINT, if any. +`delete-process' (SIGKILL) timer is started if `emms-mpv-proc-kill-delay' is non-nil." + (when emms-mpv-proc + (let ((proc emms-mpv-proc)) + (emms-mpv-debug-msg "proc[%s]: stop" proc) + (setq emms-mpv-proc nil) + (if (not (process-live-p proc)) (delete-process proc) + (emms-mpv-proc-playing nil proc) + (interrupt-process proc) + (when emms-mpv-proc-kill-delay + (run-at-time + emms-mpv-proc-kill-delay nil + (lambda (proc) (delete-process proc)) proc)))))) + + +;; ----- IPC socket/fifo + +(defun emms-mpv-ipc-sentinel (proc ev) + (emms-mpv-debug-msg "ipc[%s]: %s" proc ev) + (when (memq (process-status proc) '(open run)) + (run-hooks 'emms-mpv-event-connect-hook) + (when emms-mpv-ipc-connect-command + (let ((cmd emms-mpv-ipc-connect-command)) + (setq emms-mpv-ipc-connect-command nil) + (emms-mpv-ipc-req-send cmd nil proc))))) + +(defun emms-mpv-ipc-filter (proc s) + (when (buffer-live-p (process-buffer proc)) + (with-current-buffer (process-buffer proc) + (let ((moving (= (point) (process-mark proc)))) + (save-excursion + (goto-char (process-mark proc)) + (insert s) + (set-marker (process-mark proc) (point))) + (if moving (goto-char (process-mark proc)))) + ;; Process/remove all complete lines of json, if any + (let ((p0 (point-min))) + (while + (progn + (goto-char p0) (end-of-line) + (equal (following-char) #xa)) + (let* + ((p1 (point)) + (json (buffer-substring p0 p1))) + (delete-region p0 (+ p1 1)) + (emms-mpv-ipc-recv json))))))) + +(defun emms-mpv-ipc-connect (delays) + "Make IPC connection attempt, rescheduling if there's no socket by (car DELAYS). +(cdr DELAYS) gets passed to next connection attempt, +so it can be rescheduled further until function runs out of DELAYS values. +Sets `emms-mpv-ipc-proc' value to resulting process on success." + (emms-mpv-debug-msg "ipc: connect-delay %s" (car delays)) + (setq emms-mpv-ipc-proc + (make-network-process ; returns nil if there's no socket yet + :name "emms-mpv-ipc" + :family 'local + :service emms-player-mpv-ipc-socket + :nowait t + :coding '(utf-8 . utf-8) + :buffer (get-buffer-create emms-mpv-ipc-buffer) + :noquery t + :filter 'emms-mpv-ipc-filter + :sentinel 'emms-mpv-ipc-sentinel)) + (when (and (not emms-mpv-ipc-proc) delays) + (run-at-time (car delays) nil 'emms-mpv-ipc-connect (cdr delays)))) + +(defun emms-mpv-ipc-connect-fifo () + "Set `emms-mpv-ipc-proc' to process wrapper for +writing to a named pipe (fifo) file/node or signal error." + (setq emms-mpv-ipc-proc + (start-process-shell-command "emms-mpv-input-file" nil + (format "cat > '%s'" + (replace-regexp-in-string "'" "'\"'\"'" emms-player-mpv-ipc-socket t t)))) + (set-process-query-on-exit-flag emms-mpv-ipc-proc nil) + (unless emms-mpv-ipc-proc (error (format + "Failed to start cat-pipe to fifo: %s" emms-player-mpv-ipc-socket))) + (when emms-mpv-ipc-connect-command + (let ((cmd emms-mpv-ipc-connect-command)) + (setq emms-mpv-ipc-connect-command nil) + (emms-mpv-ipc-fifo-cmd cmd emms-mpv-ipc-proc)))) + +(defun emms-mpv-ipc-init () + "initialize new mpv ipc socket/file process and associated state." + (emms-mpv-ipc-stop) + (emms-mpv-debug-msg "ipc: init") + (if (emms-mpv-ipc-fifo-p) (emms-mpv-ipc-connect-fifo) + (when emms-mpv-ipc-connect-timer (cancel-timer emms-mpv-ipc-connect-timer)) + (with-current-buffer (get-buffer-create emms-mpv-ipc-buffer) (erase-buffer)) + (setq + emms-mpv-ipc-id 1 + emms-mpv-ipc-req-table nil + emms-mpv-ipc-connect-timer nil + emms-mpv-ipc-connect-timer + (run-at-time (car emms-mpv-ipc-connect-delays) + nil 'emms-mpv-ipc-connect (cdr emms-mpv-ipc-connect-delays))))) + +(defun emms-mpv-ipc-stop () + (when emms-mpv-ipc-proc + (emms-mpv-debug-msg "ipc: stop") + (delete-process emms-mpv-ipc-proc) + (setq emms-mpv-ipc-proc nil))) + +(defun emms-mpv-ipc () + "Returns open ipc socket/fifo process or nil, (re-)starting mpv/connection if necessary. +Will return nil when starting async process/connection, and any follow-up +command should be stored to `emms-mpv-ipc-connect-command' in this case." + (unless + ;; Don't start idle processes for fifo - just ignore all ipc requests there + (and (not (process-live-p emms-mpv-proc)) (emms-mpv-ipc-fifo-p)) + (unless (process-live-p emms-mpv-proc) (emms-mpv-proc-init)) + (unless (process-live-p emms-mpv-ipc-proc) (emms-mpv-ipc-init)) + (and + emms-mpv-ipc-proc + (memq (process-status emms-mpv-ipc-proc) '(open run)) + emms-mpv-ipc-proc))) + + +;; ----- IPC protocol + +(defun emms-mpv-ipc-id-get () + "Get new connection-unique id value, tracked via `emms-mpv-ipc-id'." + (let ((ipc-id emms-mpv-ipc-id)) + (setq emms-mpv-ipc-id + (if (< emms-mpv-ipc-id emms-mpv-ipc-id-max) (1+ emms-mpv-ipc-id) 1)) + ipc-id)) + +(defun emms-mpv-ipc-req-send (cmd &optional handler proc) + "Send JSON IPC request and assign HANDLER to response for it, if any. +CMD value is encoded via `json-encode'. +HANDLER func will be called with decoded response JSON as (handler data err), +where ERR will be either nil on \"success\", 'connection-error or whatever is in JSON. +If HANDLER is nil, default `emms-mpv-ipc-req-error-printer' will be used to at least log errors. +PROC can be specified to avoid `emms-mpv-ipc' call (e.g. from sentinel/filter funcs)." + (let + ((req-id (emms-mpv-ipc-id-get)) + (req-proc (or proc (emms-mpv-ipc))) + (handler (or handler 'emms-mpv-ipc-req-error-printer))) + (unless emms-mpv-ipc-req-table + (setq emms-mpv-ipc-req-table (make-hash-table))) + (let ((json (concat (json-encode (list :command cmd :request_id req-id)) "\n"))) + (emms-mpv-debug-msg "json >> %s" json) + (condition-case err + (process-send-string req-proc json) ; can disconnect at any time + (error + (emms-mpv-proc-stop) ; assume that mpv process is to blame and force restart + (funcall handler nil 'connection-error) (setq handler nil)))) + (when handler (puthash req-id handler emms-mpv-ipc-req-table)))) + +(defun emms-mpv-ipc-req-resolve (req-id data err) + "Run handler-func for specified req-id." + (when emms-mpv-ipc-req-table + (let + ((handler (gethash req-id emms-mpv-ipc-req-table)) + (err (if (string= err "success") nil err))) + (remhash req-id emms-mpv-ipc-req-table) + (when handler (funcall handler data err))))) + +(defun emms-mpv-ipc-req-error-printer (data err) + "Simple default `emms-mpv-ipc-req-send' handler to log errors, if any." + (when err (message "emms-mpv-ipc-error: %s" err))) + +(defun emms-mpv-ipc-recv (json) + "Handler for all JSON lines from mpv process. +Only used with JSON IPC, never called with --input-file as there's no feedback there." + (emms-mpv-debug-msg "json << %s" json) + (let* + ((json-data (json-read-from-string json)) + (req-id (alist-get 'request_id json-data)) + (ev (alist-get 'event json-data))) + (when req-id ; response to command + (emms-mpv-ipc-req-resolve req-id + (alist-get 'data json-data) (alist-get 'error json-data))) + (when ev ; mpv event + (emms-mpv-event-handler json-data) + (run-hook-with-args 'emms-mpv-event-functions json-data)))) + +(defun emms-mpv-ipc-fifo-cmd (cmd &optional proc) + "Send --input-file command string for older mpv versions. +PROC can be specified to avoid `emms-mpv-ipc' call." + (let + ((proc (or proc (emms-mpv-ipc))) + (cmd-line (concat (mapconcat (lambda (s) (format "%s" s)) cmd " ") "\n"))) + (emms-mpv-debug-msg "fifo >> %s" cmd-line) + (process-send-string proc cmd-line))) + +(defun emms-mpv-observe-property (sym) + "Send mpv observe_property command for property identified by SYM. +Only sends command once per process, removing any +potential duplication if used for same properties from different functions." + (let ((id (emms-mpv-proc-symbol-id sym))) + (when id (emms-mpv-ipc-req-send `(observe_property ,id ,sym))))) + +(defun emms-mpv-event-idle () + "Delayed check for switching tracks when mpv goes idle for no good reason." + (emms-mpv-debug-msg "idle-check (stopped=%s)" emms-mpv-stopped) + (unless emms-mpv-stopped (emms-player-stopped))) + +(defun emms-mpv-event-handler (json-data) + "Handler for supported mpv events, including property changes. +Called before `emms-mpv-event-functions' and does same thing as these hooks." + (pcase (alist-get 'event json-data) + ("playback-restart" + ;; Separate emms-mpv-proc-playing state is used for emms started/stopped signals, + ;; because start-file/end-file are also emitted after track-change and for playlists, + ;; and don't correspond to actual playback state. + (unless (emms-mpv-proc-playing-p) + (emms-mpv-proc-playing t) + (emms-player-started emms-player-mpv))) + ("end-file" + (when (emms-mpv-proc-playing-p) + (emms-mpv-proc-playing nil) + (emms-player-stopped))) + ("idle" + ;; Can mean any kind of error before or during playback. + ;; Example can be access/format error, resulting in start+end without playback-restart. + (cancel-timer emms-mpv-idle-timer) + (setq emms-mpv-idle-timer + (run-at-time emms-mpv-idle-delay nil 'emms-mpv-event-idle))) + ("start-file" (cancel-timer emms-mpv-idle-timer)))) + + +;; ----- Metadata update hooks + +(defun emms-mpv-info-meta-connect-func () + "Hook function for `emms-mpv-event-connect-hook' to update metadata from mpv." + (emms-mpv-observe-property 'metadata)) + +(defun emms-mpv-info-meta-event-func (json-data) + "Hook function for `emms-mpv-event-functions' to update metadata from mpv." + (when + (and + (string= (alist-get 'event json-data) "property-change") + (string= (alist-get 'name json-data) "metadata")) + (let ((info-alist (alist-get 'data json-data))) + (when info-alist (emms-mpv-info-meta-update-track info-alist))))) + +(defun emms-mpv-info-meta-update-track (info-alist &optional track) + "Update TRACK with mpv metadata from INFO-ALIST. +`emms-playlist-current-selected-track' is used by default." + (mapc + (lambda (cc) (setcar cc (intern (downcase (symbol-name (car cc)))))) + info-alist) + (cl-macrolet + ((key (k) `(alist-get ',k info-alist)) + (set-track-info (track &rest body) (cons 'progn + (cl-loop for (k v) on body by 'cddr collect + `(let ((value ,v)) (when value + (emms-track-set ,track ',(intern (format "info-%s" k)) value))))))) + (unless track (setq track (emms-playlist-current-selected-track))) + (set-track-info track + title (or (key title) (key icy-title)) + artist (or (key artist) (key album_artist) (key icy-name)) + album (key album) + tracknumber (key track) + year (key date) + genre (key genre) + note (key comment)) + (emms-track-updated track))) + +(defun emms-mpv-info-duration-event-func (json-data) + "Hook function for `emms-mpv-event-functions' to update track duration from mpv." + (when + (string= (alist-get 'event json-data) "playback-restart") + (emms-mpv-info-duration-check))) + +(defun emms-mpv-info-duration-check () + "Check whether current mpv track has reliable duration info and request it." + (emms-mpv-ipc-req-send '(get_property stream-end) (lambda (pts-end err) + (if err + (unless (and (stringp err) (string= err "property unavailable")) + (emms-mpv-ipc-req-error-printer pts-end err)) + (when pts-end + (emms-mpv-ipc-req-send '(get_property duration) + 'emms-mpv-info-duration-handler)))))) + +(defun emms-mpv-info-duration-handler (duration err) + "Duration property request handler to update it for current emms track." + (if err + (emms-mpv-debug-msg "duration-req-error: %s" err) + (when (and (numberp duration) (> duration 0)) ; can be nil/0 for network streams + (let + ((duration (round duration)) + (track (emms-playlist-current-selected-track))) + (emms-track-set track 'info-playing-time duration) + (emms-track-set track 'info-playing-time-min (/ duration 60)) + (emms-track-set track 'info-playing-time-sec (% duration 60)))))) + + +;; ----- High-level EMMS interface + +(defun emms-player-mpv-cmd (cmd &optional handler) + "Send mpv command to process/connection if both are running, +or otherwise schedule start/connect and set +`emms-mpv-ipc-start-track' for `emms-mpv-ipc-sentinel'. +PROC can be specified to avoid `emms-mpv-ipc' call." + (setq emms-mpv-ipc-connect-command nil) + (let ((proc (emms-mpv-ipc))) + (if proc + (if (emms-mpv-ipc-fifo-p) + (emms-mpv-ipc-fifo-cmd cmd proc) + (emms-mpv-ipc-req-send cmd handler proc)) + (setq emms-mpv-ipc-connect-command cmd)))) + +(defmacro emms-player-mpv-cmd-prog (cmd &rest handler-body) + "Macro around `emms-player-mpv-cmd' that creates +handler callback (see `emms-mpv-ipc-req-send') from HANDLER-BODY forms, +which have following bindings: +- mpv-cmd for CMD. +- mpv-data for response data (decoded json, nil if none). +- mpv-error for response error (nil if no error, decoded json or 'connection-error)." + `(emms-player-mpv-cmd ,cmd (apply-partially + (lambda (mpv-cmd mpv-data mpv-error) ,@handler-body) ,cmd))) + + +(defun emms-player-mpv-playable-p (track) + (memq (emms-track-type track) '(file url streamlist playlist))) + +(defun emms-player-mpv-start (track) + (setq emms-mpv-stopped nil) + (emms-mpv-proc-playing nil) + (let + ((track-name (emms-track-get track 'name)) + (track-is-playlist (memq (emms-track-get track 'type) '(streamlist playlist)))) + (if (emms-mpv-ipc-fifo-p) + (progn + (emms-mpv-ipc-stop) ; to clear any buffered commands + (emms-mpv-proc-init (if track-is-playlist "--playlist" "--") track-name) + (emms-player-started emms-player-mpv)) + (emms-player-mpv-cmd-prog + (list (if track-is-playlist 'loadlist 'loadfile) track-name 'replace) + (if (eq mpv-error 'connection-error) + ;; Reconnect and restart playback if current connection fails (e.g. mpv crash) + (emms-player-mpv-cmd-prog + (emms-player-mpv-cmd mpv-cmd) + (emms-player-mpv-cmd `(set pause no))) + (emms-player-mpv-cmd `(set pause no))))))) + +(defun emms-player-mpv-stop () + (setq emms-mpv-stopped t) + (emms-mpv-proc-playing nil) + (emms-player-mpv-cmd `(stop)) + (emms-player-stopped)) + (defun emms-player-mpv-pause () - "Depends on mpv's --input-file option." - (let ((cmd (emms-player-mpv--format-command "set pause yes"))) - (call-process-shell-command cmd nil nil nil))) + (emms-player-mpv-cmd `(set pause yes))) (defun emms-player-mpv-resume () - "Depends on mpv's --input-file option." - (let ((cmd (emms-player-mpv--format-command "set pause no"))) - (call-process-shell-command cmd nil nil nil))) + (emms-player-mpv-cmd `(set pause no))) (defun emms-player-mpv-seek (sec) - "Depends on mpv's --input-file option." - (let ((cmd (emms-player-mpv--format-command "seek %d" sec))) - (call-process-shell-command cmd nil nil nil))) + (emms-player-mpv-cmd `(seek ,sec relative))) (defun emms-player-mpv-seek-to (sec) - "Depends on mpv's --input-file option." - (let ((cmd (emms-player-mpv--format-command "seek %d absolute" sec))) - (call-process-shell-command cmd nil nil nil))) + (emms-player-mpv-cmd `(seek ,sec absolute))) + +(emms-player-set emms-player-mpv 'pause 'emms-player-mpv-pause) +(emms-player-set emms-player-mpv 'resume 'emms-player-mpv-resume) +(emms-player-set emms-player-mpv 'seek 'emms-player-mpv-seek) +(emms-player-set emms-player-mpv 'seek-to 'emms-player-mpv-seek-to) + (provide 'emms-player-mpv) ;;; emms-player-mpv.el ends here |