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