diff options
| -rw-r--r-- | modules/music-config.el | 137 | ||||
| -rw-r--r-- | tests/test-music-config-toggle-consume.el | 262 |
2 files changed, 387 insertions, 12 deletions
diff --git a/modules/music-config.el b/modules/music-config.el index a3960440..6d7ce132 100644 --- a/modules/music-config.el +++ b/modules/music-config.el @@ -2,7 +2,7 @@ ;; ;;; Commentary: ;; -;; Comprehensive music management in Emacs via EMMS with MPV backend. +;; Music management in Emacs via EMMS with MPV backend. ;; Focus: simple, modular helpers; consistent error handling; streamlined UX. ;; ;; Highlights: @@ -12,8 +12,73 @@ ;; - M3U playlist save/load/edit/reload ;; - Radio station M3U creation (streaming URLs supported) ;; - Playlist window toggling +;; - Consume mode (remove tracks after playback) ;; - MPV as player (no daemon required) ;; +;; Keybindings (playlist-mode-map): +;; +;; Aligned with ncmpcpp defaults where possible (83% match). +;; Additional EMMS-specific bindings for features ncmpcpp lacks. +;; +;; Key Action ncmpcpp default Match +;; ─── ────── ─────────────── ───── +;; Playback +;; SPC pause add_item * +;; s stop stop ✓ +;; > / n next track next ✓ +;; < / P previous track previous ✓ +;; p play selected (enter) ✓ +;; f seek forward seek_forward ✓ +;; b seek backward seek_backward ✓ +;; +;; Toggles +;; r repeat playlist toggle_repeat ✓ +;; t repeat track (none) + +;; z random toggle_random ✓ +;; x consume toggle_crossfade * +;; Z shuffle shuffle ✓ +;; +;; Volume +;; + / = volume up volume_up ✓ +;; - volume down volume_down ✓ +;; +;; Info +;; i song info show_song_info ✓ +;; o jump to playing jump_to_playing ✓ +;; +;; Playlist management +;; a add music (fuzzy) add_selected ✓ +;; c / C clear playlist clear_playlist ✓ +;; S save playlist (none) + +;; L load playlist (none) + +;; E edit playlist M3U (none) + +;; g reload playlist (none) + +;; A append track to M3U (none) + +;; q quit/bury quit ✓ +;; +;; Track reordering +;; S-up move track up (shift-up) ✓ +;; S-down move track down (shift-down) ✓ +;; C-up move track up (alias) (none) + +;; C-down move track down (alias) (none) + +;; +;; Other +;; R create radio station (none) + +;; +;; Legend: ✓ = matches ncmpcpp default +;; * = intentional divergence (see below) +;; + = EMMS-only feature +;; +;; Intentional divergences from ncmpcpp defaults: +;; +;; SPC/p swap: ncmpcpp defaults p=pause, SPC=add_item_to_playlist. +;; This config uses SPC=pause (more natural in Emacs) and p=play +;; selected track. Pause via SPC is a common media player convention. +;; +;; x=consume vs crossfade: ncmpcpp's crossfade is an mpd daemon +;; feature. EMMS uses mpv directly, so consume mode (remove tracks +;; after playback) is more useful here. +;; ;;; Code: (require 'subr-x) @@ -331,6 +396,32 @@ Offers completion over existing names but allows new names." (user-error "Playlist file no longer exists: %s" (file-name-nondirectory path)))))) +;;; Commands: consume mode + +(defvar cj/music-consume-mode nil + "Non-nil means consume mode is active. +When enabled, tracks are removed from the playlist after they finish playing.") + +(defun cj/music--consume-track () + "Remove the just-finished track from the playlist. +Intended for use on `emms-player-finished-hook'." + (when cj/music-consume-mode + (with-current-buffer (cj/music--ensure-playlist-buffer) + (when (and emms-playlist-selected-marker + (marker-position emms-playlist-selected-marker)) + (save-excursion + (goto-char emms-playlist-selected-marker) + (emms-playlist-mode-kill-track)))))) + +(defun cj/music-toggle-consume () + "Toggle consume mode. When active, tracks are removed after playing." + (interactive) + (setq cj/music-consume-mode (not cj/music-consume-mode)) + (if cj/music-consume-mode + (add-hook 'emms-player-finished-hook #'cj/music--consume-track) + (remove-hook 'emms-player-finished-hook #'cj/music--consume-track)) + (message "Consume mode %s" (if cj/music-consume-mode "enabled" "disabled"))) + ;;; Commands: UI ;;; Minimal ensure-loaded setup for on-demand use @@ -419,13 +510,17 @@ Dirs added recursively." "m" #'cj/music-playlist-toggle "M" #'cj/music-playlist-show "a" #'cj/music-fuzzy-select-and-add - "r" #'cj/music-create-radio-station + "R" #'cj/music-create-radio-station "SPC" #'emms-pause "s" #'emms-stop "n" #'emms-next "p" #'emms-previous "g" #'emms-playlist-mode-go - "x" #'emms-shuffle) + "Z" #'emms-shuffle + "r" #'emms-toggle-repeat-playlist + "t" #'emms-toggle-repeat-track + "z" #'emms-toggle-random-playlist + "x" #'cj/music-toggle-consume) (keymap-set cj/custom-keymap "m" cj/music-map) (with-eval-after-load 'which-key @@ -434,13 +529,17 @@ Dirs added recursively." "C-; m m" "toggle playlist" "C-; m M" "show playlist" "C-; m a" "add music" - "C-; m r" "create radio" + "C-; m R" "create radio" "C-; m SPC" "pause" "C-; m s" "stop" "C-; m n" "next track" "C-; m p" "previous track" "C-; m g" "goto playlist" - "C-; m x" "shuffle")) + "C-; m Z" "shuffle" + "C-; m r" "repeat playlist" + "C-; m t" "repeat track" + "C-; m z" "random" + "C-; m x" "consume")) (use-package emms :defer t @@ -499,27 +598,41 @@ Dirs added recursively." ("SPC" . emms-pause) ("s" . emms-stop) ("n" . emms-next) + (">" . emms-next) ("P" . emms-previous) + ("<" . emms-previous) ("f" . emms-seek-forward) ("b" . emms-seek-backward) - ("x" . emms-shuffle) ("q" . emms-playlist-mode-bury-buffer) ("a" . cj/music-fuzzy-select-and-add) + ;; Toggles (aligned with ncmpcpp) + ("r" . emms-toggle-repeat-playlist) + ("t" . emms-toggle-repeat-track) + ("z" . emms-toggle-random-playlist) + ("x" . cj/music-toggle-consume) + ("Z" . emms-shuffle) + ;; Info + ("i" . emms-show) + ("o" . emms-playlist-mode-center-current) ;; Manipulation ("A" . cj/music-append-track-to-playlist) + ("c" . cj/music-playlist-clear) ("C" . cj/music-playlist-clear) ("L" . cj/music-playlist-load) ("E" . cj/music-playlist-edit) - ("R" . cj/music-playlist-reload) + ("g" . cj/music-playlist-reload) ("S" . cj/music-playlist-save) - ;; Track reordering (bind directly to EMMS commands; no wrappers) + ;; Track reordering + ("S-<up>" . emms-playlist-mode-shift-track-up) + ("S-<down>" . emms-playlist-mode-shift-track-down) ("C-<up>" . emms-playlist-mode-shift-track-up) ("C-<down>" . emms-playlist-mode-shift-track-down) ;; Radio - ("r" . cj/music-create-radio-station) - ;; Volume (MPV) - ("-" . emms-volume-lower) - ("=" . emms-volume-raise))) + ("R" . cj/music-create-radio-station) + ;; Volume + ("+" . emms-volume-raise) + ("=" . emms-volume-raise) + ("-" . emms-volume-lower))) ;; Quick toggle key - use autoload to avoid loading emms at startup (autoload 'cj/music-playlist-toggle "music-config" "Toggle EMMS playlist window." t) diff --git a/tests/test-music-config-toggle-consume.el b/tests/test-music-config-toggle-consume.el new file mode 100644 index 00000000..c2c38d34 --- /dev/null +++ b/tests/test-music-config-toggle-consume.el @@ -0,0 +1,262 @@ +;;; test-music-config-toggle-consume.el --- Tests for consume mode -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music-toggle-consume and cj/music--consume-track. +;; +;; cj/music-toggle-consume toggles consume mode on/off, managing the +;; emms-player-finished-hook to remove tracks after playback. +;; +;; cj/music--consume-track is the hook function that removes the track +;; at the selected marker position from the playlist buffer. +;; +;; Test organization: +;; - Normal Cases: Toggle on/off, track removal during consume +;; - Boundary Cases: Double toggle, single-track playlist, consume off +;; - Error Cases: No selected marker, empty playlist +;; +;;; Code: + +(require 'ert) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Add EMMS elpa directory to load path for batch testing +(let ((emms-dir (car (file-expand-wildcards + (expand-file-name "elpa/emms-*" user-emacs-directory))))) + (when emms-dir + (add-to-list 'load-path emms-dir))) + +;; Load EMMS for playlist buffer setup +(require 'emms) +(require 'emms-playlist-mode) + +;; Load production code +(require 'music-config) + +;;; Test helpers + +(defun test-consume--setup-playlist-buffer (track-names) + "Create an EMMS playlist buffer with TRACK-NAMES and return it. +Each entry in TRACK-NAMES becomes a file track in the playlist. +Uses EMMS's own insertion function for proper text properties. +Selects the first track by default." + (let ((buf (get-buffer-create "*EMMS-Test-Playlist*"))) + (with-current-buffer buf + (emms-playlist-mode) + (setq emms-playlist-buffer-p t) + (let ((inhibit-read-only t)) + (erase-buffer) + (dolist (name track-names) + (emms-playlist-insert-track (emms-track 'file name))) + ;; Set selected marker to first track + (when track-names + (goto-char (point-min)) + (unless emms-playlist-selected-marker + (setq emms-playlist-selected-marker (make-marker))) + (set-marker emms-playlist-selected-marker (point))))) + buf)) + +(defun test-consume--teardown () + "Clean up consume mode state and test buffers." + (setq cj/music-consume-mode nil) + (remove-hook 'emms-player-finished-hook #'cj/music--consume-track) + (when-let ((buf (get-buffer "*EMMS-Test-Playlist*"))) + (kill-buffer buf))) + +(defun test-consume--track-count (buf) + "Return number of tracks in playlist buffer BUF." + (with-current-buffer buf + (if (= (point-min) (point-max)) + 0 + (count-lines (point-min) (point-max))))) + +(defun test-consume--capture-message (fn &rest args) + "Call FN with ARGS and return the message it produces. +Works in both interactive and batch mode." + (let ((captured nil)) + (cl-letf (((symbol-function 'message) + (lambda (fmt &rest msg-args) + (setq captured (apply #'format fmt msg-args))))) + (apply fn args)) + captured)) + +;;; Normal Cases - cj/music-toggle-consume + +(ert-deftest test-music-config-toggle-consume-normal-enable-sets-variable () + "Validate toggling consume on sets cj/music-consume-mode to t." + (unwind-protect + (progn + (setq cj/music-consume-mode nil) + (cj/music-toggle-consume) + (should (eq cj/music-consume-mode t))) + (test-consume--teardown))) + +(ert-deftest test-music-config-toggle-consume-normal-enable-adds-hook () + "Validate toggling consume on adds consume-track to finished hook." + (unwind-protect + (progn + (setq cj/music-consume-mode nil) + (cj/music-toggle-consume) + (should (memq #'cj/music--consume-track emms-player-finished-hook))) + (test-consume--teardown))) + +(ert-deftest test-music-config-toggle-consume-normal-disable-clears-variable () + "Validate toggling consume off sets cj/music-consume-mode to nil." + (unwind-protect + (progn + (setq cj/music-consume-mode t) + (add-hook 'emms-player-finished-hook #'cj/music--consume-track) + (cj/music-toggle-consume) + (should (eq cj/music-consume-mode nil))) + (test-consume--teardown))) + +(ert-deftest test-music-config-toggle-consume-normal-disable-removes-hook () + "Validate toggling consume off removes consume-track from finished hook." + (unwind-protect + (progn + (setq cj/music-consume-mode t) + (add-hook 'emms-player-finished-hook #'cj/music--consume-track) + (cj/music-toggle-consume) + (should-not (memq #'cj/music--consume-track emms-player-finished-hook))) + (test-consume--teardown))) + +(ert-deftest test-music-config-toggle-consume-normal-enable-message () + "Validate toggling on produces enabled message." + (unwind-protect + (progn + (setq cj/music-consume-mode nil) + (let ((msg (test-consume--capture-message #'cj/music-toggle-consume))) + (should (string-match-p "enabled" msg)))) + (test-consume--teardown))) + +(ert-deftest test-music-config-toggle-consume-normal-disable-message () + "Validate toggling off produces disabled message." + (unwind-protect + (progn + (setq cj/music-consume-mode t) + (add-hook 'emms-player-finished-hook #'cj/music--consume-track) + (let ((msg (test-consume--capture-message #'cj/music-toggle-consume))) + (should (string-match-p "disabled" msg)))) + (test-consume--teardown))) + +;;; Normal Cases - cj/music--consume-track + +(ert-deftest test-music-config--consume-track-normal-calls-kill-at-selected () + "Validate consume-track calls kill-track at the selected marker position." + (unwind-protect + (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*") + (buf (test-consume--setup-playlist-buffer + '("/music/track1.mp3" "/music/track2.mp3" "/music/track3.mp3"))) + (cj/music-consume-mode t) + (kill-called nil) + (kill-position nil) + (expected-pos (with-current-buffer buf + (marker-position emms-playlist-selected-marker)))) + (cl-letf (((symbol-function 'emms-playlist-mode-kill-track) + (lambda () + (setq kill-called t + kill-position (point))))) + (cj/music--consume-track) + (should kill-called) + (should (= kill-position expected-pos)))) + (test-consume--teardown))) + +(ert-deftest test-music-config--consume-track-normal-calls-kill-at-middle-track () + "Validate consume-track navigates to middle track before killing." + (unwind-protect + (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*") + (buf (test-consume--setup-playlist-buffer + '("/music/track1.mp3" "/music/track2.mp3" "/music/track3.mp3"))) + (cj/music-consume-mode t) + (kill-position nil) + (expected-pos nil)) + ;; Move selected marker to second track + (with-current-buffer buf + (goto-char (point-min)) + (forward-line 1) + (set-marker emms-playlist-selected-marker (point)) + (setq expected-pos (marker-position emms-playlist-selected-marker))) + (cl-letf (((symbol-function 'emms-playlist-mode-kill-track) + (lambda () (setq kill-position (point))))) + (cj/music--consume-track) + (should (= kill-position expected-pos)))) + (test-consume--teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config-toggle-consume-boundary-double-toggle-restores-state () + "Validate toggling twice returns to original off state." + (unwind-protect + (progn + (setq cj/music-consume-mode nil) + (cj/music-toggle-consume) + (cj/music-toggle-consume) + (should (eq cj/music-consume-mode nil)) + (should-not (memq #'cj/music--consume-track emms-player-finished-hook))) + (test-consume--teardown))) + +(ert-deftest test-music-config--consume-track-boundary-noop-when-disabled () + "Validate consume-track does nothing when consume mode is off." + (unwind-protect + (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*") + (buf (test-consume--setup-playlist-buffer + '("/music/track1.mp3" "/music/track2.mp3"))) + (cj/music-consume-mode nil)) + (cj/music--consume-track) + (should (= 2 (test-consume--track-count buf)))) + (test-consume--teardown))) + +(ert-deftest test-music-config--consume-track-boundary-single-track-calls-kill () + "Validate consuming the only track in playlist still calls kill-track." + (unwind-protect + (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*") + (buf (test-consume--setup-playlist-buffer + '("/music/only-track.mp3"))) + (cj/music-consume-mode t) + (kill-called nil)) + (cl-letf (((symbol-function 'emms-playlist-mode-kill-track) + (lambda () (setq kill-called t)))) + (cj/music--consume-track) + (should kill-called))) + (test-consume--teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--consume-track-error-no-marker-no-crash () + "Validate consume-track handles nil selected marker without error." + (unwind-protect + (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*") + (buf (test-consume--setup-playlist-buffer + '("/music/track1.mp3"))) + (cj/music-consume-mode t)) + ;; Clear the selected marker + (with-current-buffer buf + (setq emms-playlist-selected-marker nil)) + ;; Should not error + (should-not (condition-case err + (progn (cj/music--consume-track) nil) + (error err)))) + (test-consume--teardown))) + +(ert-deftest test-music-config--consume-track-error-empty-playlist-no-crash () + "Validate consume-track handles empty playlist buffer without error." + (unwind-protect + (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*") + (buf (get-buffer-create "*EMMS-Test-Playlist*")) + (cj/music-consume-mode t)) + (with-current-buffer buf + (emms-playlist-mode) + (setq emms-playlist-buffer-p t) + (setq emms-playlist-selected-marker nil)) + ;; Should not error + (should-not (condition-case err + (progn (cj/music--consume-track) nil) + (error err)))) + (test-consume--teardown))) + +(provide 'test-music-config-toggle-consume) +;;; test-music-config-toggle-consume.el ends here |
