pinakes-ui: add playlists; expand detail/settings/player components
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ifb9c9da6fec0a9152b54ccf48705088e6a6a6964
This commit is contained in:
parent
bb69f2fa37
commit
0feb51d7b4
5 changed files with 1510 additions and 7 deletions
|
|
@ -11,10 +11,14 @@ use super::{
|
||||||
use crate::client::{
|
use crate::client::{
|
||||||
ApiClient,
|
ApiClient,
|
||||||
BookMetadataResponse,
|
BookMetadataResponse,
|
||||||
|
CommentResponse,
|
||||||
MediaResponse,
|
MediaResponse,
|
||||||
MediaUpdateEvent,
|
MediaUpdateEvent,
|
||||||
|
RatingResponse,
|
||||||
ReadingProgressResponse,
|
ReadingProgressResponse,
|
||||||
|
SubtitleListResponse,
|
||||||
TagResponse,
|
TagResponse,
|
||||||
|
TranscodeSessionResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
|
|
@ -48,6 +52,8 @@ pub fn Detail(
|
||||||
#[props(default)] on_update_reading_progress: Option<
|
#[props(default)] on_update_reading_progress: Option<
|
||||||
EventHandler<(String, i32)>,
|
EventHandler<(String, i32)>,
|
||||||
>,
|
>,
|
||||||
|
#[props(default)] subtitle_data: Option<SubtitleListResponse>,
|
||||||
|
#[props(default)] transcode_sessions: Option<Vec<TranscodeSessionResponse>>,
|
||||||
) -> Element {
|
) -> Element {
|
||||||
let mut editing = use_signal(|| false);
|
let mut editing = use_signal(|| false);
|
||||||
let mut show_image_viewer = use_signal(|| false);
|
let mut show_image_viewer = use_signal(|| false);
|
||||||
|
|
@ -66,6 +72,36 @@ pub fn Detail(
|
||||||
|
|
||||||
let mut confirm_delete = use_signal(|| false);
|
let mut confirm_delete = use_signal(|| false);
|
||||||
|
|
||||||
|
// Enrichment state
|
||||||
|
let mut enriching = use_signal(|| false);
|
||||||
|
let mut enrich_done = use_signal(|| false);
|
||||||
|
let mut enrich_error: Signal<Option<String>> = use_signal(|| None);
|
||||||
|
let mut show_ext_meta = use_signal(|| false);
|
||||||
|
let mut ext_meta: Signal<Option<serde_json::Value>> = use_signal(|| None);
|
||||||
|
|
||||||
|
// Subtitle state
|
||||||
|
let mut subtitle_list: Signal<Option<SubtitleListResponse>> =
|
||||||
|
use_signal(|| subtitle_data.clone());
|
||||||
|
let mut subtitle_error: Signal<Option<String>> = use_signal(|| None);
|
||||||
|
|
||||||
|
// Transcode state
|
||||||
|
let mut transcode_list: Signal<Vec<TranscodeSessionResponse>> =
|
||||||
|
use_signal(|| transcode_sessions.clone().unwrap_or_default());
|
||||||
|
let mut transcode_profile = use_signal(String::new);
|
||||||
|
// Counter tracks how many start-transcode API calls are in-flight so that
|
||||||
|
// each profile's button state is independent of others.
|
||||||
|
let mut transcode_running = use_signal(|| 0u32);
|
||||||
|
let mut transcode_error: Signal<Option<String>> = use_signal(|| None);
|
||||||
|
|
||||||
|
// Social state
|
||||||
|
let mut ratings: Signal<Vec<RatingResponse>> = use_signal(Vec::new);
|
||||||
|
let mut comments: Signal<Vec<CommentResponse>> = use_signal(Vec::new);
|
||||||
|
let mut is_favorite = use_signal(|| false);
|
||||||
|
let mut social_loaded = use_signal(|| false);
|
||||||
|
let mut new_comment_text = use_signal(String::new);
|
||||||
|
let mut selected_stars = use_signal(|| 0u8);
|
||||||
|
let mut social_error: Signal<Option<String>> = use_signal(|| None);
|
||||||
|
|
||||||
let id = media.id.clone();
|
let id = media.id.clone();
|
||||||
let title = media.title.clone().unwrap_or_default();
|
let title = media.title.clone().unwrap_or_default();
|
||||||
let artist = media.artist.clone().unwrap_or_default();
|
let artist = media.artist.clone().unwrap_or_default();
|
||||||
|
|
@ -971,6 +1007,520 @@ pub fn Detail(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Social section: ratings, comments, favorites
|
||||||
|
{
|
||||||
|
let social_id = id.clone();
|
||||||
|
let client_social = client.clone();
|
||||||
|
rsx! {
|
||||||
|
div { class: "card mb-16",
|
||||||
|
div { class: "card-header",
|
||||||
|
h4 { class: "card-title", "Social" }
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let sid = social_id.clone();
|
||||||
|
let cs = client_social.clone();
|
||||||
|
move |_| {
|
||||||
|
let sid = sid.clone();
|
||||||
|
let cs = cs.clone();
|
||||||
|
spawn(async move {
|
||||||
|
let mut errors: Vec<String> = Vec::new();
|
||||||
|
match cs.get_ratings(&sid).await {
|
||||||
|
Ok(r) => ratings.set(r),
|
||||||
|
Err(e) => errors.push(format!("ratings: {e}")),
|
||||||
|
}
|
||||||
|
match cs.list_comments(&sid).await {
|
||||||
|
Ok(c) => comments.set(c),
|
||||||
|
Err(e) => errors.push(format!("comments: {e}")),
|
||||||
|
}
|
||||||
|
match cs.list_favorites().await {
|
||||||
|
Ok(favs) => is_favorite.set(favs.iter().any(|m| m.id == sid)),
|
||||||
|
Err(e) => errors.push(format!("favorites: {e}")),
|
||||||
|
}
|
||||||
|
social_loaded.set(true);
|
||||||
|
if errors.is_empty() {
|
||||||
|
social_error.set(None);
|
||||||
|
} else {
|
||||||
|
social_error.set(Some(errors.join("; ")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Load"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *social_loaded.read() {
|
||||||
|
div { class: "card-body",
|
||||||
|
if let Some(ref err) = *social_error.read() {
|
||||||
|
div { class: "error-banner mb-8", "{err}" }
|
||||||
|
}
|
||||||
|
// Favorites
|
||||||
|
div { class: "form-row mb-8",
|
||||||
|
if *is_favorite.read() {
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let sid = social_id.clone();
|
||||||
|
let cs = client_social.clone();
|
||||||
|
move |_| {
|
||||||
|
let sid = sid.clone();
|
||||||
|
let cs = cs.clone();
|
||||||
|
spawn(async move {
|
||||||
|
match cs.remove_favorite(&sid).await {
|
||||||
|
Ok(_) => is_favorite.set(false),
|
||||||
|
Err(e) => social_error.set(Some(format!("Failed: {e}"))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"\u{2665} Unfavorite"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let sid = social_id.clone();
|
||||||
|
let cs = client_social.clone();
|
||||||
|
move |_| {
|
||||||
|
let sid = sid.clone();
|
||||||
|
let cs = cs.clone();
|
||||||
|
spawn(async move {
|
||||||
|
match cs.add_favorite(&sid).await {
|
||||||
|
Ok(_) => is_favorite.set(true),
|
||||||
|
Err(e) => social_error.set(Some(format!("Failed: {e}"))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"\u{2661} Favorite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Rating
|
||||||
|
div { class: "form-row mb-8",
|
||||||
|
span { class: "detail-label", "Rate: " }
|
||||||
|
for star in 1u8..=5u8 {
|
||||||
|
{
|
||||||
|
let cs = client_social.clone();
|
||||||
|
let sid = social_id.clone();
|
||||||
|
rsx! {
|
||||||
|
button {
|
||||||
|
key: "{star}",
|
||||||
|
class: if *selected_stars.read() >= star { "btn btn-sm star-btn active" } else { "btn btn-sm star-btn" },
|
||||||
|
onclick: move |_| {
|
||||||
|
let cs = cs.clone();
|
||||||
|
let sid = sid.clone();
|
||||||
|
selected_stars.set(star);
|
||||||
|
spawn(async move {
|
||||||
|
if let Err(e) = cs.rate_media(&sid, star).await {
|
||||||
|
social_error.set(Some(format!("Rating failed: {e}")));
|
||||||
|
} else if let Ok(r) = cs.get_ratings(&sid).await {
|
||||||
|
ratings.set(r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"\u{2605}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span { class: "text-muted", "({ratings.read().len()} ratings)" }
|
||||||
|
}
|
||||||
|
// Comments
|
||||||
|
div { class: "mb-8",
|
||||||
|
h5 { "Comments" }
|
||||||
|
if comments.read().is_empty() {
|
||||||
|
p { class: "text-muted text-sm", "No comments." }
|
||||||
|
} else {
|
||||||
|
div { class: "comments-list",
|
||||||
|
for comment in comments.read().iter() {
|
||||||
|
div { class: "comment-item", key: "{comment.id}",
|
||||||
|
span { class: "comment-text", "{comment.text}" }
|
||||||
|
if let Some(ref t) = comment.created_at {
|
||||||
|
span { class: "text-muted text-sm", " - {t}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "form-row mt-8",
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Add a comment...",
|
||||||
|
value: "{new_comment_text}",
|
||||||
|
oninput: move |e| new_comment_text.set(e.value()),
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let sid = social_id.clone();
|
||||||
|
let cs = client_social.clone();
|
||||||
|
move |_| {
|
||||||
|
let text = new_comment_text.read().clone();
|
||||||
|
if text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let sid = sid.clone();
|
||||||
|
let cs = cs.clone();
|
||||||
|
spawn(async move {
|
||||||
|
match cs.add_comment(&sid, &text).await {
|
||||||
|
Ok(c) => {
|
||||||
|
comments.write().push(c);
|
||||||
|
new_comment_text.set(String::new());
|
||||||
|
}
|
||||||
|
Err(e) => social_error.set(Some(format!("Comment failed: {e}"))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Post"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrichment section (all media types)
|
||||||
|
{
|
||||||
|
let enrich_id = id.clone();
|
||||||
|
let client_enrich = client.clone();
|
||||||
|
rsx! {
|
||||||
|
div { class: "card mb-16",
|
||||||
|
div { class: "card-header",
|
||||||
|
h4 { class: "card-title", "Metadata Enrichment" }
|
||||||
|
}
|
||||||
|
div { class: "card-body",
|
||||||
|
if let Some(ref err) = *enrich_error.read() {
|
||||||
|
div { class: "error-banner mb-8", "{err}" }
|
||||||
|
}
|
||||||
|
div { class: "form-row",
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary btn-sm",
|
||||||
|
disabled: *enriching.read(),
|
||||||
|
onclick: {
|
||||||
|
let eid = enrich_id.clone();
|
||||||
|
let ce = client_enrich.clone();
|
||||||
|
move |_| {
|
||||||
|
let eid = eid.clone();
|
||||||
|
let ce = ce.clone();
|
||||||
|
spawn(async move {
|
||||||
|
enriching.set(true);
|
||||||
|
enrich_done.set(false);
|
||||||
|
enrich_error.set(None);
|
||||||
|
match ce.enrich_media(&eid).await {
|
||||||
|
Ok(_) => enrich_done.set(true),
|
||||||
|
Err(e) => enrich_error.set(Some(format!("Enrichment failed: {e}"))),
|
||||||
|
}
|
||||||
|
enriching.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
if *enriching.read() { "Enriching..." } else { "Enrich Metadata" }
|
||||||
|
}
|
||||||
|
if *enrich_done.read() {
|
||||||
|
span { class: "badge badge-success", "Enrichment complete" }
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let eid = enrich_id.clone();
|
||||||
|
let ce = client_enrich.clone();
|
||||||
|
move |_| {
|
||||||
|
let eid = eid.clone();
|
||||||
|
let ce = ce.clone();
|
||||||
|
show_ext_meta.toggle();
|
||||||
|
if *show_ext_meta.read() && ext_meta.read().is_none() {
|
||||||
|
spawn(async move {
|
||||||
|
match ce.get_external_metadata(&eid).await {
|
||||||
|
Ok(v) => ext_meta.set(Some(v)),
|
||||||
|
Err(e) => enrich_error.set(Some(format!("Metadata fetch failed: {e}"))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
if *show_ext_meta.read() { "Hide External Metadata" } else { "Show External Metadata" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *show_ext_meta.read() {
|
||||||
|
if let Some(ref meta) = *ext_meta.read() {
|
||||||
|
pre { class: "mono text-sm", "{meta}" }
|
||||||
|
} else {
|
||||||
|
p { class: "text-muted", "Loading external metadata..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitles section (video and audio only)
|
||||||
|
if category == "video" || category == "audio" {
|
||||||
|
{
|
||||||
|
let sub_id = id.clone();
|
||||||
|
let client_sub = client.clone();
|
||||||
|
rsx! {
|
||||||
|
div { class: "card mb-16",
|
||||||
|
div { class: "card-header",
|
||||||
|
h4 { class: "card-title", "Subtitles" }
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let sid = sub_id.clone();
|
||||||
|
let cs = client_sub.clone();
|
||||||
|
move |_| {
|
||||||
|
let sid = sid.clone();
|
||||||
|
let cs = cs.clone();
|
||||||
|
spawn(async move {
|
||||||
|
match cs.list_subtitles_for_media(&sid).await {
|
||||||
|
Ok(data) => {
|
||||||
|
subtitle_error.set(None);
|
||||||
|
subtitle_list.set(Some(data));
|
||||||
|
}
|
||||||
|
Err(e) => subtitle_error.set(Some(format!("Failed to load subtitles: {e}"))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Refresh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref err) = *subtitle_error.read() {
|
||||||
|
div { class: "error-banner mb-8", "{err}" }
|
||||||
|
}
|
||||||
|
if let Some(ref subs) = *subtitle_list.read() {
|
||||||
|
if !subs.available_tracks.is_empty() {
|
||||||
|
div { class: "mb-8",
|
||||||
|
h5 { "Embedded Tracks" }
|
||||||
|
table { class: "data-table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Index" }
|
||||||
|
th { "Language" }
|
||||||
|
th { "Format" }
|
||||||
|
th { "Title" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for track in subs.available_tracks.iter() {
|
||||||
|
tr { key: "{track.index}",
|
||||||
|
td { "{track.index}" }
|
||||||
|
td { "{track.language.clone().unwrap_or_default()}" }
|
||||||
|
td { "{track.format}" }
|
||||||
|
td { "{track.title.clone().unwrap_or_default()}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if subs.subtitles.is_empty() {
|
||||||
|
p { class: "text-muted", "No external subtitles." }
|
||||||
|
} else {
|
||||||
|
table { class: "data-table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Language" }
|
||||||
|
th { "Format" }
|
||||||
|
th { "Embedded" }
|
||||||
|
th { "Offset (ms)" }
|
||||||
|
th { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for sub in subs.subtitles.iter() {
|
||||||
|
{
|
||||||
|
let sub_entry_id = sub.id.clone();
|
||||||
|
let cs2 = client_sub.clone();
|
||||||
|
let sid2 = sub_id.clone();
|
||||||
|
rsx! {
|
||||||
|
tr { key: "{sub_entry_id}",
|
||||||
|
td { "{sub.language.clone().unwrap_or_default()}" }
|
||||||
|
td { "{sub.format}" }
|
||||||
|
td { if sub.is_embedded { "Yes" } else { "No" } }
|
||||||
|
td { "{sub.offset_ms}" }
|
||||||
|
td {
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger btn-sm",
|
||||||
|
onclick: move |_| {
|
||||||
|
let eid = sub_entry_id.clone();
|
||||||
|
let cs2 = cs2.clone();
|
||||||
|
let sid2 = sid2.clone();
|
||||||
|
spawn(async move {
|
||||||
|
match cs2.delete_subtitle(&eid).await {
|
||||||
|
Ok(()) => {
|
||||||
|
match cs2.list_subtitles_for_media(&sid2).await {
|
||||||
|
Ok(data) => {
|
||||||
|
subtitle_error.set(None);
|
||||||
|
subtitle_list.set(Some(data));
|
||||||
|
}
|
||||||
|
Err(e) => subtitle_error.set(Some(format!("Failed to refresh subtitles: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => subtitle_error.set(Some(format!("Failed to delete subtitle: {e}"))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p { class: "text-muted",
|
||||||
|
"Click Refresh to load subtitle information."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcode section (video and audio only)
|
||||||
|
if category == "video" || category == "audio" {
|
||||||
|
{
|
||||||
|
let tc_id = id.clone();
|
||||||
|
let client_tc = client.clone();
|
||||||
|
rsx! {
|
||||||
|
div { class: "card mb-16",
|
||||||
|
div { class: "card-header",
|
||||||
|
h4 { class: "card-title", "Transcoding" }
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let cs = client_tc.clone();
|
||||||
|
let tid = tc_id.clone();
|
||||||
|
move |_| {
|
||||||
|
let cs = cs.clone();
|
||||||
|
let tid = tid.clone();
|
||||||
|
spawn(async move {
|
||||||
|
if let Ok(all) = cs.list_transcodes().await {
|
||||||
|
let filtered: Vec<_> = all
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.media_id == tid)
|
||||||
|
.collect();
|
||||||
|
transcode_list.set(filtered);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Refresh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref err) = *transcode_error.read() {
|
||||||
|
div { class: "error-banner mb-8", "{err}" }
|
||||||
|
}
|
||||||
|
div { class: "form-row mb-8",
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Profile (e.g. web-720p)...",
|
||||||
|
value: "{transcode_profile}",
|
||||||
|
oninput: move |e| transcode_profile.set(e.value()),
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary btn-sm",
|
||||||
|
disabled: *transcode_running.read() > 0,
|
||||||
|
onclick: {
|
||||||
|
let tid = tc_id.clone();
|
||||||
|
let ct = client_tc.clone();
|
||||||
|
move |_| {
|
||||||
|
let profile = transcode_profile.read().clone();
|
||||||
|
if profile.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tid = tid.clone();
|
||||||
|
let ct = ct.clone();
|
||||||
|
spawn(async move {
|
||||||
|
*transcode_running.write() += 1;
|
||||||
|
transcode_error.set(None);
|
||||||
|
match ct.start_transcode(&tid, &profile).await {
|
||||||
|
Ok(session) => {
|
||||||
|
transcode_list.write().push(session);
|
||||||
|
transcode_profile.set(String::new());
|
||||||
|
}
|
||||||
|
Err(e) => transcode_error.set(Some(format!("Transcode failed: {e}"))),
|
||||||
|
}
|
||||||
|
let prev = *transcode_running.read();
|
||||||
|
*transcode_running.write() = prev.saturating_sub(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
if *transcode_running.read() > 0 { "Starting..." } else { "Start Transcode" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if transcode_list.read().is_empty() {
|
||||||
|
p { class: "text-muted", "No transcode sessions for this item." }
|
||||||
|
} else {
|
||||||
|
table { class: "data-table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Profile" }
|
||||||
|
th { "Status" }
|
||||||
|
th { "Progress" }
|
||||||
|
th { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for session in transcode_list.read().clone() {
|
||||||
|
{
|
||||||
|
let sess_id = session.id.clone();
|
||||||
|
let ct2 = client_tc.clone();
|
||||||
|
let is_active = session.status == "running" || session.status == "pending";
|
||||||
|
let progress = session
|
||||||
|
.progress
|
||||||
|
.map(|p| format!("{:.0}%", p))
|
||||||
|
.unwrap_or_default();
|
||||||
|
rsx! {
|
||||||
|
tr { key: "{sess_id}",
|
||||||
|
td { "{session.profile}" }
|
||||||
|
td {
|
||||||
|
span {
|
||||||
|
class: if is_active { "badge badge-warning" } else { "badge badge-neutral" },
|
||||||
|
"{session.status}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td { "{progress}" }
|
||||||
|
td {
|
||||||
|
if is_active {
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger btn-sm",
|
||||||
|
onclick: move |_| {
|
||||||
|
let sid = sess_id.clone();
|
||||||
|
let ct2 = ct2.clone();
|
||||||
|
spawn(async move {
|
||||||
|
match ct2.cancel_transcode(&sid).await {
|
||||||
|
Ok(()) => {
|
||||||
|
transcode_error.set(None);
|
||||||
|
transcode_list.write().retain(|s| s.id != sid);
|
||||||
|
}
|
||||||
|
Err(e) => transcode_error.set(Some(format!("Cancel failed: {e}"))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Image viewer overlay
|
// Image viewer overlay
|
||||||
if *show_image_viewer.read() {
|
if *show_image_viewer.read() {
|
||||||
ImageViewer {
|
ImageViewer {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
use dioxus::{document::eval, prelude::*};
|
use dioxus::{
|
||||||
|
document::{Script, eval},
|
||||||
|
prelude::*,
|
||||||
|
};
|
||||||
|
|
||||||
use super::utils::format_duration;
|
use super::utils::format_duration;
|
||||||
|
|
||||||
|
|
@ -123,6 +126,43 @@ impl PlayQueue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate JavaScript to initialize hls.js for an HLS stream URL.
|
||||||
|
///
|
||||||
|
/// Destroys any previously created instance stored at `window.__hlsInstances`
|
||||||
|
/// keyed by element ID before creating a new one, preventing memory leaks and
|
||||||
|
/// multiple instances competing for the same video element across re-renders.
|
||||||
|
fn hls_init_script(video_id: &str, hls_url: &str) -> String {
|
||||||
|
// JSON-encode both values so embedded quotes/backslashes cannot break out of
|
||||||
|
// the JS string.
|
||||||
|
let encoded_url =
|
||||||
|
serde_json::to_string(hls_url).unwrap_or_else(|_| "\"\"".to_string());
|
||||||
|
let encoded_id =
|
||||||
|
serde_json::to_string(video_id).unwrap_or_else(|_| "\"\"".to_string());
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
(function() {{
|
||||||
|
window.__hlsInstances = window.__hlsInstances || {{}};
|
||||||
|
var existing = window.__hlsInstances[{encoded_id}];
|
||||||
|
if (existing) {{
|
||||||
|
existing.destroy();
|
||||||
|
window.__hlsInstances[{encoded_id}] = null;
|
||||||
|
}}
|
||||||
|
if (typeof Hls !== 'undefined' && Hls.isSupported()) {{
|
||||||
|
var hls = new Hls();
|
||||||
|
hls.loadSource({encoded_url});
|
||||||
|
hls.attachMedia(document.getElementById({encoded_id}));
|
||||||
|
window.__hlsInstances[{encoded_id}] = hls;
|
||||||
|
}} else {{
|
||||||
|
var video = document.getElementById({encoded_id});
|
||||||
|
if (video && video.canPlayType('application/vnd.apple.mpegurl')) {{
|
||||||
|
video.src = {encoded_url};
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}})();
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn MediaPlayer(
|
pub fn MediaPlayer(
|
||||||
src: String,
|
src: String,
|
||||||
|
|
@ -200,6 +240,35 @@ pub fn MediaPlayer(
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// HLS initialization for .m3u8 streams.
|
||||||
|
// use_effect must be called unconditionally to maintain stable hook ordering.
|
||||||
|
let is_hls = src.ends_with(".m3u8");
|
||||||
|
let hls_src = src.clone();
|
||||||
|
use_effect(move || {
|
||||||
|
if !hls_src.ends_with(".m3u8") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let js = hls_init_script("pinakes-player", &hls_src);
|
||||||
|
spawn(async move {
|
||||||
|
// Poll until hls.js is loaded rather than using a fixed delay, so we
|
||||||
|
// initialize as soon as the script is ready without timing out on slow
|
||||||
|
// connections. Max wait: 25 * 100ms = 2.5s.
|
||||||
|
const MAX_POLLS: u32 = 25;
|
||||||
|
for _ in 0..MAX_POLLS {
|
||||||
|
if let Ok(val) = eval("typeof Hls !== 'undefined'").await {
|
||||||
|
if val == serde_json::Value::Bool(true) {
|
||||||
|
let _ = eval(&js).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
"hls.js did not load within 2.5s; HLS stream will not play"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Autoplay on mount
|
// Autoplay on mount
|
||||||
if autoplay {
|
if autoplay {
|
||||||
let src_auto = src.clone();
|
let src_auto = src.clone();
|
||||||
|
|
@ -367,24 +436,31 @@ pub fn MediaPlayer(
|
||||||
let mute_icon = if is_muted { "\u{1f507}" } else { "\u{1f50a}" };
|
let mute_icon = if is_muted { "\u{1f507}" } else { "\u{1f50a}" };
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
|
// Load hls.js for HLS stream support when needed
|
||||||
|
if is_hls {
|
||||||
|
// Pin to a specific release so unexpected upstream changes cannot
|
||||||
|
// break playback. Update this when intentionally upgrading hls.js.
|
||||||
|
Script { src: "https://cdn.jsdelivr.net/npm/hls.js@1.5.15/dist/hls.min.js" }
|
||||||
|
}
|
||||||
|
|
||||||
div {
|
div {
|
||||||
class: if is_video { "media-player media-player-video" } else { "media-player media-player-audio" },
|
class: if is_video { "media-player media-player-video" } else { "media-player media-player-audio" },
|
||||||
tabindex: "0",
|
tabindex: "0",
|
||||||
onkeydown: on_keydown,
|
onkeydown: on_keydown,
|
||||||
|
|
||||||
// Hidden native element
|
// Hidden native element; for HLS streams skip the src attr (hls.js attaches it)
|
||||||
if is_video {
|
if is_video {
|
||||||
video {
|
video {
|
||||||
id: "pinakes-player",
|
id: "pinakes-player",
|
||||||
src: "{src}",
|
class: "player-native-video",
|
||||||
style: if is_video { "width: 100%; display: block;" } else { "display: none;" },
|
src: if is_hls { String::new() } else { src.clone() },
|
||||||
preload: "metadata",
|
preload: "metadata",
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
audio {
|
audio {
|
||||||
id: "pinakes-player",
|
id: "pinakes-player",
|
||||||
src: "{src}",
|
class: "player-native-audio",
|
||||||
style: "display: none;",
|
src: if is_hls { String::new() } else { src.clone() },
|
||||||
preload: "metadata",
|
preload: "metadata",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ pub mod markdown_viewer;
|
||||||
pub mod media_player;
|
pub mod media_player;
|
||||||
pub mod pagination;
|
pub mod pagination;
|
||||||
pub mod pdf_viewer;
|
pub mod pdf_viewer;
|
||||||
|
pub mod playlists;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod statistics;
|
pub mod statistics;
|
||||||
|
|
|
||||||
411
crates/pinakes-ui/src/components/playlists.rs
Normal file
411
crates/pinakes-ui/src/components/playlists.rs
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
use crate::client::ConfigResponse;
|
use crate::client::{ApiClient, ConfigResponse};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Settings(
|
pub fn Settings(
|
||||||
|
|
@ -13,6 +13,8 @@ pub fn Settings(
|
||||||
#[props(default)] on_update_ui_config: Option<
|
#[props(default)] on_update_ui_config: Option<
|
||||||
EventHandler<serde_json::Value>,
|
EventHandler<serde_json::Value>,
|
||||||
>,
|
>,
|
||||||
|
#[props(default)] client: Option<ApiClient>,
|
||||||
|
#[props(default)] current_user_role: Option<String>,
|
||||||
) -> Element {
|
) -> Element {
|
||||||
let mut new_root = use_signal(String::new);
|
let mut new_root = use_signal(String::new);
|
||||||
let mut editing_poll = use_signal(|| false);
|
let mut editing_poll = use_signal(|| false);
|
||||||
|
|
@ -21,13 +23,475 @@ pub fn Settings(
|
||||||
let mut editing_patterns = use_signal(|| false);
|
let mut editing_patterns = use_signal(|| false);
|
||||||
let mut patterns_input = use_signal(String::new);
|
let mut patterns_input = use_signal(String::new);
|
||||||
|
|
||||||
|
// Tab state: "general", "users", "sync", "webhooks"
|
||||||
|
let mut active_tab = use_signal(|| "general".to_string());
|
||||||
|
|
||||||
|
// Users tab state
|
||||||
|
let mut users_list = use_signal(Vec::<crate::client::UserResponse>::new);
|
||||||
|
let mut users_loaded = use_signal(|| false);
|
||||||
|
let mut users_loading = use_signal(|| false);
|
||||||
|
let mut users_error: Signal<Option<String>> = use_signal(|| None);
|
||||||
|
let mut new_username = use_signal(String::new);
|
||||||
|
let mut new_password = use_signal(String::new);
|
||||||
|
let mut new_role = use_signal(|| "viewer".to_string());
|
||||||
|
let mut confirm_delete_user: Signal<Option<String>> = use_signal(|| None);
|
||||||
|
|
||||||
|
// Sync devices tab state
|
||||||
|
let mut devices_list = use_signal(Vec::<crate::client::DeviceResponse>::new);
|
||||||
|
let mut devices_loaded = use_signal(|| false);
|
||||||
|
let mut devices_loading = use_signal(|| false);
|
||||||
|
let mut devices_error: Signal<Option<String>> = use_signal(|| None);
|
||||||
|
let mut confirm_delete_device: Signal<Option<String>> = use_signal(|| None);
|
||||||
|
|
||||||
|
// Webhooks tab state
|
||||||
|
let mut webhooks_list =
|
||||||
|
use_signal(Vec::<crate::client::WebhookResponse>::new);
|
||||||
|
let mut webhooks_loaded = use_signal(|| false);
|
||||||
|
let mut webhooks_loading = use_signal(|| false);
|
||||||
|
let mut webhooks_error: Signal<Option<String>> = use_signal(|| None);
|
||||||
|
|
||||||
let writable = config.config_writable;
|
let writable = config.config_writable;
|
||||||
let watch_enabled = config.scanning.watch;
|
let watch_enabled = config.scanning.watch;
|
||||||
let host_port = format!("{}:{}", config.server.host, config.server.port);
|
let host_port = format!("{}:{}", config.server.host, config.server.port);
|
||||||
let db_path = config.database_path.clone().unwrap_or_default();
|
let db_path = config.database_path.clone().unwrap_or_default();
|
||||||
let root_count = config.roots.len();
|
let root_count = config.roots.len();
|
||||||
|
|
||||||
|
let is_admin = current_user_role
|
||||||
|
.as_deref()
|
||||||
|
.map(|r| r == "admin")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
|
// Tab bar
|
||||||
|
div { class: "tab-bar mb-16",
|
||||||
|
button {
|
||||||
|
class: if *active_tab.read() == "general" { "tab-btn active" } else { "tab-btn" },
|
||||||
|
onclick: move |_| active_tab.set("general".to_string()),
|
||||||
|
"General"
|
||||||
|
}
|
||||||
|
if is_admin {
|
||||||
|
button {
|
||||||
|
class: if *active_tab.read() == "users" { "tab-btn active" } else { "tab-btn" },
|
||||||
|
onclick: {
|
||||||
|
let c = client.clone();
|
||||||
|
move |_| {
|
||||||
|
active_tab.set("users".to_string());
|
||||||
|
if !*users_loaded.read() {
|
||||||
|
if let Some(ref api) = c {
|
||||||
|
let api = api.clone();
|
||||||
|
spawn(async move {
|
||||||
|
users_loading.set(true);
|
||||||
|
match api.list_users().await {
|
||||||
|
Ok(list) => { users_list.set(list); users_loaded.set(true); }
|
||||||
|
Err(e) => users_error.set(Some(format!("Failed to load users: {e}"))),
|
||||||
|
}
|
||||||
|
users_loading.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Users"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_admin {
|
||||||
|
button {
|
||||||
|
class: if *active_tab.read() == "sync" { "tab-btn active" } else { "tab-btn" },
|
||||||
|
onclick: {
|
||||||
|
let c = client.clone();
|
||||||
|
move |_| {
|
||||||
|
active_tab.set("sync".to_string());
|
||||||
|
if !*devices_loaded.read() && !*devices_loading.read() {
|
||||||
|
if let Some(ref api) = c {
|
||||||
|
let api = api.clone();
|
||||||
|
devices_loading.set(true);
|
||||||
|
spawn(async move {
|
||||||
|
match api.list_sync_devices().await {
|
||||||
|
Ok(list) => { devices_list.set(list); devices_loaded.set(true); }
|
||||||
|
Err(e) => devices_error.set(Some(format!("Failed to load devices: {e}"))),
|
||||||
|
}
|
||||||
|
devices_loading.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Sync Devices"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_admin {
|
||||||
|
button {
|
||||||
|
class: if *active_tab.read() == "webhooks" { "tab-btn active" } else { "tab-btn" },
|
||||||
|
onclick: {
|
||||||
|
let c = client.clone();
|
||||||
|
move |_| {
|
||||||
|
active_tab.set("webhooks".to_string());
|
||||||
|
if !*webhooks_loaded.read() && !*webhooks_loading.read() {
|
||||||
|
if let Some(ref api) = c {
|
||||||
|
let api = api.clone();
|
||||||
|
webhooks_loading.set(true);
|
||||||
|
spawn(async move {
|
||||||
|
match api.list_webhooks().await {
|
||||||
|
Ok(list) => { webhooks_list.set(list); webhooks_loaded.set(true); }
|
||||||
|
Err(e) => webhooks_error.set(Some(format!("Failed to load webhooks: {e}"))),
|
||||||
|
}
|
||||||
|
webhooks_loading.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Webhooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if *active_tab.read() == "users" {
|
||||||
|
div { class: "settings-layout",
|
||||||
|
if let Some(ref err) = *users_error.read() {
|
||||||
|
div { class: "error-banner mb-16", "{err}" }
|
||||||
|
}
|
||||||
|
if is_admin {
|
||||||
|
div { class: "settings-card",
|
||||||
|
div { class: "settings-card-header",
|
||||||
|
h3 { class: "settings-card-title", "Create User" }
|
||||||
|
}
|
||||||
|
div { class: "settings-card-body",
|
||||||
|
div { class: "form-row mb-8",
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Username...",
|
||||||
|
value: "{new_username}",
|
||||||
|
oninput: move |e| new_username.set(e.value()),
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
r#type: "password",
|
||||||
|
placeholder: "Password...",
|
||||||
|
value: "{new_password}",
|
||||||
|
oninput: move |e| new_password.set(e.value()),
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
value: "{new_role}",
|
||||||
|
onchange: move |e| new_role.set(e.value()),
|
||||||
|
option { value: "viewer", "Viewer" }
|
||||||
|
option { value: "editor", "Editor" }
|
||||||
|
option { value: "admin", "Admin" }
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let c = client.clone();
|
||||||
|
move |_| {
|
||||||
|
let username = new_username.read().clone();
|
||||||
|
let password = new_password.read().clone();
|
||||||
|
let role = new_role.read().clone();
|
||||||
|
if username.is_empty() || password.is_empty() {
|
||||||
|
users_error.set(Some("Username and password are required.".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
users_error.set(None);
|
||||||
|
if let Some(ref api) = c {
|
||||||
|
let api = api.clone();
|
||||||
|
spawn(async move {
|
||||||
|
match api.create_user(&username, &password, &role).await {
|
||||||
|
Ok(u) => {
|
||||||
|
users_list.write().push(u);
|
||||||
|
new_username.set(String::new());
|
||||||
|
new_password.set(String::new());
|
||||||
|
}
|
||||||
|
Err(e) => users_error.set(Some(format!("Create failed: {e}"))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Create"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "settings-card",
|
||||||
|
div { class: "settings-card-header",
|
||||||
|
h3 { class: "settings-card-title", "Users" }
|
||||||
|
}
|
||||||
|
div { class: "settings-card-body",
|
||||||
|
if *users_loading.read() {
|
||||||
|
div { class: "spinner" }
|
||||||
|
} else if users_list.read().is_empty() {
|
||||||
|
p { class: "text-muted", "No users." }
|
||||||
|
} else {
|
||||||
|
table { class: "data-table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Username" }
|
||||||
|
th { "Role" }
|
||||||
|
th { "Created" }
|
||||||
|
if is_admin {
|
||||||
|
th { "Change Role" }
|
||||||
|
th { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for user in users_list.read().clone() {
|
||||||
|
{
|
||||||
|
let uid = user.id.clone();
|
||||||
|
let created = user.created_at.clone().unwrap_or_default();
|
||||||
|
let current_role = user.role.clone();
|
||||||
|
let is_confirming = confirm_delete_user
|
||||||
|
.read()
|
||||||
|
.as_ref()
|
||||||
|
.map(|id| id == &user.id)
|
||||||
|
.unwrap_or(false);
|
||||||
|
rsx! {
|
||||||
|
tr { key: "{uid}",
|
||||||
|
td { "{user.username}" }
|
||||||
|
td {
|
||||||
|
span { class: "role-badge role-{user.role}", "{user.role}" }
|
||||||
|
}
|
||||||
|
td { "{created}" }
|
||||||
|
if is_admin {
|
||||||
|
td {
|
||||||
|
select {
|
||||||
|
value: "{current_role}",
|
||||||
|
onchange: {
|
||||||
|
let id = uid.clone();
|
||||||
|
let c = client.clone();
|
||||||
|
move |e: Event<FormData>| {
|
||||||
|
let new_role = e.value();
|
||||||
|
let id = id.clone();
|
||||||
|
if let Some(ref api) = c {
|
||||||
|
let api = api.clone();
|
||||||
|
spawn(async move {
|
||||||
|
match api.update_user(&id, &new_role).await {
|
||||||
|
Ok(updated) => {
|
||||||
|
users_error.set(None);
|
||||||
|
let mut list = users_list.write();
|
||||||
|
if let Some(u) = list.iter_mut().find(|u| u.id == id) {
|
||||||
|
*u = updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => users_error.set(Some(format!("Failed to update role: {e}"))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
option { value: "viewer", "Viewer" }
|
||||||
|
option { value: "editor", "Editor" }
|
||||||
|
option { value: "admin", "Admin" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
if is_confirming {
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let id = uid.clone();
|
||||||
|
let c = client.clone();
|
||||||
|
move |_| {
|
||||||
|
let id = id.clone();
|
||||||
|
if let Some(ref api) = c {
|
||||||
|
let api = api.clone();
|
||||||
|
spawn(async move {
|
||||||
|
if api.delete_user(&id).await.is_ok() {
|
||||||
|
users_list.write().retain(|u| u.id != id);
|
||||||
|
confirm_delete_user.set(None);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Confirm"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-sm",
|
||||||
|
onclick: move |_| confirm_delete_user.set(None),
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let id = uid.clone();
|
||||||
|
move |_| confirm_delete_user.set(Some(id.clone()))
|
||||||
|
},
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if *active_tab.read() == "sync" {
|
||||||
|
div { class: "settings-layout",
|
||||||
|
if let Some(ref err) = *devices_error.read() {
|
||||||
|
div { class: "error-banner mb-16", "{err}" }
|
||||||
|
}
|
||||||
|
div { class: "settings-card",
|
||||||
|
div { class: "settings-card-header",
|
||||||
|
h3 { class: "settings-card-title", "Sync Devices" }
|
||||||
|
}
|
||||||
|
div { class: "settings-card-body",
|
||||||
|
if *devices_loading.read() {
|
||||||
|
div { class: "spinner" }
|
||||||
|
} else if devices_list.read().is_empty() {
|
||||||
|
p { class: "text-muted", "No sync devices registered." }
|
||||||
|
} else {
|
||||||
|
table { class: "data-table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Name" }
|
||||||
|
th { "Type" }
|
||||||
|
th { "Last Seen" }
|
||||||
|
th { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for device in devices_list.read().clone() {
|
||||||
|
{
|
||||||
|
let did = device.id.clone();
|
||||||
|
let last_seen = device.last_seen.clone().unwrap_or_default();
|
||||||
|
let is_confirming = confirm_delete_device
|
||||||
|
.read()
|
||||||
|
.as_ref()
|
||||||
|
.map(|id| id == &device.id)
|
||||||
|
.unwrap_or(false);
|
||||||
|
rsx! {
|
||||||
|
tr { key: "{did}",
|
||||||
|
td { "{device.name}" }
|
||||||
|
td { "{device.device_type}" }
|
||||||
|
td { "{last_seen}" }
|
||||||
|
td {
|
||||||
|
if is_confirming {
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let id = did.clone();
|
||||||
|
let c = client.clone();
|
||||||
|
move |_| {
|
||||||
|
let id = id.clone();
|
||||||
|
if let Some(ref api) = c {
|
||||||
|
let api = api.clone();
|
||||||
|
spawn(async move {
|
||||||
|
match api.delete_sync_device(&id).await {
|
||||||
|
Ok(()) => {
|
||||||
|
devices_error.set(None);
|
||||||
|
devices_list.write().retain(|d| d.id != id);
|
||||||
|
confirm_delete_device.set(None);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
devices_error.set(Some(format!("Failed to delete device: {e}")));
|
||||||
|
confirm_delete_device.set(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Confirm"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-sm",
|
||||||
|
onclick: move |_| confirm_delete_device.set(None),
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let id = did.clone();
|
||||||
|
move |_| confirm_delete_device.set(Some(id.clone()))
|
||||||
|
},
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if *active_tab.read() == "webhooks" {
|
||||||
|
div { class: "settings-layout",
|
||||||
|
if let Some(ref err) = *webhooks_error.read() {
|
||||||
|
div { class: "error-banner mb-16", "{err}" }
|
||||||
|
}
|
||||||
|
div { class: "settings-card",
|
||||||
|
div { class: "settings-card-header",
|
||||||
|
h3 { class: "settings-card-title", "Webhooks" }
|
||||||
|
}
|
||||||
|
div { class: "settings-card-body",
|
||||||
|
if *webhooks_loading.read() {
|
||||||
|
div { class: "spinner" }
|
||||||
|
} else if webhooks_list.read().is_empty() {
|
||||||
|
p { class: "text-muted", "No webhooks configured." }
|
||||||
|
} else {
|
||||||
|
table { class: "data-table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "URL" }
|
||||||
|
th { "Events" }
|
||||||
|
th { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for wh in webhooks_list.read().clone() {
|
||||||
|
{
|
||||||
|
let wid = wh.id.clone();
|
||||||
|
let events = wh.events.join(", ");
|
||||||
|
rsx! {
|
||||||
|
tr { key: "{wid}",
|
||||||
|
td { class: "mono", "{wh.url}" }
|
||||||
|
td { "{events}" }
|
||||||
|
td {
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary btn-sm",
|
||||||
|
onclick: {
|
||||||
|
let id = wid.clone();
|
||||||
|
let c = client.clone();
|
||||||
|
move |_| {
|
||||||
|
let id = id.clone();
|
||||||
|
if let Some(ref api) = c {
|
||||||
|
let api = api.clone();
|
||||||
|
spawn(async move {
|
||||||
|
match api.test_webhook(&id).await {
|
||||||
|
Ok(_) => webhooks_error.set(None),
|
||||||
|
Err(e) => webhooks_error.set(Some(format!("Test failed: {e}"))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// General tab (original content)
|
||||||
div { class: "settings-layout",
|
div { class: "settings-layout",
|
||||||
|
|
||||||
// Configuration source
|
// Configuration source
|
||||||
|
|
@ -567,5 +1031,6 @@ pub fn Settings(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // end else (general tab)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue