;;; emus.el --- Simple mp3 player -*- lexical-binding:t -*- ;; Copyright (C) 2019 Tim Vaughan ;; Author: Tim Vaughan <timv@ughan.xyz> ;; Created: 8 December 2019 ;; Version: 1.0 ;; Keywords: multimedia ;; Homepage: http://thelambdalab.xy/emus ;; Package-Requires: ((emacs "26")) ;; This file is not part of GNU Emacs. ;; 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 file. If not, see <http://www.gnu.org/licenses/>. ;;; Commentary: ;; This is a simple package for playing audio from a local directory ;; tree of mp3 files. It uses the program mpg123 as its back-end. ;; Currently the library is loaded completely every time emus starts. ;;; Code: (provide 'emus) ;;; Dependencies ;; (require 'seq) ;;; Customizations ;; (defgroup emus nil "Simple music player for Emacs." :group 'multimedia) (defcustom emus-directory "~/Music/" "Directory containing audio files for emus." :type '(string)) (defcustom emus-mpg123-program "mpg123" "Name of (and, optionally, path to) mpg123 binary." :type '(string)) (defcustom emus-mpg123-args nil "Arguments to pass to mpg123." :type '(repeat string)) (defface emus-artist '((((background dark)) :inherit font-lock-string-face :background "#222" :extend t) (t :inherit font-lock-string-face :background "#ddd" :extend t)) "Face used for artist names in browser.") (defface emus-album '((((background dark)) :inherit font-lock-constant-face :background "#111" :extend t) (t :inherit font-lock-constant-face :background "#eee" :extend t)) "Face used for album names in browser.") (defface emus-track '((t :inherit font-lock-keyword-face)) "Face used for track titles in browser.") (defface emus-track-current '((t :inherit font-lock-keyword-face :inverse-video t :extend t)) "Face used for track titles in browser.") (defface emus-cursor '((t :inherit bold)) "Face used for current track cursor") ;;; Global variables (defvar emus--proc-in-use nil "If non-nil, disables `emus-send-cmd'. Used to prevent commands from interfering with library construction.") (defvar emus-tracks nil "Emus audio library.") (defvar emus-current-track nil "Currently-selected emus track.") (defvar emus-state 'stopped "Current playback state.") (defvar emus-continuous-playback t "If non-nil, emus will automatically play the next track when the current track is finished.") (defvar emus-current-volume 100 "The current playback volume.") (defvar emus-current-progress "" "String describing the progress through the current track.") (defvar emus-progress-enabled t "Current state of progress tracking. To enable or disable progress tracking, using `emus-toggle-progress-tracking'. (Changing the value of this variable will not affect anything.)") ;;; mpg123 process ;; (defun emus-get-process () "Return current or new mpg123 process." (let* ((emus-process-raw (get-process "emus-process")) (emus-process (if emus-process-raw (if (process-live-p emus-process-raw) emus-process-raw (kill-process emus-process-raw) nil)))) (if emus-process emus-process (let ((proc (make-process :name "emus-process" :command `(,emus-mpg123-program ,@emus-mpg123-args "-R")))) (set-process-query-on-exit-flag proc nil) (unless emus-progress-enabled (process-send-string proc "silence\n")) proc)))) (defun emus--send-cmd-raw (cmd &rest args) "Send a command CMD with args ARGS to the mpg123 process. This procedure does not respect `emus--proc-in-use' and thus should only be used by `emus--load-library'." (process-send-string (emus-get-process) (concat (seq-reduce (lambda (s1 s2) (concat s1 " " s2)) args cmd) "\n"))) (defun emus-send-cmd (cmd &rest args) "Send a command CMD with args ARGS to the mpg123 process." (unless emus--proc-in-use (apply #'emus--send-cmd-raw cmd args))) (defun emus-kill-process () "Kill any existing mpg123 process." (let ((emus-process (get-process "emus-process"))) (if emus-process (kill-process emus-process)) (setq emus-state 'stopped emus--proc-in-use nil emus-tracks nil))) ;;; Library ;; (defun emus-get-audio-files () "Get all mp3 files in main emus directory." (mapcar #'expand-file-name (directory-files-recursively emus-directory ".*\\.mp3"))) (defun emus-get-playlist-files () "Get all m3u files in the main emus music directory." (mapcar #'expand-file-name (directory-files-recursively emus-directory ".*\\.m3u"))) (defun emus-make-track (artist album title filename &optional pos) "Create an object representing an emus track. ARTIST, ALBUM and TITLE are used to describe the track, FILENAME refers to the mp3 file containing the track. If non-nil, POS specifies the position of the record representing this track in the emus browser buffer." (list artist album title filename pos)) (defun emus-track-artist (track) "The artist corresponding to TRACK." (elt track 0)) (defun emus-track-album (track) "The album corresponding to TRACK." (elt track 1)) (defun emus-track-title (track) "The title of TRACK." (elt track 2)) (defun emus-track-file (track) "The mp3 file corresponding to TRACK." (elt track 3)) (defun emus-track-browser-pos (track) "The location of the browser buffer record corresponding to TRACK." (elt track 4)) (defun emus-set-track-browser-pos (track pos) "Set the location of the browser buffer record corresponding to TRACK to POS." (setf (seq-elt track 4) pos)) (defun emus--load-library (then) "Initialize the emus track library. Once the library is initialized, the function THEN is called." (unless emus--proc-in-use (setq emus--proc-in-use t) (emus--suspend-cp) (setq emus-state 'stopped) (setq emus-tracks nil) (let ((proc (emus-get-process)) (tagstr "") (filenames (emus-get-audio-files))) (set-process-filter proc (lambda (proc string) (setq tagstr (concat tagstr string)) (when (string-suffix-p "@P 1\n" string) (add-to-list 'emus-tracks (emus--make-track-from-tagstr (car filenames) tagstr)) (setq tagstr "") (setq filenames (cdr filenames)) (if filenames (emus--send-cmd-raw "lp" (car filenames)) (set-process-filter proc nil) (setq emus-tracks (reverse emus-tracks)) (emus--sort-tracks) (emus--add-tracks-from-playlist-files) (unless emus-current-track (setq emus-current-track (car emus-tracks))) (funcall then) (emus--resume-cp) (setq emus--proc-in-use nil))))) (emus--send-cmd-raw "lp" (car filenames))))) (defun emus--dump-tracks () emus-tracks) (defun emus--make-track-from-tagstr (filename tagstr) "Parse TAGSTR to populate the fields of a track corresponding to FILENAME." (let ((artist "Unknown Artist") (album "Unknown Album") (title filename)) (dolist (line (split-string tagstr "\n")) (let ((found-artist (elt (split-string line "@I ID3v2.artist:") 1)) (found-album (elt (split-string line "@I ID3v2.album:") 1)) (found-title (elt (split-string line "@I ID3v2.title:") 1))) (cond (found-artist (setq artist found-artist)) (found-album (setq album found-album)) (found-title (setq title found-title))))) (emus-make-track artist album title filename))) (defun emus--add-tracks-from-playlist-files () (let ((tracks nil)) (dolist (filename (emus-get-playlist-files)) (let ((artist "Playlists") (album (file-name-base filename)) (title nil) (lines (split-string (with-temp-buffer (insert-file-contents filename) (buffer-string)) "\n"))) (dolist (line lines) (pcase (string-trim line) ((rx (: string-start (* space) "#extinf:" (* (not ",")) "," (let display-title (* any)) string-end)) (setq title display-title)) ((rx (: string-start (* space) "#"))) ;skip other comments ((rx (let filename (+ any))) (setq tracks (cons (emus-make-track artist album (or title filename) filename) tracks)) (setq title nil)))))) (setq emus-tracks (append emus-tracks (reverse tracks))))) (defun emus--sort-tracks () "Sort the library tracks according to artist and album. Leaves the track titles unsorted, so they will appear in the order specified by the filesystem." (setq emus-tracks (sort emus-tracks (lambda (r1 r2) (let ((artist1 (emus-track-artist r1)) (artist2 (emus-track-artist r2))) (if (string= artist1 artist2) (let ((album1 (emus-track-album r1)) (album2 (emus-track-album r2))) (string< album1 album2)) (string< artist1 artist2))))))) (defmacro emus--with-library (&rest body) "Evaluate BODY with the library initialized." `(if emus-tracks (unless emus--proc-in-use ,@body) (emus--load-library (lambda () ,@body)))) ;;; Playback ;; (defun emus--suspend-cp () "Suspend continuous playback." (setq emus-continuous-playback nil)) (defun emus--resume-cp () "Resume continuous playback." (setq emus-continuous-playback t)) (defun emus--timestamp (seconds-total) "Produce a timestamp string representation of SECONDS-TOTAL." (let* ((seconds (truncate (mod seconds-total 60))) (minutes (truncate (/ seconds-total 60)))) (format "%02d:%02d" minutes seconds))) (defun emus-play-track (track) "Set TRACK as current and start playing." (emus--with-library (let ((old-track emus-current-track)) (emus-send-cmd "l" (emus-track-file track)) (setq emus-state 'playing) (setq emus-current-track track) (setq emus-current-progress (if emus-progress-enabled "" " (progress disabled)")) (set-process-filter (emus-get-process) (lambda (_proc string) (dolist (line (string-split string "\n")) (pcase line ((and "@P 0" (guard emus-continuous-playback) (guard (eq emus-state 'playing))) (emus-play-next)) ((and (guard emus-progress-enabled) (rx (: string-start "@I ICY-META: StreamTitle=" (let str (+ (not ";"))) ";"))) (message (concat "Emus: Playing stream " str))) ((and (guard emus-progress-enabled) (rx (: string-start "@F " (+ digit) " " (+ digit) " " (let left-str (+ (not " "))) " " (let right-str (+ any))))) (let* ((left (string-to-number left-str)) (right (string-to-number right-str)) (total (+ left right))) (setq emus-current-progress (format " %s/%s" (emus--timestamp left) (emus--timestamp total))))) )))) (emus--update-track old-track) (emus--update-track track) (emus--resume-cp) (emus-goto-current)))) (defun emus-select-track (track) "Set TRACK as current, but do not start playing." (emus--with-library (let ((old-track emus-current-track)) (setq emus-state 'stopped) (setq emus-current-track track) (emus--update-track old-track) (emus--update-track track) (emus-send-cmd "o") (emus--resume-cp) (emus-goto-current)))) (defun emus-stop () "Stop playback of the current track." (interactive) (emus--with-library (setq emus-state 'stopped) (emus--update-track emus-current-track) (emus-send-cmd "s"))) (defun emus-playpause () "Begin playback of the current track. If the track is already playing, pause playback. If the track is currently paused, resume playback." (interactive) (emus--with-library (when emus-current-track (if (eq emus-state 'stopped) (emus-play-track emus-current-track) (emus-send-cmd "p") (pcase emus-state ((or 'paused 'stopped) (setq emus-state 'playing)) ('playing (setq emus-state 'paused))) (unless (eq emus-state 'paused))) (emus--update-track emus-current-track)))) (defun emus-set-volume (pct) "Set the playback volume to PCT %." (emus--with-library (setq emus-current-volume pct) (emus-send-cmd "v" (number-to-string pct)))) (defun emus-volume-increase-by (delta) "Increase the playback volume by DELTA %." (emus-set-volume (max 0 (min 100 (+ emus-current-volume delta))))) (defun emus-volume-up () "Increase the playback volume by 10%." (interactive) (emus-volume-increase-by 10)) (defun emus-volume-down () "Decrease the playback volume by 10%." (interactive) (emus-volume-increase-by -10)) (defun emus--play-adjacent-track (&optional prev) "Play the next track in the library, or the previous if PREV is non-nil." (emus--with-library (let ((idx (seq-position emus-tracks emus-current-track)) (offset (if prev -1 +1))) (if idx (let ((next-track (elt emus-tracks (+ idx offset)))) (if next-track (if (eq emus-state 'playing) (emus-play-track next-track) (emus-select-track next-track)) (error "Track does not exist"))) (error "No track selected"))))) (defun emus--play-adjacent-album (&optional prev) "Play the first track of the next album in the library. If PREV is non-nil, plays the last track of the previous album." (emus--with-library (let ((idx (seq-position emus-tracks emus-current-track))) (if idx (let* ((search-list (if prev (reverse (seq-subseq emus-tracks 0 idx)) (seq-subseq emus-tracks (+ idx 1)))) (current-album (emus-track-album emus-current-track)) (next-track (seq-some (lambda (r) (if (string= (emus-track-album r) current-album) nil r)) search-list))) (if next-track (if (eq emus-state 'playing) (emus-play-track next-track) (emus-select-track next-track)) (error "Track does not exist"))) (error "No track selected"))))) (defun emus-play-next () "Play the next track in the library." (interactive) (emus--play-adjacent-track)) (defun emus-play-prev () "Play the previous track in the library." (interactive) (emus--play-adjacent-track t)) (defun emus-play-next-album () "Play the first track of the next album in the library." (interactive) (emus--play-adjacent-album)) (defun emus-play-prev-album () "Play the last track of the previous album in the library." (interactive) (emus--play-adjacent-album t)) (defun emus-jump (seconds) "Jump forward in current track by SECONDS seconds." (emus--with-library (emus-send-cmd "jump" (format "%+ds" seconds)))) (defun emus-jump-10s-forward () "Jump 10 seconds forward in current track." (interactive) (emus-jump 10)) (defun emus-jump-10s-backward () "Jump 10 seconds backward in current track." (interactive) (emus-jump -10)) (defun emus-jump-1m-forward () "Jump 1 minute forward in current track." (interactive) (emus-jump 60)) (defun emus-jump-1m-backward () "Jump 1 minute backward in current track." (interactive) (emus-jump -60)) (defun emus-display-status () "Display the current playback status in the minibuffer." (interactive) (emus--with-library (message (concat "Emus: Volume %d%%" (pcase emus-state ('stopped " [Stopped]") ('paused (format " [Paused%s]" emus-current-progress)) ('playing (format " [Playing%s]" emus-current-progress)) (_ "")) " %s") emus-current-volume (if emus-current-track (format "- %.30s (%.20s, %.20s)" (emus-track-title emus-current-track) (emus-track-album emus-current-track) (emus-track-artist emus-current-track)) "")))) (defun emus-toggle-progress-tracking () "Enable/disable progress tracking." (interactive) (setq emus-progress-enabled (not emus-progress-enabled)) (if emus-progress-enabled (progn (emus-send-cmd "progress") (setq emus-current-progress "")) (progn (emus-send-cmd "silence") (setq emus-current-progress " (progress diabled)")))) ;;; Browser ;; (defun emus--insert-track (track &optional prev-track first) "Insert a button representing TRACK into the current buffer. When provided, PREV-TRACK is used to determine whether to insert additional headers representing the artist or the album title. If non-nil, FIRST indicates that the track is the first in the library and thus requires both artist and album headers." (let* ((artist (emus-track-artist track)) (album (emus-track-album track)) (title (emus-track-title track)) (album-symb (intern (concat artist album))) (help-str (format "mouse-1, RET: Play '%.30s' (%.20s)" title artist)) (field (intern album))) ;Allows easy jumping between albums with cursor. (when (or prev-track first) (unless (equal (emus-track-artist prev-track) artist) (insert-text-button (propertize artist 'face 'emus-artist) 'action #'emus--click-track 'follow-link t 'help-echo help-str 'emus-track track 'field field) (insert (propertize "\n" 'face 'emus-artist 'field field))) (unless (equal (emus-track-album prev-track) album) (insert-text-button (propertize (concat " " album) 'face 'emus-album) 'action #'emus--click-track 'follow-link t 'help-echo help-str 'emus-track track 'field field) (insert (propertize "\n" 'face 'emus-album 'field field)))) (emus-set-track-browser-pos track (point)) (let ((is-current (equal track emus-current-track))) (insert-text-button (concat (if is-current (propertize (pcase emus-state ('playing "->") ('paused "-)") ('stopped "-]")) 'face 'emus-cursor) (propertize " " 'face 'default)) (propertize (format " %s" title) 'face (if is-current 'emus-track-current 'emus-track))) 'action #'emus--click-track 'follow-link t 'help-echo help-str 'emus-track track 'invisible album-symb 'field field) (insert (propertize "\n" 'face (if is-current 'emus-track-current 'emus-track) 'field field 'invisible album-symb))))) (defun emus--update-track (track) "Rerender entry for TRACK in emus browser buffer. Used to update browser display when `emus-current-track' and/or `emus-state' changes." (let ((track-pos (emus-track-browser-pos track))) (when (and (get-buffer "*emus*") (emus-track-browser-pos track)) (with-current-buffer "*emus*" (let ((inhibit-read-only t) (old-point (point))) (goto-char track-pos) (search-forward "\n") (delete-region track-pos (point)) (goto-char track-pos) (emus--insert-track track) (goto-char old-point)))))) (defun emus--render-tracks () "Render all library tracks in emus browser buffer." (with-current-buffer "*emus*" (let ((inhibit-read-only t) (old-pos (point))) (erase-buffer) (goto-char (point-min)) (let ((prev-track nil)) (dolist (track emus-tracks) (emus--insert-track track prev-track (not prev-track)) (setq prev-track track))) (goto-char old-pos)))) (defun emus--click-track (button) "Begin playback of track indicated by BUTTON." (emus-play-track (button-get button 'emus-track)) (emus-display-status)) (defun emus-goto-current () "Move point to the current track in the browser buffer, if available." (interactive) (when (and (get-buffer "*emus*") emus-current-track) (with-current-buffer "*emus*" (goto-char (emus-track-browser-pos emus-current-track))))) (defun emus-browse () "Switch to *emus* audio library browser." (interactive) (emus--with-library (pop-to-buffer-same-window "*emus*") (emus-browser-mode) (emus--render-tracks) (emus-goto-current))) (defun emus-refresh () "Refresh the emus library." (interactive) (emus-stop) (setq emus-tracks nil) (emus-browse)) ;;; Playback + status display commands ;; (defun emus-playpause-status () "Start, pause or resume playback, then display the emus status in the minibuffer." (interactive) (emus-playpause) (emus-display-status)) (defun emus-stop-status () "Stop playback, then display the emus status in the minibuffer." (interactive) (emus-stop) (emus-display-status)) (defun emus-volume-up-status () "Increase volume by 10%, then display the emus status in the minibuffer." (interactive) (emus-volume-up) (emus-display-status)) (defun emus-volume-down-status () "Decrease volume by 10%, then display the emus status in the minibuffer." (interactive) (emus-volume-down) (emus-display-status)) (defun emus-play-next-status () "Play next track, then display the emus status in the minibuffer." (interactive) (emus-play-next) (emus-display-status)) (defun emus-play-prev-status () "Play previous track, then display the emus status in the minibuffer." (interactive) (emus-play-prev) (emus-display-status)) (defun emus-play-next-album-status () "Play first track of next album, then display the emus status in the minibuffer." (interactive) (emus-play-next-album) (emus-display-status)) (defun emus-play-prev-album-status () "Play last track of previous album, then display the emus status in the minibuffer." (interactive) (emus-play-prev-album) (emus-display-status)) (defun emus-jump-10s-forward-status () "Jump 10s forward in current track, then display the emus status in the minibuffer." (interactive) (emus-jump-10s-forward) (emus-display-status)) (defun emus-jump-10s-backward-status () "Jump 10s backward in current track, then display the emus status in the minibuffer." (interactive) (emus-jump-10s-backward) (emus-display-status)) (defun emus-jump-1m-forward-status () "Jump 10s forward in current track, then display the emus status in the minibuffer." (interactive) (emus-jump-1m-forward) (emus-display-status)) (defun emus-jump-1m-backward-status () "Jump 10s backward in current track, then display the emus status in the minibuffer." (interactive) (emus-jump-1m-backward) (emus-display-status)) (defun emus-toggle-progress-status () "Toggle progress tracking, then display the emus status in the minibuffer." (interactive) (emus-toggle-progress-tracking) (emus-display-status)) (defun emus-goto-current-status () "Move point to the current track, then display the emus status in the minibuffer." (interactive) (emus-goto-current) (emus-display-status)) (defun emus-refresh-status () "Refresh the emus library, then display the emus status in the minibuffer." (interactive) (emus-stop) (setq emus-tracks nil) (emus-browse) (emus-display-status)) (defun emus-restart-browse () "Restart the emus process, then refresh the browse window." (interactive) (message "Restarting mpg123.") (emus-kill-process) (run-at-time 0.1 nil #'emus-browse)) ;Slight delay to wait for kill signal to take effect (defun emus-restart-status () "Restart the emus process, then display the status." (interactive) (message "Restarting mpg123.") (emus-kill-process) (run-at-time 0.1 nil #'emus-display-status)) ;Slight delay to wait for kill signal to take effect (defvar emus-browser-mode-map (let ((map (make-sparse-keymap))) (define-key map (kbd "SPC") 'emus-playpause-status) (define-key map (kbd "o") 'emus-stop-status) (define-key map (kbd "+") 'emus-volume-up-status) (define-key map (kbd "=") 'emus-volume-up-status) (define-key map (kbd "-") 'emus-volume-down-status) (define-key map (kbd "R") 'emus-refresh-status) (define-key map (kbd "n") 'emus-play-next-status) (define-key map (kbd "p") 'emus-play-prev-status) (define-key map (kbd "N") 'emus-play-next-album-status) (define-key map (kbd "P") 'emus-play-prev-album-status) (define-key map (kbd ",") 'emus-jump-10s-backward-status) (define-key map (kbd ".") 'emus-jump-10s-forward-status) (define-key map (kbd "<") 'emus-jump-1m-backward-status) (define-key map (kbd ">") 'emus-jump-1m-forward-status) (define-key map (kbd "c") 'emus-goto-current-status) (define-key map (kbd "#") 'emus-toggle-progress-status) (define-key map (kbd "!") 'emus-restart-browse) (when (fboundp 'evil-define-key*) (evil-define-key* 'motion map (kbd "SPC") 'emus-playpause-status (kbd "o") 'emus-stop-status (kbd "+") 'emus-volume-up-status (kbd "=") 'emus-volume-up-status (kbd "-") 'emus-volume-down-status (kbd "R") 'emus-refresh-status (kbd "n") 'emus-play-next-status (kbd "p") 'emus-play-prev-status (kbd "N") 'emus-play-next-album-status (kbd "P") 'emus-play-prev-album-status (kbd ",") 'emus-jump-10s-backward-status (kbd ".") 'emus-jump-10s-forward-status (kbd "<") 'emus-jump-1m-backward-status (kbd ">") 'emus-jump-1m-forward-status (kbd "c") 'emus-goto-current-status (kbd "#") 'emus-toggle-progress-status (kbd "!") #'emus-restart-browse)) map) "Keymap for emus browser.") (define-derived-mode emus-browser-mode special-mode "emus-browser" "Major mode for EMUS music player file browser." (setq-local buffer-invisibility-spec nil)) (when (fboundp 'evil-set-initial-state) (evil-set-initial-state 'emus-browser-mode 'motion)) ;;; emus.el ends here