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

@ -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 {

View file

@ -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",
} }
} }

View file

@ -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;

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"
}
}
}
}
}
}
}
}
}
}
}
}
}

View file

@ -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)
} }
} }