;;; mpd.el --- A complete ripoff of xwem-mpd. ;; Copyright (C) 2008 - 2013 Steve Youngs ;; Original xwem-mpd: ;; Copyright (C) 2005 Richard Klinda ;; Author: Richard Klinda ;; Zajcev Evgeny ;; Created: 2004 ;; Keywords: music, entertainment ;; This file is NOT part of anything. ;; The original xwem-mpd.el was released under the terms of the GPLv2. ;; mpd.el uses the BSD licence. ;; Redistribution and use in source and binary forms, with or without ;; modification, are permitted provided that the following conditions ;; are met: ;; ;; 1. Redistributions of source code must retain the above copyright ;; notice, this list of conditions and the following disclaimer. ;; ;; 2. Redistributions in binary form must reproduce the above copyright ;; notice, this list of conditions and the following disclaimer in the ;; documentation and/or other materials provided with the distribution. ;; ;; 3. Neither the name of the author nor the names of any contributors ;; may be used to endorse or promote products derived from this ;; software without specific prior written permission. ;; ;; THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR ;; IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED ;; WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE ;; DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE ;; FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR ;; CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF ;; SUBSTITUTE GOODS OR SERVICES# LOSS OF USE, DATA, OR PROFITS# OR ;; BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, ;; WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE ;; OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN ;; IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ;;; Commentary: ;; You need MusicPD - Music Playing Daemon ;; (http://www.musicpd.org/) to be set up and running. ;; The xwem-mpd-dock from xwem-mpd.el was designed to sit in the xwem ;; minibuffer (panel), and theoretically you would be able to have the ;; mpd-dock-frame from here sit in a panel (KDE, GNOME, LXDE, etc) ;; too. I say "theoretically" because I've never tried so I'm leaving ;; it as an exercise for the reader. :-) ;; No, instead of docking the mpd-dock-frame onto a panel I prefer to ;; tweak its frame properties so it will be completely ignored by the ;; WM. That gives me a frame that has no decorations, is visible on all ;; workspaces, always "on top", and doesn't show up in "task switchers". ;; To get tha magic you set the `override-redirect' property to `t', and ;; for positioning set `left' and/or `top' properties. ;;; Keybinding Suggestions: ;; ;; (global-set-key [XF86AudioPlay] #'mpd-playpause) ;; (global-set-key [XF86AudioStop] #'mpd-stop) ;; (global-set-key [XF86AudioNext] #'mpd-next-track) ;; (global-set-key [XF86AudioPrev] #'mpd-previous-track) ;; (global-set-key [XF86AudioRaiseVolume] #'mpd-volume-up) ;; (global-set-key [XF86AudioLowerVolume] #'mpd-volume-down) ;; (global-set-key [XF86AudioMute] #'mpd-volume-mute/unmute) ;; ;; Yeah, it helps if you have a keyboard with those fancy keys. :-) ;;; Cover Art: ;; ;; First up you will need a 48x48 `nocover.jpg' and put it in ;; `mpd-directory' (see the images directory in this repo) for albums ;; that you don't have any art for. Secondly, album art is taken from ;; `cover.jpg' files in the same directory as the album's audio files. ;; Yep, sort your music by album (at least). ;; ;; Embedded art is supported via FFmpeg. The embedded art is preferred. ;;; Player control buttons: ;; ;; Put the XPM's in `mpd-directory' ;;; Code: (defgroup mpd nil "Group to customize mpd." :prefix "mpd-" :group 'hypermedia) (defcustom mpd-update-rate 5 "MPD variables updating rate in seconds." :type 'number :group 'mpd) (defcustom mpd-directory (file-name-as-directory (expand-file-name ".mpd" (user-home-directory))) "Base mpd directory." :type 'directory :group 'mpd) (defcustom mpd-conf-file (let ((locations (list (expand-file-name "mpd.conf" (paths-construct-path (list (getenv "XDG_CONFIG_HOME") "mpd"))) (expand-file-name ".mpdconf" (user-home-directory)) (expand-file-name "mpd.conf" (paths-construct-path (list (user-home-directory) ".mpd"))) (expand-file-name "mpd.conf" "/etc")))) (catch 'conf (mapcar #'(lambda (file) (and (file-exists-p file) (throw 'conf file))) locations))) "The mpd config file. The default should be fine here because the file is searched for in the same places and in the same order as mpd itself does." :type '(file :must-match t) :group 'mpd) (defcustom mpd-after-command-hook nil "Hooks to run after MPD command is executed. Executed command name stored in `mpd-this-command'." :type 'hook :group 'mpd) (defcustom mpd-before-variables-update-hook nil "Hooks to run before updating mpd variables." :type 'hook :group 'mpd) (defcustom mpd-after-variables-update-hook nil "Hooks to run after mpd variables has been updated." :type 'hook :group 'mpd) (defvar mpd-process nil) (defvar mpd-itimer nil) (defvar mpd-dock-frame nil) (defvar mpd-dock-buffer nil) (defun mpd-start-connection () "Open connection to MusicPD daemon. Set `mpd-process' by side effect." (when (or (not mpd-process) (not (eq mpd-process 'open))) (setq mpd-process (open-network-stream "mpd" " *mpd connection*" "localhost" 6600)) (when (fboundp 'set-process-coding-system) (set-process-coding-system mpd-process 'utf-8 'utf-8)) (set-process-filter mpd-process 'mpd-process-filter) (set-process-sentinel mpd-process 'mpd-process-sentinel) (process-kill-without-query mpd-process) (add-hook 'mpd-after-command-hook #'mpd-update-variables) (add-hook 'mpd-before-variables-update-hook #'mpd-clear-variables) (setq mpd-itimer (start-itimer "mpd-vars-update" #'mpd-update-variables mpd-update-rate mpd-update-rate)))) (defun mpd-disconnect () "Disconnect from the mpd daemon. Also removes the update hook, kills the itimer, and removes the dock frame." (interactive) (let ((proc (get-process (process-name mpd-process))) (timer (get-itimer (itimer-name mpd-itimer)))) (remove-hook 'mpd-after-command-hook #'mpd-update-variables) (remove-hook 'mpd-before-variables-update-hook #'mpd-clear-variables) (when (itimerp timer) (delete-itimer timer)) (when (process-live-p proc) (delete-process (get-process (process-name mpd-process)))) (when (frame-live-p mpd-dock-frame) (delete-frame mpd-dock-frame)) (when (buffer-live-p mpd-dock-buffer) (kill-buffer mpd-dock-buffer)))) (defun mpd-music-directory () "Returns the value of \"music_directory\" from mpd config." (with-temp-buffer (insert-file-contents-literally mpd-conf-file) (re-search-forward "^music_directory[[:blank:]]+" nil t) (buffer-substring (1+ (point)) (1- (point-at-eol))))) (defvar mpd-music-directory (mpd-music-directory) "The music directory.") (defvar mpd-zero-vars-p t) (defvar mpd-status-update-p nil) (defvar mpd-pre-mute-volume nil "Holds the value of `**mpd-var-volume* prior to muting. The purpose of this is so that when you unmute, it goes back to the volume you had it set to before you muted.") (defvar mpd-this-command nil "The mpd command currently executing. Useful to use in `mpd-after-command-hook' hooks.") (defmacro define-mpd-command (cmd args &rest body) "Define new mpd command. This will run the hooks in `mpd-after-command-hook' while `mpd-this-command' is let-bound to the name of the command." `(defun ,cmd ,args ,@body (let ((mpd-this-command ',cmd)) (run-hooks 'mpd-after-command-hook)))) (defun mpd-send (format &rest args) "Send formated string into connection. FORMAT and ARGS are passed directly to `format' as arguments." (let ((string (concat (apply #'format format args) "\n"))) (if (eq (process-status mpd-process) 'open) (process-send-string mpd-process string) (mpd-start-connection) (process-send-string mpd-process string)))) (defun mpd-state () "Return the current state of mpd as a string. Possible values are: \"play\", \"pause\", and \"stop\"." (and-boundp '**mpd-var-state* **mpd-var-state*)) (defun mpd-stopped-p () "Return t if the music is stopped." (string= (mpd-state) "stop")) (defun mpd-paused-p () "Return t if the music is paused." (string= (mpd-state) "pause")) ;; Volume (defun mpd-volume () "Return the current mpd volume as an integer." (or (and-boundp '**mpd-var-volume* (string-to-int **mpd-var-volume*)) 0)) (defun mpd-muted-p () "Return t when the volume is muted." (zerop (mpd-volume))) (defun mpd-volume-up (step) "Increase the volume by STEP increments. STEP can be given via numeric prefix arg and defaults to 1 if omitted." (interactive "p") (let* ((oldvol (mpd-volume)) (newvol (+ oldvol step))) (when (>= newvol 100) (setq newvol 100)) (mpd-send "setvol %d" newvol))) (defun mpd-volume-down (step) "Decrease the volume by STEP increments. STEP can be given via numeric prefix arg and defaults to 1 if omitted." (interactive "p") (let* ((oldvol (mpd-volume)) (newvol (- oldvol step))) (when (<= newvol 0) (setq newvol 0)) (mpd-send "setvol %d" newvol))) (define-mpd-command mpd-volume-mute (&optional unmute) "Mute the volume. With prefix arg, UNMUTE, let the tunes blast again." (interactive "P") (if unmute (mpd-send "setvol %s" mpd-pre-mute-volume) (setq mpd-pre-mute-volume (mpd-volume)) (mpd-send "setvol 0"))) (defun mpd-volume-mute/unmute () "Wrapper around #'mpd-volume-mute to mute and unmute." (interactive) (if (mpd-muted-p) (mpd-volume-mute 'unmute) (mpd-volume-mute))) (define-mpd-command mpd-volume-max () "Set volume to maximum." (interactive) (mpd-send "setvol 100")) (define-mpd-command mpd-volume-min () "Set volume to minimum. Mutes the mpd audio by side effect." (interactive) (setq mpd-pre-mute-volume (mpd-volume)) (mpd-send "setvol 0")) ;; Seek (defun mpd-songpos () "Return position in song as a cons of elapsed and total seconds." (and-boundp '**mpd-var-time* (destructuring-bind (a b) (split-string **mpd-var-time* ":") (cons (string-to-int a) (string-to-int b))))) (define-mpd-command mpd-seek (time) "Seek current track to TIME." (and-boundp '**mpd-var-Id* (mpd-send "seekid %s %d" **mpd-var-Id* (+ (car (mpd-songpos)) time)))) (defun mpd-seek-forward () "Seek forward 10 seconds." (interactive) (mpd-seek 10)) (defun mpd-seek-backward () "Seek backward 10 seconds." (interactive) (mpd-seek -10)) ;; Playing operations (define-mpd-command mpd-next-track () "Start playing next track." (interactive) (mpd-send "next")) (define-mpd-command mpd-previous-track () "Start playing previous track." (interactive) (mpd-send "previous")) (define-mpd-command mpd-stop () "Stop playing." (interactive) (mpd-send "stop")) (define-mpd-command mpd-play () "Start playing." (interactive) (mpd-send "play")) (define-mpd-command mpd-pause () "Pause playing." (interactive) (mpd-send "pause")) (define-mpd-command mpd-playpause () "Resume playing or pause." (interactive) (if (mpd-stopped-p) (mpd-send "play") (mpd-send "pause"))) (defun mpd-process-filter (process output) "MPD proccess filter." (with-temp-buffer (insert output) (goto-char (point-min)) (while (not (eobp)) (when (looking-at "\\(.*?\\): \\(.*\\)") (set (intern (format "**mpd-var-%s*" (match-string 1))) (match-string 2))) (forward-line 1))) (when mpd-status-update-p (setq mpd-status-update-p nil) (setq mpd-zero-vars-p nil) (run-hooks 'mpd-after-variables-update-hook))) (defun mpd-process-sentinel (proc &optional evstr) (let ((timer (get-itimer mpd-itimer))) (message "[MPD]: %s" evstr) (delete-process proc) (when (itimerp timer) (delete-itimer timer)) (setq mpd-itimer nil mpd-process nil))) (defvar mpd-cover-glyph nil "The extent holding the album cover art.") (defvar mpd-current-filename nil "Filename of the currently playing mpd track. This differs from `**mpd-var-file*' in that it is only updated once per track change instead of every time `mpd-itimer' fires.") (defun mpd-file () "Return the file name of current track from mpd. Note that this is just what is reported by mpd. To perform operations on the file on disc you will need to prepend `mpd-music-directory' to it first." (and-boundp '**mpd-var-file* **mpd-var-file*)) (defun mpd-cover-file () "Return a possible coverart filename. The file may not exist on disc so call `file-exists-p' on it, or see `mpd-has-cover-p'." (let ((ifile (expand-file-name (mpd-file) mpd-music-directory)) (ofile (expand-file-name "cover.jpg" (temp-directory))) (dir (paths-construct-path (list mpd-music-directory (file-dirname (mpd-file))))) (ffmpeg (executable-find "ffmpeg")) (embedded 1)) ;; Try for embedded art first (when ffmpeg (setq embedded (call-process "ffmpeg" nil nil nil "-hide_banner" "-loglevel" "quiet" "-y" "-i" ifile ofile))) (if (eq embedded 0) ofile (expand-file-name "cover.jpg" dir)))) (defun mpd-has-cover-p () "Return t when coverart exists for the current track." (file-exists-p (mpd-cover-file))) (defun mpd-scale-cover (cover height &optional width) "Scale image, COVER to HEIGHT x WIDTH. Argument COVER is the image filename. No checks are made on its existence, or even if it is an image file \(only JPEG is supported incidently\). So you should do some rudimentary checks before calling this. The file on disc is left unchanged. Argument HEIGHT is the height in pixels to scale the image to. Optional argument WIDTH is the width in pixels to scale the image to. If omitted it defaults to HEIGHT. A string is returned that can be used in the :data key of `make-glyph'." (with-temp-buffer (shell-command (format (concat "jpegtopnm " "'" cover "' 2>/dev/null" "|pnmnorm 2>/dev/null" "|pnmscale -height %d -width %d" "|pnmtojpeg") height (or width height)) 'insert) (buffer-string))) (defun mpd-update-cover () "Updates the cover art glyph." (unless (equal mpd-current-filename (mpd-file)) (with-current-buffer mpd-dock-buffer (let ((cover (mpd-cover-file)) (nocover (expand-file-name "nocover.jpg" mpd-directory))) (if (mpd-has-cover-p) (progn (setq cover (mpd-scale-cover cover 48)) (set-extent-end-glyph mpd-cover-glyph (make-glyph `([jpeg :data ,cover])))) (set-extent-end-glyph mpd-cover-glyph (make-glyph `([jpeg :file ,nocover])))))) (setq mpd-current-filename (mpd-file)))) (defun mpd-update-variables () "Requests status information." (run-hooks 'mpd-before-variables-update-hook) (setq mpd-zero-vars-p t) (mpd-send "currentsong") (setq mpd-status-update-p t) (mpd-send "status") (mpd-update-cover)) (defun mpd-clear-variables () "Clears the most relevant mpd variables." (with-boundp '(**mpd-var-Title* **mpd-var-Artist* **mpd-var-Album* **mpd-var-Genre* **mpd-var-Date*) (setq **mpd-var-Title* nil **mpd-var-Artist* nil **mpd-var-Album* nil **mpd-var-Genre* nil **mpd-var-Date* nil))) ;;;; Dockapp section (defvar mpd-dock-frame-plist '((name . "MpdDock") (height . 3) (width . 13) (unsplittable . t) (minibuffer . none) (menubar-visible-p . nil) (has-modeline-p . nil) (default-gutter-visible-p . nil) (default-toolbar-visible-p . nil) (scrollbar-height . 0) (scrollbar-width . 0) (text-cursor-visible-p . nil)) "Frame properties for mpd dock.") (defun mpd-info (&rest args) "Returns a string for use in the mpd balloon-help frame." (with-boundp '(**mpd-var-Title* **mpd-var-Artist* **mpd-var-Album* **mpd-var-Genre* **mpd-var-Date*) (let ((title (or **mpd-var-Title* "Unknown")) (artist (or **mpd-var-Artist* "Unknown")) (album (or **mpd-var-Album* "Unknown")) (genre (or **mpd-var-Genre* "Unknown")) (year (or **mpd-var-Date* "Unknown")) (file (file-name-nondirectory (mpd-file)))) (format (concat (when (mpd-has-cover-p) "\n\n") "--[ %s ]\n Artist: %s Album: %s Year: %s Genre: %s\n --[ %s ]" (when (mpd-has-cover-p) "\n\n\n\n\n\n\n\n")) title artist album year genre file)))) (defun mpd-balloon-cover () "Inserts coverart into mpd balloon." (let ((cover (mpd-cover-file))) (when (mpd-has-cover-p) (setq cover (mpd-scale-cover cover 128)) (set-extent-begin-glyph (make-extent (point-min) (point-min)) (make-glyph `([jpeg :data ,cover])))))) (defadvice balloon-help-display-help (after mpd-balloon-cover (&rest args) activate) "Display cover art image in the balloon." (when (process-live-p (get-process "mpd")) (set-buffer balloon-help-buffer) (goto-char (point-max)) (and (re-search-backward (file-name-nondirectory (mpd-file)) nil t) (mpd-balloon-cover)))) (defconst mpd-prev-map (let* ((map (make-sparse-keymap 'mpd-prev-map))) (define-key map [button1] 'mpd-previous-track) map) "Keymap for \"Prev\" button.") (defconst mpd-pause-map (let* ((map (make-sparse-keymap 'mpd-pause-map))) (define-key map [button1] 'mpd-pause) map) "Keymap for \"Pause\" button.") (defconst mpd-play-map (let* ((map (make-sparse-keymap 'mpd-play-map))) (define-key map [button1] 'mpd-play) map) "Keymap for \"Play\" button.") (defconst mpd-next-map (let* ((map (make-sparse-keymap 'mpd-next-map))) (define-key map [button1] 'mpd-next-track) map) "Keymap for \"Next\" button.") (defun mpd-new-frame () "Create new mpd frame." (unless (frame-live-p mpd-dock-frame) (setq mpd-dock-frame (new-frame mpd-dock-frame-plist)) (select-frame mpd-dock-frame) (unless (buffer-live-p mpd-dock-buffer) (setq mpd-dock-buffer (get-buffer-create "*MpdDock*")) (set-buffer-dedicated-frame mpd-dock-buffer mpd-dock-frame) (save-excursion (let (prev pause play next) (set-buffer mpd-dock-buffer) (set-extent-end-glyph (setq prev (make-extent (point-max) (point-max))) (make-glyph `([xpm :file ,(expand-file-name "Rewind.xpm" mpd-directory)]))) (set-extent-properties prev `(keymap ,mpd-prev-map balloon-help "Previous Track")) (set-extent-end-glyph (setq pause (make-extent (point-max) (point-max))) (make-glyph `([xpm :file ,(expand-file-name "Pause.xpm" mpd-directory)]))) (set-extent-properties pause `(keymap ,mpd-pause-map balloon-help "Pause")) (set-extent-end-glyph (setq play (make-extent (point-max) (point-max))) (make-glyph `([xpm :file ,(expand-file-name "Play.xpm" mpd-directory)]))) (set-extent-properties play `(keymap ,mpd-play-map balloon-help "Play")) (set-extent-end-glyph (setq next (make-extent (point-max) (point-max))) (make-glyph `([xpm :file ,(expand-file-name "FFwd.xpm" mpd-directory)]))) (set-extent-properties next `(keymap ,mpd-next-map balloon-help "Next Track")) (insert " ") (set-extent-end-glyph (setq mpd-cover-glyph (make-extent (point-max) (point-max))) (make-glyph `([jpeg :file ,(expand-file-name "nocover.jpg" mpd-directory)]))) (set-extent-properties mpd-cover-glyph `(keymap ,mpd-pause-map balloon-help ,#'mpd-info))))) (set-specifier horizontal-scrollbar-visible-p nil (cons mpd-dock-frame nil)) (set-specifier vertical-scrollbar-visible-p nil (cons mpd-dock-frame nil)) (set-window-buffer nil mpd-dock-buffer))) (defun mpd () "Start mpd dockapp to interact with MusicPD." (interactive) (let ((cframe (selected-frame))) ;; Start client connection (mpd-start-connection) (mpd-new-frame) (focus-frame cframe) (mpd-update-variables))) (defun mpd-now-playing () "Return a formatted string of Title and Artist. This is for use in things like Gnus for a X-Now-Playing header etc." (with-fboundp 'taglib:show-tag (let ((title (and mpd-current-filename (taglib:show-tag (expand-file-name (mpd-file) mpd-music-directory) 'title))) (artist (and mpd-current-filename (taglib:show-tag (expand-file-name (mpd-file) mpd-music-directory) 'artist)))) (format "%s --- [%s]" (or title "The Sounds of Silence") (or artist "Marcel Marceau"))))) (provide 'mpd) ;;; mpd.el ends here