pinakes-ui: add playlists; expand detail/settings/player components

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ifb9c9da6fec0a9152b54ccf48705088e6a6a6964
This commit is contained in:
raf 2026-03-21 02:23:30 +03:00
commit 0feb51d7b4
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
5 changed files with 1510 additions and 7 deletions

View file

@ -0,0 +1,411 @@
use dioxus::prelude::*;
use super::utils::{format_size, type_badge_class};
use crate::client::{ApiClient, MediaResponse, PlaylistResponse};
#[component]
pub fn Playlists(client: Signal<ApiClient>) -> Element {
let mut playlists: Signal<Vec<PlaylistResponse>> = use_signal(Vec::new);
let mut items: Signal<Vec<MediaResponse>> = use_signal(Vec::new);
let mut selected_id: Signal<Option<String>> = use_signal(|| None);
let mut loading = use_signal(|| false);
let mut error: Signal<Option<String>> = use_signal(|| None);
let mut new_name = use_signal(String::new);
let mut new_desc = use_signal(String::new);
let mut confirm_delete: Signal<Option<String>> = use_signal(|| None);
let mut renaming_id: Signal<Option<String>> = use_signal(|| None);
let mut rename_input = use_signal(String::new);
let mut add_media_id = use_signal(String::new);
// Load playlists on mount
use_effect(move || {
spawn(async move {
loading.set(true);
match client.read().list_playlists().await {
Ok(list) => playlists.set(list),
Err(e) => error.set(Some(format!("Failed to load playlists: {e}"))),
}
loading.set(false);
});
});
let load_items = move |pid: String| {
spawn(async move {
match client.read().get_playlist_items(&pid).await {
Ok(list) => items.set(list),
Err(e) => error.set(Some(format!("Failed to load items: {e}"))),
}
});
};
let on_create = move |_| {
let name = new_name.read().clone();
if name.is_empty() {
return;
}
let desc = {
let d = new_desc.read().clone();
if d.is_empty() { None } else { Some(d) }
};
spawn(async move {
match client.read().create_playlist(&name, desc.as_deref()).await {
Ok(pl) => {
playlists.write().push(pl);
new_name.set(String::new());
new_desc.set(String::new());
},
Err(e) => error.set(Some(format!("Failed to create playlist: {e}"))),
}
});
};
let on_shuffle = move |pid: String| {
spawn(async move {
match client.read().shuffle_playlist(&pid).await {
Ok(_) => {
// Reload items if this is the selected playlist
if selected_id.read().as_deref() == Some(&pid) {
match client.read().get_playlist_items(&pid).await {
Ok(list) => items.set(list),
Err(e) => error.set(Some(format!("Failed to reload: {e}"))),
}
}
},
Err(e) => error.set(Some(format!("Shuffle failed: {e}"))),
}
});
};
// Detail view: show items for selected playlist
if let Some(ref pid) = selected_id.read().clone() {
let pl_name = playlists
.read()
.iter()
.find(|p| &p.id == pid)
.map(|p| p.name.clone())
.unwrap_or_else(|| pid.clone());
let pid_for_shuffle = pid.clone();
let pid_for_back = pid.clone();
return rsx! {
div { class: "form-row mb-16",
button {
class: "btn btn-ghost",
onclick: move |_| {
let _ = &pid_for_back;
selected_id.set(None);
items.set(Vec::new());
},
"\u{2190} Back to Playlists"
}
}
h3 { class: "mb-16", "{pl_name}" }
if let Some(ref err) = *error.read() {
div { class: "error-banner", "{err}" }
}
div { class: "form-row mb-16",
button {
class: "btn btn-secondary btn-sm",
onclick: move |_| on_shuffle(pid_for_shuffle.clone()),
"\u{1f500} Shuffle"
}
}
div { class: "form-row mb-16",
input {
r#type: "text",
placeholder: "Media ID to add...",
value: "{add_media_id}",
oninput: move |e| add_media_id.set(e.value()),
}
button {
class: "btn btn-primary btn-sm",
onclick: {
let pid = pid.clone();
move |_| {
let mid = add_media_id.read().clone();
if mid.is_empty() {
return;
}
let pid = pid.clone();
spawn(async move {
match client.read().add_to_playlist(&pid, &mid).await {
Ok(_) => {
add_media_id.set(String::new());
match client.read().get_playlist_items(&pid).await {
Ok(list) => items.set(list),
Err(e) => error.set(Some(format!("Reload failed: {e}"))),
}
}
Err(e) => error.set(Some(format!("Add failed: {e}"))),
}
});
}
},
"Add Media"
}
}
if *loading.read() {
div { class: "loading-overlay",
div { class: "spinner" }
"Loading..."
}
} else if items.read().is_empty() {
div { class: "empty-state",
p { class: "empty-subtitle", "No items in this playlist." }
}
} else {
table { class: "data-table",
thead {
tr {
th { "Name" }
th { "Type" }
th { "Artist" }
th { "Size" }
th { "" }
}
}
tbody {
for item in items.read().clone() {
{
let pid_rm = pid.clone();
let mid = item.id.clone();
let artist = item.artist.clone().unwrap_or_default();
let size = format_size(item.file_size);
let badge_class = type_badge_class(&item.media_type);
rsx! {
tr { key: "{mid}",
td { "{item.file_name}" }
td {
span { class: "type-badge {badge_class}", "{item.media_type}" }
}
td { "{artist}" }
td { "{size}" }
td {
button {
class: "btn btn-danger btn-sm",
onclick: move |_| {
let pid_rm = pid_rm.clone();
let mid = mid.clone();
spawn(async move {
match client.read().remove_from_playlist(&pid_rm, &mid).await {
Ok(_) => {
match client.read().get_playlist_items(&pid_rm).await {
Ok(list) => items.set(list),
Err(e) => error.set(Some(format!("Reload failed: {e}"))),
}
}
Err(e) => error.set(Some(format!("Remove failed: {e}"))),
}
});
},
"Remove"
}
}
}
}
}
}
}
}
}
};
}
// List view
rsx! {
div { class: "card",
div { class: "card-header",
h3 { class: "card-title", "Playlists" }
}
if let Some(ref err) = *error.read() {
div { class: "error-banner mb-16", "{err}" }
}
div { class: "form-row mb-16",
input {
r#type: "text",
placeholder: "Playlist name...",
value: "{new_name}",
oninput: move |e| new_name.set(e.value()),
}
input {
r#type: "text",
placeholder: "Description (optional)...",
value: "{new_desc}",
oninput: move |e| new_desc.set(e.value()),
}
button { class: "btn btn-primary", onclick: on_create, "Create" }
}
if *loading.read() {
div { class: "loading-overlay",
div { class: "spinner" }
"Loading..."
}
} else if playlists.read().is_empty() {
div { class: "empty-state",
p { class: "empty-subtitle", "No playlists yet. Create one above." }
}
} else {
table { class: "data-table",
thead {
tr {
th { "Name" }
th { "Description" }
th { "Items" }
th { "" }
th { "" }
th { "" }
}
}
tbody {
for pl in playlists.read().clone() {
{
let pl_id = pl.id.clone();
let pl_id_open = pl.id.clone();
let pl_id_rename = pl.id.clone();
let desc = pl.description.clone().unwrap_or_default();
let count = pl.item_count.map(|c| c.to_string()).unwrap_or_default();
let is_confirming = confirm_delete
.read()
.as_ref()
.map(|id| id == &pl.id)
.unwrap_or(false);
let is_renaming = renaming_id
.read()
.as_ref()
.map(|id| id == &pl.id)
.unwrap_or(false);
rsx! {
tr { key: "{pl_id}",
td {
if is_renaming {
div { class: "form-row",
input {
r#type: "text",
value: "{rename_input}",
oninput: move |e| rename_input.set(e.value()),
}
button {
class: "btn btn-primary btn-sm",
onclick: {
let rid = pl_id_rename.clone();
move |_| {
let rid = rid.clone();
let new_name_val = rename_input.read().clone();
if new_name_val.is_empty() {
return;
}
spawn(async move {
match client.read().update_playlist(&rid, &new_name_val).await {
Ok(updated) => {
let mut list = playlists.write();
if let Some(p) = list.iter_mut().find(|p| p.id == rid) {
*p = updated;
}
renaming_id.set(None);
}
Err(e) => error.set(Some(format!("Rename failed: {e}"))),
}
});
}
},
"Save"
}
button {
class: "btn btn-ghost btn-sm",
onclick: move |_| renaming_id.set(None),
"Cancel"
}
}
} else {
"{pl.name}"
}
}
td { "{desc}" }
td { "{count}" }
td {
button {
class: "btn btn-sm btn-secondary",
onclick: move |_| {
let pid = pl_id_open.clone();
selected_id.set(Some(pid.clone()));
load_items(pid);
},
"Open"
}
}
td {
if !is_renaming {
button {
class: "btn btn-sm btn-ghost",
onclick: {
let rid = pl_id.clone();
let rname = pl.name.clone();
move |_| {
rename_input.set(rname.clone());
renaming_id.set(Some(rid.clone()));
}
},
"Rename"
}
}
}
td {
if is_confirming {
button {
class: "btn btn-danger btn-sm",
onclick: {
let id = pl_id.clone();
move |_| {
let id = id.clone();
spawn(async move {
match client.read().delete_playlist(&id).await {
Ok(_) => {
playlists.write().retain(|p| p.id != id);
confirm_delete.set(None);
}
Err(e) => {
error.set(Some(format!("Delete failed: {e}")));
confirm_delete.set(None);
}
}
});
}
},
"Confirm"
}
button {
class: "btn btn-ghost btn-sm",
onclick: move |_| confirm_delete.set(None),
"Cancel"
}
} else {
button {
class: "btn btn-danger btn-sm",
onclick: {
let id = pl_id.clone();
move |_| confirm_delete.set(Some(id.clone()))
},
"Delete"
}
}
}
}
}
}
}
}
}
}
}
}
}