use web_sys::HtmlInputElement; use web_sys::HtmlSelectElement; use yew::prelude::*; use crate::api; use crate::types::*; #[function_component(PlaylistsPage)] pub fn playlists_page() -> Html { // Tab state: "saved" | "generate" | "edit" let active_tab = use_state(|| "saved".to_string()); let editing_playlist = use_state(|| None::); // All local artists (for seed picker) let all_artists = use_state(Vec::::new); { let all_artists = all_artists.clone(); use_effect_with((), move |_| { wasm_bindgen_futures::spawn_local(async move { if let Ok(artists) = api::list_artists(500, 0).await { all_artists.set(artists); } }); }); } // All tracks (for add-track search in edit tab) let all_tracks = use_state(Vec::::new); { let all_tracks = all_tracks.clone(); use_effect_with((), move |_| { wasm_bindgen_futures::spawn_local(async move { if let Ok(tracks) = api::search_tracks("").await { all_tracks.set(tracks); } }); }); } // Generate form state let seed_input = use_state(String::new); let seed_focused = use_state(|| false); let seeds = use_state(Vec::::new); let count = use_state(|| 50usize); let popularity_bias = use_state(|| 5u8); let ordering = use_state(|| "interleave".to_string()); // Results let generated = use_state(|| None::); let saved_playlists = use_state(|| None::>); let error = use_state(|| None::); let message = use_state(|| None::); let loading = use_state(|| false); let save_name = use_state(String::new); // Edit tab state let edit_name = use_state(String::new); let track_search_input = use_state(String::new); let track_search_focused = use_state(|| false); let dragging_index = use_state(|| None::); // Load saved playlists on mount let refresh_playlists = { let saved_playlists = saved_playlists.clone(); Callback::from(move |_: ()| { let saved_playlists = saved_playlists.clone(); wasm_bindgen_futures::spawn_local(async move { if let Ok(list) = api::list_playlists().await { saved_playlists.set(Some(list)); } }); }) }; { let refresh = refresh_playlists.clone(); use_effect_with((), move |_| { refresh.emit(()); }); } // Generate handler let on_generate = { let seeds = seeds.clone(); let count = count.clone(); let popularity_bias = popularity_bias.clone(); let ordering = ordering.clone(); let generated = generated.clone(); let error = error.clone(); let loading = loading.clone(); let message = message.clone(); Callback::from(move |_: MouseEvent| { let seeds = (*seeds).clone(); let count = *count; let popularity_bias = *popularity_bias; let ordering_val = (*ordering).clone(); let generated = generated.clone(); let error = error.clone(); let loading = loading.clone(); let message = message.clone(); loading.set(true); error.set(None); message.set(None); wasm_bindgen_futures::spawn_local(async move { let req = GenerateRequest { strategy: "similar".to_string(), seed_artists: seeds, genres: vec![], count, popularity_bias, ordering: ordering_val, rules: None, }; match api::generate_playlist(&req).await { Ok(result) => { let n = result.tracks.len(); message.set(Some(format!("Generated {n} tracks"))); generated.set(Some(result)); } Err(e) => error.set(Some(e.0)), } loading.set(false); }); }) }; // Save handler let on_save = { let generated = generated.clone(); let save_name = save_name.clone(); let error = error.clone(); let message = message.clone(); let refresh = refresh_playlists.clone(); let active_tab = active_tab.clone(); Callback::from(move |_: MouseEvent| { let generated = generated.clone(); let name = (*save_name).clone(); let error = error.clone(); let message = message.clone(); let refresh = refresh.clone(); let active_tab = active_tab.clone(); if name.is_empty() { error.set(Some("Enter a playlist name".to_string())); return; } wasm_bindgen_futures::spawn_local(async move { if let Some(ref gen) = *generated { let track_ids: Vec = gen.tracks.iter().map(|t| t.track_id).collect(); match api::save_playlist(&name, None, &track_ids).await { Ok(_) => { message.set(Some(format!("Saved playlist: {name}"))); refresh.emit(()); active_tab.set("saved".to_string()); } Err(e) => error.set(Some(e.0)), } } }); }) }; // --- Tab bar --- let tab_bar = { let active = (*active_tab).clone(); let tab = active_tab.clone(); let editing = editing_playlist.clone(); html! {
if let Some(ref detail) = *editing { }
} }; // --- Tab 1: Saved Playlists --- let saved_tab = { let active_tab = active_tab.clone(); let editing_playlist = editing_playlist.clone(); let edit_name = edit_name.clone(); let saved_playlists = saved_playlists.clone(); let refresh_playlists = refresh_playlists.clone(); let saved_list = if let Some(ref playlists) = *saved_playlists { if playlists.is_empty() { html! {

