Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat: implement playlist folder navigation and structure in UI
  • Loading branch information
LargeModGames committed Feb 10, 2026
commit 5e2b9a89880eebdbdea3633ce4bbc09864613bdd
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ librespot-core = { version = "0.8", optional = true }
librespot-connect = { version = "0.8", optional = true }
librespot-oauth = { version = "0.8", optional = true }
librespot-metadata = { version = "0.8", optional = true }
librespot-protocol = { version = "0.8", optional = true, default-features = false }
protobuf = { version = "3.7", optional = true }
futures = "0.3.31"

[target.'cfg(all(target_os = "linux", not(target_env = "musl")))'.dependencies]
Expand Down Expand Up @@ -91,7 +93,7 @@ block2 = { version = "0.6", optional = true }
[features]
default = ["telemetry", "streaming", "audio-viz-cpal", "mpris", "macos-media", "discord-rpc"]
telemetry = []
streaming = ["librespot-core", "librespot-playback", "librespot-connect", "librespot-oauth", "librespot-metadata"]
streaming = ["librespot-core", "librespot-playback", "librespot-connect", "librespot-oauth", "librespot-metadata", "librespot-protocol", "protobuf"]
# Audio backend features
alsa-backend = ["streaming", "librespot-playback/alsa-backend"]
pulseaudio-backend = ["streaming", "librespot-playback/pulseaudio-backend"]
Expand Down
90 changes: 80 additions & 10 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,37 @@ pub struct NativeTrackInfo {
pub duration_ms: u32,
}

/// A node in the playlist folder hierarchy from Spotify's rootlist
#[derive(Clone, Debug)]
pub struct PlaylistFolderNode {
pub name: Option<String>,
pub node_type: String, // "folder" or "playlist"
pub uri: String,
pub children: Vec<PlaylistFolderNode>,
Comment on lines +347 to +351
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PlaylistFolderNode.node_type is a free-form String but the code relies on specific values ("folder" vs "playlist") for control flow. Using a string here makes invalid states representable and adds allocations; consider making this an enum (and parsing into that) to improve type-safety and avoid unexpected fallthroughs.

Copilot uses AI. Check for mistakes.
}

/// A folder entry for navigation in the playlist panel
#[derive(Clone, Debug)]
pub struct PlaylistFolder {
pub name: String,
/// Folder ID this item is visible in (which folder "page" it appears on)
pub current_id: usize,
/// Folder ID this item navigates to when selected
pub target_id: usize,
}

/// A flattened item for display in the playlist panel
#[derive(Clone, Debug)]
pub enum PlaylistFolderItem {
Folder(PlaylistFolder),
Playlist {
/// Index into app.all_playlists
index: usize,
/// Folder ID this playlist is visible in
current_id: usize,
},
}

