aboutsummaryrefslogtreecommitdiff
path: root/modules/music-config.el
diff options
context:
space:
mode:
Diffstat (limited to 'modules/music-config.el')
-rw-r--r--modules/music-config.el425
1 files changed, 369 insertions, 56 deletions
diff --git a/modules/music-config.el b/modules/music-config.el
index 3559930b..233bae72 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -86,7 +86,9 @@ falls back to the plain text player (names, a dim glyph, a thin status line)."
:group 'cj/music)
(defcustom cj/music-title-family
- (if (boundp 'cj/nov-reading-font-family) cj/nov-reading-font-family "Merriweather")
+ (if (fboundp 'cj/font-profile-properties)
+ (plist-get (cj/font-profile-properties 'reading) :default-family)
+ "Merriweather")
"Serif family for the fancy now-playing title, mirroring the nov reading view."
:type 'string
:group 'cj/music)
@@ -306,7 +308,9 @@ Directories are suffixed with /; files are plain. Hidden dirs/files skipped."
"Completion table for CANDIDATES preserving order and case-insensitive match.
Tags the `cj-music-file' category and annotates each candidate (a path relative
to `cj/music-root', with a trailing slash for directories) with its size and
-modification date so marginalia can show them."
+modification date so marginalia can show them. The category is registered
+with marginalia (builtin) so the annotations render right-aligned."
+ (cj/completion-ensure-marginalia-align 'cj-music-file)
(let ((annotate (cj/completion-file-annotator
(lambda (c)
(expand-file-name
@@ -322,19 +326,177 @@ modification date so marginalia can show them."
(completion-ignore-case . t))
(complete-with-action action candidates string pred)))))
+(defun cj/music--playlist-open-position (buffer)
+ "Return where point should land when the playlist BUFFER is displayed.
+The beginning of the playing track's line when a song is playing (during
+playback the selected track is the playing one), else the top of the
+list. Keying off the selected track alone is wrong: EMMS keeps a stale
+selection while stopped, which used to open the playlist deep in the list
+at whatever played last."
+ (with-current-buffer buffer
+ (if (and (boundp 'emms-player-playing-p) emms-player-playing-p
+ (boundp 'emms-playlist-selected-marker)
+ (markerp emms-playlist-selected-marker)
+ (marker-position emms-playlist-selected-marker)
+ (eq (marker-buffer emms-playlist-selected-marker) (current-buffer)))
+ (save-excursion
+ (goto-char emms-playlist-selected-marker)
+ (line-beginning-position))
+ (point-min))))
+
+(defun cj/music--playlist-land-point (win buffer)
+ "Move WIN's point in BUFFER per the open-position rule and settle the view.
+When a song is playing its row lands in the window's upper third, so the
+upcoming tracks fill the space below it. When stopped, the view starts at
+the top of the list. Point sits at the beginning of its line either way,
+so the row reads left-to-right from its number."
+ (let ((pos (cj/music--playlist-open-position buffer)))
+ (set-window-point win pos)
+ (if (> pos (with-current-buffer buffer (point-min)))
+ (with-selected-window win
+ (with-current-buffer buffer
+ (recenter (max 1 (/ (window-body-height) 3)))))
+ (set-window-start win pos))))
+
+(defun cj/music--pin-point-to-bol ()
+ "Keep the playlist cursor in the number gutter (column 0).
+The rows are rendered track lines, not editable text: the cursor's home is
+the number, and operations on a track (kill, shift, play) act on its row
+wherever point sits. Vertical motion over thumbnails and the stretch-space
+that right-aligns the metadata drifts point to arbitrary visual columns
+(usually line end), so this runs on the buffer-local `post-command-hook'
+and snaps every landing back to the row start. An active isearch owns
+point until it ends; the snap lands when the search exits."
+ (unless (or (bolp) (bound-and-true-p isearch-mode))
+ (beginning-of-line))
+ (cj/music--highlight-current-number))
+
+(defvar-local cj/music--renumber-timer nil
+ "Pending idle timer for the playlist row renumber, or nil.")
+
+;; Forward declaration: the real `defvar-local' is a few defuns below, next to
+;; the highlight helper that owns it. Declared special here so the setq in this
+;; function compiles as a dynamic binding, not a free-variable warning.
+(defvar cj/music--current-number-overlay)
+
+(defun cj/music--renumber-rows (&optional buffer)
+ "Number every playlist row in BUFFER (default: current buffer) via overlays.
+Each non-blank line gets an \"NNN \" before-string so the cursor stays
+visible when it sits on a cover-art thumbnail and the row's position in
+the list is readable at a glance. Overlays rebuild from scratch, so the
+numbering survives kills, inserts, and reorders; the buffer text itself is
+untouched (EMMS owns it). A dead BUFFER is a silent no-op, since the
+debounce timer can outlive the playlist buffer."
+ (let ((buf (or buffer (current-buffer))))
+ (when (buffer-live-p buf)
+ (with-current-buffer buf
+ (remove-overlays (point-min) (point-max) 'cj-music-row-number t)
+ (save-excursion
+ (goto-char (point-min))
+ (let ((n 0))
+ (while (not (eobp))
+ (unless (looking-at-p "[ \t]*$")
+ (setq n (1+ n))
+ ;; Span one char rather than zero: `overlays-in' (and so
+ ;; `remove-overlays') can miss an empty overlay sitting
+ ;; exactly at the region start.
+ (let ((ov (make-overlay (line-beginning-position)
+ (1+ (line-beginning-position)))))
+ (overlay-put ov 'cj-music-row-number t)
+ ;; Outrank the header overlay (priority 100): both anchor
+ ;; strings at position 1 on row 1, and without this the
+ ;; row's number renders above the header block instead of
+ ;; beside its own track.
+ (overlay-put ov 'priority 200)
+ (overlay-put ov 'before-string
+ (cj/music--number-string (format "%3d " n) nil))))
+ (forward-line 1))))
+ ;; The rebuild deleted the marked overlay; re-mark the current row.
+ (setq cj/music--current-number-overlay nil)
+ (cj/music--highlight-current-number)))))
+
+(defvar-local cj/music--current-number-overlay nil
+ "The number overlay currently rendered as the you-are-here mark, or nil.")
+
+(defun cj/music--number-string (text current)
+ "Build the number-gutter display string from TEXT.
+CURRENT non-nil renders it inverse video (the you-are-here mark). The
+single place the gutter string's properties live: the face, and the
+cursor property that makes redisplay draw the cursor on the number
+instead of invisibly on the album art after it."
+ (propertize text
+ 'face (if current
+ '(:inherit cj/music-keyhint-face :inverse-video t)
+ 'cj/music-keyhint-face)
+ 'cursor t))
+
+(defun cj/music--set-number-face (ov current)
+ "Re-render number overlay OV's string; CURRENT non-nil marks it inverse.
+Keeps the text, swaps only the rendering (see `cj/music--number-string')."
+ (let ((s (overlay-get ov 'before-string)))
+ (overlay-put ov 'before-string
+ (cj/music--number-string (substring-no-properties s) current))))
+
+(defun cj/music--highlight-current-number ()
+ "Render the current row's number in inverse video, restoring the last one.
+The block cursor draws only in the selected window, and the playlist dock
+is glanced at from other windows constantly, so the number itself carries
+the you-are-here mark -- visible whether or not the window has focus."
+ (let ((ov (seq-find (lambda (o) (overlay-get o 'cj-music-row-number))
+ (overlays-in (line-beginning-position)
+ (min (1+ (line-beginning-position)) (point-max))))))
+ (unless (eq ov cj/music--current-number-overlay)
+ (when (and (overlayp cj/music--current-number-overlay)
+ (overlay-buffer cj/music--current-number-overlay))
+ (cj/music--set-number-face cj/music--current-number-overlay nil))
+ (setq cj/music--current-number-overlay ov)
+ (when ov
+ (cj/music--set-number-face ov t)))))
+
+(defun cj/music--schedule-renumber (&rest _)
+ "Debounced renumber of the current playlist buffer after a text change.
+Wired buffer-locally into `after-change-functions' by
+`cj/music--ensure-playlist-buffer'; the idle delay coalesces a burst of
+inserts or kills into one renumber pass."
+ (when (timerp cj/music--renumber-timer)
+ (cancel-timer cj/music--renumber-timer))
+ (setq cj/music--renumber-timer
+ (run-with-idle-timer 0.2 nil #'cj/music--renumber-rows (current-buffer))))
+
(defun cj/music--ensure-playlist-buffer ()
"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))
- (setq emms-playlist-buffer-p t))
+ (setq emms-playlist-buffer-p t)
+ ;; Row numbering: renumber after every playlist change, debounced.
+ (add-hook 'after-change-functions #'cj/music--schedule-renumber nil t)
+ ;; The highlighted row stays findable even when the cursor sits on
+ ;; album art (pairs with the row-number prefixes).
+ (hl-line-mode 1)
+ ;; Gutter cursor: point lives at the row start (the number column).
+ (add-hook 'post-command-hook #'cj/music--pin-point-to-bol nil t)
+ ;; Logical-line motion: the multi-line header overlay string at
+ ;; position 1 otherwise absorbs next-line from the top row (vertical
+ ;; motion walks the header's screen lines, which all map back to the
+ ;; same buffer position, so arrows look dead). Rows are one logical
+ ;; line each; visual movement buys nothing here.
+ (setq-local line-move-visual nil)
+ ;; Sticky header: re-anchor the header block at the window start on
+ ;; every scroll, so it stays frozen while the list scrolls under it.
+ (add-hook 'window-scroll-functions #'cj/music--stick-header nil t))
+ (cj/music--renumber-rows buffer)
;; Set this as the current EMMS playlist buffer
(setq emms-playlist-buffer buffer)
buffer))
(defun cj/music--m3u-file-tracks (m3u-file)
- "Return list of absolute track paths from M3U-FILE. Ignore # comment lines."
+ "Return list of absolute track paths from M3U-FILE. Ignore # comment lines.
+Stream URLs pass through untouched; a local path must carry an accepted
+music extension (`cj/music--valid-file-p') -- old playlists saved before
+directory adds were filtered can carry cover.jpg lines, and loading one
+would put the cover right back in the playlist."
(when (and m3u-file (file-exists-p m3u-file))
(with-temp-buffer
(insert-file-contents m3u-file)
@@ -344,11 +506,12 @@ modification date so marginalia can show them."
(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))))
+ (let* ((url-p (string-match-p "\\`\\(https?\\|mms\\)://" line))
+ (path (cond (url-p line)
+ ((file-name-absolute-p line) line)
+ (t (expand-file-name line dir)))))
+ (when (or url-p (cj/music--valid-file-p path))
+ (push path tracks))))))
(nreverse tracks)))))
(defun cj/music--playlist-track-objects ()
@@ -435,17 +598,51 @@ Returns the full path to the selected file, or nil if cancelled."
(unless (string= choice "(Cancel)")
(cdr (assoc choice m3u-files)))))
+(defun cj/music--delete-playlist-file (path)
+ "Delete the playlist file at PATH.
+Signals a `user-error' when PATH is nil or missing. When the playlist
+buffer's associated file is PATH, the association is cleared (the in-memory
+queue is untouched). Refreshes the radio metadata cache since an .m3u just
+left the roots."
+ (unless (and path (file-exists-p path))
+ (user-error "Playlist file does not exist: %s"
+ (if path (file-name-nondirectory path) "nil")))
+ (delete-file path)
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (when (equal cj/music-playlist-file path)
+ (setq cj/music-playlist-file nil)))
+ (cj/music--refresh-radio-name-map))
+
;;; Commands: add/select
+(defun cj/music--music-files-recursive (directory)
+ "Return sorted absolute paths of the music files under DIRECTORY.
+Only files passing `cj/music--valid-file-p' (the accepted extensions in
+`cj/music-file-extensions') come back; hidden files and hidden
+directories are skipped. This is the filter the directory-add commands
+route through -- handing the raw tree to EMMS added every file it found,
+so cover art and liner notes ended up as playlist rows."
+ (sort (seq-filter #'cj/music--valid-file-p
+ (directory-files-recursively
+ directory "\\`[^.]" nil
+ (lambda (dir)
+ (not (string-prefix-p "." (file-name-nondirectory dir))))))
+ #'string-lessp))
+
(defun cj/music-add-directory-recursive (directory)
- "Add all music files under DIRECTORY recursively to the EMMS playlist."
+ "Add all music files under DIRECTORY recursively to the EMMS playlist.
+Only files with accepted music extensions are added; cover art and other
+non-music files in album directories stay out."
(interactive
(list (read-directory-name "Add directory recursively: " cj/music-root nil t)))
(unless (file-directory-p directory)
(user-error "Not a directory: %s" directory))
(cj/music--ensure-playlist-buffer)
- (emms-add-directory-tree directory)
- (message "Added recursively: %s" directory))
+ (let ((files (cj/music--music-files-recursive directory)))
+ (dolist (f files)
+ (emms-add-file f))
+ (message "Added %d music file%s from %s"
+ (length files) (if (= (length files) 1) "" "s") directory)))
(defun cj/music-fuzzy-select-and-add ()
@@ -597,6 +794,10 @@ ENTRIES. Nil when neither applies (the caller falls back to a timestamp)."
(plist-get (cdr (assoc (emms-track-name tr) entries))
:name)))))
+;; Forward declaration: the real `defvar' lives with the radio config block far
+;; below. Declared special here so this reference compiles clean.
+(defvar cj/music-radio-save-dir)
+
(defun cj/music--save-directory (tracks)
"Directory a saved playlist targets.
An all-stream queue is a radio playlist and saves into
@@ -664,6 +865,23 @@ reloaded playlist keeps its display name and cover art."
(message "Reloaded playlist: %s" name)))
+(defun cj/music-delete-playlist ()
+ "Delete an .m3u playlist file after strong confirmation.
+Candidates are the playlists `cj/music-playlist-load' offers -- every
+directory in `cj/music-m3u-roots' (the local library and MPD's playlist
+dir). Deleting the loaded playlist's file keeps the in-memory queue but
+clears its file association."
+ (interactive)
+ (let ((file (cj/music--select-m3u-file "Delete playlist: ")))
+ (if (not file)
+ (message "Playlist deletion cancelled")
+ (unless (cj/confirm-strong (format "Delete playlist %s? "
+ (file-name-nondirectory file)))
+ (user-error "Aborted deleting playlist"))
+ (cj/music--delete-playlist-file file)
+ (message "Deleted playlist: %s" (file-name-nondirectory file)))))
+
+
(defun cj/music-playlist-edit ()
"Open the playlist's M3U file in other window, prompting to save if modified."
(interactive)
@@ -775,11 +993,13 @@ Intended for use on `emms-player-finished-hook'."
)
-(defvar cj/music-playlist-window-height 0.3
+(defvar cj/music-playlist-window-height 0.5
"Default fraction of frame height for the F10 music playlist side window.
-Used when the playlist hasn't been resized and toggled off this session;
-after that, the toggled-off height is remembered in
-`cj/--music-playlist-height'.")
+Half the frame, so a playlist of real length shows enough rows (a third
+still read too short in practice). Used when the playlist hasn't been
+resized and toggled off this session; after that, the toggled-off height
+is remembered in `cj/--music-playlist-height' -- but only when it's at
+least this default (see the discard in `cj/music-playlist-toggle').")
(defvar cj/--music-playlist-height nil
"Last height fraction the playlist was toggled off at.
@@ -802,6 +1022,13 @@ resized and toggled off this session, it reopens at that remembered height."
(if win
(progn
(cj/side-window-capture-size win 'bottom 'cj/--music-playlist-height)
+ ;; Remember enlargements only. Window churn (another side window
+ ;; opening) squeezes the dock, and remembering the squeeze reopens
+ ;; it too short on every later toggle. A deliberate shrink is the
+ ;; rare case; losing it costs one resize.
+ (when (and (numberp cj/--music-playlist-height)
+ (< cj/--music-playlist-height cj/music-playlist-window-height))
+ (setq cj/--music-playlist-height nil))
(delete-window win)
(message "Playlist window closed"))
(progn
@@ -811,11 +1038,7 @@ resized and toggled off this session, it reopens at that remembered height."
buffer 'bottom 'cj/--music-playlist-height
cj/music-playlist-window-height))
(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))))
+ (cj/music--playlist-land-point win buffer)
(let ((count (with-current-buffer buffer
(count-lines (point-min) (point-max)))))
(message (if (> count 0)
@@ -833,7 +1056,9 @@ Initializes EMMS if needed."
(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))
+ (let ((buffer (cj/music--ensure-playlist-buffer)))
+ (switch-to-buffer buffer)
+ (cj/music--playlist-land-point (selected-window) buffer))
(cond
((not emms-was-loaded) (message "EMMS started. Current playlist empty"))
((and buffer-exists has-content) (message "EMMS running. Displaying current playlist"))
@@ -848,9 +1073,10 @@ Dirs added recursively."
(unless (derived-mode-p 'dired-mode)
(user-error "This command must be run in a Dired buffer"))
(cj/music--ensure-playlist-buffer)
- (let ((files (if (use-region-p)
- (dired-get-marked-files)
- (list (dired-get-file-for-visit)))))
+ ;; dired-get-marked-files already honors m-marks, an active region, or the
+ ;; file at point; gating it behind use-region-p silently dropped all but
+ ;; the point file whenever files were marked without a region.
+ (let ((files (dired-get-marked-files)))
(when (null files)
(user-error "No files selected"))
(dolist (file files)
@@ -1160,12 +1386,12 @@ The rule uses a resize-safe :align-to span, not a hardcoded character count."
(propertize "Mode " 'face 'cj/music-header-face)
(propertize " : " 'face 'cj/music-header-face)
(funcall mode-indicator "r" "repeat" (bound-and-true-p emms-repeat-playlist)) " "
- (funcall mode-indicator "s" "single" (bound-and-true-p emms-repeat-track)) " "
+ (funcall mode-indicator "1" "single" (bound-and-true-p emms-repeat-track)) " "
(funcall mode-indicator "z" "random" (bound-and-true-p emms-random-playlist)) " "
(funcall mode-indicator "x" "consume" cj/music-consume-mode) "\n"
(propertize "Keys " 'face 'cj/music-header-face)
(propertize " : " 'face 'cj/music-header-face)
- (propertize "a:add c:clear L:load v:save S:stop SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
+ (propertize "a:add c:clear L:load s:save D:delete SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
'face 'cj/music-keyhint-face) "\n"
(propertize "Radio " 'face 'cj/music-header-face)
(propertize " : " 'face 'cj/music-header-face)
@@ -1217,14 +1443,51 @@ the controls."
(cj/music--fancy-header)
(cj/music--text-header)))
+(defun cj/music--header-anchor-position ()
+ "Return the position the header overlay should anchor at right now.
+The start of the displaying window when the playlist is shown (so the
+header stays at the top of the window while the list scrolls under it),
+else the top of the buffer. Searches all frames -- the refresh timer can
+run with any frame selected, and missing a window on another frame would
+anchor at the buffer top and yank a scrolled header back."
+ (if-let ((win (get-buffer-window (current-buffer) t)))
+ (max (point-min) (min (window-start win) (point-max)))
+ (point-min)))
+
+(defun cj/music--stick-header (win start)
+ "Re-anchor the header overlay at START, WIN's new display start.
+Runs on the buffer-local `window-scroll-functions', so every scroll pins
+the header block to the top of the window and the track list scrolls
+beneath it. Converges: an already-anchored header is a no-op, so the
+redisplay this move triggers doesn't loop. Always returns nil."
+ (with-current-buffer (window-buffer win)
+ (when (and (overlayp cj/music--header-overlay)
+ (overlay-buffer cj/music--header-overlay)
+ (integer-or-marker-p start))
+ (let ((pos (max (point-min) (min start (point-max)))))
+ (unless (= (overlay-start cj/music--header-overlay) pos)
+ (move-overlay cj/music--header-overlay pos pos))))
+ nil))
+
+(defun cj/music--refresh-header-after-toggle (&rest _)
+ "Refresh the playlist header after a repeat/random/consume toggle.
+Named (not an anonymous lambda) so the :config reload can advice-remove
+it before re-adding -- anonymous advice stacks a copy per reload."
+ (cj/music--update-header))
+
(defun cj/music--update-header ()
- "Insert or update the multi-line header overlay in the playlist buffer."
+ "Insert or update the multi-line header overlay in the playlist buffer.
+Anchors at the displaying window's start (see
+`cj/music--header-anchor-position') -- the refresh timer calls this every
+second, and re-anchoring at the buffer top would yank the sticky header
+away whenever the list is scrolled."
(when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
(with-current-buffer buf
(unless cj/music--header-overlay
(setq cj/music--header-overlay (make-overlay (point-min) (point-min)))
(overlay-put cj/music--header-overlay 'priority 100))
- (move-overlay cj/music--header-overlay (point-min) (point-min))
+ (let ((pos (cj/music--header-anchor-position)))
+ (move-overlay cj/music--header-overlay pos pos))
(overlay-put cj/music--header-overlay 'before-string
(cj/music--header-text)))))
@@ -1344,19 +1607,22 @@ unless fancy."
(add-hook 'emms-player-stopped-hook #'cj/music--stop-bar-timer)
(add-hook 'emms-player-finished-hook #'cj/music--stop-bar-timer)
- ;; Refresh header immediately when toggling modes
+ ;; Refresh header immediately when toggling modes. Named advice with a
+ ;; remove-then-add guard (like the emms-playlist-clear advice above):
+ ;; an anonymous lambda can't be advice-removed and stacks a copy on every
+ ;; :config reload, firing the refresh N times per toggle.
(dolist (fn '(emms-toggle-repeat-playlist
emms-toggle-repeat-track
emms-toggle-random-playlist
cj/music-toggle-consume))
- (advice-add fn :after (lambda (&rest _) (cj/music--update-header))))
+ (advice-remove fn #'cj/music--refresh-header-after-toggle)
+ (advice-add fn :after #'cj/music--refresh-header-after-toggle))
:bind
(:map emms-playlist-mode-map
;; Playback
("p" . emms-playlist-mode-go)
("SPC" . emms-pause)
- ("s" . emms-stop)
("n" . cj/music-next)
(">" . cj/music-next)
("P" . cj/music-previous)
@@ -1379,9 +1645,9 @@ unless fancy."
("c" . cj/music-playlist-clear)
("C" . cj/music-playlist-clear)
("L" . cj/music-playlist-load)
+ ("D" . cj/music-delete-playlist)
("E" . cj/music-playlist-edit)
("g" . cj/music-playlist-reload)
- ("v" . cj/music-playlist-save)
;; Track reordering
("S-<up>" . emms-playlist-mode-shift-track-up)
("S-<down>" . emms-playlist-mode-shift-track-down)
@@ -1434,6 +1700,15 @@ On a connection failure the client falls back to a host from /json/servers.")
"User-Agent sent with radio-browser requests.
The project asks clients to identify themselves.")
+(defvar cj/music-radio-tag-limit 500
+ "Maximum number of tags fetched from radio-browser for tag completion.
+The /json/tags endpoint is fetched ordered by station count, so the limit
+keeps the popular tags and drops the long tail of one-station noise tags.")
+
+(defvar cj/music-radio--tags-cache nil
+ "Session cache of radio-browser tag names, or nil before the first fetch.
+A failed fetch leaves it nil so the next tag search retries.")
+
(defvar cj/music-radio-search-limit 30
"Maximum number of stations a radio-browser search returns.")
@@ -1504,14 +1779,16 @@ TAGS is a comma-separated string or nil; nil or empty yields the empty string."
(defun cj/music-radio--format-candidate (st)
"Marginalia annotation for station ST.
-Variant B: codec, bitrate, country, votes, and the first few tags."
+Variant B: codec, bitrate, country, votes, and the first few tags. Every
+field pads to a fixed width (votes included) so the listing reads as
+aligned columns across stations."
(let ((codec (or (plist-get st :codec) ""))
(bitrate (let ((b (plist-get st :bitrate)))
(if (and (integerp b) (> b 0)) (format "%dk" b) "")))
(cc (or (plist-get st :countrycode) ""))
(votes (or (plist-get st :votes) 0))
(tags (cj/music-radio--tags-snippet (plist-get st :tags) 3)))
- (format "%-4s %-5s %-2s ♥%d %s" codec bitrate cc votes tags)))
+ (format "%-4s %-5s %-2s %-7s %s" codec bitrate cc (format "♥%d" votes) tags)))
(defun cj/music-radio--search-url (server query &optional field)
"Build the radio-browser station-search URL for QUERY against SERVER.
@@ -1571,23 +1848,21 @@ to one station. Pure helper."
(push (cons disp st) out)))))
(defun cj/music-radio--completion-table (candidates)
- "Completion table over CANDIDATES carrying the Variant-B marginalia affix."
+ "Completion table over CANDIDATES carrying the Variant-B annotation.
+Tagged `cj-radio-station' and registered with marginalia (builtin), so the
+codec/bitrate/country/votes/tags annotation renders right-aligned like the
+stock categories. The \"[done]\" sentinel has no station and annotates as
+nil rather than a bogus zero row."
+ (cj/completion-ensure-marginalia-align 'cj-radio-station)
(lambda (string pred action)
(if (eq action 'metadata)
`(metadata
(category . cj-radio-station)
- (affixation-function
- . ,(lambda (cands)
- (mapcar (lambda (c)
- (let ((st (cdr (assoc c candidates))))
- ;; The "[done]" sentinel has no station, so it gets
- ;; no annotation rather than a bogus "0" row.
- (list c "" (if st
- (concat " "
- (propertize (cj/music-radio--format-candidate st)
- 'face 'completions-annotations))
- ""))))
- cands))))
+ (annotation-function
+ . ,(lambda (c)
+ (when-let ((st (cdr (assoc c candidates))))
+ (concat " " (propertize (cj/music-radio--format-candidate st)
+ 'face 'completions-annotations))))))
(complete-with-action action (mapcar #'car candidates) string pred))))
(defun cj/music-radio--pick-loop (candidates)
@@ -1617,8 +1892,10 @@ bitrate, country, votes, and tags), lets you pick several one at a time, adds
each to the playlist as a url track carrying its station metadata, and plays
the first pick (interrupting whatever was playing). Nothing is written to
disk; save the queue with the normal playlist save, where the station name
-pre-fills the prompt."
- (when (string-empty-p (string-trim query))
+pre-fills the prompt. QUERY is trimmed of surrounding whitespace first --
+a stray trailing space otherwise reaches the API as %20 and matches nothing."
+ (setq query (string-trim query))
+ (when (string-empty-p query)
(user-error "Empty search"))
(cj/emms--setup)
(let* ((stations (cj/music-radio--search query field))
@@ -1650,9 +1927,44 @@ pre-fills the prompt."
(interactive "sRadio search (name): ")
(cj/music-radio--search-and-play query "name"))
+(defun cj/music-radio--tags-url (server)
+ "Build the radio-browser tag-list URL against SERVER.
+Ordered by station count descending so the limit keeps the popular tags."
+ (format "https://%s/json/tags?order=stationcount&reverse=true&limit=%d"
+ server cj/music-radio-tag-limit))
+
+(defun cj/music-radio--parse-tags (json-text)
+ "Parse a radio-browser JSON-TEXT tag array into a clean list of tag names.
+Names come back whitespace-trimmed with empties and duplicates dropped --
+the source data is user-generated and carries all three. Signals
+`user-error' on unreadable JSON (via `cj/music-radio--parse-search')."
+ (let ((names '()))
+ (dolist (tag (cj/music-radio--parse-search json-text))
+ (let ((name (string-trim (or (plist-get tag :name) ""))))
+ (unless (or (string-empty-p name) (member name names))
+ (push name names))))
+ (nreverse names)))
+
+(defun cj/music-radio--available-tags ()
+ "Return cached radio-browser tag names, fetching once per session.
+Returns nil when the fetch or parse fails, leaving the cache empty so a
+later call retries; the tag prompt then falls back to free-form input."
+ (or cj/music-radio--tags-cache
+ (setq cj/music-radio--tags-cache
+ (ignore-errors
+ (when-let ((body (cj/music-radio--http-get
+ (cj/music-radio--tags-url cj/music-radio-server))))
+ (cj/music-radio--parse-tags body))))))
+
(defun cj/music-radio-search-by-tag (tag)
- "Search radio-browser.info by tag/genre, then queue and play a selection."
- (interactive "sRadio search (tag): ")
+ "Search radio-browser.info by tag/genre, then queue and play a selection.
+The prompt completes over the popular tags fetched from radio-browser
+\(cached per session), so you pick from tags that exist instead of
+guessing. Free-form input still works for an unlisted tag, and the prompt
+degrades to plain input when the tag fetch fails."
+ (interactive
+ (list (completing-read "Radio search (tag): "
+ (cj/music-radio--available-tags))))
(cj/music-radio--search-and-play tag "tag"))
;; ------------------------------- Cover art -----------------------------------
@@ -1789,14 +2101,15 @@ when there is nothing to fetch."
(message "Cleared music art cache: %s" cj/music-art-cache-dir))
;; Radio row in the playlist buffer: n = search by name, t = search by tag,
-;; m = enter a station by hand. This moves the "single" mode toggle off t to s
-;; and emms-stop off s to S (see the header's Mode/Keys/Radio rows).
+;; m = enter a station by hand. Single-track mode is on 1 and s saves the
+;; playlist; stop was dropped (SPC/pause covers it). These run after
+;; use-package's :map, so they win (see the header's Mode/Keys/Radio rows).
(with-eval-after-load 'emms
(keymap-set emms-playlist-mode-map "n" #'cj/music-radio-search-by-name)
(keymap-set emms-playlist-mode-map "t" #'cj/music-radio-search-by-tag)
(keymap-set emms-playlist-mode-map "m" #'cj/music-create-radio-station)
- (keymap-set emms-playlist-mode-map "s" #'emms-toggle-repeat-track)
- (keymap-set emms-playlist-mode-map "S" #'emms-stop))
+ (keymap-set emms-playlist-mode-map "1" #'emms-toggle-repeat-track)
+ (keymap-set emms-playlist-mode-map "s" #'cj/music-playlist-save))
(provide 'music-config)
;;; music-config.el ends here