Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
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
144 changes: 134 additions & 10 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,45 @@ pub struct NativeTrackInfo {
pub duration_ms: u32,
}

/// A node in the playlist folder hierarchy from Spotify's rootlist
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[allow(dead_code)]
pub enum PlaylistFolderNodeType {
Folder,
Playlist,
}

/// A node in the playlist folder hierarchy from Spotify's rootlist
#[derive(Clone, Debug)]
pub struct PlaylistFolderNode {
pub name: Option<String>,
pub node_type: PlaylistFolderNodeType,
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 @@ -508,13 +547,16 @@ pub struct App {
/// Whether native streaming is active (disables API-based progress calculation)
pub is_streaming_active: bool,
/// Device id for the native streaming device when known
#[allow(dead_code)]
pub native_device_id: Option<String>,
/// Native playback state - updated by player events, used when streaming is active
/// This is more reliable than current_playback_context.is_playing during native streaming
pub native_is_playing: Option<bool>,
/// Timestamp of the last native device activation
#[allow(dead_code)]
pub last_device_activation: Option<Instant>,
/// Whether a native device activation is still in progress
#[allow(dead_code)]
pub native_activation_pending: bool,
/// Selected index in the Discover view
pub discover_selected_index: usize,
Expand Down Expand Up @@ -545,6 +587,16 @@ 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,
/// Incremented every time playlists are refreshed to guard stale background tasks
pub playlist_refresh_generation: u64,
/// 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 +734,11 @@ 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,
playlist_refresh_generation: 0,
#[cfg(feature = "streaming")]
streaming_player: None,
#[cfg(all(feature = "mpris", target_os = "linux"))]
Expand Down Expand Up @@ -722,6 +779,70 @@ impl App {
self.io_tx = None;
}

pub fn is_playlist_item_visible_in_current_folder(&self, item: &PlaylistFolderItem) -> bool {
match item {
PlaylistFolderItem::Folder(f) => f.current_id == self.current_playlist_folder_id,
PlaylistFolderItem::Playlist { current_id, .. } => {
*current_id == self.current_playlist_folder_id
}
}
}

/// Get the number of items visible in the current folder level.
pub fn get_playlist_display_count(&self) -> usize {
self
.playlist_folder_items
.iter()
.filter(|item| self.is_playlist_item_visible_in_current_folder(item))
.count()
}

/// Get a visible item by display index in the current folder.
pub fn get_playlist_display_item_at(&self, display_index: usize) -> Option<&PlaylistFolderItem> {
self
.playlist_folder_items
.iter()
.filter(|item| self.is_playlist_item_visible_in_current_folder(item))
.nth(display_index)
}

/// Get visible playlist items in the current folder (used by UI rendering).
pub fn get_playlist_display_items(&self) -> Vec<&PlaylistFolderItem> {
self
.playlist_folder_items
.iter()
.filter(|item| self.is_playlist_item_visible_in_current_folder(item))
.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,
}
}

/// Get the currently selected playlist id in the visible playlist list.
pub fn get_selected_playlist_id(&self) -> Option<String> {
let selected_index = self.selected_playlist_index?;
if let Some(PlaylistFolderItem::Playlist { index, .. }) =
self.get_playlist_display_item_at(selected_index)
{
return self
.all_playlists
.get(*index)
.map(|p| p.id.id().to_string());
}

self
.playlists
.as_ref()
.and_then(|playlists| playlists.items.get(selected_index))
.map(|playlist| playlist.id.id().to_string())
}

fn apply_seek(&mut self, seek_ms: u32) {
if let Some(CurrentPlaybackContext {
item: Some(item), ..
Expand Down Expand Up @@ -1724,16 +1845,19 @@ 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) {
if let Some(PlaylistFolderItem::Playlist { index, .. }) =
self.get_playlist_display_item_at(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
3 changes: 3 additions & 0 deletions src/audio/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ pub use analyzer::SpectrumData;
feature = "audio-viz-cpal"
)))]
#[derive(Clone, Default)]
#[allow(dead_code)]
pub struct SpectrumData {
pub bands: [f32; 12],
pub peak: f32,
Expand All @@ -46,12 +47,14 @@ pub struct SpectrumData {
all(feature = "audio-viz", target_os = "linux"),
feature = "audio-viz-cpal"
)))]
#[allow(dead_code)]
pub struct AudioCaptureManager;

#[cfg(not(any(
all(feature = "audio-viz", target_os = "linux"),
feature = "audio-viz-cpal"
)))]
#[allow(dead_code)]
impl AudioCaptureManager {
pub fn new() -> Option<Self> {
None
Expand Down
113 changes: 65 additions & 48 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,85 @@ 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 {
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));
app.selected_playlist_index = Some(next_index);
}
};
let count = app.get_playlist_display_count();
if count > 0 {
let current = app.selected_playlist_index.unwrap_or(0);
app.selected_playlist_index = Some((current + 1) % count);
}
}
k if common_key_events::up_event(k) => {
if let Some(p) = &app.playlists {
let next_index =
common_key_events::on_up_press_handler(&p.items, app.selected_playlist_index);
app.selected_playlist_index = Some(next_index);
};
let count = app.get_playlist_display_count();
if count > 0 {
let current = app.selected_playlist_index.unwrap_or(0);
app.selected_playlist_index = Some(if current == 0 { count - 1 } else { current - 1 });
}
}
k if common_key_events::high_event(k) => {
if let Some(_p) = &app.playlists {
let next_index = common_key_events::on_high_press_handler();
app.selected_playlist_index = Some(next_index);
};
if app.get_playlist_display_count() > 0 {
app.selected_playlist_index = Some(0);
}
}
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 count = app.get_playlist_display_count();
if count > 0 {
let next_index = if count.is_multiple_of(2) {
count.saturating_sub(1) / 2
} else {
count / 2
};
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);
app.selected_playlist_index = Some(next_index);
};
let count = app.get_playlist_display_count();
if count > 0 {
app.selected_playlist_index = Some(count - 1);
}
}
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));
if let Some(selected_idx) = app.selected_playlist_index {
if let Some(item) = app.get_playlist_display_item_at(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;
if let Some(selected_idx) = app.selected_playlist_index {
if let Some(PlaylistFolderItem::Playlist { index, .. }) =
app.get_playlist_display_item_at(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
Loading
Loading