diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 1cd06eff6..8df17bcc8 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -36,7 +36,9 @@ mod location_view; mod mix; mod musician; mod myqbz; +mod myqbz_cover; mod myqbz_detail; +mod myqbz_edit; mod myqbz_play; mod nav; mod play_history; @@ -2264,7 +2266,10 @@ fn wire_myqbz_detail( }); } - // --- STILL-STUBBED hero/CTA actions (later slices) ------------------ + // --- STILL-STUBBED hero CTAs (DJ-mix modal + discography sync) ------- + // DJ-mix: the TrackMixModal is a later slice — keep the existing stub. + // Sync: artist_discography has NO sync impl (spec §8) — no-op stub (the + // hero button is shown only for artist_collection for Tauri parity). { let weak = window.as_weak(); let log_stub = move |what: &str| { @@ -2273,19 +2278,169 @@ fn wire_myqbz_detail( .map(|w| w.global::().get_id().to_string()) .unwrap_or_default(); log::info!( - "[qbz-slint] myqbz_detail {what}({id}) — not yet wired (read-only slice)" + "[qbz-slint] myqbz_detail {what}({id}) — not yet wired (later slice / no impl)" ); }; let s = log_stub.clone(); window.global::().on_dj_mix(move || s("dj-mix")); let s = log_stub.clone(); - window.global::().on_edit(move || s("edit")); - let s = log_stub.clone(); - window.global::().on_delete(move || s("delete")); - let s = log_stub.clone(); window.global::().on_sync(move || s("sync")); } + // --- Hero overflow (⋯) menu — open the edit modals (spec 12 §10/§11) --- + // Rename / Edit description / Delete-confirm open the shared MyQbzEditModal + // with the right mode + prefill; the mutations + reload run on submit. + { + let weak = window.as_weak(); + window.global::().on_open_rename(move || { + let Some(w) = weak.upgrade() else { return }; + let ds = w.global::(); + let es = w.global::(); + es.set_mode("rename".into()); + es.set_name(ds.get_name()); + es.set_draft_name(ds.get_name()); + es.set_busy(false); + es.set_open(true); + }); + } + { + let weak = window.as_weak(); + window.global::().on_open_description(move || { + let Some(w) = weak.upgrade() else { return }; + let ds = w.global::(); + let es = w.global::(); + es.set_mode("description".into()); + es.set_name(ds.get_name()); + es.set_draft_description(ds.get_description()); + es.set_busy(false); + es.set_open(true); + }); + } + { + let weak = window.as_weak(); + window.global::().on_open_delete(move || { + let Some(w) = weak.upgrade() else { return }; + let ds = w.global::(); + let es = w.global::(); + es.set_mode("delete".into()); + es.set_name(ds.get_name()); + es.set_busy(false); + es.set_open(true); + }); + } + + // --- Hero overflow — custom cover (set / remove) -------------------- + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window.global::().on_upload_cover(move || { + let Some(w) = weak.upgrade() else { return }; + let id = w.global::().get_id().to_string(); + if id.is_empty() { + return; + } + myqbz_cover::upload(weak.clone(), handle.clone(), image_cache.clone(), id); + }); + } + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window.global::().on_remove_cover(move || { + let Some(w) = weak.upgrade() else { return }; + let id = w.global::().get_id().to_string(); + if id.is_empty() { + return; + } + myqbz_cover::remove(weak.clone(), handle.clone(), image_cache.clone(), id); + }); + } + + // --- Hero overflow — play-mode toggle / convert kind --------------- + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window.global::().on_toggle_play_mode(move || { + let Some(w) = weak.upgrade() else { return }; + let ds = w.global::(); + let id = ds.get_id().to_string(); + let mode = ds.get_play_mode().to_string(); + if id.is_empty() { + return; + } + myqbz_edit::toggle_play_mode(weak.clone(), handle.clone(), image_cache.clone(), id, mode); + }); + } + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window.global::().on_convert_kind(move || { + let Some(w) = weak.upgrade() else { return }; + let ds = w.global::(); + let id = ds.get_id().to_string(); + let kind = ds.get_kind().to_string(); + if id.is_empty() { + return; + } + myqbz_edit::convert_kind(weak.clone(), handle.clone(), image_cache.clone(), id, kind); + }); + } + + // --- Edit modals — submit (rename / description / delete) ---------- + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window.global::().on_submit_rename(move || { + let Some(w) = weak.upgrade() else { return }; + let id = w.global::().get_id().to_string(); + let name = w.global::().get_draft_name().to_string(); + myqbz_edit::rename(weak.clone(), handle.clone(), image_cache.clone(), id, name); + }); + } + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_submit_description(move || { + let Some(w) = weak.upgrade() else { return }; + let id = w.global::().get_id().to_string(); + let desc = w.global::().get_draft_description().to_string(); + myqbz_edit::set_description( + weak.clone(), + handle.clone(), + image_cache.clone(), + id, + desc, + ); + }); + } + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window.global::().on_confirm_delete(move || { + let Some(w) = weak.upgrade() else { return }; + let id = w.global::().get_id().to_string(); + myqbz_edit::delete(weak.clone(), handle.clone(), id); + }); + } + { + let weak = window.as_weak(); + window.global::().on_close(move || { + if let Some(w) = weak.upgrade() { + let es = w.global::(); + es.set_open(false); + es.set_mode("".into()); + es.set_busy(false); + } + }); + } + // --- Per-row PLAY (default) ----------------------------------------- { let runtime = app_runtime.clone(); diff --git a/crates/qbz-slint/src/myqbz_cover.rs b/crates/qbz-slint/src/myqbz_cover.rs new file mode 100644 index 000000000..d84649030 --- /dev/null +++ b/crates/qbz-slint/src/myqbz_cover.rs @@ -0,0 +1,231 @@ +//! My QBZ — custom-cover upload / remove (Phase-2 Slice 7). +//! +//! Mirrors Tauri's `v2_mixtape_upload_custom_cover` / +//! `v2_mixtape_remove_custom_cover` (spec 40 §7) 1:1, and the album +//! custom-cover convention (same artwork cache dir, same 1000×1000 Lanczos3 +//! resize, same failure-safe "persist before deleting the previous file" +//! ordering): +//! +//! - **Upload:** validate the picked file's extension (png/jpg/jpeg/webp), +//! read the previous `custom_artwork_path` (to delete after persist), decode +//! + `resize(1000, 1000, Lanczos3)`, save as `mixtape_custom_{safe_id}_{epoch_secs}.jpg` +//! in `qbz_library::get_artwork_cache_dir()`, persist via +//! `repo::set_custom_artwork(Some(dest))`, then delete the previous file only +//! if it differs. +//! - **Remove:** read previous, `repo::set_custom_artwork(None)`, delete prev. +//! +//! Frontend-agnostic (ADR-005/006): the persistence is `qbz_mixtape::repo` +//! reached directly through `crate::library_db::with_db`; no Tauri command +//! wrappers. All blocking work (file decode/resize/IO + DB) runs on a +//! `spawn_blocking` worker; the reload + toast hop back to the event loop. +//! +//! NOTE on webp: the workspace `image` crate is built with only the `jpeg` + +//! `png` features, so a `.webp` source decodes to an error at runtime (the +//! extension is accepted to match the Tauri picker filter, but the decode +//! surfaces the "upload failed" toast). png/jpg/jpeg are the working formats. + +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use image::imageops::FilterType; +use slint::ComponentHandle; + +use crate::artwork::ImageCache; +use crate::AppWindow; + +/// The four extensions the Tauri picker accepts (spec §7.1 step 1). +const ALLOWED_EXTENSIONS: [&str; 4] = ["png", "jpg", "jpeg", "webp"]; + +/// Epoch SECONDS (NOT ms) — the custom-cover filename timestamp is in seconds +/// (spec §1.6 / §7.1 step 4). +fn epoch_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Keep ascii-alphanumeric / `-` / `_`; everything else becomes `_` (spec +/// §7.1 step 5). The collection id is a UUID, so this is normally a no-op. +fn safe_id(id: &str) -> String { + id.chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .collect() +} + +/// Read the stored `custom_artwork_path` for a collection (used to delete the +/// previous file after a new one persists). Runs synchronously via `with_db`. +fn get_prev_path(id: &str) -> Option { + crate::library_db::with_db(|db| { + Ok(db.with_connection(|conn| { + qbz_mixtape::repo::get_custom_artwork(conn, id).unwrap_or(None) + })) + }) + .flatten() +} + +/// Persist `path` (or clear with None) into `custom_artwork_path`. Returns true +/// on success. Runs synchronously via `with_db`. +fn set_custom_artwork(id: &str, path: Option<&str>) -> bool { + let id = id.to_string(); + let path = path.map(|p| p.to_string()); + crate::library_db::with_db(move |db| { + db.with_connection(|conn| { + qbz_mixtape::repo::set_custom_artwork(conn, &id, path.as_deref()) + }) + .map_err(|e| { + qbz_library::LibraryError::Database(format!("set_custom_artwork failed: {e}")) + }) + }) + .is_some() +} + +/// Decode `source`, resize to 1000×1000 (Lanczos3), and save as a JPEG at +/// `dest`. Returns an error string on any failure (decode / resize / save). +fn resize_and_save(source: &Path, dest: &Path) -> Result<(), String> { + let img = image::open(source).map_err(|e| format!("decode failed: {e}"))?; + let resized = img.resize(1000, 1000, FilterType::Lanczos3); + resized + .to_rgb8() + .save_with_format(dest, image::ImageFormat::Jpeg) + .map_err(|e| format!("save failed: {e}")) +} + +/// The blocking upload body (extension validation → resize/save → persist → +/// delete-prev). Returns Ok(dest) or Err(reason). Mirrors the Tauri command +/// step order exactly (persist BEFORE deleting the previous file, and only +/// delete when it differs). +fn do_upload(id: &str, source_path: &str) -> Result { + let source = PathBuf::from(source_path); + if !source.exists() { + return Err("source file not found".to_string()); + } + // 1 — extension validation. + let ext_ok = source + .extension() + .and_then(|e| e.to_str()) + .map(|e| ALLOWED_EXTENSIONS.contains(&e.to_lowercase().as_str())) + .unwrap_or(false); + if !ext_ok { + return Err("unsupported image type".to_string()); + } + + // 2 — read previous path (to delete after persist). + let prev = get_prev_path(id); + + // 3-6 — build the destination filename in the shared artwork cache dir. + let artwork_dir = qbz_library::get_artwork_cache_dir(); + let filename = format!("mixtape_custom_{}_{}.jpg", safe_id(id), epoch_secs()); + let dest = artwork_dir.join(&filename); + + // 7 — decode + resize + save. + resize_and_save(&source, &dest)?; + + // 8 — persist the new path. + let dest_str = dest.to_string_lossy().to_string(); + if !set_custom_artwork(id, Some(&dest_str)) { + // Persist failed — clean up the orphan we just wrote, surface the error. + let _ = std::fs::remove_file(&dest); + return Err("failed to save cover".to_string()); + } + + // 9 — delete the previous file AFTER persist, only if it differs. + if let Some(prev) = prev { + if prev != dest_str { + let _ = std::fs::remove_file(&prev); + } + } + + Ok(dest_str) +} + +/// The blocking remove body: read previous → clear → delete prev file. +fn do_remove(id: &str) -> Result<(), String> { + let prev = get_prev_path(id); + if !set_custom_artwork(id, None) { + return Err("failed to clear cover".to_string()); + } + if let Some(prev) = prev { + let _ = std::fs::remove_file(&prev); + } + Ok(()) +} + +/// Hero overflow "Set custom cover": open the native image picker, then upload +/// + resize + persist + reload. Toasts success/failure (spec §10 item 3). +pub fn upload( + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: ImageCache, + id: String, +) { + handle.clone().spawn(async move { + let Some(file) = rfd::AsyncFileDialog::new() + .set_title("Choose a cover image") + .add_filter("Image", &["png", "jpg", "jpeg", "webp"]) + .pick_file() + .await + else { + return; // user cancelled — no toast. + }; + let source = file.path().to_string_lossy().to_string(); + + let upload_id = id.clone(); + let result = tokio::task::spawn_blocking(move || do_upload(&upload_id, &source)) + .await + .unwrap_or_else(|e| Err(format!("upload task panicked: {e}"))); + + match result { + Ok(_) => { + crate::toast::success_weak(&weak, "Cover updated"); + reload(weak.clone(), handle.clone(), image_cache.clone(), id); + } + Err(e) => { + log::warn!("[qbz-slint] myqbz_cover upload failed: {e}"); + crate::toast::error_weak(&weak, "Failed to upload cover"); + } + } + }); +} + +/// Hero overflow "Remove custom cover": clear + delete the file + reload. +pub fn remove( + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: ImageCache, + id: String, +) { + handle.clone().spawn(async move { + let remove_id = id.clone(); + let result = tokio::task::spawn_blocking(move || do_remove(&remove_id)) + .await + .unwrap_or_else(|e| Err(format!("remove task panicked: {e}"))); + + match result { + Ok(()) => { + crate::toast::success_weak(&weak, "Cover removed"); + reload(weak.clone(), handle.clone(), image_cache.clone(), id); + } + Err(e) => { + log::warn!("[qbz-slint] myqbz_cover remove failed: {e}"); + crate::toast::error_weak(&weak, "Failed to remove cover"); + } + } + }); +} + +/// Reload the open detail view so the hero reflects the new cover. Re-runs the +/// detail navigator's load/apply/artwork path for the same id (Tauri's +/// "-> reload"). The `set_view` inside `navigate` is harmless (we're already on +/// the detail view). +fn reload( + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: ImageCache, + id: String, +) { + let _ = weak.upgrade_in_event_loop(move |w| { + let _ = &w; // keep the closure's capture explicit. + crate::myqbz_detail::navigate(w.as_weak(), handle.clone(), image_cache.clone(), id); + }); +} diff --git a/crates/qbz-slint/src/myqbz_edit.rs b/crates/qbz-slint/src/myqbz_edit.rs new file mode 100644 index 000000000..5282efb4a --- /dev/null +++ b/crates/qbz-slint/src/myqbz_edit.rs @@ -0,0 +1,284 @@ +//! My QBZ — collection detail EDIT operations (Phase-2 Slice 7). +//! +//! Wires the hero overflow (⋯) menu + the Rename / Description / Delete-confirm +//! modals to the shared `qbz_mixtape::repo` setters, reached directly through +//! `crate::library_db::with_db` (ADR-005/006 — no Tauri command wrappers). Each +//! mutation mirrors its Tauri command (spec 40 §3.5/§3.6) and then RELOADS the +//! open detail view so the hero + state reflect the change (Tauri's +//! "-> reload"): +//! +//! - **Rename** (`v2_rename_mixtape_collection`): trim; empty -> no-op. +//! - **Description** (`v2_set_mixtape_description`): empty -> NULL (clear). +//! - **Play-mode toggle** (`v2_set_mixtape_play_mode`): in_order <-> album_shuffle. +//! - **Convert kind** (`v2_set_mixtape_kind`): mixtape <-> collection; the repo +//! REJECTS any artist_collection conversion -> the "Cannot convert this kind" +//! toast. Success -> "Converted". +//! - **Delete** (`v2_delete_mixtape_collection`, CASCADE): navigate BACK (which +//! re-applies the previous grid entry, dropping the deleted row) on success; +//! "Failed to delete" toast on error. +//! +//! All DB work runs synchronously inside `with_db` on a `spawn_blocking` worker +//! (no `&Connection` crosses an `.await`); the reload + toast hop back to the +//! event loop. + +use qbz_models::mixtape::{CollectionKind, CollectionPlayMode}; +use slint::ComponentHandle; + +use crate::artwork::ImageCache; +use crate::{AppWindow, MyQbzEditState, NavState}; + +// ──────────────────────────── DB write helpers ──────────────────────── + +/// Run a repo mutation that returns `rusqlite::Result<()>` against the per-user +/// library.db. Returns `Ok(())` on success, `Err(message)` on any failure +/// (DB unavailable or the repo error). Synchronous (`with_db`). +fn with_repo(f: F) -> Result<(), String> +where + F: FnOnce(&rusqlite::Connection) -> rusqlite::Result<()> + Send, +{ + match crate::library_db::with_db(|db| Ok(db.with_connection(f))) { + Some(Ok(())) => Ok(()), + Some(Err(e)) => Err(e.to_string()), + None => Err("library database unavailable".to_string()), + } +} + +// ──────────────────────────── reload after mutation ─────────────────── + +/// Reload the open detail view for `id` (Tauri's "-> reload") so the hero + +/// toolbar reflect the mutation. Re-runs the detail navigator's +/// load/apply/artwork path; the inner `set_view` is harmless (already there). +fn reload( + weak: &slint::Weak, + handle: &tokio::runtime::Handle, + image_cache: &ImageCache, + id: String, +) { + let handle = handle.clone(); + let image_cache = image_cache.clone(); + let _ = weak.upgrade_in_event_loop(move |w| { + crate::myqbz_detail::navigate(w.as_weak(), handle.clone(), image_cache.clone(), id); + }); +} + +// ──────────────────────────── public entry points ───────────────────── + +/// Rename modal submit: trim the draft; empty -> close without writing. Else +/// `repo::rename_collection` -> reload -> close. Caps at 80 chars (Tauri +/// ``). +pub fn rename( + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: ImageCache, + id: String, + raw_name: String, +) { + let name: String = raw_name.trim().chars().take(80).collect(); + if id.is_empty() || name.is_empty() { + close_modal(&weak); + return; + } + set_busy(&weak, true); + handle.clone().spawn(async move { + let write_id = id.clone(); + let write_name = name.clone(); + let result = tokio::task::spawn_blocking(move || { + with_repo(|conn| qbz_mixtape::repo::rename_collection(conn, &write_id, &write_name)) + }) + .await + .unwrap_or_else(|e| Err(format!("rename task panicked: {e}"))); + + finish(&weak, &handle, &image_cache, id, result, None, "Failed to rename"); + }); +} + +/// Description modal submit: trimmed empty -> NULL (clear). Else set it. +/// `repo::set_description` -> reload -> close. +pub fn set_description( + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: ImageCache, + id: String, + raw_description: String, +) { + if id.is_empty() { + close_modal(&weak); + return; + } + let trimmed = raw_description.trim().to_string(); + let desc: Option = if trimmed.is_empty() { None } else { Some(trimmed) }; + set_busy(&weak, true); + handle.clone().spawn(async move { + let write_id = id.clone(); + let write_desc = desc.clone(); + let result = tokio::task::spawn_blocking(move || { + with_repo(|conn| { + qbz_mixtape::repo::set_description(conn, &write_id, write_desc.as_deref()) + }) + }) + .await + .unwrap_or_else(|e| Err(format!("description task panicked: {e}"))); + + finish(&weak, &handle, &image_cache, id, result, None, "Failed to save description"); + }); +} + +/// Hero overflow play-mode toggle: flip in_order <-> album_shuffle. Reads the +/// current mode from `MyQbzDetailState.play_mode`, persists the OTHER, reloads. +pub fn toggle_play_mode( + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: ImageCache, + id: String, + current_mode: String, +) { + if id.is_empty() { + return; + } + let next = if current_mode == "in_order" { + CollectionPlayMode::AlbumShuffle + } else { + CollectionPlayMode::InOrder + }; + handle.clone().spawn(async move { + let write_id = id.clone(); + let result = tokio::task::spawn_blocking(move || { + with_repo(|conn| qbz_mixtape::repo::set_play_mode(conn, &write_id, next)) + }) + .await + .unwrap_or_else(|e| Err(format!("play-mode task panicked: {e}"))); + + finish(&weak, &handle, &image_cache, id, result, None, "Failed to change play mode"); + }); +} + +/// Hero overflow convert-kind: flip mixtape <-> collection. The repo rejects +/// any artist_collection conversion -> "Cannot convert this kind"; success -> +/// "Converted". Reads the current kind from `MyQbzDetailState.kind`. +pub fn convert_kind( + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: ImageCache, + id: String, + current_kind: String, +) { + if id.is_empty() { + return; + } + // artist_collection is non-convertible (the menu item is hidden for it, but + // guard here too). + let next = match current_kind.as_str() { + "mixtape" => CollectionKind::Collection, + "collection" => CollectionKind::Mixtape, + _ => { + crate::toast::error_weak(&weak, "Cannot convert this kind"); + return; + } + }; + handle.clone().spawn(async move { + let write_id = id.clone(); + let result = tokio::task::spawn_blocking(move || { + with_repo(|conn| qbz_mixtape::repo::set_kind(conn, &write_id, next)) + }) + .await + .unwrap_or_else(|e| Err(format!("convert-kind task panicked: {e}"))); + + match result { + Ok(()) => { + crate::toast::success_weak(&weak, "Converted"); + reload(&weak, &handle, &image_cache, id); + } + Err(_) => { + // The repo's only rejection here is the artist_collection guard. + crate::toast::error_weak(&weak, "Cannot convert this kind"); + } + } + }); +} + +/// Delete-confirm: `repo::delete_collection` (CASCADE) -> navigate BACK (which +/// re-applies the previous grid entry, so the deleted row is gone) -> close. +/// "Failed to delete" toast on error. +pub fn delete( + weak: slint::Weak, + handle: tokio::runtime::Handle, + id: String, +) { + if id.is_empty() { + close_modal(&weak); + return; + } + set_busy(&weak, true); + handle.spawn(async move { + let write_id = id.clone(); + let result = tokio::task::spawn_blocking(move || { + with_repo(|conn| qbz_mixtape::repo::delete_collection(conn, &write_id)) + }) + .await + .unwrap_or_else(|e| Err(format!("delete task panicked: {e}"))); + + let _ = weak.upgrade_in_event_loop(move |w| { + let es = w.global::(); + es.set_busy(false); + match result { + Ok(()) => { + es.set_open(false); + es.set_mode("".into()); + // Navigate back: re-applies the previous grid entry, which + // re-lists collections from the DB (the deleted one is gone). + w.global::().invoke_request_back(); + } + Err(e) => { + log::warn!("[qbz-slint] myqbz_edit delete failed: {e}"); + crate::toast::error(&w, "Failed to delete"); + } + } + }); + }); +} + +// ──────────────────────────── modal-state helpers ───────────────────── + +/// On a successful mutation: close the modal (if any), reload, and (when given) +/// toast a success message. On failure: clear busy + toast `err_msg`. +fn finish( + weak: &slint::Weak, + handle: &tokio::runtime::Handle, + image_cache: &ImageCache, + id: String, + result: Result<(), String>, + success_toast: Option<&'static str>, + err_msg: &'static str, +) { + match result { + Ok(()) => { + if let Some(msg) = success_toast { + crate::toast::success_weak(weak, msg); + } + close_modal(weak); + reload(weak, handle, image_cache, id); + } + Err(e) => { + log::warn!("[qbz-slint] myqbz_edit mutation failed: {e}"); + set_busy(weak, false); + crate::toast::error_weak(weak, err_msg); + } + } +} + +/// Close the edit modal (clears mode + busy). UI thread hop. +fn close_modal(weak: &slint::Weak) { + let _ = weak.upgrade_in_event_loop(|w| { + let es = w.global::(); + es.set_open(false); + es.set_mode("".into()); + es.set_busy(false); + }); +} + +/// Set the modal busy flag (disables submit) from any thread. +fn set_busy(weak: &slint::Weak, busy: bool) { + let _ = weak.upgrade_in_event_loop(move |w| { + w.global::().set_busy(busy); + }); +} diff --git a/crates/qbz-slint/src/toast.rs b/crates/qbz-slint/src/toast.rs index 2947c23e6..f33c32503 100644 --- a/crates/qbz-slint/src/toast.rs +++ b/crates/qbz-slint/src/toast.rs @@ -108,3 +108,8 @@ pub fn error_weak(weak: &Weak, message: impl Into) { pub fn success_weak(weak: &Weak, message: impl Into) { show_weak(weak, message, ToastKind::Success); } + +/// Info toast from any thread. +pub fn info_weak(weak: &Weak, message: impl Into) { + show_weak(weak, message, ToastKind::Info); +} diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index 5a9d22208..a5fc88611 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint"; import { Toast } from "primitives/Toast.slint"; // Re-export the state globals so the Rust layer can populate them. -export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint"; +export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint"; // Which top-level screen is shown. The app starts on `splash` while it // restores a saved session, then resolves to `shell` or `login`. diff --git a/crates/qbz-slint/ui/assets/icons/image.svg b/crates/qbz-slint/ui/assets/icons/image.svg new file mode 100644 index 000000000..135ea2223 --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/image.svg @@ -0,0 +1 @@ + diff --git a/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint b/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint index 85e29639b..62ec75275 100644 --- a/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint +++ b/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint @@ -47,8 +47,10 @@ component ActionCircle inherits Rectangle { height: root.diameter; border-radius: root.diameter / 2; opacity: root.enabled ? 1.0 : 0.4; - background: ac-ta.has-hover ? Theme.surface-hover : transparent; - border-width: 1px; + background: root.primary + ? (ac-ta.has-hover ? #ffffffd6 : #ffffff) + : (ac-ta.has-hover ? Theme.surface-hover : transparent); + border-width: root.primary ? 0px : 1px; border-color: ac-ta.has-hover ? Theme.text-disabled : Theme.border-subtle; QbzIcon { source: root.icon; @@ -56,7 +58,7 @@ component ActionCircle inherits Rectangle { height: root.glyph; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; - tint: ac-ta.has-hover ? Theme.text-primary : Theme.text-secondary; + tint: root.primary ? #000000 : (ac-ta.has-hover ? Theme.text-primary : Theme.text-secondary); } ac-ta := TouchArea { mouse-cursor: root.enabled ? pointer : default; @@ -862,10 +864,15 @@ export component MixtapeDetailView inherits Rectangle { font-size: 13px; } Rectangle { height: 4px; } - // Action row (play / shuffle / dj-mix / edit / delete / sync). + // Hero action row (spec 12 §3.6): EXACTLY four controls from + // the shared circular-icon-button family — Play (primary), + // Shuffle, DJ-mix, and the Overflow (⋯) menu trigger. Edit / + // delete / cover / convert / play-mode now live in the ⋯ menu + // (spec §10), NOT as flat circles. HorizontalLayout { spacing: 8px; alignment: start; + // (1) Play — primary. ActionCircle { icon: @image-url("../assets/icons/play-fill.svg"); primary: true; @@ -874,6 +881,7 @@ export component MixtapeDetailView inherits Rectangle { enabled: MyQbzDetailState.item-count > 0; clicked => { MyQbzDetailActions.play-all(); } } + // (2) Shuffle. VerticalLayout { alignment: center; ActionCircle { @@ -884,43 +892,95 @@ export component MixtapeDetailView inherits Rectangle { clicked => { MyQbzDetailActions.shuffle(); } } } + // (3) DJ-mix (DjIcon) — STUB click (modal is a later slice). VerticalLayout { alignment: center; ActionCircle { - icon: @image-url("../assets/icons/cassette-tape.svg"); + icon: @image-url("../assets/icons/turntable.svg"); diameter: 40px; glyph: 18px; enabled: MyQbzDetailState.item-count > 0; clicked => { MyQbzDetailActions.dj-mix(); } } } + // (4) Overflow (⋯) — toggles the hero overflow menu. VerticalLayout { alignment: center; - ActionCircle { - icon: @image-url("../assets/icons/pen-line.svg"); - diameter: 40px; - glyph: 18px; - clicked => { MyQbzDetailActions.edit(); } - } - } - VerticalLayout { - alignment: center; - ActionCircle { - icon: @image-url("../assets/icons/trash-2.svg"); - diameter: 40px; - glyph: 18px; - clicked => { MyQbzDetailActions.delete(); } - } - } - // Sync — artist collections only (parity with Tauri's - // discography sync surface; stub here). - if MyQbzDetailState.kind == "artist_collection": VerticalLayout { - alignment: center; - ActionCircle { - icon: @image-url("../assets/icons/refresh-cw.svg"); - diameter: 40px; - glyph: 18px; - clicked => { MyQbzDetailActions.sync(); } + overflow-anchor := Rectangle { + width: 40px; + height: 40px; + ActionCircle { + icon: @image-url("../assets/icons/ellipsis.svg"); + diameter: 40px; + glyph: 18px; + clicked => { overflow-menu.show(); } + } + overflow-menu := PopupWindow { + x: 0; + y: 44px; + width: 220px; + Rectangle { + background: Theme.surface-main; + border-width: 1px; + border-color: Theme.surface-elevated; + border-radius: 8px; + drop-shadow-blur: 24px; + drop-shadow-color: #00000066; + VerticalLayout { + padding: 4px; + spacing: 1px; + // 1 — Rename. + RowMenuItem { + icon: @image-url("../assets/icons/pen-line.svg"); + label: @tr("Rename"); + chosen => { MyQbzDetailActions.open-rename(); } + } + // 2 — Edit description. + RowMenuItem { + icon: @image-url("../assets/icons/pen-line.svg"); + label: @tr("Edit description"); + chosen => { MyQbzDetailActions.open-description(); } + } + // 3 — Set custom cover. + RowMenuItem { + icon: @image-url("../assets/icons/image.svg"); + label: @tr("Upload cover"); + chosen => { MyQbzDetailActions.upload-cover(); } + } + // 4 — Remove custom cover (only when set). + if MyQbzDetailState.has-custom-cover: RowMenuItem { + icon: @image-url("../assets/icons/x.svg"); + label: @tr("Clear custom cover"); + chosen => { MyQbzDetailActions.remove-cover(); } + } + Rectangle { height: 1px; background: Theme.surface-elevated; } + // 5 — Play-mode toggle (label = the OTHER mode). + RowMenuItem { + icon: @image-url("../assets/icons/shuffle.svg"); + label: MyQbzDetailState.play-mode == "in_order" + ? @tr("Album shuffle") + : @tr("In order"); + chosen => { MyQbzDetailActions.toggle-play-mode(); } + } + // 6 — Convert kind (hidden for artist collections). + if MyQbzDetailState.kind != "artist_collection": RowMenuItem { + icon: @image-url("../assets/icons/cassette-tape.svg"); + label: MyQbzDetailState.kind == "mixtape" + ? @tr("Convert to Collection") + : @tr("Convert to Mixtape"); + chosen => { MyQbzDetailActions.convert-kind(); } + } + Rectangle { height: 1px; background: Theme.surface-elevated; } + // 7 — Delete (destructive). + RowMenuItem { + icon: @image-url("../assets/icons/trash-2.svg"); + label: @tr("Delete"); + destructive: true; + chosen => { MyQbzDetailActions.open-delete(); } + } + } + } + } } } } diff --git a/crates/qbz-slint/ui/myqbz/MyQbzEditModal.slint b/crates/qbz-slint/ui/myqbz/MyQbzEditModal.slint new file mode 100644 index 000000000..48b7f3cd4 --- /dev/null +++ b/crates/qbz-slint/ui/myqbz/MyQbzEditModal.slint @@ -0,0 +1,167 @@ +// My QBZ — Rename / Description / Delete-confirm modals (spec 12 §11). +// +// Driven from the hero overflow (⋯) menu. A single global (MyQbzEditState) +// backs all three; `mode` ("rename" | "description" | "delete") selects which +// body renders. Chrome mirrors EditPlaylistModal / CreateMyQbzModal: a 420px +// panel, h2 title (18px bold), the m-input field family, and an m-footer with +// secondary Cancel + a primary/destructive submit. Backdrop click + Cancel +// close without committing; Enter submits the rename. Submit is disabled while +// busy or (rename) the trimmed name is blank. +// +// Per spec §23 the Cancel / Save button labels are HARDCODED in the Tauri +// source; we keep them @tr here (DQ2 — translate hardcoded UI strings on port). + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; +import { MyQbzEditState, MyQbzEditActions } from "../state.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { LineEdit, TextEdit } from "std-widgets.slint"; + +export component MyQbzEditModal inherits Rectangle { + visible: MyQbzEditState.open; + + property is-rename: MyQbzEditState.mode == "rename"; + property is-description: MyQbzEditState.mode == "description"; + property is-delete: MyQbzEditState.mode == "delete"; + // Rename submit needs a non-blank trimmed name; description/delete always + // allowed (description "" clears to null). Trim check approximated by + // comparing against a stripped copy is not available in Slint, so we gate + // on a non-empty raw string for rename (the Rust side trims again). + property can-submit: + MyQbzEditState.busy ? false + : (root.is-rename ? (MyQbzEditState.draft-name != "") : true); + + if MyQbzEditState.open: Rectangle { + background: #000000bf; + TouchArea { + mouse-cursor: default; + clicked => { + MyQbzEditActions.close(); + } + } + Rectangle { + width: Math.min(root.width - 80px, 420px); + height: panel.preferred-height; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + border-radius: Radius.md; + background: Theme.surface-card; + border-width: 1px; + border-color: Theme.border-subtle; + drop-shadow-blur: 32px; + drop-shadow-color: #00000080; + // Swallow clicks on the panel itself (so they don't hit the + // backdrop's close TouchArea below). + TouchArea { } + + panel := VerticalLayout { + padding: 24px; + spacing: 16px; + + // --- Title (h2) ------------------------------------------ + Text { + text: root.is-rename + ? @tr("Rename") + : (root.is-description ? @tr("Edit description") : @tr("Delete")); + color: Theme.text-primary; + font-size: 18px; + font-weight: Typography.bold; + } + + // --- Body: rename (name input) --------------------------- + if root.is-rename: rename-input := LineEdit { + text <=> MyQbzEditState.draft-name; + accepted => { + if (root.can-submit) { + MyQbzEditActions.submit-rename(); + } + } + } + + // --- Body: description (textarea) ------------------------ + if root.is-description: desc-input := TextEdit { + height: 96px; + wrap: word-wrap; + text <=> MyQbzEditState.draft-description; + } + + // --- Body: delete-confirm (prompt) ----------------------- + if root.is-delete: Text { + // Delete "{name}"? This cannot be undone. — interpolated + // with the collection name (spec §11.3 deleteConfirm, double quotes). + text: @tr("Delete \"{}\"? This cannot be undone.", MyQbzEditState.name); + color: Theme.text-secondary; + font-size: 14px; + wrap: word-wrap; + } + + // --- Footer (m-footer) ----------------------------------- + HorizontalLayout { + alignment: end; + spacing: 8px; + + // Cancel (secondary). + Rectangle { + width: cancel-label.preferred-width + 32px; + height: 38px; + border-radius: Radius.sm; + background: cancel-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; + border-width: 1px; + border-color: Theme.border-subtle; + cancel-label := Text { + text: @tr("Cancel"); + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.semibold; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + cancel-ta := TouchArea { + mouse-cursor: MyQbzEditState.busy ? default : pointer; + clicked => { + if (!MyQbzEditState.busy) { + MyQbzEditActions.close(); + } + } + } + } + + // Submit — primary (rename/description) or destructive + // (delete). + Rectangle { + width: submit-label.preferred-width + 40px; + height: 38px; + border-radius: Radius.sm; + opacity: root.can-submit ? 1.0 : 0.5; + background: root.is-delete + ? (submit-ta.has-hover ? #e57373d9 : #e57373) + : (submit-ta.has-hover ? #ffffffd6 : Theme.accent); + submit-label := Text { + text: root.is-delete ? @tr("Delete") : @tr("Save"); + color: #ffffff; + font-size: Typography.body; + font-weight: Typography.semibold; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + submit-ta := TouchArea { + mouse-cursor: root.can-submit ? pointer : default; + clicked => { + if (root.can-submit) { + if (root.is-rename) { + MyQbzEditActions.submit-rename(); + } else if (root.is-description) { + MyQbzEditActions.submit-description(); + } else { + MyQbzEditActions.confirm-delete(); + } + } + } + } + } + } + } + } + } +} diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index 2b5d2a34b..dec0d467b 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -21,6 +21,7 @@ import { CreatePlaylistModal } from "../primitives/CreatePlaylistModal.slint"; import { EditPlaylistModal } from "../primitives/EditPlaylistModal.slint"; import { CreateFolderModal } from "../primitives/CreateFolderModal.slint"; import { CreateMyQbzModal } from "../myqbz/CreateMyQbzModal.slint"; +import { MyQbzEditModal } from "../myqbz/MyQbzEditModal.slint"; import { EphemeralPlayChoiceModal } from "../primitives/EphemeralPlayChoiceModal.slint"; import { FolderEditModal } from "../primitives/FolderEditModal.slint"; import { LibFolderEditModal } from "../settings/LibFolderEditModal.slint"; @@ -385,6 +386,9 @@ export component AppShell inherits Rectangle { // Global "Create Mixtape / Collection" modal (My QBZ "+ New"). CreateMyQbzModal { } + // Global My QBZ detail edit modals (rename / description / delete-confirm). + MyQbzEditModal { } + // Ephemeral "already playing" add-vs-replace choice. EphemeralPlayChoiceModal { } diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index ce1e389b0..8a744696e 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -1603,14 +1603,20 @@ export global MyQbzDetailActions { callback open-item(string /* source */, string /* item-type */, string /* source-item-id */); // Open an item's artist page by name (qobuz items only). callback open-artist(string /* source */, string /* artist-name */); - // --- READ-ONLY STUBS (logged; not yet wired) -------------------------- - // Hero CTAs. + // --- Hero CTAs -------------------------------------------------------- callback play-all(); callback shuffle(); - callback dj-mix(); - callback edit(); // overflow: rename / description / cover / convert - callback delete(); - callback sync(); + callback dj-mix(); // STUB (the DJ-mix modal is a later slice). + callback sync(); // STUB / no-op (no discography sync impl, spec §8). + // --- Hero overflow (⋯) menu (spec 12 §10) ----------------------------- + // Each opens the matching modal or fires the matching mutation + reload. + callback open-rename(); // -> Rename modal (prefilled with name). + callback open-description(); // -> Description modal (prefilled). + callback upload-cover(); // native file picker -> resize -> set + reload. + callback remove-cover(); // clear custom cover -> reload. + callback toggle-play-mode(); // in_order <-> album_shuffle -> reload. + callback convert-kind(); // mixtape <-> collection -> reload + toast. + callback open-delete(); // -> Delete-confirm modal. // Per-row affordances. callback play-item(string /* source-item-id */); callback item-action(string /* source-item-id */, string /* action */); @@ -1618,6 +1624,31 @@ export global MyQbzDetailActions { callback toggle-item-select(int /* position */); } +// ──────────────────────────── MyQBZ edit modals ─────────────────────── +// The Rename / Description / Delete-confirm modals (spec 12 §11), driven +// from the hero overflow (⋯) menu. One global backs all three (only one is +// ever open at a time). `busy` disables submit while the mutation runs. +export global MyQbzEditState { + // "" = closed; "rename" | "description" | "delete". + in-out property mode: ""; + in property open: false; + // The collection name (delete-confirm body interpolation + rename prefill). + in property name; + // Rename: prefilled with the collection name; submit trims. + in-out property draft-name; + // Description: prefilled with the current description ("" clears to null). + in-out property draft-description; + // Disables submit while a mutation + reload is in flight. + in-out property busy: false; +} + +export global MyQbzEditActions { + callback close(); + callback submit-rename(); // trim; rename_collection -> reload -> close. + callback submit-description(); // empty -> null; set_description -> reload. + callback confirm-delete(); // delete_collection -> nav back + cleanup. +} + // Folder editor modal — create + edit (icon preset, solid color, custom // image, hidden toggle, delete). Ports Tauri's FolderEditModal. Gradient // color presets are intentionally omitted (Slint brush limitation).