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.el719
1 files changed, 483 insertions, 236 deletions
diff --git a/modules/music-config.el b/modules/music-config.el
index ea66df8e..3559930b 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,44 @@
(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 (boundp 'cj/nov-reading-font-family) cj/nov-reading-font-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 +107,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")
@@ -179,6 +227,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)
@@ -279,18 +351,22 @@ modification date so marginalia can show them."
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,
@@ -317,10 +393,6 @@ collision the earlier directory wins."
(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
@@ -467,23 +539,103 @@ 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)))))
+
+(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)))
@@ -520,8 +672,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)
@@ -624,40 +777,22 @@ Intended for use on `emms-player-finished-hook'."
(defvar cj/music-playlist-window-height 0.3
"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'.")
+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'.")
(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)
@@ -666,28 +801,15 @@ 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)
(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)
@@ -744,19 +866,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)
@@ -766,16 +894,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"))
@@ -913,15 +1044,25 @@ fontless frame never shows a tofu box."
(if stream "»" "•"))))
(defun cj/music--row-string (track)
- "Playlist row for TRACK: a dim type glyph, the display name, and the meta
-right-aligned to the window edge with a resize-safe :align-to space.
+ "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)))
- (glyph (cj/music--type-glyph track))
- (meta (cj/music--format-meta track)))
+ (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 glyph " " name)
- (concat glyph " " name
+ (concat lead " " label)
+ (concat lead " " label
(propertize " " 'display
`(space :align-to (- right ,(1+ (length meta)))))
(propertize meta 'face 'cj/music-keyhint-face)))))
@@ -934,65 +1075,148 @@ duration for a timed file, empty otherwise."
(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
- (concat (cj/music--display-name
- track (cj/music--radio-name-map))
- (cj/music--now-playing-suffix 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 "s" "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 "s" "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:stop SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
- 'face 'cj/music-keyhint-face)
- "\n"
+ (propertize "a:add c:clear L:load v:save S:stop 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"
- ;; A full-width rule under the header, resize-safe (redisplay recomputes
- ;; the :align-to span rather than a hardcoded character count).
+ '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--update-header ()
"Insert or update the multi-line header overlay in the playlist buffer."
(when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
@@ -1004,6 +1228,43 @@ duration for a timed file, empty otherwise."
(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.")
@@ -1076,6 +1337,13 @@ duration for a timed file, empty otherwise."
(add-hook 'emms-player-finished-hook #'cj/music--update-header)
(add-hook 'emms-playlist-cleared-hook #'cj/music--update-header)
+ ;; 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
(dolist (fn '(emms-toggle-repeat-playlist
emms-toggle-repeat-track
@@ -1113,7 +1381,7 @@ duration for a timed file, empty otherwise."
("L" . cj/music-playlist-load)
("E" . cj/music-playlist-edit)
("g" . cj/music-playlist-reload)
- ("S" . cj/music-playlist-save)
+ ("v" . cj/music-playlist-save)
;; Track reordering
("S-<up>" . emms-playlist-mode-shift-track-up)
("S-<down>" . emms-playlist-mode-shift-track-down)
@@ -1129,7 +1397,10 @@ duration for a timed file, empty otherwise."
;;; 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: ")))
@@ -1137,25 +1408,21 @@ duration for a timed file, empty otherwise."
(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))
- (cj/music--refresh-radio-name-map)
- (message "Created radio station: %s" (file-name-nondirectory file))))
+ (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 turn a selection into a playable radio .m3u.
-;; Spec: docs/specs/2026-07-06-radio-browser-lookup-spec.org. The pure pieces
-;; (parse / emit / format / filename) carry the tests; the network GET and the
-;; interactive command are exercised live.
+;; 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)
@@ -1171,11 +1438,8 @@ The project asks clients to identify themselves.")
"Maximum number of stations a radio-browser search returns.")
(defvar cj/music-radio-save-dir (expand-file-name "~/.local/share/mpd/playlists/")
- "Directory new radio-browser stations are written to (the radio home).")
-
-(defvar cj/music-radio-filename-suffix "-Radio"
- "Suffix appended to a created station's basename, before the .m3u extension.
-Marks a lookup-created station in the playlist directory.")
+ "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.
@@ -1193,29 +1457,43 @@ clear message instead of a stack trace."
(cond ((and (stringp r) (not (string-empty-p r))) r)
((and (stringp u) (not (string-empty-p u))) u))))
-(defun cj/music-radio--station-m3u (st)
- "Return the .m3u file text for station ST, or nil when it has no stream URL.
-Matches the existing radio files: #EXTM3U, an optional #RADIOBROWSERUUID line,
-#EXTINF:1,<name>, and the stream URL."
- (let* ((url (cj/music-radio--station-url st))
- ;; Strip newlines so an unexpected multi-line name can't inject extra
- ;; m3u lines; the API returns single-line names, but it's external data.
- (name (replace-regexp-in-string "[\r\n]+" " "
- (or (plist-get st :name) "Radio")))
- (uuid (plist-get st :stationuuid))
- (favicon (plist-get st :favicon)))
- (when url
- (concat "#EXTM3U\n"
- (if (and (stringp uuid) (not (string-empty-p uuid)))
- (format "#RADIOBROWSERUUID:%s\n" uuid)
- "")
- ;; Capture the favicon at creation so the cover-art layer needs
- ;; no byuuid lookup later; same newline-strip guard as the name.
- (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%s\n" name url)))))
+(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.
@@ -1235,15 +1513,6 @@ Variant B: codec, bitrate, country, votes, and the first few tags."
(tags (cj/music-radio--tags-snippet (plist-get st :tags) 3)))
(format "%-4s %-5s %-2s ♥%d %s" codec bitrate cc votes tags)))
-(defun cj/music-radio--disambiguate-name (name uuid taken)
- "Return a filesystem-safe basename for NAME, unique against the TAKEN list.
-On a collision, append a short fragment of UUID. Pure helper: keeps two
-same-named stations picked in one search from overwriting each other."
- (let ((base (cj/music--safe-filename name)))
- (if (member base taken)
- (concat base "_" (substring (or uuid "x") 0 (min 8 (length (or uuid "x")))))
- base)))
-
(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\"."
@@ -1301,29 +1570,6 @@ to one station. Pure helper."
(puthash disp t seen)
(push (cons disp st) out)))))
-(defun cj/music-radio--write-stations (stations dir)
- "Write each station in STATIONS as an .m3u into DIR.
-Skips a station with no stream URL and disambiguates a filename that collides
-with an already-written one this run. Returns a plist (:written PATHS :skipped
-NAMES). Creates DIR when absent."
- (make-directory dir t)
- (let ((taken '()) (written '()) (skipped '()))
- (dolist (st stations)
- (let ((m3u (cj/music-radio--station-m3u st)))
- (if (not m3u)
- (push (or (plist-get st :name) "(unnamed)") skipped)
- (let* ((base (cj/music-radio--disambiguate-name
- (or (plist-get st :name) "Radio")
- (plist-get st :stationuuid) taken))
- (path (expand-file-name
- (concat base cj/music-radio-filename-suffix ".m3u") dir)))
- (push base taken)
- (with-temp-file path (insert m3u))
- (push path written)))))
- ;; New .m3u files landed, so the cached url->label map is stale.
- (when written (cj/music--refresh-radio-name-map))
- (list :written (nreverse written) :skipped (nreverse skipped))))
-
(defun cj/music-radio--completion-table (candidates)
"Completion table over CANDIDATES carrying the Variant-B marginalia affix."
(lambda (string pred action)
@@ -1364,21 +1610,17 @@ selection order; each pick is removed from the pool so it can't be chosen twice.
(setq pool (delq cell pool))))))
(nreverse chosen)))
-(defun cj/music-radio--play (paths)
- "Play the first .m3u in PATHS immediately (interrupting), enqueue the rest."
- (when paths
- (emms-play-playlist (car paths))
- (dolist (p (cdr paths))
- (emms-add-playlist p))))
-
(defun cj/music-radio--search-and-play (query field)
- "Search radio-browser for QUERY on FIELD, pick stations, then create and play.
+ "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, writes
-each as an .m3u into `cj/music-radio-save-dir', then plays the selection through
-mpv (interrupting whatever was playing)."
+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))
(user-error "Empty search"))
+ (cj/emms--setup)
(let* ((stations (cj/music-radio--search query field))
(candidates (cj/music-radio--candidates stations)))
(unless candidates
@@ -1386,31 +1628,30 @@ mpv (interrupting whatever was playing)."
(let ((chosen (cj/music-radio--pick-loop candidates)))
(unless chosen
(user-error "No stations selected"))
- (let* ((result (cj/music-radio--write-stations chosen cj/music-radio-save-dir))
- (written (plist-get result :written))
- (skipped (plist-get result :skipped)))
- (when written
- (cj/music-radio--play written))
- (message "Created %d station%s%s%s"
- (length written)
- (if (= (length written) 1) "" "s")
+ (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 written
+ (if tracks
(format " — playing %s"
- (file-name-sans-extension
- (file-name-nondirectory (car written))))
+ (emms-track-get (car tracks) 'info-title))
""))))))
(defun cj/music-radio-search-by-name (query)
- "Search radio-browser.info by station name, then create and play a selection."
+ "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-search-by-tag (tag)
- "Search radio-browser.info by tag/genre, then create and play a selection."
+ "Search radio-browser.info by tag/genre, then queue and play a selection."
(interactive "sRadio search (tag): ")
(cj/music-radio--search-and-play tag "tag"))
@@ -1436,23 +1677,28 @@ file hash. Gitignored runtime state; `cj/music-clear-art-cache' empties it.")
(defun cj/music-art--cache-key (track &optional entries)
"Stable cache-file basename (no extension) for TRACK.
-A url with 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."
+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 (plist-get (cdr (assoc name entries)) :uuid)))
+ (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 from its captured
-#RADIOBROWSERFAVICON, or nil. A station with only a uuid resolves via a byuuid
-lookup elsewhere; a file track has no favicon URL."
+ "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 (plist-get (cdr (assoc (emms-track-name track) entries)) :favicon)))
+ (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)
@@ -1526,9 +1772,10 @@ when there is nothing to fetch."
(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 (plist-get (cdr (assoc (emms-track-name track)
- entries))
- :uuid)))
+ (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)))))))