aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-06 16:56:40 -0500
committerCraig Jennings <c@cjennings.net>2026-07-06 16:56:40 -0500
commit0a7afa70fa9c2c20771696b303c9cae4aa7c58a2 (patch)
tree8d97a9f3b8cba2ad97d808114e9a00edce903711 /modules
parentea8bbce911650515b88fd3a61fdb88186e550347 (diff)
downloaddotemacs-0a7afa70fa9c2c20771696b303c9cae4aa7c58a2.tar.gz
dotemacs-0a7afa70fa9c2c20771696b303c9cae4aa7c58a2.zip
feat(music): show station and track names, not stream URLs (fancy UI phase 1)
The EMMS playlist showed the raw stream URL for every radio track, so the buffer read like a debug log. This is phase 1 of the fancy player: real names, still plain text. It ships on its own and becomes the fallback for the image and GUI phases. I split the old cj/music--track-description into a pure, name-only cj/music--display-name that the header's Current line and the row renderer both call, so the two never drift. A url track resolves to its #EXTINF label (read once from all the playlist .m3u files into a cached map) or a tidied host. A file shows Artist - Title or its filename. The row renderer adds a dim nerd-icon glyph and the duration as right-aligned meta with an :align-to space, so it survives a window resize. The header line stays clean: just the name plus an on-air or duration suffix. The pure helpers carry the tests: display-name, tidy-host, m3u-labels, bar-fill, format-meta, Normal/Boundary/Error each (32 tests across four files). The glyph, the aligned meta, and the disk-backed name map are the thin composition, verified live against 44 real stations. The progress bar's visual and its live elapsed source come in phase 3. The bar-fill helper is the tested pure core it will feed. The full suite is green.
Diffstat (limited to 'modules')
-rw-r--r--modules/music-config.el147
1 files changed, 124 insertions, 23 deletions
diff --git a/modules/music-config.el b/modules/music-config.el
index 22b8aa4f..109f496c 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -796,34 +796,125 @@ 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-labels (text)
+ "Parse M3U TEXT into an alist of (STREAM-URL . #EXTINF-LABEL).
+A url line takes the label from the most recent #EXTINF; a url with no
+preceding #EXTINF is skipped, and other comment lines (a #RADIOBROWSERUUID)
+between the two are ignored."
+ (let ((pending nil) (pairs '()))
+ (dolist (line (split-string text "[\r\n]+" t) (nreverse pairs))
+ (cond
+ ((string-match "\\`#EXTINF:[^,]*,\\(.*\\)\\'" line)
+ (setq pending (match-string 1 line)))
+ ((string-prefix-p "#" line)) ; other comment — keep PENDING
+ (t (when pending (push (cons line pending) pairs))
+ (setq pending nil))))))
+
+(defvar cj/music--radio-name-map-cache nil
+ "Cached url->#EXTINF-label alist 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-name-map ()
+ "Alist of stream-url -> station label, unioned across all playlist roots.
+Reads each .m3u once and caches the result."
+ (or cj/music--radio-name-map-cache
+ (setq cj/music--radio-name-map-cache
+ (cl-loop for (_base . path) in (cj/music--get-m3u-files)
+ append (cj/music--m3u-labels
+ (with-temp-buffer
+ (insert-file-contents path)
+ (buffer-string)))))))
+
+(defun cj/music--refresh-radio-name-map ()
+ "Clear the cached radio name-map so the next render rebuilds it."
+ (setq cj/music--radio-name-map-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 dim type glyph, the display name, and the meta
+right-aligned to the window edge with a resize-safe :align-to space.
+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)))
+ (if (string-empty-p meta)
+ (concat glyph " " name)
+ (concat glyph " " name
+ (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) ""))))
+
;; Multi-line header overlay
(defvar-local cj/music--header-overlay nil
"Overlay displaying the playlist header.")
@@ -840,7 +931,9 @@ For URL tracks: decoded URL."
(emms-player-paused-p "Paused")
(t (let ((track (emms-playlist-current-selected-track)))
(if track
- (cj/music--track-description 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)
@@ -874,6 +967,11 @@ For URL tracks: decoded URL."
(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).
+ (propertize " " 'face '(:strike-through t :inherit shadow)
+ 'display '(space :align-to right))
"\n\n")))
(defun cj/music--update-header ()
@@ -949,7 +1047,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)
@@ -1028,6 +1126,7 @@ For URL tracks: decoded URL."
(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))))
;; The manual name+URL creator is bound to m in the radio row below (see the
@@ -1195,6 +1294,8 @@ NAMES). Creates DIR when absent."
(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)