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
43 changes: 43 additions & 0 deletions src/core/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ pub enum ActiveBlock {
SortMenu,
Queue,
Party,
CreatePlaylistForm,
}

#[derive(Clone, PartialEq, Debug)]
Expand Down Expand Up @@ -271,6 +272,7 @@ pub enum RouteId {
HelpMenu,
Queue,
Party,
CreatePlaylist,
}

#[derive(Clone, Copy, PartialEq, Debug)]
Expand Down Expand Up @@ -509,6 +511,23 @@ pub enum PlaylistFolderItem {
},
}

/// Which stage of the "Create Playlist" form we are on
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub enum CreatePlaylistStage {
#[default]
Name,
AddTracks,
}

/// Which panel inside the AddTracks stage has focus
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub enum CreatePlaylistFocus {
#[default]
SearchInput,
SearchResults,
AddedTracks,
}

/// Settings screen category tabs
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub enum SettingsCategory {
Expand Down Expand Up @@ -785,6 +804,19 @@ pub struct App {
/// Reference to MPRIS manager for emitting Seeked signals after native seeks
#[cfg(all(feature = "mpris", target_os = "linux"))]
pub mpris_manager: Option<Arc<crate::mpris::MprisManager>>,

// Create Playlist form state
pub create_playlist_name: Vec<char>,
pub create_playlist_name_idx: usize,
pub create_playlist_name_cursor: u16,
pub create_playlist_stage: CreatePlaylistStage,
pub create_playlist_tracks: Vec<FullTrack>,
pub create_playlist_search_results: Vec<FullTrack>,
pub create_playlist_search_input: Vec<char>,
pub create_playlist_search_idx: usize,
pub create_playlist_search_cursor: u16,
pub create_playlist_selected_result: usize,
pub create_playlist_focus: CreatePlaylistFocus,
}

#[derive(Clone, Copy, PartialEq, Debug)]
Expand Down Expand Up @@ -951,6 +983,17 @@ impl Default for App {
mpris_manager: None,
#[cfg(feature = "cover-art")]
cover_art: crate::tui::cover_art::CoverArt::new(),
create_playlist_name: Vec::new(),
create_playlist_name_idx: 0,
create_playlist_name_cursor: 0,
create_playlist_stage: CreatePlaylistStage::Name,
create_playlist_tracks: Vec::new(),
create_playlist_search_results: Vec::new(),
create_playlist_search_input: Vec::new(),
create_playlist_search_idx: 0,
create_playlist_search_cursor: 0,
create_playlist_selected_result: 0,
create_playlist_focus: CreatePlaylistFocus::SearchInput,
}
}
}
Expand Down
133 changes: 112 additions & 21 deletions src/infra/network/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use reqwest::Method;
use rspotify::model::{
idtypes::{AlbumId, LibraryId, PlaylistId, ShowId, TrackId, UserId},
page::Page,
playlist::PlaylistItem,
playlist::{PlaylistItem, SimplifiedPlaylist},
track::SavedTrack,
PlayableItem,
};
Expand Down Expand Up @@ -169,6 +169,7 @@ pub trait LibraryNetwork {
async fn toggle_save_track(&mut self, track_id: rspotify::model::idtypes::PlayableId<'static>);
async fn current_user_saved_tracks_contains(&mut self, ids: Vec<TrackId<'static>>);
async fn fetch_all_playlist_tracks_and_sort(&mut self, playlist_id: PlaylistId<'static>);
async fn create_new_playlist(&mut self, name: String, track_ids: Vec<TrackId<'static>>);
}

// Private helper methods
Expand Down Expand Up @@ -275,32 +276,37 @@ impl LibraryNetwork for Network {
let mut first_page = None;

loop {
match self
.spotify
.current_user_playlists_manual(Some(limit), Some(offset))
.await
// Always use the compat path: parses raw JSON to serde_json::Value first,
// which silently deduplicates keys (last-wins). This handles the known Spotify
// API bug where "items" appears twice in the same JSON object.
let page = match super::requests::spotify_get_typed_compat_for::<Page<SimplifiedPlaylist>>(
&self.spotify,
"me/playlists",
&[("limit", limit.to_string()), ("offset", offset.to_string())],
)
.await
{
Ok(page) => {
if offset == 0 {
first_page = Some(page.clone());
}

if page.items.is_empty() {
break;
}

all_playlists.extend(page.items);

if page.next.is_none() {
break;
}
offset += limit;
}
Ok(page) => page,
Err(e) => {
self.handle_error(anyhow!(e)).await;
return;
}
};

if offset == 0 {
first_page = Some(page.clone());
}

if page.items.is_empty() {
break;
}

all_playlists.extend(page.items);

if page.next.is_none() {
break;
}
offset += limit;
}

#[cfg(feature = "streaming")]
Expand Down Expand Up @@ -766,6 +772,91 @@ impl LibraryNetwork for Network {
sorter.sort_tracks(&mut all_tracks);
let _ = app.apply_sorted_playlist_tracks_if_current(&playlist_id, all_tracks);
}

async fn create_new_playlist(&mut self, name: String, track_ids: Vec<TrackId<'static>>) {
let user_id = {
let app = self.app.lock().await;
app.user.as_ref().map(|u| u.id.clone())
};

let user_id = match user_id {
Some(id) => id,
None => {
self
.show_status_message("Cannot create playlist: not logged in".to_string(), 4)
.await;
return;
}
};

// Use raw API call to avoid rspotify deserializing FullPlaylist, which crashes when
// Spotify returns a duplicate "items" key in the response (known API migration bug).
let user_id_str = user_id.id().to_string();
let create_path = format!("users/{}/playlists", user_id_str);
let create_body = json!({
"name": name,
"public": false,
"collaborative": false,
"description": "Created with spotatui"
});
let playlist_value = match spotify_api_request_json_for(
&self.spotify,
Method::POST,
&create_path,
&[],
Some(create_body),
)
.await
{
Ok(v) => v,
Err(e) => {
self.handle_error(e).await;
return;
}
};

let playlist_id_str = match playlist_value.get("id").and_then(|v| v.as_str()) {
Some(id) => id.to_string(),
None => {
self
.show_status_message("Playlist created but could not get its ID".to_string(), 4)
.await;
return;
}
};

let playlist_id = match PlaylistId::from_id(playlist_id_str) {
Ok(id) => id.into_static(),
Err(e) => {
self.handle_error(anyhow!(e)).await;
return;
}
};

if !track_ids.is_empty() {
let items: Vec<rspotify::model::idtypes::PlayableId> = track_ids
.iter()
.map(|id| rspotify::model::idtypes::PlayableId::Track(id.clone()))
.collect();
if let Err(e) = self
.spotify
.playlist_add_items(playlist_id, items, None)
.await
{
self.handle_error(anyhow!(e)).await;
return;
}
}

// Refresh playlists
{
let mut app = self.app.lock().await;
app.dispatch(IoEvent::GetPlaylists);
}

let status = format!("Playlist \"{}\" created!", name);
self.show_status_message(status, 4).await;
}
}

#[cfg(test)]
Expand Down
10 changes: 10 additions & 0 deletions src/infra/network/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ pub enum IoEvent {
/// Send a playback command to the party host (guest only, Phase 2)
#[allow(dead_code)]
PartyPlaybackCommand(sync::PlaybackAction),
/// Search tracks to add to a new playlist
SearchTracksForPlaylist(String),
/// Create a new playlist with the given name and track IDs
CreateNewPlaylist(String, Vec<TrackId<'static>>),
}

pub struct Network {
Expand Down Expand Up @@ -413,6 +417,12 @@ impl Network {
IoEvent::PartyPlaybackCommand(action) => {
self.party_playback_command(action).await;
}
IoEvent::SearchTracksForPlaylist(query) => {
self.search_tracks_for_playlist(query).await;
}
IoEvent::CreateNewPlaylist(name, track_ids) => {
self.create_new_playlist(name, track_ids).await;
}
};

{
Expand Down
8 changes: 8 additions & 0 deletions src/infra/network/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,14 @@ impl PlaybackNetwork for Network {
return;
}

// 404 = no active device/player; treat as idle, not an error
if err.to_string().contains("404") || err.to_string().contains("Not Found") {
app.current_playback_context = None;
app.instant_since_last_current_playback_poll = Instant::now();
app.is_fetching_current_playback = false;
return;
}

app.handle_error(err);
return;
}
Expand Down
32 changes: 32 additions & 0 deletions src/infra/network/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub struct ArtistSearchResponse {

pub trait SearchNetwork {
async fn get_search_results(&mut self, search_term: String, country: Option<Country>);
async fn search_tracks_for_playlist(&mut self, search_term: String);
}

impl SearchNetwork for Network {
Expand Down Expand Up @@ -151,4 +152,35 @@ impl SearchNetwork for Network {
app.search_results.playlists = playlist_result;
app.search_results.shows = show_result;
}

async fn search_tracks_for_playlist(&mut self, search_term: String) {
let result = self
.spotify
.search(
&search_term,
SearchType::Track,
None,
None,
Some(self.large_search_limit),
Some(0),
)
.await;

let tracks = match result {
Ok(SearchResult::Tracks(page)) => page
.items
.into_iter()
.filter_map(|t| if t.id.is_some() { Some(t) } else { None })
.collect::<Vec<_>>(),
Ok(_) => return,
Err(e) => {
self.handle_error(anyhow!(e)).await;
return;
}
};

let mut app = self.app.lock().await;
app.create_playlist_search_results = tracks;
app.create_playlist_selected_result = 0;
}
}
8 changes: 8 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2544,6 +2544,10 @@ async fn start_ui(
ActiveBlock::Settings => {
ui::settings::draw_settings(f, &app);
}
ActiveBlock::CreatePlaylistForm => {
ui::draw_main_layout(f, &app);
ui::draw_create_playlist_form(f, &app);
}
_ => {
ui::draw_main_layout(f, &app);
}
Expand Down Expand Up @@ -2853,6 +2857,10 @@ async fn start_ui(
ActiveBlock::AnnouncementPrompt => ui::draw_announcement_prompt(f, &app),
ActiveBlock::ExitPrompt => ui::draw_exit_prompt(f, &app),
ActiveBlock::Settings => ui::settings::draw_settings(f, &app),
ActiveBlock::CreatePlaylistForm => {
ui::draw_main_layout(f, &app);
ui::draw_create_playlist_form(f, &app);
}
_ => ui::draw_main_layout(f, &app),
}
})?;
Expand Down
1 change: 1 addition & 0 deletions src/tui/handlers/common_key_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ pub fn handle_right_event(app: &mut App) {
RouteId::HelpMenu => {}
RouteId::Queue => {}
RouteId::Party => {}
RouteId::CreatePlaylist => {}
},
_ => {}
};
Expand Down
Loading
Loading