From 698fb2fa7f93022599f1786cff3553e8fb62cecf Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 9 Jun 2026 02:49:44 -0600 Subject: [PATCH] feat(myqbz): Slint My QBZ sidebar nav + read-only Mixtapes/Collections grids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-2 Slice 2 of the MyQBZ port. Wires the mixtape DB + the first visually-reachable surface in Slint (read-only): - Migrations: run qbz_mixtape::schema::run_mixtape_migrations in enter_shell (via library_db with_db -> with_connection), best-effort, on session open — same per-user library.db the Tauri build migrates. - Glue: new src/myqbz.rs mirrors playlist_manager.rs — list_collections via the shared crate (items hydrated), model/sort/filter builders, mosaic artwork jobs, navigate. NavEntry::Mixtapes/Collections + back/forward + scroll-restore scopes. - UI: ui/myqbz/{MixtapesView,CollectionsView,CollectionMosaic,MyQbzShared}.slint, 1:1 with specs 10/11 (208px grid, mosaic custom->2x2/3x3->kind-glyph, toolbar, both empty states, ICU plural). MixtapeCardItem + MyQbzState/MyQbzActions in state.slint; mounted in AppShell. My QBZ sidebar entry routed to the two grids (branded mask-tinted icons), matching the port's existing flyout-nav idiom. Read-only boundary: create/open-detail are logging stubs; titlebar My QBZ dropdown and the Settings>Appearance rename (DQ3) are deferred to later slices. Backend crates untouched. cargo check -p qbz-slint green. cc --- crates/Cargo.lock | 1 + crates/qbz-slint/Cargo.toml | 1 + crates/qbz-slint/src/artwork.rs | 24 + crates/qbz-slint/src/main.rs | 192 ++++++++ crates/qbz-slint/src/myqbz.rs | 441 ++++++++++++++++++ crates/qbz-slint/src/nav.rs | 7 + crates/qbz-slint/ui/app.slint | 2 +- crates/qbz-slint/ui/assets/icons/cassette.svg | 2 + .../qbz-slint/ui/assets/icons/collection.svg | 30 ++ crates/qbz-slint/ui/assets/icons/my-qbz.svg | 28 ++ .../qbz-slint/ui/assets/icons/rotate-ccw.svg | 4 + .../qbz-slint/ui/myqbz/CollectionMosaic.slint | 121 +++++ .../qbz-slint/ui/myqbz/CollectionsView.slint | 387 +++++++++++++++ crates/qbz-slint/ui/myqbz/MixtapesView.slint | 321 +++++++++++++ crates/qbz-slint/ui/myqbz/MyQbzShared.slint | 346 ++++++++++++++ crates/qbz-slint/ui/shell/AppShell.slint | 4 + crates/qbz-slint/ui/shell/Sidebar.slint | 6 +- crates/qbz-slint/ui/state.slint | 84 +++- 18 files changed, 1996 insertions(+), 5 deletions(-) create mode 100644 crates/qbz-slint/src/myqbz.rs create mode 100644 crates/qbz-slint/ui/assets/icons/cassette.svg create mode 100644 crates/qbz-slint/ui/assets/icons/collection.svg create mode 100644 crates/qbz-slint/ui/assets/icons/my-qbz.svg create mode 100644 crates/qbz-slint/ui/assets/icons/rotate-ccw.svg create mode 100644 crates/qbz-slint/ui/myqbz/CollectionMosaic.slint create mode 100644 crates/qbz-slint/ui/myqbz/CollectionsView.slint create mode 100644 crates/qbz-slint/ui/myqbz/MixtapesView.slint create mode 100644 crates/qbz-slint/ui/myqbz/MyQbzShared.slint diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 26b220de7..951f4fbbf 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -5487,6 +5487,7 @@ dependencies = [ "qbz-integrations", "qbz-library", "qbz-media-controls", + "qbz-mixtape", "qbz-models", "qbz-offline-cache", "qbz-player", diff --git a/crates/qbz-slint/Cargo.toml b/crates/qbz-slint/Cargo.toml index 58692c3cd..63d85ee4e 100644 --- a/crates/qbz-slint/Cargo.toml +++ b/crates/qbz-slint/Cargo.toml @@ -55,6 +55,7 @@ qbz-library = { path = "../qbz-library" } qbz-plex = { path = "../qbz-plex" } qbz-offline-cache = { path = "../qbz-offline-cache" } qbz-models = { path = "../qbz-models" } +qbz-mixtape = { path = "../qbz-mixtape" } qbz-qobuz = { path = "../qbz-qobuz" } qbz-integrations = { path = "../qbz-integrations" } rusqlite = { version = "0.31", features = ["bundled"] } diff --git a/crates/qbz-slint/src/artwork.rs b/crates/qbz-slint/src/artwork.rs index 8ba18271d..829608894 100644 --- a/crates/qbz-slint/src/artwork.rs +++ b/crates/qbz-slint/src/artwork.rs @@ -141,6 +141,13 @@ pub enum ArtworkTarget { /// One collage cover slot (0-3) of a tree row's playlist /// (`PlaylistManagerState.tree[index].playlist`). PmTreeCover { index: usize, slot: usize }, + /// One mosaic cover slot (0-8) of a My QBZ Mixtapes-grid card + /// (`MyQbzState.mixtapes[index]`). Up to 9 slots (3x3 Collections); + /// mixtapes use only 0-3. + MyQbzMixtapeCover { index: usize, slot: usize }, + /// One mosaic cover slot (0-8) of a My QBZ Collections-grid card + /// (`MyQbzState.collections[index]`). + MyQbzCollectionCover { index: usize, slot: usize }, } impl ArtworkTarget { @@ -159,6 +166,9 @@ impl ArtworkTarget { ArtworkTarget::SidebarPlaylistCover { .. } => 48, // Playlist Manager collage tiles render at ~70-140px. ArtworkTarget::PmPlaylistCover { .. } | ArtworkTarget::PmTreeCover { .. } => 160, + // My QBZ mosaic tiles render at ~60-92px (184/2 or 184/3 grid). + ArtworkTarget::MyQbzMixtapeCover { .. } + | ArtworkTarget::MyQbzCollectionCover { .. } => 160, _ => DECODE_SIZE, } } @@ -894,5 +904,19 @@ fn apply_artwork( model.set_row_data(index, row); } } + ArtworkTarget::MyQbzMixtapeCover { index, slot } => { + let model = window.global::().get_mixtapes(); + if let Some(mut item) = model.row_data(index) { + crate::myqbz::set_mosaic_cover(&mut item, slot, image); + model.set_row_data(index, item); + } + } + ArtworkTarget::MyQbzCollectionCover { index, slot } => { + let model = window.global::().get_collections(); + if let Some(mut item) = model.row_data(index) { + crate::myqbz::set_mosaic_cover(&mut item, slot, image); + model.set_row_data(index, item); + } + } } } diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index dda2932e4..3ea51255a 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -35,6 +35,7 @@ mod label; mod location_view; mod mix; mod musician; +mod myqbz; mod nav; mod play_history; mod strip_html; @@ -101,6 +102,18 @@ async fn enter_shell( // settings live in the per-user library.db). library_db::set_user(session.user_id); + // Run the Mixtapes & Collections schema migrations against the same + // per-user library.db (the mixtape tables live in that file). Mirrors + // the Tauri build's session_lifecycle.rs `run_mixtape_migrations`. + // Best-effort: log on error, never block shell entry. + library_db::with_db(|db| { + Ok(db.with_connection(|conn| { + if let Err(e) = qbz_mixtape::schema::run_mixtape_migrations(conn) { + log::error!("[qbz-slint] mixtape migrations failed: {e}"); + } + })) + }); + // Bind tray settings to this user (per-user tray_settings.db, shared with // the Tauri build) and snapshot them to seed the settings UI. tray_settings::init_for_user(session.user_id); @@ -716,6 +729,8 @@ fn scope_for(entry: &nav::NavEntry) -> String { nav::NavEntry::Playlist(_) => "playlist".into(), nav::NavEntry::PlaylistManager => "playlist-manager".into(), nav::NavEntry::OfflineManager => "offline-manager".into(), + nav::NavEntry::Mixtapes => "mixtapes".into(), + nav::NavEntry::Collections => "collections".into(), nav::NavEntry::Album(_) => "album".into(), nav::NavEntry::LocalAlbum(_) => "local-album".into(), nav::NavEntry::Artist(_) => "artist".into(), @@ -861,6 +876,22 @@ fn apply_entry( }); offline_manager::load(w2, handle.clone()); } + nav::NavEntry::Mixtapes => { + myqbz::navigate( + weak.clone(), + handle.clone(), + image_cache.clone(), + qbz_models::mixtape::CollectionKind::Mixtape, + ); + } + nav::NavEntry::Collections => { + myqbz::navigate( + weak.clone(), + handle.clone(), + image_cache.clone(), + qbz_models::mixtape::CollectionKind::Collection, + ); + } nav::NavEntry::Location { mbid, area_id, @@ -1814,6 +1845,137 @@ fn wire_playlist_manager( } } +/// Wire the My QBZ (Mixtapes & Collections) index grids. READ-ONLY slice: +/// `open-card` / `create-*` are logging STUBS; the toolbar callbacks +/// (search / sort / view / kind-filter / reset) drive `crate::myqbz` rebuilds +/// + re-issue mosaic artwork jobs. Mirrors `wire_playlist_manager`. +fn wire_myqbz(window: &AppWindow, image_cache: &artwork::ImageCache) { + use myqbz::Grid; + + // Re-issue mosaic artwork jobs for a grid after a toolbar rebuild (the + // row set / order changed, so visible cards need their covers reloaded). + fn refresh_covers(window: &AppWindow, grid: Grid, image_cache: &artwork::ImageCache) { + let jobs = myqbz::artwork_jobs(window, grid); + artwork::spawn_loads(jobs, window.as_weak(), image_cache.clone()); + } + + // --- STUBS (read-only boundary) ------------------------------------- + { + window.global::().on_open_card(move |id| { + log::info!("[qbz-slint] myqbz open-card({id}) — detail not yet wired (read-only slice)"); + }); + } + { + window.global::().on_create_mixtape(move || { + log::info!("[qbz-slint] myqbz create-mixtape — not yet wired (read-only slice)"); + }); + } + { + window.global::().on_create_collection(move || { + log::info!("[qbz-slint] myqbz create-collection — not yet wired (read-only slice)"); + }); + } + + // --- Mixtapes toolbar ----------------------------------------------- + { + let weak = window.as_weak(); + let image_cache = image_cache.clone(); + window + .global::() + .on_mix_search_changed(move |query| { + if let Some(w) = weak.upgrade() { + w.global::().set_mix_search(query); + myqbz::rebuild(&w, Grid::Mixtapes); + refresh_covers(&w, Grid::Mixtapes, &image_cache); + } + }); + } + { + let weak = window.as_weak(); + let image_cache = image_cache.clone(); + window.global::().on_mix_set_sort(move |field| { + if let Some(w) = weak.upgrade() { + myqbz::set_sort(&w, Grid::Mixtapes, field.as_str()); + refresh_covers(&w, Grid::Mixtapes, &image_cache); + } + }); + } + { + let weak = window.as_weak(); + window.global::().on_mix_set_view(move |view| { + if let Some(w) = weak.upgrade() { + w.global::().set_mix_view(view); + } + }); + } + { + let weak = window.as_weak(); + let image_cache = image_cache.clone(); + window.global::().on_mix_reset(move || { + if let Some(w) = weak.upgrade() { + myqbz::reset(&w, Grid::Mixtapes); + refresh_covers(&w, Grid::Mixtapes, &image_cache); + } + }); + } + + // --- Collections toolbar -------------------------------------------- + { + let weak = window.as_weak(); + let image_cache = image_cache.clone(); + window + .global::() + .on_col_search_changed(move |query| { + if let Some(w) = weak.upgrade() { + w.global::().set_col_search(query); + myqbz::rebuild(&w, Grid::Collections); + refresh_covers(&w, Grid::Collections, &image_cache); + } + }); + } + { + let weak = window.as_weak(); + let image_cache = image_cache.clone(); + window.global::().on_col_set_sort(move |field| { + if let Some(w) = weak.upgrade() { + myqbz::set_sort(&w, Grid::Collections, field.as_str()); + refresh_covers(&w, Grid::Collections, &image_cache); + } + }); + } + { + let weak = window.as_weak(); + window.global::().on_col_set_view(move |view| { + if let Some(w) = weak.upgrade() { + w.global::().set_col_view(view); + } + }); + } + { + let weak = window.as_weak(); + let image_cache = image_cache.clone(); + window + .global::() + .on_col_set_kind_filter(move |kind| { + if let Some(w) = weak.upgrade() { + w.global::().set_col_kind_filter(kind); + myqbz::rebuild(&w, Grid::Collections); + refresh_covers(&w, Grid::Collections, &image_cache); + } + }); + } + { + let weak = window.as_weak(); + let image_cache = image_cache.clone(); + window.global::().on_col_reset(move || { + if let Some(w) = weak.upgrade() { + myqbz::reset(&w, Grid::Collections); + refresh_covers(&w, Grid::Collections, &image_cache); + } + }); + } +} + /// Open a MusicianPageView for `name + role`. Routes to the artist /// page instead when the resolved musician has a Confirmed Qobuz /// match (Tauri's `confidence === 'confirmed'` shortcut). Fetches @@ -4445,6 +4607,35 @@ fn main() -> Result<(), Box> { } return; } + // My QBZ — Mixtapes / Collections index grids (read-only slice). + // Record history + navigate (loads via myqbz::navigate), mirroring + // the Favorites / Local Library per-route pattern. + if route == "myqbz-mixtapes" { + nav::record(nav::NavEntry::Mixtapes); + if let Some(w) = weak.upgrade() { + update_nav_flags(&w); + } + myqbz::navigate( + weak.clone(), + handle.clone(), + image_cache.clone(), + qbz_models::mixtape::CollectionKind::Mixtape, + ); + return; + } + if route == "myqbz-collections" { + nav::record(nav::NavEntry::Collections); + if let Some(w) = weak.upgrade() { + update_nav_flags(&w); + } + myqbz::navigate( + weak.clone(), + handle.clone(), + image_cache.clone(), + qbz_models::mixtape::CollectionKind::Collection, + ); + return; + } // Discover tabs — switch to Home and select the tab. The // section sets are already cached from the initial load, // so this just swaps the visible set + re-fires artwork. @@ -6266,6 +6457,7 @@ fn main() -> Result<(), Box> { // === Playlist Manager actions ====================================== wire_playlist_manager(&window, &app_runtime, &tokio_rt, &image_cache); + wire_myqbz(&window, &image_cache); { let weak = window.as_weak(); window diff --git a/crates/qbz-slint/src/myqbz.rs b/crates/qbz-slint/src/myqbz.rs new file mode 100644 index 000000000..2069b25da --- /dev/null +++ b/crates/qbz-slint/src/myqbz.rs @@ -0,0 +1,441 @@ +//! My QBZ controller — the Mixtapes & Collections index grids (read-only in +//! this slice). Mirrors `crate::playlist_manager`: it loads `MixtapeCollection` +//! rows from the per-user `library.db` via `qbz_mixtape::repo::list_collections` +//! (called through `crate::library_db::with_db` + `with_connection`), precomputes +//! every display string (eyebrow label, "N albums" ICU plural, pre-downscaled +//! mosaic cover URLs) and pushes ready-to-render `MixtapeCardItem`s into +//! `MyQbzState`. The views do NO per-row lookups. +//! +//! READ-ONLY SCOPE (Phase-2 Slice 2): create-new + open-detail are wired as +//! logging STUBS (`open_card` / `create_*`). The sidebar nav routes here and +//! loads real data; that is the testable path for this slice. +//! +//! The backend (`qbz-mixtape`) is reused directly — no Tauri command wrappers +//! (ADR-005), headless (ADR-006). The repo hydrates each collection's items so +//! counts + mosaic artwork are accurate (`repo.rs` `list_collections`). + +use std::sync::{LazyLock, Mutex}; + +use qbz_models::mixtape::{CollectionKind, MixtapeCollection}; +use slint::{ComponentHandle, Model, ModelRc, VecModel}; + +use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache}; +use crate::{AppWindow, ContentView, MixtapeCardItem, MyQbzState, NavState}; + +/// Last-loaded data per kind-group (so toolbar changes rebuild from cache, +/// no DB refetch). Mirrors `playlist_manager::CACHE`. +static MIXTAPES_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); +static COLLECTIONS_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + +/// Which grid a navigation targets. +#[derive(Clone, Copy, PartialEq)] +pub enum Grid { + Mixtapes, + Collections, +} + +// ──────────────────────────── DB read path ──────────────────────────── + +/// List collections of the given kind filter from the per-user library.db. +/// `None` returns all kinds. Items come hydrated (the repo loads them) so +/// counts + mosaic artwork are accurate. Returns an empty Vec when the DB is +/// unavailable (logged by `with_db`). +pub fn list_collections(kind: Option) -> Vec { + crate::library_db::with_db(|db| { + Ok(db.with_connection(|conn| { + qbz_mixtape::repo::list_collections(conn, kind).unwrap_or_else(|e| { + log::warn!("[qbz-slint] myqbz list_collections failed: {e}"); + Vec::new() + }) + })) + }) + .unwrap_or_default() +} + +// ──────────────────────────── string helpers ────────────────────────── + +fn kind_str(kind: CollectionKind) -> &'static str { + match kind { + CollectionKind::Mixtape => "mixtape", + CollectionKind::Collection => "collection", + CollectionKind::ArtistCollection => "artist_collection", + } +} + +/// Eyebrow label, uppercase (Tauri `labelFor` / `mixtapes.label`). +fn label_for(kind: CollectionKind) -> &'static str { + match kind { + CollectionKind::Mixtape => "MIXTAPE", + CollectionKind::Collection => "COLLECTION", + CollectionKind::ArtistCollection => "ARTIST", + } +} + +/// `mixtapes.albumCount` ICU plural — "1 album" / "N albums". Always "album(s)" +/// regardless of item_type (1:1 with the PSD). +fn album_count_label(count: usize) -> String { + if count == 1 { + "1 album".to_string() + } else { + format!("{count} albums") + } +} + +/// Pre-downscale a Qobuz cover URL to a per-cell target size, mirroring the +/// mosaic's `smallQobuzUrl` (regex-swap `_.jpg` → `_.jpg`). Used +/// before handing URLs to the image loader so we never pull 600px covers for +/// ~60-92px cells. Non-Qobuz URLs (local/plex) pass through unchanged. +fn small_qobuz_url(url: &str, target: u32) -> String { + if url.is_empty() { + return String::new(); + } + // Lowercase scan for the size token; rewrite in place keeping original case + // of the rest. Old tokens: 50|100|150|230|300|600|max|org. + const TOKENS: [&str; 8] = ["_50.jpg", "_100.jpg", "_150.jpg", "_230.jpg", "_300.jpg", "_600.jpg", "_max.jpg", "_org.jpg"]; + let lower = url.to_lowercase(); + for tok in TOKENS { + if let Some(pos) = lower.rfind(tok) { + let mut out = String::with_capacity(url.len()); + out.push_str(&url[..pos]); + out.push_str(&format!("_{target}.jpg")); + out.push_str(&url[pos + tok.len()..]); + return out; + } + } + url.to_string() +} + +/// Per-cell target size given the mosaic `size` and column count +/// (`cellPx = round(size/cols)`; `<=80 → 50`, `<=200 → 150`, else 300). The +/// grid card mosaic is 184px (2x2 → cell 92 → 150; 3x3 → cell ~61 → 50). +fn cell_target(size: u32, cols: u32) -> u32 { + let cell_px = ((size as f32) / (cols as f32)).round() as u32; + if cell_px <= 80 { + 50 + } else if cell_px <= 200 { + 150 + } else { + 300 + } +} + +// ──────────────────────────── model builders ────────────────────────── + +/// Build one ready-to-render card. Decides cover-count (0 / 4 / 9) per the +/// 2x2-vs-3x3 rule, and pre-downscales the up-to-9 cover URLs per cell. +fn card_item(c: &MixtapeCollection) -> MixtapeCardItem { + let item_count = c.items.len(); + let has_custom = c.custom_artwork_path.is_some(); + + // cols: 3x3 only for a Collection with >= 9 items; else 2x2. + let cols: u32 = if c.kind == CollectionKind::Collection && item_count >= 9 { + 3 + } else { + 2 + }; + let cell_count = (cols * cols) as usize; + // cover-count is the number of mosaic cells actually used (0 when empty or + // when a custom cover full-bleeds; the view checks has-custom-cover first). + let cover_count = if has_custom || item_count == 0 { + 0 + } else { + cell_count + }; + + // Grid-card mosaic renders at 184px; size the downscale to that. + let target = cell_target(184, cols); + + // Up-to-9 cell URLs: the first `cell_count` items' artwork, padded "". + let url = |i: usize| -> slint::SharedString { + if has_custom || item_count == 0 || i >= cell_count { + return slint::SharedString::default(); + } + match c.items.get(i).and_then(|it| it.artwork_url.as_deref()) { + Some(u) if !u.is_empty() => small_qobuz_url(u, target).into(), + _ => slint::SharedString::default(), + } + }; + + let custom_image = slint::Image::default(); + + MixtapeCardItem { + id: c.id.clone().into(), + name: c.name.clone().into(), + kind: kind_str(c.kind).into(), + label: label_for(c.kind).into(), + meta: album_count_label(item_count).into(), + item_count: item_count as i32, + play_count: c.play_count, + updated_at: c.updated_at as i32, + custom_cover: custom_image, + has_custom_cover: has_custom, + cover_count: cover_count as i32, + url1: url(0), + url2: url(1), + url3: url(2), + url4: url(3), + url5: url(4), + url6: url(5), + url7: url(6), + url8: url(7), + url9: url(8), + cover1: slint::Image::default(), + cover2: slint::Image::default(), + cover3: slint::Image::default(), + cover4: slint::Image::default(), + cover5: slint::Image::default(), + cover6: slint::Image::default(), + cover7: slint::Image::default(), + cover8: slint::Image::default(), + cover9: slint::Image::default(), + } +} + +/// Set a decoded mosaic cover onto a card item by slot (0-8). Called from the +/// artwork apply arm. +pub fn set_mosaic_cover(item: &mut MixtapeCardItem, slot: usize, image: slint::Image) { + match slot { + 0 => item.cover1 = image, + 1 => item.cover2 = image, + 2 => item.cover3 = image, + 3 => item.cover4 = image, + 4 => item.cover5 = image, + 5 => item.cover6 = image, + 6 => item.cover7 = image, + 7 => item.cover8 = image, + 8 => item.cover9 = image, + _ => {} + } +} + +// ──────────────────────────── sort / filter ─────────────────────────── + +/// Sort a collection list by the active toolbar sort (mirrors `visibleX`): +/// name (locale-ish), items (count), updated (updated_at), position (default). +/// `dir` = "asc"/"desc". +fn sort_collections(list: &mut [MixtapeCollection], sort: &str, dir: &str) { + let desc = dir == "desc"; + match sort { + "name" => list.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())), + "items" => list.sort_by(|a, b| a.items.len().cmp(&b.items.len())), + "updated" => list.sort_by(|a, b| a.updated_at.cmp(&b.updated_at)), + // default "position" + _ => list.sort_by(|a, b| a.position.cmp(&b.position)), + } + if desc { + list.reverse(); + } +} + +/// Whether a collection passes the search filter (name OR description, +/// case-insensitive substring). Empty query = pass. +fn passes_search(c: &MixtapeCollection, query: &str) -> bool { + if query.is_empty() { + return true; + } + if c.name.to_lowercase().contains(query) { + return true; + } + c.description + .as_deref() + .map(|d| d.to_lowercase().contains(query)) + .unwrap_or(false) +} + +// ──────────────────────────── apply / rebuild ───────────────────────── + +pub fn set_loading(window: &AppWindow, loading: bool) { + window.global::().set_loading(loading); +} + +/// Store freshly-loaded rows for `grid` and render them through the active +/// toolbar state. +pub fn apply(window: &AppWindow, grid: Grid, rows: Vec) { + match grid { + Grid::Mixtapes => { + if let Ok(mut c) = MIXTAPES_CACHE.lock() { + *c = rows; + } + } + Grid::Collections => { + if let Ok(mut c) = COLLECTIONS_CACHE.lock() { + *c = rows; + } + } + } + rebuild(window, grid); +} + +/// Rebuild the visible card model for one grid from its cache, honoring the +/// active toolbar state (search / sort / kind-filter). UI thread only. +pub fn rebuild(window: &AppWindow, grid: Grid) { + let state = window.global::(); + match grid { + Grid::Mixtapes => { + let data = MIXTAPES_CACHE.lock().map(|c| c.clone()).unwrap_or_default(); + let query = state.get_mix_search().trim().to_lowercase(); + let sort = state.get_mix_sort().to_string(); + let dir = state.get_mix_sort_dir().to_string(); + let mut filtered: Vec = + data.into_iter().filter(|c| passes_search(c, &query)).collect(); + sort_collections(&mut filtered, &sort, &dir); + let items: Vec = filtered.iter().map(card_item).collect(); + state.set_mixtapes(ModelRc::new(VecModel::from(items))); + } + Grid::Collections => { + let data = COLLECTIONS_CACHE.lock().map(|c| c.clone()).unwrap_or_default(); + let query = state.get_col_search().trim().to_lowercase(); + let sort = state.get_col_sort().to_string(); + let dir = state.get_col_sort_dir().to_string(); + let kind_filter = state.get_col_kind_filter().to_string(); + let mut filtered: Vec = data + .into_iter() + .filter(|c| match kind_filter.as_str() { + "collection" => c.kind == CollectionKind::Collection, + "artist_collection" => c.kind == CollectionKind::ArtistCollection, + _ => true, + }) + .filter(|c| passes_search(c, &query)) + .collect(); + sort_collections(&mut filtered, &sort, &dir); + let items: Vec = filtered.iter().map(card_item).collect(); + state.set_collections(ModelRc::new(VecModel::from(items))); + } + } + state.set_loading(false); +} + +/// Re-clicking the active sort field flips direction; a new field resets to +/// asc. Mirrors `selectSort`. +pub fn set_sort(window: &AppWindow, grid: Grid, field: &str) { + let state = window.global::(); + let (cur_sort, cur_dir) = match grid { + Grid::Mixtapes => (state.get_mix_sort().to_string(), state.get_mix_sort_dir().to_string()), + Grid::Collections => (state.get_col_sort().to_string(), state.get_col_sort_dir().to_string()), + }; + let new_dir = if cur_sort == field { + if cur_dir == "asc" { "desc" } else { "asc" } + } else { + "asc" + }; + match grid { + Grid::Mixtapes => { + state.set_mix_sort(field.into()); + state.set_mix_sort_dir(new_dir.into()); + } + Grid::Collections => { + state.set_col_sort(field.into()); + state.set_col_sort_dir(new_dir.into()); + } + } + rebuild(window, grid); +} + +/// Reset toolbar filters/sort (search too, like Tauri's `resetFilters`). +pub fn reset(window: &AppWindow, grid: Grid) { + let state = window.global::(); + match grid { + Grid::Mixtapes => { + state.set_mix_sort("position".into()); + state.set_mix_sort_dir("asc".into()); + state.set_mix_search("".into()); + } + Grid::Collections => { + state.set_col_sort("position".into()); + state.set_col_sort_dir("asc".into()); + state.set_col_kind_filter("all".into()); + state.set_col_search("".into()); + } + } + rebuild(window, grid); +} + +// ──────────────────────────── artwork jobs ──────────────────────────── + +/// Build mosaic-cover artwork jobs for every visible card of `grid`. +pub fn artwork_jobs(window: &AppWindow, grid: Grid) -> Vec { + let state = window.global::(); + let model = match grid { + Grid::Mixtapes => state.get_mixtapes(), + Grid::Collections => state.get_collections(), + }; + let mut jobs = Vec::new(); + for index in 0..model.row_count() { + let Some(card) = model.row_data(index) else { continue }; + let urls = [ + card.url1, card.url2, card.url3, card.url4, card.url5, card.url6, card.url7, + card.url8, card.url9, + ]; + for (slot, url) in urls.iter().enumerate() { + if url.is_empty() { + continue; + } + let target = match grid { + Grid::Mixtapes => ArtworkTarget::MyQbzMixtapeCover { index, slot }, + Grid::Collections => ArtworkTarget::MyQbzCollectionCover { index, slot }, + }; + jobs.push(ArtworkJob { + target, + url: url.to_string(), + }); + } + } + jobs +} + +// ──────────────────────────── navigation ────────────────────────────── + +/// Open a My QBZ grid (Mixtapes or Collections) and load its rows from the +/// per-user library.db on a blocking worker, then render + spawn mosaic +/// artwork. `kind` selects the grid (Mixtape → Mixtapes; Collection → the +/// Collections grid, which displays collection + artist_collection). +pub fn navigate( + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: ImageCache, + kind: CollectionKind, +) { + let grid = match kind { + CollectionKind::Mixtape => Grid::Mixtapes, + _ => Grid::Collections, + }; + let view = match grid { + Grid::Mixtapes => ContentView::Mixtapes, + Grid::Collections => ContentView::Collections, + }; + + handle.clone().spawn(async move { + let _ = weak.upgrade_in_event_loop(move |w| { + set_loading(&w, true); + w.global::().set_view(view); + }); + + // The Mixtapes grid wants kind == mixtape; the Collections grid loads + // ALL kinds and filters locally (collection | artist_collection) so the + // kind-filter dropdown can switch between them without a refetch. + let kind_arg = match grid { + Grid::Mixtapes => Some(CollectionKind::Mixtape), + Grid::Collections => None, + }; + let rows = tokio::task::spawn_blocking(move || list_collections(kind_arg)) + .await + .unwrap_or_default(); + + // For the Collections grid, drop mixtapes (load returned all kinds). + let rows: Vec = match grid { + Grid::Mixtapes => rows, + Grid::Collections => rows + .into_iter() + .filter(|c| c.kind != CollectionKind::Mixtape) + .collect(), + }; + + let _ = weak.upgrade_in_event_loop(move |w| { + apply(&w, grid, rows); + let jobs = artwork_jobs(&w, grid); + artwork::spawn_loads(jobs, w.as_weak(), image_cache.clone()); + }); + }); +} diff --git a/crates/qbz-slint/src/nav.rs b/crates/qbz-slint/src/nav.rs index 401217f00..61dbb9ca0 100644 --- a/crates/qbz-slint/src/nav.rs +++ b/crates/qbz-slint/src/nav.rs @@ -59,6 +59,13 @@ pub enum NavEntry { /// The Offline Cache Manager — the manage-downloads surface (Tauri's /// OfflineCacheManagerView). Session-scoped; no payload. OfflineManager, + /// The My QBZ > Mixtapes index grid (read-only in this slice). Toolbar + /// state (sort/view/search) is session-scoped in the controller, so the + /// entry carries no payload. + Mixtapes, + /// The My QBZ > Collections index grid (read-only in this slice). Same + /// session-scoped toolbar; no payload. + Collections, Album(String), /// A Local Library album detail page (dedicated view, separate from the /// Qobuz Album view); the string is the metadata group key. diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index d0f7894d1..ea10a2400 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, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, 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, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, 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/cassette.svg b/crates/qbz-slint/ui/assets/icons/cassette.svg new file mode 100644 index 000000000..51ad87a15 --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/cassette.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/crates/qbz-slint/ui/assets/icons/collection.svg b/crates/qbz-slint/ui/assets/icons/collection.svg new file mode 100644 index 000000000..5afa7165e --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/collection.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-slint/ui/assets/icons/my-qbz.svg b/crates/qbz-slint/ui/assets/icons/my-qbz.svg new file mode 100644 index 000000000..6a3f26eea --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/my-qbz.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-slint/ui/assets/icons/rotate-ccw.svg b/crates/qbz-slint/ui/assets/icons/rotate-ccw.svg new file mode 100644 index 000000000..17530e155 --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/rotate-ccw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-slint/ui/myqbz/CollectionMosaic.slint b/crates/qbz-slint/ui/myqbz/CollectionMosaic.slint new file mode 100644 index 000000000..6c41c2f71 --- /dev/null +++ b/crates/qbz-slint/ui/myqbz/CollectionMosaic.slint @@ -0,0 +1,121 @@ +// CollectionMosaic — the artwork renderer for Mixtapes & Collections cards, +// list rows and empty states (Tauri's CollectionMosaic.svelte). Ported as a +// new component modeled on PmCollage's layout discipline (clip + radius + +// 1px gaps), but with the MyQBZ-specific rules: +// +// - custom-cover (has-custom-cover) -> single full-bleed cover (precedence) +// - else empty (cover-count == 0) -> centered kind glyph +// mixtape -> cassette, artist_collection -> user, collection -> library-big +// icon size = round(size * 0.4), muted, opacity 0.6 +// - else collage: +// cols = (kind == "collection" && item-count >= 9) ? 3 : 2 +// 2x2 (4 cells) for everything else; cells padded with empty squares +// +// The covers + decoded images live on the MixtapeCardItem (cover-count, +// url1..url9 / cover1..cover9). Rust pre-decides cover-count (0 / 4 / 9) and +// pre-downscales the Qobuz URLs per cell. This component only lays them out. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Radius } from "../foundation/radius.slint"; +import { MixtapeCardItem } from "../state.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; + +// One mosaic cell: the cover when present, else an empty --bg-tertiary square. +// Defined before CollectionMosaic (Slint has no forward references within a +// file). +component MosaicCell inherits Rectangle { + in property url; + in property cover-image; + in property cell; + width: root.cell; + height: root.cell; + background: Theme.surface-elevated; // empty-cell var(--bg-tertiary) + clip: true; + if root.url != "": Image { + width: 100%; + height: 100%; + source: root.cover-image; + image-fit: cover; + } +} + +export component CollectionMosaic inherits Rectangle { + in property item; + in property size: 184px; + // For the empty state the caller passes an explicit kind (e.g. "mixtape" + // / "collection") when the item itself is empty; falls back to item.kind. + in property empty-kind: ""; + + width: root.size; + height: root.size; + border-radius: Radius.sm; // 8px outer radius (Tauri). + clip: true; + background: Theme.surface-elevated; // var(--bg-tertiary) + + property n: root.item.cover-count; + property kind: root.empty-kind != "" ? root.empty-kind : root.item.kind; + // 3x3 only for a Collection with >= 9 items; everything else 2x2. + property grid3: root.kind == "collection" && root.item.item-count >= 9; + property gap: 2px; + + // --- custom cover (precedence) -> single full-bleed image ----------- + if root.item.has-custom-cover: Image { + width: 100%; + height: 100%; + source: root.item.custom-cover; + image-fit: cover; + } + + // --- empty -> kind glyph -------------------------------------------- + if !root.item.has-custom-cover && root.n == 0: QbzIcon { + source: root.kind == "mixtape" + ? @image-url("../assets/icons/cassette-tape.svg") + : root.kind == "artist_collection" + ? @image-url("../assets/icons/user.svg") + : @image-url("../assets/icons/library-big.svg"); + width: Math.round(root.size * 0.4 / 1px) * 1px; + height: Math.round(root.size * 0.4 / 1px) * 1px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: Theme.text-muted; + opacity: 0.6; + } + + // --- collage: 2x2 --------------------------------------------------- + if !root.item.has-custom-cover && root.n > 0 && !root.grid3: VerticalLayout { + spacing: root.gap; + HorizontalLayout { + spacing: root.gap; + MosaicCell { url: root.item.url1; cover-image: root.item.cover1; cell: (root.size - root.gap) / 2; } + MosaicCell { url: root.item.url2; cover-image: root.item.cover2; cell: (root.size - root.gap) / 2; } + } + HorizontalLayout { + spacing: root.gap; + MosaicCell { url: root.item.url3; cover-image: root.item.cover3; cell: (root.size - root.gap) / 2; } + MosaicCell { url: root.item.url4; cover-image: root.item.cover4; cell: (root.size - root.gap) / 2; } + } + } + + // --- collage: 3x3 (collection + >=9 items) -------------------------- + if !root.item.has-custom-cover && root.n > 0 && root.grid3: VerticalLayout { + spacing: root.gap; + HorizontalLayout { + spacing: root.gap; + MosaicCell { url: root.item.url1; cover-image: root.item.cover1; cell: (root.size - 2 * root.gap) / 3; } + MosaicCell { url: root.item.url2; cover-image: root.item.cover2; cell: (root.size - 2 * root.gap) / 3; } + MosaicCell { url: root.item.url3; cover-image: root.item.cover3; cell: (root.size - 2 * root.gap) / 3; } + } + HorizontalLayout { + spacing: root.gap; + MosaicCell { url: root.item.url4; cover-image: root.item.cover4; cell: (root.size - 2 * root.gap) / 3; } + MosaicCell { url: root.item.url5; cover-image: root.item.cover5; cell: (root.size - 2 * root.gap) / 3; } + MosaicCell { url: root.item.url6; cover-image: root.item.cover6; cell: (root.size - 2 * root.gap) / 3; } + } + HorizontalLayout { + spacing: root.gap; + MosaicCell { url: root.item.url7; cover-image: root.item.cover7; cell: (root.size - 2 * root.gap) / 3; } + MosaicCell { url: root.item.url8; cover-image: root.item.cover8; cell: (root.size - 2 * root.gap) / 3; } + MosaicCell { url: root.item.url9; cover-image: root.item.cover9; cell: (root.size - 2 * root.gap) / 3; } + } + } +} diff --git a/crates/qbz-slint/ui/myqbz/CollectionsView.slint b/crates/qbz-slint/ui/myqbz/CollectionsView.slint new file mode 100644 index 000000000..a4f86c3cc --- /dev/null +++ b/crates/qbz-slint/ui/myqbz/CollectionsView.slint @@ -0,0 +1,387 @@ +// My QBZ > Collections — the index grid of the user's Collections + Artist +// Collections (Tauri's CollectionsView.svelte). Same vocabulary as Mixtapes +// plus the kind-filter dropdown (.filter-count badge + 3-item menu). The 3x3 +// mosaic appears only for a `collection` with >= 9 items (handled inside +// CollectionMosaic). READ-ONLY in this slice: create CTA + card click STUB. +// +// Every px / token verbatim from spec 11. Root padding 8 8 100 18. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { MyQbzState, MyQbzActions, MixtapeCardItem, NavState } from "../state.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { ListScrollbar } from "../primitives/ListScrollbar.slint"; +import { CollectionMosaic } from "CollectionMosaic.slint"; +import { + PrimaryCta, MyQbzSearch, ControlBtn, ViewModeGroup, MyQbzCard, MyQbzListRow, +} from "MyQbzShared.slint"; + +// One dropdown row (sort or kind-filter). Sort rows show a trailing arrow; +// filter rows show a trailing Check on the selected kind. +component ColMenuItem inherits Rectangle { + in property label; + in property selected: false; + in property indicator: ""; // sort arrow + in property check: false; // kind-filter check + callback activated(); + height: 32px; + border-radius: 4px; + background: item-ta.has-hover ? Theme.surface-hover : transparent; + HorizontalLayout { + padding-left: 10px; + padding-right: 10px; + spacing: 8px; + Text { + text: root.label; + color: root.selected ? Theme.accent : Theme.text-secondary; + font-size: 13px; + vertical-alignment: center; + horizontal-stretch: 1; + } + if root.indicator != "": Text { + text: root.indicator; + color: Theme.text-muted; + font-size: 11px; + vertical-alignment: center; + } + if root.check: VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/check.svg"); + width: 12px; + height: 12px; + tint: Theme.accent; + } + } + } + item-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.activated(); } + } +} + + +export component CollectionsView inherits Rectangle { + property card-w: 208px; + property grid-gap: 20px; + property card-h: 184px + 12px + 12px + 16px + 2 * 18px + 12px + 16px + 12px; + + property active-sort-label: + MyQbzState.col-sort == "name" ? @tr("Name") + : MyQbzState.col-sort == "items" ? @tr("Items") + : MyQbzState.col-sort == "updated" ? @tr("Recently updated") + : @tr("Position"); + property sort-arrow: MyQbzState.col-sort-dir == "asc" ? "↑" : "↓"; + property has-active-filters: + MyQbzState.col-kind-filter != "all" + || MyQbzState.col-sort != "position" + || MyQbzState.col-sort-dir != "asc"; + + background: Theme.surface-main; + + flick := Flickable { + viewport-width: self.width; + viewport-height: page.preferred-height; + property sr-armed: NavState.restore-scope; + function sr-restore() { + if (NavState.restore-scope == "collections" && self.viewport-height > self.height) { + self.viewport-y = NavState.scroll-restore; + NavState.restore-scope = ""; + } + } + init => { self.sr-restore(); } + changed sr-armed => { self.sr-restore(); } + changed viewport-height => { self.sr-restore(); } + changed viewport-y => { NavState.report-scroll(self.viewport-y); } + + page := VerticalLayout { + width: parent.width; + padding-top: 8px; + padding-right: 8px; + padding-bottom: 100px; + padding-left: 18px; + spacing: 0px; + alignment: start; + + // --- Back button ------------------------------------------- + Rectangle { + height: 16px + 8px + 24px; + back-row := HorizontalLayout { + y: 8px; + height: 16px; + width: self.preferred-width; + spacing: 8px; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/arrow-left.svg"); + width: 16px; + height: 16px; + tint: back-ta.has-hover ? Theme.text-secondary : Theme.text-muted; + } + } + Text { + text: @tr("Back"); + color: back-ta.has-hover ? Theme.text-secondary : Theme.text-muted; + font-size: 14px; + vertical-alignment: center; + } + } + back-ta := TouchArea { + width: back-row.preferred-width; + height: 16px; + y: 8px; + mouse-cursor: pointer; + clicked => { NavState.request-back(); } + } + } + + // --- Header ------------------------------------------------ + HorizontalLayout { + height: 38px; + VerticalLayout { + alignment: center; + horizontal-stretch: 1; + Text { + text: @tr("Collections"); + color: Theme.text-primary; + font-size: 24px; + font-weight: Typography.bold; + vertical-alignment: center; + } + } + VerticalLayout { + alignment: center; + PrimaryCta { + label: @tr("+ New Collection"); + show-plus: true; + clicked => { MyQbzActions.create-collection(); } + } + } + } + Rectangle { height: 16px; } + + // ====================== EMPTY STATE ======================== + if MyQbzState.collections.length == 0 && MyQbzState.col-search == "" && MyQbzState.col-kind-filter == "all" && !MyQbzState.loading: VerticalLayout { + padding-top: 80px; + padding-bottom: 80px; + spacing: 16px; + alignment: start; + HorizontalLayout { + alignment: center; + CollectionMosaic { + size: 160px; + empty-kind: "collection"; + } + } + HorizontalLayout { + alignment: center; + Text { + text: @tr("No collections yet"); + color: Theme.text-secondary; + font-size: 18px; + font-weight: Typography.semibold; + } + } + HorizontalLayout { + alignment: center; + PrimaryCta { + label: @tr("+ New Collection"); + show-plus: false; + clicked => { MyQbzActions.create-collection(); } + } + } + } + + // ====================== POPULATED ========================== + if MyQbzState.collections.length > 0 || MyQbzState.col-search != "" || MyQbzState.col-kind-filter != "all": VerticalLayout { + spacing: 0px; + alignment: start; + + // --- Toolbar ------------------------------------------- + HorizontalLayout { + height: 32px; + spacing: 8px; + MyQbzSearch { + placeholder: @tr("Search collections…"); + query <=> MyQbzState.col-search; + edited(q) => { MyQbzActions.col-search-changed(q); } + } + // Sort dropdown. + Rectangle { + width: sort-btn.width; + sort-btn := ControlBtn { + icon: @image-url("../assets/icons/arrow-up-down.svg"); + label: root.active-sort-label; + indicator: root.sort-arrow; + clicked => { sort-menu.show(); } + } + sort-menu := PopupWindow { + x: 0; y: 36px; width: 180px; + 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; + ColMenuItem { + label: @tr("Position"); + selected: MyQbzState.col-sort == "position"; + indicator: MyQbzState.col-sort == "position" ? root.sort-arrow : ""; + activated => { MyQbzActions.col-set-sort("position"); } + } + ColMenuItem { + label: @tr("Name"); + selected: MyQbzState.col-sort == "name"; + indicator: MyQbzState.col-sort == "name" ? root.sort-arrow : ""; + activated => { MyQbzActions.col-set-sort("name"); } + } + ColMenuItem { + label: @tr("Items"); + selected: MyQbzState.col-sort == "items"; + indicator: MyQbzState.col-sort == "items" ? root.sort-arrow : ""; + activated => { MyQbzActions.col-set-sort("items"); } + } + ColMenuItem { + label: @tr("Recently updated"); + selected: MyQbzState.col-sort == "updated"; + indicator: MyQbzState.col-sort == "updated" ? root.sort-arrow : ""; + activated => { MyQbzActions.col-set-sort("updated"); } + } + } + } + } + } + // Kind-filter dropdown. + Rectangle { + width: filter-btn.width; + filter-btn := ControlBtn { + icon: @image-url("../assets/icons/list-filter.svg"); + label: @tr("Filter"); + active: MyQbzState.col-kind-filter != "all"; + clicked => { filter-menu.show(); } + } + // .filter-count badge (always "1" when a kind is set). + if MyQbzState.col-kind-filter != "all": Rectangle { + x: filter-btn.width - self.width - 6px; + y: 4px; + width: 16px; + height: 16px; + border-radius: 8px; + background: Theme.accent; + Text { + text: "1"; + color: Theme.accent-text; + font-size: 10px; + font-weight: Typography.semibold; + horizontal-alignment: center; + vertical-alignment: center; + } + } + filter-menu := PopupWindow { + x: 0; y: 36px; width: 180px; + 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; + ColMenuItem { + label: @tr("All kinds"); + selected: MyQbzState.col-kind-filter == "all"; + check: MyQbzState.col-kind-filter == "all"; + activated => { MyQbzActions.col-set-kind-filter("all"); } + } + ColMenuItem { + label: @tr("Collections"); + selected: MyQbzState.col-kind-filter == "collection"; + check: MyQbzState.col-kind-filter == "collection"; + activated => { MyQbzActions.col-set-kind-filter("collection"); } + } + ColMenuItem { + label: @tr("Artist Collections"); + selected: MyQbzState.col-kind-filter == "artist_collection"; + check: MyQbzState.col-kind-filter == "artist_collection"; + activated => { MyQbzActions.col-set-kind-filter("artist_collection"); } + } + } + } + } + } + // Reset. + if root.has-active-filters: ControlBtn { + icon: @image-url("../assets/icons/rotate-ccw.svg"); + subtle: true; + clicked => { MyQbzActions.col-reset(); } + } + Rectangle { horizontal-stretch: 1; } + VerticalLayout { + alignment: center; + ViewModeGroup { + view: MyQbzState.col-view; + set-view(v) => { MyQbzActions.col-set-view(v); } + } + } + } + Rectangle { height: 16px; } + + // --- Body: search/filter-empty ------------------------- + if MyQbzState.collections.length == 0: Rectangle { + height: 80px; + Text { + text: @tr("No results found"); + color: Theme.text-muted; + font-size: 13px; + horizontal-alignment: center; + vertical-alignment: center; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + } + + // --- Body: GRID --------------------------------------- + if MyQbzState.collections.length > 0 && MyQbzState.col-view == "grid": grid := Rectangle { + property columns: Math.max(1, + Math.floor((self.width + root.grid-gap) / (root.card-w + root.grid-gap))); + property rows: Math.ceil(MyQbzState.collections.length / self.columns); + height: self.rows > 0 + ? self.rows * root.card-h + Math.max(0, self.rows - 1) * root.grid-gap + : 0px; + for item[i] in MyQbzState.collections: MyQbzCard { + x: Math.mod(i, grid.columns) * (root.card-w + root.grid-gap); + y: Math.floor(i / grid.columns) * (root.card-h + root.grid-gap); + item: item; + clicked => { MyQbzActions.open-card(item.id); } + } + } + + // --- Body: LIST --------------------------------------- + if MyQbzState.collections.length > 0 && MyQbzState.col-view == "list": VerticalLayout { + spacing: 4px; + for item in MyQbzState.collections: MyQbzListRow { + item: item; + clicked => { MyQbzActions.open-card(item.id); } + } + } + } + } + } + + ListScrollbar { + x: parent.width - self.width - 4px; + y: 0px; + width: 14px; + height: parent.height; + viewport-height: flick.viewport-height; + visible-height: flick.height; + viewport-y <=> flick.viewport-y; + } +} diff --git a/crates/qbz-slint/ui/myqbz/MixtapesView.slint b/crates/qbz-slint/ui/myqbz/MixtapesView.slint new file mode 100644 index 000000000..d93ef3993 --- /dev/null +++ b/crates/qbz-slint/ui/myqbz/MixtapesView.slint @@ -0,0 +1,321 @@ +// My QBZ > Mixtapes — the index/landing grid of the user's mixtapes +// (Tauri's MixtapesView.svelte). READ-ONLY in this slice: the grid DISPLAYS +// rows and routes from the sidebar; the create CTA + card click are STUBS +// (MyQbzActions.create-mixtape / open-card just log "not yet wired"). +// +// Every px / token is verbatim from spec 10. Root padding 8 8 100 18 (left +// 18 lines up with sidebar-accessed views; bottom 100 clears the player bar). +// Grid = fixed 208px auto-fill columns, justify-start (cards never stretch). +// Two distinct empty states: zero-mixtapes (big mosaic+title+CTA) vs +// search-matched-none (small "No results found"). + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { MyQbzState, MyQbzActions, MixtapeCardItem, NavState } from "../state.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { ListScrollbar } from "../primitives/ListScrollbar.slint"; +import { CollectionMosaic } from "CollectionMosaic.slint"; +import { + PrimaryCta, MyQbzSearch, ControlBtn, ViewModeGroup, MyQbzCard, MyQbzListRow, +} from "MyQbzShared.slint"; + +// One sort dropdown row (space-between label + trailing arrow on active). +// Defined before MixtapesView (Slint has no forward references in a file). +component SortItem inherits Rectangle { + in property label; + in property selected: false; + in property indicator: ""; + callback activated(); + height: 32px; + border-radius: 4px; + background: item-ta.has-hover ? Theme.surface-hover : transparent; + HorizontalLayout { + padding-left: 10px; + padding-right: 10px; + Text { + text: root.label; + color: root.selected ? Theme.accent : Theme.text-secondary; + font-size: 13px; + vertical-alignment: center; + horizontal-stretch: 1; + } + if root.indicator != "": Text { + text: root.indicator; + color: Theme.text-muted; + font-size: 11px; + vertical-alignment: center; + } + } + item-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.activated(); } + } +} + +export component MixtapesView inherits Rectangle { + // Fixed grid geometry (spec 10: 208px columns, 20px gap, 12px-padded + // card -> 184 mosaic). card-h matches MyQbzCard's computed height. + property card-w: 208px; + property grid-gap: 20px; + property card-h: 184px + 12px + 12px + 16px + 2 * 18px + 12px + 16px + 12px; + + property active-sort-label: + MyQbzState.mix-sort == "name" ? @tr("Name") + : MyQbzState.mix-sort == "items" ? @tr("Items") + : MyQbzState.mix-sort == "updated" ? @tr("Recently updated") + : @tr("Position"); + property sort-arrow: MyQbzState.mix-sort-dir == "asc" ? "↑" : "↓"; + property has-active-filters: + MyQbzState.mix-sort != "position" || MyQbzState.mix-sort-dir != "asc"; + + background: Theme.surface-main; + + flick := Flickable { + viewport-width: self.width; + viewport-height: page.preferred-height; + // Scroll-position restore on back/forward (see nav::scope_for). + property sr-armed: NavState.restore-scope; + function sr-restore() { + if (NavState.restore-scope == "mixtapes" && self.viewport-height > self.height) { + self.viewport-y = NavState.scroll-restore; + NavState.restore-scope = ""; + } + } + init => { self.sr-restore(); } + changed sr-armed => { self.sr-restore(); } + changed viewport-height => { self.sr-restore(); } + changed viewport-y => { NavState.report-scroll(self.viewport-y); } + + page := VerticalLayout { + width: parent.width; + // .mixtapes-view padding: 8 8 100 18. + padding-top: 8px; + padding-right: 8px; + padding-bottom: 100px; + padding-left: 18px; + spacing: 0px; + alignment: start; + + // --- Back button (.back-btn) ------------------------------- + Rectangle { + height: 16px + 8px + 24px; // icon row + top/bottom margins + back-row := HorizontalLayout { + y: 8px; + height: 16px; + width: self.preferred-width; + spacing: 8px; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/arrow-left.svg"); + width: 16px; + height: 16px; + tint: back-ta.has-hover ? Theme.text-secondary : Theme.text-muted; + } + } + Text { + text: @tr("Back"); + color: back-ta.has-hover ? Theme.text-secondary : Theme.text-muted; + font-size: 14px; + vertical-alignment: center; + } + } + back-ta := TouchArea { + width: back-row.preferred-width; + height: 16px; + y: 8px; + mouse-cursor: pointer; + clicked => { NavState.request-back(); } + } + } + + // --- Header (.view-header) --------------------------------- + HorizontalLayout { + height: 38px; + VerticalLayout { + alignment: center; + horizontal-stretch: 1; + Text { + text: @tr("Mixtapes"); + color: Theme.text-primary; + font-size: 24px; + font-weight: Typography.bold; + vertical-alignment: center; + } + } + VerticalLayout { + alignment: center; + PrimaryCta { + label: @tr("+ New Mixtape"); + show-plus: true; + clicked => { MyQbzActions.create-mixtape(); } + } + } + } + Rectangle { height: 16px; } + + // ====================== EMPTY STATE ======================== + if MyQbzState.mixtapes.length == 0 && MyQbzState.mix-search == "" && !MyQbzState.loading: VerticalLayout { + padding-top: 80px; + padding-bottom: 80px; + spacing: 16px; + alignment: start; + HorizontalLayout { + alignment: center; + CollectionMosaic { + size: 160px; + empty-kind: "mixtape"; + } + } + HorizontalLayout { + alignment: center; + Text { + text: @tr("No mixtapes yet"); + color: Theme.text-secondary; + font-size: 18px; + font-weight: Typography.semibold; + } + } + HorizontalLayout { + alignment: center; + PrimaryCta { + label: @tr("+ New Mixtape"); + show-plus: false; + clicked => { MyQbzActions.create-mixtape(); } + } + } + } + + // ====================== POPULATED ========================== + if MyQbzState.mixtapes.length > 0 || MyQbzState.mix-search != "": VerticalLayout { + spacing: 0px; + alignment: start; + + // --- Toolbar (.list-controls) -------------------------- + HorizontalLayout { + height: 32px; + spacing: 8px; + MyQbzSearch { + placeholder: @tr("Search mixtapes…"); + query <=> MyQbzState.mix-search; + edited(q) => { MyQbzActions.mix-search-changed(q); } + } + // Sort dropdown. + Rectangle { + width: sort-btn.width; + sort-btn := ControlBtn { + icon: @image-url("../assets/icons/arrow-up-down.svg"); + label: root.active-sort-label; + indicator: root.sort-arrow; + clicked => { sort-menu.show(); } + } + sort-menu := PopupWindow { + x: 0; y: 36px; width: 180px; + 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; + SortItem { + label: @tr("Position"); + selected: MyQbzState.mix-sort == "position"; + indicator: MyQbzState.mix-sort == "position" ? root.sort-arrow : ""; + activated => { MyQbzActions.mix-set-sort("position"); } + } + SortItem { + label: @tr("Name"); + selected: MyQbzState.mix-sort == "name"; + indicator: MyQbzState.mix-sort == "name" ? root.sort-arrow : ""; + activated => { MyQbzActions.mix-set-sort("name"); } + } + SortItem { + label: @tr("Items"); + selected: MyQbzState.mix-sort == "items"; + indicator: MyQbzState.mix-sort == "items" ? root.sort-arrow : ""; + activated => { MyQbzActions.mix-set-sort("items"); } + } + SortItem { + label: @tr("Recently updated"); + selected: MyQbzState.mix-sort == "updated"; + indicator: MyQbzState.mix-sort == "updated" ? root.sort-arrow : ""; + activated => { MyQbzActions.mix-set-sort("updated"); } + } + } + } + } + } + // Reset (only when filters active). + if root.has-active-filters: ControlBtn { + icon: @image-url("../assets/icons/rotate-ccw.svg"); + subtle: true; + clicked => { MyQbzActions.mix-reset(); } + } + // Spacer pushes the view-mode group flush right (margin-left:auto). + Rectangle { horizontal-stretch: 1; } + VerticalLayout { + alignment: center; + ViewModeGroup { + view: MyQbzState.mix-view; + set-view(v) => { MyQbzActions.mix-set-view(v); } + } + } + } + Rectangle { height: 16px; } + + // --- Body: search-empty ------------------------------- + if MyQbzState.mixtapes.length == 0 && MyQbzState.mix-search != "": Rectangle { + height: 80px; + Text { + text: @tr("No results found"); + color: Theme.text-muted; + font-size: 13px; + horizontal-alignment: center; + vertical-alignment: center; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + } + + // --- Body: GRID --------------------------------------- + if MyQbzState.mixtapes.length > 0 && MyQbzState.mix-view == "grid": grid := Rectangle { + property columns: Math.max(1, + Math.floor((self.width + root.grid-gap) / (root.card-w + root.grid-gap))); + property rows: Math.ceil(MyQbzState.mixtapes.length / self.columns); + height: self.rows > 0 + ? self.rows * root.card-h + Math.max(0, self.rows - 1) * root.grid-gap + : 0px; + for item[i] in MyQbzState.mixtapes: MyQbzCard { + x: Math.mod(i, grid.columns) * (root.card-w + root.grid-gap); + y: Math.floor(i / grid.columns) * (root.card-h + root.grid-gap); + item: item; + clicked => { MyQbzActions.open-card(item.id); } + } + } + + // --- Body: LIST --------------------------------------- + if MyQbzState.mixtapes.length > 0 && MyQbzState.mix-view == "list": VerticalLayout { + spacing: 4px; + for item in MyQbzState.mixtapes: MyQbzListRow { + item: item; + clicked => { MyQbzActions.open-card(item.id); } + } + } + } + } + } + + ListScrollbar { + x: parent.width - self.width - 4px; + y: 0px; + width: 14px; + height: parent.height; + viewport-height: flick.viewport-height; + visible-height: flick.height; + viewport-y <=> flick.viewport-y; + } +} diff --git a/crates/qbz-slint/ui/myqbz/MyQbzShared.slint b/crates/qbz-slint/ui/myqbz/MyQbzShared.slint new file mode 100644 index 000000000..1455955e8 --- /dev/null +++ b/crates/qbz-slint/ui/myqbz/MyQbzShared.slint @@ -0,0 +1,346 @@ +// Shared MyQBZ index widgets — the toolbar controls + grid card + list row, +// reused 1:1 by both MixtapesView and CollectionsView (the Svelte vocabulary +// is intentionally identical between the two index views). Every px / token +// here is verbatim from spec 10/11. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; +import { MixtapeCardItem } from "../state.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { CollectionMosaic } from "CollectionMosaic.slint"; + +// --- Primary CTA ("+ New Mixtape" / "+ New Collection") ----------------- +// Header instance shows a Plus icon AND the label (which already starts with +// "+ " — verbatim, not a bug). Empty-state instance is text-only. +export component PrimaryCta inherits Rectangle { + in property label; + in property show-plus: true; + callback clicked(); + height: 38px; // padding 10px*2 + ~18 line + width: row.preferred-width + 40px; // 20px horizontal padding each side + border-radius: 8px; + background: cta-ta.has-hover ? Theme.accent-hover : Theme.accent; + row := HorizontalLayout { + x: 20px; + height: 100%; + spacing: 8px; + alignment: center; + if root.show-plus: VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/plus.svg"); + width: 16px; + height: 16px; + tint: Theme.accent-text; + } + } + Text { + text: root.label; + color: Theme.accent-text; + font-size: 14px; + font-weight: Typography.semibold; + vertical-alignment: center; + } + } + cta-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +// --- Search box --------------------------------------------------------- +export component MyQbzSearch inherits Rectangle { + in property placeholder; + in-out property query; + callback edited(string); + height: 32px; + min-width: 220px; + border-radius: 6px; + property has-query: root.query != ""; + background: (input.has-focus || root.has-query) ? Theme.surface-main : Theme.surface-card; + border-width: 1px; + border-color: (input.has-focus || root.has-query) ? Theme.accent : Theme.surface-elevated; + HorizontalLayout { + padding-left: 10px; + padding-right: 10px; + spacing: 8px; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/search.svg"); + width: 14px; + height: 14px; + tint: Theme.text-muted; + } + } + Rectangle { + horizontal-stretch: 1; + clip: true; + input := TextInput { + width: 100%; + height: 100%; + vertical-alignment: center; + color: Theme.text-primary; + font-size: 13px; + text <=> root.query; + edited => { root.edited(self.text); } + } + if root.query == "": Text { + text: root.placeholder; + color: Theme.text-muted; + font-size: 13px; + vertical-alignment: center; + } + } + // Clear (X) — only when query non-empty. + if root.has-query: VerticalLayout { + alignment: center; + Rectangle { + width: 18px; + height: 18px; + border-radius: 9px; + background: clear-ta.has-hover ? Theme.surface-elevated : transparent; + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 12px; + height: 12px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: clear-ta.has-hover ? Theme.text-primary : Theme.text-muted; + } + clear-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.query = ""; + root.edited(""); + } + } + } + } + } +} + +// --- Generic control button (sort / filter / reset / view-seg base) ----- +export component ControlBtn inherits Rectangle { + in property icon; + in property label; // "" -> icon-only + in property indicator; // sort arrow "↑"/"↓" or "" + in property active: false; + in property subtle: false; + callback clicked(); + height: 32px; + border-radius: 6px; + width: row.preferred-width + (root.subtle ? 16px : 20px); + background: btn-ta.has-hover ? Theme.surface-hover : Theme.surface-card; + border-width: 1px; + border-color: root.active ? Theme.accent : Theme.surface-elevated; + row := HorizontalLayout { + x: root.subtle ? 8px : 10px; + height: 100%; + spacing: 6px; + alignment: center; + VerticalLayout { + alignment: center; + QbzIcon { + source: root.icon; + width: 14px; + height: 14px; + tint: root.active + ? Theme.accent + : (root.subtle ? Theme.text-muted : Theme.text-primary); + } + } + if root.label != "": Text { + text: root.label; + color: root.active ? Theme.accent : Theme.text-primary; + font-size: 13px; + vertical-alignment: center; + } + if root.indicator != "": Text { + text: root.indicator; + color: Theme.text-muted; + font-size: 11px; + vertical-alignment: center; + } + } + btn-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +// --- Segmented list/grid view toggle ------------------------------------ +// One segment of the toggle; defined before ViewModeGroup (no forward refs). +// Slint Rectangles have no per-side border, so the group draws ONE outer +// border + corner rounds and the segments fill it; a 1px divider sits between +// them (the `.seg` shared-border look from the PSD). +component ViewSeg inherits Rectangle { + in property icon; + in property active: false; + callback clicked(); + height: 30px; // 32px group - 2*1px outer border + width: 31px; + background: root.active + ? Theme.accent + : (seg-ta.has-hover ? Theme.surface-hover : transparent); + QbzIcon { + source: root.icon; + width: 14px; + height: 14px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: root.active ? Theme.accent-text : Theme.text-primary; + } + seg-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +// Two joined buttons (List | Grid) reading as one pill; active fills accent. +export component ViewModeGroup inherits Rectangle { + in property view; // "grid" | "list" + callback set-view(string); + height: 32px; + width: 64px; + border-radius: 6px; + border-width: 1px; + border-color: Theme.surface-elevated; + background: Theme.surface-card; + clip: true; + HorizontalLayout { + spacing: 0px; + ViewSeg { + icon: @image-url("../assets/icons/list.svg"); + active: root.view == "list"; + clicked => { root.set-view("list"); } + } + // 1px divider between the segments. + Rectangle { + width: 1px; + background: Theme.surface-elevated; + } + ViewSeg { + icon: @image-url("../assets/icons/layout-grid.svg"); + active: root.view == "grid"; + clicked => { root.set-view("grid"); } + } + } +} + +// --- Grid card ---------------------------------------------------------- +// 208px column; 12px padding -> 184px mosaic. Eyebrow + 2-line name (reserve +// 2 lines so cards align) + album-count meta. +export component MyQbzCard inherits Rectangle { + in property item; + callback clicked(); + width: 208px; + height: 184px + 12px + 12px + 16px + 2 * 18px + 12px + 16px + 12px; + border-radius: 8px; + background: card-ta.has-hover ? Theme.surface-hover : Theme.surface-card; + border-width: 1px; + border-color: card-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; + VerticalLayout { + padding: 12px; + spacing: 12px; + alignment: start; + CollectionMosaic { + item: root.item; + size: 184px; + } + // card-label (eyebrow) — accent, uppercase, +4px top margin. + Rectangle { height: 4px; } + Text { + text: root.item.label; + color: Theme.accent; + font-size: 10px; + font-weight: Typography.semibold; + letter-spacing: 1.2px; + } + // card-name — 2-line clamp; reserve 2 lines (~36px at 14px/1.3). + Text { + text: root.item.name; + color: Theme.text-primary; + font-size: 14px; + font-weight: Typography.semibold; + wrap: word-wrap; + overflow: elide; + height: 36px; + width: 100%; + } + Text { + text: root.item.meta; + color: Theme.text-muted; + font-size: 12px; + } + } + card-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +// --- List row ----------------------------------------------------------- +// 48px cover + name (single-line ellipsis) + sub (label · count). +export component MyQbzListRow inherits Rectangle { + in property item; + callback clicked(); + height: 64px; + border-radius: 6px; + background: row-ta.has-hover ? Theme.surface-hover : transparent; + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + spacing: 12px; + alignment: start; + VerticalLayout { + alignment: center; + CollectionMosaic { + item: root.item; + size: 48px; + } + } + VerticalLayout { + alignment: center; + spacing: 2px; + horizontal-stretch: 1; + Text { + text: root.item.name; + color: Theme.text-primary; + font-size: 14px; + font-weight: Typography.semibold; + overflow: elide; + width: 100%; + } + HorizontalLayout { + spacing: 8px; + alignment: start; + Text { + text: root.item.label; + color: Theme.accent; + font-size: 10px; + font-weight: Typography.semibold; + letter-spacing: 1.2px; + vertical-alignment: center; + } + Text { + text: "·"; + color: Theme.text-muted; + font-size: 12px; + vertical-alignment: center; + } + Text { + text: root.item.meta; + color: Theme.text-muted; + font-size: 12px; + vertical-alignment: center; + } + } + } + } + row-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index c94ce37be..93ff015ba 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -43,6 +43,8 @@ import { FavoritesView } from "../favorites/FavoritesView.slint"; import { MixView } from "../mix/MixView.slint"; import { PlaylistView } from "../playlist/PlaylistView.slint"; import { PlaylistManagerView } from "../playlist/PlaylistManagerView.slint"; +import { MixtapesView } from "../myqbz/MixtapesView.slint"; +import { CollectionsView } from "../myqbz/CollectionsView.slint"; import { OfflineManagerView } from "../offline/OfflineManagerView.slint"; import { LocalLibraryView } from "../locallibrary/LocalLibraryView.slint"; import { SettingsView } from "../settings/SettingsView.slint"; @@ -229,6 +231,8 @@ export component AppShell inherits Rectangle { } } if NavState.view == ContentView.playlist-manager: PlaylistManagerView { } + if NavState.view == ContentView.mixtapes: MixtapesView { } + if NavState.view == ContentView.collections: CollectionsView { } if NavState.view == ContentView.offline-manager: OfflineManagerView { } if NavState.view == ContentView.local-library: LocalLibraryView { } if NavState.view == ContentView.mix: MixView { diff --git a/crates/qbz-slint/ui/shell/Sidebar.slint b/crates/qbz-slint/ui/shell/Sidebar.slint index c11e4e967..d6d011967 100644 --- a/crates/qbz-slint/ui/shell/Sidebar.slint +++ b/crates/qbz-slint/ui/shell/Sidebar.slint @@ -616,11 +616,11 @@ export component Sidebar inherits Rectangle { } SidebarNavRow { tab-index: 3; - icon: @image-url("../assets/icons/cassette-tape.svg"); + icon: @image-url("../assets/icons/my-qbz.svg"); label: @tr("My QBZ"); items: [ - { label: @tr("Collections"), icon: @image-url("../assets/icons/library-big.svg"), has-glyph: true }, - { label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true }, + { label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette.svg"), has-glyph: true, route: "myqbz-mixtapes" }, + { label: @tr("Collections"), icon: @image-url("../assets/icons/collection.svg"), has-glyph: true, route: "myqbz-collections" }, ]; } } diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 93c3b8def..d94bb7cb8 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -345,7 +345,7 @@ export global DragActions { } // Which content view the shell shows. -export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager, offline-manager, local-library, local-album } +export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager, offline-manager, local-library, local-album, mixtapes, collections } // In-app toast (transient notification). Mirrors Tauri's toastStore: // a single toast at a time (a new one replaces the current), auto-hidden @@ -1385,6 +1385,88 @@ export global OfflineManagerActions { callback play-track(string /* track id */); } +// === My QBZ — Mixtapes & Collections (read-only index grids) ============ +// Mirrors PlaylistManagerState/PmPlaylistItem. Both the Mixtapes grid and the +// Collections grid render `MixtapeCardItem`s; the Rust controller (crate::myqbz) +// filters by kind and precomputes every display string, so the views do no +// per-row lookups. Mosaic covers (up to 4, or 9 for a 3x3 Collection) are +// decoded into the item by the artwork pipeline. +// +// READ-ONLY SCOPE (Phase-2 Slice 2): the grids DISPLAY rows + route from the +// sidebar. Card click (open detail) and "+ New" (create) are STUBS that log +// "not yet wired" — wired in later slices. +export struct MixtapeCardItem { + id: string, + name: string, + // "mixtape" | "collection" | "artist_collection". + kind: string, + // Eyebrow label, uppercase ("MIXTAPE" | "COLLECTION" | "ARTIST"). + label: string, + // "5 albums" / "1 album" — ICU-plural rendered in Rust (always "album(s)" + // regardless of item_type, matching the PSD). + meta: string, + // Item count (drives the 3x3-vs-2x2 mosaic rule + sort). + item-count: int, + play-count: int, + // updated_at epoch ms (sort key). + updated-at: int, + // Custom user-uploaded cover (decoded local file); empty when none. When + // present it full-bleeds the mosaic (precedence over the collage). + custom-cover: image, + has-custom-cover: bool, + // Mosaic cell count actually used: 0 (empty -> kind glyph), or 4 / 9. + cover-count: int, + // Up to 9 collage cover slots (only 0-3 used for 2x2; 0-8 for a 3x3 + // Collection with >=9 items). URLs are pre-downscaled in Rust. + url1: string, url2: string, url3: string, url4: string, + url5: string, url6: string, url7: string, url8: string, url9: string, + cover1: image, cover2: image, cover3: image, cover4: image, + cover5: image, cover6: image, cover7: image, cover8: image, cover9: image, +} + +export global MyQbzState { + // The Mixtapes grid set (kind == mixtape), filtered + sorted. + in property <[MixtapeCardItem]> mixtapes: []; + // The Collections grid set (kind == collection | artist_collection), + // filtered (by kind-filter) + sorted. + in property <[MixtapeCardItem]> collections: []; + in property loading: false; + // --- Mixtapes toolbar (session-scoped) --- + in-out property mix-search; + // "position" | "name" | "items" | "updated". + in-out property mix-sort: "position"; + // "asc" | "desc". + in-out property mix-sort-dir: "asc"; + // "grid" | "list". + in-out property mix-view: "grid"; + // --- Collections toolbar (session-scoped) --- + in-out property col-search; + in-out property col-sort: "position"; + in-out property col-sort-dir: "asc"; + in-out property col-view: "grid"; + // "all" | "collection" | "artist_collection". + in-out property col-kind-filter: "all"; +} + +export global MyQbzActions { + // Card click -> open detail (STUB: logs in this slice). + callback open-card(string /* id */); + // "+ New Mixtape" / "+ New Collection" (STUB: logs in this slice). + callback create-mixtape(); + callback create-collection(); + // --- Mixtapes toolbar --- + callback mix-search-changed(string /* query */); + callback mix-set-sort(string /* field */); + callback mix-set-view(string /* grid|list */); + callback mix-reset(); + // --- Collections toolbar --- + callback col-search-changed(string /* query */); + callback col-set-sort(string /* field */); + callback col-set-view(string /* grid|list */); + callback col-set-kind-filter(string /* all|collection|artist_collection */); + callback col-reset(); +} + // 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).