Repository: aome510/spotify-player (language: rust)

Issue:
add to a new playlist / create a playlist?
I was trying to add a song to a new playlist but I couldn't find the option. Also I think isn't possible to create a new playlist from the user playlist view.

Is there a way to do it?

Sorry for open 2 bugs in 1 day.


Discussion / hints:
> Sorry for open 2 bugs in 1 day.

No problem lol.

> I was trying to add a song to a new playlist but I couldn't find the option. Also I think isn't possible to create a new playlist from the user playlist view.
> 
> Is there a way to do it?

Currently, iirc, `spotify-player` doesn't support creating a new playlist. It does support adding song to already existed playlist, which can be triggered by `ShowActionsOnSelectedItem` or `ShowActionsOnCurrentTrack` commands. You can then choose `AddToPlaylist` action from the popup list
It would be nice to add support to it 😁 
> It would be nice to add support to it

It should be easy to implement. However, I currently don't have much time as I'm quite busy with school. PRs are welcome though 💪 
I would like to but I don't know a word of Rust.

Interested in learning btw 👀

Fix patch:
diff --git a/README.md b/README.md
index 0777c4ce..c975d2e4 100644
--- a/README.md
+++ b/README.md
@@ -357,6 +357,7 @@ List of supported commands:
 | `ReverseOrder`                 | reverse the order of the track table (if any)                           | `s r`              |
 | `MovePlaylistItemUp`           | move playlist item up one position                                      | `C-k`              |
 | `MovePlaylistItemDown`         | move playlist item down one position                                    | `C-j`              |
+| `CreatePlaylist`               | create a new playlist                                                   | `N`                |
 
 To add new shortcuts or modify the default shortcuts, please refer to the [keymaps section](docs/config.md#keymaps) in the configuration documentation.
 
diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs
index 78e73ec3..385c6c1e 100644
--- a/spotify_player/src/client/mod.rs
+++ b/spotify_player/src/client/mod.rs
@@ -452,6 +452,30 @@ impl Client {
                 )
                 .await?;
             }
+            ClientRequest::CreatePlaylist {
+                playlist_name,
+                public,
+                collab,
+                desc,
+            } => {
+                let user_id = state
+                    .data
+                    .read()
+                    .user_data
+                    .user
+                    .as_ref()
+                    .map(|u| u.id.to_owned())
+                    .unwrap();
+                self.create_new_playlist(
+                    state,
+                    user_id,
+                    playlist_name.as_str(),
+                    public,
+                    collab,
+                    desc.as_str(),
+                )
+                .await?;
+            }
         };
 
         tracing::info!(
@@ -1387,6 +1411,35 @@ impl Client {
         Ok(())
     }
 
+    pub async fn create_new_playlist(
+        &self,
+        state: &SharedState,
+        user_id: UserId<'static>,
+        playlist_name: &str,
+        public: bool,
+        collab: bool,
+        desc: &str,
+    ) -> Result<()> {
+        let playlist: Playlist = self
+            .spotify
+            .user_playlist_create(
+                user_id,
+                playlist_name,
+                Some(public),
+                Some(collab),
+                Some(desc),
+            )
+            .await?
+            .into();
+        tracing::info!(
+            "new playlist (name={},id={}) was successfully created",
+            playlist.name,
+            playlist.id
+        );
+        state.data.write().user_data.playlists.insert(0, playlist);
+        Ok(())
+    }
+
     #[cfg(feature = "notify")]
     fn notify_new_track(
         track: rspotify_model::FullTrack,
diff --git a/spotify_player/src/command.rs b/spotify_player/src/command.rs
index 244dc6a8..f644a318 100644
--- a/spotify_player/src/command.rs
+++ b/spotify_player/src/command.rs
@@ -74,6 +74,8 @@ pub enum Command {
 
     MovePlaylistItemUp,
     MovePlaylistItemDown,
+
+    CreatePlaylist,
 }
 
 #[derive(Debug, Copy, Clone)]
@@ -259,6 +261,7 @@ impl Command {
             Self::ReverseTrackOrder => "reverse the order of the track table (if any)",
             Self::MovePlaylistItemUp => "move playlist item up one position",
             Self::MovePlaylistItemDown => "move playlist item down one position",
+            Self::CreatePlaylist => "create a new playlist",
         }
     }
 }
