Files
qbz/crates/qbz-slint/src/myqbz_detail.rs
T
vicrodh 5fd41e8026 feat(slint): offline content filtering and LocalLibrary offline default (D11/D12)
Offline, unavailable content is filtered out rather than shown broken:
sidebar, manager and folder popups list only local playlists and mixed
playlists with local sidecar rows (100% Qobuz hidden; empty folders
skipped); a mixed playlist opens an offline-subset detail rendering only
its local sidecar rows (plex rows under induced only, read-only, same row
machinery); MyQBZ grids and details retain only available items (qobuz =
cached album/track within grace, plex = induced only, local always) and
collections with zero available items drop from the grids.

Mixed-playlist names offline: kept from the session cache on a mid-session
flip; honest 'Playlist (N local)' fallback on a cold offline start (names
are not persisted anywhere locally - noted as a future membership-snapshot
roadmap item).

D12: an offline session entry roots navigation at LocalLibrary, and an
engine watcher auto-navigates there when the mode flips to offline while a
blocked Qobuz view is open (re-filtering open MyQBZ views in place). No
navigation on the offline-to-online edge.

cc
2026-06-09 22:43:23 -06:00

1276 lines
54 KiB
Rust

//! 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, TrackItem};
/// Process-global runtime handle, set ONCE during startup wiring. The
/// mutation-reload paths (cover upload/remove, rename/description/convert/
/// remove-selected) re-run `navigate` to refresh the open detail, and
/// `navigate`'s resolveItems pass needs the runtime; rather than thread the
/// `Arc<AppRuntime>` through every one of those entry points + their main.rs
/// callsites, they pull it from here. The primary nav-in path still passes the
/// runtime explicitly. Set by `set_runtime` at wiring time.
static GLOBAL_RUNTIME: std::sync::OnceLock<
std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
> = std::sync::OnceLock::new();
/// Store the shared runtime for the global reload paths (idempotent — a second
/// call is ignored). Called once during startup wiring.
pub fn set_runtime(
runtime: std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
) {
let _ = GLOBAL_RUNTIME.set(runtime);
}
/// The shared runtime for the reload paths. `None` only before wiring (never in
/// practice, since reloads happen after the UI is up).
pub fn global_runtime(
) -> Option<std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>> {
GLOBAL_RUNTIME.get().cloned()
}
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()) };
/// Expanded-mode inline-tracks cache, keyed by a STABLE per-item key
/// (`source|source_item_id`). Populated ONCE per item when its inline tracks
/// are first resolved (spec 12 §8). It is the durable home of the resolved
/// tracks: `refresh_view` rebuilds the `MixtapeDetailItem` render rows on
/// every filter/sort/search (so the per-row `inline_tracks` would be wiped),
/// so after each re-derive we re-hydrate the rows from THIS cache instead of
/// re-fetching. The cached `Vec<TrackItem>` carries `slint::Image`s (`!Send`)
/// — safe here because the cache lives on the UI thread only. Cleared on
/// `reset` (a fresh collection open). Mirrors the Tauri per-item track cache
/// that survives the client-side re-derive.
static INLINE_CACHE: std::cell::RefCell<std::collections::HashMap<String, Vec<TrackItem>>> =
std::cell::RefCell::new(std::collections::HashMap::new());
/// Per-collection view-prefs "hydrated" gate (mirrors Tauri's
/// `prefsHydrated`). `false` from `reset` until `apply` has restored the
/// stored prefs; while `false` every toolbar persist is suppressed so an
/// early setter can't clobber the about-to-be-restored prefs. UI thread.
static PREFS_HYDRATED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// resolveItems cache, keyed by the STABLE per-item key
/// (`source|source_item_id`). Holds the LIVE-resolved per-row display values
/// that `MixtapeCollectionItem` alone can't carry: the resolved source kind
/// (qobuz / plex / local), the album-level quality tier + detail (derived
/// from the item's first resolved track), and the resolved TYPE label
/// (album -> EP/Single/Album by track count). Populated once per item by the
/// `resolve_items` pass (spawned after `apply`), re-hydrated in `to_item` on
/// every filter/sort/search re-derive so the columns stay populated without
/// re-fetching. Cleared on `reset`. UI thread only.
static RESOLVE_CACHE: std::cell::RefCell<std::collections::HashMap<String, ResolvedItem>> =
std::cell::RefCell::new(std::collections::HashMap::new());
}
/// The live-resolved per-row display values cached by `RESOLVE_CACHE`.
#[derive(Clone, Default)]
struct ResolvedItem {
/// "qobuz" | "plex" | "local".
source_kind: String,
/// "hires" | "cd" | "" — the row's `QualityBadgeFull` tier.
quality_tier: String,
/// "24-bit / 96 kHz" etc; "" when tier is "".
quality_detail: String,
/// Uppercased TYPE-column label (ALBUM / EP / SINGLE / TRACK / PLAYLIST).
type_label: String,
/// First resolved track's artwork (bare local file path / Plex
/// `/library/...` thumb / Qobuz URL — the `file://` prefix is stripped).
/// Backfills rows whose stored `artwork_url` was empty (e.g. disco-builder
/// local items saved with NULL art before the builder carried the cover).
artwork_url: String,
}
/// Persist the open collection's current toolbar prefs (spec 12 §18), gated on
/// the hydrated flag so a setter firing before `apply` restores the stored
/// prefs cannot clobber them. The persisted fields are the five §18 fields
/// (view-mode / sort / sort-dir / type-filter / source flags); search +
/// select-mode stay transient. UI thread.
pub fn persist_prefs(window: &AppWindow) {
if !PREFS_HYDRATED.with(|c| c.get()) {
return;
}
let state = window.global::<MyQbzDetailState>();
let id = state.get_id().to_string();
if id.is_empty() {
return;
}
let prefs = crate::myqbz_view_prefs::Prefs {
view_mode: state.get_view_mode().to_string(),
sort_by: state.get_sort().to_string(),
sort_dir: state.get_sort_dir().to_string(),
type_filter: state.get_type_filter().to_string(),
src_qobuz: state.get_src_qobuz(),
src_plex: state.get_src_plex(),
src_local: state.get_src_local(),
};
crate::myqbz_view_prefs::save(&id, &prefs);
}
/// Stable per-item key for the inline-tracks cache (`source|source_item_id`).
/// `source_item_id` alone is the row's logical key, but pairing it with the
/// source keeps qobuz-vs-local collisions impossible.
fn inline_cache_key(source: &str, source_item_id: &str) -> String {
format!("{source}|{source_item_id}")
}
// ──────────────────────────── 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",
}
}
pub fn source_str(source: AlbumSource) -> &'static str {
match source {
AlbumSource::Qobuz => "qobuz",
AlbumSource::Local => "local",
}
}
pub 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);
// `small_qobuz_url` only rewrites Qobuz CDN `_<size>.jpg` URLs; running it on
// a LOCAL filesystem path (or a Plex `/library/...` path) corrupts/no-ops it.
// Gate the rewrite to Qobuz items; local/plex artwork passes through raw so
// the source-aware artwork dispatch can read it as a file/Plex thumb.
let mut artwork_url = item
.artwork_url
.as_deref()
.filter(|u| !u.is_empty())
.map(|u| {
if item.source == AlbumSource::Qobuz {
crate::myqbz::small_qobuz_url(u, 50)
} else {
u.to_string()
}
})
.unwrap_or_default();
// Re-hydrate the live-resolved display values (source kind, quality
// tier/detail, type label, backfilled artwork) from the resolveItems cache
// so a filter/sort/search re-derive keeps the columns populated without
// re-fetching. A miss falls back to the stored-source defaults (qobuz/local
// + no quality), which the `resolve_items` pass then fills in. On a miss the
// row is flagged `quality_resolving` so the quality cell shows a skeleton
// until the async pass lands.
let cache_key = inline_cache_key(source, &item.source_item_id);
let resolved = RESOLVE_CACHE.with(|cell| cell.borrow().get(&cache_key).cloned());
let (source_kind, quality_tier, quality_detail, type_label, quality_resolving) = match resolved
{
Some(r) => {
// Backfill the row cover from the resolved track when the stored
// `artwork_url` was empty (disco-builder local items, older saves).
if artwork_url.is_empty() && !r.artwork_url.is_empty() {
artwork_url = r.artwork_url.clone();
}
(r.source_kind, r.quality_tier, r.quality_detail, r.type_label, false)
}
None => (
source.to_string(),
String::new(),
String::new(),
type_label(item.item_type).to_string(),
true,
),
};
// Re-hydrate inline tracks from the controller-level cache (keyed
// `source|source_item_id`) so a filter/sort/search re-derive does NOT lose
// already-resolved tracks or trigger a re-fetch (spec 12 §8 — the cache
// survives the re-derive). A cache hit marks the row loaded.
let cache_key = inline_cache_key(source, &item.source_item_id);
let (cached_tracks, tracks_loaded) = INLINE_CACHE.with(|cell| {
match cell.borrow().get(&cache_key) {
Some(tracks) => (tracks.clone(), true),
None => (Vec::new(), false),
}
});
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 / quality / type label — from the resolveItems
// cache (above) when resolved; else the stored-source defaults.
source_kind: source_kind.into(),
type_label: type_label.into(),
quality_tier: quality_tier.into(),
quality_detail: quality_detail.into(),
quality_resolving,
tracks_text: tracks_text(item).into(),
year_text: year_text(item).into(),
artwork_url: artwork_url.into(),
artwork: slint::Image::default(),
selected: false,
// Expanded-mode inline tracks (spec 12 §8): albums and playlists can
// host inline tracks; a bare track item is itself (no expansion).
can_expand: matches!(item.item_type, ItemType::Album | ItemType::Playlist),
// Loaded/tracks come from the per-item cache (above) so the re-derive
// keeps previously-resolved tracks instead of re-fetching.
tracks_loaded,
expand_loading: false,
inline_tracks: ModelRc::new(VecModel::from(cached_tracks)),
}
}
// ──────────────────────────── 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();
}
let Some(it) = c.items.get(i) else {
return slint::SharedString::default();
};
match it.artwork_url.as_deref() {
// `small_qobuz_url` is Qobuz-CDN-specific; only rewrite Qobuz cells.
// Local/Plex artwork paths pass through raw for the source-aware
// dispatch.
Some(u) if !u.is_empty() && it.source == AlbumSource::Qobuz => {
crate::myqbz::small_qobuz_url(u, target).into()
}
Some(u) if !u.is_empty() => u.to_string().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());
}
persist_prefs(window);
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());
persist_prefs(window);
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()),
_ => {}
}
persist_prefs(window);
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());
persist_prefs(window);
refresh_view(window);
}
/// Set the view-mode (list|grid|expanded) + persist it (spec 12 §18). The
/// expanded-mode inline-track fetch stays in `main.rs` (it needs the runtime +
/// handle); this only updates state + persists so the per-collection prefs
/// remember the chosen mode.
pub fn set_view_mode(window: &AppWindow, mode: &str) {
window.global::<MyQbzDetailState>().set_view_mode(mode.into());
persist_prefs(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);
}
/// The set of currently-selected row positions (select-mode), read off the
/// rendered item model. UI thread.
pub fn selected_positions(window: &AppWindow) -> Vec<i32> {
let model = window.global::<MyQbzDetailState>().get_items();
(0..model.row_count())
.filter_map(|i| model.row_data(i))
.filter(|it| it.selected)
.map(|it| it.position)
.collect()
}
/// The full `MixtapeCollectionItem`s (with year / track_count) for the
/// currently-selected positions, in ascending position order. Sourced from
/// `FULL_ITEMS` (the slint `MixtapeDetailItem` carries only display text, not
/// the numeric year/track_count the add payload needs). UI thread.
pub fn selected_full_items(window: &AppWindow) -> Vec<MixtapeCollectionItem> {
let mut positions = selected_positions(window);
positions.sort_unstable();
FULL_ITEMS.with(|cell| {
let items = cell.borrow();
positions
.iter()
.filter_map(|p| items.iter().find(|it| it.position == *p).cloned())
.collect()
})
}
// ──────────────────────── expanded-mode inline tracks ─────────────────────
/// The full `MixtapeCollectionItem` for one `source_item_id` (the row's stable
/// key). Sourced from `FULL_ITEMS` so the resolver gets the numeric
/// year/track_count + the typed item_type/source. UI thread.
fn full_item_by_source_id(source_item_id: &str) -> Option<MixtapeCollectionItem> {
FULL_ITEMS.with(|cell| {
cell.borrow()
.iter()
.find(|it| it.source_item_id == source_item_id)
.cloned()
})
}
/// "m:ss" track duration (spec 12 §8 `formatSec`). A zero/missing duration
/// renders the placeholder "--:--" (NOT "0:00") so an unresolved length reads as
/// unknown, matching the Tauri formatter. (`duration_secs` is `u64`, so the
/// "negative" case collapses to the zero case.)
fn track_duration_str(secs: u64) -> String {
if secs == 0 {
"--:--".to_string()
} else {
format!("{}:{:02}", secs / 60, secs % 60)
}
}
/// Title + parenthesized Qobuz version suffix (spec 12 §8 `formatTrackTitle`).
fn inline_track_title(track: &qbz_models::QueueTrack) -> String {
match track.version.as_deref().filter(|v| !v.is_empty()) {
Some(version) => format!("{} ({version})", track.title),
None => track.title.clone(),
}
}
/// Map one resolved `QueueTrack` into the shared `TrackItem` the inline
/// `TrackRow`s render. Quality tier/detail are derived the same way as the
/// now-playing + album-row badges (24-bit+ = Hi-Res), so the inline badge
/// matches every other surface. `source` drives the per-source `TrackRow`
/// affordances (Plex/local rows hide the favorite + offline columns).
///
/// `resolver_index` is the 0-based position of this track in the resolver's
/// output. The resolved `QueueTrack` carries no explicit album track number, so
/// the displayed number is the resolver's order (1-based) — i.e. "use the
/// resolver's track number when present", which for this model is the resolved
/// sequence position. (`TrackRow` would otherwise index-fall-back, but baking
/// the number here keeps the row number correct regardless of the caller.)
fn track_to_item(track: &qbz_models::QueueTrack, resolver_index: usize) -> TrackItem {
let quality_tier = match track.bit_depth {
Some(d) if d >= 24 => "hires",
Some(_) => "cd",
None if track.hires => "hires",
None => "",
};
let quality_detail = if quality_tier.is_empty() {
String::new()
} else {
crate::quality::detail(track.bit_depth, track.sample_rate)
};
let source = track
.source
.clone()
.unwrap_or_else(|| if track.is_local { "local".into() } else { "qobuz".into() });
TrackItem {
id: track.id.to_string().into(),
number: (resolver_index + 1).to_string().into(),
title: inline_track_title(track).into(),
artist: track.artist.clone().into(),
album: String::new().into(),
duration: track_duration_str(track.duration_secs).into(),
quality_tier: quality_tier.into(),
quality_detail: quality_detail.into(),
explicit: track.parental_warning,
selected: false,
artwork_url: String::new().into(),
artwork: slint::Image::default(),
is_favorite: false,
artist_id: track.artist_id.map(|id| id.to_string()).unwrap_or_default().into(),
album_id: track.album_id.clone().unwrap_or_default().into(),
source: source.into(),
removing: false,
cache_status: 0,
cache_progress: 0.0,
unlocking: false,
}
}
/// Find the rendered row for `source_item_id` and mutate it in place. UI thread.
fn with_row_by_source_id<F: FnOnce(&mut MixtapeDetailItem)>(
window: &AppWindow,
source_item_id: &str,
f: F,
) {
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.source_item_id == source_item_id {
f(&mut it);
model.set_row_data(i, it);
break;
}
}
}
}
/// Ensure every expandable item's inline tracks are loaded (spec 12 §8). Fired
/// when the "expanded" view-mode becomes active. For each rendered row that
/// `can_expand` and is not already loaded / loading, flips `expand-loading` on
/// and spawns a per-item fetch via the shared enqueue resolver
/// (`myqbz_play::fetch_item_tracks`); on completion it populates that row's
/// inline-tracks model + marks it loaded. Idempotent: already-cached rows are
/// skipped, so re-entering expanded mode is instant (and re-deriving the model
/// after a filter/sort resets `tracks_loaded`, so the new rows re-fetch).
pub fn ensure_expanded(
runtime: std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
) {
let Some(window) = weak.upgrade() else { return };
let model = window.global::<MyQbzDetailState>().get_items();
// Snapshot the rows that still need a fetch (source + source-item-id), then
// mark them loading in one pass (mutating the model while iterating is fine
// — we set_row_data the same index we read). `tracks_loaded` rows are
// skipped: the cache already re-hydrated them in `to_item`, so a re-derive
// is instant (no re-fetch).
let mut pending: Vec<(String, String)> = Vec::new();
for i in 0..model.row_count() {
if let Some(mut it) = model.row_data(i) {
if it.can_expand && !it.tracks_loaded && !it.expand_loading {
it.expand_loading = true;
let source = it.source.to_string();
let id = it.source_item_id.to_string();
model.set_row_data(i, it);
pending.push((source, id));
}
}
}
for (source, source_item_id) in pending {
let Some(full_item) = full_item_by_source_id(&source_item_id) else {
// No backing item (shouldn't happen) — clear the spinner.
with_row_by_source_id(&window, &source_item_id, |it| it.expand_loading = false);
continue;
};
let runtime = runtime.clone();
let weak = weak.clone();
handle.spawn(async move {
// `Vec<QueueTrack>` is `Send`; the mapped `Vec<TrackItem>` carries
// a `slint::Image` (!Send), so it must be built INSIDE the event
// loop, not moved across the thread boundary.
let tracks = crate::myqbz_play::fetch_item_tracks(&runtime, &full_item).await;
let _ = weak.upgrade_in_event_loop(move |w| {
let items: Vec<TrackItem> = tracks
.iter()
.enumerate()
.map(|(i, t)| track_to_item(t, i))
.collect();
// Persist into the controller-level cache (keyed
// `source|source_item_id`) so a later filter/sort/search
// re-derive re-hydrates this row from the cache instead of
// re-fetching (spec 12 §8 — cache survives the re-derive).
let cache_key = inline_cache_key(&source, &source_item_id);
INLINE_CACHE.with(|cell| {
cell.borrow_mut().insert(cache_key, items.clone());
});
with_row_by_source_id(&w, &source_item_id, |it| {
it.expand_loading = false;
it.tracks_loaded = true;
it.inline_tracks = ModelRc::new(VecModel::from(items));
});
});
});
}
}
/// Derive a row's resolved display values from its resolved tracks (spec §17
/// resolveItems). Album-level quality = the item's first resolved track's
/// quality (24-bit+ = Hi-Res); the same tier/detail rule every other surface
/// uses. Source kind = the first track's `source` (or `is_local` fallback) —
/// this is how a Plex item (stored as `AlbumSource::Local`) is finally
/// classified `"plex"`. Type label: non-album rows keep their stored type;
/// album rows resolve to ALBUM/EP/SINGLE by the resolved track count (the
/// `QueueTrack` payload carries no release_type, so the track-count heuristic
/// — the same one favorites/labels use — applies).
fn resolve_from_tracks(
item: &MixtapeCollectionItem,
tracks: &[qbz_models::QueueTrack],
) -> ResolvedItem {
let stored = source_str(item.source);
let first = tracks.first();
let source_kind = match first {
Some(t) => t
.source
.clone()
.unwrap_or_else(|| if t.is_local { "local".into() } else { "qobuz".into() }),
None => stored.to_string(),
};
let quality_tier = match first {
Some(t) => match t.bit_depth {
Some(d) if d >= 24 => "hires",
Some(_) => "cd",
None if t.hires => "hires",
None => "",
},
None => "",
};
let quality_detail = match (first, quality_tier.is_empty()) {
(Some(t), false) => crate::quality::detail(t.bit_depth, t.sample_rate),
_ => String::new(),
};
// Type label: albums resolve their release type from the resolved track
// count; tracks/playlists keep their stored type. Uppercased to match the
// column eyebrow.
let type_label = match item.item_type {
ItemType::Album => {
crate::album_map::classify_release_type(Some(tracks.len() as u32)).to_uppercase()
}
other => type_label(other).to_string(),
};
// First resolved track's artwork — backfills rows whose stored
// `artwork_url` was empty (disco-builder local items saved with NULL art).
// Strip the `file://` prefix that `local_queue_track` adds: the source-aware
// artwork dispatch reads a bare filesystem path (a raw `tokio::fs::read` of a
// `file://…` URI fails). Plex `/library/...` thumbs and Qobuz CDN urls have
// no prefix and pass through unchanged.
let artwork_url = first
.and_then(|t| t.artwork_url.clone())
.map(|u| u.strip_prefix("file://").map(str::to_string).unwrap_or(u))
.unwrap_or_default();
ResolvedItem {
source_kind,
quality_tier: quality_tier.to_string(),
quality_detail,
type_label,
artwork_url,
}
}
/// resolveItems pass (spec §17): resolve every rendered row's tracks via the
/// shared enqueue resolver (`myqbz_play::fetch_item_tracks` — the SAME
/// qobuz/local/plex backends), derive the row's source kind + album-level
/// quality + type label from the first resolved track, push the values into the
/// row, and cache them (keyed `source|source_item_id`) so a later filter/sort/
/// search re-derive re-hydrates instead of re-fetching. Spawned once after
/// `apply` (alongside the artwork jobs); already-cached rows are skipped, so a
/// re-derive is instant. Fire-and-forget: failures leave the stored-source
/// defaults in place.
pub fn resolve_items(
runtime: std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
) {
let Some(window) = weak.upgrade() else { return };
// Snapshot the items needing resolution (every full item not already
// cached). Sourced from FULL_ITEMS so the resolver gets the typed
// item_type/source + numeric fields.
let pending: Vec<MixtapeCollectionItem> = FULL_ITEMS.with(|cell| {
cell.borrow()
.iter()
.filter(|it| {
let key = inline_cache_key(source_str(it.source), &it.source_item_id);
!RESOLVE_CACHE.with(|c| c.borrow().contains_key(&key))
})
.cloned()
.collect()
});
drop(window);
for full_item in pending {
let runtime = runtime.clone();
let weak = weak.clone();
let image_cache = image_cache.clone();
handle.spawn(async move {
let tracks = crate::myqbz_play::fetch_item_tracks(&runtime, &full_item).await;
let resolved = resolve_from_tracks(&full_item, &tracks);
let source = source_str(full_item.source).to_string();
let source_item_id = full_item.source_item_id.clone();
let stored_artwork_empty = full_item
.artwork_url
.as_deref()
.map(|u| u.is_empty())
.unwrap_or(true);
let _ = weak.upgrade_in_event_loop(move |w| {
let key = inline_cache_key(&source, &source_item_id);
RESOLVE_CACHE.with(|cell| {
cell.borrow_mut().insert(key, resolved.clone());
});
// Push the resolved values into the currently-rendered row (if
// still present after any re-derive). Clear `quality_resolving`
// (the skeleton) and backfill the row cover when the stored
// `artwork_url` was empty (disco-builder local items, older
// saves) so the disc placeholder is replaced by the real art —
// the album-view pattern applied to the detail rows.
let mut backfilled_pos: Option<i32> = None;
with_row_by_source_id(&w, &source_item_id, |it| {
it.source_kind = resolved.source_kind.clone().into();
it.quality_tier = resolved.quality_tier.clone().into();
it.quality_detail = resolved.quality_detail.clone().into();
it.type_label = resolved.type_label.clone().into();
it.quality_resolving = false;
if it.artwork_url.is_empty() && !resolved.artwork_url.is_empty() {
it.artwork_url = resolved.artwork_url.clone().into();
backfilled_pos = Some(it.position);
}
});
// Dispatch the one backfilled cover through the source-aware
// path (qobuz CDN -> HTTP; local/plex -> source-aware decode).
// Only when the stored art was empty AND a row was actually
// backfilled (skips the common already-had-art case).
if stored_artwork_empty {
if let Some(pos) = backfilled_pos {
let job = ArtworkJob {
target: ArtworkTarget::MyQbzDetailRow { position: pos },
url: resolved.artwork_url.clone(),
};
let split = if resolved.source_kind == "qobuz" {
ArtworkJobSplit { remote: vec![job], ..Default::default() }
} else {
ArtworkJobSplit { local_or_plex: vec![job], ..Default::default() }
};
dispatch_artwork(split, w.as_weak(), image_cache.clone());
}
}
});
});
}
}
/// Clear the current selection (uncheck every row + zero the count), staying in
/// select-mode. Used after a bulk action completes. UI thread.
pub fn clear_selection(window: &AppWindow) {
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.selected {
it.selected = false;
model.set_row_data(i, it);
}
}
}
state.set_selected_count(0);
}
// ──────────────────────────── 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());
// Drop the inline-tracks cache — a different collection's tracks must not
// leak into the freshly-opened one.
INLINE_CACHE.with(|cell| cell.borrow_mut().clear());
// Drop the resolveItems cache too (same reason — the resolved source/
// quality/type of a different collection's items must not leak).
RESOLVE_CACHE.with(|cell| cell.borrow_mut().clear());
// Close the persist gate until `apply` restores this collection's prefs —
// any toolbar setter that fires meanwhile must NOT overwrite stored prefs
// with the in-flight defaults (mirrors Tauri's prefsHydrated).
PREFS_HYDRATED.with(|c| c.set(false));
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 -> defaults during load; `apply` then restores this collection's
// persisted view-prefs (spec 12 §18) over these. Search + select-mode stay
// transient (never persisted) so they always start fresh.
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);
// Restore this collection's persisted view-prefs over the reset defaults
// (spec 12 §18). `load` returns the §18 defaults when nothing is stored, so
// a never-opened collection lands on list/position/asc/all/empty exactly as
// before. Open the persist gate AFTER applying so the restore itself isn't
// re-persisted (and so subsequent setter-driven persists are live).
let prefs = crate::myqbz_view_prefs::load(c.id.as_str());
state.set_view_mode(prefs.view_mode.into());
state.set_sort(prefs.sort_by.into());
state.set_sort_dir(prefs.sort_dir.into());
state.set_type_filter(prefs.type_filter.into());
state.set_src_qobuz(prefs.src_qobuz);
state.set_src_plex(prefs.src_plex);
state.set_src_local(prefs.src_local);
PREFS_HYDRATED.with(|cell| cell.set(true));
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 ────────────────────────────
/// The artwork jobs for the loaded collection, SPLIT by source so each is
/// dispatched through the correct decoder (spec §17 fallback chain): Qobuz items
/// carry an HTTP CDN url → the Remote/HTTP path (`spawn_loads`); local/Plex
/// items carry a filesystem path or a Plex `/library/...` path → the
/// source-aware path (`spawn_local_or_plex_loads`). Mixing them (the old single
/// `spawn_loads`) broke local/Plex covers — a filesystem path was fetched as an
/// HTTP url and failed silently, leaving the row/hero cell blank.
#[derive(Default)]
pub struct ArtworkJobSplit {
/// Qobuz CDN urls — HTTP fetch via the disk cache.
pub remote: Vec<ArtworkJob>,
/// Local filesystem paths + Plex thumb paths — source-aware decode.
pub local_or_plex: Vec<ArtworkJob>,
}
/// Build the (remote, local/plex) artwork jobs for the loaded collection: the
/// up-to-9 hero-mosaic cells (only when no custom cover) + one thumbnail per
/// visible item row. Each job is routed to the `remote` bucket for Qobuz items
/// and the `local_or_plex` bucket otherwise.
pub fn artwork_jobs(window: &AppWindow) -> ArtworkJobSplit {
let state = window.global::<MyQbzDetailState>();
let mut split = ArtworkJobSplit::default();
// Hero mosaic cells: classify each cell by the corresponding FULL_ITEMS
// item's source (the cells map 1:1 to the first N items in original order).
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(),
];
let cell_sources: Vec<AlbumSource> =
FULL_ITEMS.with(|cell| cell.borrow().iter().map(|it| it.source).collect());
for (slot, url) in urls.iter().enumerate() {
if url.is_empty() {
continue;
}
let job = ArtworkJob {
target: ArtworkTarget::MyQbzDetailCover { slot },
url: url.to_string(),
};
match cell_sources.get(slot) {
Some(AlbumSource::Qobuz) => split.remote.push(job),
_ => split.local_or_plex.push(job),
}
}
}
// Row thumbnails (the rendered model — matched back by position on apply).
// Route by the row's resolved source-kind (qobuz -> remote; plex/local ->
// source-aware). A not-yet-resolved row defaults to its stored kind.
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() {
continue;
}
let job = ArtworkJob {
target: ArtworkTarget::MyQbzDetailRow { position: item.position },
url: item.artwork_url.to_string(),
};
if item.source_kind == "qobuz" {
split.remote.push(job);
} else {
split.local_or_plex.push(job);
}
}
split
}
/// Dispatch a built `ArtworkJobSplit` through the correct decoders: Qobuz CDN
/// urls via the HTTP path (`spawn_loads`), local/Plex paths via the source-aware
/// path (`spawn_local_or_plex_loads`, threading the live Plex creds). The single
/// entry point both `navigate` (initial load) and the toolbar re-derive
/// (`refresh_row_covers`) use, so the source-split routing lives in ONE place.
pub fn dispatch_artwork(
split: ArtworkJobSplit,
weak: slint::Weak<AppWindow>,
image_cache: ImageCache,
) {
if !split.remote.is_empty() {
artwork::spawn_loads(split.remote, weak.clone(), image_cache.clone());
}
if !split.local_or_plex.is_empty() {
let plex = crate::plex_settings::get();
artwork::spawn_local_or_plex_loads(
split.local_or_plex,
plex.base_url,
plex.token,
weak,
image_cache,
);
}
}
/// 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 (source-split) artwork + the resolveItems pass. Mirrors
/// `myqbz::navigate` (load/apply/render) and the album/playlist detail
/// navigators. The `runtime` drives the resolveItems backend resolution
/// (quality / source-kind / type per item).
pub fn navigate(
runtime: std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
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();
// D11.c — OFFLINE: drop the items failing the availability rule
// (qobuz not-cached / grace-expired; plex under real offline) before
// the rows render. Online: untouched.
let collection = match collection {
Some(mut c) if crate::offline_mode::engine().is_offline() => {
let items: Vec<&qbz_models::mixtape::MixtapeCollectionItem> =
c.items.iter().collect();
let avail = crate::myqbz::offline_availability(&items).await;
drop(items);
let before = c.items.len();
c.items.retain(|it| avail.item_available(it));
if c.items.len() < before {
log::info!(
"[qbz-slint] myqbz_detail {}: {} item(s) unavailable offline, hidden (D11)",
c.id,
before - c.items.len()
);
}
Some(c)
}
other => other,
};
let resolve_handle = handle.clone();
let _ = weak.upgrade_in_event_loop(move |w| match collection {
Some(c) => {
apply(&w, c);
let split = artwork_jobs(&w);
dispatch_artwork(split, w.as_weak(), image_cache.clone());
// resolveItems (spec §17): resolve each item's quality / source
// kind / type from the backends and hydrate the rows (also
// backfills + dispatches covers for rows stored with empty art).
resolve_items(runtime, w.as_weak(), resolve_handle, image_cache.clone());
}
None => {
log::warn!("[qbz-slint] myqbz_detail navigate({id}): collection not found");
apply_not_found(&w);
}
});
});
}