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

Issue:
Playlist Folder Support
**Is your feature already implemented in the latest `master`?**
No

**Is your feature request related to a problem? Please describe.**
Being able to organize playlists on spotify helps navigation a lot. It will be especially helpful when adding a song to a playlist

**Describe the solution you'd like**
Support playlist folders like on the official clients

**Describe alternatives you've considered**
Allowing custom ordering of playlists on spotify-player but that would always go out of sync with the official mobile client whenever a new playlist is added and generally be clumsy



Discussion / hints:

Fix patch:
diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs
index d911bd8b..f585f4c0 100644
--- a/spotify_player/src/client/mod.rs
+++ b/spotify_player/src/client/mod.rs
@@ -300,6 +300,15 @@ impl Client {
             }
             ClientRequest::GetUserPlaylists => {
                 let playlists = self.current_user_playlists().await?;
+                let node = state.data.read().user_data.playlist_folder_node.clone();
+                let playlists = if let Some(node) = node.filter(|n| !n.children.is_empty()) {
+                    crate::playlist_folders::structurize(playlists, node.children)
+                } else {
+                    playlists
+                        .into_iter()
+                        .map(PlaylistFolderItem::Playlist)
+                        .collect()
+                };
                 store_data_into_file_cache(
                     FileCacheKey::Playlists,
                     &config::get_config().cache_folder,
@@ -1085,7 +1094,12 @@ impl Client {
                     if !follows[0] {
                         self.playlist_follow(playlist.id.as_ref(), None).await?;
                         // update the in-memory `user_data`
-                        state.data.write().user_data.playlists.insert(0, playlist);
+                        state
+                            .data
+                            .write()
+                            .user_data
+                            .playlists
+                            .insert(0, PlaylistFolderItem::Playlist(playlist));
                     }
                 }
             }
@@ -1125,7 +1139,10 @@ impl Client {
                     .write()
                     .user_data
                     .playlists
-                    .retain(|p| p.id != id);
+                    .retain(|item| match item {
+                        PlaylistFolderItem::Playlist(p) => p.id != id,
+                        _ => true,
+                    });
                 self.playlist_unfollow(id).await?;
             }
         }
@@ -1478,7 +1495,12 @@ impl Client {
             playlist.name,
             playlist.id
         );
-        state.data.write().user_data.playlists.insert(0, playlist);
+        state
+            .data
+            .write()
+            .user_data
+            .playlists
+            .insert(0, PlaylistFolderItem::Playlist(playlist));
         Ok(())
     }
 
diff --git a/spotify_player/src/command.rs b/spotify_player/src/command.rs
index 52ff9617..7fbd7047 100644
--- a/spotify_player/src/command.rs
+++ b/spotify_player/src/command.rs
@@ -1,4 +1,6 @@
-use crate::state::{Album, Artist, DataReadGuard, Playlist, Track};
+use crate::state::{
+    Album, Artist, DataReadGuard, Playlist, PlaylistFolder, PlaylistFolderItem, Track,
+};
 use serde::Deserialize;
 
 #[derive(Copy, Clone, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
@@ -105,6 +107,9 @@ pub enum ActionContext {
     Album(Album),
     Artist(Artist),
     Playlist(Playlist),
+    #[allow(dead_code)]
+    // TODO: support actions for playlist folders
+    PlaylistFolder(PlaylistFolder),
 }
 
 pub enum CommandOrAction {
@@ -136,6 +141,15 @@ impl From<Playlist> for ActionContext {
     }
 }
 
