summaryrefslogtreecommitdiff
path: root/naviel.el
diff options
context:
space:
mode:
authorDavid Moc <personal@cdatgoose.org>2026-04-04 22:54:19 +0200
committerDavid Moc <personal@cdatgoose.org>2026-04-04 22:54:19 +0200
commitf2d36a2e6ebf3b7d6cee6714481726b6f2ad4e3d (patch)
tree0bb1312ce53a619ae87698be8b11128761a06747 /naviel.el
parent034ee0e21c3943f4b4c5c0ce05d2a41cb6999f43 (diff)
Added playlist view, lyrics shuffle queue. Revised UI a bit.
Signed-off-by: David Moc <personal@cdatgoose.org> Modified-by: David Moc <personal@cdatgoose.org>
Diffstat (limited to 'naviel.el')
-rwxr-xr-xnaviel.el829
1 files changed, 466 insertions, 363 deletions
diff --git a/naviel.el b/naviel.el
index 9c832b9..473e023 100755
--- a/naviel.el
+++ b/naviel.el
@@ -1,21 +1,19 @@
;;; naviel.el --- Navidrome music player client for Emacs -*- lexical-binding: t -*-
;; Author: CdatGoose
-;; Version: 0.1
+;; Version: 0.2
;; Package-Requires: ((emacs "28.1"))
;; 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 tegether by sheer hope, I don't know elisp and probably never will.
-
-
+;; This project is glued together by sheer hope, I don't know elisp and probably never will.
;; Quick setup:
;; 1. ~/.authinfo: machine your-host login USER password PASS
;; 2. (setq naviel-url "http://host:4533")
;; 3. M-x naviel
-
+;; Code:
(require 'url)
(require 'url-util)
@@ -23,116 +21,99 @@
(require 'auth-source)
(require 'cl-lib)
(require 'json)
+(require 'seq)
-;;; Customisation
-
+;;; Customisation
(defgroup naviel nil
"Naviel music client."
:group 'multimedia
:prefix "naviel-")
-
(defcustom naviel-url "http://localhost:4533"
"Base URL of the Navidrome server (no trailing slash)."
:type 'string :group 'naviel)
-
(defcustom naviel-username nil
"Username. If nil, looked up from ~/.authinfo."
:type '(choice (const nil) string) :group 'naviel)
-
(defcustom naviel-password nil
"Password. If nil, looked up from ~/.authinfo."
:type '(choice (const nil) string) :group 'naviel)
-
(defcustom naviel-client-name "naviel.el"
"Client identifier sent with every API request."
:type 'string :group 'naviel)
-
(defcustom naviel-api-version "1.16.1"
"Subsonic API version declared in requests."
:type 'string :group 'naviel)
-
(defcustom naviel-mpv-executable "mpv"
"Path to the mpv executable."
:type 'string :group 'naviel)
-
(defcustom naviel-volume-step 5
"Volume increment/decrement step (0-100)."
:type 'integer :group 'naviel)
-
(defcustom naviel-seek-step 10
- "Seek step in seconds for the < / > keys."
+ "Seek step in seconds for the [ / ] keys."
:type 'integer :group 'naviel)
-
(defcustom naviel-position-poll-interval 1.0
"Seconds between IPC polls for playback position."
:type 'float :group 'naviel)
+(defcustom naviel-album-list-size 30
+ "Default number of albums fetched for recently-added/played views."
+ :type 'integer :group 'naviel)
+(defcustom naviel-genre-song-limit 200
+ "Maximum songs fetched when browsing a genre."
+ :type 'integer :group 'naviel)
-;;; Faces
-
+;;; Faces
(defface naviel-header-face '((t :inherit bold :height 1.15)) "Main header.")
-(defface naviel-separator-face '((t :inherit shadow)) "Separator lines.")
+(defface naviel-separator-face '((t :inherit shadow)) "Separator lines.")
(defface naviel-artist-face '((t :inherit font-lock-function-name-face
- :weight semi-bold)) "Artist names.")
-(defface naviel-album-face '((t :inherit font-lock-string-face)) "Album names.")
-(defface naviel-song-title-face '((t :inherit default)) "Song titles.")
-(defface naviel-song-meta-face '((t :inherit shadow)) "Song metadata.")
-(defface naviel-track-num-face '((t :inherit shadow)) "Track numbers.")
-(defface naviel-year-face '((t :inherit shadow)) "Album years.")
-(defface naviel-now-playing-face '((t :inherit success :weight bold)) "Now-playing text.")
-(defface naviel-paused-face '((t :inherit warning :weight bold)) "Paused indicator.")
-(defface naviel-footer-face '((t :inherit shadow :height 0.85)) "Footer hints.")
-(defface naviel-count-face '((t :inherit shadow)) "Item counts.")
-(defface naviel-breadcrumb-face '((t :inherit font-lock-comment-face)) "Breadcrumb trail.")
-(defface naviel-repeat-face '((t :inherit font-lock-keyword-face)) "Repeat mode.")
-(defface naviel-error-face '((t :inherit error)) "In-buffer errors.")
-(defface naviel-position-face '((t :inherit shadow)) "Playback position.")
+ :weight semi-bold)) "Artist names.")
+(defface naviel-album-face '((t :inherit font-lock-string-face)) "Album names.")
+(defface naviel-song-title-face '((t :inherit default)) "Song titles.")
+(defface naviel-song-meta-face '((t :inherit shadow)) "Song metadata.")
+(defface naviel-track-num-face '((t :inherit shadow)) "Track numbers.")
+(defface naviel-year-face '((t :inherit shadow)) "Album years.")
+(defface naviel-now-playing-face '((t :inherit success :weight bold)) "Now-playing text.")
+(defface naviel-paused-face '((t :inherit warning :weight bold)) "Paused indicator.")
+(defface naviel-footer-face '((t :inherit shadow :height 0.85)) "Footer hints.")
+(defface naviel-count-face '((t :inherit shadow)) "Item counts.")
+(defface naviel-breadcrumb-face '((t :inherit font-lock-comment-face)) "Breadcrumb trail.")
+(defface naviel-repeat-face '((t :inherit font-lock-keyword-face)) "Repeat mode.")
+(defface naviel-error-face '((t :inherit error)) "In-buffer errors.")
+(defface naviel-position-face '((t :inherit shadow)) "Playback position.")
;;; Repeat mode
-
(defvar naviel-repeat-mode 'off
"Playback repeat mode: \\='off, \\='one, or \\='album.")
-
(defun naviel-toggle-repeat ()
- "Cycle repeat mode: off → one → album → off."
+ "Cycle repeat mode: off -> one -> album -> off."
(interactive)
(setq naviel-repeat-mode
(pcase naviel-repeat-mode ('off 'one) ('one 'album) (_ 'off)))
(naviel--refresh-footer)
(message "Repeat: %s" (naviel--repeat-label)))
-
(defun naviel--repeat-label ()
(pcase naviel-repeat-mode ('one "one") ('album "all") (_ "off")))
-;;; Internal state
-
-;; mpv process and IPC
-(defvar naviel--process nil "The mpv OS process.")
-(defvar naviel--ipc-process nil "Persistent mpv IPC network process.")
-(defvar naviel--ipc-buffer "" "Accumulated un-parsed IPC bytes.")
-(defvar naviel--ipc-request-id 0 "Monotonically increasing request id.")
-(defvar naviel--ipc-callbacks nil "Alist of (id . callback) for pending IPC requests.")
-(defvar naviel--position-timer nil "Timer that polls mpv for time-pos.")
-(defvar naviel--elapsed nil "Elapsed seconds (float or nil).")
-(defvar naviel--duration nil "Track duration in seconds (float or nil).")
-
-;; Queue / playback
-(defvar naviel--queue '() "Current play queue — list of song plists.")
-(defvar naviel--queue-index 0 "Index of the currently playing track.")
-(defvar naviel--volume 80 "Current volume 0-100.")
-(defvar naviel--paused nil "Non-nil when paused.")
-(defvar naviel--current-song nil "Plist of the currently playing song.")
-
-;; Browser
-(defvar naviel--browser-stack '() "Navigation stack; each entry is a plist.")
-(defvar naviel--current-view nil "Plist describing the rendered view.")
-
-;; Mode line
-(defvar naviel--mode-line-timer nil "Timer for mode-line refreshes.")
-
-;;; Authentication
-
+;;; Internal state
+(defvar naviel--process nil)
+(defvar naviel--ipc-process nil)
+(defvar naviel--ipc-buffer "")
+(defvar naviel--ipc-request-id 0)
+(defvar naviel--ipc-callbacks nil)
+(defvar naviel--position-timer nil)
+(defvar naviel--elapsed nil)
+(defvar naviel--duration nil)
+(defvar naviel--queue '())
+(defvar naviel--queue-index 0)
+(defvar naviel--volume 80)
+(defvar naviel--paused nil)
+(defvar naviel--current-song nil)
+(defvar naviel--browser-stack '())
+(defvar naviel--current-view nil)
+(defvar naviel--mode-line-timer nil)
+
+;;; Authentication
(defun naviel--credentials ()
- "Return (USER . PASS) from customisation variables or ~/.authinfo."
(let* ((host (url-host (url-generic-parse-url naviel-url)))
(user (or naviel-username
(plist-get (car (auth-source-search :host host :require '(:user))) :user)))
@@ -141,32 +122,22 @@
(s (plist-get entry :secret)))
(when s (if (functionp s) (funcall s) s))))))
(unless (and user pass)
- (error "naviel: no credentials for %s — set naviel-username/naviel-password or use ~/.authinfo"
- host))
+ (error "naviel: no credentials for %s - set naviel-username/naviel-password or use ~/.authinfo" host))
(cons user pass)))
(defun naviel--auth-params ()
- "Return alist of Subsonic token-auth query parameters."
(let* ((creds (naviel--credentials))
(salt (naviel--random-salt 10))
(token (md5 (concat (cdr creds) salt))))
- `(("u" . ,(car creds))
- ("t" . ,token)
- ("s" . ,salt)
- ("v" . ,naviel-api-version)
- ("c" . ,naviel-client-name)
- ("f" . "xml"))))
+ `(("u" . ,(car creds)) ("t" . ,token) ("s" . ,salt)
+ ("v" . ,naviel-api-version) ("c" . ,naviel-client-name) ("f" . "xml"))))
(defun naviel--random-salt (len)
(let ((chars "abcdefghijklmnopqrstuvwxyz0123456789") (r ""))
(dotimes (_ len r)
(setq r (concat r (string (aref chars (random (length chars)))))))))
-;;; Async HTTP / API
-;;
-;; Every API call is non-blocking. CALLBACK is called with the parsed
-;; result on success, or nil on any error (an in-buffer message is shown).
-
+;;; Async HTTP / API
(defun naviel--build-url (endpoint params)
(let* ((all (append (naviel--auth-params) params))
(q (mapconcat
@@ -175,7 +146,6 @@
(format "%s/rest/%s?%s" naviel-url endpoint q)))
(defun naviel--request-async (endpoint params parse-fn callback)
- "GET ENDPOINT asynchronously. Call (CALLBACK (PARSE-FN xml)) on success."
(let ((url (naviel--build-url endpoint params)))
(url-retrieve
url
@@ -183,8 +153,7 @@
(if (plist-get status :error)
(progn
(ignore-errors (kill-buffer (current-buffer)))
- (naviel--show-error
- (format "Could not reach %s — is the server running?" naviel-url))
+ (naviel--show-error (format "Could not reach %s - is the server running?" naviel-url))
(funcall callback nil))
(condition-case err
(progn
@@ -218,41 +187,26 @@
(defun naviel--attr (node attr) (xml-get-attribute node attr))
;;; Error display
-
(defun naviel--show-error (msg)
- "Display MSG as a friendly in-buffer error (no raw Lisp tracebacks)."
(with-current-buffer (naviel--browser-buffer)
(naviel-browser-mode)
(let ((inhibit-read-only t))
(erase-buffer)
- (insert (propertize "\n ✗ naviel error\n\n" 'face 'naviel-error-face))
+ (insert (propertize "\n X naviel error\n\n" 'face 'naviel-error-face))
(insert (propertize (format " %s\n\n" msg) 'face 'naviel-error-face))
(insert (propertize " Press g to retry, q to quit.\n" 'face 'naviel-footer-face))
(goto-char (point-min))))
(pop-to-buffer (naviel--browser-buffer)))
;;; Stream URL
-
(defun naviel--stream-url (song-id)
(naviel--build-url "stream" `(("id" . ,song-id))))
;;; mpv IPC
-;;
-;; naviel--ipc-process is a Unix socket client kept alive for the
-;; lifetime of a track. Its filter accumulates newline-delimited JSON
-;; from mpv and dispatches each complete line to naviel--ipc-dispatch.
-;;
-;; Outgoing commands use naviel--ipc-send, which optionally registers a
-;; one-shot callback keyed by request_id so callers can receive results.
-;;
-;; mpv also emits unsolicited "event" objects; naviel--ipc-handle-event
-;; handles the ones we care about (end-file, pause, unpause).
-
(defun naviel--ipc-socket-path ()
(expand-file-name "naviel-mpv.sock" temporary-file-directory))
(defun naviel--ipc-connect ()
- "Open a persistent connection to the mpv IPC socket."
(let ((sock (naviel--ipc-socket-path))
(limit (+ (float-time) 1.5)))
(while (and (not (file-exists-p sock)) (< (float-time) limit))
@@ -268,10 +222,9 @@
:sentinel #'naviel--ipc-sentinel
:coding 'utf-8-unix)
naviel--ipc-buffer "")
- (error (message "naviel: IPC connect failed — %s" (error-message-string err)))))))
+ (error (message "naviel: IPC connect failed - %s" (error-message-string err)))))))
(defun naviel--ipc-filter (_proc string)
- "Accumulate STRING and dispatch complete JSON lines."
(setq naviel--ipc-buffer (concat naviel--ipc-buffer string))
(let ((start 0))
(while (string-match "\n" naviel--ipc-buffer start)
@@ -287,33 +240,28 @@
(setq naviel--ipc-process nil)))
(defun naviel--ipc-dispatch (json-line)
- "Parse one JSON LINE from mpv and act on it."
(condition-case nil
(let* ((obj (json-read-from-string json-line))
(event (cdr (assq 'event obj)))
(req-id (cdr (assq 'request_id obj)))
(data (cdr (assq 'data obj)))
(err (cdr (assq 'error obj))))
- ;; One-shot request callbacks.
(when req-id
(let ((cb (cdr (assq req-id naviel--ipc-callbacks))))
(when cb
(setq naviel--ipc-callbacks (assq-delete-all req-id naviel--ipc-callbacks))
(when (or (null err) (string= err "success"))
(funcall cb data)))))
- ;; Unsolicited events.
(when event (naviel--ipc-handle-event event)))
(error nil)))
(defun naviel--ipc-handle-event (event)
- "Dispatch mpv EVENT string to the appropriate handler."
(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))))
(defun naviel--ipc-send (command &optional callback)
- "Send COMMAND list to mpv IPC. Call CALLBACK with the data field if provided."
(when (and naviel--ipc-process (process-live-p naviel--ipc-process))
(cl-incf naviel--ipc-request-id)
(let* ((id naviel--ipc-request-id)
@@ -326,13 +274,10 @@
(error (message "naviel IPC send: %s" (error-message-string err)))))))
;;; Playback position polling
-
(defun naviel--start-position-timer ()
(naviel--stop-position-timer)
(setq naviel--position-timer
- (run-with-timer naviel-position-poll-interval
- naviel-position-poll-interval
- #'naviel--poll-position)))
+ (run-with-timer naviel-position-poll-interval naviel-position-poll-interval #'naviel--poll-position)))
(defun naviel--stop-position-timer ()
(when naviel--position-timer
@@ -340,7 +285,6 @@
(setq naviel--position-timer nil)))
(defun naviel--poll-position ()
- "Ask mpv for time-pos and duration; update state and refresh footer."
(when (and naviel--ipc-process (process-live-p naviel--ipc-process))
(naviel--ipc-send '("get_property" "time-pos")
(lambda (v) (setq naviel--elapsed (and (numberp v) v))
@@ -348,80 +292,60 @@
(naviel--ipc-send '("get_property" "duration")
(lambda (v) (setq naviel--duration (and (numberp v) v))))))
-;;; mpv process management
-
+;;; mpv process management
(defun naviel--stop ()
- "Stop playback and tear down all mpv-related state."
(naviel--stop-position-timer)
- (when (and naviel--ipc-process (process-live-p naviel--ipc-process))
- (delete-process naviel--ipc-process))
+ (when (and naviel--ipc-process (process-live-p naviel--ipc-process)) (delete-process naviel--ipc-process))
(setq naviel--ipc-process nil naviel--ipc-buffer "")
- (when (and naviel--process (process-live-p naviel--process))
- (delete-process naviel--process))
+ (when (and naviel--process (process-live-p naviel--process)) (delete-process naviel--process))
(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))
+ (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))
(defun naviel--play-url (url song-info)
- "Launch mpv on URL and track SONG-INFO as the current song."
(naviel--stop)
- (setq naviel--current-song song-info
- naviel--paused nil
- naviel--elapsed nil
- naviel--duration nil)
+ (setq naviel--current-song song-info naviel--paused nil naviel--elapsed nil naviel--duration nil)
(let* ((sock (naviel--ipc-socket-path))
- (proc (start-process "naviel-mpv" nil
- naviel-mpv-executable
+ (proc (start-process "naviel-mpv" nil naviel-mpv-executable
"--no-video" "--quiet"
- (format "--volume=%d" naviel--volume)
- (format "--input-ipc-server=%s" sock)
- url)))
+ (format "--volume=%d" naviel--volume)
+ (format "--input-ipc-server=%s" sock) url)))
(setq naviel--process proc))
- ;; Connect IPC after a brief delay for mpv to initialise.
(run-with-timer 0.3 nil #'naviel--ipc-connect)
(run-with-timer 0.6 nil #'naviel--start-position-timer)
(naviel--refresh-footer)
- (message "♪ %s — %s"
- (plist-get song-info :title)
- (plist-get song-info :artist)))
+ (message "Playing: %s - %s" (plist-get song-info :title) (plist-get song-info :artist)))
(defun naviel--toggle-pause ()
- "Toggle pause/resume via IPC — no restart."
(interactive)
(if (and naviel--process (process-live-p naviel--process))
(naviel--ipc-send '("cycle" "pause"))
(message "naviel: nothing is playing")))
(defun naviel--seek (seconds)
- "Seek SECONDS relative to the current position."
(if (and naviel--process (process-live-p naviel--process))
(naviel--ipc-send `("seek" ,seconds "relative"))
(message "naviel: nothing is playing")))
-(defun naviel-seek-forward () "Seek forward." (interactive) (naviel--seek naviel-seek-step))
-(defun naviel-seek-backward () "Seek backward." (interactive) (naviel--seek (- naviel-seek-step)))
+(defun naviel-seek-forward () (interactive) (naviel--seek naviel-seek-step))
+(defun naviel-seek-backward () (interactive) (naviel--seek (- naviel-seek-step)))
;;; Queue management
-
(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--rerender-current-view)
+ (naviel--queue-render-if-live)))
(defun naviel--next-in-queue ()
- "Advance to the next track, obeying repeat mode."
(interactive)
(pcase naviel-repeat-mode
('one (naviel--play-queue-index naviel--queue-index))
- ('album (naviel--play-queue-index
- (mod (1+ naviel--queue-index) (max 1 (length naviel--queue)))))
+ ('album (naviel--play-queue-index (mod (1+ naviel--queue-index) (max 1 (length naviel--queue)))))
(_ (let ((next (1+ naviel--queue-index)))
(if (< next (length naviel--queue))
(naviel--play-queue-index next)
@@ -448,242 +372,354 @@
(naviel--refresh-footer)
(message "Volume: %d%%" naviel--volume))
-;;; API parse helpers
+(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)
+ (message "naviel: appended %d track%s from \"%s\"" (length songs) (if (= 1 (length songs)) "" "s") label)))
+
+(defun naviel-queue-shuffle ()
+ (interactive)
+ (when (null naviel--queue) (user-error "Queue is empty"))
+ (let* ((vec (vconcat naviel--queue)) (len (length vec)))
+ (dotimes (i (1- len))
+ (let* ((j (+ i (random (- len i)))) (tmp (aref vec i)))
+ (aset vec i (aref vec j)) (aset vec j tmp)))
+ (setq naviel--queue (append vec nil)))
+ (when naviel--current-song
+ (let* ((cur-id (plist-get naviel--current-song :id))
+ (pos (cl-position-if (lambda (s) (equal (plist-get s :id) cur-id)) naviel--queue)))
+ (when pos
+ (let ((cur (nth pos naviel--queue)))
+ (setq naviel--queue (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)
+ (message "naviel: queue shuffled (%d tracks)" (length naviel--queue)))
+;;; API parse helpers
(defun naviel--parse-artists (response)
(let (result)
(dolist (index (xml-get-children (naviel--get-child response 'artists) 'index) result)
(dolist (a (xml-get-children index 'artist))
- (push `(:id ,(naviel--attr a 'id) :name ,(naviel--attr a 'name)
- :albums ,(naviel--attr a 'albumCount))
- result)))
+ (push `(:id ,(naviel--attr a 'id) :name ,(naviel--attr a 'name) :albums ,(naviel--attr a 'albumCount)) result)))
(nreverse result)))
(defun naviel--parse-artist-albums (response)
(let (result)
(dolist (al (xml-get-children (naviel--get-child response 'artist) 'album) result)
- (push `(:id ,(naviel--attr al 'id) :name ,(naviel--attr al 'name)
- :year ,(naviel--attr al 'year))
- result))
+ (push `(:id ,(naviel--attr al 'id) :name ,(naviel--attr al 'name) :year ,(naviel--attr al 'year)) result))
(nreverse result)))
(defun naviel--parse-album-songs (response)
(let (result)
(dolist (s (xml-get-children (naviel--get-child response 'album) 'song) result)
- (push `(:id ,(naviel--attr s 'id) :title ,(naviel--attr s 'title)
- :artist ,(naviel--attr s 'artist) :album ,(naviel--attr s 'album)
- :track ,(naviel--attr s 'track) :duration ,(naviel--attr s 'duration))
- result))
+ (push `(:id ,(naviel--attr s 'id) :title ,(naviel--attr s 'title) :artist ,(naviel--attr s 'artist)
+ :album ,(naviel--attr s 'album) :track ,(naviel--attr s 'track) :duration ,(naviel--attr s 'duration)) result))
(nreverse result)))
(defun naviel--parse-search (response)
- (let* ((r3 (naviel--get-child response 'searchResult3))
- artists albums songs)
- (dolist (a (xml-get-children r3 'artist))
- (push `(:id ,(naviel--attr a 'id) :name ,(naviel--attr a 'name)) artists))
- (dolist (al (xml-get-children r3 'album))
- (push `(:id ,(naviel--attr al 'id) :name ,(naviel--attr al 'name)
- :artist ,(naviel--attr al 'artist)) albums))
- (dolist (s (xml-get-children r3 'song))
- (push `(:id ,(naviel--attr s 'id) :title ,(naviel--attr s 'title)
- :artist ,(naviel--attr s 'artist) :album ,(naviel--attr s 'album)
- :duration ,(naviel--attr s 'duration)) songs))
+ (let* ((r3 (naviel--get-child response 'searchResult3)) artists albums songs)
+ (dolist (a (xml-get-children r3 'artist)) (push `(:id ,(naviel--attr a 'id) :name ,(naviel--attr a 'name)) artists))
+ (dolist (al (xml-get-children r3 'album)) (push `(:id ,(naviel--attr al 'id) :name ,(naviel--attr al 'name) :artist ,(naviel--attr al 'artist)) albums))
+ (dolist (s (xml-get-children r3 'song)) (push `(:id ,(naviel--attr s 'id) :title ,(naviel--attr s 'title) :artist ,(naviel--attr s 'artist) :album ,(naviel--attr s 'album) :duration ,(naviel--attr s 'duration)) songs))
`(:artists ,(nreverse artists) :albums ,(nreverse albums) :songs ,(nreverse songs))))
(defun naviel--parse-random-songs (response)
(let (result)
(dolist (s (xml-get-children (naviel--get-child response 'randomSongs) 'song) result)
- (push `(:id ,(naviel--attr s 'id) :title ,(naviel--attr s 'title)
- :artist ,(naviel--attr s 'artist) :album ,(naviel--attr s 'album)
- :duration ,(naviel--attr s 'duration))
- result))
+ (push `(:id ,(naviel--attr s 'id) :title ,(naviel--attr s 'title) :artist ,(naviel--attr s 'artist) :album ,(naviel--attr s 'album) :duration ,(naviel--attr s 'duration)) result))
+ (nreverse result)))
+
+(defun naviel--parse-playlists (response)
+ (let (result)
+ (dolist (pl (xml-get-children (naviel--get-child response 'playlists) 'playlist) result)
+ (push `(:id ,(naviel--attr pl 'id) :name ,(naviel--attr pl 'name) :count ,(naviel--attr pl 'songCount) :duration ,(naviel--attr pl 'duration) :owner ,(naviel--attr pl 'owner)) result))
(nreverse result)))
-;;; Async API entry points
+(defun naviel--parse-playlist-songs (response)
+ (let (result)
+ (dolist (s (xml-get-children (naviel--get-child response 'playlist) 'entry) result)
+ (push `(:id ,(naviel--attr s 'id) :title ,(naviel--attr s 'title) :artist ,(naviel--attr s 'artist) :album ,(naviel--attr s 'album) :track ,(naviel--attr s 'track) :duration ,(naviel--attr s 'duration)) result))
+ (nreverse result)))
-(defun naviel--get-artists (cb) (naviel--request-async "getArtists" '() #'naviel--parse-artists cb))
-(defun naviel--get-artist (id cb) (naviel--request-async "getArtist" `(("id" . ,id)) #'naviel--parse-artist-albums cb))
-(defun naviel--get-album (id cb) (naviel--request-async "getAlbum" `(("id" . ,id)) #'naviel--parse-album-songs cb))
-(defun naviel--search-async (q cb) (naviel--request-async "search3"
- `(("query" . ,q) ("artistCount" . "10")
- ("albumCount" . "10") ("songCount" . "20"))
- #'naviel--parse-search cb))
-(defun naviel--get-random-songs (n cb) (naviel--request-async "getRandomSongs" `(("size" . ,(number-to-string n)))
- #'naviel--parse-random-songs cb))
+(defun naviel--parse-album-list2 (response)
+ (let (result)
+ (dolist (al (xml-get-children (naviel--get-child response 'albumList2) 'album) result)
+ (push `(:id ,(naviel--attr al 'id) :name ,(naviel--attr al 'name) :artist ,(naviel--attr al 'artist) :year ,(naviel--attr al 'year)) result))
+ (nreverse result)))
-;;; Duration / string helpers
+(defun naviel--parse-genres (response)
+ (let (result)
+ (dolist (g (xml-get-children (naviel--get-child response 'genres) 'genre) result)
+ (let* ((children (xml-node-children g))
+ (text-node (seq-find #'stringp children))
+ (name (when text-node (string-trim text-node)))
+ (songs (naviel--attr g 'songCount))
+ (albums (naviel--attr g 'albumCount)))
+ (when (and name (not (string= name ""))) (push `(:name ,name :songs ,songs :albums ,albums) result))))
+ (nreverse result)))
+(defun naviel--parse-songs-by-genre (response)
+ (let (result)
+ (dolist (s (xml-get-children (naviel--get-child response 'songsByGenre) 'song) result)
+ (push `(:id ,(naviel--attr s 'id) :title ,(naviel--attr s 'title) :artist ,(naviel--attr s 'artist) :album ,(naviel--attr s 'album) :duration ,(naviel--attr s 'duration)) result))
+ (nreverse result)))
+
+(defun naviel--parse-lyrics (response)
+ (let* ((lyrics-node (naviel--get-child response 'lyrics)))
+ (if lyrics-node
+ (let* ((children (xml-node-children lyrics-node))
+ (text-node (seq-find #'stringp children)))
+ (if text-node (string-trim text-node) "No lyrics found."))
+ "No lyrics found.")))
+
+;;; Async API entry points
+(defun naviel--get-artists (cb) (naviel--request-async "getArtists" '() #'naviel--parse-artists cb))
+(defun naviel--get-artist (id cb) (naviel--request-async "getArtist" `(("id" . ,id)) #'naviel--parse-artist-albums cb))
+(defun naviel--get-album (id cb) (naviel--request-async "getAlbum" `(("id" . ,id)) #'naviel--parse-album-songs cb))
+(defun naviel--get-playlists (cb) (naviel--request-async "getPlaylists" '() #'naviel--parse-playlists cb))
+(defun naviel--get-playlist (id cb) (naviel--request-async "getPlaylist" `(("id" . ,id)) #'naviel--parse-playlist-songs cb))
+(defun naviel--get-album-list2 (type size cb) (naviel--request-async "getAlbumList2" `(("type" . ,type) ("size" . ,(number-to-string size))) #'naviel--parse-album-list2 cb))
+(defun naviel--get-genres (cb) (naviel--request-async "getGenres" '() #'naviel--parse-genres cb))
+(defun naviel--get-songs-by-genre (genre count offset cb) (naviel--request-async "getSongsByGenre" `(("genre" . ,genre) ("count" . ,(number-to-string count)) ("offset" . ,(number-to-string offset))) #'naviel--parse-songs-by-genre cb))
+(defun naviel--search-async (q cb) (naviel--request-async "search3" `(("query" . ,q) ("artistCount" . "10") ("albumCount" . "10") ("songCount" . "20")) #'naviel--parse-search cb))
+(defun naviel--get-random-songs (n cb) (naviel--request-async "getRandomSongs" `(("size" . ,(number-to-string n))) #'naviel--parse-random-songs cb))
+(defun naviel--get-lyrics (artist title cb) (naviel--request-async "getLyrics" `(("artist" . ,artist) ("title" . ,title)) #'naviel--parse-lyrics cb))
+
+;;; Duration / string helpers
(defun naviel--format-secs (secs)
- "Format SECS (number or nil) as M:SS string."
(if (and secs (numberp secs) (> secs 0))
(let* ((s (round secs)) (m (/ s 60)) (ss (% s 60)))
(format "%d:%02d" m ss))
"--:--"))
(defun naviel--trunc (s max)
- "Truncate string S to MAX chars, appending … if needed."
(if (and s (> (length s) max))
(concat (substring s 0 (- max 1)) "…")
(or s "")))
;;; Browser buffer
-
(defun naviel--browser-buffer () (get-buffer-create "*Naviel*"))
(defun naviel--make-breadcrumbs ()
(if (null naviel--browser-stack) ""
(propertize
(concat " " (mapconcat (lambda (v) (plist-get v :label))
- (reverse naviel--browser-stack) " › ") "\n")
+ (reverse naviel--browser-stack) " ❯ ") "\n")
'face 'naviel-breadcrumb-face)))
(defun naviel--volume-bar (vol)
(let* ((w 10) (f (round (* w (/ vol 100.0)))))
- (concat "[" (make-string f ?█) (make-string (- w f) ?░) (format "] %3d%%" vol))))
+ (concat "[" (make-string f ?■) (make-string (- w f) ?-) (format "] %3d%%" vol))))
(defun naviel--now-playing-bar ()
- "Build the persistent footer string."
(let* ((song naviel--current-song)
(elap (naviel--format-secs naviel--elapsed))
(dur (naviel--format-secs
(or naviel--duration
- (when song
- (let ((d (plist-get song :duration)))
- (when (and d (not (string= d ""))) (string-to-number d)))))))
+ (when song (let ((d (plist-get song :duration))) (when (and d (not (string= d ""))) (string-to-number d)))))))
(qlen (length naviel--queue)))
(concat
- "\n"
- (propertize (make-string (max 40 (- (window-width) 2)) ?─) 'face 'naviel-separator-face)
- "\n"
+ "\n" (propertize (make-string (max 50 (- (window-width) 2)) ?─) 'face 'naviel-separator-face) "\n"
(if song
- (format " %s%s%s%s\n"
- (if naviel--paused
- (propertize "⏸ " 'face 'naviel-paused-face)
- (propertize "▶ " 'face 'naviel-now-playing-face))
- (propertize (naviel--trunc (plist-get song :title) 35) 'face 'naviel-now-playing-face)
- (propertize (concat " — " (naviel--trunc (plist-get song :artist) 25)) 'face 'naviel-song-meta-face)
- (propertize (format " %s / %s [%d/%d]" elap dur (1+ naviel--queue-index) qlen)
- 'face 'naviel-position-face))
- (propertize " ♪ Nothing playing\n" 'face 'shadow))
+ (format " %s %s\n %s\n %s\n"
+ (if naviel--paused (propertize "⏸ " 'face 'naviel-paused-face) (propertize "▶ " 'face 'naviel-now-playing-face))
+ (propertize (naviel--trunc (plist-get song :title) 50) 'face 'naviel-now-playing-face)
+ (propertize (concat (naviel--trunc (plist-get song :artist) 30) " • " (naviel--trunc (or (plist-get song :album) "") 30)) 'face 'naviel-song-meta-face)
+ (propertize (format "%s / %s [Queue: %d/%d]" elap dur (1+ naviel--queue-index) qlen) 'face 'naviel-position-face))
+ (propertize " ■ Nothing playing\n" 'face 'shadow))
(format " %s repeat: %s\n"
(propertize (format "vol %s" (naviel--volume-bar naviel--volume)) 'face 'naviel-song-meta-face)
(propertize (naviel--repeat-label) 'face 'naviel-repeat-face))
- (propertize
- " RET play SPC pause n/p skip [ ] seek +/- vol s search r random b back R repeat ? help"
- 'face 'naviel-footer-face)
- "\n")))
-
-(defconst naviel--footer-sep "\0naviel-footer\0"
- "Invisible marker separating body from footer in the browser buffer.")
+ (propertize " RET play a append SPC pause n/p skip [ ] seek +/- vol s search l lyrics ? help" 'face 'naviel-footer-face) "\n")))
-;;; Core render
+(defconst naviel--footer-sep "\0naviel-footer\0")
+;;; Core render
(defun naviel--browser-render (heading items format-fn type &optional section-label)
- "Render the browser buffer and store view state."
- (setq naviel--current-view
- `(:type ,type :items ,items :heading ,heading
- :format-fn ,format-fn :section-label ,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)))
+ (let ((inhibit-read-only t) (saved-line (line-number-at-pos)))
(erase-buffer)
- ;; Header
(insert (naviel--make-breadcrumbs))
- (insert (propertize (format " ♪ %s\n" heading) 'face 'naviel-header-face))
- (insert (propertize (make-string (min 72 (window-width)) ?═) 'face 'naviel-separator-face))
+ (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)))
- ;; Body
+ (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))))
+ (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 (propertize line 'naviel-item item 'naviel-type type 'naviel-idx idx 'face (when is-playing 'naviel-now-playing-face)))
(insert "\n"))
(cl-incf idx))))
- ;; Footer
(insert (propertize naviel--footer-sep 'invisible t))
(insert (naviel--now-playing-bar))
- ;; Restore cursor position
(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))))
+ (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))
-;;; Format callbacks
-
+;;; Format callbacks
(defun naviel--format-artist (artist _p)
- (format " %s%s"
- (propertize (naviel--trunc (plist-get artist :name) 52) 'face 'naviel-artist-face)
- (let ((n (plist-get artist :albums)))
- (if (and n (not (string= n "")))
- (propertize (format " (%s)" n) 'face 'naviel-count-face) ""))))
+ (format " %-40s %s"
+ (propertize (naviel--trunc (plist-get artist :name) 40) 'face 'naviel-artist-face)
+ (let ((n (plist-get artist :albums))) (if (and n (not (string= n ""))) (propertize (format " (%s)" n) 'face 'naviel-count-face) ""))))
(defun naviel--format-album (album _p)
- (format " %s%s"
- (propertize (naviel--trunc (plist-get album :name) 52) 'face 'naviel-album-face)
- (let ((y (plist-get album :year)))
- (if (and y (not (string= y "")))
- (propertize (format " %s" y) 'face 'naviel-year-face) ""))))
+ (format " %-40s %s"
+ (propertize (naviel--trunc (plist-get album :name) 40) 'face 'naviel-album-face)
+ (let ((y (plist-get album :year))) (if (and y (not (string= y ""))) (propertize y 'face 'naviel-year-face) ""))))
+
+(defun naviel--format-album-with-artist (album _p)
+ (format " %-38s │ %-28s %s"
+ (propertize (naviel--trunc (or (plist-get album :name) "?") 38) 'face 'naviel-album-face)
+ (let ((a (plist-get album :artist))) (if (and a (not (string= a ""))) (propertize (naviel--trunc a 28) 'face 'naviel-artist-face) ""))
+ (let ((y (plist-get album :year))) (if (and y (not (string= y ""))) (propertize (format " │ %s" y) 'face 'naviel-year-face) ""))))
(defun naviel--format-song (song playing)
(let* ((track (or (plist-get song :track) ""))
- (title (naviel--trunc (or (plist-get song :title) "?") 36))
+ (title (naviel--trunc (or (plist-get song :title) "?") 38))
(artist (naviel--trunc (or (plist-get song :artist) "") 26))
- (dur (naviel--format-secs
- (let ((d (plist-get song :duration)))
- (when (and d (not (string= d ""))) (string-to-number d))))))
- (format "%s%s %-36s %-26s %s"
- (if playing (propertize "▶ " 'face 'naviel-now-playing-face) " ")
- (propertize (format "%3s." track) 'face 'naviel-track-num-face)
+ (dur (naviel--format-secs (let ((d (plist-get song :duration))) (when (and d (not (string= d ""))) (string-to-number d))))))
+ (format "%s %3s │ %-38s │ %-26s │ %s"
+ (if playing (propertize "▶" 'face 'naviel-now-playing-face) " ")
+ (propertize track 'face 'naviel-track-num-face)
(propertize title 'face (if playing 'naviel-now-playing-face 'naviel-song-title-face))
(propertize artist 'face 'naviel-song-meta-face)
(propertize dur 'face 'naviel-song-meta-face))))
-;;; Render entry points
+(defun naviel--format-playlist (pl _p)
+ (let* ((name (naviel--trunc (or (plist-get pl :name) "?") 40))
+ (count (plist-get pl :count))
+ (owner (plist-get pl :owner)))
+ (format " %-40s %s %s"
+ (propertize name 'face 'naviel-album-face)
+ (if (and count (not (string= count ""))) (propertize (format " │ %s tracks" count) 'face 'naviel-count-face) "")
+ (if (and owner (not (string= owner ""))) (propertize (format " │ %s" owner) 'face 'naviel-song-meta-face) ""))))
+
+(defun naviel--format-genre (genre _p)
+ (let* ((name (naviel--trunc (or (plist-get genre :name) "?") 30))
+ (songs (plist-get genre :songs))
+ (albums (plist-get genre :albums)))
+ (format " %-30s │ %s"
+ (propertize name 'face 'naviel-artist-face)
+ (if (and songs (not (string= songs ""))) (propertize (format "%s songs • %s albums" songs (or albums "?")) 'face 'naviel-count-face) ""))))
+
+;;; Render entry points
+(defun naviel--render-artists (artists) (naviel--browser-render (format "Artists (%d)" (length artists)) artists #'naviel--format-artist 'artist))
+(defun naviel--render-albums (albums name) (naviel--browser-render name albums #'naviel--format-album 'album (format "%d album%s" (length albums) (if (= 1 (length albums)) "" "s"))))
+(defun naviel--render-songs (songs context &optional section) (naviel--browser-render context songs #'naviel--format-song 'song (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 "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*"))
+(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* ((playing (= idx naviel--queue-index))
+ (marker (if playing (propertize "▶ " 'face 'naviel-now-playing-face) " "))
+ (title (naviel--trunc (or (plist-get song :title) "?") 38))
+ (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%3d. │ %-38s │ %-24s │ %s\n" marker (1+ idx) title artist dur)))
+ (insert (propertize line 'face (when playing 'naviel-now-playing-face) 'naviel-queue-idx idx)))
+ (cl-incf idx))))
+ (insert "\n")
+ (insert (propertize " RET jump d remove D clear u move-up s shuffle l lyrics q quit" 'face 'naviel-footer-face))
+ (insert "\n")
+ (goto-char (point-min))
+ (forward-line (max 0 (- saved-line 1))))))
-(defun naviel--render-artists (artists)
- (naviel--browser-render (format "Artists (%d)" (length artists))
- artists #'naviel--format-artist 'artist))
+(defun naviel-queue-show ()
+ (interactive)
+ (naviel--queue-render)
+ (pop-to-buffer (naviel--queue-buffer) '(display-buffer-reuse-window)))
-(defun naviel--render-albums (albums name)
- (naviel--browser-render name albums #'naviel--format-album 'album
- (format "%d album%s" (length albums)
- (if (= 1 (length albums)) "" "s"))))
+(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--render-songs (songs context &optional section)
- (naviel--browser-render context songs #'naviel--format-song 'song
- (or section (format "%d track%s" (length songs)
- (if (= 1 (length songs)) "" "s")))))
+(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)))
-;;; Browser mode
+(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)))
+(defun naviel-queue-move-up ()
+ (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)))
+
+;;; Browser mode
(defvar naviel-browser-mode-map
(let ((m (make-sparse-keymap)))
(define-key m (kbd "RET") #'naviel--browser-enter)
+ (define-key m (kbd "a") #'naviel--browser-append)
(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)
@@ -696,9 +732,16 @@
(define-key m (kbd "r") #'naviel-play-random)
(define-key m (kbd "b") #'naviel--browser-back)
(define-key m (kbd "g") #'naviel--browser-refresh)
+ (define-key m (kbd "l") #'naviel-lyrics-show)
(define-key m (kbd "q") #'naviel--browser-quit)
(define-key m (kbd "?") #'naviel-help)
(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)
+ (define-key m (kbd "G") #'naviel-genres)
m))
(define-derived-mode naviel-browser-mode special-mode "Naviel"
@@ -713,7 +756,6 @@
(when type (cons type item))))
(defun naviel--browser-enter ()
- "Act on the item at point."
(interactive)
(let ((entry (naviel--item-at-point)))
(unless entry (user-error "No item at point"))
@@ -722,120 +764,149 @@
('artist
(let ((id (plist-get item :id)) (name (plist-get item :name)))
(push `(:type artist :data ,id :label ,name) naviel--browser-stack)
- (message "naviel: loading albums…")
- (naviel--get-artist id
- (lambda (albums)
- (if albums (naviel--render-albums albums name)
- (pop naviel--browser-stack))))))
+ (message "naviel: loading albums...")
+ (naviel--get-artist id (lambda (albums) (if albums (naviel--render-albums albums name) (pop naviel--browser-stack))))))
('album
(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…")
- (naviel--get-album id
- (lambda (songs)
- (if songs
- (progn
- (setq naviel--queue songs naviel--queue-index 0)
- (naviel--render-songs songs name))
- (pop naviel--browser-stack))))))
- ('song
- (naviel--play-queue-index (get-text-property (point) 'naviel-idx)))
+ (message "naviel: loading tracks...")
+ (naviel--get-album id (lambda (songs) (if songs (progn (setq naviel--queue songs naviel--queue-index 0) (naviel--render-songs songs name)) (pop naviel--browser-stack))))))
+ ('playlist
+ (let ((id (plist-get item :id)) (name (plist-get item :name)))
+ (push `(:type playlist :data ,id :label ,name) naviel--browser-stack)
+ (message "naviel: loading playlist...")
+ (naviel--get-playlist id (lambda (songs) (if songs (progn (setq naviel--queue songs naviel--queue-index 0) (naviel--render-songs songs name)) (pop naviel--browser-stack))))))
+ ('genre
+ (let* ((name (plist-get item :name))
+ (count (min naviel-genre-song-limit (string-to-number (or (plist-get item :songs) "50")))))
+ (push `(:type genre :data ,name :label ,name) naviel--browser-stack)
+ (message "naviel: loading songs for genre \"%s\"..." name)
+ (naviel--get-songs-by-genre name count 0 (lambda (songs) (if songs (progn (setq naviel--queue songs naviel--queue-index 0) (naviel--render-songs songs (format "Genre: %s" name) (format "%d tracks" (length songs)))) (pop naviel--browser-stack) (message "naviel: no songs found for genre \"%s\"" name))))))
+ ('song (naviel--play-queue-index (get-text-property (point) 'naviel-idx)))
(_ (message "naviel: unknown type: %s" type))))))
+(defun naviel--browser-append ()
+ (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 (naviel--get-album (plist-get item :id) (lambda (songs) (when songs (naviel--append-songs-to-queue songs (plist-get item :name))))))
+ ('playlist (naviel--get-playlist (plist-get item :id) (lambda (songs) (when songs (naviel--append-songs-to-queue songs (plist-get item :name))))))
+ ('song (naviel--append-songs-to-queue (list item) (or (plist-get item :title) "?")))
+ (_ (message "naviel: cannot append type: %s" type))))))
+
(defun naviel--browser-back ()
- "Navigate back one level in the browser."
(interactive)
(cond
((null naviel--browser-stack) (message "naviel: already at top level"))
- ((= 1 (length naviel--browser-stack))
- (setq naviel--browser-stack '())
- (naviel--show-artists))
+ ((= 1 (length naviel--browser-stack)) (setq naviel--browser-stack '()) (naviel--show-artists))
(t
(pop naviel--browser-stack)
- (let* ((frame (car naviel--browser-stack))
- (id (plist-get frame :data))
- (name (plist-get frame :label)))
- (naviel--get-artist id (lambda (albums)
- (when albums (naviel--render-albums albums name))))))))
+ (let* ((frame (car naviel--browser-stack)) (type (plist-get frame :type)) (id (plist-get frame :data)) (name (plist-get frame :label)))
+ (pcase type
+ ('artist (naviel--get-artist id (lambda (a) (when a (naviel--render-albums a name)))))
+ ('album (naviel--get-album id (lambda (s) (when s (naviel--render-songs s name)))))
+ ('playlist (naviel--get-playlist id (lambda (s) (when s (naviel--render-songs s name)))))
+ ('genre (naviel--get-songs-by-genre id naviel-genre-song-limit 0 (lambda (s) (when s (naviel--render-songs s (format "Genre: %s" id))))))
+ (_ (naviel--show-artists)))))))
(defun naviel--browser-refresh (&rest _)
- "Reload current view from the server."
(interactive)
(if (null naviel--browser-stack)
(naviel--show-artists)
- (let* ((frame (car naviel--browser-stack))
- (type (plist-get frame :type))
- (id (plist-get frame :data))
- (name (plist-get frame :label)))
+ (let* ((frame (car naviel--browser-stack)) (type (plist-get frame :type)) (id (plist-get frame :data)) (name (plist-get frame :label)))
(pcase type
- ('artist (naviel--get-artist id (lambda (a) (when a (naviel--render-albums a name)))))
- ('album (naviel--get-album id (lambda (s) (when s (naviel--render-songs s name)))))
- (_ (naviel--show-artists))))))
+ ('artist (naviel--get-artist id (lambda (a) (when a (naviel--render-albums a name)))))
+ ('album (naviel--get-album id (lambda (s) (when s (naviel--render-songs s name)))))
+ ('playlist-list (naviel-playlists))
+ ('playlist (naviel--get-playlist id (lambda (s) (when s (naviel--render-songs s name)))))
+ ('genre-list (naviel-genres))
+ ('genre (naviel--get-songs-by-genre id naviel-genre-song-limit 0 (lambda (s) (when s (naviel--render-songs s (format "Genre: %s" id))))))
+ ('album-list2 (naviel--get-album-list2 id naviel-album-list-size (lambda (als) (when als (naviel--browser-render name als #'naviel--format-album-with-artist 'album)))))
+ (_ (naviel--show-artists))))))
(defun naviel--browser-quit () (interactive) (quit-window))
(defun naviel--show-artists ()
- (message "naviel: loading library…")
+ (message "naviel: loading library...")
(naviel--get-artists
(lambda (artists)
- (when artists
- (setq naviel--browser-stack '())
- (naviel--render-artists artists)))))
-
-;;; Mode line
+ (when artists (setq naviel--browser-stack '()) (naviel--render-artists artists)))))
+;;; Mode line
(defun naviel--mode-line-string ()
(if naviel--current-song
- (format " ♪%s %s%s"
- (if naviel--paused "⏸" "▶")
- (naviel--trunc (plist-get naviel--current-song :title) 28)
- (if (and naviel--elapsed naviel--duration)
- (format " %s/%s"
- (naviel--format-secs naviel--elapsed)
- (naviel--format-secs naviel--duration))
- ""))
- ""))
+ (format " [%s] %s%s" (if naviel--paused "paused" "playing") (naviel--trunc (plist-get naviel--current-song :title) 28) (if (and naviel--elapsed naviel--duration) (format " %s/%s" (naviel--format-secs naviel--elapsed) (naviel--format-secs naviel--duration)) "")) ""))
(defun naviel--update-mode-line () (force-mode-line-update t))
-;;; Public commands
-
+;;; Public commands
;;;###autoload
(defun naviel ()
- "Open the Naviel music browser."
(interactive)
- (unless (executable-find naviel-mpv-executable)
- (error "naviel: mpv not found on PATH"))
+ (unless (executable-find naviel-mpv-executable) (error "naviel: mpv not found on PATH"))
(pop-to-buffer (naviel--browser-buffer))
(naviel--show-artists))
;;;###autoload
+(defun naviel-lyrics-show ()
+ "Fetch and display lyrics for the song at point, or the currently playing song."
+ (interactive)
+ (let* ((entry (if (eq major-mode 'naviel-queue-mode)
+ (let ((idx (get-text-property (point) 'naviel-queue-idx)))
+ (if idx (cons 'song (nth idx naviel--queue)) nil))
+ (naviel--item-at-point)))
+ (type (and entry (car entry)))
+ (item (and entry (cdr entry)))
+ (song (if (eq type 'song) item naviel--current-song)))
+ (if (not song)
+ (user-error "naviel: No song playing or at point")
+ (let ((artist (plist-get song :artist))
+ (title (plist-get song :title)))
+ (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 • %s\n" title artist) 'face 'naviel-header-face))
+ (insert (propertize (make-string (min 80 (window-width)) ?─) 'face 'naviel-separator-face))
+ (insert "\n\n ")
+ (insert (replace-regexp-in-string "\n" "\n " (or lyrics "No lyrics found for this track.")))
+ (insert "\n\n")
+ (insert (propertize (make-string (min 80 (window-width)) ?─) 'face 'naviel-separator-face))
+ (insert "\n")
+ (insert (propertize " Press 'q' to close." 'face 'naviel-footer-face))
+ (goto-char (point-min))))
+ (pop-to-buffer "*Naviel Lyrics*")))))))
+
+;;;###autoload
(defun naviel-search (query)
- "Search Naviel for QUERY, displaying artists, albums, and songs."
(interactive "sSearch Naviel: ")
(when (string= query "") (user-error "Search query cannot be empty"))
- (message "naviel: searching for \"%s\"…" query)
+ (message "naviel: searching for \"%s\"..." query)
(naviel--search-async
query
(lambda (results)
(when results
- (let ((artists (plist-get results :artists))
- (albums (plist-get results :albums))
- (songs (plist-get results :songs)))
+ (let ((artists (plist-get results :artists)) (albums (plist-get results :albums)) (songs (plist-get results :songs)))
(pop-to-buffer (naviel--browser-buffer))
(naviel-browser-mode)
(let ((inhibit-read-only t))
(erase-buffer)
- (insert (propertize (format " ♪ Search: %s\n" query) 'face 'naviel-header-face))
- (insert (propertize (make-string (min 72 (window-width)) ?═) 'face 'naviel-separator-face))
+ (insert (propertize (format " Search: %s\n" query) 'face 'naviel-header-face))
+ (insert (propertize (make-string (min 80 (window-width)) ?─) 'face 'naviel-separator-face))
(insert "\n")
(cl-flet ((section (label items type fmt-fn)
(when items
(insert (propertize (format "\n %s\n" label) 'face 'naviel-breadcrumb-face))
(let ((idx 0))
(dolist (item items)
- (insert (propertize (funcall fmt-fn item nil)
- 'naviel-item item 'naviel-type type 'naviel-idx idx))
+ (insert (propertize (funcall fmt-fn item nil) 'naviel-item item 'naviel-type type 'naviel-idx idx))
(insert "\n") (cl-incf idx))))))
(section "Artists" artists 'artist #'naviel--format-artist)
(section "Albums" albums 'album #'naviel--format-album)
@@ -844,15 +915,9 @@
(setq naviel--queue songs naviel--queue-index 0)
(let ((idx 0))
(dolist (s songs)
- (insert (propertize
- (naviel--format-song
- s (and naviel--current-song
- (equal (plist-get s :id)
- (plist-get naviel--current-song :id))))
- 'naviel-item s 'naviel-type 'song 'naviel-idx idx))
+ (insert (propertize (naviel--format-song s (and naviel--current-song (equal (plist-get s :id) (plist-get naviel--current-song :id)))) '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")))
+ (message "naviel: %d song%s in queue" (length songs) (if (= 1 (length songs)) "" "s")))
(when (and (null artists) (null albums) (null songs))
(insert (propertize " No results found.\n" 'face 'shadow))))
(insert (propertize naviel--footer-sep 'invisible t))
@@ -862,40 +927,74 @@
;;;###autoload
(defun naviel-play-random (&optional count)
- "Play COUNT (default 20) random songs."
(interactive "P")
(let ((n (if count (prefix-numeric-value count) 20)))
- (message "naviel: fetching %d random tracks…" n)
+ (message "naviel: fetching %d random tracks..." n)
(naviel--get-random-songs
- n
- (lambda (songs)
- (when songs
- (setq naviel--queue songs naviel--queue-index 0)
- (pop-to-buffer (naviel--browser-buffer))
- (naviel--render-songs songs (format "Random — %d tracks" n))
- (naviel--play-queue-index 0))))))
+ n (lambda (songs)
+ (when songs (setq naviel--queue songs naviel--queue-index 0) (pop-to-buffer (naviel--browser-buffer)) (naviel--render-songs songs (format "Random - %d tracks" n)) (naviel--play-queue-index 0))))))
;;;###autoload
(defun naviel-stop ()
- "Stop playback."
(interactive)
(naviel--stop)
(message "naviel: stopped"))
;;;###autoload
+(defun naviel-playlists ()
+ (interactive)
+ (message "naviel: loading playlists...")
+ (naviel--get-playlists
+ (lambda (pls)
+ (if (null pls) (message "naviel: no playlists found")
+ (push `(:type playlist-list :data nil :label "Playlists") naviel--browser-stack)
+ (naviel--browser-render (format "Playlists (%d)" (length pls)) pls #'naviel--format-playlist 'playlist)))))
+
+;;;###autoload
+(defun naviel-recently-added (&optional size)
+ (interactive "P")
+ (let ((n (if size (prefix-numeric-value size) naviel-album-list-size)))
+ (message "naviel: fetching recently added albums...")
+ (naviel--get-album-list2 "newest" n (lambda (albums) (if (null albums) (message "naviel: no results") (push `(:type album-list2 :data "newest" :label "Recently Added") naviel--browser-stack) (naviel--browser-render (format "Recently Added (%d)" (length albums)) albums #'naviel--format-album-with-artist 'album))))))
+
+;;;###autoload
+(defun naviel-recently-played (&optional size)
+ (interactive "P")
+ (let ((n (if size (prefix-numeric-value size) naviel-album-list-size)))
+ (message "naviel: fetching recently played albums...")
+ (naviel--get-album-list2 "recent" n (lambda (albums) (if (null albums) (message "naviel: no results") (push `(:type album-list2 :data "recent" :label "Recently Played") naviel--browser-stack) (naviel--browser-render (format "Recently Played (%d)" (length albums)) albums #'naviel--format-album-with-artist 'album))))))
+
+;;;###autoload
+(defun naviel-genres ()
+ (interactive)
+ (message "naviel: loading genres...")
+ (naviel--get-genres
+ (lambda (genres)
+ (if (null genres) (message "naviel: no genres found")
+ (setq genres (sort genres (lambda (a b) (> (string-to-number (or (plist-get a :songs) "0")) (string-to-number (or (plist-get b :songs) "0"))))))
+ (push `(:type genre-list :data nil :label "Genres") naviel--browser-stack)
+ (naviel--browser-render (format "Genres (%d)" (length genres)) genres #'naviel--format-genre 'genre)))))
+
+;;;###autoload
(defun naviel-help ()
- "Show naviel keybinding help."
(interactive)
(with-help-window "*Naviel Help*"
- (princ "Naviel — Navidrome client for Emacs (v0.3 / Phase 1)\n")
+ (princ "Naviel - Navidrome client for Emacs\n")
(princ (make-string 52 ?─)) (princ "\n\n")
(princ "Navigation\n")
- (princ " RET Play item / expand artist or album\n")
+ (princ " RET Play item / expand artist, album, playlist, or genre\n")
+ (princ " a Append item to queue without replacing it\n")
+ (princ " l Fetch and show lyrics for song at point\n")
(princ " b Go back one level\n")
(princ " g Refresh current view from server\n")
(princ " s Search library\n")
(princ " r Play random (C-u r for custom count)\n")
(princ " q Quit browser\n\n")
+ (princ "Library views\n")
+ (princ " P Browse playlists\n")
+ (princ " N Recently added albums (C-u N for custom count)\n")
+ (princ " H Recently played albums (C-u H for custom count)\n")
+ (princ " G Browse genres\n\n")
(princ "Playback\n")
(princ " SPC Toggle pause / resume\n")
(princ " n Next track\n")
@@ -904,13 +1003,17 @@
(princ " ] Seek forward (naviel-seek-step seconds)\n")
(princ " + = Volume up\n")
(princ " - Volume down\n")
- (princ " R Cycle repeat: off → one → all → off\n\n")
- (princ "Customisation\n")
- (princ " naviel-seek-step seconds per seek (default 10)\n")
- (princ " naviel-volume-step % per +/- press (default 5)\n")
- (princ " naviel-position-poll-interval IPC poll rate in s (default 1.0)\n")))
-
-;;; Mode line integration
+ (princ " R Cycle repeat: off -> one -> all -> off\n\n")
+ (princ "Queue\n")
+ (princ " Q Toggle queue window\n")
+ (princ " S Shuffle queue in place\n\n")
+ (princ "Queue window\n")
+ (princ " RET Jump to track\n")
+ (princ " d Remove track from queue\n")
+ (princ " D Clear entire queue\n")
+ (princ " u Move track up one position\n\n")))
+
+;;; Mode line integration
(defvar naviel-mode-line-format '(:eval (naviel--mode-line-string)))
;;;###autoload