summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid Moc <personal@cdatgoose.org>2026-05-08 00:20:57 +0200
committerDavid Moc <personal@cdatgoose.org>2026-05-08 00:20:57 +0200
commitafae6363427c56162bde0678ee49d4bd341bbb34 (patch)
tree310e35686577ac673471881e6325c8de0ebb6031
parent843510e3acd89622b3dc15dec0630ba7dec94c12 (diff)
Add responsive player visualizer layoutHEADmaster
-rwxr-xr-xnaviel.el644
1 files changed, 618 insertions, 26 deletions
diff --git a/naviel.el b/naviel.el
index 4289d03..33bd4e3 100755
--- a/naviel.el
+++ b/naviel.el
@@ -74,6 +74,38 @@
(defcustom naviel-player-cover-size 180
"Pixel size for cover art in the player buffer."
:type 'integer :group 'naviel)
+(defcustom naviel-player-visualizer t
+ "Non-nil to show a CAVA-like visualizer in the player buffer."
+ :type 'boolean :group 'naviel)
+(defcustom naviel-player-visualizer-bars 32
+ "Maximum number of bars shown in the player buffer visualizer."
+ :type 'integer :group 'naviel)
+(defcustom naviel-player-visualizer-style 'default
+ "Style used by the player buffer visualizer."
+ :type '(choice (const :tag "Default" default)
+ (const :tag "Compact" compact)
+ (const :tag "Wide" wide)
+ (const :tag "Monochrome" monochrome)
+ (const :tag "High contrast" high-contrast))
+ :group 'naviel)
+(defcustom naviel-player-visualizer-backend 'auto
+ "Visualizer backend.
+`auto' uses CAVA when available and falls back to the built-in animation.
+`cava' only uses CAVA when it can be started. `simulated' never starts CAVA."
+ :type '(choice (const auto) (const simulated) (const cava))
+ :group 'naviel)
+(defcustom naviel-cava-executable "cava"
+ "Path to the cava executable used by the player visualizer."
+ :type 'string :group 'naviel)
+(defcustom naviel-player-queue-preview-size 3
+ "Number of upcoming songs shown in the player buffer."
+ :type 'integer :group 'naviel)
+(defcustom naviel-player-dashboard-width 120
+ "Maximum content width for the single-window player dashboard."
+ :type 'integer :group 'naviel)
+(defcustom naviel-player-progress-width 100
+ "Maximum width of the player progress bar."
+ :type 'integer :group 'naviel)
(defcustom naviel-browser-cover-size 80
"Pixel size for album cover art in the browser expand view."
:type 'integer :group 'naviel)
@@ -116,6 +148,12 @@
'((t :inherit success)) "Progress bar fill.")
(defface naviel-progress-empty-face
'((t :inherit shadow)) "Progress bar empty.")
+(defface naviel-visualizer-low-face
+ '((t :inherit shadow)) "Low bars in the player visualizer.")
+(defface naviel-visualizer-mid-face
+ '((t :inherit font-lock-string-face)) "Mid bars in the player visualizer.")
+(defface naviel-visualizer-high-face
+ '((t :inherit success :weight bold)) "High bars in the player visualizer.")
(defface naviel-starred-face
'((t :inherit font-lock-warning-face)) "Starred indicator.")
(defface naviel-radio-face
@@ -147,6 +185,29 @@
(defun naviel--repeat-label ()
(pcase naviel-repeat-mode ('one "one") ('album "all") (_ "off")))
+(defun naviel-toggle-visualizer ()
+ "Toggle the player buffer visualizer."
+ (interactive)
+ (setq naviel-player-visualizer (not naviel-player-visualizer))
+ (if naviel-player-visualizer
+ (naviel--maybe-start-cava)
+ (naviel--stop-cava))
+ (naviel--player-render)
+ (message "Visualizer: %s" (if naviel-player-visualizer "on" "off")))
+
+(defun naviel-cycle-visualizer-style ()
+ "Cycle player visualizer styles."
+ (interactive)
+ (setq naviel-player-visualizer-style
+ (pcase naviel-player-visualizer-style
+ ('default 'compact)
+ ('compact 'wide)
+ ('wide 'monochrome)
+ ('monochrome 'high-contrast)
+ (_ 'default)))
+ (naviel--player-render)
+ (message "Visualizer style: %s" naviel-player-visualizer-style))
+
;;; Internal state
(defvar naviel--process nil)
@@ -165,6 +226,12 @@
(defvar naviel--browser-stack '())
(defvar naviel--current-view nil)
(defvar naviel--mode-line-timer nil)
+(defvar naviel--cover-cache (make-hash-table :test 'equal))
+(defvar naviel--cava-process nil)
+(defvar naviel--cava-buffer "")
+(defvar naviel--cava-config-file nil)
+(defvar naviel--cava-levels nil)
+(defvar naviel--cava-bars nil)
;; Star cache
(defvar naviel--starred-ids (make-hash-table :test 'equal))
@@ -309,11 +376,39 @@
(let ((data (buffer-substring-no-properties (point) (point-max))))
(ignore-errors (kill-buffer (current-buffer)))
(with-temp-file tmp (set-buffer-multibyte nil) (insert data))
- (naviel--do-insert-image tmp buffer marker-regex)))
+ (naviel--do-insert-image tmp buffer marker-regex size)))
(ignore-errors (kill-buffer (current-buffer))))
nil t))))
-(defun naviel--do-insert-image (path buffer marker-regex)
+(defun naviel--cover-cache-key (cover-id size)
+ (format "%s:%s" cover-id size))
+
+(defun naviel--fetch-cover-to-cache (cover-id size callback)
+ "Fetch COVER-ID at SIZE, cache it, then call CALLBACK with the image path."
+ (when (and naviel-cover-art-display cover-id (not (string= cover-id "")))
+ (let* ((key (naviel--cover-cache-key cover-id size))
+ (cached (gethash key naviel--cover-cache)))
+ (if (and cached (file-readable-p cached))
+ (funcall callback cached)
+ (let ((url (naviel--cover-art-url cover-id size))
+ (tmp (make-temp-file "naviel-art" nil ".jpg")))
+ (url-retrieve
+ url
+ (lambda (status)
+ (unwind-protect
+ (unless (plist-get status :error)
+ (goto-char (point-min))
+ (re-search-forward "\r?\n\r?\n" nil t)
+ (let ((data (buffer-substring-no-properties (point) (point-max))))
+ (with-temp-file tmp
+ (set-buffer-multibyte nil)
+ (insert data))
+ (puthash key tmp naviel--cover-cache)
+ (funcall callback tmp)))
+ (ignore-errors (kill-buffer (current-buffer)))))
+ nil t))))))
+
+(defun naviel--do-insert-image (path buffer marker-regex size)
"Insert image at PATH into BUFFER after first match of MARKER-REGEX."
(when (and (display-graphic-p)
(image-type-available-p 'jpeg)
@@ -324,11 +419,43 @@
(goto-char (point-min))
(when (re-search-forward marker-regex nil t)
(forward-line 1)
- (let ((img (create-image path 'jpeg nil :width 180)))
+ (let ((img (create-image path 'jpeg nil :width size)))
(insert " ")
(insert-image img)
(insert "\n"))))))))
+(defun naviel--player-insert-cover (cover-id)
+ "Insert cached player cover art for COVER-ID, fetching asynchronously if needed."
+ (when (and naviel-cover-art-display cover-id (not (string= cover-id ""))
+ (display-graphic-p) (image-type-available-p 'jpeg))
+ (let* ((size (naviel--player-cover-display-size))
+ (key (naviel--cover-cache-key cover-id size))
+ (cached (gethash key naviel--cover-cache)))
+ (cond
+ ((and cached (file-readable-p cached))
+ (insert " ")
+ (insert-image (create-image cached 'jpeg nil :width size))
+ (insert "\n\n"))
+ (t
+ (insert (propertize " [cover loading...]\n\n" 'face 'naviel-separator-face))
+ (naviel--fetch-cover-to-cache
+ cover-id size
+ (lambda (_path) (naviel--player-render))))))))
+
+(defun naviel--player-cover-path (cover-id)
+ "Return cached player cover path for COVER-ID, fetching it if missing."
+ (when (and naviel-cover-art-display cover-id (not (string= cover-id ""))
+ (display-graphic-p) (image-type-available-p 'jpeg))
+ (let* ((size (naviel--player-cover-display-size))
+ (key (naviel--cover-cache-key cover-id size))
+ (cached (gethash key naviel--cover-cache)))
+ (if (and cached (file-readable-p cached))
+ cached
+ (naviel--fetch-cover-to-cache
+ cover-id size
+ (lambda (_path) (naviel--player-render)))
+ nil))))
+
;;; mpv IPC
(defun naviel--ipc-socket-path ()
@@ -383,8 +510,14 @@
(defun naviel--ipc-handle-event (event)
(cond
((string= event "end-file") (naviel--next-in-queue))
- ((string= event "pause") (setq naviel--paused t) (naviel--player-render))
- ((string= event "unpause") (setq naviel--paused nil) (naviel--player-render))))
+ ((string= event "pause")
+ (setq naviel--paused t)
+ (naviel--stop-cava)
+ (naviel--player-render))
+ ((string= event "unpause")
+ (setq naviel--paused nil)
+ (naviel--maybe-start-cava)
+ (naviel--player-render))))
(defun naviel--ipc-send (command &optional callback)
(when (and naviel--ipc-process (process-live-p naviel--ipc-process))
@@ -398,6 +531,107 @@
(process-send-string naviel--ipc-process json)
(error (message "naviel IPC send: %s" (error-message-string err)))))))
+;;; CAVA visualizer backend
+
+(defun naviel--cava-available-p ()
+ (and (memq naviel-player-visualizer-backend '(auto cava))
+ (executable-find naviel-cava-executable)))
+
+(defun naviel--cava-desired-bars ()
+ (let* ((width (naviel--player-window-width))
+ (glyph-width (if (eq naviel-player-visualizer-style 'wide) 2 1)))
+ (max 8 (min 160 (/ (max 20 (- width 4)) glyph-width)))))
+
+(defun naviel--cava-config (bars)
+ (let ((bars (max 8 bars)))
+ (mapconcat
+ #'identity
+ `("[general]"
+ ,(format "bars = %d" bars)
+ "framerate = 20"
+ ""
+ "[input]"
+ "method = pulse"
+ "source = auto"
+ ""
+ "[output]"
+ "method = raw"
+ "raw_target = /dev/stdout"
+ "data_format = ascii"
+ "ascii_max_range = 7"
+ "channels = mono")
+ "\n")))
+
+(defun naviel--maybe-start-cava ()
+ "Start CAVA for the visualizer when configured and available."
+ (when (and naviel-player-visualizer
+ (not naviel--paused)
+ (naviel--cava-available-p)
+ (let ((desired-bars (naviel--cava-desired-bars)))
+ (if (and naviel--cava-process
+ (process-live-p naviel--cava-process))
+ (not (equal desired-bars naviel--cava-bars))
+ t)))
+ (condition-case err
+ (let* ((bars (naviel--cava-desired-bars))
+ (config (make-temp-file "naviel-cava" nil ".conf")))
+ (naviel--stop-cava)
+ (with-temp-file config (insert (naviel--cava-config bars)))
+ (setq naviel--cava-config-file config
+ naviel--cava-bars bars
+ naviel--cava-buffer ""
+ naviel--cava-levels nil
+ naviel--cava-process
+ (start-process "naviel-cava" nil naviel-cava-executable "-p" config))
+ (set-process-filter naviel--cava-process #'naviel--cava-filter)
+ (set-process-sentinel naviel--cava-process #'naviel--cava-sentinel))
+ (error
+ (setq naviel--cava-process nil
+ naviel--cava-levels nil)
+ (when (eq naviel-player-visualizer-backend 'cava)
+ (message "naviel: could not start cava: %s" (error-message-string err)))))))
+
+(defun naviel--stop-cava ()
+ "Stop the optional CAVA visualizer process."
+ (when (and naviel--cava-process (process-live-p naviel--cava-process))
+ (delete-process naviel--cava-process))
+ (setq naviel--cava-process nil
+ naviel--cava-buffer ""
+ naviel--cava-levels nil
+ naviel--cava-bars nil)
+ (when (and naviel--cava-config-file (file-exists-p naviel--cava-config-file))
+ (ignore-errors (delete-file naviel--cava-config-file)))
+ (setq naviel--cava-config-file nil))
+
+(defun naviel--cava-filter (_proc string)
+ (setq naviel--cava-buffer (concat naviel--cava-buffer string))
+ (let ((start 0))
+ (while (string-match "\n" naviel--cava-buffer start)
+ (let* ((end (match-beginning 0))
+ (line (substring naviel--cava-buffer start end)))
+ (setq start (1+ end))
+ (naviel--cava-handle-line line)))
+ (setq naviel--cava-buffer (substring naviel--cava-buffer start))))
+
+(defun naviel--cava-handle-line (line)
+ (let (levels)
+ (dolist (chunk (split-string line "[;[:space:]]+" t))
+ (let ((n (string-to-number chunk)))
+ (push (max 0 (min 7 n)) levels)))
+ (when levels
+ (setq naviel--cava-levels (nreverse levels))
+ (naviel--player-render))))
+
+(defun naviel--cava-sentinel (proc _event)
+ (when (eq proc naviel--cava-process)
+ (setq naviel--cava-process nil
+ naviel--cava-buffer ""
+ naviel--cava-levels nil
+ naviel--cava-bars nil)
+ (when (and naviel--cava-config-file (file-exists-p naviel--cava-config-file))
+ (ignore-errors (delete-file naviel--cava-config-file)))
+ (setq naviel--cava-config-file nil)))
+
;;; Playback position polling
(defun naviel--start-position-timer ()
@@ -426,6 +660,7 @@
(defun naviel--stop ()
(naviel--stop-position-timer)
+ (naviel--stop-cava)
(when (and naviel--ipc-process (process-live-p naviel--ipc-process))
(delete-process naviel--ipc-process))
(setq naviel--ipc-process nil naviel--ipc-buffer "")
@@ -453,6 +688,7 @@
(setq naviel--process proc))
(run-with-timer 0.3 nil #'naviel--ipc-connect)
(run-with-timer 0.6 nil #'naviel--start-position-timer)
+ (run-with-timer 0.8 nil #'naviel--maybe-start-cava)
;; Fetch synced lyrics from lrclib
(naviel--lrclib-fetch song-info)
(naviel--player-render)
@@ -622,6 +858,162 @@
(defun naviel--player-buffer-p ()
(get-buffer "*Naviel Player*"))
+(defun naviel--player-window ()
+ (get-buffer-window (naviel--player-buffer) t))
+
+(defun naviel--player-window-width ()
+ (let ((win (naviel--player-window)))
+ (max 40 (- (if win (window-width win) (frame-width)) 2))))
+
+(defun naviel--player-window-height ()
+ (let ((win (naviel--player-window)))
+ (max 12 (if win (window-height win) (frame-height)))))
+
+(defun naviel--player-single-window-p ()
+ "Return non-nil when the player buffer is the only ordinary window."
+ (let ((win (naviel--player-window)))
+ (and win
+ (= 1 (length (window-list (window-frame win) 'no-minibuf))))))
+
+(defun naviel--player-visualizer-height (window-height)
+ "Return visualizer row count for WINDOW-HEIGHT."
+ (cond
+ ((>= window-height 38) 8)
+ ((>= window-height 30) 6)
+ ((>= window-height 24) 4)
+ ((>= window-height 18) 2)
+ (t 1)))
+
+(defun naviel--player-cover-display-size ()
+ "Return responsive player cover-art size in pixels."
+ (let* ((w (naviel--player-window-width))
+ (h (naviel--player-window-height))
+ (scale (cond
+ ((or (< w 55) (< h 22)) 0.55)
+ ((or (< w 75) (< h 30)) 0.75)
+ ((and (> w 110) (> h 38)) 1.25)
+ (t 1.0))))
+ (max 72 (round (* naviel-player-cover-size scale)))))
+
+(defun naviel--pad-right (s width)
+ "Return S truncated or padded to WIDTH display columns."
+ (let* ((truncated (naviel--trunc s width))
+ (pad (max 0 (- width (string-width truncated)))))
+ (concat truncated (make-string pad ? ))))
+
+(defun naviel--centered-indent (outer-width inner-width)
+ "Return indentation needed to center INNER-WIDTH in OUTER-WIDTH."
+ (make-string (max 2 (/ (max 0 (- outer-width inner-width)) 2)) ? ))
+
+(defun naviel--indent-lines (s indent)
+ "Prefix every non-final line in S with INDENT."
+ (mapconcat (lambda (line) (concat indent line))
+ (split-string (string-remove-suffix "\n" s) "\n")
+ "\n"))
+
+(defun naviel--player-footer-line (width)
+ "Return a footer hint line fitting WIDTH columns."
+ (propertize
+ (concat " " (naviel--trunc "SPC pause n/p skip [ ] seek +/- vol R repeat V viz C style ? menu"
+ width))
+ 'face 'naviel-footer-face))
+
+(defun naviel--player-song-info-lines (song width)
+ "Return responsive song info lines for SONG within WIDTH columns."
+ (let* ((title (or (plist-get song :title) "?"))
+ (artist (or (plist-get song :artist) ""))
+ (album (or (plist-get song :album) ""))
+ (state (if naviel--paused
+ (propertize "paused" 'face 'naviel-paused-face)
+ (propertize "playing" 'face 'naviel-now-playing-face))))
+ (list
+ (propertize "Song Info" 'face 'naviel-breadcrumb-face)
+ (format "%s %s"
+ (propertize "Title:" 'face 'naviel-radio-face)
+ (propertize (naviel--trunc title (max 8 (- width 7)))
+ 'face 'naviel-now-playing-face))
+ (format "%s %s"
+ (propertize "By:" 'face 'naviel-radio-face)
+ (propertize (naviel--trunc artist (max 8 (- width 4)))
+ 'face 'naviel-artist-face))
+ (format "%s %s"
+ (propertize "In:" 'face 'naviel-radio-face)
+ (propertize (naviel--trunc album (max 8 (- width 4)))
+ 'face 'naviel-album-face))
+ (format "State: %s vol %d%% repeat: %s"
+ state naviel--volume
+ (propertize (naviel--repeat-label) 'face 'naviel-repeat-face)))))
+
+(defun naviel--player-queue-preview-lines (width)
+ "Return upcoming queue entries as lines fitting WIDTH columns."
+ (let (lines)
+ (push (propertize "Up Next" 'face 'naviel-breadcrumb-face) lines)
+ (if (and naviel--queue
+ (> naviel-player-queue-preview-size 0)
+ (< (1+ naviel--queue-index) (length naviel--queue)))
+ (let* ((start (1+ naviel--queue-index))
+ (end (min (length naviel--queue)
+ (+ start naviel-player-queue-preview-size)))
+ (idx start))
+ (while (< idx end)
+ (let* ((song (nth idx naviel--queue))
+ (artist-width (min 18 (max 0 (/ width 3))))
+ (title-width (max 8 (- width artist-width 7)))
+ (title (naviel--trunc (or (plist-get song :title) "?") title-width))
+ (artist (naviel--trunc (or (plist-get song :artist) "") artist-width)))
+ (push (format "%s. %s%s"
+ (propertize (number-to-string (1+ idx)) 'face 'naviel-track-num-face)
+ (propertize title 'face 'naviel-song-title-face)
+ (if (string= artist "")
+ ""
+ (concat " "
+ (propertize artist 'face 'naviel-song-meta-face))))
+ lines))
+ (setq idx (1+ idx))))
+ (push (propertize "End of queue" 'face 'shadow) lines))
+ (nreverse lines)))
+
+(defun naviel--player-lyrics-lines (width)
+ "Return lyrics preview lines fitting WIDTH columns."
+ (cond
+ (naviel--lrc-lines
+ (let* ((elapsed naviel--elapsed)
+ (idx (or (naviel--lrc-active-index elapsed) -1))
+ (lines naviel--lrc-lines)
+ (total (length lines))
+ (start (max 0 (- idx 3)))
+ (end (min total (+ idx 5)))
+ result)
+ (setq naviel--lrc-current-idx idx)
+ (push (propertize "Lyrics" 'face 'naviel-breadcrumb-face) result)
+ (if (< idx 0)
+ (push (propertize "· · ·" 'face 'naviel-lyrics-context-face) result)
+ (cl-loop for i from start below end
+ for entry = (nth i lines)
+ do (push
+ (if (= i idx)
+ (format "▸ %s"
+ (propertize (naviel--trunc (cdr entry) (max 1 (- width 2)))
+ 'face 'naviel-lyrics-current-face))
+ (propertize (naviel--trunc (cdr entry) width)
+ 'face 'naviel-lyrics-context-face))
+ result)))
+ (push (propertize "(synced via lrclib.net)" 'face 'naviel-footer-face) result)
+ (nreverse result)))
+ (naviel--lrc-raw
+ (append
+ (list (propertize "Lyrics" 'face 'naviel-breadcrumb-face)
+ (propertize "(unsynced)" 'face 'naviel-footer-face))
+ (mapcar (lambda (ln)
+ (propertize (naviel--trunc ln width)
+ 'face 'naviel-lyrics-nosync-face))
+ (seq-take (split-string naviel--lrc-raw "\n") 12))))
+ (t
+ (list
+ (propertize "Lyrics" 'face 'naviel-breadcrumb-face)
+ (propertize "No lyrics found." 'face 'shadow)
+ (propertize "l search lyrics manually" 'face 'naviel-footer-face)))))
+
(defun naviel--player-render ()
"Redraw the *Naviel Player* buffer."
(when (naviel--player-buffer-p)
@@ -636,20 +1028,23 @@
(defun naviel--player-insert-content ()
"Insert all player buffer content."
(let* ((song naviel--current-song)
- (w (max 40 (- (window-width (get-buffer-window (naviel--player-buffer) t)) 2)))
- (sep (propertize (make-string (min 60 w) ?─) 'face 'naviel-separator-face)))
+ (w (naviel--player-window-width))
+ (h (naviel--player-window-height))
+ (content-width (max 20 (- w 4)))
+ (sep (propertize (make-string w ?─) 'face 'naviel-separator-face)))
;; ── Header ──
(insert (propertize " Naviel\n" 'face 'naviel-header-face))
(insert sep) (insert "\n")
(if (not song)
;; Nothing playing
- (progn
- (insert (propertize " · Nothing playing\n\n" 'face 'shadow))
- (insert sep) (insert "\n"))
+ (progn
+ (insert (propertize " · Nothing playing\n\n" 'face 'shadow))
+ (insert sep) (insert "\n"))
- ;; ── Cover art placeholder (filled asynchronously on first load) ──
- (insert (propertize "naviel-cover-marker\n" 'invisible t 'naviel-cover-marker t))
+ (let ((single-layout (and (naviel--player-single-window-p) (>= w 72))))
+ (if single-layout
+ (naviel--player-insert-single-window-content song w h content-width sep)
;; ── Song info ──
(let* (( title (or (plist-get song :title) "?"))
@@ -659,18 +1054,21 @@
(state (if naviel--paused
(propertize "⏸ paused" 'face 'naviel-paused-face)
(propertize "▶ playing" 'face 'naviel-now-playing-face))))
+ ;; ── Cover art ──
+ (naviel--player-insert-cover cover)
+
(if naviel--paused
(insert (format " %s\n\n" state))
(progn (insert (format " %s %s\n"
(propertize "Title:" 'face 'naviel-radio-face)
- (propertize (naviel--trunc title (min 52 (- w 4)))
+ (propertize (naviel--trunc title (max 12 (- content-width 7)))
'face 'naviel-now-playing-face)))
(insert (format " %s %s\n"
(propertize "By:" 'face 'naviel-radio-face)
- (propertize (naviel--trunc artist 40) 'face 'naviel-artist-face)))
+ (propertize (naviel--trunc artist (max 12 (- content-width 4))) 'face 'naviel-artist-face)))
(insert (format " %s %s\n\n"
(propertize "In:" 'face 'naviel-radio-face)
- (propertize (naviel--trunc album 40) 'face 'naviel-album-face)))
+ (propertize (naviel--trunc album (max 12 (- content-width 4))) 'face 'naviel-album-face)))
))
@@ -681,7 +1079,7 @@
(when (and d (not (string= d ""))) (string-to-number d)))))
(e-str (naviel--format-secs elap))
(d-str (naviel--format-secs dur))
- (pbar (naviel--progress-bar elap dur (min 46 (- w 16))))
+ (pbar (naviel--progress-bar elap dur (max 12 (- content-width 12))))
(qidx (format "[%d/%d]" (1+ naviel--queue-index) (length naviel--queue))))
(insert (format " %s\n" pbar))
(insert (format " %s / %s %s\n\n"
@@ -689,20 +1087,101 @@
(propertize d-str 'face 'naviel-position-face)
(propertize qidx 'face 'naviel-count-face))))
+ ;; ── CAVA-like visualizer ──
+ (when naviel-player-visualizer
+ (insert (naviel--visualizer-bars song naviel--elapsed
+ content-width
+ (naviel--player-visualizer-height h)))
+ (insert "\n"))
+
;; ── Volume + repeat ──
(insert (format " vol %s repeat: %s\n"
(naviel--volume-bar naviel--volume)
(propertize (naviel--repeat-label) 'face 'naviel-repeat-face)))
+ (naviel--player-insert-queue-preview content-width)
(insert "\n") (insert sep) (insert "\n")
;; ── Lyrics ──
(naviel--player-insert-lyrics)))
- ;; ── Footer ──
- (insert "\n")
- (insert (propertize
- " SPC pause n/p skip [ ] seek +/- vol R repeat ? menu"
- 'face 'naviel-footer-face))
+ (unless single-layout
+ ;; ── Footer ──
+ (insert "\n")
+ (insert (naviel--player-footer-line content-width))
+ (insert "\n"))))))
+
+(defun naviel--player-insert-single-window-content (song _w h content-width sep)
+ "Insert dashboard-style player content for one-window use."
+ (let* ((dashboard-width (max 68 (min content-width naviel-player-dashboard-width)))
+ (indent (naviel--centered-indent content-width dashboard-width))
+ (gap (propertize " │ " 'face 'naviel-separator-face))
+ (left-width (max 24 (floor (* (- dashboard-width 3) 0.43))))
+ (right-width (max 24 (- dashboard-width left-width 3)))
+ (top-rule (concat indent
+ (propertize (make-string (1+ left-width) ?─) 'face 'naviel-separator-face)
+ (propertize "┬" 'face 'naviel-separator-face)
+ (propertize (make-string (1+ right-width) ?─) 'face 'naviel-separator-face)
+ "\n"))
+ (bottom-rule (concat indent
+ (propertize (make-string (1+ left-width) ?─) 'face 'naviel-separator-face)
+ (propertize "┴" 'face 'naviel-separator-face)
+ (propertize (make-string (1+ right-width) ?─) 'face 'naviel-separator-face)
+ "\n"))
+ (left-lines (append (naviel--player-song-info-lines song left-width)
+ (list "")
+ (naviel--player-queue-preview-lines left-width)))
+ (right-lines (naviel--player-lyrics-lines right-width))
+ (rows (max (length left-lines) (length right-lines)))
+ (elap naviel--elapsed)
+ (dur (or naviel--duration
+ (let ((d (plist-get song :duration)))
+ (when (and d (not (string= d ""))) (string-to-number d)))))
+ (e-str (naviel--format-secs elap))
+ (d-str (naviel--format-secs dur))
+ (qidx (format "[%d/%d]" (1+ naviel--queue-index) (length naviel--queue)))
+ (progress-width (max 24 (min naviel-player-progress-width (- dashboard-width 12))))
+ (progress-indent (naviel--centered-indent content-width progress-width))
+ (visualizer-height (max 4 (naviel--player-visualizer-height h)))
+ (cover-path (naviel--player-cover-path (plist-get song :coverArt))))
+ (when cover-path
+ (insert indent)
+ (insert-image (create-image cover-path 'jpeg nil :width (max 96 (min 140 (naviel--player-cover-display-size)))))
+ (insert "\n\n"))
+ (insert top-rule)
+ (dotimes (i rows)
+ (let ((left (or (nth i left-lines) ""))
+ (right (or (nth i right-lines) "")))
+ (insert indent)
+ (insert (naviel--pad-right left left-width))
+ (insert gap)
+ (insert (naviel--trunc right right-width))
+ (insert "\n")))
+ (insert bottom-rule)
+ (let* ((footer-lines 2)
+ (bottom-lines (+ 2 1
+ (if naviel-player-visualizer (+ visualizer-height 1) 0)
+ 1 footer-lines))
+ (target-line (- h bottom-lines))
+ (max-spacer 3)
+ (spacer-lines (min max-spacer
+ (max 1 (- target-line (line-number-at-pos))))))
+ (dotimes (_ spacer-lines)
+ (insert "\n")))
+ (insert (format "%s%s\n"
+ progress-indent
+ (naviel--progress-bar elap dur progress-width)))
+ (insert (format "%s%s / %s %s\n\n"
+ progress-indent
+ (propertize e-str 'face 'naviel-position-face)
+ (propertize d-str 'face 'naviel-position-face)
+ (propertize qidx 'face 'naviel-count-face)))
+ (when naviel-player-visualizer
+ (insert (naviel--indent-lines
+ (naviel--visualizer-bars song elap dashboard-width visualizer-height)
+ indent))
+ (insert "\n"))
+ (insert sep) (insert "\n\n")
+ (insert (naviel--player-footer-line content-width))
(insert "\n")))
(defun naviel--player-insert-lyrics ()
@@ -759,6 +1238,32 @@
(insert (propertize " No lyrics found.\n" 'face 'shadow))
(insert (propertize " l search lyrics manually\n" 'face 'naviel-footer-face)))))
+(defun naviel--player-insert-queue-preview (width)
+ "Insert upcoming queue entries into the player buffer."
+ (when (and naviel--queue
+ (> naviel-player-queue-preview-size 0)
+ (< (1+ naviel--queue-index) (length naviel--queue)))
+ (insert "\n")
+ (insert (propertize " Up next\n" 'face 'naviel-breadcrumb-face))
+ (let* ((start (1+ naviel--queue-index))
+ (end (min (length naviel--queue)
+ (+ start naviel-player-queue-preview-size)))
+ (idx start))
+ (while (< idx end)
+ (let* ((song (nth idx naviel--queue))
+ (artist-width (min 24 (max 0 (/ width 3))))
+ (title-width (max 12 (- width artist-width 8)))
+ (title (naviel--trunc (or (plist-get song :title) "?") title-width))
+ (artist (naviel--trunc (or (plist-get song :artist) "") artist-width)))
+ (insert (format " %s. %s%s\n"
+ (propertize (number-to-string (1+ idx)) 'face 'naviel-track-num-face)
+ (propertize title 'face 'naviel-song-title-face)
+ (if (string= artist "")
+ ""
+ (concat " "
+ (propertize artist 'face 'naviel-song-meta-face))))))
+ (setq idx (1+ idx))))))
+
;;; Player mode
(defvar naviel-player-mode-map
@@ -772,6 +1277,8 @@
(define-key m (kbd "[") #'naviel-seek-backward)
(define-key m (kbd "]") #'naviel-seek-forward)
(define-key m (kbd "R") #'naviel-toggle-repeat)
+ (define-key m (kbd "V") #'naviel-toggle-visualizer)
+ (define-key m (kbd "C") #'naviel-cycle-visualizer-style)
(define-key m (kbd "l") #'naviel-lyrics-show)
(define-key m (kbd "*") #'naviel-toggle-star-current)
(define-key m (kbd "?") #'naviel-dispatch)
@@ -1031,6 +1538,89 @@ CJK characters and emoji are measured by display width, not byte count."
(propertize (make-string empty ?─) 'face 'naviel-progress-empty-face)))
(propertize (make-string width ?─) 'face 'naviel-separator-face)))
+(defun naviel--visualizer-glyphs ()
+ (pcase naviel-player-visualizer-style
+ ('compact [" " " " "▁" "▂" "▃" "▄" "▅" "▆"])
+ ('wide ["▁ " "▂ " "▃ " "▄ " "▅ " "▆ " "▇ " "█ "])
+ (_ ["▁" "▂" "▃" "▄" "▅" "▆" "▇" "█"])))
+
+(defun naviel--visualizer-face (level)
+ (pcase naviel-player-visualizer-style
+ ('monochrome 'naviel-now-playing-face)
+ ('high-contrast (if (>= level 4) 'naviel-error-face 'naviel-separator-face))
+ (_ (cond ((>= level 6) 'naviel-visualizer-high-face)
+ ((>= level 3) 'naviel-visualizer-mid-face)
+ (t 'naviel-visualizer-low-face)))))
+
+(defun naviel--visualizer-format-levels (levels width height)
+ "Format visualizer LEVELS to fit WIDTH columns and HEIGHT rows."
+ (let* ((glyphs (naviel--visualizer-glyphs))
+ (glyph-width (max 1 (string-width (aref glyphs 7))))
+ (levels (or levels '(0)))
+ (len (length levels))
+ (bars (max 1 (min (max 1 (/ width glyph-width)) len)))
+ parts)
+ (if (<= height 1)
+ (progn
+ (dotimes (i bars)
+ (let* ((src (floor (* i (/ (float len) bars))))
+ (level (max 0 (min 7 (nth src levels)))))
+ (push (propertize (aref glyphs level)
+ 'face (naviel--visualizer-face level))
+ parts)))
+ (concat " " (truncate-string-to-width (apply #'concat (nreverse parts)) width) "\n"))
+ (let (rows)
+ (dotimes (row height)
+ (setq parts nil)
+ (let ((threshold (ceiling (* 8.0 (/ (- height row) (float height))))))
+ (dotimes (i bars)
+ (let* ((src (floor (* i (/ (float len) bars))))
+ (level (max 0 (min 7 (nth src levels))))
+ (active (>= (1+ level) threshold))
+ (cell (if active
+ (if (eq naviel-player-visualizer-style 'wide) "█ " "█")
+ (if (eq naviel-player-visualizer-style 'wide) " " " "))))
+ (push (propertize cell
+ 'face (if active
+ (naviel--visualizer-face level)
+ 'naviel-separator-face))
+ parts))))
+ (push (concat " "
+ (truncate-string-to-width (apply #'concat (nreverse parts)) width)
+ "\n")
+ rows))
+ (apply #'concat (nreverse rows))))))
+
+(defun naviel--visualizer-simulated-levels (song elapsed width)
+ "Return simulated visualizer levels for SONG at ELAPSED seconds."
+ (let* ((bars (max 1 width))
+ (time (if naviel--paused
+ (or elapsed 0.0)
+ (or elapsed (float-time))))
+ (seed (sxhash (format "%s:%s"
+ (or (plist-get song :id) "")
+ (or (plist-get song :title) ""))))
+ levels)
+ (dotimes (i bars)
+ (let* ((phase (+ (* time 5.0) (* i 0.47) (% seed 31)))
+ (bass (+ 0.5 (* 0.5 (sin (+ phase (* i 0.13))))))
+ (mid (+ 0.5 (* 0.5 (sin (+ (* phase 0.67) (/ seed 97.0))))))
+ (spark (+ 0.5 (* 0.5 (sin (+ (* phase 1.73) (* i i 0.021))))))
+ (edge-falloff (- 1.0 (* 0.38 (/ (abs (- i (/ (1- bars) 2.0)))
+ (max 1.0 (/ bars 2.0))))))
+ (level (truncate (* 7.0 edge-falloff
+ (+ (* 0.52 bass) (* 0.34 mid) (* 0.14 spark))))))
+ (push (max 0 (min 7 level)) levels)))
+ (nreverse levels)))
+
+(defun naviel--visualizer-bars (song elapsed width height)
+ "Return a CAVA-like visualizer line for SONG at ELAPSED seconds."
+ (naviel--maybe-start-cava)
+ (naviel--visualizer-format-levels
+ (or naviel--cava-levels
+ (naviel--visualizer-simulated-levels song elapsed width))
+ width height))
+
(defun naviel--volume-bar (vol)
(let* ((w 8) (f (round (* w (/ vol 100.0)))))
(concat
@@ -1143,7 +1733,7 @@ CJK characters and emoji are measured by display width, not byte count."
(propertize artist 'face 'naviel-song-meta-face)
(propertize dur 'face 'naviel-song-meta-face))))
-(defun naviel--format-inline-song (song playing idx)
+(defun naviel--format-inline-song (song playing _idx)
"Format a song for inline display under an expanded album."
(let* ((id (plist-get song :id))
(track (naviel--trunc (or (plist-get song :track) "") 3))
@@ -1238,7 +1828,7 @@ CJK characters and emoji are measured by display width, not byte count."
(goto-char (point-min))
(forward-line (max 0 (- saved-line 1))))))
-(defun naviel--browser-insert-expanded-album (album parent-idx)
+(defun naviel--browser-insert-expanded-album (album _parent-idx)
"Insert inline song list and optional cover art for ALBUM below its line."
(let* ((album-id (plist-get album :id))
(cover-id (plist-get album :coverArt))
@@ -1443,7 +2033,7 @@ CJK characters and emoji are measured by display width, not byte count."
(count (min naviel-genre-song-limit
(string-to-number (or (plist-get item :songs) "50")))))
(push `(:type genre :data ,name :label ,name) naviel--browser-stack)
- (message "naviel: loading genre "%s"…" name)
+ (message "naviel: loading genre \"%s\"..." name)
(naviel--get-songs-by-genre
name count 0
(lambda (songs)
@@ -1452,7 +2042,7 @@ CJK characters and emoji are measured by display width, not byte count."
(naviel--render-songs songs (format "Genre: %s" name)
(format "%d tracks" (length songs))))
(pop naviel--browser-stack)
- (message "naviel: no songs for "%s"" name))))))
+ (message "naviel: no songs for \"%s\"" name))))))
('radio
(let* ((name (plist-get item :name))
(url (plist-get item :stream-url))
@@ -1569,7 +2159,9 @@ CJK characters and emoji are measured by display width, not byte count."
("]" "Seek forward" naviel-seek-forward)
("+" "Volume up" naviel--volume-up)
("-" "Volume down" naviel--volume-down)
- ("R" "Cycle repeat" naviel-toggle-repeat)]
+ ("R" "Cycle repeat" naviel-toggle-repeat)
+ ("V" "Visualizer" naviel-toggle-visualizer)
+ ("C" "Viz style" naviel-cycle-visualizer-style)]
["Library"
("s" "Search" naviel-search)
("r" "Random" naviel-play-random)