diff options
Diffstat (limited to 'modules/music-config.el')
| -rw-r--r-- | modules/music-config.el | 858 | 
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 | 