/// Settings screen category tabs
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub enum SettingsCategory {
Expand Down Expand Up @@ -545,6 +576,14 @@ pub struct App {
pub status_message_expires_at: Option<Instant>,
/// Pending track table selection to apply when new page loads
pub pending_track_table_selection: Option<PendingTrackSelection>,
/// Full flat list of all user playlists (all pages combined)
pub all_playlists: Vec<SimplifiedPlaylist>,
/// Folder tree from rootlist (None if not fetched or streaming disabled)
pub playlist_folder_nodes: Option<Vec<PlaylistFolderNode>>,
/// Flattened folder+playlist items for display navigation
pub playlist_folder_items: Vec<PlaylistFolderItem>,
/// Current folder ID being viewed (0 = root)
pub current_playlist_folder_id: usize,
/// Reference to the native streaming player for direct control (bypasses event channel)
#[cfg(feature = "streaming")]
pub streaming_player: Option<Arc<crate::player::StreamingPlayer>>,
Expand Down Expand Up @@ -682,6 +721,10 @@ impl Default for App {
status_message: None,
status_message_expires_at: None,
pending_track_table_selection: None,
all_playlists: Vec::new(),
playlist_folder_nodes: None,
playlist_folder_items: Vec::new(),
current_playlist_folder_id: 0,
#[cfg(feature = "streaming")]
streaming_player: None,
#[cfg(all(feature = "mpris", target_os = "linux"))]
Expand Down Expand Up @@ -722,6 +765,31 @@ impl App {
self.io_tx = None;
}

/// Get the items visible in the current folder level.
/// Returns a filtered view of playlist_folder_items where current_id matches
/// the current_playlist_folder_id.
pub fn get_playlist_display_items(&self) -> Vec<&PlaylistFolderItem> {
self
.playlist_folder_items
.iter()
.filter(|item| match item {
PlaylistFolderItem::Folder(f) => f.current_id == self.current_playlist_folder_id,
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_playlist_display_items allocates a new Vec of references and linearly scans playlist_folder_items on every call. This method is invoked in render paths and key handlers, so with large libraries it can become a noticeable CPU hot spot. Consider returning an iterator (or cached per-folder index lists) to avoid repeated allocations and full scans each frame.

Copilot uses AI. Check for mistakes.
PlaylistFolderItem::Playlist { current_id, .. } => {
*current_id == self.current_playlist_folder_id
}
})
.collect()
}

/// Get the SimplifiedPlaylist for a PlaylistFolderItem::Playlist variant
#[allow(dead_code)]
pub fn get_playlist_for_item(&self, item: &PlaylistFolderItem) -> Option<&SimplifiedPlaylist> {
match item {
PlaylistFolderItem::Playlist { index, .. } => self.all_playlists.get(*index),
PlaylistFolderItem::Folder(_) => None,
}
}

fn apply_seek(&mut self, seek_ms: u32) {
if let Some(CurrentPlaybackContext {
item: Some(item), ..
Expand Down Expand Up @@ -1724,16 +1792,18 @@ impl App {
}

pub fn user_unfollow_playlist(&mut self) {
if let (Some(playlists), Some(selected_index), Some(user)) =
(&self.playlists, self.selected_playlist_index, &self.user)
{
let selected_playlist = &playlists.items[selected_index];
let selected_id = selected_playlist.id.clone();
let user_id = user.id.clone();
self.dispatch(IoEvent::UserUnfollowPlaylist(
user_id.into_static(),
selected_id.into_static(),
));
if let (Some(selected_index), Some(user)) = (self.selected_playlist_index, &self.user) {
let display_items = self.get_playlist_display_items();
if let Some(PlaylistFolderItem::Playlist { index, .. }) = display_items.get(selected_index) {
if let Some(playlist) = self.all_playlists.get(*index) {
let selected_id = playlist.id.clone();
let user_id = user.id.clone();
self.dispatch(IoEvent::UserUnfollowPlaylist(
user_id.into_static(),
selected_id.into_static(),
));
}
}
}
}

Expand Down
98 changes: 59 additions & 39 deletions src/handlers/playlist.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use super::{
super::app::{App, DialogContext, TrackTableContext},
super::app::{App, DialogContext, PlaylistFolderItem, TrackTableContext},
common_key_events,
};
use crate::app::{ActiveBlock, RouteId};
Expand All @@ -10,68 +10,88 @@ pub fn handler(key: Key, app: &mut App) {
match key {
k if common_key_events::right_event(k) => common_key_events::handle_right_event(app),
k if common_key_events::down_event(k) => {
if let Some(p) = &app.playlists {
let display_items = app.get_playlist_display_items();
if !display_items.is_empty() {
if let Some(selected_playlist_index) = app.selected_playlist_index {
let next_index =
common_key_events::on_down_press_handler(&p.items, Some(selected_playlist_index));
common_key_events::on_down_press_handler(&display_items, Some(selected_playlist_index));
app.selected_playlist_index = Some(next_index);
}
};
}
}
k if common_key_events::up_event(k) => {
if let Some(p) = &app.playlists {
let display_items = app.get_playlist_display_items();
if !display_items.is_empty() {
let next_index =
common_key_events::on_up_press_handler(&p.items, app.selected_playlist_index);
common_key_events::on_up_press_handler(&display_items, app.selected_playlist_index);
app.selected_playlist_index = Some(next_index);
};
}
}
k if common_key_events::high_event(k) => {
if let Some(_p) = &app.playlists {
let display_items = app.get_playlist_display_items();
if !display_items.is_empty() {
let next_index = common_key_events::on_high_press_handler();
app.selected_playlist_index = Some(next_index);
};
}
}
k if common_key_events::middle_event(k) => {
if let Some(p) = &app.playlists {
let next_index = common_key_events::on_middle_press_handler(&p.items);
let display_items = app.get_playlist_display_items();
if !display_items.is_empty() {
let next_index = common_key_events::on_middle_press_handler(&display_items);
app.selected_playlist_index = Some(next_index);
};
}
}
k if common_key_events::low_event(k) => {
if let Some(p) = &app.playlists {
let next_index = common_key_events::on_low_press_handler(&p.items);
let display_items = app.get_playlist_display_items();
if !display_items.is_empty() {
let next_index = common_key_events::on_low_press_handler(&display_items);
app.selected_playlist_index = Some(next_index);
};
}
}
Key::Enter => {
if let (Some(playlists), Some(selected_playlist_index)) =
(&app.playlists, &app.selected_playlist_index)
{
app.active_playlist_index = Some(selected_playlist_index.to_owned());
app.track_table.context = Some(TrackTableContext::MyPlaylists);
app.playlist_offset = 0;
if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) {
let playlist_id = selected_playlist.id.clone().into_static();
app.dispatch(IoEvent::GetPlaylistItems(
playlist_id.clone(),
app.playlist_offset,
));
// Pre-fetch more pages in background for seamless playback
app.dispatch(IoEvent::PreFetchAllPlaylistTracks(playlist_id));
let display_items = app.get_playlist_display_items();
if let Some(selected_idx) = app.selected_playlist_index {
if let Some(item) = display_items.get(selected_idx) {
match item {
PlaylistFolderItem::Folder(folder) => {
// Navigate into/out of folder
app.current_playlist_folder_id = folder.target_id;
app.selected_playlist_index = Some(0);
}
PlaylistFolderItem::Playlist { index, .. } => {
// Open the playlist tracks
if let Some(playlist) = app.all_playlists.get(*index) {
app.active_playlist_index = Some(*index);
app.track_table.context = Some(TrackTableContext::MyPlaylists);
app.playlist_offset = 0;
let playlist_id = playlist.id.clone().into_static();
app.dispatch(IoEvent::GetPlaylistItems(
playlist_id.clone(),
app.playlist_offset,
));
// Pre-fetch more pages in background for seamless playback
app.dispatch(IoEvent::PreFetchAllPlaylistTracks(playlist_id));
}
}
}
}
};
}
}
Key::Char('D') => {
if let (Some(playlists), Some(selected_index)) = (&app.playlists, app.selected_playlist_index)
{
let selected_playlist = &playlists.items[selected_index].name;
app.dialog = Some(selected_playlist.clone());
app.confirm = false;
let display_items = app.get_playlist_display_items();
if let Some(selected_idx) = app.selected_playlist_index {
if let Some(PlaylistFolderItem::Playlist { index, .. }) = display_items.get(selected_idx) {
if let Some(playlist) = app.all_playlists.get(*index) {
let selected_playlist = &playlist.name;
app.dialog = Some(selected_playlist.clone());
app.confirm = false;

app.push_navigation_stack(
RouteId::Dialog,
ActiveBlock::Dialog(DialogContext::PlaylistWindow),
);
app.push_navigation_stack(
RouteId::Dialog,
ActiveBlock::Dialog(DialogContext::PlaylistWindow),
);
}
}
}
}
_ => {}
Expand Down
6 changes: 3 additions & 3 deletions src/handlers/track_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,9 +358,9 @@ fn on_enter(app: &mut App) {
// Get the track ID to play
let track_playable_id = track_playable_id(track.id.clone());

let context_id = match (&app.active_playlist_index, &app.playlists) {
(Some(active_playlist_index), Some(playlists)) => playlists
.items
let context_id = match &app.active_playlist_index {
Some(active_playlist_index) => app
.all_playlists
.get(active_playlist_index.to_owned())
.map(|selected_playlist| playlist_context_id_from_ref(&selected_playlist.id)),
_ => None,
Comment on lines +321 to 326
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selected_playlist_index now refers to an entry in the folder-aware display list, but much of this file still uses (&app.playlists, &app.selected_playlist_index) to resolve the active playlist for pagination and random playback. Once folders are enabled or playlists exceed the first page, those code paths can fetch/play tracks from the wrong playlist or fail to paginate. Consider resolving playlist IDs via active_playlist_index (set when opening) and all_playlists, and using that consistently for all TrackTableContext::MyPlaylists actions.

Copilot uses AI. Check for mistakes.
Expand Down
Loading