+impl From<PlaylistFolderItem> for ActionContext {
+    fn from(value: PlaylistFolderItem) -> Self {
+        match value {
+            PlaylistFolderItem::Playlist(p) => ActionContext::Playlist(p),
+            PlaylistFolderItem::Folder(f) => ActionContext::PlaylistFolder(f),
+        }
+    }
+}
+
 impl ActionContext {
     pub fn get_available_actions(&self, data: &DataReadGuard) -> Vec<Action> {
         match self {
@@ -143,6 +157,8 @@ impl ActionContext {
             Self::Album(album) => construct_album_actions(album, data),
             Self::Artist(artist) => construct_artist_actions(artist, data),
             Self::Playlist(playlist) => construct_playlist_actions(playlist, data),
+            // TODO: support actions for playlist folders
+            Self::PlaylistFolder(_) => vec![],
         }
     }
 }
@@ -207,7 +223,12 @@ pub fn construct_artist_actions(artist: &Artist, data: &DataReadGuard) -> Vec<Ac
 pub fn construct_playlist_actions(playlist: &Playlist, data: &DataReadGuard) -> Vec<Action> {
     let mut actions = vec![Action::GoToRadio, Action::CopyLink];
 
-    if data.user_data.playlists.iter().any(|a| a.id == playlist.id) {
+    if data
+        .user_data
+        .playlists
+        .iter()
+        .any(|item| matches!(item, PlaylistFolderItem::Playlist(p) if p.id == playlist.id))
+    {
         actions.push(Action::DeleteFromLibrary);
     } else {
         actions.push(Action::AddToLibrary);
diff --git a/spotify_player/src/event/mod.rs b/spotify_player/src/event/mod.rs
index d816dc79..22598c34 100644
--- a/spotify_player/src/event/mod.rs
+++ b/spotify_player/src/event/mod.rs
@@ -159,7 +159,10 @@ pub fn handle_action_in_context(
             Action::AddToPlaylist => {
                 client_pub.send(ClientRequest::GetUserPlaylists)?;
                 ui.popup = Some(PopupState::UserPlaylistList(
-                    PlaylistPopupAction::AddTrack(track.id),
+                    PlaylistPopupAction::AddTrack {
+                        folder_id: 0,
+                        track_id: track.id,
+                    },
                     ListState::default(),
                 ));
             }
@@ -304,6 +307,8 @@ pub fn handle_action_in_context(
             }
             _ => {}
         },
+        // TODO: support actions for playlist folders
+        ActionContext::PlaylistFolder(_) => {}
     }
 
     Ok(())
@@ -444,7 +449,7 @@ fn handle_global_command(
         Command::BrowseUserPlaylists => {
             client_pub.send(ClientRequest::GetUserPlaylists)?;
             ui.popup = Some(PopupState::UserPlaylistList(
-                PlaylistPopupAction::Browse,
+                PlaylistPopupAction::Browse { folder_id: 0 },
                 ListState::default(),
             ));
         }
diff --git a/spotify_player/src/event/page.rs b/spotify_player/src/event/page.rs
index a27c7da3..57fb7c10 100644
--- a/spotify_player/src/event/page.rs
+++ b/spotify_player/src/event/page.rs
@@ -50,14 +50,17 @@ fn handle_action_for_library_page(
     state: &SharedState,
 ) -> Result<bool> {
     let data = state.data.read();
-    let focus_state = match ui.current_page() {
-        PageState::Library { state } => state.focus,
+    let (focus_state, folder_id) = match ui.current_page() {
+        PageState::Library { state } => (state.focus, state.playlist_folder_id),
         _ => anyhow::bail!("expect a library page state"),
     };
     match focus_state {
         LibraryFocusState::Playlists => window::handle_action_for_selected_item(
             action,
-            ui.search_filtered_items(&data.user_data.playlists),
+            ui.search_filtered_items(&data.user_data.folder_playlists_items(folder_id))
+                .into_iter()
+                .cloned()
+                .collect(),
             &data,
             ui,
             client_pub,
@@ -92,14 +95,17 @@ fn handle_command_for_library_page(
         }
         _ => {
             let data = state.data.read();
-            let focus_state = match ui.current_page() {
-                PageState::Library { state } => state.focus,
+            let (focus_state, folder_id) = match ui.current_page() {
+                PageState::Library { state } => (state.focus, state.playlist_folder_id),
                 _ => anyhow::bail!("expect a library page state"),
             };
             match focus_state {
                 LibraryFocusState::Playlists => window::handle_command_for_playlist_list_window(
                     command,
-                    ui.search_filtered_items(&data.user_data.playlists),
+                    ui.search_filtered_items(&data.user_data.folder_playlists_items(folder_id))
+                        .into_iter()
+                        .cloned()
+                        .collect(),
                     &data,
                     ui,
                 ),
@@ -214,16 +220,31 @@ fn handle_key_sequence_for_search_page(
             }
         }
         SearchFocusState::Playlists => {
-            let playlists = search_results
-                .map(|s| s.playlists.iter().collect())
+            let playlists: Vec<PlaylistFolderItem> = search_results
+                .map(|s| {
+                    s.playlists
+                        .iter()
+                        .map(|p| PlaylistFolderItem::Playlist(p.clone()))
+                        .collect()
+                })
                 .unwrap_or_default();
+            let playlist_refs = playlists.iter().collect();
 
             match found_keymap {
                 CommandOrAction::Command(command) => {
-                    window::handle_command_for_playlist_list_window(command, playlists, &data, ui)
+                    window::handle_command_for_playlist_list_window(
+                        command,
+                        playlist_refs,
+                        &data,
+                        ui,
+                    )
                 }
                 CommandOrAction::Action(action) => window::handle_action_for_selected_item(
-                    action, playlists, &data, ui, client_pub,
+                    action,
+                    playlist_refs,
+                    &data,
+                    ui,
+                    client_pub,
                 ),
             }
         }
diff --git a/spotify_player/src/event/popup.rs b/spotify_player/src/event/popup.rs
index 339cf633..41718275 100644
--- a/spotify_player/src/event/popup.rs
+++ b/spotify_player/src/event/popup.rs
@@ -89,45 +89,74 @@ pub fn handle_key_sequence_for_popup(
             )
         }
         PopupState::UserPlaylistList(action, _) => match action {
-            PlaylistPopupAction::Browse => {
-                let playlist_uris = state
-                    .data
-                    .read()
-                    .user_data
-                    .playlists
-                    .iter()
-                    .map(|p| p.id.uri())
-                    .collect::<Vec<_>>();
+            PlaylistPopupAction::Browse { folder_id } => {
+                let data = state.data.read();
+                let items = data.user_data.folder_playlists_items(*folder_id);
 
-                handle_command_for_context_browsing_list_popup(
+                handle_command_for_list_popup(
                     command,
                     ui,
-                    playlist_uris,
-                    rspotify_model::Type::Playlist,
+                    items.len(),
+                    |_, _| {},
+                    |ui: &mut UIStateGuard, id: usize| -> Result<()> {
+                        match items[id] {
+                            PlaylistFolderItem::Folder(f) => {
+                                ui.popup = Some(PopupState::UserPlaylistList(
+                                    PlaylistPopupAction::Browse {
+                                        folder_id: f.target_id,
+                                    },
+                                    ListState::default(),
+                                ));
+                            }
+                            PlaylistFolderItem::Playlist(p) => {
+                                let context_id = ContextId::Playlist(
+                                    PlaylistId::from_uri(&crate::utils::parse_uri(&p.id.uri()))?
+                                        .into_static(),
+                                );
+                                ui.new_page(PageState::Context {
+                                    id: None,
+                                    context_page_type: ContextPageType::Browsing(context_id),
+                                    state: None,
+                                });
+                            }
+                        }
+                        Ok(())
+                    },
+                    |ui: &mut UIStateGuard| {
+                        ui.popup = None;
+                    },
                 )
             }
-            PlaylistPopupAction::AddTrack(track_id) => {
+            PlaylistPopupAction::AddTrack {
+                folder_id,
+                track_id,
+            } => {
                 let track_id = track_id.clone();
-                let playlist_ids = state
-                    .data
-                    .read()
-                    .user_data
-                    .modifiable_playlists()
-                    .into_iter()
-                    .map(|p| p.id.clone())
-                    .collect::<Vec<_>>();
+                let data = state.data.read();
+                let items = data.user_data.modifiable_playlist_items(Some(*folder_id));
 
                 handle_command_for_list_popup(
                     command,
                     ui,
-                    playlist_ids.len(),
+                    items.len(),
                     |_, _| {},
                     |ui: &mut UIStateGuard, id: usize| -> Result<()> {
-                        client_pub.send(ClientRequest::AddTrackToPlaylist(
-                            playlist_ids[id].clone(),
-                            track_id.clone(),
-                        ))?;
-                        ui.popup = None;
+                        ui.popup = match items[id] {
+                            PlaylistFolderItem::Folder(f) => Some(PopupState::UserPlaylistList(
+                                PlaylistPopupAction::AddTrack {
+                                    folder_id: f.target_id,
+                                    track_id,
+                                },
+                                ListState::default(),
+                            )),
+                            PlaylistFolderItem::Playlist(p) => {
+                                client_pub.send(ClientRequest::AddTrackToPlaylist(
+                                    p.id.clone(),
+                                    track_id,
+                                ))?;
+                                None
+                            }
+                        };
                         Ok(())
                     },
                     |ui: &mut UIStateGuard| {
@@ -370,9 +399,9 @@ fn handle_command_for_list_popup(
     command: Command,
     ui: &mut UIStateGuard,
     n_items: usize,
-    on_select_func: impl Fn(&mut UIStateGuard, usize),
-    on_choose_func: impl Fn(&mut UIStateGuard, usize) -> Result<()>,
-    on_close_func: impl Fn(&mut UIStateGuard),
+    on_select_func: impl FnOnce(&mut UIStateGuard, usize),
+    on_choose_func: impl FnOnce(&mut UIStateGuard, usize) -> Result<()>,
+    on_close_func: impl FnOnce(&mut UIStateGuard),
 ) -> Result<bool> {
     let popup = ui.popup.as_mut().with_context(|| "expect a popup")?;
     let current_id = popup.list_selected().unwrap_or_default();
diff --git a/spotify_player/src/event/window.rs b/spotify_player/src/event/window.rs
index a5a07a8d..5bc14a71 100644
--- a/spotify_player/src/event/window.rs
+++ b/spotify_player/src/event/window.rs
@@ -275,11 +275,10 @@ fn handle_command_for_track_table_window(
     }
 
     if let Some(ContextId::Playlist(ref playlist_id)) = context_id {
-        let modifiable = data
-            .user_data
-            .modifiable_playlists()
-            .iter()
-            .any(|p| p.id.eq(playlist_id));
+        let modifiable =
+            data.user_data.modifiable_playlist_items(None).iter().any(
+                |item| matches!(item, PlaylistFolderItem::Playlist(p) if p.id.eq(playlist_id)),
+            );
         if modifiable
             && handle_playlist_modify_command(
                 id,
@@ -456,7 +455,7 @@ pub fn handle_command_for_album_list_window(
 
 pub fn handle_command_for_playlist_list_window(
     command: Command,
-    playlists: Vec<&Playlist>,
+    playlists: Vec<&PlaylistFolderItem>,
     data: &DataReadGuard,
     ui: &mut UIStateGuard,
 ) -> Result<bool> {
@@ -470,19 +469,37 @@ pub fn handle_command_for_playlist_list_window(
     }
     match command {
         Command::ChooseSelected => {
-            let context_id = ContextId::Playlist(playlists[id].id.clone());
-            ui.new_page(PageState::Context {
-                id: None,
-                context_page_type: ContextPageType::Browsing(context_id),
-                state: None,
-            });
+            let playlist = playlists[id];
+            match playlist {
+                PlaylistFolderItem::Folder(f) => {
+                    // currently folders are only supported in the library page
+                    match ui.current_page_mut() {
+                        PageState::Library { state } => {
+                            state.playlist_list.select(Some(0));
+                            state.focus = LibraryFocusState::Playlists;
+                            state.playlist_folder_id = f.target_id;
+                        }
+                        _ => return Ok(false),
+                    };
+                }
+                PlaylistFolderItem::Playlist(p) => {
+                    let context_id = ContextId::Playlist(p.id.clone());
+                    ui.new_page(PageState::Context {
+                        id: None,
+                        context_page_type: ContextPageType::Browsing(context_id),
+                        state: None,
+                    });
+                }
+            }
         }
         Command::ShowActionsOnSelectedItem => {
-            let actions = construct_playlist_actions(playlists[id], data);
-            ui.popup = Some(PopupState::ActionList(
-                Box::new(ActionListItem::Playlist(playlists[id].clone(), actions)),
-                ListState::default(),
-            ));
+            if let PlaylistFolderItem::Playlist(p) = playlists[id] {
+                let actions = construct_playlist_actions(p, data);
+                ui.popup = Some(PopupState::ActionList(
+                    Box::new(ActionListItem::Playlist(p.clone(), actions)),
+                    ListState::default(),
+                ));
+            }
         }
         _ => return Ok(false),
     }
diff --git a/spotify_player/src/main.rs b/spotify_player/src/main.rs
index 8046da57..20ae9b94 100644
--- a/spotify_player/src/main.rs
+++ b/spotify_player/src/main.rs
@@ -7,6 +7,7 @@ mod event;
 mod key;
 #[cfg(feature = "media-control")]
 mod media_control;
+mod playlist_folders;
 mod state;
 #[cfg(feature = "streaming")]
 mod streaming;
diff --git a/spotify_player/src/playlist_folders.rs b/spotify_player/src/playlist_folders.rs
new file mode 100644
index 00000000..985e26e8
--- /dev/null
+++ b/spotify_player/src/playlist_folders.rs
@@ -0,0 +1,64 @@
+use std::collections::HashMap;
+
+use rspotify::model::Id;
+
+use crate::state::{Playlist, PlaylistFolder, PlaylistFolderItem, PlaylistFolderNode};
+
+/// Structurize a flat input playlist according to the playlist folder nodes
+pub fn structurize(
+    playlists: Vec<Playlist>,
+    nodes: Vec<PlaylistFolderNode>,
+) -> Vec<PlaylistFolderItem> {
+    let mut playlist_folders = Vec::new();
+
+    let mut playlists = playlists
+        .into_iter()
+        .map(|p| (p.id.id().to_string(), p))
+        .collect::<HashMap<_, _>>();
+
+    // Construct playlist folders with relevant playlists
+    add_playlist_folders(&nodes, &mut playlists, &mut 0, &mut playlist_folders);
+
+    // Remaining playlists that don't belong to any folders are added as root playlists
+    for (_, mut p) in playlists {
+        p.current_folder_id = 0;
+        playlist_folders.push(PlaylistFolderItem::Playlist(p));
+    }
+    playlist_folders
+}
+
+fn add_playlist_folders(
+    nodes: &Vec<PlaylistFolderNode>,
+    playlists: &mut HashMap<String, Playlist>,
+    folder_id: &mut usize,
+    acc: &mut Vec<PlaylistFolderItem>,
+) {
+    let current_folder_id = *folder_id;
+    for f in nodes {
+        if let Some((_, id)) = f.uri.rsplit_once(':') {
+            if f.node_type == "folder" {
+                *folder_id += 1;
+                let name = f
+                    .name
+                    .clone()
+                    .unwrap_or(format!("folder_{current_folder_id}"));
+                // Folder node
+                acc.push(PlaylistFolderItem::Folder(PlaylistFolder {
+                    name: name.clone(),
+                    current_id: current_folder_id,
+                    target_id: *folder_id,
+                }));
+                // Up node
+                acc.push(PlaylistFolderItem::Folder(PlaylistFolder {
+                    name: format!("← {name}"),
+                    current_id: *folder_id,
+                    target_id: current_folder_id,
+                }));
+                add_playlist_folders(&f.children, playlists, folder_id, acc);
+            } else if let Some(mut p) = playlists.remove(id) {
+                p.current_folder_id = current_folder_id;
+                acc.push(PlaylistFolderItem::Playlist(p));
+            }
+        }
+    }
+}
diff --git a/spotify_player/src/state/data.rs b/spotify_player/src/state/data.rs
index 6b58ffa3..9fb56aa4 100644
--- a/spotify_player/src/state/data.rs
+++ b/spotify_player/src/state/data.rs
@@ -11,6 +11,7 @@ pub type DataReadGuard<'a> = parking_lot::RwLockReadGuard<'a, AppData>;
 #[derive(Debug)]
 pub enum FileCacheKey {
     Playlists,
+    PlaylistFolders,
     FollowedArtists,
     SavedAlbums,
     SavedTracks,
@@ -31,7 +32,8 @@ pub struct AppData {
 /// current user's data
 pub struct UserData {
     pub user: Option<rspotify_model::PrivateUser>,
-    pub playlists: Vec<Playlist>,
+    pub playlists: Vec<PlaylistFolderItem>,
+    pub playlist_folder_node: Option<PlaylistFolderNode>,
     pub followed_artists: Vec<Artist>,
     pub saved_albums: Vec<Album>,
     pub saved_tracks: HashMap<String, Track>,
@@ -107,6 +109,10 @@ impl UserData {
             user: None,
             playlists: load_data_from_file_cache(FileCacheKey::Playlists, cache_folder)
                 .unwrap_or_default(),
+            playlist_folder_node: load_data_from_file_cache(
+                FileCacheKey::PlaylistFolders,
+                cache_folder,
+            ),
             followed_artists: load_data_from_file_cache(
                 FileCacheKey::FollowedArtists,
                 cache_folder,
@@ -119,18 +125,47 @@ impl UserData {
         }
     }
 
-    /// Get a list of playlists that are **possibly** modifiable by user
-    pub fn modifiable_playlists(&self) -> Vec<&Playlist> {
+    /// Get a list of playlist items that are **possibly** modifiable by user
+    ///
+    /// If `folder_id` is provided, returns items in the given folder id.
+    /// Otherwise, returns the all items.
+    pub fn modifiable_playlist_items(&self, folder_id: Option<usize>) -> Vec<&PlaylistFolderItem> {
         match self.user {
             None => vec![],
             Some(ref u) => self
                 .playlists
                 .iter()
-                .filter(|p| p.owner.1 == u.id || p.collaborative)
+                // filter items in a folder (if specified)
+                .filter(|item| {
+                    if let Some(folder_id) = folder_id {
+                        match item {
+                            PlaylistFolderItem::Playlist(p) => p.current_folder_id == folder_id,
+                            PlaylistFolderItem::Folder(f) => f.current_id == folder_id,
+                        }
+                    } else {
+                        true
+                    }
+                })
+                // filter modifiable items
+                .filter(|item| match item {
+                    PlaylistFolderItem::Playlist(p) => p.owner.1 == u.id || p.collaborative,
+                    PlaylistFolderItem::Folder(_) => true,
+                })
                 .collect(),
         }
     }
 
+    /// Get playlists items for the given folder id
+    pub fn folder_playlists_items(&self, folder_id: usize) -> Vec<&PlaylistFolderItem> {
+        self.playlists
+            .iter()
+            .filter(|item| match item {
+                PlaylistFolderItem::Playlist(p) => p.current_folder_id == folder_id,
+                PlaylistFolderItem::Folder(f) => f.current_id == folder_id,
+            })
+            .collect()
+    }
+
     /// Check if a track is a liked track
     pub fn is_liked_track(&self, track: &Track) -> bool {
         self.saved_tracks.contains_key(&track.id.uri())
diff --git a/spotify_player/src/state/model.rs b/spotify_player/src/state/model.rs
index e57e67e6..61754edb 100644
--- a/spotify_player/src/state/model.rs
+++ b/spotify_player/src/state/model.rs
@@ -155,6 +155,39 @@ pub struct Playlist {
     pub name: String,
     pub owner: (String, UserId<'static>),
     pub desc: String,
+    /// which folder id the playlist refers to
+    #[serde(default)]
+    pub current_folder_id: usize,
+}
+
+#[derive(Deserialize, Serialize, Debug, Clone)]
+/// A playlist folder, not related to Spotify API yet
+pub struct PlaylistFolder {
+    pub name: String,
+    /// current folder id in the folders tree
+    pub current_id: usize,
+    /// target folder id it refers to
+    pub target_id: usize,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+/// A playlist folder item
+pub enum PlaylistFolderItem {
+    Playlist(Playlist),
+    Folder(PlaylistFolder),
+}
+
+#[derive(Deserialize, Debug, Clone)]
+/// A reference node retrieved by running https://github.com/mikez/spotify-folders
+/// Helps building a playlist folder hierarchy
+pub struct PlaylistFolderNode {
+    pub name: Option<String>,
+    #[serde(rename = "type")]
+    pub node_type: String,
+    #[serde(default)]
+    pub uri: String,
+    #[serde(default = "Vec::new")]
+    pub children: Vec<PlaylistFolderNode>,
 }
 
 #[derive(Clone, Debug)]
@@ -416,6 +449,7 @@ impl From<rspotify_model::SimplifiedPlaylist> for Playlist {
                 playlist.owner.id,
             ),
             desc: String::new(),
+            current_folder_id: 0,
         }
     }
 }
@@ -436,6 +470,7 @@ impl From<rspotify_model::FullPlaylist> for Playlist {
                 playlist.owner.id,
             ),
             desc,
+            current_folder_id: 0,
         }
     }
 }
@@ -446,6 +481,21 @@ impl std::fmt::Display for Playlist {
     }
 }
 
+impl std::fmt::Display for PlaylistFolder {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}/", self.name)
+    }
+}
+
+impl std::fmt::Display for PlaylistFolderItem {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            PlaylistFolderItem::Playlist(playlist) => playlist.fmt(f),
+            PlaylistFolderItem::Folder(folder) => folder.fmt(f),
+        }
+    }
+}
+
 impl From<rspotify_model::category::Category> for Category {
     fn from(c: rspotify_model::category::Category) -> Self {
         Self {
diff --git a/spotify_player/src/state/ui/page.rs b/spotify_player/src/state/ui/page.rs
index 3506364f..0e4e783d 100644
--- a/spotify_player/src/state/ui/page.rs
+++ b/spotify_player/src/state/ui/page.rs
@@ -51,6 +51,7 @@ pub struct LibraryPageUIState {
     pub saved_album_list: ListState,
     pub followed_artist_list: ListState,
     pub focus: LibraryFocusState,
+    pub playlist_folder_id: usize,
 }
 
 #[derive(Clone, Debug)]
@@ -165,6 +166,7 @@ impl PageState {
                         saved_album_list,
                         followed_artist_list,
                         focus,
+                        ..
                     },
             } => Some(match focus {
                 LibraryFocusState::Playlists => MutableWindowState::List(playlist_list),
@@ -233,6 +235,7 @@ impl LibraryPageUIState {
             saved_album_list: ListState::default(),
             followed_artist_list: ListState::default(),
             focus: LibraryFocusState::Playlists,
+            playlist_folder_id: 0,
         }
     }
 }
diff --git a/spotify_player/src/state/ui/popup.rs b/spotify_player/src/state/ui/popup.rs
index f0a68152..af774ddd 100644
--- a/spotify_player/src/state/ui/popup.rs
+++ b/spotify_player/src/state/ui/popup.rs
@@ -37,8 +37,13 @@ pub enum ActionListItem {
 /// An action on an item in a playlist popup list
 #[derive(Debug)]
 pub enum PlaylistPopupAction {
-    Browse,
-    AddTrack(TrackId<'static>),
+    Browse {
+        folder_id: usize,
+    },
+    AddTrack {
+        folder_id: usize,
+        track_id: TrackId<'static>,
+    },
 }
 
 /// An action on an item in an artist popup list
diff --git a/spotify_player/src/ui/page.rs b/spotify_player/src/ui/page.rs
index 46a1d5b5..d88ce461 100644
--- a/spotify_player/src/ui/page.rs
+++ b/spotify_player/src/ui/page.rs
@@ -317,8 +317,8 @@ pub fn render_library_page(
     let data = state.data.read();
     let configs = config::get_config();
 
-    let focus_state = match ui.current_page() {
-        PageState::Library { state } => state.focus,
+    let (focus_state, playlist_folder_id) = match ui.current_page() {
+        PageState::Library { state } => (state.focus, state.playlist_folder_id),
         _ => return,
     };
 
@@ -357,13 +357,23 @@ pub fn render_library_page(
 
     // 3. Construct the page's widgets
     // Construct the playlist window
+    let items = ui
+        .search_filtered_items(&data.user_data.folder_playlists_items(playlist_folder_id))
+        .into_iter()
+        .map(|item| match item {
+            PlaylistFolderItem::Playlist(p) => {
+                (p.to_string(), curr_context_uri == Some(p.id.uri()))
+            }
+            PlaylistFolderItem::Folder(f) => (f.to_string(), false),
+        })
+        .collect::<Vec<_>>();
+
     let (playlist_list, n_playlists) = utils::construct_list_widget(
         &ui.theme,
-        ui.search_filtered_items(&data.user_data.playlists)
-            .into_iter()
-            .map(|p| (p.to_string(), curr_context_uri == Some(p.id.uri())))
-            .collect(),
-        is_active && focus_state == LibraryFocusState::Playlists,
+        items,
+        is_active
+            && focus_state != LibraryFocusState::SavedAlbums
+            && focus_state != LibraryFocusState::FollowedArtists,
     );
     // Construct the saved album window
     let (album_list, n_albums) = utils::construct_list_widget(
diff --git a/spotify_player/src/ui/popup.rs b/spotify_player/src/ui/popup.rs
index 88fb4338..fa85fa87 100644
--- a/spotify_player/src/ui/popup.rs
+++ b/spotify_player/src/ui/popup.rs
@@ -105,14 +105,15 @@ pub fn render_popup(
             }
             PopupState::UserPlaylistList(action, _) => {
                 let data = state.data.read();
-                let playlists = match action {
-                    PlaylistPopupAction::Browse => data.user_data.playlists.iter().collect(),
-                    PlaylistPopupAction::AddTrack(_) => data.user_data.modifiable_playlists(),
+                let items = match action {
+                    PlaylistPopupAction::Browse { folder_id } => {
+                        data.user_data.folder_playlists_items(*folder_id)
+                    }
+                    PlaylistPopupAction::AddTrack { folder_id, .. } => {
+                        data.user_data.modifiable_playlist_items(Some(*folder_id))
+                    }
                 };
-                let items = playlists
-                    .into_iter()
-                    .map(|p| (p.to_string(), false))
-                    .collect();
+                let items = items.into_iter().map(|p| (p.to_string(), false)).collect();
 
                 let rect = render_list_popup(frame, rect, "User Playlists", items, 10, ui);
                 (rect, false)


Test patch: