branch: externals/emms commit 7bf67ea70ec5e3162dd7e4597dba70f3a238eef1 Merge: 6910be1656 b00955ad3e Author: Yoni Rabkin <y...@gnu.org> Commit: Yoni Rabkin <y...@gnu.org>
Merge branch 'mpris' integrate the mpris branch into main --- doc/emms.texinfo | 25 +++ emms-mpris.el | 528 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ emms-setup.el | 3 +- 3 files changed, 555 insertions(+), 1 deletion(-) diff --git a/doc/emms.texinfo b/doc/emms.texinfo index 791e998b11..6d741bc727 100644 --- a/doc/emms.texinfo +++ b/doc/emms.texinfo @@ -84,6 +84,7 @@ Modules and Extensions * Bookmarks:: Saving a place in a media file. * Managing Playlists:: Managing multiple playlists. * GNU FM:: Connect to music community websites. +* D-Bus:: Control Emms over D-Bus Copying and license * Copying:: The GNU General Public License gives you permission to @@ -3004,6 +3005,30 @@ setup level. Then invoke @kbd{emms-librefm-stream} and enter the URL of the station you wish to listen to, for example ``librefm://globaltags/Classical''. +@c ------------------------------------------------------------------- +@node D-Bus +@chapter D-Bus + +@cindex D-Bus + +Emms can provide an MPRIS interface which allows it to be +controlled over D-Bus. + +To enable this, first load the feature: + +@lisp +(require 'emms-mpris) +@end lisp + + +and then turn it on with @kbd{emms-mpris-enable}. You can +turn it off with @kbd{emms-mpris-disable}. + +At present, Emms only provides a partial implementation of +the @url{ +https://specifications.freedesktop.org/mpris-spec/latest/index.html, +MPRIS specification}: changing the volume, shuffle or loop +status is not currently supported. @c including the relevant licenses diff --git a/emms-mpris.el b/emms-mpris.el new file mode 100644 index 0000000000..8e5de25c57 --- /dev/null +++ b/emms-mpris.el @@ -0,0 +1,528 @@ +;;; emms-mpris.el --- Mpris interface for EMMS -*- lexical-binding: t; -*- + +;; Copyright (C) 2022 Fran Burstall + +;; Author: Fran Burstall <fran.burst...@gmail.com> +;; Keywords: multimedia + +;; This program is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <https://www.gnu.org/licenses/>. + +;;; Commentary: +;; This package provides a dbus interface to EMMS. + +;; Usage: +;; (require 'emms-mpris) +;; (emms-mpris-enable) + +;; Switch off with +;; (emms-mpris-disable) + +;; Caveats: this is not quite a complete implementation of the +;; org.mpris.MediaPlayer2 and org.mpris.MediaPlayer2.Player interfaces +;; (see +;; https://specifications.freedesktop.org/mpris-spec/latest/index.html). +;; What is missing: +;; - Volume: this should be easy but there seems to be no way to get a +;; simple percentage to report the volume---every emms-volume +;; controller returns a string in a different format, sigh. +;; - Shuffle +;; - LoopStatus +;; The issue with the last two is how to allow them to be set both +;; over dbus and via lisp/emms-ui. I do not know how to do this in a +;; simple way. + +;; TODO: +;; * Shuffle: value is emms-random-playlist and +;; emms-toggle-random-playlist shows implementation +;; * LoopStatus: mpris expects three possibilities here: None, Track +;; and Playlist.See emms-repeat-track and emms-repeat-playlist for +;; values and emms-toggle-repeat-* for implementations +;; * Support more than tracks in files and directories (example: +;; playlists and urls: I need a way to work out which emms-play-* to call). + +;; NEXT: think through how setting Shuffle or LoopStatus should work: +;; How can shuffle be set and what should happen when it does? +;; 1. Can be set over dbus and then a PropertiesChanged signal is +;; fired. This should provoke emms into changing state. This is part +;; of a general story: we have d-bus state and emms-state and the +;; question is how to keep them in sync. +;; Change in d-bus state (via dbus-set-property called from a client +;; or wherever): this fires a PropertiesChanged signal to which emms +;; should respond to change its state. So we need a signal handler +;; listening. +;; Change in emms-state (from Lisp or emms UI): this should trigger a +;; change in d-bus state via dbus-set-property. This in turn emits +;; the signal to which the handler must respond without creating a +;; loop. This last requirement is the heart of the matter. +;; Strategies: +;; 1. Advise the state changing functions in emms to only change the +;; dbus state and then let the signal handler bring everything up to +;; date using d-bus state as the source of truth. +;; Pros: +;; - Easy to reason about +;; Cons: +;; - Lots of advice to keep track of +;; 2. Have a second signal internal to us which is fired from emms hooks. + + +;;; Code: + +;;* What we need +(require 'dbus) +(require 'url-parse) +(require 'emms) +(require 'emms-browser) +(require 'emms-playing-time) +(require 'cl-lib) + +;;* Dbus components +(defconst emms-mpris-service "org.mpris.MediaPlayer2.emms" + "The service we expose.") + +(defconst emms-mpris-path "/org/mpris/MediaPlayer2" + "Our object path.") + +;;* Register and update +(defun emms-mpris-register-method (iface method handler) + "Register METHOD with HANDLER on interface IFACE." + (dbus-register-method :session + emms-mpris-service + emms-mpris-path + iface + method + handler + t)) + +(defun emms-mpris-register-property (iface property access value) + "Register PROPERTY on interface IFACE. + +VALUE is the initial value, ACCESS the access mode." + (let ((val (cond ((functionp value) (funcall value)) + ((and (symbolp value) (boundp value)) (symbol-value value)) + (t value)))) + (dbus-register-property :session + emms-mpris-service + emms-mpris-path + iface + property + access + val + nil t))) + +(defun emms-mpris-register-iface (spec) + "Register an interface with spec SPEC on the EMMS service. + +The spec is a list of the form (IFACE METHODS PROPS). + +IFACE is a string naming the interface being registered. + +METHODS is a list of methods to register on the interface. +Each method is a list (NAME FN) with NAME a string and FN the +function the method calls. + +PROPS is a list of properties to register on the interface. +Each property is a list of the form (NAME ACCESS VAL) with +NAME a string, ACCESS a keyword and VAL either a function +that returns the default value of the property, a variable +which evaluates to that value or the value itself." + (cl-destructuring-bind (iface methods props) spec + (dolist (method methods) + (apply #'emms-mpris-register-method iface method)) + (dolist (prop props) + (apply #'emms-mpris-register-property iface prop)))) + + +;;* Interfaces + +;;** MediaPlayer2 interface + +(defvar emms-mpris-mediaplayer-iface-spec + '("org.mpris.MediaPlayer2" + (("Raise" ignore) + ("Quit" ignore)) + (("CanQuit" :read nil) + ("CanRaise" :read nil) + ("HasTrackList" :read nil) + ("Identity" :read "EMMS media player") + ("SupportedUriSchemes" :read (:array "file")) + ("SupportedMimeTypes" :read (:array "audio/mpeg" "application/ogg")))) + "Interface spec for MediaPlayer2.") + +;;** MediaPlayer2.Player interface + +(defvar emms-mpris-player-iface-spec + '("org.mpris.MediaPlayer2.Player" + ;; Methods: + (("OpenUri" emms-mpris-open-uri) + ("Next" (lambda () (ignore-errors (emms-next)) :ignore)) + ("Previous" (lambda () (ignore-errors (emms-previous)) :ignore)) + ("Pause" (lambda () (emms-pause) :ignore)) + ("PlayPause" (lambda () (emms-pause) :ignore)) + ("Stop" (lambda () (emms-stop) :ignore)) + ("Play" (lambda () (emms-pause) :ignore)) + ("Seek" emms-mpris-seek) + ("SetPosition" emms-mpris-set-position)) + ;; Properties: Shuffle, LoopStatus, Volume not supported (yet) + (;; ("LoopStatus" :readwrite emms-mpris-loop-status) + ;; ("Shuffle" :readwrite emms-random-playlist) + ("PlaybackStatus" :read emms-mpris-status) + ("Rate" :readwrite 1.0) + ("MinimumRate" :read 1.0) + ("MaximumRate" :read 1.0) + ("Position" :read (:int64 0)) ;think more about this + ("CanGoNext" :read t) + ("CanGoPrevious" :read t) + ("CanPlay" :read t) + ("CanPause" :read t) + ("CanPause" :read t) + ("CanControl" :read t) + ("CanSeek" :read t) + ("Metadata" :read emms-mpris-current-metadata))) + "Interface spec for MediaPlayer2.Player.") +;;** Introspection interface + +(defvar emms-mpris-xml + "<!DOCTYPE node PUBLIC \"-//freedesktop//DTD D-BUS Object Introspection 1.0//EN\" + \"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd\"> +<!-- GDBus 2.66.8 --> +<node> + <interface name=\"org.freedesktop.DBus.Properties\"> + <method name=\"Get\"> + <arg type=\"s\" name=\"interface_name\" direction=\"in\"/> + <arg type=\"s\" name=\"property_name\" direction=\"in\"/> + <arg type=\"v\" name=\"value\" direction=\"out\"/> + </method> + <method name=\"GetAll\"> + <arg type=\"s\" name=\"interface_name\" direction=\"in\"/> + <arg type=\"a{sv}\" name=\"properties\" direction=\"out\"/> + </method> + <method name=\"Set\"> + <arg type=\"s\" name=\"interface_name\" direction=\"in\"/> + <arg type=\"s\" name=\"property_name\" direction=\"in\"/> + <arg type=\"v\" name=\"value\" direction=\"in\"/> + </method> + <signal name=\"PropertiesChanged\"> + <arg type=\"s\" name=\"interface_name\"/> + <arg type=\"a{sv}\" name=\"changed_properties\"/> + <arg type=\"as\" name=\"invalidated_properties\"/> + </signal> + </interface> + <interface name=\"org.freedesktop.DBus.Introspectable\"> + <method name=\"Introspect\"> + <arg type=\"s\" name=\"xml_data\" direction=\"out\"/> + </method> + </interface> + <interface name=\"org.freedesktop.DBus.Peer\"> + <method name=\"Ping\"/> + <method name=\"GetMachineId\"> + <arg type=\"s\" name=\"machine_uuid\" direction=\"out\"/> + </method> + </interface> + <interface name=\"org.mpris.MediaPlayer2\"> + <method name=\"Raise\"/> + <method name=\"Quit\"/> + <property type=\"b\" name=\"CanQuit\" access=\"read\"/> + <property type=\"b\" name=\"CanRaise\" access=\"read\"/> + <property type=\"b\" name=\"HasTrackList\" access=\"read\"/> + <property type=\"s\" name=\"Identity\" access=\"read\"/> + <property type=\"s\" name=\"DesktopEntry\" access=\"read\"/> + <property type=\"as\" name=\"SupportedUriSchemes\" access=\"read\"/> + <property type=\"as\" name=\"SupportedMimeTypes\" access=\"read\"/> + </interface> + <interface name=\"org.mpris.MediaPlayer2.Player\"> + <method name=\"Next\"/> + <method name=\"Previous\"/> + <method name=\"Pause\"/> + <method name=\"PlayPause\"/> + <method name=\"Stop\"/> + <method name=\"Play\"/> + <method name=\"Seek\"> + <arg type=\"x\" name=\"Offset\" direction=\"in\"/> + </method> + <method name=\"SetPosition\"> + <arg type=\"o\" name=\"TrackId\" direction=\"in\"/> + <arg type=\"x\" name=\"Position\" direction=\"in\"/> + </method> + <method name=\"OpenUri\"> + <arg type=\"s\" name=\"Uri\" direction=\"in\"/> + </method> + <signal name=\"Seeked\"> + <arg type=\"x\" name=\"Position\"/> + </signal> + <property type=\"s\" name=\"PlaybackStatus\" access=\"read\"/> + <!-- <property type=\"s\" name=\"LoopStatus\" access=\"readwrite\"/> --> + <property type=\"d\" name=\"Rate\" access=\"readwrite\"/> + <!-- <property type=\"b\" name=\"Shuffle\" access=\"readwrite\"/> --> + <property type=\"a{sv}\" name=\"Metadata\" access=\"read\"/> + <property type=\"d\" name=\"Volume\" access=\"readwrite\"/> + <property type=\"x\" name=\"Position\" access=\"read\"/> + <property type=\"d\" name=\"MinimumRate\" access=\"read\"/> + <property type=\"d\" name=\"MaximumRate\" access=\"read\"/> + <property type=\"b\" name=\"CanGoNext\" access=\"read\"/> + <property type=\"b\" name=\"CanGoPrevious\" access=\"read\"/> + <property type=\"b\" name=\"CanPlay\" access=\"read\"/> + <property type=\"b\" name=\"CanPause\" access=\"read\"/> + <property type=\"b\" name=\"CanSeek\" access=\"read\"/> + <property type=\"b\" name=\"CanControl\" access=\"read\"/> + </interface> +</node> +" + "Mpris introspection data for emms.") +(defun emms-mpris-introspect () + "Return dbus introspection data." + emms-mpris-xml) + +(defvar emms-mpris-introspectable-iface-spec + '("org.freedesktop.DBus.Introspectable" + (("Introspect" emms-mpris-introspect)) + nil) + "Introspectable interface spec for dbus.") + +;;** Properties interface + +;; We re-implement the "Get" method of the dbus.properties interface. +;; For why? Well, the default handler looks up the value of a property +;; in a table which works fine unless we want the "Position" property +;; of the Player interface which changes all the time (and we don't +;; want to update the table every second!). So we wrap the default +;; handle to treat this special case differently. + +(defun emms-mpris-get-property-handler (&rest args) + "Handle Get event for property in ARGS. + + The Position property gets special treatment." + (let* ((last-input-event last-input-event) + (prop (cadr args))) + (if (string-equal prop "Position") + (list :variant :int64 + (emms-mpris-sec-to-musec emms-playing-time)) + (apply #'dbus-property-handler args)))) + +(defvar emms-mpris-properties-iface-spec + '("org.freedesktop.DBus.Properties" + (("Get" emms-mpris-get-property-handler)) + nil) + "Partial Properties interface spec for dbus.") + + +;;* Implementation + +;;** Utilities +;; Emms thinks in seconds but mpris in microseconds +(defun emms-mpris-musec-to-sec (ms) + "Convert MS microseconds to seconds." + (* ms .000001)) + +(defun emms-mpris-sec-to-musec (s) + "Convert S seconds to microseconds." + (truncate (* s 1000000))) + +;; Track-id is a d-bus object id and these have rules... +(defun emms-mpris-track-id (track) + "Return track-id of TRACK as D-Bus object id." + ;; FIX ME: this won't work if we implement the tracklist interface + ;; and the tracklist has repeated tracks. + (concat "/" (mapconcat #'dbus-escape-as-identifier + (split-string (emms-track-get track 'name) "/" t) + "/"))) + +;;** Update properties +(defun emms-mpris-update-property (iface property access value) + "Update PROPERTY on interface IFACE to VALUE." + (dbus-register-property :session + emms-mpris-service + emms-mpris-path + iface + property + access + value + t nil)) + +;;*** Playback status +(defun emms-mpris-status () + "Return the playback status of EMMS as string: Playing, Paused or Stopped." + (if emms-player-playing-p + (if emms-player-paused-p + "Paused" "Playing") + "Stopped")) + +;;*** Loop status (not used yet) +(defun emms-mpris-loop-status () + "Return the loop status of EMMS as a string: Track, Playlist or None." + (cond (emms-repeat-track "Track") + (emms-repeat-playlist "Playlist") + (t "None"))) + +;;*** Metadata + +(defvar emms-mpris-metadata-dict + '((info-album "xesam:album" :s) + (info-albumartist "xesam:albumArtist" :as) + (info-artist "xesam:artist" :as) + (info-composer "xesam:composer" :as) + (info-discnumber "xesam:discNumber" :int) + (info-tracknumber "xesam:trackNumber" :int) + (info-title "xesam:title" :s) + (play-count "xesam:useCount" :int)) + "Dictionary between emms metadata and mpris metadata. + +Each entry of the form (info-field mpris-field dbus-type).") + +(defun emms-mpris-dict (k v &optional type) + "Return a dbus dict-entry with key K and value V, optionally of type TYPE." + (if type + (list :dict-entry k (list :variant type v)) + (list :dict-entry k (list :variant v)))) + +(defun emms-mpris-convert-field (track info key type) + "Convert field INFO of TRACK into dbus dict-entry with key KEY and type TYPE." + (let ((data (emms-track-get track info)) + value) + (when data + (setq value (pcase type + (:as (list :array data)) + (:int (if (stringp data) (string-to-number data) data)) + (:s data))) + (emms-mpris-dict key value)))) + +(defun emms-mpris-metadata (track) + "Return mpris metadata for TRACK." + (let ((track-name (emms-track-get track 'name)) + metadata) + ;; standard fields + (dolist (field emms-mpris-metadata-dict) + (when-let ((entry (apply #'emms-mpris-convert-field track field))) + (push entry metadata))) + ;; url + (push (emms-mpris-dict "xesam:url" (url-encode-url (concat "file:" track-name))) metadata) + ;; artUrl + (when-let ((art-file (emms-browser-get-cover-from-path track-name 'medium))) + (push (emms-mpris-dict "mpris:artUrl" (url-encode-url (concat "file:" art-file))) metadata)) + ;; length + (push + (emms-mpris-dict "mpris:length" + (emms-mpris-sec-to-musec (emms-track-get track 'info-playing-time 0)) + :int64) + metadata) + ;; trackid + (push + (emms-mpris-dict "mpris:trackid" + (emms-mpris-track-id track) + :object-path) + metadata) + (cons :array metadata))) + +(defun emms-mpris-current-metadata () + "Return metadata of current track if it exists, else return a placeholder." + (if-let ((track (emms-playlist-current-selected-track))) + (emms-mpris-metadata track) + '(:array (:dict-entry "mpris:trackid" (:variant :object-path "/no/track/here"))))) + +;;*** update them! +(defun emms-mpris-change-status () + "Notify emms status to dbus." + (let ((iface "org.mpris.MediaPlayer2.Player")) + (emms-mpris-update-property iface + "PlaybackStatus" + :read + (emms-mpris-status)) + (emms-mpris-update-property iface + "Metadata" + :read + (emms-mpris-current-metadata)))) + + +;;** Seek and SetPosition + +;;*** Signal position change (after Seek or SetPosition) +(defun emms-mpris-signal-position (pos) + "Send \"Seeked\" signal with new position POS (in seconds)." + (dbus-send-signal :session + nil + emms-mpris-path + "org.mpris.MediaPlayer2.Player" + "Seeked" + :int64 + (emms-mpris-sec-to-musec pos))) + +;;*** Seek method +(defun emms-mpris-seek (ms) + "Method to seek by MS microseconds." + (emms-seek (emms-mpris-musec-to-sec ms)) + (emms-mpris-signal-position emms-playing-time) + :ignore) + +;;*** SetPosition method +(defun emms-mpris-set-position (track-id pos) + "Method to seek to POS (in microseconds) if current track has id TRACK-ID." + (let* ((track (emms-playlist-current-selected-track)) + (duration (emms-track-get track 'info-playing-time 0)) + (current-track-id (emms-mpris-track-id track)) + (pos-in-secs (emms-mpris-musec-to-sec pos))) + (when (and (string-equal track-id current-track-id) + (<= 0.0 pos-in-secs duration)) + (emms-seek-to pos-in-secs) + (emms-mpris-signal-position emms-playing-time)) + :ignore)) + +;;** OpenURI + +(defun emms-mpris-open-uri (uri) + "Method for opening file URI and playing it." + (let* ((parsed-uri (url-generic-parse-url uri)) + (file (url-unhex-string (url-filename parsed-uri))) + (type (url-type parsed-uri))) + (when (and (string-equal type "file") (file-exists-p file)) + (cond ((file-regular-p file) (emms-play-file file)) + ((file-directory-p file) (emms-play-directory file))))) + :ignore) + + +;;* Entry point + +(defvar emms-mpris-enabled-p nil + "Non-nil if the EMMS mpris service is enabled.") + +(defun emms-mpris-enable () + "Activate EMMS dbus service." + (interactive) + (unless emms-mpris-enabled-p + (emms-mpris-register-iface emms-mpris-mediaplayer-iface-spec) + (emms-mpris-register-iface emms-mpris-player-iface-spec) + (emms-mpris-register-iface emms-mpris-introspectable-iface-spec) + (emms-mpris-register-iface emms-mpris-properties-iface-spec) + (dbus-register-service :session emms-mpris-service :allow-replacement) + (add-hook 'emms-player-started-hook #'emms-mpris-change-status) + (add-hook 'emms-player-paused-hook #'emms-mpris-change-status) + (add-hook 'emms-player-stopped-hook #'emms-mpris-change-status) + (add-hook 'emms-player-finished-hook #'emms-mpris-change-status) + (setq emms-mpris-enabled-p t))) + +(defun emms-mpris-disable () + "Turn off EMMS dbus service." + (interactive) + (when emms-mpris-enabled-p + (remove-hook 'emms-player-started-hook #'emms-mpris-change-status) + (remove-hook 'emms-player-paused-hook #'emms-mpris-change-status) + (remove-hook 'emms-player-stopped-hook #'emms-mpris-change-status) + (remove-hook 'emms-player-finished-hook #'emms-mpris-change-status) + ;; Call this twice: we have two methods for "Get" on the Properties + ;; interface (there /must/ be a better way to do this!): + (dbus-unregister-service :session emms-mpris-service) + (dbus-unregister-service :session emms-mpris-service) + (setq emms-mpris-enabled-p nil))) + + +(provide 'emms-mpris) +;;; emms-mpris.el ends here diff --git a/emms-setup.el b/emms-setup.el index b7a9f4e267..895413bce5 100644 --- a/emms-setup.el +++ b/emms-setup.el @@ -119,7 +119,8 @@ the stable features which come with the Emms distribution." (require 'emms-volume) (require 'emms-playlist-limit) (require 'emms-librefm-scrobbler) - (require 'emms-librefm-stream)) + (require 'emms-librefm-stream) + (require 'emms-mpris)) ;; setup (setq emms-playlist-default-major-mode #'emms-playlist-mode) (add-to-list 'emms-track-initialize-functions #'emms-info-initialize-track)