diff --git a/spotify_player/src/config/keymap.rs b/spotify_player/src/config/keymap.rs
index e023f4f4..d7abfb76 100644
--- a/spotify_player/src/config/keymap.rs
+++ b/spotify_player/src/config/keymap.rs
@@ -295,6 +295,10 @@ impl Default for KeymapConfig {
                     key_sequence: "C-j".into(),
                     command: Command::MovePlaylistItemDown,
                 },
+                Keymap {
+                    key_sequence: "N".into(),
+                    command: Command::CreatePlaylist,
+                },
             ],
         }
     }
diff --git a/spotify_player/src/event/mod.rs b/spotify_player/src/event/mod.rs
index 51a57bc6..88553ed7 100644
--- a/spotify_player/src/event/mod.rs
+++ b/spotify_player/src/event/mod.rs
@@ -77,6 +77,12 @@ pub enum ClientRequest {
     },
     #[cfg(feature = "streaming")]
     RestartIntegratedClient,
+    CreatePlaylist {
+        playlist_name: String,
+        public: bool,
+        collab: bool,
+        desc: String,
+    },
 }
 
 /// starts a terminal event handler (key pressed, mouse clicked, etc)
@@ -144,7 +150,7 @@ fn handle_key_event(
 
     // parse the key sequence from user's previous inputs
     let mut key_sequence = state.ui.lock().input_key_sequence.clone();
-    key_sequence.keys.push(key.clone());
+    key_sequence.keys.push(key);
     if state
         .configs
         .keymap_config
@@ -460,6 +466,13 @@ fn handle_global_command(
             ui.popup = Some(PopupState::Queue { scroll_offset: 0 });
             client_pub.send(ClientRequest::GetCurrentUserQueue)?;
         }
+        Command::CreatePlaylist => {
+            ui.popup = Some(PopupState::PlaylistCreate {
+                name: LineInput::default(),
+                desc: LineInput::default(),
+                current_field: PlaylistCreateCurrentField::Name,
+            });
+        }
         _ => return Ok(false),
     }
     Ok(true)
