From ec2a52707fdefb70821a4ed2d46ca4fa7ce4a79d Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 9 Jun 2026 03:43:56 -0600 Subject: [PATCH] feat(myqbz): Slint collection/mixtape detail view (read-only) Phase-2 Slice 3. Opening a grid card navigates to the detail view: - NavEntry::MixtapeDetail(id) + back/forward + scroll-restore; nav-in un-stubs the grid card click. - src/myqbz_detail.rs: get_collection via the shared crate (hydrated items), load/apply/refresh deriving the render model (filter -> search -> sort). - ui/myqbz/MixtapeDetailView.slint: hero (CollectionMosaic/custom cover, title, kind eyebrow, meta), sticky toolbar (search/sort/type+source filter/reset/ select/view-mode), the item list in list + grid modes -- exact 8-col grid (40 1fr 140 80 160 72 60 40), 56px rows, per item_type, source/quality cells. Empty/loading/not-found states. Read-only boundary: hero CTAs + per-row play/menu actions are logging stubs; expanded-mode track expansion, in-detail modals, DJ-mix, bulk bar, and live resolveItems (quality/plex source) are deferred. KNOWN DEVIATION: the hero action row shows flat circles; the spec-3.6 layout (Play/Shuffle/DJ-mix + MoreHorizontal overflow menu) is folded into Slice 7 (its overflow items are the edit actions). Backend crates untouched. cargo check -p qbz-slint green. cc --- crates/qbz-slint/src/artwork.rs | 19 +- crates/qbz-slint/src/main.rs | 225 ++- crates/qbz-slint/src/myqbz.rs | 2 +- crates/qbz-slint/src/myqbz_detail.rs | 592 ++++++++ crates/qbz-slint/src/nav.rs | 6 + crates/qbz-slint/ui/app.slint | 2 +- .../ui/myqbz/MixtapeDetailView.slint | 1308 +++++++++++++++++ crates/qbz-slint/ui/shell/AppShell.slint | 2 + crates/qbz-slint/ui/state.slint | 136 +- 9 files changed, 2284 insertions(+), 8 deletions(-) create mode 100644 crates/qbz-slint/src/myqbz_detail.rs create mode 100644 crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint diff --git a/crates/qbz-slint/src/artwork.rs b/crates/qbz-slint/src/artwork.rs index 829608894..d3addd37d 100644 --- a/crates/qbz-slint/src/artwork.rs +++ b/crates/qbz-slint/src/artwork.rs @@ -148,6 +148,13 @@ pub enum ArtworkTarget { /// One mosaic cover slot (0-8) of a My QBZ Collections-grid card /// (`MyQbzState.collections[index]`). MyQbzCollectionCover { index: usize, slot: usize }, + /// A row thumbnail in the My QBZ collection-detail item list + /// (`MyQbzDetailState.items[index]`). Matched by item position on apply so + /// a later sort/filter keeps the cover (the rendered model is re-derived). + MyQbzDetailRow { position: i32 }, + /// One hero-mosaic cover slot (0-8) of the My QBZ collection-detail view + /// (`MyQbzDetailState.cover{N}`). + MyQbzDetailCover { slot: usize }, } impl ArtworkTarget { @@ -168,7 +175,11 @@ impl ArtworkTarget { ArtworkTarget::PmPlaylistCover { .. } | ArtworkTarget::PmTreeCover { .. } => 160, // My QBZ mosaic tiles render at ~60-92px (184/2 or 184/3 grid). ArtworkTarget::MyQbzMixtapeCover { .. } - | ArtworkTarget::MyQbzCollectionCover { .. } => 160, + | ArtworkTarget::MyQbzCollectionCover { .. } + // Hero mosaic tiles render at ~62-93px (186/2 or 186/3 grid). + | ArtworkTarget::MyQbzDetailCover { .. } => 160, + // Detail list-row thumbnails render at 36px. + ArtworkTarget::MyQbzDetailRow { .. } => 96, _ => DECODE_SIZE, } } @@ -918,5 +929,11 @@ fn apply_artwork( model.set_row_data(index, item); } } + ArtworkTarget::MyQbzDetailRow { position } => { + crate::myqbz_detail::set_row_artwork(window, position, image); + } + ArtworkTarget::MyQbzDetailCover { slot } => { + crate::myqbz_detail::set_hero_cover(window, slot, image); + } } } diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 3ea51255a..033357025 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -36,6 +36,7 @@ mod location_view; mod mix; mod musician; mod myqbz; +mod myqbz_detail; mod nav; mod play_history; mod strip_html; @@ -731,6 +732,7 @@ fn scope_for(entry: &nav::NavEntry) -> String { nav::NavEntry::OfflineManager => "offline-manager".into(), nav::NavEntry::Mixtapes => "mixtapes".into(), nav::NavEntry::Collections => "collections".into(), + nav::NavEntry::MixtapeDetail(_) => "mixtape-detail".into(), nav::NavEntry::Album(_) => "album".into(), nav::NavEntry::LocalAlbum(_) => "local-album".into(), nav::NavEntry::Artist(_) => "artist".into(), @@ -892,6 +894,9 @@ fn apply_entry( qbz_models::mixtape::CollectionKind::Collection, ); } + nav::NavEntry::MixtapeDetail(id) => { + myqbz_detail::navigate(weak.clone(), handle.clone(), image_cache.clone(), id); + } nav::NavEntry::Location { mbid, area_id, @@ -1849,7 +1854,11 @@ fn wire_playlist_manager( /// `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) { +fn wire_myqbz( + window: &AppWindow, + tokio_rt: &tokio::runtime::Runtime, + image_cache: &artwork::ImageCache, +) { use myqbz::Grid; // Re-issue mosaic artwork jobs for a grid after a toolbar rebuild (the @@ -1859,12 +1868,25 @@ fn wire_myqbz(window: &AppWindow, image_cache: &artwork::ImageCache) { artwork::spawn_loads(jobs, window.as_weak(), image_cache.clone()); } - // --- STUBS (read-only boundary) ------------------------------------- + // --- Open a card -> the collection-detail view (Phase-2 Slice 3) ----- + // NAV-IN: record history + navigate (loads via myqbz_detail::navigate), + // mirroring the grid's own nav and the album/playlist detail openers. { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); window.global::().on_open_card(move |id| { - log::info!("[qbz-slint] myqbz open-card({id}) — detail not yet wired (read-only slice)"); + nav::record(nav::NavEntry::MixtapeDetail(id.to_string())); + myqbz_detail::navigate( + weak.clone(), + handle.clone(), + image_cache.clone(), + id.to_string(), + ); }); } + + // --- Create CTAs: STUBS (read-only boundary) ------------------------ { window.global::().on_create_mixtape(move || { log::info!("[qbz-slint] myqbz create-mixtape — not yet wired (read-only slice)"); @@ -1976,6 +1998,200 @@ fn wire_myqbz(window: &AppWindow, image_cache: &artwork::ImageCache) { } } +/// Wire the My QBZ collection-DETAIL view (Phase-2 Slice 3, read-only). The +/// toolbar callbacks (search / sort / type-filter / source-filter / view-mode / +/// select / reset) drive `crate::myqbz_detail` re-derives + re-issue row +/// artwork; `open-item` / `open-artist` route to the existing +/// album/playlist/artist navigators (reusing the top-level open-album / +/// open-artist callbacks so local-vs-qobuz routing + history stay in one +/// place). Every hero CTA + per-row context action is a logging STUB — the +/// read-only boundary for this slice. +fn wire_myqbz_detail( + window: &AppWindow, + app_runtime: &Arc>, + tokio_rt: &tokio::runtime::Runtime, + image_cache: &artwork::ImageCache, +) { + use MyQbzDetailActions as Act; + + // After a toolbar re-derive the rendered model changed, so the visible + // rows need their thumbnails reloaded. + fn refresh_row_covers(window: &AppWindow, image_cache: &artwork::ImageCache) { + let jobs = myqbz_detail::artwork_jobs(window); + artwork::spawn_loads(jobs, window.as_weak(), image_cache.clone()); + } + + // --- Toolbar (client-side re-derive) -------------------------------- + { + let weak = window.as_weak(); + let image_cache = image_cache.clone(); + window.global::().on_search_changed(move |q| { + if let Some(w) = weak.upgrade() { + myqbz_detail::search(&w, q.as_str()); + refresh_row_covers(&w, &image_cache); + } + }); + } + { + let weak = window.as_weak(); + let image_cache = image_cache.clone(); + window.global::().on_set_sort(move |field| { + if let Some(w) = weak.upgrade() { + myqbz_detail::set_sort(&w, field.as_str()); + refresh_row_covers(&w, &image_cache); + } + }); + } + { + let weak = window.as_weak(); + let image_cache = image_cache.clone(); + window.global::().on_set_type_filter(move |value| { + if let Some(w) = weak.upgrade() { + myqbz_detail::set_type_filter(&w, value.as_str()); + refresh_row_covers(&w, &image_cache); + } + }); + } + { + let weak = window.as_weak(); + let image_cache = image_cache.clone(); + window.global::().on_toggle_source_filter(move |kind| { + if let Some(w) = weak.upgrade() { + myqbz_detail::toggle_source_filter(&w, kind.as_str()); + refresh_row_covers(&w, &image_cache); + } + }); + } + { + let weak = window.as_weak(); + window.global::().on_set_view_mode(move |mode| { + if let Some(w) = weak.upgrade() { + w.global::().set_view_mode(mode); + } + }); + } + { + let weak = window.as_weak(); + window.global::().on_toggle_select_mode(move || { + if let Some(w) = weak.upgrade() { + myqbz_detail::toggle_select_mode(&w); + } + }); + } + { + let weak = window.as_weak(); + window.global::().on_toggle_item_select(move |position| { + if let Some(w) = weak.upgrade() { + myqbz_detail::toggle_item_select(&w, position); + } + }); + } + { + let weak = window.as_weak(); + let image_cache = image_cache.clone(); + window.global::().on_reset_filters(move || { + if let Some(w) = weak.upgrade() { + myqbz_detail::reset_filters(&w); + refresh_row_covers(&w, &image_cache); + } + }); + } + + // --- Open an item -> album / local-album / playlist ----------------- + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_open_item(move |_source, item_type, source_item_id| { + let Some(w) = weak.upgrade() else { return }; + let id = source_item_id.to_string(); + match item_type.as_str() { + // Album / track items both open an album view; the top-level + // open-album callback handles Qobuz-vs-local routing + history. + "album" | "track" => { + w.invoke_open_album(id.into()); + } + "playlist" => { + nav::record(nav::NavEntry::Playlist(id.clone())); + navigate_playlist( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + id, + ); + update_nav_flags(&w); + } + other => { + log::warn!("[qbz-slint] myqbz_detail open-item: unknown type {other}"); + } + } + }); + } + + // --- Open an item's artist (qobuz items only) ----------------------- + { + let weak = window.as_weak(); + window + .global::() + .on_open_artist(move |_source, artist_name| { + if let Some(w) = weak.upgrade() { + // The top-level open-artist callback routes a name to the + // Local Library Artists tab and a numeric id to the Qobuz + // artist page. Collection items carry an artist NAME. + if !artist_name.trim().is_empty() { + w.invoke_open_artist(artist_name); + } + } + }); + } + + // --- READ-ONLY STUBS (the slice boundary) --------------------------- + { + let weak = window.as_weak(); + let log_stub = move |what: &str| { + let id = weak + .upgrade() + .map(|w| w.global::().get_id().to_string()) + .unwrap_or_default(); + log::info!( + "[qbz-slint] myqbz_detail {what}({id}) — not yet wired (read-only slice)" + ); + }; + let s = log_stub.clone(); + window.global::().on_play_all(move || s("play-all")); + let s = log_stub.clone(); + window.global::().on_shuffle(move || s("shuffle")); + let s = log_stub.clone(); + window.global::().on_dj_mix(move || s("dj-mix")); + let s = log_stub.clone(); + window.global::().on_edit(move || s("edit")); + let s = log_stub.clone(); + window.global::().on_delete(move || s("delete")); + let s = log_stub.clone(); + window.global::().on_sync(move || s("sync")); + } + { + window.global::().on_play_item(move |source_item_id| { + log::info!( + "[qbz-slint] myqbz_detail play-item({source_item_id}) — not yet wired (read-only slice)" + ); + }); + } + { + window + .global::() + .on_item_action(move |source_item_id, action| { + log::info!( + "[qbz-slint] myqbz_detail item-action({source_item_id}, {action}) — not yet wired (read-only slice)" + ); + }); + } +} + /// 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 @@ -6457,7 +6673,8 @@ fn main() -> Result<(), Box> { // === Playlist Manager actions ====================================== wire_playlist_manager(&window, &app_runtime, &tokio_rt, &image_cache); - wire_myqbz(&window, &image_cache); + wire_myqbz(&window, &tokio_rt, &image_cache); + wire_myqbz_detail(&window, &app_runtime, &tokio_rt, &image_cache); { let weak = window.as_weak(); window diff --git a/crates/qbz-slint/src/myqbz.rs b/crates/qbz-slint/src/myqbz.rs index 2069b25da..4bc8dbee1 100644 --- a/crates/qbz-slint/src/myqbz.rs +++ b/crates/qbz-slint/src/myqbz.rs @@ -87,7 +87,7 @@ fn album_count_label(count: usize) -> String { /// 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 { +pub fn small_qobuz_url(url: &str, target: u32) -> String { if url.is_empty() { return String::new(); } diff --git a/crates/qbz-slint/src/myqbz_detail.rs b/crates/qbz-slint/src/myqbz_detail.rs new file mode 100644 index 000000000..815bd72c7 --- /dev/null +++ b/crates/qbz-slint/src/myqbz_detail.rs @@ -0,0 +1,592 @@ +//! My QBZ — Collection / Mixtape DETAIL view controller (read-only slice). +//! +//! Mirrors `crate::playlist` (a cached full item list backs a client-side +//! filter -> search -> sort that re-derives the visible model) and reuses the +//! grid controller's mosaic + URL-downscale helpers from `crate::myqbz`. It +//! loads ONE `MixtapeCollection` (items come hydrated) via +//! `qbz_mixtape::repo::get_collection` through `library_db::with_db` + +//! `with_connection`, precomputes every display string (type label, source +//! kind, quality detail, tracks/year columns, downscaled `_50` row artwork +//! URL, up-to-9 hero-mosaic URLs), and pushes ready-to-render +//! `MixtapeDetailItem`s into `MyQbzDetailState`. The view does NO per-row +//! lookups. +//! +//! READ-ONLY SCOPE (Phase-2 Slice 3): nav-in (the grid card click) routes here +//! and loads real data — that is the testable path. The hero CTAs +//! (play/shuffle/dj-mix/edit/delete/sync), per-row context-menu items, and the +//! select-mode bulk bar are VISIBLE 1:1 but their handlers are logging stubs +//! (wired in main.rs). DEFERRED to a later slice: the live source/quality +//! `resolveItems` resolution (so quality badges + plex/local source kinds are +//! placeholders here, derived only from the stored `source`), the per-item +//! inline track expansion (the "expanded" view-mode renders its toggle + shell +//! only), the rename/description/delete/cover/DJ-mix modals, and persisted +//! per-collection view-prefs. +//! +//! The backend (`qbz-mixtape`) is reused directly — no Tauri command wrappers +//! (ADR-005), headless (ADR-006). + +use qbz_models::mixtape::{ + AlbumSource, CollectionKind, CollectionPlayMode, ItemType, MixtapeCollection, + MixtapeCollectionItem, +}; +use slint::{ComponentHandle, Model, ModelRc, VecModel}; + +use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache}; +use crate::{AppWindow, ContentView, MixtapeDetailItem, MyQbzDetailState, NavState}; + +thread_local! { + /// The full, original-order item list for the open collection — the + /// canonical source the toolbar derives the visible list from. UI thread + /// only (mirrors `playlist::FULL_ITEMS`). + static FULL_ITEMS: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; +} + +// ──────────────────────────── DB read path ──────────────────────────── + +/// Load one collection (items hydrated by the repo) from the per-user +/// library.db. Returns `None` when the DB is unavailable or the id is unknown. +pub fn get_collection(id: &str) -> Option { + crate::library_db::with_db(|db| { + Ok(db.with_connection(|conn| { + qbz_mixtape::repo::get_collection(conn, id).unwrap_or_else(|e| { + log::warn!("[qbz-slint] myqbz_detail get_collection({id}) failed: {e}"); + None + }) + })) + }) + .flatten() +} + +// ──────────────────────────── string helpers ────────────────────────── + +fn kind_str(kind: CollectionKind) -> &'static str { + match kind { + CollectionKind::Mixtape => "mixtape", + CollectionKind::Collection => "collection", + CollectionKind::ArtistCollection => "artist_collection", + } +} + +/// Eyebrow label (Tauri `kindLabel`): mixtapes.label / collections.artistLabel +/// / collections.label, uppercased to match the grid card eyebrow. +fn kind_label(kind: CollectionKind) -> &'static str { + match kind { + CollectionKind::Mixtape => "MIXTAPE", + CollectionKind::ArtistCollection => "ARTIST", + CollectionKind::Collection => "COLLECTION", + } +} + +fn play_mode_str(mode: CollectionPlayMode) -> &'static str { + match mode { + CollectionPlayMode::InOrder => "in_order", + CollectionPlayMode::AlbumShuffle => "album_shuffle", + } +} + +fn source_str(source: AlbumSource) -> &'static str { + match source { + AlbumSource::Qobuz => "qobuz", + AlbumSource::Local => "local", + } +} + +fn item_type_str(t: ItemType) -> &'static str { + match t { + ItemType::Album => "album", + ItemType::Track => "track", + ItemType::Playlist => "playlist", + } +} + +/// `mixtapes.albumCount` ICU plural — always "album(s)" regardless of +/// item_type (1:1 with the PSD / the grid card meta). +fn album_count_label(count: usize) -> String { + if count == 1 { + "1 album".to_string() + } else { + format!("{count} albums") + } +} + +/// Type-cell label, uppercase (spec 12 §6.3 col-3 `itemTypeLabel`). Release-type +/// overrides (album rows showing EP/Single/…) are a later slice — albums render +/// "ALBUM" here. +fn type_label(t: ItemType) -> &'static str { + match t { + ItemType::Album => "ALBUM", + ItemType::Track => "TRACK", + ItemType::Playlist => "PLAYLIST", + } +} + +/// TRACKS column (spec 12 §6.3 col-6 `itemTracks`): "1" for a track, else the +/// count or an em-dash. +fn tracks_text(item: &MixtapeCollectionItem) -> String { + match item.item_type { + ItemType::Track => "1".to_string(), + _ => match item.track_count { + Some(n) => n.to_string(), + None => "—".to_string(), + }, + } +} + +/// YEAR column (spec 12 §6.3 col-7 `itemYear`): the year or "". +fn year_text(item: &MixtapeCollectionItem) -> String { + item.year.map(|y| y.to_string()).unwrap_or_default() +} + +// ──────────────────────────── model builder ─────────────────────────── + +/// Build one ready-to-render row. The `_50` row-artwork downscale reuses the +/// grid controller's `small_qobuz_url`. Source kind defaults from the stored +/// `source` (the live plex-vs-local-vs-qobuz `resolveItems` resolution is +/// DEFERRED, so quality badge inputs stay empty here). +fn to_item(item: &MixtapeCollectionItem) -> MixtapeDetailItem { + let source = source_str(item.source); + let artwork_url = item + .artwork_url + .as_deref() + .filter(|u| !u.is_empty()) + .map(|u| crate::myqbz::small_qobuz_url(u, 50)) + .unwrap_or_default(); + + MixtapeDetailItem { + position: item.position, + item_type: item_type_str(item.item_type).into(), + source: source.into(), + source_item_id: item.source_item_id.clone().into(), + title: item.title.clone().into(), + subtitle: item.subtitle.clone().unwrap_or_default().into(), + // Only qobuz items get a clickable artist subtitle (spec 12 §6.3). + subtitle_is_link: item.source == AlbumSource::Qobuz + && item.subtitle.as_deref().map(|s| !s.is_empty()).unwrap_or(false), + // Resolved source kind — defaults to the raw source until resolveItems + // lands (a later slice). qobuz -> "qobuz"; local -> "local". + source_kind: source.into(), + type_label: type_label(item.item_type).into(), + // Quality resolution is deferred; no badge until then. + quality_tier: "".into(), + quality_detail: "".into(), + tracks_text: tracks_text(item).into(), + year_text: year_text(item).into(), + artwork_url: artwork_url.into(), + artwork: slint::Image::default(), + selected: false, + } +} + +// ──────────────────────────── hero mosaic ───────────────────────────── + +/// Decide the hero-mosaic cover-count (0 / 4 / 9) + downscaled cell URLs, and +/// push them into `MyQbzDetailState`. Mirrors the grid card's mosaic rule +/// (3x3 only for a Collection with >= 9 items; else 2x2) but at the hero +/// `size = 186` (so the downscale target differs: 2x2 -> 150, 3x3 -> 50). +fn apply_hero_mosaic(state: &MyQbzDetailState, c: &MixtapeCollection) { + let item_count = c.items.len(); + let has_custom = c.custom_artwork_path.is_some(); + + let cols: usize = if c.kind == CollectionKind::Collection && item_count >= 9 { + 3 + } else { + 2 + }; + let cell_count = cols * cols; + let cover_count = if has_custom || item_count == 0 { + 0 + } else { + cell_count + }; + // Hero renders at 186px; cell ~93 (2x2) -> 150, ~62 (3x3) -> 50. + let target: u32 = if cols == 3 { 50 } else { 150 }; + + 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() => crate::myqbz::small_qobuz_url(u, target).into(), + _ => slint::SharedString::default(), + } + }; + + state.set_cover_count(cover_count as i32); + state.set_url1(url(0)); + state.set_url2(url(1)); + state.set_url3(url(2)); + state.set_url4(url(3)); + state.set_url5(url(4)); + state.set_url6(url(5)); + state.set_url7(url(6)); + state.set_url8(url(7)); + state.set_url9(url(8)); + // Reset the decoded covers so a re-open does not show stale tiles. + state.set_cover1(slint::Image::default()); + state.set_cover2(slint::Image::default()); + state.set_cover3(slint::Image::default()); + state.set_cover4(slint::Image::default()); + state.set_cover5(slint::Image::default()); + state.set_cover6(slint::Image::default()); + state.set_cover7(slint::Image::default()); + state.set_cover8(slint::Image::default()); + state.set_cover9(slint::Image::default()); +} + +// ──────────────────────────── sort / filter / search ────────────────── + +/// Apply the active toolbar (type filter -> source filter -> search -> sort) +/// over `FULL_ITEMS` and push the resulting render model. Non-destructive (the +/// persisted order is untouched). UI thread only. Mirrors spec 12 §19. +pub fn refresh_view(window: &AppWindow) { + let state = window.global::(); + let query = state.get_search().trim().to_lowercase(); + let type_filter = state.get_type_filter().to_string(); + let (sq, sp, sl) = ( + state.get_src_qobuz(), + state.get_src_plex(), + state.get_src_local(), + ); + let any_source = sq || sp || sl; + let sort = state.get_sort().to_string(); + let desc = state.get_sort_dir().to_string() == "desc"; + + let mut view: Vec = FULL_ITEMS.with(|cell| { + cell.borrow() + .iter() + .filter(|it| { + // Type filter (single-select). + type_filter == "all" || item_type_str(it.item_type) == type_filter + }) + .filter(|it| { + // Source filter (multi-select). source_kind currently equals + // the raw source (resolveItems deferred) — qobuz / local. + if !any_source { + return true; + } + let kind = source_str(it.source); + (sq && kind == "qobuz") || (sp && kind == "plex") || (sl && kind == "local") + }) + .filter(|it| { + if query.is_empty() { + return true; + } + it.title.to_lowercase().contains(&query) + || it + .subtitle + .as_deref() + .map(|s| s.to_lowercase().contains(&query)) + .unwrap_or(false) + }) + .cloned() + .collect() + }); + + match sort.as_str() { + "name" => view.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())), + "year" => view.sort_by(|a, b| a.year.unwrap_or(0).cmp(&b.year.unwrap_or(0))), + "tracks" => { + view.sort_by(|a, b| a.track_count.unwrap_or(0).cmp(&b.track_count.unwrap_or(0))) + } + // default "position" + _ => view.sort_by(|a, b| a.position.cmp(&b.position)), + } + if desc { + view.reverse(); + } + + let items: Vec = view.iter().map(to_item).collect(); + state.set_items(ModelRc::new(VecModel::from(items))); + + // Derived toolbar badges (Rust-computed; the view only reads them). + let source_count = (sq as i32) + (sp as i32) + (sl as i32); + state.set_filter_count(source_count + if type_filter != "all" { 1 } else { 0 }); + state.set_has_any_filter( + type_filter != "all" || any_source || sort != "position" || desc, + ); +} + +/// Update the search query and re-render. +pub fn search(window: &AppWindow, query: &str) { + window.global::().set_search(query.into()); + refresh_view(window); +} + +/// Set the sort field. Re-selecting the active field flips asc/desc; a new +/// field resets to asc (spec 12 §5.4 `selectSort`). +pub fn set_sort(window: &AppWindow, field: &str) { + let state = window.global::(); + if state.get_sort() == field { + let dir = if state.get_sort_dir() == "asc" { "desc" } else { "asc" }; + state.set_sort_dir(dir.into()); + } else { + state.set_sort(field.into()); + state.set_sort_dir("asc".into()); + } + refresh_view(window); +} + +/// Single-select the type filter. +pub fn set_type_filter(window: &AppWindow, value: &str) { + window.global::().set_type_filter(value.into()); + refresh_view(window); +} + +/// Toggle one source-filter flag (multi-select; menu stays open in the view). +pub fn toggle_source_filter(window: &AppWindow, kind: &str) { + let state = window.global::(); + match kind { + "qobuz" => state.set_src_qobuz(!state.get_src_qobuz()), + "plex" => state.set_src_plex(!state.get_src_plex()), + "local" => state.set_src_local(!state.get_src_local()), + _ => {} + } + refresh_view(window); +} + +/// Reset filters + sort (spec 12 §5.6 reset: type 'all', no sources, sort +/// 'position' asc). Search query is left intact (Tauri's reset doesn't clear +/// it; `hasAnyFilter` excludes search). +pub fn reset_filters(window: &AppWindow) { + let state = window.global::(); + state.set_type_filter("all".into()); + state.set_src_qobuz(false); + state.set_src_plex(false); + state.set_src_local(false); + state.set_sort("position".into()); + state.set_sort_dir("asc".into()); + refresh_view(window); +} + +/// Toggle multi-select edit mode. Leaving clears any selection. +pub fn toggle_select_mode(window: &AppWindow) { + let state = window.global::(); + let on = !state.get_select_mode(); + if !on { + let model = state.get_items(); + for i in 0..model.row_count() { + if let Some(mut it) = model.row_data(i) { + if it.selected { + it.selected = false; + model.set_row_data(i, it); + } + } + } + state.set_selected_count(0); + } + state.set_select_mode(on); +} + +/// Toggle one row's selection by position. Recounts the selection. +pub fn toggle_item_select(window: &AppWindow, position: i32) { + let state = window.global::(); + let model = state.get_items(); + for i in 0..model.row_count() { + if let Some(mut it) = model.row_data(i) { + if it.position == position { + it.selected = !it.selected; + model.set_row_data(i, it); + break; + } + } + } + let count = (0..model.row_count()) + .filter(|&i| model.row_data(i).map(|it| it.selected).unwrap_or(false)) + .count() as i32; + state.set_selected_count(count); +} + +// ──────────────────────────── reset / apply ─────────────────────────── + +/// Clear the view to its loading state before a fresh load (so a re-open does +/// not flash the previous collection's hero + rows). +pub fn reset(window: &AppWindow) { + FULL_ITEMS.with(|cell| cell.borrow_mut().clear()); + let state = window.global::(); + state.set_loading(true); + state.set_found(true); + state.set_items(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_name("".into()); + state.set_description("".into()); + state.set_meta("".into()); + state.set_item_count(0); + state.set_has_custom_cover(false); + state.set_custom_cover(slint::Image::default()); + state.set_cover_count(0); + state.set_selected_count(0); + state.set_select_mode(false); + // Toolbar is session-scoped per the slice spec: reset to defaults on open. + state.set_search("".into()); + state.set_sort("position".into()); + state.set_sort_dir("asc".into()); + state.set_type_filter("all".into()); + state.set_src_qobuz(false); + state.set_src_plex(false); + state.set_src_local(false); + state.set_view_mode("list".into()); + state.set_filter_count(0); + state.set_has_any_filter(false); +} + +/// Apply a freshly-loaded collection: header strings, hero mosaic, the full +/// item list (-> FULL_ITEMS), then render through the (reset) toolbar. +pub fn apply(window: &AppWindow, c: MixtapeCollection) { + let state = window.global::(); + let item_count = c.items.len(); + + state.set_id(c.id.clone().into()); + state.set_kind(kind_str(c.kind).into()); + state.set_kind_label(kind_label(c.kind).into()); + state.set_name(c.name.clone().into()); + state.set_description(c.description.clone().unwrap_or_default().into()); + state.set_meta(album_count_label(item_count).into()); + state.set_item_count(item_count as i32); + state.set_play_mode(play_mode_str(c.play_mode).into()); + state.set_found(true); + + // Custom cover (overrides the mosaic) — load the local file directly (it + // lives in the artwork cache on disk; same as the playlist controller). + let has_custom = c + .custom_artwork_path + .as_ref() + .filter(|p| !p.is_empty()) + .filter(|p| std::path::Path::new(p).exists()) + .and_then(|p| slint::Image::load_from_path(std::path::Path::new(p)).ok()); + if let Some(img) = has_custom { + state.set_has_custom_cover(true); + state.set_custom_cover(img); + } else { + state.set_has_custom_cover(false); + state.set_custom_cover(slint::Image::default()); + } + + apply_hero_mosaic(&state, &c); + + FULL_ITEMS.with(|cell| *cell.borrow_mut() = c.items); + refresh_view(window); + state.set_loading(false); +} + +/// Mark the load as not-found (the id resolved to no collection). +pub fn apply_not_found(window: &AppWindow) { + let state = window.global::(); + state.set_loading(false); + state.set_found(false); +} + +// ──────────────────────────── artwork jobs ──────────────────────────── + +/// Build artwork jobs for the loaded collection: the up-to-9 hero-mosaic cells +/// (only when no custom cover) + one thumbnail per visible item row. +pub fn artwork_jobs(window: &AppWindow) -> Vec { + let state = window.global::(); + let mut jobs = Vec::new(); + + // Hero mosaic cells. + if !state.get_has_custom_cover() { + let urls = [ + state.get_url1(), + state.get_url2(), + state.get_url3(), + state.get_url4(), + state.get_url5(), + state.get_url6(), + state.get_url7(), + state.get_url8(), + state.get_url9(), + ]; + for (slot, url) in urls.iter().enumerate() { + if !url.is_empty() { + jobs.push(ArtworkJob { + target: ArtworkTarget::MyQbzDetailCover { slot }, + url: url.to_string(), + }); + } + } + } + + // Row thumbnails (the rendered model — matched back by position on apply). + let model = state.get_items(); + for i in 0..model.row_count() { + let Some(item) = model.row_data(i) else { continue }; + if !item.artwork_url.is_empty() { + jobs.push(ArtworkJob { + target: ArtworkTarget::MyQbzDetailRow { position: item.position }, + url: item.artwork_url.to_string(), + }); + } + } + jobs +} + +/// Set a decoded row thumbnail by item position (the rendered model order may +/// differ from FULL_ITEMS after a sort, so match by the stable position). +pub fn set_row_artwork(window: &AppWindow, position: i32, image: slint::Image) { + let model = window.global::().get_items(); + for i in 0..model.row_count() { + if let Some(mut it) = model.row_data(i) { + if it.position == position { + it.artwork = image; + model.set_row_data(i, it); + break; + } + } + } +} + +/// Set a decoded hero-mosaic cover by slot (0-8). +pub fn set_hero_cover(window: &AppWindow, slot: usize, image: slint::Image) { + let state = window.global::(); + match slot { + 0 => state.set_cover1(image), + 1 => state.set_cover2(image), + 2 => state.set_cover3(image), + 3 => state.set_cover4(image), + 4 => state.set_cover5(image), + 5 => state.set_cover6(image), + 6 => state.set_cover7(image), + 7 => state.set_cover8(image), + 8 => state.set_cover9(image), + _ => {} + } +} + +// ──────────────────────────── navigation ────────────────────────────── + +/// Open the collection-detail view for `id`: switch the ContentView + loading +/// state immediately, fetch the collection on a blocking worker, then apply + +/// render + spawn artwork. Mirrors `myqbz::navigate` (load/apply/render) and +/// the album/playlist detail navigators. +pub fn navigate( + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: ImageCache, + id: String, +) { + handle.clone().spawn(async move { + { + let weak = weak.clone(); + let _ = weak.upgrade_in_event_loop(move |w| { + reset(&w); + w.global::().set_view(ContentView::MixtapeDetail); + }); + } + + let fetch_id = id.clone(); + let collection = + tokio::task::spawn_blocking(move || get_collection(&fetch_id)).await.ok().flatten(); + + let _ = weak.upgrade_in_event_loop(move |w| match collection { + Some(c) => { + apply(&w, c); + let jobs = artwork_jobs(&w); + artwork::spawn_loads(jobs, w.as_weak(), image_cache.clone()); + } + None => { + log::warn!("[qbz-slint] myqbz_detail navigate({id}): collection not found"); + apply_not_found(&w); + } + }); + }); +} diff --git a/crates/qbz-slint/src/nav.rs b/crates/qbz-slint/src/nav.rs index 61dbb9ca0..6ef69755d 100644 --- a/crates/qbz-slint/src/nav.rs +++ b/crates/qbz-slint/src/nav.rs @@ -66,6 +66,12 @@ pub enum NavEntry { /// The My QBZ > Collections index grid (read-only in this slice). Same /// session-scoped toolbar; no payload. Collections, + /// A Mixtape / Collection / Artist-Collection DETAIL page (read-only in + /// this slice); the string is the collection id. Mirrors `Album` / + /// `Playlist` — the in-detail toolbar state (search / sort / type-filter / + /// view-mode) is session-scoped in the controller, so the entry carries + /// only the id. + MixtapeDetail(String), 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 ea10a2400..96b701336 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, 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"; +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, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint"; // 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/myqbz/MixtapeDetailView.slint b/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint new file mode 100644 index 000000000..85e29639b --- /dev/null +++ b/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint @@ -0,0 +1,1308 @@ +// My QBZ — Mixtape / Collection DETAIL view (read-only slice). +// +// Spec 12 (MixtapeCollectionDetailView). A hero header (CollectionMosaic / +// custom cover + kind eyebrow + title + description + meta + the +// play/shuffle/dj-mix/edit/delete/sync action row), a sticky toolbar +// (search + sort + type-filter + source-filter + reset + select + the +// list/grid/expanded view-mode segmented group), then the 8-col item list +// (or the auto-fill grid). Data comes from MyQbzDetailState; the toolbar + +// open-item / open-artist callbacks are live, but every hero CTA + per-row +// context action is a logging STUB (read-only boundary). +// +// DEFERRED (later slice): the "expanded" view-mode's per-item inline track +// expansion (the toggle + shell render here, but no inline TrackRows — needs +// the enqueue resolver), the rename/description/delete/cover/DJ-mix modals, +// the BulkActionBar, live source/quality resolveItems, and the per-row +// release-type submenu. Quality badges + non-qobuz source kinds are +// placeholders until resolveItems lands. +// +// Geometry is verbatim from spec 12: row height 56px; 8-col grid +// 40 / 1fr / 140 / 80 / 160 / 72 / 60 / 40 (gap 12, padding 8 12); grid cards +// auto-fill minmax(150,1fr) gap 20; root padding 8 8 100 18. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; +import { MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, NavState } from "../state.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint"; +import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; +import { ListScrollbar } from "../primitives/ListScrollbar.slint"; +import { CollectionMosaic } from "CollectionMosaic.slint"; + +// --- Hero action button (.action-btn-circle family) --------------------- +// Ghost circle (translucent fill + faint ring) homologated with the album / +// playlist transport rows; `.primary` keeps the same ghost look (the Tauri +// CTA fills accent but our transport row family is ghost everywhere). `enabled` +// dims + disables (item-count == 0). +component ActionCircle inherits Rectangle { + in property icon; + in property primary: false; + in property diameter: 44px; + in property enabled: true; + in property glyph: 19px; + callback clicked(); + + width: root.diameter; + height: root.diameter; + border-radius: root.diameter / 2; + opacity: root.enabled ? 1.0 : 0.4; + background: ac-ta.has-hover ? Theme.surface-hover : transparent; + border-width: 1px; + border-color: ac-ta.has-hover ? Theme.text-disabled : Theme.border-subtle; + QbzIcon { + source: root.icon; + width: root.glyph; + height: root.glyph; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: ac-ta.has-hover ? Theme.text-primary : Theme.text-secondary; + } + ac-ta := TouchArea { + mouse-cursor: root.enabled ? pointer : default; + clicked => { + if (root.enabled) { + root.clicked(); + } + } + } +} + +// --- One sort / type-filter dropdown row -------------------------------- +component MenuOption inherits Rectangle { + in property label; + in property selected: false; + in property indicator: ""; // sort arrow on the active sort + in property check: false; // trailing Check (filter selection) + callback chosen(); + height: 30px; + border-radius: 4px; + background: mo-ta.has-hover ? Theme.surface-hover : transparent; + HorizontalLayout { + padding-left: 8px; + padding-right: 8px; + spacing: 8px; + Text { + text: root.label; + color: root.selected ? Theme.accent : Theme.text-primary; + font-size: 12px; + 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 && root.selected: VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/check.svg"); + width: 12px; + height: 12px; + tint: Theme.accent; + } + } + } + mo-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.chosen(); } + } +} + +// --- A generic toolbar control button (sort / filter / reset / select) -- +component ControlBtn inherits Rectangle { + in property icon; + in property label; // "" -> icon-only + in property indicator; // sort arrow "↑"/"↓" or "" + in property badge: 0; // filter count badge (0 = hidden) + in property active: false; + in property subtle: false; + in property enabled: true; + callback clicked(); + height: 32px; + border-radius: 6px; + width: row.preferred-width + (root.subtle ? 16px : 20px); + opacity: root.enabled ? 1.0 : 0.5; + background: cb-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: 12px; + vertical-alignment: center; + } + if root.indicator != "": Text { + text: root.indicator; + color: Theme.text-muted; + font-size: 11px; + vertical-alignment: center; + } + if root.badge > 0: Rectangle { + width: 16px; + height: 16px; + border-radius: 8px; + background: Theme.accent; + Text { + text: root.badge; + color: Theme.accent-text; + font-size: 10px; + font-weight: Typography.semibold; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + cb-ta := TouchArea { + mouse-cursor: root.enabled ? pointer : default; + clicked => { + if (root.enabled) { + root.clicked(); + } + } + } +} + +// --- One segment of the view-mode group --------------------------------- +component ViewSeg inherits Rectangle { + in property icon; + in property active: false; + callback clicked(); + height: 30px; + width: 31px; + background: root.active + ? Theme.accent + : (vs-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; + } + vs-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +// --- Source indicator (masked SVG tinted to accent) --------------------- +component SourceIndicator inherits Rectangle { + in property kind; // "qobuz" | "plex" | "local" + width: 28px; + height: 28px; + QbzIcon { + source: root.kind == "plex" + ? @image-url("../assets/icons/plex-logo.svg") + : (root.kind == "local" + ? @image-url("../assets/icons/hard-drive.svg") + : @image-url("../assets/icons/qobuz-logo-filled.svg")); + width: root.kind == "qobuz" ? 16px : 14px; + height: root.kind == "qobuz" ? 16px : 14px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: Theme.accent; + opacity: 0.9; + } +} + +// --- The per-row ⋯ context menu (read-only: items are stubs) ------------- +component RowMenuItem inherits Rectangle { + in property icon; + in property label; + in property destructive: false; + callback chosen(); + height: 32px; + border-radius: 6px; + background: rmi-ta.has-hover ? Theme.surface-hover : transparent; + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + spacing: 8px; + alignment: start; + VerticalLayout { + alignment: center; + QbzIcon { + source: root.icon; + width: 13px; + height: 13px; + tint: root.destructive ? #e57373 : Theme.text-secondary; + } + } + Text { + text: root.label; + color: root.destructive ? #e57373 : Theme.text-primary; + font-size: 13px; + vertical-alignment: center; + } + } + rmi-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.chosen(); } + } +} + +// --- One item ROW (8-col grid; spec 12 §6.3) ---------------------------- +component DetailRow inherits Rectangle { + in property item; + in property index; // 1-based display ordinal among visible + in property select-mode: false; + height: 56px; + background: (row-ta.has-hover || root.item.selected) ? Theme.surface-hover : transparent; + // Subtle bottom border (rgba(255,255,255,0.04)). + Rectangle { + y: parent.height - 1px; + height: 1px; + background: #ffffff0a; + } + + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + spacing: 12px; + + // Col 1 — # / checkbox (40px). + Rectangle { + width: 40px; + if root.select-mode: Rectangle { + width: 15px; + height: 15px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + border-radius: 3px; + border-width: 1px; + border-color: root.item.selected ? Theme.accent : Theme.text-muted; + background: root.item.selected ? Theme.accent : transparent; + if root.item.selected: QbzIcon { + source: @image-url("../assets/icons/check.svg"); + width: 11px; + height: 11px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: Theme.accent-text; + } + cb-ta := TouchArea { + mouse-cursor: pointer; + clicked => { MyQbzDetailActions.toggle-item-select(root.item.position); } + } + } + if !root.select-mode: Text { + text: root.index; + 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; + } + } + + // Col 2 — artwork + title/subtitle (1fr). + HorizontalLayout { + horizontal-stretch: 1; + spacing: 12px; + // Artwork (36px) with a hover play overlay. + art := Rectangle { + width: 36px; + height: 36px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + border-radius: 4px; + background: Theme.surface-elevated; + clip: true; + if root.item.artwork-url != "": Image { + width: 100%; + height: 100%; + source: root.item.artwork; + image-fit: cover; + } + if root.item.artwork-url == "": QbzIcon { + source: root.item.item-type == "track" + ? @image-url("../assets/icons/music.svg") + : (root.item.item-type == "playlist" + ? @image-url("../assets/icons/list-music.svg") + : @image-url("../assets/icons/disc.svg")); + width: 18px; + height: 18px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: Theme.text-muted; + } + // Hover play overlay. + Rectangle { + background: #00000099; + opacity: play-ta.has-hover ? 1.0 : 0.0; + animate opacity { duration: 150ms; } + QbzIcon { + source: @image-url("../assets/icons/play-fill.svg"); + width: 16px; + height: 16px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: #ffffff; + } + play-ta := TouchArea { + mouse-cursor: pointer; + clicked => { MyQbzDetailActions.play-item(root.item.source-item-id); } + } + } + } + // Title + subtitle. + VerticalLayout { + alignment: center; + spacing: 2px; + horizontal-stretch: 1; + title-ta := TouchArea { + height: 18px; + mouse-cursor: pointer; + clicked => { + MyQbzDetailActions.open-item(root.item.source, root.item.item-type, root.item.source-item-id); + } + Text { + width: 100%; + text: root.item.title; + color: title-ta.has-hover ? Theme.accent : Theme.text-primary; + font-size: 14px; + font-weight: Typography.medium; + overflow: elide; + vertical-alignment: center; + } + } + if root.item.subtitle != "": sub-ta := TouchArea { + height: 16px; + mouse-cursor: root.item.subtitle-is-link ? pointer : default; + clicked => { + if (root.item.subtitle-is-link) { + MyQbzDetailActions.open-artist(root.item.source, root.item.subtitle); + } + } + Text { + width: 100%; + text: root.item.subtitle; + color: (root.item.subtitle-is-link && sub-ta.has-hover) + ? Theme.accent : Theme.text-muted; + font-size: 12px; + overflow: elide; + vertical-alignment: center; + } + } + } + } + + // Col 3 — type (140px). + Rectangle { + width: 140px; + HorizontalLayout { + spacing: 6px; + alignment: start; + VerticalLayout { + alignment: center; + QbzIcon { + source: root.item.item-type == "track" + ? @image-url("../assets/icons/music.svg") + : (root.item.item-type == "playlist" + ? @image-url("../assets/icons/list-music.svg") + : @image-url("../assets/icons/disc.svg")); + width: 13px; + height: 13px; + tint: Theme.text-muted; + } + } + Text { + text: root.item.type-label; + color: Theme.text-muted; + font-size: 10px; + font-weight: Typography.semibold; + letter-spacing: 1.2px; + vertical-alignment: center; + } + } + } + + // Col 4 — source (80px). + Rectangle { + width: 80px; + SourceIndicator { + kind: root.item.source-kind; + x: 0; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + } + + // Col 5 — quality (160px). Deferred: hidden until resolveItems. + Rectangle { + width: 160px; + if root.item.quality-tier != "": QualityBadgeFull { + tier: root.item.quality-tier; + detail: root.item.quality-detail; + x: 0; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + } + + // Col 6 — tracks (72px, right-aligned). + Text { + width: 72px; + text: root.item.tracks-text; + color: Theme.text-secondary; + font-size: 13px; + horizontal-alignment: right; + vertical-alignment: center; + } + + // Col 7 — year (60px, right-aligned). + Text { + width: 60px; + text: root.item.year-text; + color: Theme.text-secondary; + font-size: 13px; + horizontal-alignment: right; + vertical-alignment: center; + } + + // Col 8 — ⋯ context menu (40px). + Rectangle { + width: 40px; + menu-btn := Rectangle { + width: 24px; + height: 24px; + x: parent.width - self.width; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + border-radius: 4px; + background: menu-ta.has-hover ? Theme.surface-hover : transparent; + QbzIcon { + source: @image-url("../assets/icons/ellipsis.svg"); + 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: menu-ta.has-hover ? Theme.text-primary : Theme.text-muted; + } + menu-ta := TouchArea { + mouse-cursor: pointer; + clicked => { row-menu.show(); } + } + row-menu := PopupWindow { + x: menu-btn.width - 184px; + y: 28px; + width: 184px; + 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; + RowMenuItem { + icon: @image-url("../assets/icons/play-fill.svg"); + label: @tr("Play"); + chosen => { MyQbzDetailActions.play-item(root.item.source-item-id); } + } + RowMenuItem { + icon: @image-url("../assets/icons/list-music.svg"); + label: @tr("Play next"); + chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "play-next"); } + } + RowMenuItem { + icon: @image-url("../assets/icons/list-music.svg"); + label: @tr("Add to queue"); + chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "play-later"); } + } + Rectangle { height: 1px; background: Theme.surface-elevated; } + RowMenuItem { + icon: @image-url("../assets/icons/trash-2.svg"); + label: @tr("Remove from collection"); + destructive: true; + chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "remove"); } + } + } + } + } + } + } + } + + // Row click (outside the interactive cells) — open the item. + row-ta := TouchArea { + // Sits below the cell TouchAreas (title/subtitle/play/menu/checkbox). + clicked => { + if (root.select-mode) { + MyQbzDetailActions.toggle-item-select(root.item.position); + } else { + MyQbzDetailActions.open-item(root.item.source, root.item.item-type, root.item.source-item-id); + } + } + } +} + +// --- One GRID card (spec 12 §7) ----------------------------------------- +component DetailCard inherits Rectangle { + in property item; + in property select-mode: false; + in property card-w: 150px; + width: root.card-w; + // artwork (card-w) + gap 8 + title ~18 + subtitle ~16 + padding 8*2. + height: root.card-w + 8px + 18px + 16px + 16px; + border-radius: 8px; + background: (card-ta.has-hover || root.item.selected) ? Theme.surface-hover : transparent; + border-width: root.item.selected ? 2px : 0px; + border-color: Theme.accent; + VerticalLayout { + padding: 8px; + spacing: 8px; + artw := Rectangle { + width: parent.width - 16px; + height: self.width; + border-radius: 6px; + background: Theme.surface-elevated; + clip: true; + if root.item.artwork-url != "": Image { + width: 100%; + height: 100%; + source: root.item.artwork; + image-fit: cover; + } + if root.item.artwork-url == "": QbzIcon { + source: root.item.item-type == "track" + ? @image-url("../assets/icons/music.svg") + : (root.item.item-type == "playlist" + ? @image-url("../assets/icons/list-music.svg") + : @image-url("../assets/icons/disc.svg")); + width: 28px; + height: 28px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: Theme.text-muted; + } + // Select checkbox (top-left) OR hover play overlay. + if root.select-mode: Rectangle { + x: 8px; + y: 8px; + width: 18px; + height: 18px; + border-radius: 3px; + background: root.item.selected ? Theme.accent : #0000008c; + border-width: 1px; + border-color: root.item.selected ? Theme.accent : Theme.text-muted; + if root.item.selected: QbzIcon { + source: @image-url("../assets/icons/check.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: Theme.accent-text; + } + gcb-ta := TouchArea { + mouse-cursor: pointer; + clicked => { MyQbzDetailActions.toggle-item-select(root.item.position); } + } + } + if !root.select-mode: Rectangle { + background: #00000073; + opacity: gp-ta.has-hover ? 1.0 : 0.0; + animate opacity { duration: 150ms; } + QbzIcon { + source: @image-url("../assets/icons/play-fill.svg"); + width: 24px; + height: 24px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: #ffffff; + } + gp-ta := TouchArea { + mouse-cursor: pointer; + clicked => { MyQbzDetailActions.play-item(root.item.source-item-id); } + } + } + } + Text { + text: root.item.title; + color: Theme.text-primary; + font-size: 14px; + font-weight: Typography.medium; + overflow: elide; + width: 100%; + } + Text { + text: root.item.subtitle; + color: Theme.text-muted; + font-size: 12px; + overflow: elide; + width: 100%; + } + } + card-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if (root.select-mode) { + MyQbzDetailActions.toggle-item-select(root.item.position); + } else { + MyQbzDetailActions.open-item(root.item.source, root.item.item-type, root.item.source-item-id); + } + } + } +} + +export component MixtapeDetailView inherits Rectangle { + property active-sort-label: + MyQbzDetailState.sort == "name" ? @tr("Name") + : MyQbzDetailState.sort == "year" ? @tr("Year") + : MyQbzDetailState.sort == "tracks" ? @tr("Tracks") + : @tr("Position"); + property sort-arrow: MyQbzDetailState.sort-dir == "asc" ? "↑" : "↓"; + + // Grid geometry (spec 12 §7/§15): auto-fill minmax(150,1fr), gap 20. + property grid-min-w: 150px; + property grid-gap: 20px; + + background: Theme.surface-main; + + // ===================== LOADING ===================================== + if MyQbzDetailState.loading: HorizontalLayout { + alignment: center; + VerticalLayout { + alignment: center; + LoadingSpinner { size: 36px; } + } + } + + // ===================== NOT-FOUND =================================== + if !MyQbzDetailState.loading && !MyQbzDetailState.found: VerticalLayout { + padding: 40px; + spacing: 16px; + alignment: start; + HorizontalLayout { + alignment: start; + nf-back := TouchArea { + width: nf-row.preferred-width; + height: 20px; + mouse-cursor: pointer; + clicked => { NavState.request-back(); } + nf-row := HorizontalLayout { + spacing: 8px; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/arrow-left.svg"); + width: 16px; + height: 16px; + tint: nf-back.has-hover ? Theme.text-secondary : Theme.text-muted; + } + } + Text { + text: @tr("Back"); + color: nf-back.has-hover ? Theme.text-secondary : Theme.text-muted; + font-size: 14px; + vertical-alignment: center; + } + } + } + } + HorizontalLayout { + alignment: center; + Text { + text: @tr("Not found"); + color: Theme.text-muted; + font-size: 14px; + } + } + } + + // ===================== LOADED ====================================== + // The Flickable is always in the tree (so the root-level ListScrollbar can + // bind to it); its content is gated on the loaded state. + flick := Flickable { + visible: !MyQbzDetailState.loading && MyQbzDetailState.found; + 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 == "mixtape-detail" && 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; + // .detail-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; + 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(); } + } + } + + // --- Hero header (.header-content) ------------------------- + HorizontalLayout { + spacing: 32px; + alignment: start; + // Cover (186px mosaic / custom cover). + VerticalLayout { + alignment: end; + Rectangle { + width: 186px; + height: 186px; + // CollectionMosaic reads a MixtapeCardItem; the detail + // state carries the same mosaic fields, so wrap them. + CollectionMosaic { + size: 186px; + item: { + kind: MyQbzDetailState.kind, + item-count: MyQbzDetailState.item-count, + has-custom-cover: MyQbzDetailState.has-custom-cover, + custom-cover: MyQbzDetailState.custom-cover, + cover-count: MyQbzDetailState.cover-count, + url1: MyQbzDetailState.url1, url2: MyQbzDetailState.url2, + url3: MyQbzDetailState.url3, url4: MyQbzDetailState.url4, + url5: MyQbzDetailState.url5, url6: MyQbzDetailState.url6, + url7: MyQbzDetailState.url7, url8: MyQbzDetailState.url8, + url9: MyQbzDetailState.url9, + cover1: MyQbzDetailState.cover1, cover2: MyQbzDetailState.cover2, + cover3: MyQbzDetailState.cover3, cover4: MyQbzDetailState.cover4, + cover5: MyQbzDetailState.cover5, cover6: MyQbzDetailState.cover6, + cover7: MyQbzDetailState.cover7, cover8: MyQbzDetailState.cover8, + cover9: MyQbzDetailState.cover9, + }; + } + } + } + // Info column. + VerticalLayout { + alignment: end; + horizontal-stretch: 1; + spacing: 8px; + // Eyebrow (.kind-tag). + Text { + text: MyQbzDetailState.kind-label; + color: Theme.text-muted; + font-size: 12px; + font-weight: Typography.semibold; + letter-spacing: 1.2px; + } + // Title. + Text { + text: MyQbzDetailState.name; + color: Theme.text-primary; + font-size: 24px; + font-weight: Typography.bold; + wrap: word-wrap; + } + // Description (optional). + if MyQbzDetailState.description != "": Text { + width: Math.min(root.width - 280px, 720px); + text: MyQbzDetailState.description; + color: Theme.text-secondary; + font-size: 14px; + wrap: word-wrap; + } + // Meta (item-count summary). + Text { + text: MyQbzDetailState.meta; + color: Theme.text-muted; + font-size: 13px; + } + Rectangle { height: 4px; } + // Action row (play / shuffle / dj-mix / edit / delete / sync). + HorizontalLayout { + spacing: 8px; + alignment: start; + ActionCircle { + icon: @image-url("../assets/icons/play-fill.svg"); + primary: true; + diameter: 44px; + glyph: 20px; + enabled: MyQbzDetailState.item-count > 0; + clicked => { MyQbzDetailActions.play-all(); } + } + VerticalLayout { + alignment: center; + ActionCircle { + icon: @image-url("../assets/icons/shuffle.svg"); + diameter: 40px; + glyph: 18px; + enabled: MyQbzDetailState.item-count > 0; + clicked => { MyQbzDetailActions.shuffle(); } + } + } + VerticalLayout { + alignment: center; + ActionCircle { + icon: @image-url("../assets/icons/cassette-tape.svg"); + diameter: 40px; + glyph: 18px; + enabled: MyQbzDetailState.item-count > 0; + clicked => { MyQbzDetailActions.dj-mix(); } + } + } + VerticalLayout { + alignment: center; + ActionCircle { + icon: @image-url("../assets/icons/pen-line.svg"); + diameter: 40px; + glyph: 18px; + clicked => { MyQbzDetailActions.edit(); } + } + } + VerticalLayout { + alignment: center; + ActionCircle { + icon: @image-url("../assets/icons/trash-2.svg"); + diameter: 40px; + glyph: 18px; + clicked => { MyQbzDetailActions.delete(); } + } + } + // Sync — artist collections only (parity with Tauri's + // discography sync surface; stub here). + if MyQbzDetailState.kind == "artist_collection": VerticalLayout { + alignment: center; + ActionCircle { + icon: @image-url("../assets/icons/refresh-cw.svg"); + diameter: 40px; + glyph: 18px; + clicked => { MyQbzDetailActions.sync(); } + } + } + } + } + } + + Rectangle { height: 32px; } + + // --- Empty list (collection with 0 items) ------------------ + if MyQbzDetailState.item-count == 0: Rectangle { + height: 80px; + Text { + text: @tr("No items yet. Add albums, tracks, or playlists from their detail pages."); + 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; + } + } + + // --- Toolbar + body (only when the collection has items) --- + if MyQbzDetailState.item-count > 0: VerticalLayout { + spacing: 0px; + alignment: start; + + // --- Sticky toolbar (.list-controls) ------------------- + HorizontalLayout { + height: 32px; + spacing: 8px; + // Search box. + search-box := Rectangle { + width: 260px; + height: 32px; + border-radius: 6px; + property has-query: MyQbzDetailState.search != ""; + background: (sin.has-focus || search-box.has-query) ? Theme.surface-main : Theme.surface-card; + border-width: 1px; + border-color: (sin.has-focus || search-box.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: search-box.has-query ? Theme.accent : Theme.text-muted; + } + } + Rectangle { + horizontal-stretch: 1; + clip: true; + sin := TextInput { + width: 100%; + height: 100%; + vertical-alignment: center; + color: Theme.text-primary; + font-size: 12px; + text <=> MyQbzDetailState.search; + edited => { MyQbzDetailActions.search-changed(self.text); } + } + if MyQbzDetailState.search == "": Text { + text: @tr("Search albums & tracks…"); + color: Theme.text-muted; + font-size: 12px; + vertical-alignment: center; + } + } + if search-box.has-query: VerticalLayout { + alignment: center; + Rectangle { + width: 18px; + height: 18px; + border-radius: 9px; + background: sclr-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: sclr-ta.has-hover ? Theme.text-primary : Theme.text-muted; + } + sclr-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + MyQbzDetailState.search = ""; + MyQbzDetailActions.search-changed(""); + } + } + } + } + } + } + + // 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: 170px; + 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; + MenuOption { + label: @tr("Position"); + selected: MyQbzDetailState.sort == "position"; + indicator: MyQbzDetailState.sort == "position" ? root.sort-arrow : ""; + chosen => { MyQbzDetailActions.set-sort("position"); } + } + MenuOption { + label: @tr("Name"); + selected: MyQbzDetailState.sort == "name"; + indicator: MyQbzDetailState.sort == "name" ? root.sort-arrow : ""; + chosen => { MyQbzDetailActions.set-sort("name"); } + } + MenuOption { + label: @tr("Year"); + selected: MyQbzDetailState.sort == "year"; + indicator: MyQbzDetailState.sort == "year" ? root.sort-arrow : ""; + chosen => { MyQbzDetailActions.set-sort("year"); } + } + MenuOption { + label: @tr("Tracks"); + selected: MyQbzDetailState.sort == "tracks"; + indicator: MyQbzDetailState.sort == "tracks" ? root.sort-arrow : ""; + chosen => { MyQbzDetailActions.set-sort("tracks"); } + } + } + } + } + } + + // Type + source filter dropdown. + Rectangle { + width: filter-btn.width; + filter-btn := ControlBtn { + icon: @image-url("../assets/icons/list-filter.svg"); + label: @tr("Filter"); + badge: MyQbzDetailState.filter-count; + active: MyQbzDetailState.filter-count > 0; + clicked => { filter-menu.show(); } + } + filter-menu := PopupWindow { + x: 0; y: 36px; width: 200px; + 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; + // Type section. + Text { + text: @tr("Type"); + color: Theme.text-muted; + font-size: 10px; + font-weight: Typography.semibold; + letter-spacing: 0.8px; + // padding 6 8 2. + height: 20px; + vertical-alignment: center; + horizontal-alignment: left; + } + MenuOption { + label: @tr("All types"); + selected: MyQbzDetailState.type-filter == "all"; + check: true; + chosen => { MyQbzDetailActions.set-type-filter("all"); } + } + MenuOption { + label: @tr("Album"); + selected: MyQbzDetailState.type-filter == "album"; + check: true; + chosen => { MyQbzDetailActions.set-type-filter("album"); } + } + MenuOption { + label: @tr("Track"); + selected: MyQbzDetailState.type-filter == "track"; + check: true; + chosen => { MyQbzDetailActions.set-type-filter("track"); } + } + MenuOption { + label: @tr("Playlist"); + selected: MyQbzDetailState.type-filter == "playlist"; + check: true; + chosen => { MyQbzDetailActions.set-type-filter("playlist"); } + } + Rectangle { height: 1px; background: Theme.surface-elevated; } + // Source section (multi-select). + Text { + text: @tr("Source"); + color: Theme.text-muted; + font-size: 10px; + font-weight: Typography.semibold; + letter-spacing: 0.8px; + height: 20px; + vertical-alignment: center; + horizontal-alignment: left; + } + MenuOption { + label: @tr("Qobuz"); + selected: MyQbzDetailState.src-qobuz; + check: true; + chosen => { MyQbzDetailActions.toggle-source-filter("qobuz"); } + } + MenuOption { + label: @tr("Plex"); + selected: MyQbzDetailState.src-plex; + check: true; + chosen => { MyQbzDetailActions.toggle-source-filter("plex"); } + } + MenuOption { + label: @tr("Local"); + selected: MyQbzDetailState.src-local; + check: true; + chosen => { MyQbzDetailActions.toggle-source-filter("local"); } + } + } + } + } + } + + // Reset (when any filter/sort differs from defaults). + if MyQbzDetailState.has-any-filter: ControlBtn { + icon: @image-url("../assets/icons/rotate-ccw.svg"); + subtle: true; + clicked => { MyQbzDetailActions.reset-filters(); } + } + + // Select / Cancel. + ControlBtn { + icon: @image-url("../assets/icons/square-check-big.svg"); + label: MyQbzDetailState.select-mode ? @tr("Cancel") : @tr("Select"); + active: MyQbzDetailState.select-mode; + clicked => { MyQbzDetailActions.toggle-select-mode(); } + } + + // Spacer pushes the view-mode group flush right. + Rectangle { horizontal-stretch: 1; } + + // View-mode group (list / grid / expanded). + VerticalLayout { + alignment: center; + Rectangle { + height: 32px; + width: 95px; + 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: MyQbzDetailState.view-mode == "list"; + clicked => { MyQbzDetailActions.set-view-mode("list"); } + } + Rectangle { width: 1px; background: Theme.surface-elevated; } + ViewSeg { + icon: @image-url("../assets/icons/layout-grid.svg"); + active: MyQbzDetailState.view-mode == "grid"; + clicked => { MyQbzDetailActions.set-view-mode("grid"); } + } + Rectangle { width: 1px; background: Theme.surface-elevated; } + ViewSeg { + icon: @image-url("../assets/icons/rows-3.svg"); + active: MyQbzDetailState.view-mode == "expanded"; + clicked => { MyQbzDetailActions.set-view-mode("expanded"); } + } + } + } + } + } + Rectangle { height: 10px; } + + // ============ BODY: GRID ============ + if MyQbzDetailState.view-mode == "grid": grid := Rectangle { + property columns: Math.max(1, + Math.floor((self.width - 24px + root.grid-gap) / (root.grid-min-w + root.grid-gap))); + // card width = floor((W - gap*(cols-1)) / cols). + property card-w: + (self.width - 24px - root.grid-gap * (self.columns - 1)) / self.columns; + property card-h: self.card-w + 8px + 18px + 16px + 16px; + property rows: Math.ceil(MyQbzDetailState.items.length / self.columns); + height: self.rows > 0 + ? self.rows * self.card-h + Math.max(0, self.rows - 1) * root.grid-gap + : 0px; + for item[i] in MyQbzDetailState.items: DetailCard { + x: 12px + Math.mod(i, grid.columns) * (grid.card-w + root.grid-gap); + y: Math.floor(i / grid.columns) * (grid.card-h + root.grid-gap); + item: item; + card-w: grid.card-w; + select-mode: MyQbzDetailState.select-mode; + } + } + + // ============ BODY: LIST / EXPANDED ============ + if MyQbzDetailState.view-mode != "grid": VerticalLayout { + spacing: 0px; + // Column header (.item-list-header) — HARDCODED labels. + Rectangle { + height: 1px; + background: Theme.surface-elevated; + } + HorizontalLayout { + height: 34px; + padding-left: 12px; + padding-right: 12px; + spacing: 12px; + Text { width: 40px; text: "#"; color: Theme.text-muted; font-size: 10px; font-weight: Typography.semibold; letter-spacing: 1.2px; horizontal-alignment: center; vertical-alignment: center; } + Text { horizontal-stretch: 1; text: @tr("Item"); color: Theme.text-muted; font-size: 10px; font-weight: Typography.semibold; letter-spacing: 1.2px; vertical-alignment: center; } + Text { width: 140px; text: @tr("Type"); color: Theme.text-muted; font-size: 10px; font-weight: Typography.semibold; letter-spacing: 1.2px; vertical-alignment: center; } + Text { width: 80px; text: @tr("Source"); color: Theme.text-muted; font-size: 10px; font-weight: Typography.semibold; letter-spacing: 1.2px; vertical-alignment: center; } + Text { width: 160px; text: @tr("Quality"); color: Theme.text-muted; font-size: 10px; font-weight: Typography.semibold; letter-spacing: 1.2px; vertical-alignment: center; } + Text { width: 72px; text: @tr("Tracks"); color: Theme.text-muted; font-size: 10px; font-weight: Typography.semibold; letter-spacing: 1.2px; horizontal-alignment: right; vertical-alignment: center; } + Text { width: 60px; text: @tr("Year"); color: Theme.text-muted; font-size: 10px; font-weight: Typography.semibold; letter-spacing: 1.2px; horizontal-alignment: right; vertical-alignment: center; } + Rectangle { width: 40px; } + } + Rectangle { + height: 1px; + background: Theme.surface-elevated; + } + + // Rows. Expanded mode renders the same rows; per-item inline + // track expansion is DEFERRED (a later slice) — the mode + // toggle + shell are present, but no inline TrackRows yet. + for item[i] in MyQbzDetailState.items: DetailRow { + item: item; + index: i + 1; + select-mode: MyQbzDetailState.select-mode; + } + + // Expanded-mode deferred note (only when no rows would + // otherwise hint at it): a thin caption so the toggle's + // effect is discoverable without faking the feature. + if MyQbzDetailState.view-mode == "expanded": Rectangle { + height: 28px; + Text { + text: @tr("Inline tracks coming soon"); + color: Theme.text-muted; + font-size: 11px; + 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; + } + } + } + } + } + } + + // Custom scrollbar (only meaningful in the loaded state). + ListScrollbar { + visible: !MyQbzDetailState.loading && MyQbzDetailState.found; + 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/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index 93ff015ba..b03310dce 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -45,6 +45,7 @@ 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 { MixtapeDetailView } from "../myqbz/MixtapeDetailView.slint"; import { OfflineManagerView } from "../offline/OfflineManagerView.slint"; import { LocalLibraryView } from "../locallibrary/LocalLibraryView.slint"; import { SettingsView } from "../settings/SettingsView.slint"; @@ -233,6 +234,7 @@ 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.mixtape-detail: MixtapeDetailView { } 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/state.slint b/crates/qbz-slint/ui/state.slint index d94bb7cb8..577562c8d 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, mixtapes, collections } +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, mixtape-detail } // In-app toast (transient notification). Mirrors Tauri's toastStore: // a single toast at a time (a new one replaces the current), auto-hidden @@ -1467,6 +1467,140 @@ export global MyQbzActions { callback col-reset(); } +// === My QBZ — Collection / Mixtape DETAIL view (read-only) ============== +// Spec 12. One row per collection item (album / track / playlist), rendered +// in the 8-col list / the grid. The Rust controller (crate::myqbz_detail) +// precomputes every display string (type label, source kind, quality detail, +// tracks/year columns, downscaled artwork URL) so the view does no lookups. +// READ-ONLY SCOPE (Phase-2 Slice 3): the hero action buttons + per-row +// context-menu items + grid/list view modes render 1:1, but every click is a +// logging STUB. The "expanded" view-mode toggle + shell render; per-item +// inline track expansion is DEFERRED (needs the enqueue resolver — a later +// slice). Edit/rename/delete/cover/DJ-mix modals + the BulkActionBar are also +// deferred. +export struct MixtapeDetailItem { + // 0-based persisted position (the row's stable key; display is +1). + position: int, + // "album" | "track" | "playlist". + item-type: string, + // "qobuz" | "local". + source: string, + source-item-id: string, + title: string, + subtitle: string, + // True for qobuz items whose subtitle is a clickable artist link + // (spec 12 §6.3 col-2 — local subtitles are plain text). + subtitle-is-link: bool, + // Resolved source kind for the source indicator + source filter + // ("qobuz" | "plex" | "local"). Defaults from `source` until resolveItems + // lands (deferred — for now equals the raw source mapping). + source-kind: string, + // Type-cell label (already localized): "ALBUM" / "TRACK" / "PLAYLIST" + // (or an EP/Single/… release-type override — overrides are a later slice). + type-label: string, + // Quality badge inputs (resolved; empty/0 until resolveItems — deferred). + quality-tier: string, // "hires" | "cd" | "" + quality-detail: string, // "24-bit / 96 kHz" | "" + // TRACKS column: "1" for a track, else the count or "—". + tracks-text: string, + // YEAR column: the year or "". + year-text: string, + // Downscaled (_50) row artwork; decoded into `artwork` by the pipeline. + artwork-url: string, + artwork: image, + // UI-owned multi-select state (select-mode — deferred wiring; the column + // renders when select-mode is on so geometry matches the PSD). + selected: bool, +} + +export global MyQbzDetailState { + in property id; + // "mixtape" | "collection" | "artist_collection". + in property kind; + // Eyebrow label, uppercase ("MIXTAPE" | "COLLECTION" | "ARTIST"). + in property kind-label; + in property name; + in property description; + // "5 albums" / "1 album" meta line (ICU plural, rendered in Rust). + in property meta; + in property item-count: 0; + // play_mode ("in_order" | "album_shuffle") — drives the overflow toggle + // label (deferred wiring; shown for parity). + in property play-mode: "in_order"; + // True when the user set a custom cover (full-bleeds the hero mosaic). + in property has-custom-cover: false; + in property custom-cover; + in property loading: false; + // False once the load completes with no collection (not-found state). + in property found: true; + + // --- Hero mosaic (CollectionMosaic inputs, same scheme as the card) ---- + // cover-count 0 (empty -> kind glyph) / 4 / 9; up-to-9 downscaled URLs + + // decoded covers. Mirrors MixtapeCardItem so CollectionMosaic renders it. + in property cover-count: 0; + in property url1; in property url2; in property url3; + in property url4; in property url5; in property url6; + in property url7; in property url8; in property url9; + in property cover1; in property cover2; in property cover3; + in property cover4; in property cover5; in property cover6; + in property cover7; in property cover8; in property cover9; + + // --- Item list (the render model: filter -> search -> sort applied) ---- + in property <[MixtapeDetailItem]> items: []; + + // --- Toolbar (session-scoped) ------------------------------------------ + in-out property search; + // "position" | "name" | "year" | "tracks". + in-out property sort: "position"; + in-out property sort-dir: "asc"; + // "all" | "album" | "track" | "playlist" (single-select). + in-out property type-filter: "all"; + // Multi-select source filter membership (spec 12 §5.5). Three flags + // (Slint has no Set); Rust derives the badge count. + in-out property src-qobuz: false; + in-out property src-plex: false; + in-out property src-local: false; + // "list" | "grid" | "expanded". + in-out property view-mode: "list"; + // Multi-select edit mode (renders the per-row checkbox column; bulk-bar + // wiring is DEFERRED). + in-out property select-mode: false; + in property selected-count: 0; + // Rust-computed filter badge count (sources + (type-filter != all)). + in property filter-count: 0; + // Whether any filter/sort differs from the defaults (drives the reset btn). + in property has-any-filter: false; +} + +export global MyQbzDetailActions { + // Client-side toolbar — re-derive the visible item list (UI thread). + callback search-changed(string /* query */); + callback set-sort(string /* field */); + callback set-type-filter(string /* all|album|track|playlist */); + callback toggle-source-filter(string /* qobuz|plex|local */); + callback set-view-mode(string /* list|grid|expanded */); + callback toggle-select-mode(); + callback reset-filters(); + // Open an item's detail (album / track-album / playlist). Routed to the + // existing open-album / open-playlist paths in main.rs. + callback open-item(string /* source */, string /* item-type */, string /* source-item-id */); + // Open an item's artist page by name (qobuz items only). + callback open-artist(string /* source */, string /* artist-name */); + // --- READ-ONLY STUBS (logged; not yet wired) -------------------------- + // Hero CTAs. + callback play-all(); + callback shuffle(); + callback dj-mix(); + callback edit(); // overflow: rename / description / cover / convert + callback delete(); + callback sync(); + // Per-row affordances. + callback play-item(string /* source-item-id */); + callback item-action(string /* source-item-id */, string /* action */); + // Toggle one row's selection (select-mode). + callback toggle-item-select(int /* position */); +} + // 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).