diff --git a/crates/qbz-slint/Cargo.toml b/crates/qbz-slint/Cargo.toml index fd46446a3..943f887c4 100644 --- a/crates/qbz-slint/Cargo.toml +++ b/crates/qbz-slint/Cargo.toml @@ -38,6 +38,7 @@ dirs = "5" qbz-app = { path = "../qbz-app" } qbz-audio = { path = "../qbz-audio" } qbz-core = { path = "../qbz-core" } +qbz-library = { path = "../qbz-library" } qbz-models = { path = "../qbz-models" } qbz-integrations = { path = "../qbz-integrations" } rusqlite = { version = "0.31", features = ["bundled"] } diff --git a/crates/qbz-slint/src/library_db.rs b/crates/qbz-slint/src/library_db.rs new file mode 100644 index 000000000..90c4c6fd3 --- /dev/null +++ b/crates/qbz-slint/src/library_db.rs @@ -0,0 +1,83 @@ +//! Local library database access — playlist folders + playlist +//! settings (folder membership, custom artwork, hidden/favorite, +//! ordering). These are local-only, stored in the SAME per-user +//! SQLite file the Tauri app uses so the data is shared: +//! +//! /qbz/users//library.db +//! +//! `LibraryDatabase` holds a non-Send `rusqlite::Connection`, so each +//! operation opens it fresh on a blocking thread (the qbz-radio +//! pattern). Helpers here are synchronous; async callers wrap them in +//! `tokio::task::spawn_blocking`. + +use std::path::PathBuf; +use std::sync::{LazyLock, Mutex}; + +use qbz_library::{LibraryDatabase, LibraryError}; + +/// The active user id, set on shell entry. The per-user library.db +/// path depends on it. +static CURRENT_USER_ID: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +pub fn set_user(user_id: u64) { + if let Ok(mut guard) = CURRENT_USER_ID.lock() { + *guard = Some(user_id); + } +} + +fn user_id() -> Option { + CURRENT_USER_ID.lock().ok().and_then(|g| *g) +} + +/// `/qbz/users//library.db` — matches the Tauri +/// per-user path so the local organization data is shared. +fn db_path() -> Option { + let uid = user_id()?; + Some( + dirs::data_dir()? + .join("qbz") + .join("users") + .join(uid.to_string()) + .join("library.db"), + ) +} + +/// Open the per-user library database, creating the directory if +/// needed. Returns None when there is no active user or the open +/// fails (logged). +fn open() -> Option { + let path = db_path()?; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + match LibraryDatabase::open(&path) { + Ok(db) => Some(db), + Err(e) => { + log::error!("[qbz-slint] open library.db failed: {e}"); + None + } + } +} + +/// Run `f` against the per-user library database on the current +/// (blocking) thread. Returns None if the DB can't be opened; logs and +/// returns None on a database error. +pub fn with_db(f: F) -> Option +where + F: FnOnce(&LibraryDatabase) -> Result, +{ + let db = open()?; + match f(&db) { + Ok(r) => Some(r), + Err(e) => { + log::error!("[qbz-slint] library.db op failed: {e}"); + None + } + } +} + +/// Resolve the artwork-cache directory used for copied custom images, +/// matching Tauri (`/qbz/artwork`). +pub fn artwork_cache_dir() -> Option { + Some(dirs::cache_dir()?.join("qbz").join("artwork")) +} diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index d766a7e68..a236123c8 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -35,6 +35,7 @@ mod play_history; mod strip_html; mod playback; mod queue; +mod library_db; mod playlist; mod playlist_picker; mod recently; @@ -68,6 +69,10 @@ async fn enter_shell( settings_ctx: Arc, session: auth::SessionInfo, ) { + // Bind the local library DB to this user (folders / playlist + // settings live in the per-user library.db). + library_db::set_user(session.user_id); + let _ = weak.upgrade_in_event_loop(move |w| { let state = w.global::(); state.set_user_name(session.display_name.into()); @@ -1604,15 +1609,71 @@ fn main() -> Result<(), Box> { } } } + ("playlist", "set-artwork") => { + // Pick an image, copy it into the artwork cache, store + // it as the playlist's custom cover, then reload. + if let Some(w) = weak.upgrade() { + let pid = w.global::().get_id().to_string(); + if let Ok(pid) = pid.parse::() { + let runtime = runtime.clone(); + let weak = weak.clone(); + let handle = handle.clone(); + let image_cache = image_cache.clone(); + handle.clone().spawn(async move { + let Some(file) = rfd::AsyncFileDialog::new() + .add_filter("Images", &["png", "jpg", "jpeg", "webp"]) + .pick_file() + .await + else { + return; + }; + let src = file.path().to_string_lossy().into_owned(); + let ok = tokio::task::spawn_blocking(move || { + playlist::set_custom_artwork(pid, &src).is_some() + }) + .await + .unwrap_or(false); + if ok { + navigate_playlist( + runtime, weak, &handle, image_cache, pid.to_string(), + ); + } + }); + } + } + } + ("playlist", "clear-artwork") => { + if let Some(w) = weak.upgrade() { + let pid = w.global::().get_id().to_string(); + if let Ok(pid) = pid.parse::() { + let runtime = runtime.clone(); + let weak = weak.clone(); + let handle = handle.clone(); + let image_cache = image_cache.clone(); + handle.clone().spawn(async move { + tokio::task::spawn_blocking(move || { + playlist::clear_custom_artwork(pid); + }) + .await + .ok(); + navigate_playlist( + runtime, weak, &handle, image_cache, pid.to_string(), + ); + }); + } + } + } ("playlist", "edit") => { // Open the edit modal, prefilled from the open playlist. if let Some(w) = weak.upgrade() { let ps = w.global::(); let pid = ps.get_id(); let name = ps.get_name(); + let desc = ps.get_description(); let es = w.global::(); es.set_id(pid); es.set_name(name); + es.set_description(desc); es.set_open(true); } } @@ -2443,6 +2504,7 @@ fn main() -> Result<(), Box> { let Some(w) = weak.upgrade() else { return; }; let es = w.global::(); let name = es.get_name().to_string(); + let description = es.get_description().to_string(); let id = es.get_id().to_string(); if name.trim().is_empty() || es.get_busy() { return; @@ -2452,19 +2514,26 @@ fn main() -> Result<(), Box> { let weak = weak.clone(); let handle = handle.clone(); handle.clone().spawn(async move { - match runtime.core().update_playlist(pid, Some(name.trim()), None, None).await { + let desc_opt = Some(description.trim()); + match runtime + .core() + .update_playlist(pid, Some(name.trim()), desc_opt, None) + .await + { Ok(_) => { let nm = name.trim().to_string(); + let ds = description.trim().to_string(); let r2 = runtime.clone(); let w2 = weak.clone(); let h2 = handle.clone(); let _ = weak.upgrade_in_event_loop(move |w| { w.global::().set_name(nm.into()); + w.global::().set_description(ds.into()); w.global::().set_open(false); load_sidebar_playlists(r2, w2, &h2); }); } - Err(e) => log::error!("[qbz-slint] rename playlist failed: {e}"), + Err(e) => log::error!("[qbz-slint] update playlist failed: {e}"), } }); }); diff --git a/crates/qbz-slint/src/playlist.rs b/crates/qbz-slint/src/playlist.rs index 647da4d79..19c5b5f35 100644 --- a/crates/qbz-slint/src/playlist.rs +++ b/crates/qbz-slint/src/playlist.rs @@ -26,6 +26,9 @@ pub struct PlaylistData { pub owner: String, pub description: String, pub cover_url: String, + /// Local custom artwork path (from playlist_settings), if the user + /// set one — overrides the collage / server image. + pub custom_artwork_path: Option, pub tracks: Vec, } @@ -54,6 +57,16 @@ where .and_then(|a| a.image.best().cloned()) }) .unwrap_or_default(); + // Local custom artwork (shared with the Tauri app via library.db). + let custom_artwork_path = tokio::task::spawn_blocking(move || { + crate::library_db::with_db(|db| db.get_playlist_settings(playlist_id)) + .flatten() + .and_then(|s| s.custom_artwork_path) + .filter(|p| !p.is_empty()) + }) + .await + .ok() + .flatten(); Some(PlaylistData { id: pl.id.to_string(), name: pl.name, @@ -63,6 +76,7 @@ where .map(|d| crate::strip_html::strip_html(&d)) .unwrap_or_default(), cover_url, + custom_artwork_path, tracks, }) } @@ -137,12 +151,26 @@ pub fn apply(window: &AppWindow, data: PlaylistData) { if let Ok(mut cur) = CURRENT.lock() { *cur = data.tracks; } + // Custom artwork overrides the collage / server image. Load the + // local file directly (it lives in the artwork cache on disk). + let custom = data + .custom_artwork_path + .as_ref() + .filter(|p| std::path::Path::new(p).exists()) + .and_then(|p| slint::Image::load_from_path(std::path::Path::new(p)).ok()); let state = window.global::(); state.set_id(data.id.into()); state.set_name(data.name.into()); state.set_owner(data.owner.into()); state.set_description(data.description.into()); - state.set_cover_url(data.cover_url.into()); + if let Some(img) = custom { + state.set_cover(img); + state.set_cover_url(data.custom_artwork_path.clone().unwrap_or_default().into()); + state.set_has_custom(true); + } else { + state.set_cover_url(data.cover_url.into()); + state.set_has_custom(false); + } state.set_tracks(ModelRc::new(VecModel::from(items))); state.set_track_count(count); state.set_total_duration(duration.into()); @@ -166,7 +194,9 @@ pub fn artwork_jobs(data: &PlaylistData) -> Vec { }) }) .collect(); - if !data.cover_url.is_empty() { + // Skip the server-cover job when a local custom artwork is set + // (it's already loaded in apply and cover_url holds a file path). + if data.custom_artwork_path.is_none() && !data.cover_url.is_empty() { jobs.push(ArtworkJob { url: data.cover_url.clone(), target: ArtworkTarget::PlaylistCover, @@ -205,6 +235,37 @@ pub fn index_of(track_id: &str) -> usize { .unwrap_or(0) } +// ==================== Custom artwork ==================== + +/// Copy `src` into the artwork cache and store it as this playlist's +/// custom artwork (shared with Tauri via library.db). Returns the +/// stored path. Blocking — run on a worker thread. +pub fn set_custom_artwork(playlist_id: u64, src: &str) -> Option { + let cache = crate::library_db::artwork_cache_dir()?; + std::fs::create_dir_all(&cache).ok()?; + let ext = std::path::Path::new(src) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("jpg"); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let dest = cache.join(format!("playlist_{playlist_id}_{ts}.{ext}")); + if let Err(e) = std::fs::copy(src, &dest) { + log::error!("[qbz-slint] copy custom artwork failed: {e}"); + return None; + } + let dest_str = dest.to_string_lossy().to_string(); + crate::library_db::with_db(|db| db.update_playlist_artwork(playlist_id, Some(&dest_str)))?; + Some(dest_str) +} + +/// Clear this playlist's custom artwork. Blocking. +pub fn clear_custom_artwork(playlist_id: u64) { + crate::library_db::with_db(|db| db.update_playlist_artwork(playlist_id, None)); +} + // ==================== Multi-select edit mode ==================== use slint::Model; diff --git a/crates/qbz-slint/ui/playlist/PlaylistView.slint b/crates/qbz-slint/ui/playlist/PlaylistView.slint index a24363da0..bfd2f7e85 100644 --- a/crates/qbz-slint/ui/playlist/PlaylistView.slint +++ b/crates/qbz-slint/ui/playlist/PlaylistView.slint @@ -81,20 +81,72 @@ export component PlaylistView inherits Rectangle { HorizontalLayout { spacing: 32px; alignment: start; - // Cover. - Rectangle { + // Cover — custom artwork if set, else a 4-up collage of + // the first tracks' covers (Tauri parity), else the + // server playlist image, else the first track, else a + // placeholder. + cover-box := Rectangle { width: 224px; height: 224px; border-radius: Radius.md; background: Theme.surface-elevated; clip: true; - Image { + + // Custom artwork / single server image. + property show-single: + PlaylistState.has-custom + || (PlaylistState.track-count < 4 && PlaylistState.cover-url != ""); + property show-collage: + !PlaylistState.has-custom && PlaylistState.track-count >= 4; + + if cover-box.show-single: Image { width: 100%; height: 100%; source: PlaylistState.cover; image-fit: cover; } - if PlaylistState.cover-url == "": QbzIcon { + // 2x2 collage from the first four track covers. + if cover-box.show-collage: VerticalLayout { + spacing: 2px; + HorizontalLayout { + spacing: 2px; + Image { + width: 111px; height: 111px; + source: PlaylistState.tracks[0].artwork; + image-fit: cover; + } + Image { + width: 111px; height: 111px; + source: PlaylistState.tracks[1].artwork; + image-fit: cover; + } + } + HorizontalLayout { + spacing: 2px; + Image { + width: 111px; height: 111px; + source: PlaylistState.tracks[2].artwork; + image-fit: cover; + } + Image { + width: 111px; height: 111px; + source: PlaylistState.tracks[3].artwork; + image-fit: cover; + } + } + } + // Single first-track cover when 1-3 tracks and no + // server image. + if !cover-box.show-single && !cover-box.show-collage + && PlaylistState.track-count >= 1: Image { + width: 100%; + height: 100%; + source: PlaylistState.tracks[0].artwork; + image-fit: cover; + } + // Empty placeholder. + if PlaylistState.track-count == 0 && PlaylistState.cover-url == "" + && !PlaylistState.has-custom: QbzIcon { source: @image-url("../assets/icons/list-music.svg"); width: 56px; height: 56px; @@ -102,6 +154,53 @@ export component PlaylistView inherits Rectangle { y: (parent.height - self.height) / 2; tint: Theme.text-muted; } + + // Set custom artwork (owner only) — bottom-right. + if PlaylistState.is-owner: Rectangle { + width: 32px; + height: 32px; + border-radius: 16px; + x: parent.width - self.width - 8px; + y: parent.height - self.height - 8px; + background: set-ta.has-hover ? Theme.accent : #000000aa; + QbzIcon { + source: @image-url("../assets/icons/image-plus.svg"); + width: 16px; + height: 16px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: #ffffff; + } + set-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.media-action("playlist", "", "set-artwork"); + } + } + } + // Clear custom artwork — top-right, only when set. + if PlaylistState.is-owner && PlaylistState.has-custom: Rectangle { + width: 28px; + height: 28px; + border-radius: 14px; + x: parent.width - self.width - 8px; + y: 8px; + background: clr-ta.has-hover ? #ef4444 : #000000aa; + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 14px; + height: 14px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: #ffffff; + } + clr-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.media-action("playlist", "", "clear-artwork"); + } + } + } } // Metadata. VerticalLayout { diff --git a/crates/qbz-slint/ui/primitives/EditPlaylistModal.slint b/crates/qbz-slint/ui/primitives/EditPlaylistModal.slint index 18615d9db..96d380afc 100644 --- a/crates/qbz-slint/ui/primitives/EditPlaylistModal.slint +++ b/crates/qbz-slint/ui/primitives/EditPlaylistModal.slint @@ -6,7 +6,7 @@ import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; import { EditPlaylistState, EditPlaylistActions } from "../state.slint"; import { QbzIcon } from "QbzIcon.slint"; -import { LineEdit } from "std-widgets.slint"; +import { LineEdit, TextEdit } from "std-widgets.slint"; export component EditPlaylistModal inherits Rectangle { visible: EditPlaylistState.open; @@ -71,6 +71,17 @@ export component EditPlaylistModal inherits Rectangle { } } + Text { + text: @tr("Description"); + color: Theme.text-muted; + font-size: Typography.legal; + } + desc-input := TextEdit { + height: 90px; + wrap: word-wrap; + text <=> EditPlaylistState.description; + } + HorizontalLayout { spacing: 10px; // Delete (destructive, left). diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 6008f1325..f9c2b28a2 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -242,6 +242,9 @@ export global PlaylistState { in property total-duration; in property cover-url; in property cover; + // True when a user-set custom artwork is shown (overrides the + // track-cover collage). + in property has-custom: false; in property is-favorite: false; // True when the playlist is one of the user's own (editable). in property is-owner: false; @@ -253,11 +256,12 @@ export global PlaylistState { in property loading: false; } -// "Edit playlist" modal state (rename + delete). +// "Edit playlist" modal state (rename + description + delete). export global EditPlaylistState { in-out property open: false; in property id; in-out property name; + in-out property description; in property busy: false; }