diff --git a/spotify_player/src/event/page.rs b/spotify_player/src/event/page.rs
index cf4b630a..b0d3ce74 100644
--- a/spotify_player/src/event/page.rs
+++ b/spotify_player/src/event/page.rs
@@ -118,19 +118,20 @@ pub fn handle_key_sequence_for_search_page(
     // handle user's input
     if let SearchFocusState::Input = focus_state {
         if key_sequence.keys.len() == 1 {
-            if let Key::None(crossterm::event::KeyCode::Enter) = key_sequence.keys[0] {
-                if !line_input.is_empty() {
-                    *current_query = line_input.get_text();
-                    client_pub.send(ClientRequest::Search(line_input.get_text()))?;
+            return match &key_sequence.keys[0] {
+                Key::None(crossterm::event::KeyCode::Enter) => {
+                    if !line_input.is_empty() {
+                        *current_query = line_input.get_text();
+                        client_pub.send(ClientRequest::Search(line_input.get_text()))?;
+                    }
+                    Ok(true)
                 }
-                return Ok(true);
-            }
+                k => match line_input.input(k) {
+                    None => Ok(false),
+                    _ => Ok(true),
+                },
+            };
         }
-
-        return match line_input.input(key_sequence) {
-            None => Ok(false),
-            _ => Ok(true),
-        };
     }
 
     let command = match state
diff --git a/spotify_player/src/event/popup.rs b/spotify_player/src/event/popup.rs
index 648710f9..752033ad 100644
--- a/spotify_player/src/event/popup.rs
+++ b/spotify_player/src/event/popup.rs
@@ -25,11 +25,16 @@ pub fn handle_key_sequence_for_popup(
     // handle popups that need reading the raw key sequence instead of the matched command
     match popup {
         PopupState::Search { .. } => {
-            // NOTE: the `drop(ui)` is important as the handle function for search
+            // NOTE: the `drop(ui)` is important as the handle function for the popup
             // re-acquire the UI lock, so we need to drop the current UI lock to avoid a deadlock.
             drop(ui);
             return handle_key_sequence_for_search_popup(key_sequence, client_pub, state);
         }
+        PopupState::PlaylistCreate { .. } => {
+            // NOTE: same here.
+            drop(ui);
+            return handle_key_sequence_for_create_playlist_popup(key_sequence, client_pub, state);
+        }
         PopupState::ActionList(item, ..) => {
             return handle_key_sequence_for_action_list_popup(
                 item.n_actions(),
@@ -53,6 +58,9 @@ pub fn handle_key_sequence_for_popup(
 
     match popup {
         PopupState::Search { .. } => anyhow::bail!("search popup should be handled before"),
+        PopupState::PlaylistCreate { .. } => {
+            anyhow::bail!("create playlist popup should be handled before")
+        }
         PopupState::ActionList(..) => {
             anyhow::bail!("action list popup should be handled before")
         }
@@ -264,6 +272,66 @@ fn handle_command_for_queue_popup(
     Ok(true)
 }
 
+fn handle_key_sequence_for_create_playlist_popup(
+    key_sequence: &KeySequence,
+    client_pub: &flume::Sender<ClientRequest>,
+    state: &SharedState,
+) -> Result<bool> {
+    {
+        let mut ui = state.ui.lock();
+        let (name, desc, current_field) = match ui.popup {
+            Some(PopupState::PlaylistCreate {
+                ref mut name,
+                ref mut desc,
+                ref mut current_field,
+            }) => (name, desc, current_field),
+            _ => return Ok(false),
+        };
+        if key_sequence.keys.len() == 1 {
+            match &key_sequence.keys[0] {
+                Key::None(crossterm::event::KeyCode::Enter) => {
+                    client_pub.send(ClientRequest::CreatePlaylist {
+                        playlist_name: name.get_text(),
+                        public: false,
+                        collab: false,
+                        desc: desc.get_text(),
+                    })?;
+                    ui.popup = None;
+                    return Ok(true);
+                }
+                Key::None(crossterm::event::KeyCode::Tab)
+                | Key::None(crossterm::event::KeyCode::BackTab) => {
+                    *current_field = match &current_field {
+                        PlaylistCreateCurrentField::Name => PlaylistCreateCurrentField::Desc,
+                        PlaylistCreateCurrentField::Desc => PlaylistCreateCurrentField::Name,
+                    };
+                    return Ok(true);
+                }
+                k => {
+                    let line_input = match current_field {
+                        PlaylistCreateCurrentField::Name => name,
+                        PlaylistCreateCurrentField::Desc => desc,
+                    };
+                    if line_input.input(k).is_some() {
+                        return Ok(true);
+                    }
+                }
+            }
+        }
+    }
+
+    let command = state
+        .configs
+        .keymap_config
+        .find_command_from_key_sequence(key_sequence);
+    if let Some(Command::ClosePopup) = command {
+        state.ui.lock().popup = None;
+        return Ok(true);
+    }
+
+    Ok(false)
+}
+
 /// handles a key sequence for a context search popup
 fn handle_key_sequence_for_search_popup(
     key_sequence: &KeySequence,
diff --git a/spotify_player/src/key.rs b/spotify_player/src/key.rs
index 462c4ba9..1ba5c123 100644
--- a/spotify_player/src/key.rs
+++ b/spotify_player/src/key.rs
@@ -1,6 +1,6 @@
 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
 
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
 /// Key represents a key received from user's input
 pub enum Key {
     Unknown,
diff --git a/spotify_player/src/state/ui/popup.rs b/spotify_player/src/state/ui/popup.rs
index aa21ea44..06b80cbb 100644
--- a/spotify_player/src/state/ui/popup.rs
+++ b/spotify_player/src/state/ui/popup.rs
@@ -1,10 +1,20 @@
-use crate::{command, state::model::*};
+use crate::{command, state::model::*, ui::single_line_input::LineInput};
 use tui::widgets::ListState;
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PlaylistCreateCurrentField {
+    Name,
+    Desc,
+}
+
 #[derive(Debug)]
 pub enum PopupState {
-    CommandHelp { scroll_offset: usize },
-    Search { query: String },
+    CommandHelp {
+        scroll_offset: usize,
+    },
+    Search {
+        query: String,
+    },
     UserPlaylistList(PlaylistPopupAction, ListState),
     UserFollowedArtistList(ListState),
     UserSavedAlbumList(ListState),
@@ -12,7 +22,14 @@ pub enum PopupState {
     ArtistList(ArtistPopupAction, Vec<Artist>, ListState),
     ThemeList(Vec<crate::config::Theme>, ListState),
     ActionList(ActionListItem, ListState),
-    Queue { scroll_offset: usize },
+    Queue {
+        scroll_offset: usize,
+    },
+    PlaylistCreate {
+        name: LineInput,
+        desc: LineInput,
+        current_field: PlaylistCreateCurrentField,
+    },
 }
 
 #[derive(Debug, Clone)]
@@ -48,7 +65,10 @@ impl PopupState {
             Self::ArtistList(.., list_state) => Some(list_state),
             Self::ThemeList(.., list_state) => Some(list_state),
             Self::ActionList(.., list_state) => Some(list_state),
-            Self::CommandHelp { .. } | Self::Search { .. } | Self::Queue { .. } => None,
+            Self::CommandHelp { .. }
+            | Self::Search { .. }
+            | Self::Queue { .. }
+            | Self::PlaylistCreate { .. } => None,
         }
     }
 
@@ -62,7 +82,10 @@ impl PopupState {
             Self::ArtistList(.., list_state) => Some(list_state),
             Self::ThemeList(.., list_state) => Some(list_state),
             Self::ActionList(.., list_state) => Some(list_state),
-            Self::CommandHelp { .. } | Self::Search { .. } | Self::Queue { .. } => None,
+            Self::CommandHelp { .. }
+            | Self::Search { .. }
+            | Self::Queue { .. }
+            | Self::PlaylistCreate { .. } => None,
         }
     }
 
diff --git a/spotify_player/src/ui/popup.rs b/spotify_player/src/ui/popup.rs
index c7ff95e3..4ce88887 100644
--- a/spotify_player/src/ui/popup.rs
+++ b/spotify_player/src/ui/popup.rs
@@ -25,6 +25,46 @@ pub fn render_popup(
     match ui.popup {
         None => (rect, true),
         Some(ref popup) => match popup {
+            PopupState::PlaylistCreate {
+                name,
+                desc,
+                current_field,
+            } => {
+                let chunks =
+                    Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(rect);
+
+                let popup_chunks =
+                    Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
+                        .split(chunks[1]);
+
+                let name_input = construct_and_render_block(
+                    "Enter Name for New Playlist:",
+                    &ui.theme,
+                    state,
+                    Borders::ALL,
+                    frame,
+                    popup_chunks[0],
+                );
+
+                let desc_input = construct_and_render_block(
+                    "Enter Description for New Playlist:",
+                    &ui.theme,
+                    state,
+                    Borders::ALL,
+                    frame,
+                    popup_chunks[1],
+                );
+
+                frame.render_widget(
+                    name.widget(PlaylistCreateCurrentField::Name == *current_field),
+                    name_input,
+                );
+                frame.render_widget(
+                    desc.widget(PlaylistCreateCurrentField::Desc == *current_field),
+                    desc_input,
+                );
+                (chunks[0], true)
+            }
             PopupState::Search { query } => {
                 let chunks =
                     Layout::vertical([Constraint::Fill(0), Constraint::Length(3)]).split(rect);
diff --git a/spotify_player/src/ui/single_line_input.rs b/spotify_player/src/ui/single_line_input.rs
index 0e4798cc..48c26285 100644
--- a/spotify_player/src/ui/single_line_input.rs
+++ b/spotify_player/src/ui/single_line_input.rs
@@ -2,7 +2,7 @@ use crossterm::event::KeyCode;
 use tui::widgets::Widget;
 
 use super::*;
-use crate::key::{Key, KeySequence};
+use crate::key::Key;
 
 #[derive(Debug, Clone)]
 pub struct LineInput {
@@ -34,14 +34,14 @@ impl LineInput {
         }
     }
 
-    pub fn input(&mut self, key_sequence: &KeySequence) -> Option<InputEffect> {
-        match key_sequence.keys[0] {
+    pub fn input(&mut self, key: &Key) -> Option<InputEffect> {
+        match key {
             Key::None(c) => match c {
                 KeyCode::Char(c) => {
                     if self.cursor == self.line.len() {
-                        self.line.push(c);
+                        self.line.push(*c);
                     } else {
-                        self.line.insert(self.cursor, c);
+                        self.line.insert(self.cursor, *c);
                     }
                     self.cursor += 1;
                     Some(InputEffect::TextChanged)


Test patch: