diff --git a/README.md b/README.md index 5fef3d7..f99350a 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,19 @@ you can set the following, i.e.: ```` Otherwise, it defaults to 4 spaces. +## Helm Integration ## + +Helm integration can be enabled for all Spotify features of this package +by adding this to your config + +````el +(setq spotify-helm-integration 1) +```` + +Note: Spotify will use tabulated modes by default if this is turned on without +helm installed. If you do install helm afterwards, please ensure to reload the +package or restart emacs for it to take effect. + ## Donate If this project is useful for you, buy me a beer! diff --git a/spotify-api.el b/spotify-api.el index a301617..6598262 100644 --- a/spotify-api.el +++ b/spotify-api.el @@ -43,6 +43,10 @@ relevant to a particular country. If omitted, the returned items will be globally relevant." :type 'string) +(defcustom spotify-helm-integration nil + "Optional. If true, then use the helm front end for all APIs." + :type 'boolean) + ;; Do not rely on the auto-refresh logic from oauth2.el, which seems broken for async requests (defun spotify-oauth2-token () "Retrieve the Oauth2 access token that must be used to interact with the @@ -198,8 +202,11 @@ the current user." (gethash 'id json)) (defun spotify-get-item-uri (json) - "Return the uri from the given track/album/artist JSON object." - (gethash 'uri json)) + "Return the uri from the given track/album/artist JSON object. +Track link objects are preceded if relinking is applied for the track server side" + (if-let (linked-from-json (gethash 'linked_from json)) + (gethash 'uri linked-from-json) + (gethash 'uri json))) (defun spotify-get-playlist-track-count (json) "Return the number of tracks of the given playlist JSON object." @@ -445,5 +452,14 @@ which must be a number between 0 and 100." nil callback)) +(defun spotify-api-enqueue (uri &optional callback) + "Add track/episode to playlist queue." + (spotify-api-call-async + "POST" + (concat "/me/player/queue?" + (url-build-query-string `((uri ,uri)) + nil t)) + nil + callback)) (provide 'spotify-api) diff --git a/spotify-device-select.el b/spotify-device-select.el index 296229b..8c043b6 100644 --- a/spotify-device-select.el +++ b/spotify-device-select.el @@ -10,6 +10,9 @@ (require 'spotify-api) +(when (require 'helm nil 'noerror) + (require 'spotify-helm-integration)) + (defcustom spotify-selected-device-id "" "The id of the device selected for transport." :type 'string) @@ -33,11 +36,15 @@ (if-let ((devices (gethash 'devices json)) (line (string-to-number (format-mode-line "%l")))) (progn - (pop-to-buffer buffer) - (spotify-devices-print devices) - (goto-char (point-min)) - (forward-line (1- line)) - (message "Device list updated.")) + (if (and spotify-helm-integration (require 'helm nil 'noerror)) + (with-current-buffer buffer + (spotify-devices-print devices) + (helm-devices "Spotify Devices")) + (pop-to-buffer buffer) + (spotify-devices-print devices) + (goto-char (point-min)) + (forward-line (1- line)) + (message "Device list updated."))) (message "No devices are available.")))))) (defun spotify-devices-print (devices) diff --git a/spotify-helm-integration.el b/spotify-helm-integration.el new file mode 100644 index 0000000..92219e6 --- /dev/null +++ b/spotify-helm-integration.el @@ -0,0 +1,249 @@ +;;; package --- Summary + +;;; Commentary: + +;; spotify-helm-integration.el --- Spotify.el helm integration + +;; Code: + + +(defvar helm-playlists-doc-header + " (\\\\[helm-playlists-load-more-interactive]: Load more playlists)" + "*The doc that is inserted in the Name header of the helm spotify source.") + +(defvar helm-tracks-doc-header + " (\\\\[helm-tracks-load-more-interactive]: Load more tracks)" + "*The doc that is inserted in the Name header of the helm spotify source.") + + +;; Helm-Keymaps + +(defvar helm-playlists-map + (let ((map (make-sparse-keymap))) + (set-keymap-parent map helm-map) + (define-key map (kbd "C-l") 'helm-playlists-load-more-interactive) + (define-key map (kbd "C-M-f") 'helm-playlists-follow-interactive) + (define-key map (kbd "C-M-u") 'helm-playlists-unfollow-interactive) + map) + "Local keymap for playlists in helm buffers") + +(defvar helm-tracks-map + (let ((map (make-sparse-keymap))) + (set-keymap-parent map helm-map) + (define-key map (kbd "C-q") 'helm-tracks-enqueue-interactive) + (define-key map (kbd "C-l") 'helm-tracks-load-more-interactive) + (define-key map (kbd "C-M-a") 'helm-tracks-view-album-interactive) + map) + "Local keymap for tracks in helm buffers") + + +;;; Helm-playlists +;; +;; + +(defcustom helm-playlists-actions (helm-make-actions + "View playlist's tracks `RET'" 'helm-playlists-view-tracks-core + "Load more playlists `C-l'" 'helm-playlists-load-more-core + "Follow playlist `C-M-f'" 'helm-playlists-follow-core + "Unfollow playlist `C-M-u'" 'helm-playlists-unfollow-core) + "Actions for playlists in helm buffers" + :group 'spotify + :type '(alist :key-type string :value-type function)) + +(defun helm-playlists-view-tracks-core (candidate) + "Helm action to view all tracks of the selected playlist" + (when helm-in-persistent-action + (helm-exit-minibuffer)) + (spotify-playlist-tracks candidate)) + +(defun helm-playlists-load-more-interactive () + "Helm action wrapper to bind to a key map" + (interactive) + (with-helm-alive-p + (helm-exit-and-execute-action 'helm-playlists-load-more-core))) + +(defun helm-playlists-load-more-core (_candidate) + "Helm action to load more playlists" + (spotify-playlist-load-more)) + +(defun helm-playlists-follow-interactive () + "Helm action wrapper to bind to a key map" + (interactive) + (with-helm-alive-p + (helm-attrset 'follow '(helm-playlists-follow-core . never-split)) + (helm-execute-persistent-action 'follow))) + +(defun helm-playlists-follow-core (candidate) + "Helm action to follow the selected playlist" + (spotify-playlist-follow candidate)) + +(defun helm-playlists-unfollow-interactive () + "Helm action wrapper to bind to a key map" + (interactive) + (with-helm-alive-p + (helm-attrset 'unfollow '(helm-playlists-unfollow-core . never-split)) + (helm-execute-persistent-action 'unfollow))) + +(defun helm-playlists-unfollow-core (candidate) + "Helm action to unfollow the selected playlist" + (spotify-playlist-unfollow candidate)) + +(defun helm-playlists (source-name) + "This will use the tab buffer generated from loading playlist items as a source for helm to +operate on" + (lexical-let ((tabulated-list-entries tabulated-list-entries)) + (helm :sources (helm-build-in-buffer-source source-name + :header-name (lambda (name) + (concat name (substitute-command-keys helm-playlists-doc-header))) + :data (current-buffer) + :get-line #'buffer-substring + :display-to-real (lambda (_candidate) + (let* ((candidate + (helm-get-selection nil 'withprop)) + (tabulated-list-id + (get-text-property 0 'tabulated-list-id candidate))) + tabulated-list-id)) + :action helm-playlists-actions + :keymap helm-playlists-map + :fuzzy-match t) + :buffer "*helm spotify*"))) + + +;;; Helm-tracks +;; +;; + +(defcustom helm-tracks-actions (helm-make-actions + "Play track `RET'" 'helm-tracks-select-default-core + "Enqueue track `C-q'" 'helm-tracks-enqueue-core + "Load more tracks `C-l'" 'helm-tracks-load-more-core + "View album of track `C-M-a'" 'helm-tracks-view-album-core) + "Actions for tracks in helm buffers" + :group 'spotify + :type '(alist :key-type string :value-type function)) + +(defun helm-tracks-select-default-core (candidate) + "Helm action to play a selected track & clean up dangling Spotify buffers" + (spotify-track-select-default candidate) + (unless helm-in-persistent-action + (helm-spotify-cleanup-buffers))) + +(defun helm-tracks-load-more-interactive () + "Helm action wrapper to bind to a key map" + (interactive) + (with-helm-alive-p + (helm-exit-and-execute-action 'helm-tracks-load-more-core))) + +(defun helm-tracks-load-more-core (_candidate) + "Helm action to load more tracks" + (spotify-track-load-more)) + +(defun helm-tracks-view-album-interactive () + "Helm action wrapper to bind to a key map" + (interactive) + (with-helm-alive-p + (helm-exit-and-execute-action 'helm-tracks-view-album-core))) + +(defun helm-tracks-view-album-core (candidate) + "Helm action to view a track's album context" + (let ((album (spotify-get-track-album candidate))) + (spotify-album-tracks album))) + +(defun helm-tracks-enqueue-interactive () + "Helm action wrapper to bind to a key map" + (interactive) + (with-helm-alive-p + (helm-attrset 'enqueue '(helm-tracks-enqueue-core . never-split)) + (helm-execute-persistent-action 'enqueue))) + +(defun helm-tracks-enqueue-core (candidate) + "Helm action to enqueue a track into the active device's playback" + (hash-table-keys candidate) + (lexical-let ((name (spotify-get-item-name candidate)) + (uri (spotify-get-item-uri candidate))) + (spotify-api-enqueue uri (lambda (_) + (message (format "Added '%s' to playback queue" name)))))) + +(defun helm-tracks (source-name) + "This will use the tab buffer generated from loading track items as a source for helm to +operate on" + (lexical-let ((tabulated-list-entries tabulated-list-entries)) + (helm :sources (helm-build-in-buffer-source source-name + :header-name (lambda (name) + (concat name (substitute-command-keys helm-tracks-doc-header))) + :data (current-buffer) + :get-line #'buffer-substring + :display-to-real (lambda (_candidate) + (let* ((candidate + (helm-get-selection nil 'withprop)) + (tabulated-list-id + (get-text-property 0 'tabulated-list-id candidate))) + tabulated-list-id)) + :action helm-tracks-actions + :keymap helm-tracks-map + :fuzzy-match t) + :buffer "*helm spotify*"))) + + +;;; Helm-devices +;; +;; + +(defcustom helm-devices-actions (helm-make-actions + "Select device `RET'" 'helm-devices-select-core) + "Actions for devices in helm buffers" + :group 'spotify + :type '(alist :key-type string :value-type function)) + +(defun helm-devices-select-core (candidate) + "Helm action to make the selected device active" + (lexical-let ((device-id (spotify-get-device-id candidate)) + (name (spotify-get-device-name candidate))) + (spotify-api-transfer-player + device-id + (lambda (json) + (setq spotify-selected-device-id device-id) + (message "Device '%s' selected" name))) + (helm-spotify-cleanup-buffers))) + +(defun helm-devices (source-name) + "This will use the tab buffer generated from loading device items as a source for helm to +operate on" + (lexical-let ((tabulated-list-entries tabulated-list-entries)) + (helm :sources (helm-build-in-buffer-source source-name + :data (current-buffer) + :get-line #'buffer-substring + :display-to-real (lambda (_candidate) + (let* ((candidate + (helm-get-selection nil 'withprop)) + (tabulated-list-id + (get-text-property 0 'tabulated-list-id candidate))) + tabulated-list-id)) + :action helm-devices-actions + :fuzzy-match t) + :buffer "*helm spotify*"))) + + +;;; Misc +;; +;; + +(defun helm-spotify-cleanup-buffers () + "Cleanup dangling tabulated-mode buffers from the core search APIs." + (let ((buffer-list (mapcar (lambda (buffer) (buffer-name buffer)) (buffer-list))) + (spotify-buffer-candidates '("*Devices*" + "*Featured Playlists*" + "*Recently Played*" + "\*Playlists: .*\*" + "\*Playlist Search: .*\*" + "\*Track Search: .*\*" + "\*Playlist Tracks: .*\*" + "\*Album: .*\*"))) + (mapc (lambda (spotify-buffer) (kill-buffer spotify-buffer)) + (seq-filter (lambda (buffer) + (when (some (lambda (candidate) (string-match-p candidate buffer)) + spotify-buffer-candidates) + buffer)) + buffer-list)))) + +(provide 'spotify-helm-integration) diff --git a/spotify-playlist-search.el b/spotify-playlist-search.el index 78c40e0..baa893f 100644 --- a/spotify-playlist-search.el +++ b/spotify-playlist-search.el @@ -6,6 +6,9 @@ (require 'spotify-api) +(when (require 'helm nil 'noerror) + (require 'spotify-helm-integration)) + (defvar spotify-playlist-search-mode-map (let ((map (make-sparse-keymap))) (set-keymap-parent map tabulated-list-mode-map) @@ -43,12 +46,14 @@ (let ((next-page (1+ spotify-current-page))) (cond ((bound-and-true-p spotify-query) (spotify-playlist-search-update spotify-query next-page)) ((bound-and-true-p spotify-browse-message) (spotify-featured-playlists-update next-page)) - (t (spotify-user-playlists-update spotify-user-id next-page))))) + (t (if (and spotify-helm-integration (eq playlists-loaded total-playlists)) + (spotify-user-playlists-update spotify-user-id spotify-current-page) + (spotify-user-playlists-update spotify-user-id next-page)))))) -(defun spotify-playlist-follow () +(defun spotify-playlist-follow (&optional helm-selection-id) "Adds the current user as the follower of the playlist under the cursor." (interactive) - (lexical-let* ((selected-playlist (tabulated-list-get-id)) + (lexical-let* ((selected-playlist (or helm-selection-id (tabulated-list-get-id))) (name (spotify-get-item-name selected-playlist))) (when (y-or-n-p (format "Follow playlist '%s'?" name)) (spotify-api-playlist-follow @@ -56,10 +61,10 @@ (lambda (_) (message (format "Followed playlist '%s'" name))))))) -(defun spotify-playlist-unfollow () +(defun spotify-playlist-unfollow (&optional helm-selection-id) "Removes the current user as the follower of the playlist under the cursor." (interactive) - (lexical-let* ((selected-playlist (tabulated-list-get-id)) + (lexical-let* ((selected-playlist (or helm-selection-id (tabulated-list-get-id))) (name (spotify-get-item-name selected-playlist))) (when (y-or-n-p (format "Unfollow playlist '%s'?" name)) (spotify-api-playlist-unfollow @@ -81,9 +86,14 @@ (with-current-buffer buffer (setq-local spotify-current-page current-page) (setq-local spotify-query query) - (pop-to-buffer buffer) - (spotify-playlist-search-print items current-page) - (message "Playlist view updated")) + (if (and spotify-helm-integration (require 'helm nil 'noerror)) + (progn + (spotify-playlist-search-print items current-page) + (helm-playlists + (format "Spotify Playlists - Search Results for '%s'" spotify-query))) + (pop-to-buffer buffer) + (spotify-playlist-search-print items current-page) + (message "Playlist view updated"))) (message "No more playlists")))))) (defun spotify-user-playlists-update (user-id current-page) @@ -96,12 +106,23 @@ current-page (lambda (playlists) (if-let ((items (spotify-get-items playlists))) - (with-current-buffer buffer - (setq-local spotify-user-id user-id) - (setq-local spotify-current-page current-page) - (pop-to-buffer buffer) - (spotify-playlist-search-print items current-page) - (message "Playlist view updated")) + (with-current-buffer buffer + (setq-local spotify-user-id user-id) + (setq-local spotify-current-page current-page) + (if (and spotify-helm-integration (require 'helm nil 'noerror)) + (progn + (setq-local current-page-size (length (gethash 'items json))) + (setq-local playlists-loaded (+ (* 50 (1- current-page)) current-page-size)) + (setq-local total-playlists (gethash 'total json)) + (spotify-playlist-search-print items current-page) + (helm-playlists + (format "Spotify Playlists - %s (%s/%s playlists loaded)" + spotify-user-id + playlists-loaded + total-playlists))) + (pop-to-buffer buffer) + (spotify-playlist-search-print items current-page) + (message "Playlist view updated"))) (message "No more playlists")))))) (defun spotify-featured-playlists-update (current-page) @@ -113,18 +134,23 @@ (lambda (json) (if-let ((items (spotify-get-search-playlist-items json)) (msg (spotify-get-message json))) - (with-current-buffer buffer - (setq-local spotify-current-page current-page) - (setq-local spotify-browse-message msg) - (pop-to-buffer buffer) - (spotify-playlist-search-print items current-page) - (message "Playlist view updated")) + (with-current-buffer buffer + (setq-local spotify-current-page current-page) + (setq-local spotify-browse-message msg) + (if (and spotify-helm-integration (require 'helm nil 'noerror)) + (progn + (spotify-playlist-search-print items current-page) + (helm-playlists "Spotify Playlists - Featured")) + (pop-to-buffer buffer) + (spotify-playlist-search-print items current-page) + (message "Playlist view updated"))) (message "No more playlists")))))) -(defun spotify-playlist-tracks () - "Displays the tracks that belongs to the playlist under the cursor." +(defun spotify-playlist-tracks (&optional helm-selection-id) + "Displays the tracks that belongs to the playlist by either the value under the cursor or +from the selection in helm." (interactive) - (let* ((selected-playlist (tabulated-list-get-id)) + (let* ((selected-playlist (or helm-selection-id (tabulated-list-get-id))) (name (spotify-get-item-name selected-playlist)) (buffer (get-buffer-create (format "*Playlist Tracks: %s*" name)))) (with-current-buffer buffer diff --git a/spotify-track-search.el b/spotify-track-search.el index aa2dc79..a04b812 100644 --- a/spotify-track-search.el +++ b/spotify-track-search.el @@ -6,6 +6,9 @@ (require 'spotify-api) +(when (require 'helm nil 'noerror) + (require 'spotify-helm-integration)) + (defvar spotify-track-search-mode-map (let ((map (make-sparse-keymap))) (set-keymap-parent map tabulated-list-mode-map) @@ -36,13 +39,13 @@ Otherwise, play the track selected." (spotify-track-album-select)) (t (spotify-track-select-default))))) -(defun spotify-track-select-default () +(defun spotify-track-select-default (&optional helm-selection-id) "Plays the track under the cursor. If the track list represents a playlist, the given track is played in the context of that playlist; if the track list represents an album, the given track is played in the context of that album; otherwise, it will be played without a context." (interactive) - (let* ((track (tabulated-list-get-id)) + (let* ((track (or helm-selection-id (tabulated-list-get-id))) (context (cond ((bound-and-true-p spotify-selected-playlist) spotify-selected-playlist) ((bound-and-true-p spotify-selected-album) spotify-selected-album) (t nil)))) @@ -109,9 +112,13 @@ otherwise, it will be played without a context." (cond ((bound-and-true-p spotify-recently-played) (spotify-recently-played-tracks-update (1+ spotify-current-page))) ((bound-and-true-p spotify-selected-playlist) - (spotify-playlist-tracks-update (1+ spotify-current-page))) + (if (and spotify-helm-integration (eq tracks-loaded total-tracks)) + (spotify-playlist-tracks-update spotify-current-page) + (spotify-playlist-tracks-update (1+ spotify-current-page)))) ((bound-and-true-p spotify-selected-album) - (spotify-album-tracks-update spotify-selected-album (1+ spotify-current-page))) + (if (and spotify-helm-integration (eq tracks-loaded total-tracks)) + (spotify-album-tracks-update spotify-selected-album spotify-current-page) + (spotify-album-tracks-update spotify-selected-album (1+ spotify-current-page)))) ((bound-and-true-p spotify-query) (spotify-track-search-update spotify-query (1+ spotify-current-page))))) @@ -129,9 +136,13 @@ otherwise, it will be played without a context." (with-current-buffer buffer (setq-local spotify-current-page current-page) (setq-local spotify-query query) - (pop-to-buffer buffer) - (spotify-track-search-print items current-page) - (message "Track view updated")) + (if (and spotify-helm-integration (require 'helm nil 'noerror)) + (progn + (spotify-track-search-print items current-page) + (helm-tracks (format "Spotify Tracks - Search Results for '%s'" query))) + (pop-to-buffer buffer) + (spotify-track-search-print items current-page) + (message "Track view updated"))) (message "No more tracks")))))) (defun spotify-playlist-tracks-update (current-page) @@ -146,9 +157,19 @@ otherwise, it will be played without a context." (if-let ((items (spotify-get-playlist-tracks json))) (with-current-buffer buffer (setq-local spotify-current-page current-page) - (pop-to-buffer buffer) - (spotify-track-search-print items current-page) - (message "Track view updated")) + (if (and spotify-helm-integration (require 'helm nil 'noerror)) + (progn + (setq-local current-page-size (length (gethash 'items json))) + (setq-local tracks-loaded (+ (* 50 (1- current-page)) current-page-size)) + (setq-local total-tracks (gethash 'total json)) + (spotify-track-search-print items current-page) + (helm-tracks (format "%s (%s/%s tracks loaded)" + (gethash 'name spotify-selected-playlist) + tracks-loaded + total-tracks))) + (pop-to-buffer buffer) + (spotify-track-search-print items current-page) + (message "Track view updated"))) (message "No more tracks"))))))) (defun spotify-album-tracks-update (album current-page) @@ -164,9 +185,19 @@ otherwise, it will be played without a context." (with-current-buffer buffer (setq-local spotify-current-page current-page) (setq-local spotify-selected-album album) - (pop-to-buffer buffer) - (spotify-track-search-print items current-page) - (message "Track view updated")) + (if (and spotify-helm-integration (require 'helm nil 'noerror)) + (progn + (setq-local current-page-size (length (gethash 'items json))) + (setq-local tracks-loaded (+ (* 50 (1- current-page)) current-page-size)) + (setq-local total-tracks (gethash 'total json)) + (spotify-track-search-print items current-page) + (helm-tracks (format "%s (%s/%s tracks loaded)" + (gethash 'name spotify-selected-album) + tracks-loaded + total-tracks))) + (pop-to-buffer buffer) + (spotify-track-search-print items current-page) + (message "Track view updated"))) (message "No more tracks")))))) (defun spotify-recently-played-tracks-update (current-page) @@ -180,9 +211,13 @@ otherwise, it will be played without a context." (with-current-buffer buffer (setq-local spotify-current-page current-page) (setq-local spotify-recently-played t) - (pop-to-buffer buffer) - (spotify-track-search-print items current-page) - (message "Track view updated")) + (if (and spotify-helm-integration (require 'helm nil 'noerror)) + (progn + (spotify-track-search-print items current-page) + (helm-tracks "Spotify Tracks - Recently Played")) + (pop-to-buffer buffer) + (spotify-track-search-print items current-page) + (message "Track view updated"))) (message "No more tracks")))))) (defun spotify-track-search-set-list-format () @@ -210,7 +245,7 @@ otherwise, it will be played without a context." "Appends the given songs to the current track view." (let (entries) (dolist (song songs) - (when (spotify-is-track-playable song) + (when (and song (spotify-is-track-playable song)) (let* ((artist-name (spotify-get-track-artist-name song)) (album (or (spotify-get-track-album song) spotify-selected-album)) (album-name (spotify-get-item-name album))