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.el1474
1 files changed, 1292 insertions, 182 deletions
diff --git a/modules/music-config.el b/modules/music-config.el
index d16e2bb2..233bae72 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -16,13 +16,20 @@
;;
;; The playlist keymap intentionally follows ncmpcpp where it maps cleanly, with
;; EMMS-specific additions for M3U editing and consume mode.
+;;
+;; The player has two render paths. In a graphical frame with `cj/music-fancy-ui'
+;; on (the default), it draws the fancy hi-fi surface: a now-playing hero with
+;; cover art (station favicon / sibling album art / a shipped vinyl placeholder,
+;; cached under data/music-art/), a serif title, and a block progress bar that
+;; advances from mpv's percent-pos while a file plays. A TTY frame, or the
+;; toggle off, falls back to the plain text player (names, a dim glyph, a thin
+;; status line). `cj/music-clear-art-cache' empties the art cache.
;;; Code:
(require 'subr-x)
(require 'user-constants)
(require 'keybindings) ;; provides cj/custom-keymap
-(require 'cj-window-geometry-lib) ;; cj/preferred-dock-direction (F10 dock side)
(require 'cj-window-toggle-lib) ;; side-window size memory (F10 toggle)
(require 'system-lib) ;; cj/confirm-strong (overwrite confirms)
@@ -30,7 +37,6 @@
;; compiles as a dynamic bind, not a dead lexical local -- otherwise emms /
;; orderless never see the binding (the lexical-binding foreign-special-var trap).
(defvar orderless-smart-case)
-(defvar emms-source-playlist-ask-before-overwrite)
(defvar emms-playlist-buffer-p)
(defvar emms-playlist-buffer)
(defvar emms-random-playlist)
@@ -56,6 +62,46 @@
(defface cj/music-keyhint-face '((t :inherit shadow))
"Key hints in the playlist header.")
+;; Fancy-render faces (Phase 3). Amber comes from the themed `warning' face so
+;; the active theme (dupre) owns the color; the serif family is applied at
+;; render time from `cj/music-title-family'.
+(defface cj/music-title-face '((t :inherit cj/music-header-value-face :weight bold))
+ "Now-playing title in the fancy player.")
+(defface cj/music-subtitle-face '((t :inherit shadow))
+ "Now-playing subtitle (station or album) in the fancy player.")
+(defface cj/music-bar-fill-face '((t :inherit warning))
+ "Filled portion of the fancy progress bar (amber).")
+(defface cj/music-bar-empty-face '((t :inherit shadow))
+ "Empty portion of the fancy progress bar.")
+
+(defgroup cj/music nil
+ "Personal EMMS music-player tweaks."
+ :group 'emms)
+
+(defcustom cj/music-fancy-ui t
+ "When non-nil and the frame is graphical, render the fancy hi-fi player:
+cover art, a serif now-playing hero, and a progress bar. Nil, or a TTY frame,
+falls back to the plain text player (names, a dim glyph, a thin status line)."
+ :type 'boolean
+ :group 'cj/music)
+
+(defcustom cj/music-title-family
+ (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)
+
+(defvar cj/music-hero-size 96
+ "Pixel height of the now-playing hero cover image.")
+(defvar cj/music-thumb-size 22
+ "Pixel height of a playlist row's cover thumbnail.")
+(defvar cj/music-bar-width 24
+ "Cell width of the now-playing progress bar.")
+(defvar cj/music-bar-interval 1
+ "Seconds between progress-bar redraws while a track is playing and visible.")
+
;; Foreign functions used lazily after their packages load.
(declare-function emms-playlist-mode "emms-playlist-mode")
(declare-function emms-playlist-track-at "emms-playlist-mode")
@@ -63,11 +109,15 @@
(declare-function emms-track-name "emms")
(declare-function emms-track-type "emms")
(declare-function emms-track-get "emms")
+(declare-function emms-track "emms")
+(declare-function emms-track-set "emms")
(declare-function emms-track-simple-description "emms")
(declare-function emms-playlist-current-selected-track "emms")
(declare-function emms-playlist-select "emms")
+(declare-function emms-playlist-selected-track "emms")
(declare-function emms-playlist-clear "emms")
-(declare-function emms-playlist-save "emms-source-playlist")
+(declare-function emms-playlist-insert-track "emms")
+(declare-function emms-stop "emms")
(declare-function emms-start "emms")
(declare-function emms-random "emms")
(declare-function emms-next "emms")
@@ -82,7 +132,19 @@
"Root directory of your music collection.")
(defvar cj/music-m3u-root cj/music-root
- "Directory where M3U playlists are saved and loaded.")
+ "Directory M3U playlists are saved to (the single writable target).
+Reading and selection union `cj/music-m3u-roots'; only saving and radio-station
+creation write here.")
+
+(defvar cj/music-m3u-roots
+ (list cj/music-root
+ (expand-file-name "~/.local/share/mpd/playlists/"))
+ "Directories to source M3U playlists from, in precedence order.
+Both the local-library playlists (`cj/music-root') and the dotfiles-tracked
+internet-radio playlists (MPD's playlist_directory) surface together for
+selection and loading. Earlier directories win on a basename collision.
+Missing directories are skipped. Saving still targets the single
+`cj/music-m3u-root'.")
(defvar cj/music-file-extensions '("aac" "flac" "m4a" "mp3" "ogg" "opus" "wav")
"List of valid music file extensions.")
@@ -167,6 +229,30 @@ A no-op when nothing is playing or the socket is gone, so it never errors."
(accept-process-output proc 0.1))
(delete-process proc))))))
+(defun cj/music--mpv-get-property (prop)
+ "Query the mpv IPC socket for PROP and return its value, or nil.
+Reads the reply (unlike `cj/music--mpv-command', which only sends), so the
+progress bar can read percent-pos. Blocks briefly, so call it off redisplay."
+ (when (file-exists-p cj/music--mpv-socket)
+ (ignore-errors
+ (let ((out "") proc)
+ (setq proc (make-network-process
+ :name "cj-music-mpv-get" :family 'local
+ :service cj/music--mpv-socket :noquery t
+ :filter (lambda (_p s) (setq out (concat out s)))))
+ (unwind-protect
+ (progn
+ (process-send-string
+ proc (format "{\"command\":[\"get_property\",\"%s\"]}\n" prop))
+ (accept-process-output proc 0.2)
+ (cl-loop for line in (split-string out "\n" t)
+ for obj = (ignore-errors
+ (json-parse-string line :object-type 'plist
+ :null-object nil))
+ when (and obj (plist-member obj :data))
+ return (plist-get obj :data)))
+ (delete-process proc))))))
+
(defun cj/music-seek-forward ()
"Seek `cj/music-seek-seconds' seconds forward in the current track."
(interactive)
@@ -222,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
@@ -238,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)
@@ -260,39 +506,56 @@ 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-tracks ()
- "Return list of track names from current EMMS playlist buffer."
+(defun cj/music--playlist-track-objects ()
+ "Return the track objects from the current EMMS playlist buffer, in order."
(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))
+ (push track tracks))
(forward-line 1))))
(nreverse tracks)))
+(defun cj/music--playlist-tracks ()
+ "Return list of track names from current EMMS playlist buffer."
+ (mapcar #'emms-track-name (cj/music--playlist-track-objects)))
+
+(defun cj/music--dedup-m3u-files (paths)
+ "Return (BASENAME . PATH) conses for PATHS, first occurrence of a basename winning.
+Pure helper: since `cj/music--get-m3u-files' scans `cj/music-m3u-roots' in order,
+an earlier directory shadows a same-named playlist in a later one."
+ (let ((seen (make-hash-table :test 'equal))
+ (result '()))
+ (dolist (p paths (nreverse result))
+ (let ((base (file-name-nondirectory p)))
+ (unless (gethash base seen)
+ (puthash base t seen)
+ (push (cons base p) result))))))
+
(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)))
+ "Return (BASENAME . FULLPATH) conses for M3Us across `cj/music-m3u-roots'.
+Directories are scanned in order and missing ones skipped; on a basename
+collision the earlier directory wins."
+ (cj/music--dedup-m3u-files
+ (cl-loop for dir in cj/music-m3u-roots
+ when (file-directory-p dir)
+ append (directory-files dir t "\\.m3u\\'" t))))
(defun cj/music--get-m3u-basenames ()
- "Return list of M3U basenames (no extension) in cj/music-m3u-root."
+ "Return list of M3U basenames (no extension) across `cj/music-m3u-roots'."
(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
@@ -312,11 +575,12 @@ Signals user-error if missing or deleted."
(file-name-nondirectory cj/music-playlist-file))))))
(defun cj/music--assert-m3u-files-exist ()
- "Assert that M3U files exist in cj/music-m3u-root.
+ "Assert that M3U files exist across `cj/music-m3u-roots'.
Returns the list of (BASENAME . FULLPATH) conses. Signals user-error if none."
(let ((files (cj/music--get-m3u-files)))
(when (null files)
- (user-error "No M3U files found in %s" cj/music-m3u-root))
+ (user-error "No M3U files found in %s"
+ (string-join cj/music-m3u-roots ", ")))
files))
(defun cj/music--sync-playlist-file (file-path)
@@ -334,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 ()
@@ -438,23 +736,107 @@ Replaces current playlist."
(message "Loaded playlist: %s" choice-name)))
+(defun cj/music--m3u-track-lines (track entries)
+ "The .m3u lines for TRACK.
+A file track is its bare absolute path. A url track carries its station
+metadata — an #EXTINF label plus #RADIOBROWSERUUID / #RADIOBROWSERFAVICON when
+known — read from the track's properties first, then its ENTRIES metadata (a
+loaded legacy playlist has entries but no properties), so a saved station
+keeps its display name and cover art on reload."
+ (let ((name (emms-track-name track)))
+ (if (not (eq (emms-track-type track) 'url))
+ (concat name "\n")
+ (let* ((meta (cdr (assoc name entries)))
+ (label (or (emms-track-get track 'info-title)
+ (plist-get meta :name)
+ (cj/music--tidy-host name)))
+ (uuid (or (emms-track-get track 'radio-uuid)
+ (plist-get meta :uuid)))
+ (favicon (or (emms-track-get track 'radio-favicon)
+ (plist-get meta :favicon))))
+ (concat
+ (if (and (stringp uuid) (not (string-empty-p uuid)))
+ (format "#RADIOBROWSERUUID:%s\n" uuid)
+ "")
+ (if (and (stringp favicon) (not (string-empty-p favicon)))
+ (format "#RADIOBROWSERFAVICON:%s\n"
+ (replace-regexp-in-string "[\r\n]+" " " favicon))
+ "")
+ (format "#EXTINF:-1,%s\n"
+ (replace-regexp-in-string "[\r\n]+" " " label))
+ name "\n")))))
+
+(defun cj/music--m3u-text (tracks entries)
+ "The full .m3u file text for TRACKS, station metadata from ENTRIES.
+The stock EMMS m3u writer emits bare URLs; this emitter writes the comment
+lines `cj/music--m3u-entries' parses, so save -> load round-trips."
+ (concat "#EXTM3U\n"
+ (mapconcat (lambda (tr) (cj/music--m3u-track-lines tr entries))
+ tracks "")))
+
+(defun cj/music--write-playlist-file (path tracks entries)
+ "Write TRACKS to PATH as .m3u text, station metadata from ENTRIES.
+Refreshes the radio metadata cache since a new .m3u just landed."
+ (with-temp-file path
+ (insert (cj/music--m3u-text tracks entries)))
+ (cj/music--refresh-radio-name-map))
+
+(defun cj/music--save-default-name (tracks file entries)
+ "The name the save prompt should offer.
+FILE (the playlist's associated .m3u) wins when present. Otherwise the first
+url track's station name — its title property, else its #EXTINF label from
+ENTRIES. Nil when neither applies (the caller falls back to a timestamp)."
+ (if file
+ (file-name-sans-extension (file-name-nondirectory file))
+ (cl-loop for tr in tracks
+ when (eq (emms-track-type tr) 'url)
+ thereis (or (emms-track-get tr 'info-title)
+ (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
+`cj/music-radio-save-dir'; anything else saves into `cj/music-m3u-root'."
+ (if (and tracks
+ (cl-every (lambda (tr) (eq (emms-track-type tr) 'url)) tracks))
+ cj/music-radio-save-dir
+ cj/music-m3u-root))
+
(defun cj/music-playlist-save ()
- "Save current EMMS playlist to a file in cj/music-m3u-root.
-Offers completion over existing names but allows new names."
+ "Save the current EMMS playlist to an .m3u file.
+An all-stream queue saves into `cj/music-radio-save-dir' (the radio playlist
+home); anything else saves into `cj/music-m3u-root'. A queue of freshly
+looked-up stations pre-fills the first station's name in the prompt; a
+playlist with an associated file keeps that file's name as the default.
+Station metadata (name, uuid, favicon) is written with each stream so a
+reloaded playlist keeps its display name and cover art."
(interactive)
- (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))
+ (let* ((tracks (cj/music--playlist-track-objects))
+ (entries (cj/music--radio-metadata))
+ (existing (cj/music--get-m3u-basenames))
+ (assoc-file (with-current-buffer (cj/music--ensure-playlist-buffer)
+ cj/music-playlist-file))
+ (prefill (and (null assoc-file)
+ (cj/music--save-default-name tracks nil entries)))
+ (default-name (or (cj/music--save-default-name tracks assoc-file entries)
+ (format-time-string "playlist-%Y%m%d-%H%M%S")))
+ (chosen (completing-read "Save playlist as: " existing nil nil
+ prefill nil default-name))
(filename (if (string-suffix-p ".m3u" chosen) chosen (concat chosen ".m3u")))
- (full (expand-file-name filename cj/music-m3u-root)))
+ (dir (cj/music--save-directory tracks))
+ (full (expand-file-name filename dir)))
+ (when (string-empty-p (string-trim chosen))
+ (user-error "Playlist name cannot be empty"))
(when (and (file-exists-p full)
(not (cj/confirm-strong (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)))
+ (make-directory dir t)
+ (cj/music--write-playlist-file full tracks entries)
(cj/music--sync-playlist-file full)
(message "Saved playlist: %s" filename)))
@@ -483,6 +865,23 @@ Offers completion over existing names but allows new names."
(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)
@@ -491,8 +890,9 @@ Offers completion over existing names but allows new names."
(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))))
+ (cj/music--write-playlist-file path
+ (cj/music--playlist-track-objects)
+ (cj/music--radio-metadata))))
;; Re-validate existence before opening
(if (file-exists-p path)
(find-file-other-window path)
@@ -593,42 +993,26 @@ 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 docks at the bottom and hasn't been resized and
-toggled off this session; after that, the toggled-off height is remembered
-in `cj/--music-playlist-height'.")
-
-(defvar cj/music-playlist-window-width 0.4
- "Default fraction of frame width for the F10 music playlist side window.
-Used when the playlist docks as a right-side column (see
-`cj/--music-playlist-side') and hasn't been resized this session; after
-that the toggled-off width is remembered in `cj/--music-playlist-width'.")
+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 while docked bottom.
+ "Last height fraction the playlist was toggled off at.
nil means fall back to `cj/music-playlist-window-height'. In-memory only --
resets each Emacs session.")
-(defvar cj/--music-playlist-width nil
- "Last width fraction the playlist was toggled off at while docked right.
-nil means fall back to `cj/music-playlist-window-width'. In-memory only --
-resets each Emacs session.")
-
-(defun cj/--music-playlist-side ()
- "Return the side the F10 playlist should dock on: `right' or `bottom'.
-Docks as a right-side column only when a side-by-side split would leave
-both panes at least `cj/window-dock-min-columns' wide (the playlist's
-share is `cj/music-playlist-window-width'); otherwise docks at the bottom.
-See `cj/preferred-dock-direction'."
- (if (eq (cj/preferred-dock-direction (frame-width)
- cj/music-playlist-window-width)
- 'right)
- 'right
- 'bottom))
-
(defun cj/music-playlist-toggle ()
"Toggle the EMMS playlist buffer in a bottom side window.
+The playlist always docks at the bottom, whatever the frame's shape. It
+used to dock as a right-side column on a wide frame (via
+`cj/preferred-dock-direction'), which split a wide frame three ways --
+unexpected often enough that Craig retired the rule (2026-07-09).
+
The window opens at `cj/music-playlist-window-height'; if it has been
resized and toggled off this session, it reopens at that remembered height."
(interactive)
@@ -637,34 +1021,24 @@ resized and toggled off this session, it reopens at that remembered height."
(win (and buffer (get-buffer-window buffer))))
(if win
(progn
- ;; Capture the resized size into the var matching the window's
- ;; actual side, so width and height memories stay independent.
- ;; Guard the parameter lookup: a dead or non-window WIN (the
- ;; capture helpers tolerate one) must not error here.
- (let ((side (if (window-live-p win)
- (or (window-parameter win 'window-side) 'bottom)
- 'bottom)))
- (if (memq side '(left right))
- (cj/side-window-capture-size win side 'cj/--music-playlist-width)
- (cj/side-window-capture-size win 'bottom 'cj/--music-playlist-height)))
+ (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
(cj/emms--setup)
(setq buffer (cj/music--ensure-playlist-buffer))
- (let* ((side (cj/--music-playlist-side))
- (right (eq side 'right)))
- (setq win (cj/side-window-display
- buffer side
- (if right 'cj/--music-playlist-width 'cj/--music-playlist-height)
- (if right cj/music-playlist-window-width
- cj/music-playlist-window-height))))
+ (setq win (cj/side-window-display
+ 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)
@@ -682,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"))
@@ -697,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)
@@ -715,19 +1092,25 @@ Dirs added recursively."
;;; EMMS setup and keybindings
;; Music/EMMS keymap
+(defvar-keymap cj/music-radio-map
+ :doc "Radio prefix: mirrors the playlist buffer's n/t/m radio row."
+ "n" #'cj/music-radio-search-by-name
+ "t" #'cj/music-radio-search-by-tag
+ "m" #'cj/music-create-radio-station)
+
(defvar-keymap cj/music-map
- :doc "Keymap for music commands"
+ :doc "Keymap for music commands (all lowercase, chord-friendly)"
"m" #'cj/music-playlist-toggle
- "M" #'cj/music-playlist-show
+ "v" #'cj/music-playlist-show
"a" #'cj/music-fuzzy-select-and-add
- "R" #'cj/music-create-radio-station
+ "r" cj/music-radio-map
"SPC" #'emms-pause
"s" #'emms-stop
"n" #'cj/music-next
"p" #'cj/music-previous
"g" #'emms-playlist-mode-go
- "Z" #'emms-shuffle
- "r" #'emms-toggle-repeat-playlist
+ "u" #'emms-shuffle
+ "l" #'emms-toggle-repeat-playlist
"t" #'emms-toggle-repeat-track
"z" #'emms-toggle-random-playlist
"x" #'cj/music-toggle-consume)
@@ -737,16 +1120,19 @@ Dirs added recursively."
(which-key-add-key-based-replacements
"C-; m" "music menu"
"C-; m m" "toggle playlist"
- "C-; m M" "show playlist"
+ "C-; m v" "show playlist"
"C-; m a" "add music"
- "C-; m R" "create radio"
+ "C-; m r" "+radio"
+ "C-; m r n" "radio by name"
+ "C-; m r t" "radio by tag"
+ "C-; m r m" "radio manual entry"
"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 Z" "shuffle"
- "C-; m r" "repeat playlist"
+ "C-; m u" "shuffle"
+ "C-; m l" "repeat playlist"
"C-; m t" "repeat track"
"C-; m z" "random"
"C-; m x" "consume"))
@@ -767,92 +1153,381 @@ Dirs added recursively."
(when (and seconds (numberp seconds) (> seconds 0))
(format "%d:%02d" (/ seconds 60) (mod seconds 60))))
-(defun cj/music--track-description (track)
- "Return a human-readable description of TRACK.
-For tagged tracks: \"Artist - Title [M:SS]\".
-For file tracks without tags: filename without path or extension.
-For URL tracks: decoded URL."
+;; ---------------------------- Display-name layer -----------------------------
+;; A track maps to a display NAME (shared by the header's Current line and the
+;; playlist row renderer) plus, on a row, a dim type glyph and right-aligned
+;; meta. The pure pieces (name resolution, #EXTINF-label extraction, host
+;; tidying, progress-bar fill) carry the tests; the glyph, the :align-to meta,
+;; and the disk-backed name-map are exercised live.
+
+(defun cj/music--tidy-host (url)
+ "Return a readable host label for URL: scheme and path dropped, a leading
+\"www.\" removed, and a multi-label host reduced to its last two labels
+\(ice6.somafm.com -> somafm.com). A string with no scheme://host is returned
+unchanged, so a non-URL name shows as-is instead of erroring."
+ (if (string-match "\\`[a-zA-Z]+://\\(?:[^@/]*@\\)?\\([^:/?#]+\\)" url)
+ (let* ((host (replace-regexp-in-string "\\`www\\." "" (match-string 1 url)))
+ (labels (split-string host "\\." t)))
+ (if (> (length labels) 2)
+ (string-join (last labels 2) ".")
+ host))
+ url))
+
+(defun cj/music--m3u-entries (text)
+ "Parse M3U TEXT into an alist of (STREAM-URL . PLIST).
+Each PLIST carries :name (the #EXTINF label), :uuid (#RADIOBROWSERUUID), and
+:favicon (#RADIOBROWSERFAVICON), read from the comment lines preceding the url.
+A url with no #EXTINF is skipped; fields reset after each url so nothing leaks
+between stations."
+ (let ((name nil) (uuid nil) (favicon nil) (entries '()))
+ (dolist (line (split-string text "[\r\n]+" t) (nreverse entries))
+ (cond
+ ((string-match "\\`#EXTINF:[^,]*,\\(.*\\)\\'" line)
+ (setq name (match-string 1 line)))
+ ((string-match "\\`#RADIOBROWSERUUID:\\(.*\\)\\'" line)
+ (setq uuid (match-string 1 line)))
+ ((string-match "\\`#RADIOBROWSERFAVICON:\\(.*\\)\\'" line)
+ (setq favicon (match-string 1 line)))
+ ((string-prefix-p "#" line)) ; other comment
+ (t (when name
+ (push (cons line (list :name name :uuid uuid :favicon favicon))
+ entries))
+ (setq name nil uuid nil favicon nil))))))
+
+(defun cj/music--m3u-labels (text)
+ "Alist of (STREAM-URL . #EXTINF-LABEL) parsed from M3U TEXT.
+A thin projection of `cj/music--m3u-entries' onto the label field."
+ (mapcar (lambda (e) (cons (car e) (plist-get (cdr e) :name)))
+ (cj/music--m3u-entries text)))
+
+(defvar cj/music--radio-metadata-cache nil
+ "Cached url->plist metadata (:name :uuid :favicon) across `cj/music-m3u-roots'.
+Built lazily so a row render never re-scans disk; cleared by
+`cj/music--refresh-radio-name-map' when a station is created or a playlist
+loads.")
+
+(defun cj/music--radio-metadata ()
+ "Alist of stream-url -> plist metadata, unioned across all playlist roots.
+Reads each .m3u once and caches the result; both name resolution and the
+cover-art layer read from it."
+ (or cj/music--radio-metadata-cache
+ (setq cj/music--radio-metadata-cache
+ (cl-loop for (_base . path) in (cj/music--get-m3u-files)
+ append (cj/music--m3u-entries
+ (with-temp-buffer
+ (insert-file-contents path)
+ (buffer-string)))))))
+
+(defun cj/music--radio-name-map ()
+ "Alist of stream-url -> station label, derived from the cached metadata."
+ (mapcar (lambda (e) (cons (car e) (plist-get (cdr e) :name)))
+ (cj/music--radio-metadata)))
+
+(defun cj/music--refresh-radio-name-map ()
+ "Clear the cached radio metadata so the next render rebuilds it."
+ (setq cj/music--radio-metadata-cache nil))
+
+(defun cj/music--display-name (track &optional name-map)
+ "Human display name for TRACK, name only (no duration).
+A tagged track (file or url) shows \"Artist - Title\" or the bare title; an
+untagged file shows its filename; a url track resolves to its #EXTINF label
+from NAME-MAP (an alist of url->label), else a tidied host. Unknown types
+fall back to `emms-track-simple-description'."
(let ((type (emms-track-type track))
(title (emms-track-get track 'info-title))
(artist (emms-track-get track 'info-artist))
- (duration (emms-track-get track 'info-playing-time))
(name (emms-track-name track)))
(cond
- ;; Tagged track with title
- (title
- (let ((dur-str (cj/music--format-duration duration))
- (parts '()))
- (when artist (push artist parts))
- (push title parts)
- (let ((desc (string-join (nreverse parts) " - ")))
- (if dur-str (format "%s [%s]" desc dur-str) desc))))
- ;; File without tags — show clean filename
- ((eq type 'file)
- (file-name-sans-extension (file-name-nondirectory name)))
- ;; URL — decode percent-encoded characters
- ((eq type 'url)
- (decode-coding-string (url-unhex-string name) 'utf-8))
- ;; Fallback
+ (title (if artist (format "%s - %s" artist title) title))
+ ((eq type 'file) (file-name-sans-extension (file-name-nondirectory name)))
+ ((eq type 'url) (or (cdr (assoc name name-map)) (cj/music--tidy-host name)))
(t (emms-track-simple-description track)))))
+(defun cj/music--format-meta (track)
+ "Right-aligned meta string for TRACK's row: a file's duration as \"[M:SS]\",
+empty when there's no duration (a live stream, or an untimed file)."
+ (let ((dur (cj/music--format-duration (emms-track-get track 'info-playing-time))))
+ (if dur (format "[%s]" dur) "")))
+
+(defun cj/music--bar-fill (elapsed total width)
+ "Filled-cell count for a WIDTH-cell progress bar at ELAPSED/TOTAL seconds.
+Returns 0..WIDTH, or the symbol `indeterminate' when TOTAL is nil or
+non-positive (a live stream). A nil ELAPSED counts as zero."
+ (if (or (null total) (<= total 0))
+ 'indeterminate
+ (let ((ratio (min 1.0 (max 0.0 (/ (float (or elapsed 0)) total)))))
+ (round (* ratio width)))))
+
+(defun cj/music--type-glyph (track)
+ "A leading glyph for TRACK: a broadcast icon for a stream, a note for a file.
+Uses nerd-icons when available; otherwise a plain marker, so a TTY or a
+fontless frame never shows a tofu box."
+ (let ((stream (eq (emms-track-type track) 'url)))
+ (or (and (fboundp 'nerd-icons-mdicon)
+ (ignore-errors
+ (nerd-icons-mdicon (if stream "nf-md-broadcast" "nf-md-music_note")
+ :face 'cj/music-header-face)))
+ (if stream "»" "•"))))
+
+(defun cj/music--row-string (track)
+ "Playlist row for TRACK: a lead glyph or cover thumbnail, the display name,
+and the meta right-aligned to the window edge with a resize-safe :align-to
+space. In the fancy render the lead is a thumbnail and the name is serif.
+This is `emms-track-description-function'."
+ (let* ((name (cj/music--display-name track (cj/music--radio-name-map)))
+ (meta (cj/music--format-meta track))
+ (fancy (cj/music--fancy-p))
+ (lead (if-let* ((fancy)
+ (img (cj/music--image (cj/music-art--for-track track)
+ cj/music-thumb-size)))
+ (propertize " " 'display img)
+ (cj/music--type-glyph track)))
+ (label (if fancy
+ (propertize name 'face (list :family cj/music-title-family
+ :inherit 'cj/music-title-face))
+ name)))
+ (if (string-empty-p meta)
+ (concat lead " " label)
+ (concat lead " " label
+ (propertize " " 'display
+ `(space :align-to (- right ,(1+ (length meta)))))
+ (propertize meta 'face 'cj/music-keyhint-face)))))
+
+(defun cj/music--now-playing-suffix (track)
+ "Trailing status for the header Current line: on-air for a stream, the
+duration for a timed file, empty otherwise."
+ (if (eq (emms-track-type track) 'url)
+ " ◉ on air"
+ (let ((d (cj/music--format-duration (emms-track-get track 'info-playing-time))))
+ (if d (format " %s" d) ""))))
+
+;; ------------------------------ Fancy render ---------------------------------
+;; The GUI hero (cover image + serif title + bar) and thumbnailed serif rows,
+;; gated on a graphical frame + `cj/music-fancy-ui'. Cover art comes from the
+;; non-blocking `cj/music-art--for-track' (defined with the art layer below).
+
+(defun cj/music--fancy-p ()
+ "Non-nil when the fancy render applies: a graphical frame with the
+`cj/music-fancy-ui' toggle on. Decided per redisplay, so a TTY frame and a GUI
+frame in the same session can differ."
+ (and cj/music-fancy-ui (display-graphic-p)))
+
+(defun cj/music--image (path height)
+ "Image spec for PATH scaled to HEIGHT px, or nil when it can't be displayed
+\(no image support, an unreadable file, an unavailable format)."
+ (when (and path (file-readable-p path))
+ (ignore-errors
+ (create-image path nil nil :height height :ascent 'center))))
+
+(defun cj/music--bar-string (fill width)
+ "Render a WIDTH-cell block progress bar with FILL filled cells.
+FILL `indeterminate' (a live stream) renders an on-air marker instead."
+ (if (eq fill 'indeterminate)
+ (propertize "◉ on air" 'face 'cj/music-subtitle-face)
+ (let ((n (max 0 (min fill width))))
+ (concat (propertize (make-string n ?█) 'face 'cj/music-bar-fill-face)
+ (propertize (make-string (- width n) ?░)
+ 'face 'cj/music-bar-empty-face)))))
+
+(defun cj/music--current-bar (track)
+ "The progress bar for TRACK: a stream is indeterminate; a file fills from
+mpv's percent-pos."
+ (if (eq (emms-track-type track) 'url)
+ (cj/music--bar-string 'indeterminate cj/music-bar-width)
+ (let ((pct (cj/music--mpv-get-property "percent-pos")))
+ (cj/music--bar-string
+ (cj/music--bar-fill (and (numberp pct) pct) 100 cj/music-bar-width)
+ cj/music-bar-width))))
+
+(defun cj/music--hero-header (track)
+ "Fancy now-playing hero for TRACK: cover image, serif amber title, subtitle,
+and the progress bar, stacked vertically."
+ (let* ((img (cj/music--image (cj/music-art--for-track track) cj/music-hero-size))
+ (title (cj/music--display-name track (cj/music--radio-name-map)))
+ (sub (if (eq (emms-track-type track) 'url)
+ "radio"
+ (or (emms-track-get track 'info-album) ""))))
+ (concat
+ (if img (concat (propertize " " 'display img) "\n") "")
+ (propertize title 'face (list :family cj/music-title-family
+ :inherit 'cj/music-title-face))
+ "\n"
+ (if (string-empty-p sub)
+ ""
+ (concat (propertize sub 'face 'cj/music-subtitle-face) "\n"))
+ (cj/music--current-bar track)
+ "\n")))
+
;; Multi-line header overlay
(defvar-local cj/music--header-overlay nil
"Overlay displaying the playlist header.")
-(defun cj/music--header-text ()
- "Build a multi-line header string for the playlist buffer overlay."
- (let* ((pl-name (if cj/music-playlist-file
- (file-name-sans-extension
- (file-name-nondirectory cj/music-playlist-file))
- "Untitled"))
- (track-count (count-lines (point-min) (point-max)))
- (now-playing (cond
- ((not emms-player-playing-p) "Stopped")
- (emms-player-paused-p "Paused")
- (t (let ((track (emms-playlist-current-selected-track)))
- (if track
- (cj/music--track-description track)
- "Playing")))))
- (mode-indicator
- (lambda (key label active)
- (let ((face (if active 'cj/music-mode-on-face 'cj/music-mode-off-face)))
- (propertize (format "[%s] %s" key label) 'face face)))))
+(defun cj/music--playlist-string ()
+ "The \"Playlist : NAME (N)\" header line."
+ (let ((pl-name (if cj/music-playlist-file
+ (file-name-sans-extension
+ (file-name-nondirectory cj/music-playlist-file))
+ "Untitled"))
+ (track-count (count-lines (point-min) (point-max))))
+ (concat (propertize "Playlist" 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (propertize (format "%s (%d)" pl-name track-count)
+ 'face 'cj/music-header-value-face)
+ "\n")))
+
+(defun cj/music--controls-string ()
+ "The Mode / Keys / Radio control lines and the closing full-width rule.
+The rule uses a resize-safe :align-to span, not a hardcoded character count."
+ (let ((mode-indicator
+ (lambda (key label active)
+ (let ((face (if active 'cj/music-mode-on-face 'cj/music-mode-off-face)))
+ (propertize (format "[%s] %s" key label) 'face face)))))
(concat
- (propertize "Playlist" 'face 'cj/music-header-face)
- (propertize " : " 'face 'cj/music-header-face)
- (propertize (format "%s (%d)" pl-name track-count) 'face 'cj/music-header-value-face)
- "\n"
- (propertize "Current " 'face 'cj/music-header-face)
- (propertize " : " 'face 'cj/music-header-face)
- (propertize now-playing 'face 'cj/music-header-value-face)
- "\n"
(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 "t" "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"
+ (funcall mode-indicator "r" "repeat" (bound-and-true-p emms-repeat-playlist)) " "
+ (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 S:save SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
- 'face 'cj/music-keyhint-face)
+ (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)
+ (propertize "n:by name t:by tag m:enter manually"
+ 'face 'cj/music-keyhint-face) "\n"
+ (propertize " " 'face '(:strike-through t :inherit shadow)
+ 'display '(space :align-to right))
"\n\n")))
+(defun cj/music--current-track ()
+ "The selected track when one is playing or paused, else nil."
+ (and emms-player-playing-p
+ (ignore-errors (emms-playlist-current-selected-track))))
+
+(defun cj/music--text-header ()
+ "The plain text header: Playlist, Current, then the controls."
+ (let ((now (cond ((not emms-player-playing-p) "Stopped")
+ (emms-player-paused-p "Paused")
+ (t (let ((track (cj/music--current-track)))
+ (if track
+ (concat (cj/music--display-name
+ track (cj/music--radio-name-map))
+ (cj/music--now-playing-suffix track))
+ "Playing"))))))
+ (concat (cj/music--playlist-string)
+ (propertize "Current " 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (propertize now 'face 'cj/music-header-value-face) "\n"
+ (cj/music--controls-string))))
+
+(defun cj/music--fancy-header ()
+ "The fancy header: Playlist, the now-playing hero when a track plays, then
+the controls."
+ (let ((track (cj/music--current-track)))
+ (concat (cj/music--playlist-string)
+ (if track
+ (cj/music--hero-header track)
+ (concat (propertize "Current " 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (propertize (if emms-player-paused-p "Paused" "Stopped")
+ 'face 'cj/music-header-value-face)
+ "\n"))
+ (cj/music--controls-string))))
+
+(defun cj/music--header-text ()
+ "Build the playlist header overlay string: fancy in a graphical frame with
+`cj/music-fancy-ui' on, plain text otherwise."
+ (if (cj/music--fancy-p)
+ (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)))))
+;; Progress-bar redraw timer and cover-art pre-warm (Phase 3).
+(defvar cj/music--bar-timer nil
+ "Repeating timer redrawing the progress bar while a track plays.")
+
+(defun cj/music--bar-tick ()
+ "Redraw the header when the player buffer is visible and a track is playing.
+The timer keeps running while idle/paused; it just skips the redraw."
+ (when (and emms-player-playing-p (not emms-player-paused-p)
+ (get-buffer-window cj/music-playlist-buffer-name t))
+ (cj/music--update-header)))
+
+(defun cj/music--start-bar-timer (&rest _)
+ "Start the progress-bar redraw timer if it is not already running."
+ (unless cj/music--bar-timer
+ (setq cj/music--bar-timer
+ (run-at-time t cj/music-bar-interval #'cj/music--bar-tick))))
+
+(defun cj/music--stop-bar-timer (&rest _)
+ "Stop the progress-bar redraw timer."
+ (when cj/music--bar-timer
+ (cancel-timer cj/music--bar-timer)
+ (setq cj/music--bar-timer nil)))
+
+(defun cj/music--do-prewarm-art ()
+ "Fetch the current track's cover art, then refresh the header so the fetched
+art replaces the placeholder. Blocks on the network; runs off an idle timer."
+ (when-let ((track (cj/music--current-track)))
+ (when (cj/music-art--ensure track)
+ (cj/music--update-header))))
+
+(defun cj/music--prewarm-art (&rest _)
+ "Schedule a cover-art fetch for the current track during idle, so a slow
+fetch never blocks playback start (the emms-player-started-hook). A no-op
+unless fancy."
+ (when (cj/music--fancy-p)
+ (run-with-idle-timer 0.2 nil #'cj/music--do-prewarm-art)))
+
(defvar-local cj/music--bg-remap-cookie nil
"Cookie for the active-window background face remapping.")
@@ -915,7 +1590,7 @@ For URL tracks: decoded URL."
;;; Playlist display
;; Track description: show "Artist - Title [M:SS]" instead of file paths
- (setq emms-track-description-function #'cj/music--track-description)
+ (setq emms-track-description-function #'cj/music--row-string)
(add-hook 'emms-playlist-mode-hook #'cj/music--setup-playlist-display)
(add-hook 'emms-player-started-hook #'cj/music--record-random-history)
@@ -925,19 +1600,29 @@ For URL tracks: decoded URL."
(add-hook 'emms-player-finished-hook #'cj/music--update-header)
(add-hook 'emms-playlist-cleared-hook #'cj/music--update-header)
- ;; Refresh header immediately when toggling modes
+ ;; Fancy render: run the bar timer only across a playing span, and pre-warm
+ ;; the current track's cover art off the redisplay path.
+ (add-hook 'emms-player-started-hook #'cj/music--start-bar-timer)
+ (add-hook 'emms-player-started-hook #'cj/music--prewarm-art)
+ (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. 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)
@@ -960,9 +1645,9 @@ For URL tracks: decoded URL."
("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)
- ("S" . cj/music-playlist-save)
;; Track reordering
("S-<up>" . emms-playlist-mode-shift-track-up)
("S-<down>" . emms-playlist-mode-shift-track-down)
@@ -978,7 +1663,10 @@ For URL tracks: decoded URL."
;;; Radio station creation
(defun cj/music-create-radio-station (name url)
- "Create a radio station M3U playlist with NAME and URL in cj/music-m3u-root."
+ "Queue and play a radio station from a hand-entered NAME and URL.
+The station becomes a url track in the playlist (NAME as its title) and
+playback starts. Nothing is written to disk — save the queue with the normal
+playlist save, where NAME pre-fills the prompt."
(interactive
(list (read-string "Radio station name: ")
(read-string "Stream URL: ")))
@@ -986,20 +1674,442 @@ For URL tracks: decoded URL."
(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 (cj/confirm-strong (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))))
-
-;; Bound here rather than in the emms `:bind' so use-package does not emit a
-;; redundant autoload that collides with this same-file definition.
+ (cj/emms--setup)
+ (cj/music-radio--enqueue-and-play
+ (list (cj/music-radio--station-track (list :name name :url url))))
+ (message "Queued radio station: %s" name))
+
+;; The manual name+URL creator is bound to m in the radio row below (see the
+;; with-eval-after-load block near the radio-browser lookup), not R.
+
+;; --------------------------- Radio-browser Lookup ----------------------------
+;; Search radio-browser.info and queue a selection as playing url tracks, each
+;; carrying its station metadata as track properties. Nothing is written at
+;; pick time; the playlist save writes the metadata back out as .m3u comment
+;; lines. Spec: docs/specs/2026-07-06-radio-browser-lookup-spec.org. The
+;; pure pieces (parse / track-build / format) carry the tests; the network GET
+;; and the interactive command are exercised live.
+
+(require 'url)
+
+(defvar cj/music-radio-server "de1.api.radio-browser.info"
+ "Default radio-browser API host.
+On a connection failure the client falls back to a host from /json/servers.")
+
+(defvar cj/music-radio-user-agent "cj-emacs-music/1.0 (radio-browser lookup)"
+ "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.")
+
+(defvar cj/music-radio-save-dir (expand-file-name "~/.local/share/mpd/playlists/")
+ "Directory radio playlists are saved to (the radio home).
+The playlist save targets it when every track in the queue is a stream.")
+
+(defun cj/music-radio--parse-search (json-text)
+ "Parse a radio-browser JSON-TEXT array into a list of station plists.
+Signals a `user-error' rather than a raw parse error when JSON-TEXT is not
+JSON (a gateway HTML page or a rate-limit notice), so a bad response reads as a
+clear message instead of a stack trace."
+ (condition-case nil
+ (json-parse-string json-text :object-type 'plist :array-type 'list :null-object nil)
+ (error (user-error "radio-browser returned an unreadable response"))))
+
+(defun cj/music-radio--station-url (st)
+ "Best stream URL for station ST: url_resolved, then url, then nil."
+ (let ((r (plist-get st :url_resolved))
+ (u (plist-get st :url)))
+ (cond ((and (stringp r) (not (string-empty-p r))) r)
+ ((and (stringp u) (not (string-empty-p u))) u))))
+
+(defun cj/music-radio--station-track (st)
+ "Return an EMMS url track for station ST, or nil when it has no stream URL.
+The track carries the station name as `info-title' plus `radio-uuid' and
+`radio-favicon' properties, so display names and cover art need no .m3u on
+disk; the playlist save writes the same metadata back out as comment lines.
+Newlines in the external name/favicon are flattened so they can't inject
+extra .m3u lines at save time."
+ (when-let ((url (cj/music-radio--station-url st)))
+ (let ((track (emms-track 'url url))
+ (name (replace-regexp-in-string "[\r\n]+" " "
+ (or (plist-get st :name) "Radio")))
+ (uuid (plist-get st :stationuuid))
+ (favicon (plist-get st :favicon)))
+ (emms-track-set track 'info-title name)
+ (when (and (stringp uuid) (not (string-empty-p uuid)))
+ (emms-track-set track 'radio-uuid uuid))
+ (when (and (stringp favicon) (not (string-empty-p favicon)))
+ (emms-track-set track 'radio-favicon
+ (replace-regexp-in-string "[\r\n]+" " " favicon)))
+ track)))
+
+(defun cj/music-radio--enqueue-and-play (tracks)
+ "Append TRACKS to the playlist buffer and play the first of them.
+Interrupts whatever is playing; the rest of the queue is left in place. A nil
+TRACKS is a no-op."
+ (when tracks
+ (cj/emms--setup)
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (let ((first-pos nil))
+ (save-excursion
+ (dolist (tr tracks)
+ (goto-char (point-max))
+ (unless first-pos (setq first-pos (point)))
+ (emms-playlist-insert-track tr)))
+ (emms-playlist-select first-pos)))
+ (when emms-player-playing-p (emms-stop))
+ (emms-start)))
+
+(defun cj/music-radio--tags-snippet (tags n)
+ "Return the first N comma-separated TAGS as a trimmed display string.
+TAGS is a comma-separated string or nil; nil or empty yields the empty string."
+ (if (and (stringp tags) (not (string-empty-p tags)))
+ (string-join (seq-take (split-string tags "," t "[ \t]*") n) ", ")
+ ""))
+
+(defun cj/music-radio--format-candidate (st)
+ "Marginalia annotation for station ST.
+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 %-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.
+FIELD is the search field: \"name\" (default) or \"tag\"."
+ (format "https://%s/json/stations/search?%s=%s&limit=%d&hidebroken=true&order=votes&reverse=true"
+ server (or field "name") (url-hexify-string query) cj/music-radio-search-limit))
+
+(defun cj/music-radio--http-get (url)
+ "GET URL with the radio-browser User-Agent; return the response body or nil."
+ (let ((url-request-extra-headers `(("User-Agent" . ,cj/music-radio-user-agent))))
+ (when-let* ((buf (url-retrieve-synchronously url t t 15)))
+ (with-current-buffer buf
+ (goto-char (point-min))
+ (prog1 (when (re-search-forward "\n\n" nil t)
+ (buffer-substring-no-properties (point) (point-max)))
+ (kill-buffer buf))))))
+
+(defun cj/music-radio--search-fallback (query &optional field)
+ "Fetch an alternate radio-browser host and retry the QUERY/FIELD search once."
+ (when-let* ((body (cj/music-radio--http-get
+ "https://all.api.radio-browser.info/json/servers"))
+ (servers (cj/music-radio--parse-search body))
+ (host (plist-get (car servers) :name)))
+ (cj/music-radio--http-get (cj/music-radio--search-url host query field))))
+
+(defun cj/music-radio--search (query &optional field)
+ "Search radio-browser for QUERY on FIELD; return a list of station plists.
+FIELD is \"name\" (default) or \"tag\". Tries `cj/music-radio-server' first,
+then falls back to a host from /json/servers once. Signals a `user-error' when
+nothing responds."
+ (let ((body (or (ignore-errors
+ (cj/music-radio--http-get
+ (cj/music-radio--search-url cj/music-radio-server query field)))
+ (ignore-errors (cj/music-radio--search-fallback query field)))))
+ (unless body (user-error "radio-browser: no response (network down?)"))
+ (cj/music-radio--parse-search body)))
+
+(defun cj/music-radio--candidates (stations)
+ "Return an alist of (DISPLAY . STATION) for STATIONS with unique display keys.
+DISPLAY is the station name; a repeated name gets its codec/bitrate appended,
+then a numeric suffix, so completing-read keys never collide and each maps back
+to one station. Pure helper."
+ (let ((seen (make-hash-table :test 'equal))
+ (out '()))
+ (dolist (st stations (nreverse out))
+ (let* ((name (string-trim (or (plist-get st :name) "(unnamed)")))
+ (disp name)
+ (n 2))
+ (when (gethash disp seen)
+ (setq disp (format "%s (%s%s)" name (or (plist-get st :codec) "")
+ (let ((b (plist-get st :bitrate)))
+ (if (and (integerp b) (> b 0)) (format " %dk" b) "")))))
+ (while (gethash disp seen)
+ (setq disp (format "%s #%d" name n))
+ (setq n (1+ n)))
+ (puthash disp t seen)
+ (push (cons disp st) out)))))
+
+(defun cj/music-radio--completion-table (candidates)
+ "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)
+ (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)
+ "Repeatedly prompt to pick from CANDIDATES until \"[done]\" is chosen.
+CANDIDATES is a (DISPLAY . STATION) alist. Returns the chosen station plists in
+selection order; each pick is removed from the pool so it can't be chosen twice."
+ (let ((pool (copy-sequence candidates))
+ (chosen '())
+ (done nil))
+ (while (and (not done) pool)
+ (let ((pick (completing-read
+ (format "Add station (%d picked, RET [done] to finish): "
+ (length chosen))
+ (cj/music-radio--completion-table (cons '("[done]") pool))
+ nil t)))
+ (if (equal pick "[done]")
+ (setq done t)
+ (when-let ((cell (assoc pick pool)))
+ (push (cdr cell) chosen)
+ (setq pool (delq cell pool))))))
+ (nreverse chosen)))
+
+(defun cj/music-radio--search-and-play (query field)
+ "Search radio-browser for QUERY on FIELD, pick stations, then queue and play.
+FIELD is \"name\" or \"tag\". Lists matching stations (annotated with codec,
+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. 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))
+ (candidates (cj/music-radio--candidates stations)))
+ (unless candidates
+ (user-error "No stations found for %s %S" field query))
+ (let ((chosen (cj/music-radio--pick-loop candidates)))
+ (unless chosen
+ (user-error "No stations selected"))
+ (let ((tracks (delq nil (mapcar #'cj/music-radio--station-track chosen)))
+ (skipped (cl-loop for st in chosen
+ unless (cj/music-radio--station-url st)
+ collect (or (plist-get st :name) "(unnamed)"))))
+ (cj/music-radio--enqueue-and-play tracks)
+ (message "Queued %d station%s%s%s"
+ (length tracks)
+ (if (= (length tracks) 1) "" "s")
+ (if skipped
+ (format ", skipped %d with no URL (%s)"
+ (length skipped) (string-join skipped ", "))
+ "")
+ (if tracks
+ (format " — playing %s"
+ (emms-track-get (car tracks) 'info-title))
+ ""))))))
+
+(defun cj/music-radio-search-by-name (query)
+ "Search radio-browser.info by station name, then queue and play a selection."
+ (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.
+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 -----------------------------------
+;; A track maps to a local cover-image path: a cached favicon/album art, or a
+;; shipped vinyl placeholder. `cj/music-art--for-track' is non-blocking (it
+;; reads only the cache) so the row renderer can call it during redisplay;
+;; `cj/music-art--ensure' does the network fetch off the render path. The pure
+;; pieces (cache key, favicon URL, image validation) carry the tests; the fetch
+;; is a live smoke test. Consumed by the Phase 3 fancy render.
+
+(require 'image)
+
+(defvar cj/music-art-cache-dir
+ (expand-file-name "music-art/" (expand-file-name "data/" user-emacs-directory))
+ "Directory holding fetched or extracted cover art, keyed by station UUID or a
+file hash. Gitignored runtime state; `cj/music-clear-art-cache' empties it.")
+
+(defvar cj/music-art-placeholder
+ (expand-file-name "vinyl-placeholder.svg"
+ (expand-file-name "assets/" user-emacs-directory))
+ "Shipped vinyl-record placeholder shown when a track has no cover art.")
+
+(defun cj/music-art--cache-key (track &optional entries)
+ "Stable cache-file basename (no extension) for TRACK.
+A url with a station uuid — the track's `radio-uuid' property, else a
+#RADIOBROWSERUUID in ENTRIES — keys on the uuid so a station shares one cached
+logo; any other url keys on a hash of its address; a file keys on a hash of
+its path."
+ (let ((name (emms-track-name track)))
+ (if (eq (emms-track-type track) 'url)
+ (let ((uuid (or (emms-track-get track 'radio-uuid)
+ (plist-get (cdr (assoc name entries)) :uuid))))
+ (if (and (stringp uuid) (not (string-empty-p uuid)))
+ uuid
+ (concat "url-" (sha1 name))))
+ (concat "file-" (sha1 name)))))
+
+(defun cj/music-art--favicon-url (track &optional entries)
+ "Direct favicon image URL for a url TRACK, or nil.
+The track's `radio-favicon' property wins, then its captured
+#RADIOBROWSERFAVICON from ENTRIES. A station with only a uuid resolves via a
+byuuid lookup elsewhere; a file track has no favicon URL."
+ (when (eq (emms-track-type track) 'url)
+ (let ((fav (or (emms-track-get track 'radio-favicon)
+ (plist-get (cdr (assoc (emms-track-name track) entries))
+ :favicon))))
+ (and (stringp fav) (not (string-empty-p fav)) fav))))
+
+(defun cj/music-art--valid-image-p (data)
+ "Non-nil when DATA looks like a displayable image (a recognizable image
+header), so an empty body, an HTML error page, or a text response is rejected
+before it is cached."
+ (and (stringp data) (not (string-empty-p data))
+ (image-type-from-data data) t))
+
+(defun cj/music-art--cached-file (key)
+ "Return an existing cached art file for KEY (any extension), or nil."
+ (car (file-expand-wildcards
+ (expand-file-name (concat key ".*") cj/music-art-cache-dir))))
+
+(defun cj/music-art--file-cover (track)
+ "Return a sibling cover image (cover/folder/front .jpg/.jpeg/.png) next to a
+file TRACK, or nil. Embedded-tag art extraction is deferred (vNext)."
+ (when (eq (emms-track-type track) 'file)
+ (when-let ((dir (file-name-directory (emms-track-name track))))
+ (cl-loop for base in '("cover" "folder" "front")
+ thereis (cl-loop for ext in '("jpg" "jpeg" "png")
+ for f = (expand-file-name (concat base "." ext) dir)
+ when (file-exists-p f) return f)))))
+
+(defun cj/music-art--fetch-to-cache (url key)
+ "Fetch URL and, if it is a valid image, write it into the art cache under KEY.
+Returns the cached path, or nil on a failed or non-image response. Blocks on
+the network, so call it off the redisplay path. Only http/https URLs are
+fetched, so an external favicon field can't point the reader at a file:// or
+other-scheme resource."
+ (when-let* (((string-match-p "\\`https?://" url))
+ (data (cj/music-radio--http-get url))
+ ((cj/music-art--valid-image-p data)))
+ (make-directory cj/music-art-cache-dir t)
+ (let ((path (expand-file-name
+ (concat key "." (symbol-name (image-type-from-data data)))
+ cj/music-art-cache-dir))
+ (coding-system-for-write 'binary))
+ (with-temp-file path
+ (set-buffer-multibyte nil)
+ (insert data))
+ path)))
+
+(defun cj/music-art--byuuid-favicon (uuid)
+ "Look up station UUID via radio-browser byuuid and return its favicon URL, or
+nil. The fallback for a legacy station that carries a uuid but no captured
+favicon. Blocks on the network."
+ (when-let* ((body (cj/music-radio--http-get
+ (format "https://%s/json/stations/byuuid/%s"
+ cj/music-radio-server uuid)))
+ (stations (ignore-errors (cj/music-radio--parse-search body)))
+ (fav (plist-get (car stations) :favicon)))
+ (and (stringp fav) (not (string-empty-p fav)) fav)))
+
+(defun cj/music-art--for-track (track)
+ "Local cover-art path for TRACK, WITHOUT any network: an already-cached file,
+a sibling cover for a local file, else the vinyl placeholder. Never blocks, so
+the row renderer can call it during redisplay; `cj/music-art--ensure' does the
+fetch off the render path."
+ (let ((key (cj/music-art--cache-key track (cj/music--radio-metadata))))
+ (or (cj/music-art--cached-file key)
+ (cj/music-art--file-cover track)
+ cj/music-art-placeholder)))
+
+(defun cj/music-art--ensure (track)
+ "Fetch and cache TRACK's cover art if it is not cached yet. Blocks on the
+network, so call it off the redisplay path. Returns the cached path, or nil
+when there is nothing to fetch."
+ (let* ((entries (cj/music--radio-metadata))
+ (key (cj/music-art--cache-key track entries)))
+ (unless (cj/music-art--cached-file key)
+ (when (eq (emms-track-type track) 'url)
+ (let ((fav (or (cj/music-art--favicon-url track entries)
+ (let ((uuid (or (emms-track-get track 'radio-uuid)
+ (plist-get (cdr (assoc (emms-track-name track)
+ entries))
+ :uuid))))
+ (and (stringp uuid) (not (string-empty-p uuid))
+ (cj/music-art--byuuid-favicon uuid))))))
+ (and fav (cj/music-art--fetch-to-cache fav key)))))))
+
+(defun cj/music-clear-art-cache ()
+ "Delete every cached cover-art file so art is re-fetched on next need."
+ (interactive)
+ (when (file-directory-p cj/music-art-cache-dir)
+ (dolist (f (directory-files cj/music-art-cache-dir t "\\`[^.]"))
+ (delete-file f)))
+ (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. 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 "R" #'cj/music-create-radio-station))
+ (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 "1" #'emms-toggle-repeat-track)
+ (keymap-set emms-playlist-mode-map "s" #'cj/music-playlist-save))
(provide 'music-config)
;;; music-config.el ends here