diff options
| author | David Moc <personal@cdatgoose.org> | 2026-05-08 00:20:57 +0200 |
|---|---|---|
| committer | David Moc <personal@cdatgoose.org> | 2026-05-08 00:20:57 +0200 |
| commit | afae6363427c56162bde0678ee49d4bd341bbb34 (patch) | |
| tree | 310e35686577ac673471881e6325c8de0ebb6031 /naviel.el | |
| parent | 843510e3acd89622b3dc15dec0630ba7dec94c12 (diff) | |
Diffstat (limited to 'naviel.el')
| -rwxr-xr-x | naviel.el | 644 |
1 files changed, 618 insertions, 26 deletions
@@ -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) |
