summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xnaviel.el1017
1 files changed, 625 insertions, 392 deletions
diff --git a/naviel.el b/naviel.el
index 2522175..b9742aa 100755
--- a/naviel.el
+++ b/naviel.el
@@ -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…")