Added mpg123 command-line arguments
[emus.git] / emus.el
1 ;;; emus.el --- Simple mp3 player  -*- lexical-binding:t -*-
2
3 ;; Copyright (C) 2019 Tim Vaughan
4
5 ;; Author: Tim Vaughan <timv@ughan.xyz>
6 ;; Created: 8 December 2019
7 ;; Version: 1.0
8 ;; Keywords: multimedia
9 ;; Homepage: http://thelambdalab.xy/emus
10 ;; Package-Requires: ((emacs "26"))
11
12 ;; This file is not part of GNU Emacs.
13
14 ;; This program is free software: you can redistribute it and/or modify
15 ;; it under the terms of the GNU General Public License as published by
16 ;; the Free Software Foundation, either version 3 of the License, or
17 ;; (at your option) any later version.
18
19 ;; This program is distributed in the hope that it will be useful,
20 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
21 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
22 ;; GNU General Public License for more details.
23
24 ;; You should have received a copy of the GNU General Public License
25 ;; along with this file.  If not, see <http://www.gnu.org/licenses/>.
26
27 ;;; Commentary:
28
29 ;; This is a simple package for playing audio from a local directory
30 ;; tree of mp3 files.  It uses the program mpg123 as its back-end.
31 ;; Currently the library is loaded completely every time emus starts.
32
33 ;;; Code:
34
35 (provide 'emus)
36
37
38 ;;; Dependencies
39 ;;
40
41 (require 'seq)
42
43
44 ;;; Customizations
45 ;;
46
47 (defgroup emus nil
48   "Simple music player for Emacs."
49   :group 'multimedia)
50
51 (defcustom emus-directory "~/Music/"
52   "Directory containing audio files for emus."
53   :type '(string))
54
55 (defcustom emus-mpg123-program "mpg123"
56   "Name of (and, optionally, path to) mpg123 binary."
57   :type '(string))
58
59 (defface emus-artist
60   '(((background dark) :inherit font-lock-string-face :inverse-video t :extend t)
61     (t :inherit font-lock-string-face :background "#ddd" :extend t))
62   "Face used for artist names in browser.")
63
64 (defface emus-album
65   '(((background dark) :inherit font-lock-constant-face :inverse-video t :extend t)
66     (t :inherit font-lock-constant-face :background "#eee" :extend t))
67   "Face used for album names in browser.")
68
69 (defface emus-track
70   '((t :inherit font-lock-keyword-face))
71   "Face used for track titles in browser.")
72
73 (defface emus-track-current
74   '((t :inherit font-lock-keyword-face :inverse-video t))
75   "Face used for track titles in browser.")
76
77 (defface emus-cursor
78   '((t :inherit bold))
79   "Face used for current track cursor")
80
81
82 ;;; Global variables
83
84 (defvar emus--proc-in-use nil
85   "If non-nil, disables `emus-send-cmd'.
86 Used to prevent commands from interfering with library construction.")
87
88 (defvar emus-tracks nil
89   "Emus audio library.")
90
91 (defvar emus-current-track nil
92   "Currently-selected emus track.")
93
94 (defvar emus-state 'stopped
95   "Current playback state.")
96
97 (defvar emus-continuous-playback t
98   "If non-nil, emus will automatically play the next track when the current track is finished.")
99
100 (defvar emus-current-volume 100
101   "The current playback volume.")
102
103
104 ;;; mpg123 process
105 ;;
106
107
108 (defun emus-get-process ()
109   "Return current or new mpg123 process."
110   (let* ((emus-process-raw (get-process "emus-process"))
111          (emus-process (if emus-process-raw
112                            (if (process-live-p emus-process-raw)
113                                emus-process-raw
114                              (kill-process emus-process-raw)
115                              nil))))
116     (if emus-process
117         emus-process
118       (let ((proc
119              (make-process :name "emus-process"
120                            :command `(,emus-mpg123-program "-R"))))
121         (set-process-query-on-exit-flag proc nil)
122         (process-send-string proc "silence\n")
123         proc))))
124
125 (defun emus--send-cmd-raw (cmd &rest args)
126   "Send a command CMD with args ARGS to the mpg123 process.
127 This procedure does not respect `emus--proc-in-use' and thus should only
128 be used by `emus--load-library'."
129     (process-send-string (emus-get-process)
130                          (concat
131                           (seq-reduce (lambda (s1 s2) (concat s1 " " s2)) args cmd)
132                           "\n")))
133
134 (defun emus-send-cmd (cmd &rest args)
135   "Send a command CMD with args ARGS to the mpg123 process."
136   (unless emus--proc-in-use
137     (apply #'emus--send-cmd-raw cmd args)))
138
139
140 ;;; Library
141 ;;
142
143 (defun emus-get-audio-files ()
144   "Get all mp3 files in main emus directory."
145   (mapcar
146    (lambda (f)
147      (expand-file-name f))
148    (directory-files-recursively emus-directory ".*\\.mp3")))
149
150 (defun emus-make-track (artist album title filename &optional pos)
151   "Create an object representing an emus track.
152 ARTIST, ALBUM and TITLE are used to describe the track, FILENAME
153 refers to the mp3 file containing the track.  If non-nil, POS
154 specifies the position of the record representing this track in the
155 emus browser buffer."
156   (vector artist album title filename pos))
157
158 (defun emus-track-artist (track)
159   "The artist corresponding to TRACK."
160   (elt track 0))
161
162 (defun emus-track-album (track)
163   "The album corresponding to TRACK."
164   (elt track 1))
165
166 (defun emus-track-title (track)
167   "The title of TRACK."
168   (elt track 2))
169
170 (defun emus-track-file (track)
171   "The mp3 file corresponding to TRACK."
172   (elt track 3))
173
174 (defun emus-track-browser-pos (track)
175   "The location of the browser buffer record corresponding to TRACK."
176   (elt track 4))
177
178 (defun emus-set-track-browser-pos (track pos)
179   "Set the location of the browser buffer record corresponding to TRACK to POS."
180   (aset track 4 pos))
181
182 (defun emus--load-library (then)
183   "Initialize the emus track library.
184 Once the library is initialized, the function THEN is called."
185   (unless emus--proc-in-use
186     (setq emus--proc-in-use t)
187     (emus--suspend-cp)
188     (setq emus-state 'stopped)
189     (let ((proc (emus-get-process))
190           (tagstr "")
191           (filenames (emus-get-audio-files)))
192       (setq emus-tracks nil)
193       (set-process-filter proc (lambda (proc string)
194                                  (setq tagstr (concat tagstr string))
195                                  (when (string-suffix-p "@P 1\n" string)
196                                    (add-to-list 'emus-tracks
197                                                 (emus--make-track-from-tagstr (car filenames)
198                                                                               tagstr))
199                                    (setq tagstr "")
200                                    (setq filenames (cdr filenames))
201                                    (if filenames
202                                        (emus--send-cmd-raw "lp" (car filenames))
203                                      (set-process-filter proc nil)
204                                      (setq emus-tracks (reverse emus-tracks))
205                                      (emus--sort-tracks)
206                                      (unless emus-current-track
207                                        (setq emus-current-track (car emus-tracks)))
208                                      (funcall then)
209                                      (emus--resume-cp)
210                                      (setq emus--proc-in-use nil)))))
211       (emus--send-cmd-raw "lp" (car filenames)))))
212
213 (defun emus--make-track-from-tagstr (filename tagstr)
214   "Parse TAGSTR to populate the fields of a track corresponding to FILENAME."
215   (let ((artist "")
216         (album "")
217         (title ""))
218     (dolist (line (split-string tagstr "\n"))
219       (let ((found-artist (elt (split-string line "@I ID3v2.artist:") 1))
220             (found-album (elt (split-string line "@I ID3v2.album:") 1))
221             (found-title (elt (split-string line "@I ID3v2.title:") 1)))
222         (cond
223          (found-artist (setq artist found-artist))
224          (found-album (setq album found-album))
225          (found-title (setq title found-title)))))
226     (emus-make-track artist album title filename nil)))
227
228 (defun emus--sort-tracks ()
229   "Sort the library tracks according to artist and album.
230 Leaves the track titles unsorted, so they will appear in the order specified
231 by the filesystem."
232   (sort emus-tracks
233         (lambda (r1 r2)
234           (let ((artist1 (emus-track-artist r1))
235                 (artist2 (emus-track-artist r2)))
236             (if (string= artist1 artist2)
237                 (let ((album1 (emus-track-album r1))
238                       (album2 (emus-track-album r2)))
239                   (string< album1 album2))
240               (string< artist1 artist2))))))
241
242 (defmacro emus--with-library (&rest body)
243   "Evaluate BODY with the library initialized."
244   `(if emus-tracks
245        (unless emus--proc-in-use ,@body)
246      (emus--load-library
247       (lambda () ,@body))))
248
249
250 ;;; Playback
251 ;;
252
253 (defun emus--suspend-cp ()
254   "Suspend continuous playback."
255   (setq emus-continuous-playback nil))
256
257 (defun emus--resume-cp ()
258   "Resume continuous playback."
259   (setq emus-continuous-playback t)
260   (set-process-filter (emus-get-process)
261                       (lambda (_proc string)
262                         (and emus-continuous-playback
263                              (eq emus-state 'playing)
264                              (string-suffix-p "@P 0\n" string)
265                              (emus-play-next)))))
266
267 (defun emus-play-track (track)
268   "Set TRACK as current and start playing."
269   (emus--with-library
270    (let ((old-track emus-current-track))
271      (emus-send-cmd "l" (emus-track-file track))
272      (setq emus-state 'playing)
273      (setq emus-current-track track)
274      (emus--update-track old-track)
275      (emus--update-track track)
276      (emus--resume-cp)
277      (emus-goto-current))))
278
279 (defun emus-select-track (track)
280   "Set TRACK as current, but do not start playing."
281   (emus--with-library
282    (let ((old-track emus-current-track))
283      (setq emus-state 'stopped)
284      (setq emus-current-track track)
285      (emus--update-track old-track)
286      (emus--update-track track)
287      (emus-send-cmd "o")
288      (emus--resume-cp)
289      (emus-goto-current))))
290
291 (defun emus-stop ()
292   "Stop playback of the current track."
293   (interactive)
294   (emus--with-library
295    (setq emus-state 'stopped)
296    (emus--update-track emus-current-track)
297    (emus-send-cmd "s")))
298
299 (defun emus-playpause ()
300   "Begin playback of the current track.
301 If the track is already playing, pause playback.
302 If the track is currently paused, resume playback."
303   (interactive)
304   (emus--with-library
305    (when emus-current-track
306      (if (eq emus-state 'stopped)
307          (emus-play-track emus-current-track)
308        (emus-send-cmd "p")
309        (pcase emus-state
310          ((or 'paused 'stopped) (setq emus-state 'playing))
311          ('playing (setq emus-state 'paused)))
312        (unless (eq emus-state 'paused)))
313      (emus--update-track emus-current-track))))
314
315 (defun emus-set-volume (pct)
316   "Set the playback volume to PCT %."
317   (emus--with-library
318    (setq emus-current-volume pct)
319    (emus-send-cmd "v" (number-to-string pct))))
320
321 (defun emus-volume-increase-by (delta)
322   "Increase the playback volume by DELTA %."
323   (emus-set-volume (max 0 (min 100 (+ emus-current-volume delta)))))
324
325 (defun emus-volume-up ()
326   "Increase the playback volume by 10%."
327   (interactive)
328   (emus-volume-increase-by 10))
329
330 (defun emus-volume-down ()
331   "Decrease the playback volume by 10%."
332   (interactive)
333   (emus-volume-increase-by -10))
334
335 (defun emus--play-adjacent-track (&optional prev)
336   "Play the next track in the library, or the previous if PREV is non-nil."
337   (emus--with-library
338    (let ((idx (seq-position emus-tracks emus-current-track))
339          (offset (if prev -1 +1)))
340      (if idx
341          (let ((next-track (elt emus-tracks (+ idx offset))))
342            (if next-track
343                (if (eq emus-state 'playing)
344                    (emus-play-track next-track)
345                  (emus-select-track next-track))
346              (error "Track does not exist")))
347        (error "No track selected")))))
348
349 (defun emus--play-adjacent-album (&optional prev)
350   "Play the first track of the next album in the library.
351 If PREV is non-nil, plays the last track of the previous album."
352   (emus--with-library
353    (let ((idx (seq-position emus-tracks emus-current-track)))
354      (if idx
355          (let* ((search-list (if prev
356                                  (reverse (seq-subseq emus-tracks 0 idx))
357                                (seq-subseq emus-tracks (+ idx 1))))
358                 (current-album (emus-track-album emus-current-track))
359                 (next-track (seq-some (lambda (r)
360                                         (if (string= (emus-track-album r)
361                                                      current-album)
362                                             nil
363                                           r))
364                                       search-list)))
365            (if next-track
366                (if (eq emus-state 'playing)
367                    (emus-play-track next-track)
368                  (emus-select-track next-track))
369              (error "Track does not exist")))
370        (error "No track selected")))))
371
372 (defun emus-play-next ()
373   "Play the next track in the library."
374   (interactive)
375   (emus--play-adjacent-track))
376
377 (defun emus-play-prev ()
378   "Play the previous track in the library."
379   (interactive)
380   (emus--play-adjacent-track t))
381
382 (defun emus-play-next-album ()
383   "Play the first track of the next album in the library."
384   (interactive)
385   (emus--play-adjacent-album))
386
387 (defun emus-play-prev-album ()
388   "Play the last track of the previous album in the library."
389   (interactive)
390   (emus--play-adjacent-album t))
391
392 (defun emus-jump (seconds)
393   "Jump forward in current track by SECONDS seconds."
394   (emus--with-library
395    (emus-send-cmd "jump" (format "%+ds" seconds))))
396
397 (defun emus-jump-10s-forward ()
398   "Jump 10 seconds forward in current track."
399   (interactive)
400   (emus-jump 10))
401
402 (defun emus-jump-10s-backward ()
403   "Jump 10 seconds backward in current track."
404   (interactive)
405   (emus-jump -10))
406
407 (defun emus-display-status ()
408   "Display the current playback status in the minibuffer."
409   (interactive)
410   (emus--with-library
411    (message
412     (concat "Emus: Volume %d%%"
413             (pcase emus-state
414               ('stopped " [Stopped]")
415               ('paused " [Paused]")
416               ('playing " [Playing]")
417               (_ ""))
418             (if emus-current-track
419                 (format " - %.30s (%.20s)"
420                         (emus-track-title emus-current-track)
421                         (emus-track-artist emus-current-track))
422               ""))
423     emus-current-volume)))
424
425
426 ;;; Browser
427 ;;
428
429 (defun emus--insert-track (track &optional prev-track first)
430   "Insert a button representing TRACK into the current buffer.
431
432 When provided, PREV-TRACK is used to determine whether to insert additional
433 headers representing the artist or the album title.
434
435 If non-nil, FIRST indicates that the track is the first in the library
436 and thus requires both artist and album headers."
437   (let* ((artist (emus-track-artist track))
438          (album (emus-track-album track))
439          (title (emus-track-title track))
440          (help-str (format "mouse-1, RET: Play '%.30s' (%.20s)" title artist))
441          (field (intern album))) ;Allows easy jumping between albums with cursor.
442     (when (or prev-track first)
443       (unless (equal (emus-track-artist prev-track) artist)
444         (insert-text-button
445          (propertize artist 'face 'emus-artist)
446          'action #'emus--click-track
447          'follow-link t
448          'help-echo help-str
449          'emus-track track
450          'field field)
451         (insert (propertize "\n"
452                             'face 'emus-artist
453                             'field field)))
454       (unless (equal (emus-track-album prev-track) album)
455         (insert-text-button
456          (propertize (concat "  " album) 'face 'emus-album)
457          'action #'emus--click-track
458          'follow-link t
459          'help-echo help-str
460          'emus-track track
461          'field field)
462         (insert (propertize "\n"
463                             'face 'emus-album
464                             'field field))))
465     (emus-set-track-browser-pos track (point))
466     (let ((is-current (equal track emus-current-track)))
467       (insert-text-button
468        (concat
469         (if is-current
470             (propertize
471              (pcase emus-state
472                ('playing "->")
473                ('paused "-)")
474                ('stopped "-]"))
475              'face 'emus-cursor)
476           (propertize "  " 'face 'default))
477         (propertize (format "   %s" title)
478                     'face (if is-current
479                               'emus-track-current
480                             'emus-track)))
481        'action #'emus--click-track
482        'follow-link t
483        'help-echo help-str
484        'emus-track track
485        'field field)
486       (insert (propertize "\n"
487                           'face (if is-current
488                                     'emus-track-current
489                                   'emus-track)
490                           'field field)))))
491
492 (defun emus--update-track (track)
493   "Rerender entry for TRACK in emus browser buffer.
494 Used to update browser display when `emus-current-track' and/or `emus-state' changes."
495   (let ((track-pos (emus-track-browser-pos track)))
496     (when (and (get-buffer "*emus*")
497                (emus-track-browser-pos track))
498       (with-current-buffer "*emus*"
499         (let ((inhibit-read-only t)
500               (old-point (point)))
501           (goto-char track-pos)
502           (search-forward "\n")
503           (delete-region track-pos (point))
504           (goto-char track-pos)
505           (emus--insert-track track)
506           (goto-char old-point))))))
507
508 (defun emus--render-tracks ()
509   "Render all library tracks in emus browser buffer."
510   (with-current-buffer "*emus*"
511     (let ((inhibit-read-only t)
512           (old-pos (point)))
513       (erase-buffer)
514       (goto-char (point-min))
515       (let ((prev-track nil))
516         (dolist (track emus-tracks)
517           (emus--insert-track track prev-track (not prev-track))
518           (setq prev-track track)))
519       (goto-char old-pos))))
520
521 (defun emus--click-track (button)
522   "Begin playback of track indicated by BUTTON."
523   (emus-play-track (button-get button 'emus-track))
524   (emus-display-status))
525
526 (defun emus-goto-current ()
527   "Move point to the current track in the browser buffer, if available."
528   (interactive)
529   (when (and (get-buffer "*emus*")
530              emus-current-track)
531     (with-current-buffer "*emus*"
532         (goto-char (emus-track-browser-pos emus-current-track)))))
533
534 (defun emus-browse ()
535   "Switch to *emus* audio library browser."
536   (interactive)
537   (emus--with-library
538    (pop-to-buffer-same-window "*emus*")
539    (emus-browser-mode)
540    (emus--render-tracks)
541    (emus-goto-current)))
542
543 (defun emus-refresh ()
544   "Refresh the emus library."
545   (interactive)
546   (emus-stop)
547   (setq emus-tracks nil)
548   (emus-browse))
549
550
551 ;;; Playback + status display commands
552 ;;
553
554 (defun emus-playpause-status ()
555   "Start, pause or resume playback, then display the emus status in the minibuffer."
556   (interactive)
557   (emus-playpause)
558   (emus-display-status))
559
560 (defun emus-stop-status ()
561   "Stop playback, then display the emus status in the minibuffer."
562   (interactive)
563   (emus-stop)
564   (emus-display-status))
565
566 (defun emus-volume-up-status ()
567   "Increase volume by 10%, then display the emus status in the minibuffer."
568   (interactive)
569   (emus-volume-up)
570   (emus-display-status))
571
572 (defun emus-volume-down-status ()
573   "Decrease volume by 10%, then display the emus status in the minibuffer."
574   (interactive)
575   (emus-volume-down)
576   (emus-display-status))
577
578 (defun emus-play-next-status ()
579   "Play next track, then display the emus status in the minibuffer."
580   (interactive)
581   (emus-play-next)
582   (emus-display-status))
583
584 (defun emus-play-prev-status ()
585   "Play previous track, then display the emus status in the minibuffer."
586   (interactive)
587   (emus-play-prev)
588   (emus-display-status))
589
590 (defun emus-play-next-album-status ()
591   "Play first track of next album, then display the emus status in the minibuffer."
592   (interactive)
593   (emus-play-next-album)
594   (emus-display-status))
595
596 (defun emus-play-prev-album-status ()
597   "Play last track of previous album, then display the emus status in the minibuffer."
598   (interactive)
599   (emus-play-prev-album)
600   (emus-display-status))
601
602 (defun emus-jump-10s-forward-status ()
603   "Jump 10s forward in current track, then display the emus status in the minibuffer."
604   (interactive)
605   (emus-jump-10s-forward)
606   (emus-display-status))
607
608 (defun emus-jump-10s-backward-status ()
609   "Jump 10s backward in current track, then display the emus status in the minibuffer."
610   (interactive)
611   (emus-jump-10s-backward)
612   (emus-display-status))
613
614 (defun emus-goto-current-status ()
615   "Move point to the current track, then display the emus status in the minibuffer."
616   (interactive)
617   (emus-goto-current)
618   (emus-display-status))
619
620 (defun emus-refresh-status ()
621   "Refresh the emus library, then display the emus status in the minibuffer."
622   (interactive)
623   (emus-stop)
624   (setq emus-tracks nil)
625   (emus--with-library
626    (emus-browse)
627    (emus-display-status)))
628
629 (defvar emus-browser-mode-map
630   (let ((map (make-sparse-keymap)))
631     (define-key map (kbd "SPC") 'emus-playpause-status)
632     (define-key map (kbd "o") 'emus-stop-status)
633     (define-key map (kbd "+") 'emus-volume-up-status)
634     (define-key map (kbd "=") 'emus-volume-up-status)
635     (define-key map (kbd "-") 'emus-volume-down-status)
636     (define-key map (kbd "R") 'emus-refresh-status)
637     (define-key map (kbd "n") 'emus-play-next-status)
638     (define-key map (kbd "p") 'emus-play-prev-status)
639     (define-key map (kbd "N") 'emus-play-next-album-status)
640     (define-key map (kbd "P") 'emus-play-prev-album-status)
641     (define-key map (kbd ",") 'emus-jump-10s-backward-status)
642     (define-key map (kbd ".") 'emus-jump-10s-forward-status)
643     (define-key map (kbd "c") 'emus-goto-current-status)
644     (when (fboundp 'evil-define-key*)
645       (evil-define-key* 'motion map
646                         (kbd "SPC") 'emus-playpause-status
647                         (kbd "o") 'emus-stop-status
648                         (kbd "+") 'emus-volume-up-status
649                         (kbd "=") 'emus-volume-up-status
650                         (kbd "-") 'emus-volume-down-status
651                         (kbd "R") 'emus-refresh-status
652                         (kbd "n") 'emus-play-next-status
653                         (kbd "p") 'emus-play-prev-status
654                         (kbd "N") 'emus-play-next-album-status
655                         (kbd "P") 'emus-play-prev-album-status
656                         (kbd ",") 'emus-jump-10s-backward-status
657                         (kbd ".") 'emus-jump-10s-forward-status
658                         (kbd "c") 'emus-goto-current-status))
659     map)
660   "Keymap for emus browser.")
661
662 (define-derived-mode emus-browser-mode special-mode "emus-browser"
663   "Major mode for EMUS music player file browser.")
664
665 (when (fboundp 'evil-set-initial-state)
666   (evil-set-initial-state 'emus-browser-mode 'motion))
667
668 ;;; emus.el ends here