diff options
| -rwxr-xr-x | naviel.el | 1017 |
1 files changed, 625 insertions, 392 deletions
@@ -1,14 +1,18 @@ ;;; naviel.el --- Navidrome music player client for Emacs -*- lexical-binding: t -*- ;; Author: CdatGoose -;; Version: 0.3 +;; Version: 0.4 ;; Package-Requires: ((emacs "29.1") (transient "0.5.0")) ;; Keywords: multimedia, music ;;; Commentary: ;; A Navidrome client for Emacs using the Subsonic API. ;; Plays music via mpv (must be installed on PATH). -;; This project is glued together by sheer hope, I don't know elisp and probably never will. +;; +;; Layout: Two persistent buffers: +;; *Naviel Player* — now-playing info, synced lyrics (prev/current/next) +;; *Naviel Browser* — artist/album/song tree with TAB expand, cover art +;; ;; Quick setup: ;; 1. ~/.authinfo: machine your-host login USER password PASS ;; 2. (setq naviel-url "http://host:4533") @@ -24,7 +28,7 @@ (require 'seq) (require 'transient) -;;; Customisation +;;; Customisation (defgroup naviel nil "Naviel music client." :group 'multimedia :prefix "naviel-") @@ -52,7 +56,7 @@ (defcustom naviel-seek-step 10 "Seek step in seconds for the [ / ] keys." :type 'integer :group 'naviel) -(defcustom naviel-position-poll-interval 1.0 +(defcustom naviel-position-poll-interval 0.5 "Seconds between IPC polls for playback position." :type 'float :group 'naviel) (defcustom naviel-album-list-size 30 @@ -67,6 +71,12 @@ (defcustom naviel-cover-art-display t "Non-nil to attempt inline cover art display when available." :type 'boolean :group 'naviel) +(defcustom naviel-player-cover-size 180 + "Pixel size for cover art in the player buffer." + :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) ;;; Faces @@ -110,6 +120,16 @@ '((t :inherit font-lock-warning-face)) "Starred indicator.") (defface naviel-radio-face '((t :inherit font-lock-constant-face)) "Internet radio stations.") +(defface naviel-lyrics-current-face + '((t :inherit default :weight bold :height 1.05)) "Current synced lyric line.") +(defface naviel-lyrics-context-face + '((t :inherit shadow :height 0.9)) "Adjacent lyric lines (prev/next).") +(defface naviel-lyrics-nosync-face + '((t :inherit shadow :slant italic)) "Unsynced / static lyrics.") +(defface naviel-expand-indicator-face + '((t :inherit font-lock-builtin-face)) "Album expand triangle.") +(defface naviel-inline-song-face + '((t :inherit naviel-song-title-face)) "Songs in expanded album view.") ;;; Repeat mode @@ -121,7 +141,7 @@ (interactive) (setq naviel-repeat-mode (pcase naviel-repeat-mode ('off 'one) ('one 'album) (_ 'off))) - (naviel--refresh-footer) + (naviel--player-render) (message "Repeat: %s" (naviel--repeat-label))) (defun naviel--repeat-label () @@ -145,12 +165,33 @@ (defvar naviel--browser-stack '()) (defvar naviel--current-view nil) (defvar naviel--mode-line-timer nil) -;; Star cache: hash of id -> t (starred) + +;; Star cache (defvar naviel--starred-ids (make-hash-table :test 'equal)) -(defun naviel--browser-buffer () - "Return the main Naviel browser buffer, creating it if necessary." - (get-buffer-create "*Naviel*")) +;; Synced lyrics state +(defvar naviel--lrc-lines nil "List of (seconds . text) cons cells, sorted by time.") +(defvar naviel--lrc-raw nil "Raw unsynchronised lyrics string, or nil.") +(defvar naviel--lrc-current-idx -1 "Index of last active lyric line.") + +;; Browser inline-expand state: hash of album-id -> (songs . open-p) +(defvar naviel--expanded-albums (make-hash-table :test 'equal)) + +;;; Buffer accessors + +(defun naviel--player-buffer () (get-buffer-create "*Naviel Player*")) +(defun naviel--browser-buffer () (get-buffer-create "*Naviel Browser*")) + +;;; Window layout + +(defun naviel--ensure-layout () + "Show player on the left and browser on the right." + (delete-other-windows) + (let ((player-win (selected-window)) + (browser-win (split-window-right))) + (set-window-buffer player-win (naviel--player-buffer)) + (set-window-buffer browser-win (naviel--browser-buffer)) + (select-window browser-win))) ;;; Authentication @@ -202,7 +243,6 @@ (re-search-forward "\r?\n\r?\n" nil t) (set-buffer-multibyte t) (decode-coding-region (point) (point-max) 'utf-8) - (let* ((xml (xml-parse-region (point) (point-max))) (response (naviel--check-status xml)) (result (funcall parse-fn response))) @@ -253,15 +293,13 @@ `(("id" . ,cover-id) ("size" . ,(number-to-string (or size naviel-cover-art-width)))))) -;;; Cover art display +;;; Cover art insertion -(defun naviel--display-cover-art (cover-id buffer-name &optional size) - "Fetch cover art for COVER-ID and display it in BUFFER-NAME. -Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." +(defun naviel--fetch-and-insert-cover (cover-id buffer marker-regex size) + "Fetch COVER-ID and insert it after MARKER-REGEX in BUFFER." (when (and naviel-cover-art-display cover-id (not (string= cover-id ""))) - (let* ((url (naviel--cover-art-url cover-id (or size 400))) - (tmp (make-temp-file "naviel-art" nil ".jpg"))) - ;; Fetch asynchronously, display when done + (let ((url (naviel--cover-art-url cover-id size)) + (tmp (make-temp-file "naviel-art" nil ".jpg"))) (url-retrieve url (lambda (status) @@ -271,42 +309,25 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (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--insert-art-image tmp buffer-name))) + (naviel--do-insert-image tmp buffer marker-regex))) (ignore-errors (kill-buffer (current-buffer)))) nil t)))) -(defun naviel--insert-art-image (path buffer-name) - "Insert image at PATH into BUFFER-NAME using the best available method." - (cond - ;; Emacs can display images natively in GUI frames - ((and (display-graphic-p) (image-type-available-p 'jpeg)) - (when (get-buffer buffer-name) - (with-current-buffer buffer-name - (let ((inhibit-read-only t)) +(defun naviel--do-insert-image (path buffer marker-regex) + "Insert image at PATH into BUFFER after first match of MARKER-REGEX." + (when (and (display-graphic-p) + (image-type-available-p 'jpeg) + (get-buffer buffer)) + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (save-excursion (goto-char (point-min)) - ;; Insert after the header separator line - (when (re-search-forward "^─+$" nil t) + (when (re-search-forward marker-regex nil t) (forward-line 1) - (let ((img (create-image path 'jpeg nil :width 200))) + (let ((img (create-image path 'jpeg nil :width 180))) (insert " ") (insert-image img) - (insert "\n\n"))))))) - ;; Terminal: try icat (kitty), viu, or chafa as fallback - ((executable-find "icat") - (call-process "icat" nil nil nil "--align" "left" "--width" "30" path)) - ((executable-find "viu") - (call-process "viu" nil nil nil "-w" "30" path)) - ((executable-find "chafa") - (let ((output (with-output-to-string - (call-process "chafa" nil standard-output nil - "--size" "30x15" "--format" "symbols" path)))) - (when (get-buffer buffer-name) - (with-current-buffer buffer-name - (let ((inhibit-read-only t)) - (goto-char (point-min)) - (when (re-search-forward "^─+$" nil t) - (forward-line 1) - (insert output "\n"))))))))) + (insert "\n")))))))) ;;; mpv IPC @@ -362,8 +383,8 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (defun naviel--ipc-handle-event (event) (cond ((string= event "end-file") (naviel--next-in-queue)) - ((string= event "pause") (setq naviel--paused t) (naviel--refresh-footer)) - ((string= event "unpause") (setq naviel--paused nil) (naviel--refresh-footer)))) + ((string= event "pause") (setq naviel--paused t) (naviel--player-render)) + ((string= event "unpause") (setq naviel--paused nil) (naviel--player-render)))) (defun naviel--ipc-send (command &optional callback) (when (and naviel--ipc-process (process-live-p naviel--ipc-process)) @@ -396,7 +417,7 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (naviel--ipc-send '("get_property" "time-pos") (lambda (v) (setq naviel--elapsed (and (numberp v) v)) - (naviel--refresh-footer))) + (naviel--player-render))) (naviel--ipc-send '("get_property" "duration") (lambda (v) (setq naviel--duration (and (numberp v) v)))))) @@ -413,14 +434,16 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (let ((sock (naviel--ipc-socket-path))) (when (file-exists-p sock) (ignore-errors (delete-file sock)))) (setq naviel--process nil naviel--paused nil naviel--current-song nil - naviel--elapsed nil naviel--duration nil) - (naviel--refresh-footer) - (naviel--queue-render-if-live)) + naviel--elapsed nil naviel--duration nil + naviel--lrc-lines nil naviel--lrc-raw nil naviel--lrc-current-idx -1) + (naviel--player-render) + (naviel--browser-rerender-current-view)) (defun naviel--play-url (url song-info) (naviel--stop) (setq naviel--current-song song-info naviel--paused nil - naviel--elapsed nil naviel--duration nil) + naviel--elapsed nil naviel--duration nil + naviel--lrc-lines nil naviel--lrc-raw nil naviel--lrc-current-idx -1) (let* ((sock (naviel--ipc-socket-path)) (proc (start-process "naviel-mpv" nil naviel-mpv-executable "--no-video" "--quiet" @@ -430,7 +453,9 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (setq naviel--process proc)) (run-with-timer 0.3 nil #'naviel--ipc-connect) (run-with-timer 0.6 nil #'naviel--start-position-timer) - (naviel--refresh-footer) + ;; Fetch synced lyrics from lrclib + (naviel--lrclib-fetch song-info) + (naviel--player-render) (message "▶ %s – %s" (plist-get song-info :title) (plist-get song-info :artist))) @@ -449,15 +474,14 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (defun naviel-seek-forward () (interactive) (naviel--seek naviel-seek-step)) (defun naviel-seek-backward () (interactive) (naviel--seek (- naviel-seek-step))) -;;; Queue management +;;; Queue (defun naviel--play-queue-index (idx) (setq naviel--queue-index idx) (let* ((song (nth idx naviel--queue)) (url (naviel--stream-url (plist-get song :id)))) (naviel--play-url url song) - (naviel--rerender-current-view) - (naviel--queue-render-if-live))) + (naviel--browser-rerender-current-view))) (defun naviel--next-in-queue () (interactive) @@ -481,22 +505,21 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (interactive) (setq naviel--volume (min 100 (+ naviel--volume naviel-volume-step))) (naviel--ipc-send `("set_property" "volume" ,naviel--volume)) - (naviel--refresh-footer) + (naviel--player-render) (message "Volume: %d%%" naviel--volume)) (defun naviel--volume-down () (interactive) (setq naviel--volume (max 0 (- naviel--volume naviel-volume-step))) (naviel--ipc-send `("set_property" "volume" ,naviel--volume)) - (naviel--refresh-footer) + (naviel--player-render) (message "Volume: %d%%" naviel--volume)) (defun naviel--append-songs-to-queue (songs label) (let ((was-empty (null naviel--queue))) (setq naviel--queue (append naviel--queue songs)) (when was-empty (setq naviel--queue-index 0)) - (naviel--rerender-current-view) - (naviel--queue-render-if-live) + (naviel--browser-rerender-current-view) (message "naviel: +%d from \"%s\" (queue: %d)" (length songs) label (length naviel--queue)))) @@ -518,10 +541,243 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (cons cur (append (seq-take naviel--queue pos) (seq-drop naviel--queue (1+ pos))))) (setq naviel--queue-index 0))))) - (naviel--rerender-current-view) - (naviel--queue-render-if-live) + (naviel--browser-rerender-current-view) (message "naviel: shuffled (%d tracks)" (length naviel--queue))) +;;; lrclib.net synced lyrics + +(defun naviel--lrclib-fetch (song) + "Fetch synced (or plain) lyrics from lrclib.net for SONG plist." + (let* ((title (or (plist-get song :title) "")) + (artist (or (plist-get song :artist) "")) + (album (or (plist-get song :album) "")) + (dur-str (plist-get song :duration)) + (duration (if (and dur-str (not (string= dur-str ""))) + (string-to-number dur-str) 0)) + ;; Use the GET endpoint with query params + (url (concat "https://lrclib.net/api/get?" + "track_name=" (url-hexify-string title) + "&artist_name=" (url-hexify-string artist) + "&album_name=" (url-hexify-string album) + (when (> duration 0) + (format "&duration=%d" (round duration)))))) + (url-retrieve + url + (lambda (status) + (if (plist-get status :error) + (ignore-errors (kill-buffer (current-buffer))) + (condition-case nil + (progn + (goto-char (point-min)) + (re-search-forward "\r?\n\r?\n" nil t) + (set-buffer-multibyte t) + (decode-coding-region (point) (point-max) 'utf-8) + (let* ((json-object-type 'alist) + (json-array-type 'list) + (data (json-read))) + (kill-buffer (current-buffer)) + (naviel--lrclib-handle data))) + (error (ignore-errors (kill-buffer (current-buffer))))))) + nil t))) + +(defun naviel--lrclib-handle (data) + "Process lrclib JSON response DATA." + (let ((synced (cdr (assq 'syncedLyrics data))) + (plain (cdr (assq 'plainLyrics data)))) + (cond + ((and synced (not (eq synced :null)) (not (string= synced ""))) + (setq naviel--lrc-lines (naviel--parse-lrc synced) + naviel--lrc-raw nil + naviel--lrc-current-idx -1)) + ((and plain (not (eq plain :null)) (not (string= plain ""))) + (setq naviel--lrc-lines nil + naviel--lrc-raw plain)) + (t + (setq naviel--lrc-lines nil naviel--lrc-raw nil))) + (naviel--player-render))) + +(defun naviel--parse-lrc (lrc-string) + "Parse LRC format string into sorted list of (seconds . text) conses." + (let (lines) + (dolist (line (split-string lrc-string "\n")) + (when (string-match "^\\[\\([0-9]+\\):\\([0-9]+\\(?:\\.[0-9]+\\)?\\)\\]\\(.*\\)$" line) + (let* ((min (string-to-number (match-string 1 line))) + (sec (string-to-number (match-string 2 line))) + (text (string-trim (match-string 3 line))) + (ts (+ (* min 60) sec))) + (push (cons ts text) lines)))) + (sort (nreverse lines) (lambda (a b) (< (car a) (car b)))))) + +(defun naviel--lrc-active-index (elapsed) + "Return the index of the active lyric line for ELAPSED seconds." + (when (and naviel--lrc-lines elapsed) + (let ((idx -1)) + (cl-loop for i from 0 for entry in naviel--lrc-lines + when (<= (car entry) elapsed) + do (setq idx i)) + idx))) + +;;; Player buffer rendering + +(defun naviel--player-buffer-p () + (get-buffer "*Naviel Player*")) + +(defun naviel--player-render () + "Redraw the *Naviel Player* buffer." + (when (naviel--player-buffer-p) + (with-current-buffer (naviel--player-buffer) + (naviel-player-mode) + (let ((inhibit-read-only t) + (saved-pos (point))) + (erase-buffer) + (naviel--player-insert-content) + (goto-char (min saved-pos (point-max))))))) + +(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))) + ;; ── 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")) + + ;; ── Cover art placeholder (filled asynchronously on first load) ── + (insert (propertize "naviel-cover-marker\n" 'invisible t 'naviel-cover-marker t)) + + ;; ── Song info ── + (let* (( title (or (plist-get song :title) "?")) + (artist (or (plist-get song :artist) "")) + (album (or (plist-get song :album) "")) + (cover (plist-get song :coverArt)) + (state (if naviel--paused + (propertize "⏸ paused" 'face 'naviel-paused-face) + (propertize "▶ playing" 'face 'naviel-now-playing-face)))) + (insert (format " %s\n\n" state)) + (insert (format " %s %s\n" + (propertize "Title:" 'face 'naviel-radio-face) + (propertize (naviel--trunc title (min 52 (- w 4))) + '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))) + (insert (format " %s %s\n\n" + (propertize "In:" 'face 'naviel-radio-face) + (propertize (naviel--trunc album 40) 'face 'naviel-album-face))) + + ;; ── Progress bar ── + (let* ((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)) + (pbar (naviel--progress-bar elap dur (min 46 (- w 16)))) + (qidx (format "[%d/%d]" (1+ naviel--queue-index) (length naviel--queue)))) + (insert (format " %s\n" pbar)) + (insert (format " %s / %s %s\n\n" + (propertize e-str 'face 'naviel-position-face) + (propertize d-str 'face 'naviel-position-face) + (propertize qidx 'face 'naviel-count-face)))) + + ;; ── Volume + repeat ── + (insert (format " vol %s repeat: %s\n" + (naviel--volume-bar naviel--volume) + (propertize (naviel--repeat-label) 'face 'naviel-repeat-face))) + (insert "\n") (insert sep) (insert "\n\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)) + (insert "\n"))) + +(defun naviel--player-insert-lyrics () + "Insert the lyrics section into the player buffer." + (insert (propertize " Lyrics\n" 'face 'naviel-breadcrumb-face)) + (insert (propertize (make-string 30 ?╌) 'face 'naviel-separator-face)) + (insert "\n\n") + (cond + ;; Synced lyrics + (naviel--lrc-lines + (let* ((elapsed naviel--elapsed) + (idx (or (naviel--lrc-active-index elapsed) -1)) + (lines naviel--lrc-lines) + (total (length lines)) + (prev-entry (when (> idx 0) (nth (1- idx) lines))) + (cur-entry (when (>= idx 0) (nth idx lines))) + (next-entry (when (< (1+ idx) total) (nth (1+ idx) lines)))) + (setq naviel--lrc-current-idx idx) + ;; Previous line (dimmed) + (if prev-entry + (insert (format " %s\n" + (propertize (naviel--trunc (cdr prev-entry) 82) + 'face 'naviel-lyrics-context-face))) + (insert "\n")) + ;; Current line (highlighted) + (if cur-entry + (insert (format " ▸ %s\n" + (propertize (naviel--trunc (cdr cur-entry) 80) + 'face 'naviel-lyrics-current-face))) + (insert (propertize " · · ·\n" 'face 'naviel-lyrics-context-face))) + ;; Next line (dimmed) + (if next-entry + (insert (format " %s\n" + (propertize (naviel--trunc (cdr next-entry) 82) + 'face 'naviel-lyrics-context-face))) + (insert "\n")) + (insert "\n") + (insert (propertize " (synced via lrclib.net)\n" 'face 'naviel-footer-face)))) + ;; Plain / unsynced lyrics + (naviel--lrc-raw + (insert (propertize " (unsynced)\n\n" 'face 'naviel-footer-face)) + ;; Show first 12 lines + (let ((lns (seq-take (split-string naviel--lrc-raw "\n") 12))) + (dolist (ln lns) + (insert (format " %s\n" + (propertize (naviel--trunc ln 52) + 'face 'naviel-lyrics-nosync-face)))) + (when (> (length (split-string naviel--lrc-raw "\n")) 12) + (insert (propertize " … (l for full lyrics)\n" 'face 'naviel-footer-face))))) + ;; No lyrics + (t + (insert (propertize " No lyrics found.\n" 'face 'shadow)) + (insert (propertize " l search lyrics manually\n" 'face 'naviel-footer-face))))) + +;;; Player mode + +(defvar naviel-player-mode-map + (let ((m (make-sparse-keymap))) + (define-key m (kbd "SPC") #'naviel--toggle-pause) + (define-key m (kbd "n") #'naviel--next-in-queue) + (define-key m (kbd "p") #'naviel--prev-in-queue) + (define-key m (kbd "+") #'naviel--volume-up) + (define-key m (kbd "=") #'naviel--volume-up) + (define-key m (kbd "-") #'naviel--volume-down) + (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 "l") #'naviel-lyrics-show) + (define-key m (kbd "*") #'naviel-toggle-star-current) + (define-key m (kbd "?") #'naviel-dispatch) + (define-key m (kbd "q") #'quit-window) + m)) + +(define-derived-mode naviel-player-mode special-mode "Naviel:Player" + "Major mode for the Naviel player buffer." + (setq buffer-read-only t) + (setq-local truncate-lines t)) + ;;; API parse helpers (defun naviel--parse-artists (response) @@ -643,7 +899,6 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." nil))) (defun naviel--parse-starred2 (response) - "Return plist with :artists :albums :songs from getStarred2." (let* ((starred (naviel--get-child response 'starred2)) artists albums songs) (dolist (a (xml-get-children starred 'artist)) @@ -716,36 +971,21 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." #'naviel--parse-radio-stations cb)) (defun naviel--api-star (id type cb) - "Star item ID of TYPE (song/album/artist) by calling the star endpoint." - (let ((param (pcase type - ('song "id") - ('album "albumId") - ('artist "artistId") - (_ "id")))) + (let ((param (pcase type ('song "id") ('album "albumId") ('artist "artistId") (_ "id")))) (naviel--request-async "star" `((,param . ,id)) #'naviel--parse-void cb))) (defun naviel--api-unstar (id type cb) - "Unstar item ID of TYPE." - (let ((param (pcase type - ('song "id") - ('album "albumId") - ('artist "artistId") - (_ "id")))) + (let ((param (pcase type ('song "id") ('album "albumId") ('artist "artistId") (_ "id")))) (naviel--request-async "unstar" `((,param . ,id)) #'naviel--parse-void cb))) -;;; Star cache helpers +;;; Star cache -(defun naviel--starred-p (id) - "Return non-nil if ID is in the local star cache." - (gethash id naviel--starred-ids)) +(defun naviel--starred-p (id) (gethash id naviel--starred-ids)) (defun naviel--star-cache-set (id val) - (if val - (puthash id t naviel--starred-ids) - (remhash id naviel--starred-ids))) + (if val (puthash id t naviel--starred-ids) (remhash id naviel--starred-ids))) (defun naviel--prime-star-cache (items id-key starred-key) - "Populate star cache from a list of ITEMS using ID-KEY and STARRED-KEY plists." (dolist (item items) (let ((id (plist-get item id-key)) (starred (plist-get item starred-key))) @@ -761,20 +1001,22 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." "--:--")) (defun naviel--trunc (s max) - (if (and s (> (length s) max)) - (concat (substring s 0 (- max 1)) "…") - (or s ""))) + "Truncate S to at most MAX display columns, appending … if cut. +Uses `string-width'/`truncate-string-to-width' so accented letters, +CJK characters and emoji are measured by display width, not byte count." + (if (null s) "" + (if (> (string-width s) max) + (concat (truncate-string-to-width s (max 0 (- max 1)) nil nil nil) "…") + s))) (defun naviel--star-glyph (id) - "Return a propertized star string for ID, space if unstarred." (if (naviel--starred-p id) (propertize "★" 'face 'naviel-starred-face) (propertize "☆" 'face 'naviel-separator-face))) -;;; Progress bar +;;; Progress / volume bars (defun naviel--progress-bar (elapsed duration width) - "Render a text progress bar of WIDTH chars." (if (and elapsed duration (> duration 0)) (let* ((pct (min 1.0 (/ elapsed duration))) (fill (round (* width pct))) @@ -784,8 +1026,6 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (propertize (make-string empty ?─) 'face 'naviel-progress-empty-face))) (propertize (make-string width ?─) 'face 'naviel-separator-face))) -;;; Volume bar - (defun naviel--volume-bar (vol) (let* ((w 8) (f (round (* w (/ vol 100.0))))) (concat @@ -793,120 +1033,48 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (propertize (make-string (- w f) ?▪) 'face 'naviel-separator-face) (propertize (format " %3d%%" vol) 'face 'naviel-song-meta-face)))) -;;; Now-playing bar - -(defun naviel--now-playing-bar () - (let* ((song naviel--current-song) - (elap naviel--elapsed) - (dur (or naviel--duration - (when song - (let ((d (plist-get song :duration))) - (when (and d (not (string= d ""))) (string-to-number d)))))) - (w (max 50 (- (window-width) 4))) - (pbar (naviel--progress-bar elap dur (min 50 (- w 20))))) - (concat - "\n" - (propertize (make-string (max 50 (- (window-width) 2)) ?─) 'face 'naviel-separator-face) - "\n" - (if song - (let* ((title (naviel--trunc (plist-get song :title) (min 52 (- w 4)))) - (artist (naviel--trunc (or (plist-get song :artist) "") 30)) - (album (naviel--trunc (or (plist-get song :album) "") 30)) - (e-str (naviel--format-secs elap)) - (d-str (naviel--format-secs dur)) - (qidx (format "[%d/%d]" (1+ naviel--queue-index) (length naviel--queue))) - (state (if naviel--paused - (propertize "⏸" 'face 'naviel-paused-face) - (propertize "▶" 'face 'naviel-now-playing-face)))) - (concat - (format " %s %s\n" - state - (propertize title 'face 'naviel-now-playing-face)) - (format " %s %s %s\n" - (propertize artist 'face 'naviel-artist-face) - (propertize "·" 'face 'naviel-separator-face) - (propertize album 'face 'naviel-album-face)) - (format " %s %s %s %s\n" - pbar - (propertize e-str 'face 'naviel-position-face) - (propertize "/" 'face 'naviel-separator-face) - (propertize (concat d-str " " qidx) 'face 'naviel-position-face)))) - (propertize " · Nothing playing\n" 'face 'shadow)) - (format " vol %s repeat: %s\n" - (naviel--volume-bar naviel--volume) - (propertize (naviel--repeat-label) 'face 'naviel-repeat-face)) - (propertize - " RET play a append SPC pause n/p skip [ ] seek +/- vol * star l lyrics ? menu" - 'face 'naviel-footer-face) - "\n"))) - -(defconst naviel--footer-sep "\0naviel-footer\0") - -;;; Breadcrumbs +;;; Browser buffer -(defun naviel--make-breadcrumbs () - (if (null naviel--browser-stack) "" - (propertize - (concat " " (mapconcat (lambda (v) (plist-get v :label)) - (reverse naviel--browser-stack) " ❯ ") - "\n") - 'face 'naviel-breadcrumb-face))) +(defun naviel--expanded-p (album-id) + (gethash album-id naviel--expanded-albums)) -;;; Core render +(defun naviel--expand-set (album-id val) + (puthash album-id val naviel--expanded-albums)) -(defun naviel--browser-render (heading items format-fn type &optional section-label) - (setq naviel--current-view - `(:type ,type :items ,items :heading ,heading - :format-fn ,format-fn :section-label ,section-label)) - (naviel--do-render heading items format-fn type section-label)) - -(defun naviel--do-render (heading items format-fn type section-label) - (with-current-buffer (naviel--browser-buffer) - (naviel-browser-mode) - (let ((inhibit-read-only t) - (saved-line (line-number-at-pos))) - (erase-buffer) - (insert (naviel--make-breadcrumbs)) - (insert (propertize (format " %s\n" heading) 'face 'naviel-header-face)) - (insert (propertize (make-string (min 80 (window-width)) ?─) - 'face 'naviel-separator-face)) - (insert "\n") - (when section-label - (insert (propertize (format "\n %s\n\n" section-label) 'face 'naviel-breadcrumb-face))) - (if (null items) - (insert (propertize " (no results)\n" 'face 'shadow)) - (let ((idx 0)) - (dolist (item items) - (let* ((is-playing (and naviel--current-song - (eq type 'song) - (= idx naviel--queue-index) - (equal (plist-get item :id) - (plist-get naviel--current-song :id)))) - (line (funcall format-fn item is-playing))) - (insert (propertize line - 'naviel-item item 'naviel-type type 'naviel-idx idx - 'face (when is-playing 'naviel-now-playing-face))) - (insert "\n")) - (cl-incf idx)))) - (insert (propertize naviel--footer-sep 'invisible t)) - (insert (naviel--now-playing-bar)) - (goto-char (point-min)) - (forward-line (max 0 (- saved-line 1)))))) - -(defun naviel--rerender-current-view () - (when (and naviel--current-view (get-buffer "*Naviel*")) - (naviel--do-render - (plist-get naviel--current-view :heading) - (plist-get naviel--current-view :items) - (plist-get naviel--current-view :format-fn) - (plist-get naviel--current-view :type) - (plist-get naviel--current-view :section-label)))) - -(defun naviel--refresh-footer () - (naviel--rerender-current-view) - (naviel--update-mode-line)) +(defun naviel-browser-toggle-expand () + "TAB: toggle inline expand of album at point, fetching songs if needed." + (interactive) + (let ((entry (naviel--item-at-point))) + (unless entry (user-error "No item at point")) + (let* ((type (car entry)) + (item (cdr entry))) + (pcase type + ('album + (let* ((id (plist-get item :id)) + (state (naviel--expanded-p id))) + (if state + ;; Collapse + (progn + (naviel--expand-set id nil) + (naviel--browser-rerender-current-view)) + ;; Expand – fetch if not cached + (let ((cached (plist-get state :songs))) + (if cached + (progn + (naviel--expand-set id `(:open t :songs ,cached)) + (naviel--browser-rerender-current-view)) + (message "naviel: loading tracks…") + (naviel--get-album + id (lambda (songs) + (when songs + (naviel--expand-set id `(:open t :songs ,songs)) + (naviel--browser-rerender-current-view))))))))) + ('artist + ;; For artists, RET already drills in; TAB does the same + (naviel--browser-enter)) + (_ (message "naviel: TAB expand not available for this item type")))))) -;;; Format callbacks +;;; Browser format callbacks (defun naviel--format-artist (artist _p) (let* ((id (plist-get artist :id)) @@ -922,9 +1090,14 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (defun naviel--format-album (album _p) (let* ((id (plist-get album :id)) (name (naviel--trunc (or (plist-get album :name) "?") 44)) - (y (plist-get album :year))) - (format " %s %-44s %s" + (y (plist-get album :year)) + (exp (naviel--expanded-p id)) + (tri (if exp + (propertize "▾ " 'face 'naviel-expand-indicator-face) + (propertize "▸ " 'face 'naviel-expand-indicator-face)))) + (format " %s%s %-44s %s" (naviel--star-glyph id) + tri (propertize name 'face 'naviel-album-face) (if (and y (not (string= y ""))) (propertize y 'face 'naviel-year-face) @@ -934,9 +1107,14 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (let* ((id (plist-get album :id)) (name (naviel--trunc (or (plist-get album :name) "?") 36)) (artist (naviel--trunc (or (plist-get album :artist) "") 26)) - (y (plist-get album :year))) - (format " %s %-36s %-26s %s" + (y (plist-get album :year)) + (exp (naviel--expanded-p id)) + (tri (if exp + (propertize "▾ " 'face 'naviel-expand-indicator-face) + (propertize "▸ " 'face 'naviel-expand-indicator-face)))) + (format " %s%s %-36s %-26s %s" (naviel--star-glyph id) + tri (propertize name 'face 'naviel-album-face) (propertize artist 'face 'naviel-artist-face) (if (and y (not (string= y ""))) @@ -960,6 +1138,22 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (propertize artist 'face 'naviel-song-meta-face) (propertize dur 'face 'naviel-song-meta-face)))) +(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)) + (title (naviel--trunc (or (plist-get song :title) "?") 38)) + (dur (naviel--format-secs + (let ((d (plist-get song :duration))) + (when (and d (not (string= d ""))) (string-to-number d)))))) + (format " %s %s %s %-38s %s" + (if playing (propertize "▶" 'face 'naviel-now-playing-face) + (propertize " " 'face 'naviel-separator-face)) + (naviel--star-glyph id) + (propertize (format "%3s" track) 'face 'naviel-track-num-face) + (propertize title 'face (if playing 'naviel-now-playing-face 'naviel-inline-song-face)) + (propertize dur 'face 'naviel-song-meta-face)))) + (defun naviel--format-playlist (pl _p) (let* ((name (naviel--trunc (or (plist-get pl :name) "?") 40)) (count (plist-get pl :count)) @@ -990,6 +1184,100 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (propertize name 'face 'naviel-radio-face) (propertize url 'face 'naviel-song-meta-face)))) +;;; Browser core render + +(defun naviel--browser-render (heading items format-fn type &optional section-label) + (setq naviel--current-view + `(:type ,type :items ,items :heading ,heading + :format-fn ,format-fn :section-label ,section-label)) + (naviel--browser-do-render heading items format-fn type section-label)) + +(defun naviel--browser-do-render (heading items format-fn type section-label) + (with-current-buffer (naviel--browser-buffer) + (naviel-browser-mode) + (let ((inhibit-read-only t) + (saved-line (line-number-at-pos))) + (erase-buffer) + (insert (naviel--make-breadcrumbs)) + (insert (propertize (format " %s\n" heading) 'face 'naviel-header-face)) + (insert (propertize (make-string (min 80 (window-width)) ?─) + 'face 'naviel-separator-face)) + (insert "\n") + (when section-label + (insert (propertize (format "\n %s\n\n" section-label) 'face 'naviel-breadcrumb-face))) + (if (null items) + (insert (propertize " (no results)\n" 'face 'shadow)) + (let ((idx 0)) + (dolist (item items) + (let* ((is-playing (and naviel--current-song + (eq type 'song) + (= idx naviel--queue-index) + (equal (plist-get item :id) + (plist-get naviel--current-song :id)))) + (line (funcall format-fn item is-playing))) + (insert (propertize line + 'naviel-item item 'naviel-type type 'naviel-idx idx + 'face (when is-playing 'naviel-now-playing-face))) + (insert "\n") + ;; Inline expand for albums + (when (and (eq type 'album) + (naviel--expanded-p (plist-get item :id))) + (naviel--browser-insert-expanded-album item idx))) + (cl-incf idx)))) + (insert (propertize (make-string (min 80 (window-width)) ?─) 'face 'naviel-separator-face)) + (insert "\n") + (insert (propertize + " RET play TAB expand a append SPC pause n/p skip * star l lyrics ? menu" + 'face 'naviel-footer-face)) + (insert "\n") + (goto-char (point-min)) + (forward-line (max 0 (- saved-line 1)))))) + +(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)) + (state (naviel--expanded-p album-id)) + (songs (plist-get state :songs))) + ;; Cover art (GUI only) + (when (and naviel-cover-art-display cover-id (not (string= cover-id "")) + (display-graphic-p) (image-type-available-p 'jpeg)) + ;; Insert a placeholder line; async fill will replace it + (insert (propertize (format " [cover loading…]\n") + 'face 'naviel-separator-face + 'naviel-cover-placeholder album-id)) + ;; Kick off async fetch + (naviel--fetch-and-insert-cover + cover-id "*Naviel Browser*" "cover loading" naviel-browser-cover-size)) + ;; Songs + (when songs + (setq naviel--queue songs naviel--queue-index 0) + (let ((sidx 0)) + (dolist (song songs) + (let* ((is-playing (and naviel--current-song + (equal (plist-get song :id) + (plist-get naviel--current-song :id)))) + (line (naviel--format-inline-song song is-playing sidx))) + (insert (propertize line + 'naviel-item song + 'naviel-type 'song + 'naviel-idx sidx + 'naviel-inline-song t + 'face (when is-playing 'naviel-now-playing-face))) + (insert "\n")) + (cl-incf sidx)))) + (insert (propertize " ──\n" 'face 'naviel-separator-face)))) + +;;; Breadcrumbs + +(defun naviel--make-breadcrumbs () + (if (null naviel--browser-stack) "" + (propertize + (concat " " (mapconcat (lambda (v) (plist-get v :label)) + (reverse naviel--browser-stack) " ❯ ") + "\n") + 'face 'naviel-breadcrumb-face))) + ;;; Render entry points (defun naviel--render-artists (artists) @@ -1009,130 +1297,43 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (or section (format "%d track%s" (length songs) (if (= 1 (length songs)) "" "s"))))) -;;; Queue buffer - -(defvar naviel-queue-mode-map - (let ((m (make-sparse-keymap))) - (define-key m (kbd "RET") #'naviel-queue-jump) - (define-key m (kbd "d") #'naviel-queue-remove) - (define-key m (kbd "D") #'naviel-queue-clear) - (define-key m (kbd "u") #'naviel-queue-move-up) - (define-key m (kbd "n") #'naviel--next-in-queue) - (define-key m (kbd "p") #'naviel--prev-in-queue) - (define-key m (kbd "SPC") #'naviel--toggle-pause) - (define-key m (kbd "s") #'naviel-queue-shuffle) - (define-key m (kbd "l") #'naviel-lyrics-show) - (define-key m (kbd "*") #'naviel-toggle-star) - (define-key m (kbd "q") #'quit-window) - m)) - -(define-derived-mode naviel-queue-mode special-mode "Naviel:Queue" - "Major mode for the Naviel queue buffer." - (setq buffer-read-only t) - (hl-line-mode 1)) - -(defun naviel--queue-buffer () (get-buffer-create "*Naviel Queue*")) +;;; Browser rerender -(defun naviel--queue-render-if-live () - (when (get-buffer "*Naviel Queue*") (naviel--queue-render))) - -(defun naviel--queue-render () - (with-current-buffer (naviel--queue-buffer) - (naviel-queue-mode) - (let ((inhibit-read-only t) (saved-line (line-number-at-pos))) - (erase-buffer) - (insert (propertize - (format " Queue (%d track%s)\n" - (length naviel--queue) - (if (= 1 (length naviel--queue)) "" "s")) - 'face 'naviel-header-face)) - (insert (propertize (make-string (min 80 (window-width)) ?─) - 'face 'naviel-separator-face)) - (insert "\n\n") - (if (null naviel--queue) - (insert (propertize " (queue is empty)\n" 'face 'shadow)) - (let ((idx 0)) - (dolist (song naviel--queue) - (let* ((id (plist-get song :id)) - (playing (= idx naviel--queue-index)) - (state (if playing - (propertize "▶" 'face 'naviel-now-playing-face) - " ")) - (title (naviel--trunc (or (plist-get song :title) "?") 36)) - (artist (naviel--trunc (or (plist-get song :artist) "") 24)) - (dur (naviel--format-secs - (let ((d (plist-get song :duration))) - (when (and d (not (string= d ""))) (string-to-number d))))) - (line (format " %s %s %3d %-36s %-24s %s\n" - state (naviel--star-glyph id) - (1+ idx) title artist dur))) - (insert (propertize line - 'face (when playing 'naviel-now-playing-face) - 'naviel-queue-idx idx - 'naviel-item song - 'naviel-type 'song))) - (cl-incf idx)))) - (insert "\n") - (insert (propertize - " RET jump d remove D clear u move-up s shuffle * star l lyrics q quit" - 'face 'naviel-footer-face)) - (insert "\n") - (goto-char (point-min)) - (forward-line (max 0 (- saved-line 1)))))) - -(defun naviel-queue-show () - (interactive) - (naviel--queue-render) - (pop-to-buffer (naviel--queue-buffer) '(display-buffer-reuse-window))) - -(defun naviel-queue-jump () - (interactive) - (let ((idx (get-text-property (point) 'naviel-queue-idx))) - (if idx (naviel--play-queue-index idx) (user-error "No track at point")))) +(defun naviel--browser-rerender-current-view () + (when (and naviel--current-view (get-buffer "*Naviel Browser*")) + (naviel--browser-do-render + (plist-get naviel--current-view :heading) + (plist-get naviel--current-view :items) + (plist-get naviel--current-view :format-fn) + (plist-get naviel--current-view :type) + (plist-get naviel--current-view :section-label)))) -(defun naviel-queue-remove () - (interactive) - (let ((idx (get-text-property (point) 'naviel-queue-idx))) - (unless idx (user-error "No track at point")) - (setq naviel--queue (append (seq-take naviel--queue idx) - (seq-drop naviel--queue (1+ idx)))) - (when (and (> naviel--queue-index idx) (> naviel--queue-index 0)) - (cl-decf naviel--queue-index)) - (when (>= naviel--queue-index (length naviel--queue)) - (setq naviel--queue-index (max 0 (1- (length naviel--queue))))) - (naviel--queue-render) - (naviel--rerender-current-view))) - -(defun naviel-queue-clear () - (interactive) - (when (yes-or-no-p "Clear the entire queue? ") - (naviel--stop) - (setq naviel--queue '() naviel--queue-index 0) - (naviel--queue-render) - (naviel--rerender-current-view))) +;;; Star / unstar -(defun naviel-queue-move-up () +(defun naviel-toggle-star-current () + "Toggle star on the currently playing song." (interactive) - (let ((idx (get-text-property (point) 'naviel-queue-idx))) - (unless idx (user-error "No track at point")) - (when (= idx 0) (user-error "Already at the top")) - (let ((prev (1- idx))) - (cl-rotatef (nth idx naviel--queue) (nth prev naviel--queue)) - (cond ((= naviel--queue-index idx) (setq naviel--queue-index prev)) - ((= naviel--queue-index prev) (setq naviel--queue-index idx)))) - (naviel--queue-render) - (forward-line -1))) - -;;; Star / unstar + (unless naviel--current-song (user-error "naviel: nothing playing")) + (let* ((song naviel--current-song) + (id (plist-get song :id))) + (if (naviel--starred-p id) + (naviel--api-unstar id 'song + (lambda (ok) + (when ok + (naviel--star-cache-set id nil) + (naviel--player-render) + (message "naviel: unstarred")))) + (naviel--api-star id 'song + (lambda (ok) + (when ok + (naviel--star-cache-set id t) + (naviel--player-render) + (message "naviel: starred ★"))))))) (defun naviel-toggle-star () - "Toggle the star on the item at point (or currently playing song)." + "Toggle star on the item at point." (interactive) - (let* ((entry (cond - ((eq major-mode 'naviel-queue-mode) - (let ((idx (get-text-property (point) 'naviel-queue-idx))) - (when idx (cons 'song (nth idx naviel--queue))))) - (t (naviel--item-at-point)))) + (let* ((entry (naviel--item-at-point)) (type (car entry)) (item (cdr entry)) (id (or (plist-get item :id) (plist-get item :name)))) @@ -1143,15 +1344,13 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (lambda (ok) (when ok (naviel--star-cache-set id nil) - (naviel--rerender-current-view) - (naviel--queue-render-if-live) + (naviel--browser-rerender-current-view) (message "naviel: unstarred")))) (naviel--api-star id type (lambda (ok) (when ok (naviel--star-cache-set id t) - (naviel--rerender-current-view) - (naviel--queue-render-if-live) + (naviel--browser-rerender-current-view) (message "naviel: starred ★"))))))) ;;; Browser mode @@ -1159,6 +1358,7 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (defvar naviel-browser-mode-map (let ((m (make-sparse-keymap))) (define-key m (kbd "RET") #'naviel--browser-enter) + (define-key m (kbd "TAB") #'naviel-browser-toggle-expand) (define-key m (kbd "a") #'naviel--browser-append) (define-key m (kbd "*") #'naviel-toggle-star) (define-key m (kbd "SPC") #'naviel--toggle-pause) @@ -1177,8 +1377,6 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (define-key m (kbd "q") #'naviel--browser-quit) (define-key m (kbd "?") #'naviel-dispatch) (define-key m (kbd "R") #'naviel-toggle-repeat) - (define-key m (kbd "Q") #'naviel-queue-show) - (define-key m (kbd "S") #'naviel-queue-shuffle) (define-key m (kbd "P") #'naviel-playlists) (define-key m (kbd "N") #'naviel-recently-added) (define-key m (kbd "H") #'naviel-recently-played) @@ -1187,8 +1385,8 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (define-key m (kbd "I") #'naviel-radio) m)) -(define-derived-mode naviel-browser-mode special-mode "Naviel" - "Major mode for browsing and controlling Naviel playback." +(define-derived-mode naviel-browser-mode special-mode "Naviel:Browser" + "Major mode for the Naviel music browser." (setq buffer-read-only t) (setq-local revert-buffer-function #'naviel--browser-refresh) (hl-line-mode 1)) @@ -1213,6 +1411,7 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (if albums (naviel--render-albums albums name) (pop naviel--browser-stack)))))) ('album + ;; RET on an album: load and play (let ((id (plist-get item :id)) (name (plist-get item :name))) (push `(:type album :data ,id :label ,name) naviel--browser-stack) (message "naviel: loading tracks…") @@ -1220,7 +1419,8 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." id (lambda (songs) (if songs (progn (setq naviel--queue songs naviel--queue-index 0) - (naviel--render-songs songs name)) + (naviel--render-songs songs name) + (naviel--play-queue-index 0)) (pop naviel--browser-stack)))))) ('playlist (let ((id (plist-get item :id)) (name (plist-get item :name))) @@ -1230,7 +1430,8 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." id (lambda (songs) (if songs (progn (setq naviel--queue songs naviel--queue-index 0) - (naviel--render-songs songs name)) + (naviel--render-songs songs name) + (naviel--play-queue-index 0)) (pop naviel--browser-stack)))))) ('genre (let* ((name (plist-get item :name)) @@ -1248,11 +1449,10 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (pop naviel--browser-stack) (message "naviel: no songs for "%s"" name)))))) ('radio - ;; Play radio station directly (let* ((name (plist-get item :name)) (url (plist-get item :stream-url)) (pseudo-song `(:id ,url :title ,name :artist "Internet Radio" - :album "" :duration ""))) + :album "" :duration "" :coverArt ""))) (naviel--play-url url pseudo-song))) ('song (naviel--play-queue-index (get-text-property (point) 'naviel-idx))) @@ -1376,65 +1576,111 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." ("I" "Internet radio" naviel-radio)] ["Item / Queue" ("RET" "Play / expand" naviel--browser-enter) + ("TAB" "Inline expand" naviel-browser-toggle-expand) ("a" "Append to queue" naviel--browser-append) ("*" "Toggle star" naviel-toggle-star) ("l" "Show lyrics" naviel-lyrics-show) - ("Q" "Queue window" naviel-queue-show) ("S" "Shuffle queue" naviel-queue-shuffle) ("b" "Back" naviel--browser-back) ("g" "Refresh" naviel--browser-refresh)]]) -;;; Public commands +;;; Public commands ;;;###autoload (defun naviel () - "Open the Naviel music browser." + "Open the Naviel music browser and player." (interactive) (unless (executable-find naviel-mpv-executable) (error "naviel: mpv not found on PATH")) - (pop-to-buffer (naviel--browser-buffer)) + (naviel--ensure-layout) + (naviel--player-render) (naviel--show-artists)) ;;;###autoload (defun naviel-lyrics-show () - "Fetch and display lyrics for the song at point or currently playing." + "Fetch and display full lyrics for the song at point or currently playing." (interactive) - (let* ((entry (cond - ((eq major-mode 'naviel-queue-mode) - (let ((idx (get-text-property (point) 'naviel-queue-idx))) - (when idx (cons 'song (nth idx naviel--queue))))) - (t (naviel--item-at-point)))) + (let* ((entry (naviel--item-at-point)) (type (car entry)) (item (cdr entry)) (song (if (eq type 'song) item naviel--current-song))) (unless song (user-error "naviel: no song at point or currently playing")) (let ((artist (or (plist-get song :artist) "")) (title (or (plist-get song :title) ""))) - ;; Fixed: Added backslashes to escape quotes around %s (message "naviel: fetching lyrics for \"%s\"…" title) - (naviel--get-lyrics - artist title - (lambda (lyrics) - (with-current-buffer (get-buffer-create "*Naviel Lyrics*") - (let ((inhibit-read-only t)) - (erase-buffer) - (special-mode) - (local-set-key (kbd "q") #'quit-window) - (insert (propertize (format " %s\n" title) 'face 'naviel-header-face)) - (insert (propertize (format " %s\n" artist) 'face 'naviel-artist-face)) - (insert (propertize (make-string (min 80 (window-width)) ?─) - 'face 'naviel-separator-face)) - (insert "\n\n") - (if lyrics - (progn - (insert (propertize " " 'face 'default)) - (insert (replace-regexp-in-string "\n" "\n " lyrics))) - (insert (propertize " No lyrics found for this track." 'face 'shadow))) - (insert "\n\n") - (insert (propertize " q close" 'face 'naviel-footer-face)) - (insert "\n") - (goto-char (point-min)))) - (pop-to-buffer "*Naviel Lyrics*")))))) + ;; Try lrclib first, fall back to Subsonic getLyrics + (naviel--lrclib-fetch-full song + (lambda (synced plain) + (naviel--show-lyrics-buffer title artist synced plain)))))) + +(defun naviel--lrclib-fetch-full (song callback) + "Fetch full lyrics from lrclib for SONG. CALLBACK receives (synced plain)." + (let* ((title (or (plist-get song :title) "")) + (artist (or (plist-get song :artist) "")) + (album (or (plist-get song :album) "")) + (dur-str (plist-get song :duration)) + (duration (if (and dur-str (not (string= dur-str ""))) + (string-to-number dur-str) 0)) + (url (concat "https://lrclib.net/api/get?" + "track_name=" (url-hexify-string title) + "&artist_name=" (url-hexify-string artist) + "&album_name=" (url-hexify-string album) + (when (> duration 0) + (format "&duration=%d" (round duration)))))) + (url-retrieve + url + (lambda (status) + (if (plist-get status :error) + (funcall callback nil nil) + (condition-case nil + (progn + (goto-char (point-min)) + (re-search-forward "\r?\n\r?\n" nil t) + (set-buffer-multibyte t) + (decode-coding-region (point) (point-max) 'utf-8) + (let* ((json-object-type 'alist) + (json-array-type 'list) + (data (json-read)) + (synced (cdr (assq 'syncedLyrics data))) + (plain (cdr (assq 'plainLyrics data)))) + (kill-buffer (current-buffer)) + (funcall callback + (when (and synced (not (eq synced :null)) + (not (string= synced ""))) synced) + (when (and plain (not (eq plain :null)) + (not (string= plain ""))) plain)))) + (error (ignore-errors (kill-buffer (current-buffer))) + (funcall callback nil nil))))) + nil t))) + +(defun naviel--show-lyrics-buffer (title artist synced plain) + (with-current-buffer (get-buffer-create "*Naviel Lyrics*") + (let ((inhibit-read-only t)) + (erase-buffer) + (special-mode) + (local-set-key (kbd "q") #'quit-window) + (insert (propertize (format " %s\n" title) 'face 'naviel-header-face)) + (insert (propertize (format " %s\n" artist) 'face 'naviel-artist-face)) + (insert (propertize (make-string (min 80 (window-width)) ?─) 'face 'naviel-separator-face)) + (insert "\n\n") + (cond + (synced + (insert (propertize " (synced · lrclib.net)\n\n" 'face 'naviel-footer-face)) + (dolist (entry (naviel--parse-lrc synced)) + (insert (format " %s\n" + (propertize (cdr entry) 'face 'naviel-lyrics-nosync-face))))) + (plain + (insert (propertize " (plain · lrclib.net)\n\n" 'face 'naviel-footer-face)) + (dolist (ln (split-string plain "\n")) + (insert (format " %s\n" (propertize ln 'face 'naviel-lyrics-nosync-face))))) + (t + (insert (propertize " No lyrics found on lrclib.net.\n" 'face 'shadow)))) + (insert "\n") + (insert (propertize " q close" 'face 'naviel-footer-face)) + (insert "\n") + (goto-char (point-min)))) + (pop-to-buffer "*Naviel Lyrics*")) + ;;;###autoload (defun naviel-starred-view () "Show all starred artists, albums, and songs." @@ -1447,7 +1693,6 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (let ((artists (plist-get results :artists)) (albums (plist-get results :albums)) (songs (plist-get results :songs))) - ;; Pre-populate star cache (dolist (a artists) (naviel--star-cache-set (plist-get a :id) t)) (dolist (al albums) (naviel--star-cache-set (plist-get al :id) t)) (dolist (s songs) (naviel--star-cache-set (plist-get s :id) t)) @@ -1486,8 +1731,10 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (cl-incf idx)))) (when (and (null artists) (null albums) (null songs)) (insert (propertize " Nothing starred yet.\n" 'face 'shadow)))) - (insert (propertize naviel--footer-sep 'invisible t)) - (insert (naviel--now-playing-bar)) + (insert (propertize (make-string (min 80 (window-width)) ?─) 'face 'naviel-separator-face)) + (insert "\n") + (insert (propertize " RET play TAB expand a append * star ? menu" 'face 'naviel-footer-face)) + (insert "\n") (goto-char (point-min)) (forward-line 3))))))) @@ -1517,7 +1764,6 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (let ((artists (plist-get results :artists)) (albums (plist-get results :albums)) (songs (plist-get results :songs))) - ;; Prime star cache from results (naviel--prime-star-cache artists :id :starred) (naviel--prime-star-cache albums :id :starred) (naviel--prime-star-cache songs :id :starred) @@ -1540,7 +1786,7 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." 'naviel-idx idx)) (insert "\n") (cl-incf idx)))))) (section "Artists" artists 'artist #'naviel--format-artist) - (section "Albums" albums 'album #'naviel--format-album) + (section "Albums" albums 'album #'naviel--format-album-with-artist) (when songs (insert (propertize "\n Songs\n" 'face 'naviel-breadcrumb-face)) (setq naviel--queue songs naviel--queue-index 0) @@ -1548,13 +1794,13 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." (dolist (s songs) (insert (propertize (naviel--format-song s nil) 'naviel-item s 'naviel-type 'song 'naviel-idx idx)) - (insert "\n") (cl-incf idx))) - (message "naviel: %d song%s in queue" - (length songs) (if (= 1 (length songs)) "" "s"))) + (insert "\n") (cl-incf idx)))) (when (and (null artists) (null albums) (null songs)) (insert (propertize " No results found.\n" 'face 'shadow)))) - (insert (propertize naviel--footer-sep 'invisible t)) - (insert (naviel--now-playing-bar)) + (insert (propertize (make-string (min 80 (window-width)) ?─) 'face 'naviel-separator-face)) + (insert "\n") + (insert (propertize " RET play TAB expand a append * star ? menu" 'face 'naviel-footer-face)) + (insert "\n") (goto-char (point-min)) (forward-line 3))))))) @@ -1615,19 +1861,6 @@ Uses icat (kitty/WezTerm), viu, or sixel if available; else skips." albums #'naviel--format-album-with-artist 'album)))))) ;;;###autoload -(defun naviel-starred-albums (&optional size) - "Show up to SIZE starred albums via getAlbumList2." - (interactive "P") - (let ((n (if size (prefix-numeric-value size) naviel-album-list-size))) - (naviel--get-album-list2 - "starred" n - (lambda (albums) - (if (null albums) (message "naviel: no starred albums") - (push `(:type album-list2 :data "starred" :label "Starred Albums") naviel--browser-stack) - (naviel--browser-render (format "Starred Albums (%d)" (length albums)) - albums #'naviel--format-album-with-artist 'album)))))) - -;;;###autoload (defun naviel-genres () (interactive) (message "naviel: loading genres…") |
