summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-10-18 00:04:01 -0500
committerCraig Jennings <c@cjennings.net>2025-10-18 00:04:01 -0500
commit4899d9c020d7c3fdadea6a7234818c2170aa5a07 (patch)
tree7cfe67cce7d9b602e1505956c9ced400ed29f9da
parentbebefbb7c0aeed1f4726589baa099eb48e0f9d74 (diff)
refactor: music-config: streamline EMMS integration + error handling
Simplified EMMS configuration: - restructured helper functions - standardized error handling - removed redundant wrappers - ensured buffer state synchronization - improved Dired/Dirvish integration for playlist management
-rw-r--r--modules/music-config.el858
1 files changed, 363 insertions, 495 deletions
diff --git a/modules/music-config.el b/modules/music-config.el
index 5b5dd6ae..fb5c6477 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -2,500 +2,366 @@
;;
;;; Commentary:
;;
-;; This module provides a comprehensive music management system for Emacs using
-;; EMMS (Emacs MultiMedia System) with MPD (Music Player Daemon) as the backend.
-;; It streamlines music library navigation, playlist management, and playback
-;; control within Emacs.
+;; Comprehensive music management in Emacs via EMMS with MPD backend.
+;; Focus: simple, modular helpers; consistent error handling; streamlined UX.
;;
-;; Features:
-;; - Fuzzy file/directory selection with case-insensitive, alphanumeric ordering
-;; - Recursive directory addition to playlists
-;; - Integration with Dired/Dirvish for adding files from file managers
-;; - M3U playlist saving and loading for playlist portability
-;; - Radio station URL to m3u playlist creation
-;; - Track reordering within playlists
-;; - MPD for playback control
+;; Highlights:
+;; - Fuzzy add: select files/dirs; dirs have trailing /; case-insensitive; stable order
+;; - Recursive directory add
+;; - Dired/Dirvish integration (add selection)
+;; - M3U playlist save/load/edit/reload
+;; - Radio station M3U creation
+;; - Playlist window toggling
+;; - MPD as player
;;
-;; Setup:
-;; 1. Ensure MPD is installed and running on your system
-;; 2. Set `cj/music-root' to your music library directory (default: ~/music)
-;; 3. Set `cj/music-m3u-root' to your playlist directory (default: ~/music)
-;; 4. Customize `cj/music-file-extensions' if you use formats beyond the defaults
+;; Enhancements applied:
+;; 1) Reorganized/grouped functions; unified helpers
+;; 3) Standardized error handling (user-error for user-facing errors; consistent messages)
+;; 4) Removed redundant wrappers in favor of binding to EMMS functions directly
;;
-;; Usage:
-;; All music commands are accessed through the prefix `C-; m' by default.
-;; - `C-; m m' - Show EMMS playlist buffer
-;; - `C-; m a' - Add music via fuzzy search (files or directories)
-;; - `C-; m l' - Load an existing M3U playlist
-;; - `C-; m s' - Save current playlist as M3U
-;; - `C-; m c' - Clear current playlist
-;; - `C-; m r' - Create a radio station playlist
-;; - `C-; m SPC' - Pause/resume playback
+;; Bug fixes:
+;; - cj/music-playlist-edit validates file, and always opens it correctly even after save prompt
+;; - Keep cj/music-playlist-file in sync even when emms-playlist-clear is called elsewhere
;;
-;; When the playlist is active, omit the prefix and enter the keys directly.
-;; Control + up and down arrows will reorder the playlist files.
-;;
-;;
-;; The fuzzy search interface (`C-; m a') presents your music library in a
-;; hierarchical view with directories marked by trailing slashes. Selection
-;; maintains strict alphanumeric ordering even during narrowing, and matching
-;; is case-insensitive. Selecting a directory adds all music files within it
-;; recursively.
-;;
-;; Custom functions use the "cj/" namespace to avoid conflicts with built-in
-;; EMMS functions. The configuration is designed to be testable, with core
-;; functions defined separately from the use-package declaration.
-;;
-;; Requirements:
-;; - MPD (Music Player Daemon) running on localhost:6600
-;;
-;; Debug Notes:
-;; If you want to verify what players are currently in your list, you can evaluate:
-;;
-;; #+begin_src emacs-lisp
-;; ;; Check current player list
-;; emms-player-list
-;;
-;; ;; Check which player would be used for a specific file
-;; (emms-player-for '(*track* (type . file) (name . "~/music/The Beatles/1 (Remastered)/01 Love Me Do (Mono Remastered).flac")))
-;; #+end_src
-;;
-;; Custom functions are defined separately from the use-package declaration to facilitate unit testing.
-
;;; Code:
+(eval-when-compile (require 'emms))
+(eval-when-compile (require 'emms-source-playlist))
+(eval-when-compile (require 'emms-setup))
+(eval-when-compile (require 'emms-player-mpd))
+(eval-when-compile (require 'emms-playlist-mode))
+(eval-when-compile (require 'emms-source-file))
+(eval-when-compile (require 'emms-source-playlist))
+
(require 'cl-lib)
(require 'subr-x)
-;;; Custom Variables
+;;; Settings (no Customize)
-(defgroup cj/music nil
- "Music configuration settings."
- :group 'multimedia)
+(defvar cj/music-root (expand-file-name "~/music")
+ "Root directory of your music collection.")
-(defcustom cj/music-root (expand-file-name "~/music")
- "Root directory of your music collection."
- :type 'directory
- :group 'cj/music)
+(defvar cj/music-m3u-root cj/music-root
+ "Directory where M3U playlists are saved and loaded.")
-(defcustom cj/music-m3u-root cj/music-root
- "Directory where M3U playlists are saved and loaded."
- :type 'directory
- :group 'cj/music)
+(defvar cj/music-keymap-prefix (kbd "C-; m")
+ "Prefix keybinding for all music commands. Currently not auto-bound.")
-(defcustom cj/music-keymap-prefix (kbd "C-; m")
- "Prefix keybinding for all music commands."
- :type 'key-sequence
- :group 'cj/music)
+(defvar cj/music-file-extensions '("aac", "flac", "m4a", "mp3", "ogg", "opus", "wav")
+ "List of valid music file extensions.")
-(defcustom cj/music-file-extensions '("flac" "mp3" "opus" "wav" "m4a" "aac" "ogg")
- "List of valid music file extensions."
- :type '(repeat string)
- :group 'cj/music)
+(defvar cj/music-playlist-buffer-name "*EMMS-Playlist*"
+ "Name of the EMMS playlist buffer used by this configuration.")
-;;; Local Variables
+;;; Buffer-local state
(defvar-local cj/music-playlist-file nil
- "The M3U file associated with the current playlist buffer.
-Set when loading or saving a playlist.")
+ "M3U file associated with the current playlist buffer. Set on load/save.")
-;;; Helper Functions
+;;; Helpers: file/dir/m3u/playlist
(defun cj/music--valid-file-p (file)
- "Return t if FILE is a music file with accepted extensions.
-The check is case-insensitive."
+ "Return non-nil if FILE has an accepted music extension (case-insensitive)."
(when-let ((ext (file-name-extension file)))
- (member (downcase ext) cj/music-file-extensions)))
+ (member (downcase ext) cj/music-file-extensions)))
(defun cj/music--valid-directory-p (dir)
- "Return t if DIR is a directory and is not hidden.
-Hidden directories are those starting with a dot."
+ "Return non-nil if DIR is a non-hidden directory."
(and (file-directory-p dir)
- (not (string-prefix-p "." (file-name-nondirectory (directory-file-name dir))))))
+ (not (string-prefix-p "." (file-name-nondirectory
+ (directory-file-name dir))))))
(defun cj/music--collect-entries-recursive (root)
- "Recursively collect all non-hidden directories and music files under ROOT.
-Return a list of relative paths (from ROOT) sorted alphanumerically.
-Directories and files are mixed and sorted together."
+ "Return sorted relative paths of all subdirs and music files under ROOT.
+Directories are suffixed with /; files are plain. Hidden dirs/files skipped."
(let ((base (file-name-as-directory root))
- (candidates '()))
- (cl-labels ((collect (dir)
- (when (cj/music--valid-directory-p dir)
- (let ((entries (directory-files dir t "^[^.]" t)))
- (dolist (entry (sort entries #'string-lessp))
- (cond
- ((cj/music--valid-directory-p entry)
- (push (string-remove-prefix base entry) candidates)
- (collect entry))
- ((and (file-regular-p entry)
- (cj/music--valid-file-p entry))
- (push (string-remove-prefix base entry) candidates))))))))
- (collect base))
- (nreverse candidates)))
+ (acc '()))
+ (cl-labels ((collect (dir)
+ (when (cj/music--valid-directory-p dir)
+ (dolist (entry (directory-files dir t "^[^.].*" t))
+ (cond
+ ((cj/music--valid-directory-p entry)
+ (let ((rel (string-remove-prefix base entry)))
+ (push (concat rel "/") acc))
+ (collect entry))
+ ((and (file-regular-p entry)
+ (cj/music--valid-file-p entry))
+ (push (string-remove-prefix base entry) acc)))))))
+ (collect base))
+ (sort acc #'string-lessp)))
+
+(defun cj/music--completion-table (candidates)
+ "Completion table for CANDIDATES preserving order and case-insensitive match."
+ (lambda (string pred action)
+ (if (eq action 'metadata)
+ '(metadata
+ (display-sort-function . identity)
+ (cycle-sort-function . identity)
+ (completion-ignore-case . t))
+ (complete-with-action action candidates string pred))))
(defun cj/music--ensure-playlist-buffer ()
- "Ensure EMMS playlist buffer exists and is in playlist mode.
-Returns the buffer or signals an error if it cannot be created."
- (let ((buffer (get-buffer-create "*EMMS Playlist*")))
- (with-current-buffer buffer
- (unless (eq major-mode 'emms-playlist-mode)
- (emms-playlist-mode)))
- buffer))
-
-;; Helper function to extract tracks from M3U file
+ "Ensure EMMS playlist buffer exists and is in playlist mode. Return buffer."
+ (let ((buffer (get-buffer-create cj/music-playlist-buffer-name)))
+ (with-current-buffer buffer
+ (unless (eq major-mode 'emms-playlist-mode)
+ (emms-playlist-mode)))
+ buffer))
+
(defun cj/music--m3u-file-tracks (m3u-file)
- "Extract a list of track filenames from M3U-FILE.
-Returns a list of absolute paths."
- (when (file-exists-p m3u-file)
- (with-temp-buffer
- (insert-file-contents m3u-file)
- (let ((dir (file-name-directory m3u-file))
- (tracks '()))
- (goto-char (point-min))
- (while (re-search-forward "^[^#].*$" nil t)
- (let ((track (match-string 0)))
- (unless (string-empty-p (string-trim track))
- (push (if (or (file-name-absolute-p track)
- (string-match "\\=\\(https?\\|mms\\)://" track))
- track
- (expand-file-name track dir))
- tracks))))
- (nreverse tracks)))))
-
-;; Helper function to get current playlist tracks
+ "Return list of absolute track paths from M3U-FILE. Ignore # comment lines."
+ (when (and m3u-file (file-exists-p m3u-file))
+ (with-temp-buffer
+ (insert-file-contents m3u-file)
+ (let ((dir (file-name-directory m3u-file))
+ (tracks '()))
+ (goto-char (point-min))
+ (while (re-search-forward "^[^#].*$" nil t)
+ (let ((line (string-trim (match-string 0))))
+ (unless (string-empty-p line)
+ (push (if (or (file-name-absolute-p line)
+ (string-match-p "\`\(https?\|mms\)://" line))
+ line
+ (expand-file-name line dir))
+ tracks))))
+ (nreverse tracks)))))
+
(defun cj/music--playlist-tracks ()
- "Get list of track names from the current EMMS playlist buffer."
+ "Return list of track names from current EMMS playlist buffer."
(let ((tracks '()))
- (with-current-buffer (cj/music--ensure-playlist-buffer)
- (save-excursion
- (goto-char (point-min))
- (while (not (eobp))
- (when-let ((track (emms-playlist-track-at (point))))
- (push (emms-track-name track) tracks))
- (forward-line 1))))
- (nreverse tracks)))
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (when-let ((track (emms-playlist-track-at (point))))
+ (push (emms-track-name track) tracks))
+ (forward-line 1))))
+ (nreverse tracks)))
+
+(defun cj/music--get-m3u-files ()
+ "Return list of (BASENAME . FULLPATH) conses for M3Us in cj/music-m3u-root."
+ (let ((files (directory-files cj/music-m3u-root t "\\.m3u\\'" t)))
+ (mapcar (lambda (f) (cons (file-name-nondirectory f) f)) files)))
+
+(defun cj/music--get-m3u-basenames ()
+ "Return list of M3U basenames (no extension) in cj/music-m3u-root."
+ (mapcar (lambda (pair) (file-name-sans-extension (car pair)))
+ (cj/music--get-m3u-files)))
+
+(defun cj/music--safe-filename (name)
+ "Return NAME made filesystem-safe by replacing bad chars with underscores."
+ (replace-regexp-in-string "[^a-zA-Z0-9_-]" "_" name))
+
+(defun cj/music--playlist-modified-p ()
+ "Return non-nil if current playlist differs from its associated M3U file."
+ (and cj/music-playlist-file
+ (let ((file-tracks (cj/music--m3u-file-tracks cj/music-playlist-file))
+ (current-tracks (cj/music--playlist-tracks)))
+ (not (equal file-tracks current-tracks)))))
+
+(defun cj/music--assert-valid-playlist-file ()
+ "Assert that the current playlist buffer has a valid associated M3U file.
+Signals user-error if missing or deleted."
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (cond
+ ((not cj/music-playlist-file)
+ (user-error "No playlist file associated; playlist exists only in memory"))
+ ((not (file-exists-p cj/music-playlist-file))
+ (user-error "Playlist file no longer exists: %s"
+ (file-name-nondirectory cj/music-playlist-file))))))
-;;; Interactive Commands
+;;; Commands: add/select
;;;###autoload
(defun cj/music-add-directory-recursive (directory)
- "Add all music files under DIRECTORY recursively to the EMMS playlist.
-DIRECTORY defaults to `cj/music-root' if called non-interactively."
+ "Add all music files under DIRECTORY recursively to the EMMS playlist."
(interactive
- (list (read-directory-name "Add directory recursively: "
- cj/music-root nil t)))
+ (list (read-directory-name "Add directory recursively: " cj/music-root nil t)))
(unless (file-directory-p directory)
- (user-error "Not a directory: %s" directory))
+ (user-error "Not a directory: %s" directory))
(emms-add-directory-tree directory)
(message "Added recursively: %s" directory))
-(defun cj/music--collect-entries-recursive (root)
- "Recursively collect all non-hidden directories and music files under ROOT.
-Return a list of relative paths (from ROOT) sorted alphanumerically.
-Directories have trailing '/' and everything is sorted together."
- (let ((base (file-name-as-directory root))
- (candidates '()))
- (cl-labels ((collect (dir)
- (when (cj/music--valid-directory-p dir)
- (let ((entries (directory-files dir t "^[^.]" t)))
- (dolist (entry entries)
- (cond
- ;; If it's a directory, add it with trailing / and recurse
- ((cj/music--valid-directory-p entry)
- (let ((rel-path (string-remove-prefix base entry)))
- (push (concat rel-path "/") candidates))
- (collect entry))
- ;; If it's a music file, add it
- ((and (file-regular-p entry)
- (cj/music--valid-file-p entry))
- (push (string-remove-prefix base entry) candidates))))))))
- (collect base))
- ;; Sort all candidates together alphanumerically
- (sort candidates #'string-lessp)))
-
-(defun cj/music--completion-table (candidates)
- "Create a completion table that maintains the order of CANDIDATES.
-Provides case-insensitive matching while preserving sort order."
- (lambda (string pred action)
- (if (eq action 'metadata)
- '(metadata
- (display-sort-function . identity)
- (cycle-sort-function . identity)
- (completion-ignore-case . t))
- (complete-with-action action candidates string pred))))
-
;;;###autoload
(defun cj/music-fuzzy-select-and-add ()
- "Select a music file or directory using fuzzy completion and add to playlist.
-Shows relative paths from =cj/music-root' with directories having trailing slashes.
-Selecting a directory adds it recursively, selecting a file adds that single file.
-Matching is case-insensitive."
+ "Select a music file or directory and add to EMMS playlist.
+Directories (trailing /) are added recursively; files added singly."
(interactive)
- ;; Ensure case-insensitive completion locally
(let* ((completion-ignore-case t)
- (candidates (cj/music--collect-entries-recursive cj/music-root))
- (choice-rel (completing-read "Choose music file or directory: "
- (cj/music--completion-table candidates)
- nil t))
- (cleaned-choice (if (string-suffix-p "/" choice-rel)
- (substring choice-rel 0 -1)
- choice-rel))
- (choice-abs (expand-file-name cleaned-choice cj/music-root)))
- (if (file-directory-p choice-abs)
- (cj/music-add-directory-recursive choice-abs)
- (emms-add-file choice-abs))
- (message "Added %s to EMMS playlist" choice-rel)))
+ (candidates (cj/music--collect-entries-recursive cj/music-root))
+ (choice-rel (completing-read "Choose music file or directory: "
+ (cj/music--completion-table candidates)
+ nil t))
+ (cleaned (if (string-suffix-p "/" choice-rel)
+ (substring choice-rel 0 -1)
+ choice-rel))
+ (abs (expand-file-name cleaned cj/music-root)))
+ (if (file-directory-p abs)
+ (cj/music-add-directory-recursive abs)
+ (emms-add-file abs))
+ (message "Added to playlist: %s" choice-rel)))
+
+;;; Commands: playlist management (load/save/clear/reload/edit)
;;;###autoload
(defun cj/music-playlist-load ()
- "Select and load an M3U playlist file from =cj/music-m3u-root'.
-Clears the current playlist before loading and tracks the source file."
+ "Load an M3U playlist from cj/music-m3u-root.
+Replaces current playlist."
(interactive)
- (let* ((m3u-files (directory-files cj/music-m3u-root t "\\.m3u\\'" t))
- (m3u-names (mapcar #'file-name-nondirectory m3u-files)))
- (when (null m3u-files)
- (user-error "No M3U files found in %s" cj/music-m3u-root))
- (let* ((choice-name (completing-read "Select playlist: " m3u-names nil t))
- (choice-file (expand-file-name choice-name cj/music-m3u-root)))
- (unless (file-exists-p choice-file)
- (user-error "Playlist file does not exist: %s" choice-file))
- (emms-playlist-clear)
- (emms-play-playlist choice-file)
- ;; Track the loaded file
- (with-current-buffer (cj/music--ensure-playlist-buffer)
- (setq cj/music-playlist-file choice-file))
- (message "Loaded playlist: %s" choice-name))))
+ (let* ((pairs (cj/music--get-m3u-files)))
+ (when (null pairs)
+ (user-error "No M3U files found in %s" cj/music-m3u-root))
+ (let* ((choice-name (completing-read "Select playlist: " (mapcar #'car pairs) nil t))
+ (choice-file (cdr (assoc choice-name pairs))))
+ (unless (and choice-file (file-exists-p choice-file))
+ (user-error "Playlist file does not exist: %s" choice-name))
+ (emms-playlist-clear)
+ (emms-play-playlist choice-file)
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (setq cj/music-playlist-file choice-file))
+ (message "Loaded playlist: %s" choice-name))))
;;;###autoload
-
(defun cj/music-playlist-save ()
- "Save the current EMMS playlist to a file in =cj/music-m3u-root'.
-Offers existing playlist names for completion but allows entering new names.
-Automatically adds .m3u extension if not present.
-Tracks the saved file for future reference."
- (interactive)
- (let* ((m3u-files (directory-files cj/music-m3u-root nil "\\.m3u\\'" t))
- (m3u-names-no-ext (mapcar (lambda (f)
- (file-name-sans-extension f))
- m3u-files))
- (chosen-name (completing-read "Save playlist as: "
- m3u-names-no-ext
- nil nil nil nil
- (format-time-string "playlist-%Y%m%d-%H%M%S")))
- (filename (if (string-suffix-p ".m3u" chosen-name)
- chosen-name
- (concat chosen-name ".m3u")))
- (full-path (expand-file-name filename cj/music-m3u-root)))
-
- (when (and (file-exists-p full-path)
- (not (yes-or-no-p (format "Overwrite %s? " filename))))
- (user-error "Aborted saving playlist"))
- (let ((buffer (cj/music--ensure-playlist-buffer)))
- (with-current-buffer buffer
- ;; Use 'never to never prompt for overwrite since we already asked
- (let ((emms-source-playlist-ask-before-overwrite nil))
- (emms-playlist-save 'm3u full-path))
- (setq cj/music-playlist-file full-path)))
-
- (message "Saved playlist to %s" filename)))
-
-;;;###autoload
-(defun cj/music-move-track-up ()
- "Move the current track one line up in the EMMS playlist buffer."
- (interactive)
- (with-current-buffer (cj/music--ensure-playlist-buffer)
- (emms-playlist-mode-move-up)))
-
-;;;###autoload
-(defun cj/music-move-track-down ()
- "Move the current track one line down in the EMMS playlist buffer."
+ "Save current EMMS playlist to a file in cj/music-m3u-root.
+Offers completion over existing names but allows new names."
(interactive)
- (with-current-buffer (cj/music--ensure-playlist-buffer)
- (emms-playlist-mode-move-down)))
-
-;;;###autoload
-(defun cj/music-create-radio-station (name url)
- "Create a radio station M3U playlist file with NAME and URL.
-The file is saved in `cj/music-m3u-root' as NAME.m3u.
-Prompts before overwriting an existing file."
- (interactive
- (list (read-string "Radio station name: ")
- (read-string "Stream URL: ")))
- (when (string-empty-p name)
- (user-error "Radio station name cannot be empty"))
- (when (string-empty-p url)
- (user-error "Stream URL cannot be empty"))
- (let* ((safe-name (replace-regexp-in-string "[^a-zA-Z0-9_-]" "_" name))
- (file-path (expand-file-name (concat safe-name "_Radio.m3u") cj/music-m3u-root))
- (content (format "#EXTM3U\n#EXTINF:-1,%s\n%s\n" name url)))
- (when (and (file-exists-p file-path)
- (not (yes-or-no-p (format "File %s exists. Overwrite? "
- (file-name-nondirectory file-path)))))
- (user-error "Aborted creating radio station file"))
- (with-temp-file file-path
- (insert content))
- (message "Created radio station playlist: %s" (file-name-nondirectory file-path))))
+ (let* ((existing (cj/music--get-m3u-basenames))
+ (default-name (if cj/music-playlist-file
+ (file-name-sans-extension (file-name-nondirectory cj/music-playlist-file))
+ (format-time-string "playlist-%Y%m%d-%H%M%S")))
+ (chosen (completing-read "Save playlist as: " existing nil nil nil nil default-name))
+ (filename (if (string-suffix-p ".m3u" chosen) chosen (concat chosen ".m3u")))
+ (full (expand-file-name filename cj/music-m3u-root)))
+ (when (and (file-exists-p full)
+ (not (yes-or-no-p (format "Overwrite %s? " filename))))
+ (user-error "Aborted saving playlist"))
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (let ((emms-source-playlist-ask-before-overwrite nil))
+ (emms-playlist-save 'm3u full))
+ (setq cj/music-playlist-file full))
+ (message "Saved playlist: %s" filename)))
;;;###autoload
(defun cj/music-playlist-clear ()
- "Stops playing then empties the playlist."
+ "Stop playback and empty the playlist."
(interactive)
(emms-stop)
(emms-playlist-clear)
- (setq cj/music-playlist-file nil)
- (message "EMMS playlist cleared."))
+ ;; Advice also clears, but do it eagerly for this command
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (setq cj/music-playlist-file nil))
+ (message "Playlist cleared"))
+;;;###autoload
(defun cj/music-playlist-reload ()
- "Reload the current playlist from its associated M3U file.
-Clears the current playlist and reloads from disk without confirmation.
-Errors if no file is associated or if the file doesn't exist."
+ "Reload current playlist from its associated M3U file."
(interactive)
- (with-current-buffer (cj/music--ensure-playlist-buffer)
- (cond
- ;; No file associated
- ((not cj/music-playlist-file)
- (user-error "No playlist file to reload - playlist exists only in memory"))
-
- ;; File doesn't exist
- ((not (file-exists-p cj/music-playlist-file))
- (user-error "Playlist file no longer exists: %s"
- (file-name-nondirectory cj/music-playlist-file)))
-
- ;; Reload the playlist
- (t
- (let ((file-name (file-name-nondirectory cj/music-playlist-file)))
- (emms-playlist-clear)
- (emms-play-playlist cj/music-playlist-file)
- ;; Restore the file association (emms-playlist-clear might have cleared it)
- (setq cj/music-playlist-file cj/music-playlist-file)
- (message "Reloaded playlist: %s" file-name))))))
+ (cj/music--assert-valid-playlist-file)
+ (let* ((file-path (with-current-buffer (cj/music--ensure-playlist-buffer)
+ cj/music-playlist-file))
+ (name (file-name-nondirectory file-path)))
+ (emms-playlist-clear)
+ (emms-play-playlist file-path)
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (setq cj/music-playlist-file file-path))
+ (message "Reloaded playlist: %s" name)))
;;;###autoload
(defun cj/music-playlist-edit ()
- "Open the playlist's M3U file in other window.
-If the playlist has been modified, prompt to save first.
-If no file is associated with the playlist, show an error."
+ "Open the playlist's M3U file in other window, prompting to save if modified."
(interactive)
+ (cj/music--assert-valid-playlist-file)
(with-current-buffer (cj/music--ensure-playlist-buffer)
- (cond
- ;; No file associated
- ((not cj/music-playlist-file)
- (message "Playlist not yet saved."))
-
- ;; File doesn't exist (was deleted?)
- ((not (file-exists-p cj/music-playlist-file))
- (message "Playlist file no longer exists: %s"
- (file-name-nondirectory cj/music-playlist-file)))
-
- ;; Check for modifications
- (t
- (let ((file-tracks (cj/music--m3u-file-tracks cj/music-playlist-file))
- (current-tracks (cj/music--playlist-tracks)))
- (if (equal file-tracks current-tracks)
- ;; No changes, open directly
- (find-file-other-window cj/music-playlist-file)
- ;; Changes detected, prompt
- (when (yes-or-no-p "Playlist has been modified. Save before editing? ")
- (emms-playlist-save 'm3u cj/music-playlist-file)
- (find-file-other-window cj/music-playlist-file))))))))
+ (let ((path cj/music-playlist-file))
+ (when (cj/music--playlist-modified-p)
+ (when (yes-or-no-p "Playlist modified. Save before editing? ")
+ (let ((emms-source-playlist-ask-before-overwrite nil))
+ (emms-playlist-save 'm3u path))))
+ ;; Re-validate existence before opening
+ (if (file-exists-p path)
+ (find-file-other-window path)
+ (user-error "Playlist file no longer exists: %s"
+ (file-name-nondirectory path))))))
+
+;;; Commands: UI
;;;###autoload
(defun cj/music-playlist-toggle ()
- "Toggle the visibility of the EMMS playlist buffer in a side window.
-Opens the playlist in a right side window if not visible, or closes it if visible."
+ "Toggle the EMMS playlist buffer in a right side window."
(interactive)
- (let* ((buf-name "*EMMS Playlist*")
- (buffer (get-buffer buf-name))
- (win (and buffer (get-buffer-window buffer))))
- (if win
- ;; Window exists, close it
- (progn
- (delete-window win)
- (message "EMMS Playlist window closed."))
- ;; Window doesn't exist, create/show it
- (progn
- ;; Ensure EMMS is loaded
- (unless (featurep 'emms)
- (require 'emms)
- (require 'emms-setup)
- (require 'emms-playlist-mode)
- (emms-all)
- (emms-default-players))
-
- ;; Ensure playlist buffer exists
- (setq buffer (cj/music--ensure-playlist-buffer))
-
- ;; Display in side window
- (setq win
- (display-buffer-in-side-window
- buffer
- '((side . right)
- (window-width . 0.35)))) ; Slightly narrower than AI window
-
- ;; Select the window and move to appropriate position
- (select-window win)
- (with-current-buffer buffer
- ;; If there's a current track, go to it; otherwise go to beginning
- (if (and (fboundp 'emms-playlist-current-selected-track)
- (emms-playlist-current-selected-track))
- (emms-playlist-mode-center-current)
- (goto-char (point-min))))
-
- ;; Provide feedback
- (let ((track-count (with-current-buffer buffer
- (count-lines (point-min) (point-max)))))
- (if (> track-count 0)
- (message "EMMS Playlist displayed (%d tracks)." track-count)
- (message "EMMS Playlist displayed (empty).")))))))
+ (let* ((buf-name cj/music-playlist-buffer-name)
+ (buffer (get-buffer buf-name))
+ (win (and buffer (get-buffer-window buffer))))
+ (if win
+ (progn
+ (delete-window win)
+ (message "Playlist window closed"))
+ (progn
+ (cj/emms--setup)
+ (setq buffer (cj/music--ensure-playlist-buffer))
+ (setq win (display-buffer-in-side-window buffer '((side . right) (window-width . 0.35))))
+ (select-window win)
+ (with-current-buffer buffer
+ (if (and (fboundp 'emms-playlist-current-selected-track)
+ (emms-playlist-current-selected-track))
+ (emms-playlist-mode-center-current)
+ (goto-char (point-min))))
+ (let ((count (with-current-buffer buffer
+ (count-lines (point-min) (point-max)))))
+ (message (if (> count 0)
+ (format "Playlist displayed (%d tracks)" count)
+ "Playlist displayed (empty)")))))))
;;;###autoload
(defun cj/music-playlist-show ()
- "Show the EMMS playlist buffer, initializing EMMS if necessary.
-If EMMS is not loaded, loads it first. Switches to the playlist buffer
-in the current window and provides appropriate feedback."
+ "Show the EMMS playlist buffer in the current window.
+Initializes EMMS if needed."
(interactive)
(let ((emms-was-loaded (featurep 'emms))
- (playlist-buffer-exists nil)
- (playlist-has-content nil))
-
- ;; Load EMMS if not already loaded
- (unless emms-was-loaded
- (require 'emms)
- (require 'emms-setup)
- (require 'emms-playlist-mode)
- (emms-all)
- (emms-default-players))
-
- ;; Check if playlist buffer exists
- (when (get-buffer "*EMMS Playlist*")
- (setq playlist-buffer-exists t)
- (with-current-buffer "*EMMS Playlist*"
- (setq playlist-has-content (> (point-max) (point-min)))))
-
- ;; Ensure playlist buffer exists and switch to it
- (switch-to-buffer (cj/music--ensure-playlist-buffer))
-
- ;; Provide appropriate feedback
- (cond
- ((not emms-was-loaded)
- (message "EMMS started. Current Playlist empty."))
- ((and playlist-buffer-exists playlist-has-content)
- (message "EMMS running. Displaying Current Playlist."))
- (t
- (message "EMMS running. Current Playlist empty.")))))
-
-;; ------------------------------- EMMS Settings -------------------------------
+ (buffer-exists (get-buffer cj/music-playlist-buffer-name))
+ (has-content nil))
+ (cj/emms--setup)
+ (when buffer-exists
+ (with-current-buffer cj/music-playlist-buffer-name
+ (setq has-content (> (point-max) (point-min)))))
+ (switch-to-buffer (cj/music--ensure-playlist-buffer))
+ (cond
+ ((not emms-was-loaded) (message "EMMS started. Current playlist empty"))
+ ((and buffer-exists has-content) (message "EMMS running. Displaying current playlist"))
+ (t (message "EMMS running. Current playlist empty")))))
+
+;;; Dired/Dirvish integration
+
+(with-eval-after-load 'dirvish
+ (defun cj/music-add-dired-selection ()
+ "Add selected files/dirs in Dired/Dirvish to the EMMS playlist.
+Dirs added recursively."
+ (interactive)
+ (unless (derived-mode-p 'dired-mode)
+ (user-error "This command must be run in a Dired buffer"))
+ (let ((files (if (use-region-p)
+ (dired-get-marked-files)
+ (list (dired-get-file-for-visit)))))
+ (when (null files)
+ (user-error "No files selected"))
+ (dolist (file files)
+ (cond
+ ((file-directory-p file) (cj/music-add-directory-recursive file))
+ ((cj/music--valid-file-p file) (emms-add-file file))
+ (t (message "Skipping non-music file: %s" file))))
+ (message "Added %d item(s) to playlist" (length files))))
+
+ (define-key dirvish-mode-map "p" #'cj/music-add-dired-selection))
+
+;;; EMMS setup and keybindings
(use-package emms
:defer t
:init
- ;; Create music keymap before package loads
- (defvar cj/music-map (make-sparse-keymap)
- "Keymap for music commands.")
-
+ (defvar cj/music-map (make-sparse-keymap) "Keymap for music commands.")
:commands (emms-mode-line-mode)
:config
- ;; Basic EMMS setup
(require 'emms-setup)
(require 'emms-player-mpd)
(require 'emms-playlist-mode)
@@ -503,108 +369,110 @@ in the current window and provides appropriate feedback."
(require 'emms-source-playlist)
(emms-all)
- ;; I only want to use mpd to play. To add the defaults: (emms-default-players)
- (setq emms-player-list '(emms-player-mpd))
- ;; MPD configuration
+ ;; Use only mpd to play
(add-to-list 'emms-player-list 'emms-player-mpd)
- (setq emms-player-mpd-server-name "localhost"
- emms-player-mpd-server-port "6600"
- emms-player-mpd-music-directory cj/music-root)
- ;; EMMS settings
- (setq emms-source-file-default-directory cj/music-root
- emms-playlist-buffer-name "*EMMS-Playlist*"
- emms-playlist-default-major-mode 'emms-playlist-mode)
-
- ;; modeline shows nothing
- (emms-playing-time-disable-display)
- (emms-mode-line-mode -1)
-
- ;; if mpv, don't display album art (interruptive)
- (add-to-list 'emms-player-mpv-parameters "--no-audio-display")
-
- ;; Start MPD connection
+ ;; MPD configuration
+ (setq emms-player-mpd-server-name "localhost")
+ (setq emms-player-mpd-server-port "6600")
+ (setq emms-player-mpd-music-directory cj/music-root)
(condition-case err
(emms-player-mpd-connect)
(error (message "Failed to connect to MPD: %s" err)))
- :bind-keymap
- ("C-; m" . cj/music-map)
-
- :bind
- (:map emms-playlist-mode-map
+ ;; Basic EMMS configuration
+ (setq emms-source-file-default-directory cj/music-root)
+ (setq emms-playlist-buffer-name cj/music-playlist-buffer-name)
+ (setq emms-playlist-default-major-mode 'emms-playlist-mode)
- ;; playlist playing
- ("p" . emms-playlist-mode-go) ;; start playing the playlist
- ("SPC" . emms-pause) ;; pause playing the playlist
- ("s" . emms-stop) ;; stop playing the playlist
- ("x" . emms-shuffle) ;; shuffle the playlist
- ("q" . emms-playlist-mode-bury-buffer) ;; quit the playlist
+ ;; note setopt as variable is customizeable
+ (setopt emms-player-mpd-supported-regexp
+ (apply #'emms-player-simple-regexp cj/music-file-extensions))
- ;; playlist maniuplation
- ("a" . cj/music-fuzzy-select-and-add) ;; add to playlist
- ("C" . cj/music-playlist-clear) ;; clear playlist
- ("L" . cj/music-playlist-load) ;; load an existing playlist
- ("e" . cj/music-playlist-edit) ;; edit an existing playlist
- ("R" . cj/music-playlist-reload) ;; reload an existing playlist
- ("S" . cj/music-playlist-save) ;; save current playlist
+ ;; Keep cj/music-playlist-file in sync if playlist is cleared
+ (defun cj/music--after-playlist-clear (&rest _)
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (setq cj/music-playlist-file nil))))
- ;; playlist track reordering
- ("C-<up>" . emms-playlist-mode-shift-track-up) ;; move track earlier
- ("C-<down>" . emms-playlist-mode-shift-track-down) ;; move track later
+ ;; Ensure we don't stack duplicate advice on reload
+ (when (advice-member-p #'cj/music--after-playlist-clear 'emms-playlist-clear)
+ (advice-remove 'emms-playlist-clear #'cj/music--after-playlist-clear))
- ;; Create Radio station m3u
- ("r" . cj/music-create-radio-station)
+ (advice-add 'emms-playlist-clear :after #'cj/music--after-playlist-clear)
- ;; Volume controls (MPD)
- ("-" . emms-volume-lower)
- ("=" . emms-volume-raise))
+ :bind-keymap
+ ("C-; m" . cj/music-map)
+ :bind
+ (:map emms-playlist-mode-map
+ ;; Playback
+ ("p" . emms-playlist-mode-go)
+ ("SPC" . emms-pause)
+ ("s" . emms-stop)
+ ("x" . emms-shuffle)
+ ("q" . emms-playlist-mode-bury-buffer)
+ ;; Manipulation
+ ("a" . cj/music-fuzzy-select-and-add)
+ ("C" . cj/music-playlist-clear)
+ ("L" . cj/music-playlist-load)
+ ("e" . cj/music-playlist-edit)
+ ("R" . cj/music-playlist-reload)
+ ("S" . cj/music-playlist-save)
+ ;; Track reordering (bind directly to EMMS commands; no wrappers)
+ ("C-<up>" . emms-playlist-mode-shift-track-up)
+ ("C-<down>" . emms-playlist-mode-shift-track-down)
+ ;; Radio
+ ("r" . cj/music-create-radio-station)
+ ;; Volume (MPD)
+ ("-" . emms-volume-lower)
+ ("=" . emms-volume-raise))
(:map cj/music-map
- ;; EMMS show playlist.
- ("m" . cj/music-playlist-toggle)
- ("M" . cj/music-playlist-show)
-
- ;; Add artists and albums (directories) and songs (files) in fuzzy search
- ("a" . cj/music-fuzzy-select-and-add)
-
- ;; Create Radio station m3u
- ("r" . cj/music-create-radio-station)
+ ("m" . cj/music-playlist-toggle)
+ ("M" . cj/music-playlist-show)
+ ("a" . cj/music-fuzzy-select-and-add)
+ ("r" . cj/music-create-radio-station)
+ ("SPC" . emms-pause)
+ ("s" . emms-stop)
+ ("p" . emms-playlist-mode-go)
+ ("x" . emms-shuffle)))
+
+;; Quick toggle key
+(global-unset-key (kbd "<f10>"))
+(global-set-key (kbd "<f10>") #'cj/music-playlist-toggle)
- ;; Playback controls
- ("SPC" . emms-pause)
- ("s" . emms-stop)
- ("p" . emms-playlist-mode-go)
- ("x" . emms-shuffle)))
+;;; Minimal ensure-loaded setup for on-demand use
-(global-unset-key (kbd "<f10>"))
-(global-set-key (kbd "<f10>") #'cj/music-playlist-toggle)
+(defun cj/emms--setup ()
+ "Ensure EMMS is loaded and quiet in mode line."
+ (unless (featurep 'emms)
+ (require 'emms))
-;; ------------------------- Music Add Dired Selection -------------------------
+ (emms-playing-time-disable-display)
+ (emms-mode-line-mode -1))
-(defun cj/music-add-dired-selection ()
- "Add selected files or directories in Dired/Dirvish to the EMMS playlist.
-If region is active, add marked files. Otherwise, add file at point.
-Directories are added recursively."
- (interactive)
- (unless (derived-mode-p 'dired-mode)
- (user-error "This command must be run in a Dired buffer"))
- (let ((files (if (use-region-p)
- (dired-get-marked-files)
- (list (dired-get-file-for-visit)))))
- (when (null files)
- (user-error "No files selected"))
- (dolist (file files)
- (if (file-directory-p file)
- (cj/music-add-directory-recursive file)
- (if (cj/music--valid-file-p file)
- (emms-add-file file)
- (message "Skipping non-music file: %s" file))))
- (message "Added %d item(s) to EMMS playlist" (length files))))
+;;; Radio station creation
-(with-eval-after-load 'dirvish
- (define-key dirvish-mode-map "p" 'cj/music-add-dired-selection))
+;;;###autoload
+(defun cj/music-create-radio-station (name url)
+ "Create a radio station M3U playlist with NAME and URL in cj/music-m3u-root."
+ (interactive
+ (list (read-string "Radio station name: ")
+ (read-string "Stream URL: ")))
+ (when (string-empty-p name)
+ (user-error "Radio station name cannot be empty"))
+ (when (string-empty-p url)
+ (user-error "Stream URL cannot be empty"))
+ (let* ((safe (cj/music--safe-filename name))
+ (file (expand-file-name (concat safe "_Radio.m3u") cj/music-m3u-root))
+ (content (format "#EXTM3U\n#EXTINF:-1,%s\n%s\n" name url)))
+ (when (and (file-exists-p file)
+ (not (yes-or-no-p (format "Overwrite %s? " (file-name-nondirectory file)))))
+ (user-error "Aborted creating radio station"))
+ (with-temp-file file
+ (insert content))
+ (message "Created radio station: %s" (file-name-nondirectory file))))
(provide 'music-config)
;;; music-config.el ends here