diff options
Diffstat (limited to 'modules/music-config.el')
| -rw-r--r-- | modules/music-config.el | 1474 |
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 |
