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
This commit is contained in:
vicrodh committed 2026-06-09 03:43:56 -06:00
1 parent c0d980e9c7
commit ec2a52707f
9 files changed
+2284 -8

No files matched your search

+18 -1
View File
@@ -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);
}
}
}
+221 -4
View File
@@ -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::<MyQbzActions>().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::<MyQbzActions>().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<AppRuntime<SlintAdapter>>,
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::<Act>().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::<Act>().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::<Act>().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::<Act>().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::<Act>().on_set_view_mode(move |mode| {
if let Some(w) = weak.upgrade() {
w.global::<MyQbzDetailState>().set_view_mode(mode);
}
});
}
{
let weak = window.as_weak();
window.global::<Act>().on_toggle_select_mode(move || {
if let Some(w) = weak.upgrade() {
myqbz_detail::toggle_select_mode(&w);
}
});
}
{
let weak = window.as_weak();
window.global::<Act>().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::<Act>().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::<Act>()
.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::<Act>()
.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::<MyQbzDetailState>().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::<Act>().on_play_all(move || s("play-all"));
let s = log_stub.clone();
window.global::<Act>().on_shuffle(move || s("shuffle"));
let s = log_stub.clone();
window.global::<Act>().on_dj_mix(move || s("dj-mix"));
let s = log_stub.clone();
window.global::<Act>().on_edit(move || s("edit"));
let s = log_stub.clone();
window.global::<Act>().on_delete(move || s("delete"));
let s = log_stub.clone();
window.global::<Act>().on_sync(move || s("sync"));
}
{
window.global::<Act>().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::<Act>()
.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<dyn std::error::Error>> {
// === 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
+1 -1
View File
@@ -87,7 +87,7 @@ fn album_count_label(count: usize) -> String {
/// mosaic's `smallQobuzUrl` (regex-swap `_<old>.jpg` → `_<target>.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();
}
+592
View File
@@ -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<Vec<MixtapeCollectionItem>> =
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<MixtapeCollection> {
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::<MyQbzDetailState>();
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<MixtapeCollectionItem> = 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<MixtapeDetailItem> = 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::<MyQbzDetailState>().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::<MyQbzDetailState>();
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::<MyQbzDetailState>().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::<MyQbzDetailState>();
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::<MyQbzDetailState>();
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::<MyQbzDetailState>();
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::<MyQbzDetailState>();
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::<MyQbzDetailState>();
state.set_loading(true);
state.set_found(true);
state.set_items(ModelRc::new(VecModel::from(Vec::<MixtapeDetailItem>::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::<MyQbzDetailState>();
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::<MyQbzDetailState>();
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<ArtworkJob> {
let state = window.global::<MyQbzDetailState>();
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::<MyQbzDetailState>().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::<MyQbzDetailState>();
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<AppWindow>,
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::<NavState>().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);
}
});
});
}
+6
View File
@@ -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.