{ "No saved playlists." }

} } else { html! { { for playlists.iter().map(|p| { let id = p.id; let refresh = refresh_playlists.clone(); let editing = editing_playlist.clone(); let tab = active_tab.clone(); let en = edit_name.clone(); html! { } })}
{ "Name" } { "Tracks" } { "Created" }
{ &p.name } { p.track_count } { &p.created_at }
{ "M3U" }
} } } else { html! {

{ "Loading..." }

} }; html! {
{ saved_list }
} }; // --- Tab 2: Generate --- let generate_tab = { let strategy_inputs = { let seed_input_c = seed_input.clone(); let seeds_c = seeds.clone(); let query = (*seed_input_c).to_lowercase(); let filtered: Vec<_> = (*all_artists) .iter() .filter(|a| { if query.is_empty() { return true; } let name_lower = a.name.to_lowercase(); let mut chars = query.chars(); let mut current = chars.next(); for c in name_lower.chars() { if current == Some(c) { current = chars.next(); } } current.is_none() }) .filter(|a| !seeds_c.contains(&a.name)) .take(15) .cloned() .collect(); html! {
if *seed_focused && !filtered.is_empty() {
{ for filtered.iter().map(|a| { let name = a.name.clone(); let seeds = seeds.clone(); let si = seed_input.clone(); let track_count = a.total_items; html! {
{ &a.name } { format!("{} tracks", track_count) }
} })}
}
{ for seeds_c.iter().enumerate().map(|(i, s)| { let seeds = seeds_c.clone(); html! { {s}{ " \u{00d7}" } } })}
} }; let track_list = if let Some(ref gen) = *generated { html! {

{ format!("Generated Tracks ({})", gen.tracks.len()) }

{ for gen.tracks.iter().enumerate().map(|(i, t)| html! { })}
{ "#" } { "Title" } { "Artist" } { "Album" } { "Score" }
{ i + 1 } { t.title.as_deref().unwrap_or("Unknown") } { t.artist.as_deref().unwrap_or("Unknown") } { t.album.as_deref().unwrap_or("") } { format!("{:.3}", t.score) }
} } else { html! {} }; html! {

{ "Generate Playlist" }

{ "Build a playlist from similar artists in your library using Last.fm data." }

{ strategy_inputs }
{ track_list }
} }; // --- Tab 3: Edit --- let edit_tab = { let editing = editing_playlist.clone(); let active_tab_c = active_tab.clone(); let edit_name_c = edit_name.clone(); let refresh = refresh_playlists.clone(); let error_c = error.clone(); let message_c = message.clone(); let track_search_input_c = track_search_input.clone(); let track_search_focused_c = track_search_focused.clone(); let all_tracks_c = all_tracks.clone(); let dragging_index_c = dragging_index.clone(); if let Some(ref detail) = *editing { let playlist_id = detail.playlist.id; let tracks = detail.tracks.clone(); // Filter tracks for add-track search let search_query = (*track_search_input_c).to_lowercase(); let existing_ids: Vec = tracks.iter().map(|t| t.id).collect(); let filtered_tracks: Vec<_> = if search_query.is_empty() { vec![] } else { (*all_tracks_c) .iter() .filter(|t| { if existing_ids.contains(&t.id) { return false; } let title_lower = t.title.as_deref().unwrap_or("").to_lowercase(); let artist_lower = t.artist.as_deref().unwrap_or("").to_lowercase(); // Subsequence match on title or artist let matches_field = |field: &str| { let mut chars = search_query.chars(); let mut current = chars.next(); for c in field.chars() { if current == Some(c) { current = chars.next(); } } current.is_none() }; matches_field(&title_lower) || matches_field(&artist_lower) }) .take(15) .cloned() .collect() }; html! {

{ format!("Tracks ({})", tracks.len()) }

{ for tracks.iter().enumerate().map(|(i, t)| { let track_id = t.id; let editing = editing.clone(); let dragging = dragging_index_c.clone(); let idx = i; let ondragstart = { let dragging = dragging.clone(); Callback::from(move |e: DragEvent| { dragging.set(Some(idx)); if let Some(dt) = e.data_transfer() { let _ = dt.set_data("text/plain", &idx.to_string()); } }) }; let ondragover = Callback::from(move |e: DragEvent| { e.prevent_default(); }); let ondrop = { let editing = editing.clone(); let dragging = dragging.clone(); Callback::from(move |e: DragEvent| { e.prevent_default(); let target_idx = idx; if let Some(source_idx) = *dragging { if source_idx != target_idx { let editing_inner = editing.clone(); let dragging_inner = dragging.clone(); // Reorder locally if let Some(ref detail) = *editing { let mut new_tracks = detail.tracks.clone(); let item = new_tracks.remove(source_idx); new_tracks.insert(target_idx, item); let track_ids: Vec = new_tracks.iter().map(|t| t.id).collect(); let new_detail = PlaylistDetail { playlist: detail.playlist.clone(), tracks: new_tracks, }; editing_inner.set(Some(new_detail)); dragging_inner.set(None); // Call reorder API wasm_bindgen_futures::spawn_local(async move { let _ = api::reorder_playlist_tracks(playlist_id, &track_ids).await; }); } } } dragging.set(None); }) }; let on_remove = { let editing = editing.clone(); Callback::from(move |_: MouseEvent| { let editing = editing.clone(); wasm_bindgen_futures::spawn_local(async move { let _ = api::remove_track_from_playlist(playlist_id, track_id).await; // Refresh the playlist if let Ok(detail) = api::get_playlist(playlist_id).await { editing.set(Some(detail)); } }); }) }; html! { } })}
{ "#" } { "Title" } { "Artist" } { "Album" }
{ "\u{2261}" } { i + 1 } { t.title.as_deref().unwrap_or("Unknown") } { t.artist.as_deref().unwrap_or("Unknown") } { t.album.as_deref().unwrap_or("") }
// Add track section

{ "Add Track" }

if *track_search_focused_c && !filtered_tracks.is_empty() {
{ for filtered_tracks.iter().map(|t| { let tid = t.id; let title = t.title.clone().unwrap_or_else(|| "Unknown".to_string()); let artist = t.artist.clone().unwrap_or_else(|| "Unknown".to_string()); let editing = editing.clone(); let tsi = track_search_input_c.clone(); html! {
{ format!("{} - {}", title, artist) }
} })}
}
} } else { html! {

{ "No playlist selected for editing." }

} } }; html! {
if let Some(ref msg) = *message {
{ msg }
} if let Some(ref err) = *error {
{ err }
} { tab_bar } if *active_tab == "saved" { { saved_tab } } if *active_tab == "generate" { { generate_tab } } if *active_tab == "edit" { { edit_tab } }
} }