fix(myqbz): visual homologation — app components, colors, scrollbar, resolveItems
Per the user's visual review. Replaces the reinvented MyQBZ controls with the app's existing primitives and resolves the per-item data left unresolved. Cross-cutting (all MyQBZ views): - Removed the per-view "Back" header (the app uses the global < > nav). - 32px L/R content padding so the ListScrollbar never rides over rows/cards. - Homologated page padding + Typography tokens to Favorites/LocalLibrary. - No more blue accent-filled pills / non-app colors. Component homologation (deleted the reinvented locals + dead code): - Grid/list + detail view-mode toggle -> the app ViewToggle (ToggleButton, surface-hover active, never accent). Builder ORDER-BY segmented de-blued too. - Sort/filter -> QbzSelect (sm); search -> ExpandableSearch (sm); "+ New" -> NewActionBtn (surface-elevated circle, single centered glyph). Header "+ New" hidden when empty so the empty-state CTA is the only one (no duplicate). - Deleted PrimaryCta / MyQbzSearch / ControlBtn / ViewSeg / ViewModeGroup / SortItem / ColMenuItem + the hand-rolled sort/filter popups + reset. Data (resolveItems, was deferred): - Quality column populated from the resolved tracks (QualityBadgeFull). - New ui/primitives/SourceGlyph.slint: full-color Qobuz/Plex glyphs + a text-muted local hard-drive -- distinguishes Local vs Plex, no accent blob. Reused in the detail + builder rows. - Type column shows the real release type (ALBUM/EP/SINGLE), not always ALBUM. - Local/Plex album art now loads in the detail rows (split artwork dispatch). - Collection grid card renders custom_artwork_path. - Builder rows: 44px + a hairline so quality badges read separated. Backend crates untouched. cargo check -p qbz-slint green. cc
This commit is contained in:
12 files changed
+811
-1140
No files matched your search
@@ -1037,7 +1037,13 @@ fn apply_entry(
|
||||
);
|
||||
}
|
||||
nav::NavEntry::MixtapeDetail(id) => {
|
||||
myqbz_detail::navigate(weak.clone(), handle.clone(), image_cache.clone(), id);
|
||||
myqbz_detail::navigate(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
image_cache.clone(),
|
||||
id,
|
||||
);
|
||||
}
|
||||
nav::NavEntry::DiscographyBuilder(artist_id) => {
|
||||
navigate_discography_builder(
|
||||
@@ -2031,6 +2037,7 @@ fn wire_playlist_manager(
|
||||
/// + re-issue mosaic artwork jobs. Mirrors `wire_playlist_manager`.
|
||||
fn wire_myqbz(
|
||||
window: &AppWindow,
|
||||
app_runtime: &Arc<AppRuntime<SlintAdapter>>,
|
||||
tokio_rt: &tokio::runtime::Runtime,
|
||||
image_cache: &artwork::ImageCache,
|
||||
) {
|
||||
@@ -2050,9 +2057,11 @@ fn wire_myqbz(
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
let runtime = app_runtime.clone();
|
||||
window.global::<MyQbzActions>().on_open_card(move |id| {
|
||||
nav::record(nav::NavEntry::MixtapeDetail(id.to_string()));
|
||||
myqbz_detail::navigate(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
image_cache.clone(),
|
||||
@@ -2107,6 +2116,7 @@ fn wire_myqbz(
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
let runtime = app_runtime.clone();
|
||||
window.global::<MyQbzCreateActions>().on_submit(move || {
|
||||
let Some(w) = weak.upgrade() else { return; };
|
||||
let st = w.global::<MyQbzCreateState>();
|
||||
@@ -2120,6 +2130,7 @@ fn wire_myqbz(
|
||||
let weak = weak.clone();
|
||||
let handle = handle.clone();
|
||||
let image_cache = image_cache.clone();
|
||||
let runtime = runtime.clone();
|
||||
handle.clone().spawn(async move {
|
||||
let nm = name.trim().to_string();
|
||||
let created =
|
||||
@@ -2131,6 +2142,7 @@ fn wire_myqbz(
|
||||
let weak2 = weak.clone();
|
||||
let handle2 = handle.clone();
|
||||
let image_cache2 = image_cache.clone();
|
||||
let runtime2 = runtime.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let st = w.global::<MyQbzCreateState>();
|
||||
st.set_creating(false);
|
||||
@@ -2141,6 +2153,7 @@ fn wire_myqbz(
|
||||
// Drop into the new collection's detail view.
|
||||
nav::record(nav::NavEntry::MixtapeDetail(c.id.clone()));
|
||||
myqbz_detail::navigate(
|
||||
runtime2.clone(),
|
||||
weak2.clone(),
|
||||
handle2.clone(),
|
||||
image_cache2.clone(),
|
||||
@@ -2398,11 +2411,17 @@ fn wire_myqbz_detail(
|
||||
) {
|
||||
use MyQbzDetailActions as Act;
|
||||
|
||||
// Stash the runtime for the mutation-reload paths (cover/edit) that re-run
|
||||
// `myqbz_detail::navigate` (whose resolveItems pass needs it) without
|
||||
// threading it through every entry point.
|
||||
myqbz_detail::set_runtime(app_runtime.clone());
|
||||
|
||||
// After a toolbar re-derive the rendered model changed, so the visible
|
||||
// rows need their thumbnails reloaded.
|
||||
// rows need their thumbnails reloaded — through the SOURCE-SPLIT dispatch
|
||||
// (Qobuz CDN urls via HTTP; local/Plex paths via the source-aware decoder).
|
||||
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());
|
||||
let split = myqbz_detail::artwork_jobs(window);
|
||||
myqbz_detail::dispatch_artwork(split, window.as_weak(), image_cache.clone());
|
||||
}
|
||||
|
||||
// A toolbar re-derive rebuilds the rendered model with fresh rows
|
||||
@@ -3162,12 +3181,15 @@ fn wire_disco_builder(
|
||||
myqbz_builder::toast_created(&w);
|
||||
// Navigate to the new collection's detail.
|
||||
nav::record(nav::NavEntry::MixtapeDetail(collection_id.clone()));
|
||||
myqbz_detail::navigate(
|
||||
w.as_weak(),
|
||||
handle.clone(),
|
||||
image_cache.clone(),
|
||||
collection_id,
|
||||
);
|
||||
if let Some(runtime) = myqbz_detail::global_runtime() {
|
||||
myqbz_detail::navigate(
|
||||
runtime,
|
||||
w.as_weak(),
|
||||
handle.clone(),
|
||||
image_cache.clone(),
|
||||
collection_id,
|
||||
);
|
||||
}
|
||||
update_nav_flags(&w);
|
||||
}
|
||||
None => {
|
||||
@@ -7894,7 +7916,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// === Playlist Manager actions ======================================
|
||||
wire_playlist_manager(&window, &app_runtime, &tokio_rt, &image_cache);
|
||||
wire_myqbz(&window, &tokio_rt, &image_cache);
|
||||
wire_myqbz(&window, &app_runtime, &tokio_rt, &image_cache);
|
||||
wire_myqbz_detail(&window, &app_runtime, &tokio_rt, &image_cache);
|
||||
wire_disco_builder(&window, &tokio_rt, &image_cache);
|
||||
{
|
||||
|
||||
@@ -166,7 +166,23 @@ fn cell_target(size: u32, cols: u32) -> u32 {
|
||||
/// 2x2-vs-3x3 rule, and pre-downscales the up-to-9 cover URLs per cell.
|
||||
fn card_item(c: &MixtapeCollection) -> MixtapeCardItem {
|
||||
let item_count = c.items.len();
|
||||
let has_custom = c.custom_artwork_path.is_some();
|
||||
|
||||
// Decode the custom cover from disk so a custom-art mixtape/collection
|
||||
// renders its real image in the grid (NOT a blank square). Same source-aware
|
||||
// load as the detail view (the path is the on-disk artwork-cache file). A
|
||||
// missing/undecodable path is treated as "no custom cover" so the mosaic
|
||||
// shows instead of an empty full-bleed square — and so `has_custom` drives
|
||||
// cover_count + the URL closure consistently below.
|
||||
let decoded_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());
|
||||
let (custom_image, has_custom) = match decoded_custom {
|
||||
Some(img) => (img, true),
|
||||
None => (slint::Image::default(), false),
|
||||
};
|
||||
|
||||
// cols: 3x3 only for a Collection with >= 9 items; else 2x2.
|
||||
let cols: u32 = if c.kind == CollectionKind::Collection && item_count >= 9 {
|
||||
@@ -197,8 +213,6 @@ fn card_item(c: &MixtapeCollection) -> MixtapeCardItem {
|
||||
}
|
||||
};
|
||||
|
||||
let custom_image = slint::Image::default();
|
||||
|
||||
MixtapeCardItem {
|
||||
id: c.id.clone().into(),
|
||||
name: c.name.clone().into(),
|
||||
|
||||
@@ -224,8 +224,9 @@ fn reload(
|
||||
image_cache: ImageCache,
|
||||
id: String,
|
||||
) {
|
||||
let Some(runtime) = crate::myqbz_detail::global_runtime() else { return };
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let _ = &w; // keep the closure's capture explicit.
|
||||
crate::myqbz_detail::navigate(w.as_weak(), handle.clone(), image_cache.clone(), id);
|
||||
crate::myqbz_detail::navigate(runtime, w.as_weak(), handle.clone(), image_cache.clone(), id);
|
||||
});
|
||||
}
|
||||
@@ -34,6 +34,32 @@ 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
|
||||
@@ -59,6 +85,31 @@ thread_local! {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Persist the open collection's current toolbar prefs (spec 12 §18), gated on
|
||||
@@ -198,13 +249,40 @@ fn year_text(item: &MixtapeCollectionItem) -> String {
|
||||
/// 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 artwork_url = item
|
||||
.artwork_url
|
||||
.as_deref()
|
||||
.filter(|u| !u.is_empty())
|
||||
.map(|u| crate::myqbz::small_qobuz_url(u, 50))
|
||||
.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) 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.
|
||||
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) = match resolved {
|
||||
Some(r) => (r.source_kind, r.quality_tier, r.quality_detail, r.type_label),
|
||||
None => (
|
||||
source.to_string(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
type_label(item.item_type).to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
// 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
|
||||
@@ -227,13 +305,12 @@ fn to_item(item: &MixtapeCollectionItem) -> MixtapeDetailItem {
|
||||
// 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(),
|
||||
// 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(),
|
||||
tracks_text: tracks_text(item).into(),
|
||||
year_text: year_text(item).into(),
|
||||
artwork_url: artwork_url.into(),
|
||||
@@ -278,8 +355,17 @@ fn apply_hero_mosaic(state: &MyQbzDetailState, c: &MixtapeCollection) {
|
||||
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(),
|
||||
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(),
|
||||
}
|
||||
};
|
||||
@@ -685,6 +771,119 @@ pub fn ensure_expanded(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
};
|
||||
|
||||
ResolvedItem {
|
||||
source_kind,
|
||||
quality_tier: quality_tier.to_string(),
|
||||
quality_detail,
|
||||
type_label,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
) {
|
||||
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();
|
||||
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 _ = 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).
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -710,6 +909,9 @@ pub fn reset(window: &AppWindow) {
|
||||
// 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).
|
||||
@@ -805,13 +1007,31 @@ pub fn apply_not_found(window: &AppWindow) {
|
||||
|
||||
// ──────────────────────────── 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();
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
// Hero mosaic cells.
|
||||
/// 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(),
|
||||
@@ -824,28 +1044,68 @@ pub fn artwork_jobs(window: &AppWindow) -> Vec<ArtworkJob> {
|
||||
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() {
|
||||
jobs.push(ArtworkJob {
|
||||
target: ArtworkTarget::MyQbzDetailCover { slot },
|
||||
url: url.to_string(),
|
||||
});
|
||||
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() {
|
||||
jobs.push(ArtworkJob {
|
||||
target: ArtworkTarget::MyQbzDetailRow { position: item.position },
|
||||
url: item.artwork_url.to_string(),
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
jobs
|
||||
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
|
||||
@@ -884,9 +1144,12 @@ pub fn set_hero_cover(window: &AppWindow, slot: usize, image: slint::Image) {
|
||||
|
||||
/// 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.
|
||||
/// 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,
|
||||
@@ -905,11 +1168,15 @@ pub fn navigate(
|
||||
let collection =
|
||||
tokio::task::spawn_blocking(move || get_collection(&fetch_id)).await.ok().flatten();
|
||||
|
||||
let resolve_handle = handle.clone();
|
||||
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());
|
||||
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.
|
||||
resolve_items(runtime, w.as_weak(), resolve_handle);
|
||||
}
|
||||
None => {
|
||||
log::warn!("[qbz-slint] myqbz_detail navigate({id}): collection not found");
|
||||
|
||||
@@ -54,10 +54,11 @@ fn reload(
|
||||
image_cache: &ImageCache,
|
||||
id: String,
|
||||
) {
|
||||
let Some(runtime) = crate::myqbz_detail::global_runtime() else { return };
|
||||
let handle = handle.clone();
|
||||
let image_cache = image_cache.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
crate::myqbz_detail::navigate(w.as_weak(), handle.clone(), image_cache.clone(), id);
|
||||
crate::myqbz_detail::navigate(runtime, w.as_weak(), handle.clone(), image_cache.clone(), id);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -302,7 +302,7 @@ pub fn shuffle(
|
||||
// collection wasn't already album_shuffle, and reload the open detail so
|
||||
// the overflow toggle label reflects the new mode.
|
||||
if collection.play_mode != CollectionPlayMode::AlbumShuffle {
|
||||
persist_album_shuffle(&weak, &handle, &image_cache, &collection_id);
|
||||
persist_album_shuffle(&runtime, &weak, &handle, &image_cache, &collection_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -314,6 +314,7 @@ pub fn shuffle(
|
||||
/// back to the event loop. Failures are logged, not surfaced — the shuffle
|
||||
/// already played, so a failed persist must not toast an error.
|
||||
fn persist_album_shuffle(
|
||||
runtime: &Runtime,
|
||||
weak: &slint::Weak<AppWindow>,
|
||||
handle: &tokio::runtime::Handle,
|
||||
image_cache: &crate::artwork::ImageCache,
|
||||
@@ -342,11 +343,12 @@ fn persist_album_shuffle(
|
||||
// no reload — their next open restores from the DB.
|
||||
let handle = handle.clone();
|
||||
let image_cache = image_cache.clone();
|
||||
let runtime = runtime.clone();
|
||||
let id = collection_id.to_string();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
use slint::ComponentHandle;
|
||||
if w.global::<crate::MyQbzDetailState>().get_id().as_str() == id {
|
||||
crate::myqbz_detail::navigate(w.as_weak(), handle, image_cache, id);
|
||||
crate::myqbz_detail::navigate(runtime, w.as_weak(), handle, image_cache, id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user