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:
vicrodh committed 2026-06-09 12:35:03 -06:00
1 parent 838085a492
commit 0b20a1a86a
12 files changed
+811 -1140

No files matched your search

+33 -11
View File
@@ -1037,7 +1037,13 @@ fn apply_entry(
); );
} }
nav::NavEntry::MixtapeDetail(id) => { 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) => { nav::NavEntry::DiscographyBuilder(artist_id) => {
navigate_discography_builder( navigate_discography_builder(
@@ -2031,6 +2037,7 @@ fn wire_playlist_manager(
/// + re-issue mosaic artwork jobs. Mirrors `wire_playlist_manager`. /// + re-issue mosaic artwork jobs. Mirrors `wire_playlist_manager`.
fn wire_myqbz( fn wire_myqbz(
window: &AppWindow, window: &AppWindow,
app_runtime: &Arc<AppRuntime<SlintAdapter>>,
tokio_rt: &tokio::runtime::Runtime, tokio_rt: &tokio::runtime::Runtime,
image_cache: &artwork::ImageCache, image_cache: &artwork::ImageCache,
) { ) {
@@ -2050,9 +2057,11 @@ fn wire_myqbz(
let weak = window.as_weak(); let weak = window.as_weak();
let handle = tokio_rt.handle().clone(); let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone(); let image_cache = image_cache.clone();
let runtime = app_runtime.clone();
window.global::<MyQbzActions>().on_open_card(move |id| { window.global::<MyQbzActions>().on_open_card(move |id| {
nav::record(nav::NavEntry::MixtapeDetail(id.to_string())); nav::record(nav::NavEntry::MixtapeDetail(id.to_string()));
myqbz_detail::navigate( myqbz_detail::navigate(
runtime.clone(),
weak.clone(), weak.clone(),
handle.clone(), handle.clone(),
image_cache.clone(), image_cache.clone(),
@@ -2107,6 +2116,7 @@ fn wire_myqbz(
let weak = window.as_weak(); let weak = window.as_weak();
let handle = tokio_rt.handle().clone(); let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone(); let image_cache = image_cache.clone();
let runtime = app_runtime.clone();
window.global::<MyQbzCreateActions>().on_submit(move || { window.global::<MyQbzCreateActions>().on_submit(move || {
let Some(w) = weak.upgrade() else { return; }; let Some(w) = weak.upgrade() else { return; };
let st = w.global::<MyQbzCreateState>(); let st = w.global::<MyQbzCreateState>();
@@ -2120,6 +2130,7 @@ fn wire_myqbz(
let weak = weak.clone(); let weak = weak.clone();
let handle = handle.clone(); let handle = handle.clone();
let image_cache = image_cache.clone(); let image_cache = image_cache.clone();
let runtime = runtime.clone();
handle.clone().spawn(async move { handle.clone().spawn(async move {
let nm = name.trim().to_string(); let nm = name.trim().to_string();
let created = let created =
@@ -2131,6 +2142,7 @@ fn wire_myqbz(
let weak2 = weak.clone(); let weak2 = weak.clone();
let handle2 = handle.clone(); let handle2 = handle.clone();
let image_cache2 = image_cache.clone(); let image_cache2 = image_cache.clone();
let runtime2 = runtime.clone();
let _ = weak.upgrade_in_event_loop(move |w| { let _ = weak.upgrade_in_event_loop(move |w| {
let st = w.global::<MyQbzCreateState>(); let st = w.global::<MyQbzCreateState>();
st.set_creating(false); st.set_creating(false);
@@ -2141,6 +2153,7 @@ fn wire_myqbz(
// Drop into the new collection's detail view. // Drop into the new collection's detail view.
nav::record(nav::NavEntry::MixtapeDetail(c.id.clone())); nav::record(nav::NavEntry::MixtapeDetail(c.id.clone()));
myqbz_detail::navigate( myqbz_detail::navigate(
runtime2.clone(),
weak2.clone(), weak2.clone(),
handle2.clone(), handle2.clone(),
image_cache2.clone(), image_cache2.clone(),
@@ -2398,11 +2411,17 @@ fn wire_myqbz_detail(
) { ) {
use MyQbzDetailActions as Act; 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 // 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) { fn refresh_row_covers(window: &AppWindow, image_cache: &artwork::ImageCache) {
let jobs = myqbz_detail::artwork_jobs(window); let split = myqbz_detail::artwork_jobs(window);
artwork::spawn_loads(jobs, window.as_weak(), image_cache.clone()); myqbz_detail::dispatch_artwork(split, window.as_weak(), image_cache.clone());
} }
// A toolbar re-derive rebuilds the rendered model with fresh rows // A toolbar re-derive rebuilds the rendered model with fresh rows
@@ -3162,12 +3181,15 @@ fn wire_disco_builder(
myqbz_builder::toast_created(&w); myqbz_builder::toast_created(&w);
// Navigate to the new collection's detail. // Navigate to the new collection's detail.
nav::record(nav::NavEntry::MixtapeDetail(collection_id.clone())); nav::record(nav::NavEntry::MixtapeDetail(collection_id.clone()));
myqbz_detail::navigate( if let Some(runtime) = myqbz_detail::global_runtime() {
w.as_weak(), myqbz_detail::navigate(
handle.clone(), runtime,
image_cache.clone(), w.as_weak(),
collection_id, handle.clone(),
); image_cache.clone(),
collection_id,
);
}
update_nav_flags(&w); update_nav_flags(&w);
} }
None => { None => {
@@ -7894,7 +7916,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// === Playlist Manager actions ====================================== // === Playlist Manager actions ======================================
wire_playlist_manager(&window, &app_runtime, &tokio_rt, &image_cache); 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_myqbz_detail(&window, &app_runtime, &tokio_rt, &image_cache);
wire_disco_builder(&window, &tokio_rt, &image_cache); wire_disco_builder(&window, &tokio_rt, &image_cache);
{ {
+17 -3
View File
@@ -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. /// 2x2-vs-3x3 rule, and pre-downscales the up-to-9 cover URLs per cell.
fn card_item(c: &MixtapeCollection) -> MixtapeCardItem { fn card_item(c: &MixtapeCollection) -> MixtapeCardItem {
let item_count = c.items.len(); 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. // cols: 3x3 only for a Collection with >= 9 items; else 2x2.
let cols: u32 = if c.kind == CollectionKind::Collection && item_count >= 9 { 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 { MixtapeCardItem {
id: c.id.clone().into(), id: c.id.clone().into(),
name: c.name.clone().into(), name: c.name.clone().into(),
+2 -1
View File
@@ -224,8 +224,9 @@ fn reload(
image_cache: ImageCache, image_cache: ImageCache,
id: String, id: String,
) { ) {
let Some(runtime) = crate::myqbz_detail::global_runtime() else { return };
let _ = weak.upgrade_in_event_loop(move |w| { let _ = weak.upgrade_in_event_loop(move |w| {
let _ = &w; // keep the closure's capture explicit. 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);
}); });
} }
+298 -31
View File
@@ -34,6 +34,32 @@ use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache}; use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache};
use crate::{AppWindow, ContentView, MixtapeDetailItem, MyQbzDetailState, NavState, TrackItem}; 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! { thread_local! {
/// The full, original-order item list for the open collection — the /// The full, original-order item list for the open collection — the
/// canonical source the toolbar derives the visible list from. UI thread /// 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 /// stored prefs; while `false` every toolbar persist is suppressed so an
/// early setter can't clobber the about-to-be-restored prefs. UI thread. /// 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) }; 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 /// 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). /// DEFERRED, so quality badge inputs stay empty here).
fn to_item(item: &MixtapeCollectionItem) -> MixtapeDetailItem { fn to_item(item: &MixtapeCollectionItem) -> MixtapeDetailItem {
let source = source_str(item.source); 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 let artwork_url = item
.artwork_url .artwork_url
.as_deref() .as_deref()
.filter(|u| !u.is_empty()) .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(); .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 // Re-hydrate inline tracks from the controller-level cache (keyed
// `source|source_item_id`) so a filter/sort/search re-derive does NOT lose // `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 // 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). // Only qobuz items get a clickable artist subtitle (spec 12 §6.3).
subtitle_is_link: item.source == AlbumSource::Qobuz subtitle_is_link: item.source == AlbumSource::Qobuz
&& item.subtitle.as_deref().map(|s| !s.is_empty()).unwrap_or(false), && item.subtitle.as_deref().map(|s| !s.is_empty()).unwrap_or(false),
// Resolved source kind — defaults to the raw source until resolveItems // Resolved source kind / quality / type label — from the resolveItems
// lands (a later slice). qobuz -> "qobuz"; local -> "local". // cache (above) when resolved; else the stored-source defaults.
source_kind: source.into(), source_kind: source_kind.into(),
type_label: type_label(item.item_type).into(), type_label: type_label.into(),
// Quality resolution is deferred; no badge until then. quality_tier: quality_tier.into(),
quality_tier: "".into(), quality_detail: quality_detail.into(),
quality_detail: "".into(),
tracks_text: tracks_text(item).into(), tracks_text: tracks_text(item).into(),
year_text: year_text(item).into(), year_text: year_text(item).into(),
artwork_url: artwork_url.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 { if has_custom || item_count == 0 || i >= cell_count {
return slint::SharedString::default(); return slint::SharedString::default();
} }
match c.items.get(i).and_then(|it| it.artwork_url.as_deref()) { let Some(it) = c.items.get(i) else {
Some(u) if !u.is_empty() => crate::myqbz::small_qobuz_url(u, target).into(), 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(), _ => 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 /// Clear the current selection (uncheck every row + zero the count), staying in
/// select-mode. Used after a bulk action completes. UI thread. /// select-mode. Used after a bulk action completes. UI thread.
pub fn clear_selection(window: &AppWindow) { 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 // Drop the inline-tracks cache — a different collection's tracks must not
// leak into the freshly-opened one. // leak into the freshly-opened one.
INLINE_CACHE.with(|cell| cell.borrow_mut().clear()); 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 — // Close the persist gate until `apply` restores this collection's prefs —
// any toolbar setter that fires meanwhile must NOT overwrite stored prefs // any toolbar setter that fires meanwhile must NOT overwrite stored prefs
// with the in-flight defaults (mirrors Tauri's prefsHydrated). // with the in-flight defaults (mirrors Tauri's prefsHydrated).
@@ -805,13 +1007,31 @@ pub fn apply_not_found(window: &AppWindow) {
// ──────────────────────────── artwork jobs ──────────────────────────── // ──────────────────────────── artwork jobs ────────────────────────────
/// Build artwork jobs for the loaded collection: the up-to-9 hero-mosaic cells /// The artwork jobs for the loaded collection, SPLIT by source so each is
/// (only when no custom cover) + one thumbnail per visible item row. /// dispatched through the correct decoder (spec §17 fallback chain): Qobuz items
pub fn artwork_jobs(window: &AppWindow) -> Vec<ArtworkJob> { /// carry an HTTP CDN url → the Remote/HTTP path (`spawn_loads`); local/Plex
let state = window.global::<MyQbzDetailState>(); /// items carry a filesystem path or a Plex `/library/...` path → the
let mut jobs = Vec::new(); /// 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() { if !state.get_has_custom_cover() {
let urls = [ let urls = [
state.get_url1(), state.get_url1(),
@@ -824,28 +1044,68 @@ pub fn artwork_jobs(window: &AppWindow) -> Vec<ArtworkJob> {
state.get_url8(), state.get_url8(),
state.get_url9(), 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() { for (slot, url) in urls.iter().enumerate() {
if !url.is_empty() { if url.is_empty() {
jobs.push(ArtworkJob { continue;
target: ArtworkTarget::MyQbzDetailCover { slot }, }
url: url.to_string(), 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). // 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(); let model = state.get_items();
for i in 0..model.row_count() { for i in 0..model.row_count() {
let Some(item) = model.row_data(i) else { continue }; let Some(item) = model.row_data(i) else { continue };
if !item.artwork_url.is_empty() { if item.artwork_url.is_empty() {
jobs.push(ArtworkJob { continue;
target: ArtworkTarget::MyQbzDetailRow { position: item.position }, }
url: item.artwork_url.to_string(), 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 /// 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 /// Open the collection-detail view for `id`: switch the ContentView + loading
/// state immediately, fetch the collection on a blocking worker, then apply + /// state immediately, fetch the collection on a blocking worker, then apply +
/// render + spawn artwork. Mirrors `myqbz::navigate` (load/apply/render) and /// render + spawn (source-split) artwork + the resolveItems pass. Mirrors
/// the album/playlist detail navigators. /// `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( pub fn navigate(
runtime: std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
weak: slint::Weak<AppWindow>, weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle, handle: tokio::runtime::Handle,
image_cache: ImageCache, image_cache: ImageCache,
@@ -905,11 +1168,15 @@ pub fn navigate(
let collection = let collection =
tokio::task::spawn_blocking(move || get_collection(&fetch_id)).await.ok().flatten(); 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 { let _ = weak.upgrade_in_event_loop(move |w| match collection {
Some(c) => { Some(c) => {
apply(&w, c); apply(&w, c);
let jobs = artwork_jobs(&w); let split = artwork_jobs(&w);
artwork::spawn_loads(jobs, w.as_weak(), image_cache.clone()); 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 => { None => {
log::warn!("[qbz-slint] myqbz_detail navigate({id}): collection not found"); log::warn!("[qbz-slint] myqbz_detail navigate({id}): collection not found");
+2 -1
View File
@@ -54,10 +54,11 @@ fn reload(
image_cache: &ImageCache, image_cache: &ImageCache,
id: String, id: String,
) { ) {
let Some(runtime) = crate::myqbz_detail::global_runtime() else { return };
let handle = handle.clone(); let handle = handle.clone();
let image_cache = image_cache.clone(); let image_cache = image_cache.clone();
let _ = weak.upgrade_in_event_loop(move |w| { 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);
}); });
} }
+4 -2
View File
@@ -302,7 +302,7 @@ pub fn shuffle(
// collection wasn't already album_shuffle, and reload the open detail so // collection wasn't already album_shuffle, and reload the open detail so
// the overflow toggle label reflects the new mode. // the overflow toggle label reflects the new mode.
if collection.play_mode != CollectionPlayMode::AlbumShuffle { 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 /// back to the event loop. Failures are logged, not surfaced — the shuffle
/// already played, so a failed persist must not toast an error. /// already played, so a failed persist must not toast an error.
fn persist_album_shuffle( fn persist_album_shuffle(
runtime: &Runtime,
weak: &slint::Weak<AppWindow>, weak: &slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle, handle: &tokio::runtime::Handle,
image_cache: &crate::artwork::ImageCache, image_cache: &crate::artwork::ImageCache,
@@ -342,11 +343,12 @@ fn persist_album_shuffle(
// no reload — their next open restores from the DB. // no reload — their next open restores from the DB.
let handle = handle.clone(); let handle = handle.clone();
let image_cache = image_cache.clone(); let image_cache = image_cache.clone();
let runtime = runtime.clone();
let id = collection_id.to_string(); let id = collection_id.to_string();
let _ = weak.upgrade_in_event_loop(move |w| { let _ = weak.upgrade_in_event_loop(move |w| {
use slint::ComponentHandle; use slint::ComponentHandle;
if w.global::<crate::MyQbzDetailState>().get_id().as_str() == id { 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);
} }
}); });
} }
+84 -234
View File
@@ -1,81 +1,35 @@
// My QBZ > Collections — the index grid of the user's Collections + Artist // My QBZ > Collections — the index grid of the user's Collections + Artist
// Collections (Tauri's CollectionsView.svelte). Same vocabulary as Mixtapes // Collections (Tauri's CollectionsView.svelte). Same vocabulary as Mixtapes
// plus the kind-filter dropdown (.filter-count badge + 3-item menu). The 3x3 // plus the kind-filter (a second QbzSelect). The 3x3 mosaic appears only for a
// mosaic appears only for a `collection` with >= 9 items (handled inside // `collection` with >= 9 items (handled inside CollectionMosaic).
// CollectionMosaic). READ-ONLY in this slice: create CTA + card click STUB.
// //
// Every px / token verbatim from spec 11. Root padding 8 8 100 18. // HOMOLOGATED to the app's index views: NO per-view back button, 32px L/R
// content padding, Typography tokens, ExpandableSearch + QbzSelect + the shared
// ViewToggle, surface-elevated NewActionBtn (no blue pill).
import { Theme } from "../foundation/semantic-colors.slint"; import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint"; import { Typography } from "../foundation/typography.slint";
import { MyQbzState, MyQbzActions, MixtapeCardItem, NavState } from "../state.slint"; import { MyQbzState, MyQbzActions, MixtapeCardItem, NavState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint"; import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { CollectionMosaic } from "CollectionMosaic.slint"; import { CollectionMosaic } from "CollectionMosaic.slint";
import { import { NewActionBtn, ViewToggle, MyQbzCard, MyQbzListRow } from "MyQbzShared.slint";
PrimaryCta, MyQbzSearch, ControlBtn, ViewModeGroup, MyQbzCard, MyQbzListRow,
} from "MyQbzShared.slint";
// One dropdown row (sort or kind-filter). Sort rows show a trailing arrow;
// filter rows show a trailing Check on the selected kind.
component ColMenuItem inherits Rectangle {
in property <string> label;
in property <bool> selected: false;
in property <string> indicator: ""; // sort arrow
in property <bool> check: false; // kind-filter check
callback activated();
height: 32px;
border-radius: 4px;
background: item-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
spacing: 8px;
Text {
text: root.label;
color: root.selected ? Theme.accent : Theme.text-secondary;
font-size: 13px;
vertical-alignment: center;
horizontal-stretch: 1;
}
if root.indicator != "": Text {
text: root.indicator;
color: Theme.text-muted;
font-size: 11px;
vertical-alignment: center;
}
if root.check: VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 12px;
height: 12px;
tint: Theme.accent;
}
}
}
item-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.activated(); }
}
}
export component CollectionsView inherits Rectangle { export component CollectionsView inherits Rectangle {
property <length> card-w: 208px; property <length> card-w: 208px;
property <length> grid-gap: 20px; property <length> grid-gap: 20px;
property <length> card-h: 184px + 12px + 12px + 16px + 2 * 18px + 12px + 16px + 12px; property <length> card-h: 184px + 12px + 12px + 18px + 2 * 20px + 18px + 12px;
property <string> active-sort-label: property <int> sort-index:
MyQbzState.col-sort == "name" ? @tr("Name") MyQbzState.col-sort == "name" ? 1
: MyQbzState.col-sort == "items" ? @tr("Items") : MyQbzState.col-sort == "items" ? 2
: MyQbzState.col-sort == "updated" ? @tr("Recently updated") : MyQbzState.col-sort == "updated" ? 3
: @tr("Position"); : 0;
property <string> sort-arrow: MyQbzState.col-sort-dir == "asc" ? "↑" : "↓"; property <int> kind-index:
property <bool> has-active-filters: MyQbzState.col-kind-filter == "collection" ? 1
MyQbzState.col-kind-filter != "all" : MyQbzState.col-kind-filter == "artist_collection" ? 2
|| MyQbzState.col-sort != "position" : 0;
|| MyQbzState.col-sort-dir != "asc";
background: Theme.surface-main; background: Theme.surface-main;
@@ -96,65 +50,33 @@ export component CollectionsView inherits Rectangle {
page := VerticalLayout { page := VerticalLayout {
width: parent.width; width: parent.width;
padding-top: 8px; padding-top: 16px;
padding-right: 8px; padding-right: 32px;
padding-bottom: 100px; padding-bottom: 100px;
padding-left: 18px; padding-left: 32px;
spacing: 0px; spacing: 0px;
alignment: start; alignment: start;
// --- Back button ------------------------------------------- // --- Header — no per-view back button ----------------------
Rectangle {
height: 16px + 8px + 24px;
back-row := HorizontalLayout {
y: 8px;
height: 16px;
width: self.preferred-width;
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/arrow-left.svg");
width: 16px;
height: 16px;
tint: back-ta.has-hover ? Theme.text-secondary : Theme.text-muted;
}
}
Text {
text: @tr("Back");
color: back-ta.has-hover ? Theme.text-secondary : Theme.text-muted;
font-size: 14px;
vertical-alignment: center;
}
}
back-ta := TouchArea {
width: back-row.preferred-width;
height: 16px;
y: 8px;
mouse-cursor: pointer;
clicked => { NavState.request-back(); }
}
}
// --- Header ------------------------------------------------
HorizontalLayout { HorizontalLayout {
height: 38px; height: 40px;
spacing: 16px;
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
horizontal-stretch: 1; horizontal-stretch: 1;
Text { Text {
text: @tr("Collections"); text: @tr("Collections");
color: Theme.text-primary; color: Theme.text-primary;
font-size: 24px; font-size: Typography.section;
font-weight: Typography.bold; font-weight: Typography.bold;
vertical-alignment: center; vertical-alignment: center;
} }
} }
VerticalLayout { // Header "+ New" shows only when populated; when empty the
// empty-state CTA is the single call-to-action (no duplicate).
if MyQbzState.collections.length > 0 || MyQbzState.col-search != "" || MyQbzState.col-kind-filter != "all": VerticalLayout {
alignment: center; alignment: center;
PrimaryCta { NewActionBtn {
label: @tr("+ New Collection");
show-plus: true;
clicked => { MyQbzActions.create-collection(); } clicked => { MyQbzActions.create-collection(); }
} }
} }
@@ -179,15 +101,13 @@ export component CollectionsView inherits Rectangle {
Text { Text {
text: @tr("No collections yet"); text: @tr("No collections yet");
color: Theme.text-secondary; color: Theme.text-secondary;
font-size: 18px; font-size: Typography.section;
font-weight: Typography.semibold; font-weight: Typography.semibold;
} }
} }
HorizontalLayout { HorizontalLayout {
alignment: center; alignment: center;
PrimaryCta { NewActionBtn {
label: @tr("+ New Collection");
show-plus: false;
clicked => { MyQbzActions.create-collection(); } clicked => { MyQbzActions.create-collection(); }
} }
} }
@@ -200,132 +120,62 @@ export component CollectionsView inherits Rectangle {
// --- Toolbar ------------------------------------------- // --- Toolbar -------------------------------------------
HorizontalLayout { HorizontalLayout {
height: 32px; height: 30px;
spacing: 8px; spacing: 8px;
MyQbzSearch {
placeholder: @tr("Search collections…");
query <=> MyQbzState.col-search;
edited(q) => { MyQbzActions.col-search-changed(q); }
}
// Sort dropdown.
Rectangle {
width: sort-btn.width;
sort-btn := ControlBtn {
icon: @image-url("../assets/icons/arrow-up-down.svg");
label: root.active-sort-label;
indicator: root.sort-arrow;
clicked => { sort-menu.show(); }
}
sort-menu := PopupWindow {
x: 0; y: 36px; width: 180px;
Rectangle {
background: Theme.surface-main;
border-width: 1px;
border-color: Theme.surface-elevated;
border-radius: 8px;
drop-shadow-blur: 24px;
drop-shadow-color: #00000066;
VerticalLayout {
padding: 4px;
spacing: 1px;
ColMenuItem {
label: @tr("Position");
selected: MyQbzState.col-sort == "position";
indicator: MyQbzState.col-sort == "position" ? root.sort-arrow : "";
activated => { MyQbzActions.col-set-sort("position"); }
}
ColMenuItem {
label: @tr("Name");
selected: MyQbzState.col-sort == "name";
indicator: MyQbzState.col-sort == "name" ? root.sort-arrow : "";
activated => { MyQbzActions.col-set-sort("name"); }
}
ColMenuItem {
label: @tr("Items");
selected: MyQbzState.col-sort == "items";
indicator: MyQbzState.col-sort == "items" ? root.sort-arrow : "";
activated => { MyQbzActions.col-set-sort("items"); }
}
ColMenuItem {
label: @tr("Recently updated");
selected: MyQbzState.col-sort == "updated";
indicator: MyQbzState.col-sort == "updated" ? root.sort-arrow : "";
activated => { MyQbzActions.col-set-sort("updated"); }
}
}
}
}
}
// Kind-filter dropdown.
Rectangle {
width: filter-btn.width;
filter-btn := ControlBtn {
icon: @image-url("../assets/icons/list-filter.svg");
label: @tr("Filter");
active: MyQbzState.col-kind-filter != "all";
clicked => { filter-menu.show(); }
}
// .filter-count badge (always "1" when a kind is set).
if MyQbzState.col-kind-filter != "all": Rectangle {
x: filter-btn.width - self.width - 6px;
y: 4px;
width: 16px;
height: 16px;
border-radius: 8px;
background: Theme.accent;
Text {
text: "1";
color: Theme.accent-text;
font-size: 10px;
font-weight: Typography.semibold;
horizontal-alignment: center;
vertical-alignment: center;
}
}
filter-menu := PopupWindow {
x: 0; y: 36px; width: 180px;
Rectangle {
background: Theme.surface-main;
border-width: 1px;
border-color: Theme.surface-elevated;
border-radius: 8px;
drop-shadow-blur: 24px;
drop-shadow-color: #00000066;
VerticalLayout {
padding: 4px;
spacing: 1px;
ColMenuItem {
label: @tr("All kinds");
selected: MyQbzState.col-kind-filter == "all";
check: MyQbzState.col-kind-filter == "all";
activated => { MyQbzActions.col-set-kind-filter("all"); }
}
ColMenuItem {
label: @tr("Collections");
selected: MyQbzState.col-kind-filter == "collection";
check: MyQbzState.col-kind-filter == "collection";
activated => { MyQbzActions.col-set-kind-filter("collection"); }
}
ColMenuItem {
label: @tr("Artist Collections");
selected: MyQbzState.col-kind-filter == "artist_collection";
check: MyQbzState.col-kind-filter == "artist_collection";
activated => { MyQbzActions.col-set-kind-filter("artist_collection"); }
}
}
}
}
}
// Reset.
if root.has-active-filters: ControlBtn {
icon: @image-url("../assets/icons/rotate-ccw.svg");
subtle: true;
clicked => { MyQbzActions.col-reset(); }
}
Rectangle { horizontal-stretch: 1; } Rectangle { horizontal-stretch: 1; }
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
ViewModeGroup { ExpandableSearch {
placeholder: @tr("Search");
sm: true;
query <=> MyQbzState.col-search;
edited(q) => { MyQbzActions.col-search-changed(q); }
}
}
VerticalLayout {
alignment: center;
QbzSelect {
menu-width: 180px;
sm: true;
options: [
@tr("Position"),
@tr("Name"),
@tr("Items"),
@tr("Recently updated"),
];
current-index: root.sort-index;
selected(i) => {
MyQbzActions.col-set-sort(
i == 1 ? "name"
: i == 2 ? "items"
: i == 3 ? "updated"
: "position");
}
}
}
// Kind filter (group-by idiom, second QbzSelect).
VerticalLayout {
alignment: center;
QbzSelect {
menu-width: 200px;
sm: true;
options: [
@tr("All kinds"),
@tr("Collections"),
@tr("Artist Collections"),
];
current-index: root.kind-index;
selected(i) => {
MyQbzActions.col-set-kind-filter(
i == 1 ? "collection"
: i == 2 ? "artist_collection"
: "all");
}
}
}
VerticalLayout {
alignment: center;
ViewToggle {
view: MyQbzState.col-view; view: MyQbzState.col-view;
set-view(v) => { MyQbzActions.col-set-view(v); } set-view(v) => { MyQbzActions.col-set-view(v); }
} }
@@ -339,7 +189,7 @@ export component CollectionsView inherits Rectangle {
Text { Text {
text: @tr("No results found"); text: @tr("No results found");
color: Theme.text-muted; color: Theme.text-muted;
font-size: 13px; font-size: Typography.legal;
horizontal-alignment: center; horizontal-alignment: center;
vertical-alignment: center; vertical-alignment: center;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
@@ -6,78 +6,54 @@
// footer is a fixed-height flex sibling pinned at the bottom (NOT // footer is a fixed-height flex sibling pinned at the bottom (NOT
// sticky-inside-scroll) so last rows never peek past the footer. // sticky-inside-scroll) so last rows never peek past the footer.
// //
// CSS variable values are resolved to the concrete dark-theme values from // HOMOLOGATED: all chrome colors go through the app's `Theme.*` tokens (so the
// spec §0 (this view hardcodes them rather than going through the Theme // accent matches every sibling view); the only hardcoded palette left is the
// global so the port stays pixel-exact to the quoted PSD). // per-release-type label tints (`DiscoTint`), which the spec intends as fixed
// non-token colors. The old per-view back button was removed (the shell owns
// the global < > nav; the footer Cancel covers exit).
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint"; import { Typography } from "../foundation/typography.slint";
import { DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate } from "../state.slint"; import { DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint"; import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint"; import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint";
import { SecondaryButton } from "../primitives/SecondaryButton.slint"; import { SecondaryButton } from "../primitives/SecondaryButton.slint";
import { SourceGlyph } from "../primitives/SourceGlyph.slint";
// ── Concrete dark-theme values (spec §0) ────────────────────────────────── // ── Release-type label tints (spec §0) ────────────────────────────────────
// Resolved here as global consts so the whole view shares one source. // The ONE legitimately-hardcoded palette in this view: the per-release-type
global D { // accent colors. Everything else now goes through the app's `Theme.*` tokens
out property <color> bg-primary: #0f0f0f; // footer bg // (the old `global D` — which hardcoded the dark-theme literals AND a non-app
out property <color> bg-secondary: #1a1a1a; // input/segment/menu/secondary-btn // `#4285F4` accent — was deleted; that blue mismatched every sibling view).
out property <color> bg-tertiary: #2a2a2a; // avatar placeholder, hairlines global DiscoTint {
out property <color> bg-hover: #1f1f1f; // row/segment/menu-item hover out property <color> compilation: #f59e0b; // amber
out property <color> text-primary: #ffffff; out property <color> live: #e91e63; // pink
out property <color> text-secondary: #cccccc; out property <color> ep: #60a5fa; // blue
out property <color> text-muted: #888888; out property <color> single: #a78bfa; // violet
out property <color> accent: #4285F4;
out property <color> btn-primary-text: #ffffff;
out property <color> alpha-6: #ffffff0f; // rgba(255,255,255,0.06)
out property <color> error: #e57373;
// Type-label tints (hardcoded non-var colors, spec §0).
out property <color> tint-compilation: #f59e0b; // amber
out property <color> tint-live: #e91e63; // pink
out property <color> tint-ep: #60a5fa; // blue
out property <color> tint-single: #a78bfa; // violet
} }
// Resolve a release-type to its tint (plain "album" = muted, no tint). // Resolve a release-type to its tint (plain "album" = muted, no tint).
component TypeColor { component TypeColor {
in property <string> kind; in property <string> kind;
out property <color> color: out property <color> color:
root.kind == "compilation" ? D.tint-compilation root.kind == "compilation" ? DiscoTint.compilation
: root.kind == "live" ? D.tint-live : root.kind == "live" ? DiscoTint.live
: root.kind == "ep" ? D.tint-ep : root.kind == "ep" ? DiscoTint.ep
: root.kind == "single" ? D.tint-single : root.kind == "single" ? DiscoTint.single
: D.text-muted; : Theme.text-muted;
} }
// ── Source indicator (Qobuz / Plex / Local accent-tinted silhouette) ────── // ── Source indicator — the shared SourceGlyph (full-color logos untinted;
// monochrome local hard-drive tinted muted), centered in a 28px cell. ───────
component SourceIndicator inherits Rectangle { component SourceIndicator inherits Rectangle {
in property <string> source; in property <string> source;
width: 28px; width: 28px;
height: 28px; height: 28px;
opacity: 0.9; SourceGlyph {
HorizontalLayout { kind: root.source;
alignment: center; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
VerticalLayout { y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
alignment: center;
if root.source == "plex": QbzIcon {
source: @image-url("../assets/icons/plex-logo.svg");
width: 14px;
height: 14px;
tint: D.accent;
}
if root.source == "qobuz": QbzIcon {
source: @image-url("../assets/icons/qobuz-logo-filled.svg");
width: 16px;
height: 16px;
tint: D.accent;
}
if root.source != "plex" && root.source != "qobuz": QbzIcon {
source: @image-url("../assets/icons/hard-drive.svg");
width: 14px;
height: 14px;
tint: D.accent;
}
}
} }
} }
@@ -97,7 +73,7 @@ component TypeOverride inherits Rectangle {
type-btn := Rectangle { type-btn := Rectangle {
// .type-btn negative margin (0 -4px) is replicated by a -4px touch // .type-btn negative margin (0 -4px) is replicated by a -4px touch
// bleed; the visual glyph stays put. // bleed; the visual glyph stays put.
background: btn-ta.has-hover ? D.alpha-6 : transparent; background: btn-ta.has-hover ? Theme.surface-hover : transparent;
border-radius: 4px; border-radius: 4px;
HorizontalLayout { HorizontalLayout {
padding-left: 4px; padding-left: 4px;
@@ -152,9 +128,9 @@ component TypeOverride inherits Rectangle {
x: 0; x: 0;
y: parent.height + 4px; y: parent.height + 4px;
width: 160px; width: 160px;
background: D.bg-secondary; background: Theme.surface-card;
border-width: 1px; border-width: 1px;
border-color: D.bg-tertiary; border-color: Theme.surface-elevated;
border-radius: 6px; border-radius: 6px;
drop-shadow-blur: 12px; drop-shadow-blur: 12px;
drop-shadow-color: #00000059; // rgba(0,0,0,0.35) drop-shadow-color: #00000059; // rgba(0,0,0,0.35)
@@ -171,7 +147,7 @@ component TypeOverride inherits Rectangle {
]: Rectangle { ]: Rectangle {
height: 28px; height: 28px;
border-radius: 4px; border-radius: 4px;
background: item-ta.has-hover ? D.bg-hover : transparent; background: item-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout { HorizontalLayout {
padding-left: 8px; padding-left: 8px;
padding-right: 8px; padding-right: 8px;
@@ -185,7 +161,7 @@ component TypeOverride inherits Rectangle {
source: @image-url("../assets/icons/check.svg"); source: @image-url("../assets/icons/check.svg");
width: 12px; width: 12px;
height: 12px; height: 12px;
tint: D.accent; tint: Theme.accent;
} }
if root.candidate.release-type != choice.id: Rectangle { if root.candidate.release-type != choice.id: Rectangle {
width: 12px; width: 12px;
@@ -195,7 +171,7 @@ component TypeOverride inherits Rectangle {
Text { Text {
text: choice.lbl; text: choice.lbl;
color: root.candidate.release-type == choice.id color: root.candidate.release-type == choice.id
? D.accent : D.text-primary; ? Theme.accent : Theme.text-primary;
font-size: 12px; font-size: 12px;
vertical-alignment: center; vertical-alignment: center;
horizontal-stretch: 1; horizontal-stretch: 1;
@@ -218,13 +194,13 @@ component TypeOverride inherits Rectangle {
Rectangle { Rectangle {
y: 4px; y: 4px;
height: 1px; height: 1px;
background: D.bg-tertiary; background: Theme.surface-elevated;
} }
} }
if root.candidate.is-overridden: Rectangle { if root.candidate.is-overridden: Rectangle {
height: 28px; height: 28px;
border-radius: 4px; border-radius: 4px;
background: reset-ta.has-hover ? D.bg-hover : transparent; background: reset-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout { HorizontalLayout {
padding-left: 8px; padding-left: 8px;
padding-right: 8px; padding-right: 8px;
@@ -236,12 +212,12 @@ component TypeOverride inherits Rectangle {
source: @image-url("../assets/icons/rotate-ccw.svg"); source: @image-url("../assets/icons/rotate-ccw.svg");
width: 12px; width: 12px;
height: 12px; height: 12px;
tint: D.text-muted; tint: Theme.text-muted;
} }
} }
Text { Text {
text: @tr("Use auto-detected"); text: @tr("Use auto-detected");
color: D.text-muted; color: Theme.text-muted;
font-size: 12px; font-size: 12px;
vertical-alignment: center; vertical-alignment: center;
horizontal-stretch: 1; horizontal-stretch: 1;
@@ -280,13 +256,21 @@ component CandidateRow inherits Rectangle {
// on hover. The parent passes the resting opacity. // on hover. The parent passes the resting opacity.
in property <float> rest-opacity: 1.0; in property <float> rest-opacity: 1.0;
height: 36px; height: 44px;
border-radius: 6px; border-radius: 6px;
background: row-ta.has-hover ? D.bg-hover : transparent; background: row-ta.has-hover ? Theme.surface-hover : transparent;
opacity: row-ta.has-hover ? 1.0 : root.rest-opacity; opacity: row-ta.has-hover ? 1.0 : root.rest-opacity;
row-ta := TouchArea { mouse-cursor: default; } row-ta := TouchArea { mouse-cursor: default; }
// Subtle bottom hairline so adjacent rows (and their quality badges) read
// as separated instead of stuck together.
Rectangle {
y: parent.height - 1px;
height: 1px;
background: Theme.border-subtle;
}
HorizontalLayout { HorizontalLayout {
padding-left: 8px; padding-left: 8px;
padding-right: 8px; padding-right: 8px;
@@ -299,7 +283,7 @@ component CandidateRow inherits Rectangle {
spacing: 3px; spacing: 3px;
if root.alt: Text { if root.alt: Text {
text: "↳"; text: "↳";
color: D.text-muted; color: Theme.text-muted;
font-size: 12px; font-size: 12px;
vertical-alignment: center; vertical-alignment: center;
} }
@@ -309,16 +293,16 @@ component CandidateRow inherits Rectangle {
width: 15px; width: 15px;
height: 15px; height: 15px;
border-radius: 3px; border-radius: 3px;
background: root.candidate.checked ? D.accent : transparent; background: root.candidate.checked ? Theme.accent : transparent;
border-width: root.candidate.checked ? 0px : 1.5px; border-width: root.candidate.checked ? 0px : 1.5px;
border-color: D.text-muted; border-color: Theme.text-muted;
if root.candidate.checked: QbzIcon { if root.candidate.checked: QbzIcon {
source: @image-url("../assets/icons/check.svg"); source: @image-url("../assets/icons/check.svg");
width: 11px; width: 11px;
height: 11px; height: 11px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: D.btn-primary-text; tint: Theme.accent-text;
} }
TouchArea { TouchArea {
mouse-cursor: pointer; mouse-cursor: pointer;
@@ -336,7 +320,7 @@ component CandidateRow inherits Rectangle {
width: 56px; width: 56px;
Text { Text {
text: root.candidate.year; text: root.candidate.year;
color: root.alt ? D.text-muted : D.text-secondary; color: root.alt ? Theme.text-muted : Theme.text-secondary;
font-size: root.alt ? 12px : 13px; font-size: root.alt ? 12px : 13px;
vertical-alignment: center; vertical-alignment: center;
horizontal-alignment: left; horizontal-alignment: left;
@@ -373,8 +357,8 @@ component CandidateRow inherits Rectangle {
title := Text { title := Text {
text: root.candidate.title; text: root.candidate.title;
color: title-ta.has-hover color: title-ta.has-hover
? D.accent ? Theme.accent
: (root.alt ? D.text-muted : D.text-primary); : (root.alt ? Theme.text-muted : Theme.text-primary);
font-size: root.alt ? 13px : 14px; font-size: root.alt ? 13px : 14px;
overflow: elide; overflow: elide;
vertical-alignment: center; vertical-alignment: center;
@@ -388,7 +372,7 @@ component CandidateRow inherits Rectangle {
// row kind + the candidate's own type. // row kind + the candidate's own type.
if root.alt: Text { if root.alt: Text {
text: @tr("alternate"); text: @tr("alternate");
color: D.text-muted; color: Theme.text-muted;
font-size: 10px; font-size: 10px;
font-weight: 500; font-weight: 500;
font-italic: true; font-italic: true;
@@ -397,7 +381,7 @@ component CandidateRow inherits Rectangle {
} }
if !root.alt && root.group-is-compilation: Text { if !root.alt && root.group-is-compilation: Text {
text: @tr("compilation"); text: @tr("compilation");
color: D.text-muted; color: Theme.text-muted;
font-size: 10px; font-size: 10px;
font-weight: 500; font-weight: 500;
font-italic: true; font-italic: true;
@@ -411,7 +395,7 @@ component CandidateRow inherits Rectangle {
width: 72px; width: 72px;
Text { Text {
text: root.candidate.tracks; text: root.candidate.tracks;
color: root.alt ? D.text-muted : D.text-secondary; color: root.alt ? Theme.text-muted : Theme.text-secondary;
font-size: root.alt ? 12px : 13px; font-size: root.alt ? 12px : 13px;
horizontal-alignment: center; horizontal-alignment: center;
vertical-alignment: center; vertical-alignment: center;
@@ -466,38 +450,8 @@ export component DiscographyBuilderView inherits Rectangle {
spacing: 0px; spacing: 0px;
alignment: start; alignment: start;
// ── .back-btn ── // NO per-view back button — the shell owns the global < > nav
Rectangle { // (the footer Cancel covers exit). Vertical space reclaimed.
height: 20px;
horizontal-stretch: 0;
back-ta := TouchArea {
width: back-row.preferred-width;
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.back();
}
back-row := HorizontalLayout {
spacing: 8px;
alignment: start;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/arrow-left.svg");
width: 16px;
height: 16px;
tint: back-ta.has-hover ? D.text-secondary : D.text-muted;
}
}
Text {
text: @tr("Back");
color: back-ta.has-hover ? D.text-secondary : D.text-muted;
font-size: 14px;
vertical-alignment: center;
}
}
}
}
Rectangle { height: 24px; }
// ── header.builder-header ── // ── header.builder-header ──
HorizontalLayout { HorizontalLayout {
@@ -508,7 +462,7 @@ export component DiscographyBuilderView inherits Rectangle {
width: 72px; width: 72px;
height: 72px; height: 72px;
border-radius: 36px; border-radius: 36px;
background: D.bg-tertiary; background: Theme.surface-elevated;
clip: true; clip: true;
if DiscoBuilderState.artist-avatar-url != "": Image { if DiscoBuilderState.artist-avatar-url != "": Image {
width: 100%; width: 100%;
@@ -530,7 +484,7 @@ export component DiscographyBuilderView inherits Rectangle {
// AddToMixtapeModal "ADD TO MIXTAPE/COLLECTION", the // AddToMixtapeModal "ADD TO MIXTAPE/COLLECTION", the
// grid card label from Rust kind_label). // grid card label from Rust kind_label).
text: @tr("BUILD ARTIST COLLECTION"); text: @tr("BUILD ARTIST COLLECTION");
color: D.text-muted; color: Theme.text-muted;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
letter-spacing: 1.5px; letter-spacing: 1.5px;
@@ -538,7 +492,7 @@ export component DiscographyBuilderView inherits Rectangle {
Text { Text {
text: DiscoBuilderState.artist-name == "" text: DiscoBuilderState.artist-name == ""
? "—" : DiscoBuilderState.artist-name; ? "—" : DiscoBuilderState.artist-name;
color: D.text-primary; color: Theme.text-primary;
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
overflow: elide; overflow: elide;
@@ -553,7 +507,7 @@ export component DiscographyBuilderView inherits Rectangle {
alignment: start; alignment: start;
Text { Text {
text: @tr("NAME"); text: @tr("NAME");
color: D.text-muted; color: Theme.text-muted;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
letter-spacing: 1.5px; letter-spacing: 1.5px;
@@ -561,15 +515,15 @@ export component DiscographyBuilderView inherits Rectangle {
Rectangle { Rectangle {
width: 540px; width: 540px;
height: 40px; height: 40px;
background: D.bg-secondary; background: Theme.surface-card;
border-radius: 8px; border-radius: 8px;
border-width: 1px; border-width: 1px;
border-color: name-input.has-focus ? D.accent : D.bg-tertiary; border-color: name-input.has-focus ? Theme.accent : Theme.surface-elevated;
HorizontalLayout { HorizontalLayout {
padding-left: 12px; padding-left: 12px;
padding-right: 12px; padding-right: 12px;
name-input := TextInput { name-input := TextInput {
color: D.text-primary; color: Theme.text-primary;
font-size: 14px; font-size: 14px;
vertical-alignment: center; vertical-alignment: center;
single-line: true; single-line: true;
@@ -593,7 +547,7 @@ export component DiscographyBuilderView inherits Rectangle {
// .field-label is uppercased (spec §2.6 — same // .field-label is uppercased (spec §2.6 — same
// text-transform:uppercase as the eyebrow / NAME label). // text-transform:uppercase as the eyebrow / NAME label).
text: @tr("ORDER BY"); text: @tr("ORDER BY");
color: D.text-muted; color: Theme.text-muted;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
letter-spacing: 1.5px; letter-spacing: 1.5px;
@@ -605,7 +559,7 @@ export component DiscographyBuilderView inherits Rectangle {
height: 32px; height: 32px;
border-radius: 8px; border-radius: 8px;
border-width: 1px; border-width: 1px;
border-color: D.bg-tertiary; border-color: Theme.surface-elevated;
clip: true; clip: true;
seg := HorizontalLayout { seg := HorizontalLayout {
spacing: 0px; spacing: 0px;
@@ -615,15 +569,18 @@ export component DiscographyBuilderView inherits Rectangle {
{ id: "manual", lbl: "Manual" }, { id: "manual", lbl: "Manual" },
]: seg-rect := Rectangle { ]: seg-rect := Rectangle {
property <bool> active: DiscoBuilderState.order-by == s.id; property <bool> active: DiscoBuilderState.order-by == s.id;
// Non-accent active (the app's ToggleButton idiom): a surface
// highlight, not a blue fill. Active distinguished from hover by
// tone + bolder text below.
background: seg-rect.active background: seg-rect.active
? D.accent ? Theme.surface-hover
: (seg-ta.has-hover ? D.bg-hover : D.bg-secondary); : (seg-ta.has-hover ? Theme.surface-elevated : transparent);
// Divider between segments. // Divider between segments.
if i > 0: Rectangle { if i > 0: Rectangle {
x: 0; x: 0;
width: 1px; width: 1px;
height: 100%; height: 100%;
background: D.bg-tertiary; background: Theme.surface-elevated;
} }
HorizontalLayout { HorizontalLayout {
padding-left: 14px; padding-left: 14px;
@@ -632,10 +589,10 @@ export component DiscographyBuilderView inherits Rectangle {
padding-bottom: 7px; padding-bottom: 7px;
Text { Text {
text: s.lbl; text: s.lbl;
color: seg-rect.active ? D.btn-primary-text color: seg-rect.active ? Theme.text-primary
: (seg-ta.has-hover ? D.text-primary : D.text-secondary); : (seg-ta.has-hover ? Theme.text-primary : Theme.text-secondary);
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: seg-rect.active ? 600 : 500;
vertical-alignment: center; vertical-alignment: center;
} }
} }
@@ -657,7 +614,7 @@ export component DiscographyBuilderView inherits Rectangle {
height: 96px; height: 96px;
Text { Text {
text: @tr("Loading..."); text: @tr("Loading...");
color: D.text-muted; color: Theme.text-muted;
font-size: 14px; font-size: 14px;
horizontal-alignment: center; horizontal-alignment: center;
vertical-alignment: center; vertical-alignment: center;
@@ -668,7 +625,7 @@ export component DiscographyBuilderView inherits Rectangle {
height: 96px; height: 96px;
Text { Text {
text: DiscoBuilderState.load-error; text: DiscoBuilderState.load-error;
color: D.error; color: Theme.favorite;
font-size: 14px; font-size: 14px;
horizontal-alignment: center; horizontal-alignment: center;
vertical-alignment: center; vertical-alignment: center;
@@ -681,7 +638,7 @@ export component DiscographyBuilderView inherits Rectangle {
height: 96px; height: 96px;
Text { Text {
text: @tr("No results found"); text: @tr("No results found");
color: D.text-muted; color: Theme.text-muted;
font-size: 14px; font-size: 14px;
horizontal-alignment: center; horizontal-alignment: center;
vertical-alignment: center; vertical-alignment: center;
@@ -693,7 +650,7 @@ export component DiscographyBuilderView inherits Rectangle {
&& DiscoBuilderState.groups.length > 0: VerticalLayout { && DiscoBuilderState.groups.length > 0: VerticalLayout {
spacing: 0px; spacing: 0px;
// border-top + 4px pad-top. // border-top + 4px pad-top.
Rectangle { height: 1px; background: D.bg-tertiary; } Rectangle { height: 1px; background: Theme.surface-elevated; }
Rectangle { height: 4px; } Rectangle { height: 4px; }
// ── header row (.row.header-row) ── // ── header row (.row.header-row) ──
@@ -702,7 +659,7 @@ export component DiscographyBuilderView inherits Rectangle {
Rectangle { Rectangle {
y: parent.height - 1px; y: parent.height - 1px;
height: 1px; height: 1px;
background: D.bg-tertiary; background: Theme.surface-elevated;
} }
HorizontalLayout { HorizontalLayout {
padding-left: 8px; padding-left: 8px;
@@ -720,23 +677,23 @@ export component DiscographyBuilderView inherits Rectangle {
height: 15px; height: 15px;
border-radius: 3px; border-radius: 3px;
background: DiscoBuilderState.all-checked background: DiscoBuilderState.all-checked
? D.accent : transparent; ? Theme.accent : transparent;
border-width: DiscoBuilderState.all-checked ? 0px : 1.5px; border-width: DiscoBuilderState.all-checked ? 0px : 1.5px;
border-color: D.text-muted; border-color: Theme.text-muted;
if DiscoBuilderState.all-checked: QbzIcon { if DiscoBuilderState.all-checked: QbzIcon {
source: @image-url("../assets/icons/check.svg"); source: @image-url("../assets/icons/check.svg");
width: 11px; width: 11px;
height: 11px; height: 11px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: D.btn-primary-text; tint: Theme.accent-text;
} }
// Indeterminate dash. // Indeterminate dash.
if !DiscoBuilderState.all-checked if !DiscoBuilderState.all-checked
&& DiscoBuilderState.some-checked: Rectangle { && DiscoBuilderState.some-checked: Rectangle {
width: 9px; width: 9px;
height: 2px; height: 2px;
background: D.accent; background: Theme.accent;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
} }
@@ -754,27 +711,27 @@ export component DiscographyBuilderView inherits Rectangle {
// header-row text-transform:uppercase; literal caps). // header-row text-transform:uppercase; literal caps).
Rectangle { Rectangle {
width: 56px; width: 56px;
Text { text: @tr("YEAR"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: left; } Text { text: @tr("YEAR"); color: Theme.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: left; }
} }
Rectangle { Rectangle {
width: 72px; width: 72px;
Text { text: @tr("TYPE"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: left; } Text { text: @tr("TYPE"); color: Theme.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: left; }
} }
Rectangle { Rectangle {
horizontal-stretch: 1; horizontal-stretch: 1;
Text { text: @tr("ALBUM"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; horizontal-alignment: left; } Text { text: @tr("ALBUM"); color: Theme.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; horizontal-alignment: left; }
} }
Rectangle { Rectangle {
width: 72px; width: 72px;
Text { text: @tr("TRACKS"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; } Text { text: @tr("TRACKS"); color: Theme.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; }
} }
Rectangle { Rectangle {
width: 60px; width: 60px;
Text { text: @tr("SOURCE"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; } Text { text: @tr("SOURCE"); color: Theme.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; }
} }
Rectangle { Rectangle {
width: 156px; width: 156px;
Text { text: @tr("QUALITY"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; } Text { text: @tr("QUALITY"); color: Theme.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; }
} }
} }
} }
@@ -806,11 +763,11 @@ export component DiscographyBuilderView inherits Rectangle {
Rectangle { Rectangle {
vertical-stretch: 0; vertical-stretch: 0;
height: 60px; height: 60px;
background: D.bg-primary; background: Theme.surface-main;
Rectangle { Rectangle {
y: 0; y: 0;
height: 1px; height: 1px;
background: D.bg-tertiary; background: Theme.surface-elevated;
} }
HorizontalLayout { HorizontalLayout {
padding-left: 32px; padding-left: 32px;
@@ -825,7 +782,7 @@ export component DiscographyBuilderView inherits Rectangle {
Text { Text {
text: DiscoBuilderState.selected-count + " of " text: DiscoBuilderState.selected-count + " of "
+ DiscoBuilderState.group-count + " albums selected"; + DiscoBuilderState.group-count + " albums selected";
color: D.text-secondary; color: Theme.text-secondary;
font-size: 13px; font-size: 13px;
} }
} }
+122 -311
View File
@@ -26,18 +26,21 @@ import { Radius } from "../foundation/radius.slint";
import { MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, TrackItem, NavState } from "../state.slint"; import { MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, TrackItem, NavState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint";
import { CircleAction } from "../primitives/CircleAction.slint"; import { CircleAction } from "../primitives/CircleAction.slint";
import { ToggleButton } from "../primitives/ToggleButton.slint";
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { TrackRow } from "../primitives/TrackRow.slint"; import { TrackRow } from "../primitives/TrackRow.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint"; import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint"; import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { SourceGlyph } from "../primitives/SourceGlyph.slint";
import { CollectionMosaic } from "CollectionMosaic.slint"; import { CollectionMosaic } from "CollectionMosaic.slint";
// --- One sort / type-filter dropdown row -------------------------------- // --- One type / source filter dropdown row ------------------------------
component MenuOption inherits Rectangle { component MenuOption inherits Rectangle {
in property <string> label; in property <string> label;
in property <bool> selected: false; in property <bool> selected: false;
in property <string> indicator: ""; // sort arrow on the active sort
in property <bool> check: false; // trailing Check (filter selection) in property <bool> check: false; // trailing Check (filter selection)
callback chosen(); callback chosen();
height: 30px; height: 30px;
@@ -50,16 +53,10 @@ component MenuOption inherits Rectangle {
Text { Text {
text: root.label; text: root.label;
color: root.selected ? Theme.accent : Theme.text-primary; color: root.selected ? Theme.accent : Theme.text-primary;
font-size: 12px; font-size: Typography.legal;
vertical-alignment: center; vertical-alignment: center;
horizontal-stretch: 1; horizontal-stretch: 1;
} }
if root.indicator != "": Text {
text: root.indicator;
color: Theme.text-muted;
font-size: 11px;
vertical-alignment: center;
}
if root.check && root.selected: VerticalLayout { if root.check && root.selected: VerticalLayout {
alignment: center; alignment: center;
QbzIcon { QbzIcon {
@@ -76,25 +73,26 @@ component MenuOption inherits Rectangle {
} }
} }
// --- A generic toolbar control button (sort / filter / reset / select) -- // --- A homologated toolbar trigger button (filter / select) -------------
component ControlBtn inherits Rectangle { // Matches LocalLibrary's bordered filter button: 30px tall, 6px radius, a
// surface-elevated resting fill; active borders accent (LocalLibrary's
// count>0 quality-filter idiom) and an optional count badge. Used only for the
// two controls QbzSelect can't express (the multi-section filter popup + the
// select-mode toggle). NEVER an accent fill.
component ToolBtn inherits Rectangle {
in property <image> icon; in property <image> icon;
in property <string> label; // "" -> icon-only in property <string> label; // "" -> icon-only
in property <string> indicator; // sort arrow "↑"/"↓" or ""
in property <int> badge: 0; // filter count badge (0 = hidden) in property <int> badge: 0; // filter count badge (0 = hidden)
in property <bool> active: false; in property <bool> active: false;
in property <bool> subtle: false;
in property <bool> enabled: true;
callback clicked(); callback clicked();
height: 32px; height: 30px;
border-radius: 6px; border-radius: 6px;
width: row.preferred-width + (root.subtle ? 16px : 20px); width: row.preferred-width + (root.label == "" ? 16px : 20px);
opacity: root.enabled ? 1.0 : 0.5; background: cb-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
background: cb-ta.has-hover ? Theme.surface-hover : Theme.surface-card;
border-width: 1px; border-width: 1px;
border-color: root.active ? Theme.accent : Theme.surface-elevated; border-color: root.active ? Theme.accent : Theme.border-subtle;
row := HorizontalLayout { row := HorizontalLayout {
x: root.subtle ? 8px : 10px; x: root.label == "" ? 8px : 10px;
height: 100%; height: 100%;
spacing: 6px; spacing: 6px;
alignment: center; alignment: center;
@@ -102,25 +100,17 @@ component ControlBtn inherits Rectangle {
alignment: center; alignment: center;
QbzIcon { QbzIcon {
source: root.icon; source: root.icon;
width: 14px; width: 15px;
height: 14px; height: 15px;
tint: root.active tint: root.active ? Theme.text-primary : Theme.text-muted;
? Theme.accent
: (root.subtle ? Theme.text-muted : Theme.text-primary);
} }
} }
if root.label != "": Text { if root.label != "": Text {
text: root.label; text: root.label;
color: root.active ? Theme.accent : Theme.text-primary; color: root.active ? Theme.text-primary : Theme.text-secondary;
font-size: 12px; font-size: 12px;
vertical-alignment: center; vertical-alignment: center;
} }
if root.indicator != "": Text {
text: root.indicator;
color: Theme.text-muted;
font-size: 11px;
vertical-alignment: center;
}
if root.badge > 0: Rectangle { if root.badge > 0: Rectangle {
width: 16px; width: 16px;
height: 16px; height: 16px;
@@ -137,59 +127,11 @@ component ControlBtn inherits Rectangle {
} }
} }
cb-ta := TouchArea { cb-ta := TouchArea {
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.clicked();
}
}
}
}
// --- One segment of the view-mode group ---------------------------------
component ViewSeg inherits Rectangle {
in property <image> icon;
in property <bool> active: false;
callback clicked();
height: 30px;
width: 31px;
background: root.active
? Theme.accent
: (vs-ta.has-hover ? Theme.surface-hover : transparent);
QbzIcon {
source: root.icon;
width: 14px;
height: 14px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active ? Theme.accent-text : Theme.text-primary;
}
vs-ta := TouchArea {
mouse-cursor: pointer; mouse-cursor: pointer;
clicked => { root.clicked(); } clicked => { root.clicked(); }
} }
} }
// --- Source indicator (masked SVG tinted to accent) ---------------------
component SourceIndicator inherits Rectangle {
in property <string> kind; // "qobuz" | "plex" | "local"
width: 28px;
height: 28px;
QbzIcon {
source: root.kind == "plex"
? @image-url("../assets/icons/plex-logo.svg")
: (root.kind == "local"
? @image-url("../assets/icons/hard-drive.svg")
: @image-url("../assets/icons/qobuz-logo-filled.svg"));
width: root.kind == "qobuz" ? 16px : 14px;
height: root.kind == "qobuz" ? 16px : 14px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.accent;
opacity: 0.9;
}
}
// --- The per-row ⋯ context menu (read-only: items are stubs) ------------- // --- The per-row ⋯ context menu (read-only: items are stubs) -------------
component RowMenuItem inherits Rectangle { component RowMenuItem inherits Rectangle {
in property <image> icon; in property <image> icon;
@@ -210,12 +152,12 @@ component RowMenuItem inherits Rectangle {
source: root.icon; source: root.icon;
width: 13px; width: 13px;
height: 13px; height: 13px;
tint: root.destructive ? #e57373 : Theme.text-secondary; tint: root.destructive ? Theme.favorite : Theme.text-secondary;
} }
} }
Text { Text {
text: root.label; text: root.label;
color: root.destructive ? #e57373 : Theme.text-primary; color: root.destructive ? Theme.favorite : Theme.text-primary;
font-size: 13px; font-size: 13px;
vertical-alignment: center; vertical-alignment: center;
} }
@@ -402,17 +344,20 @@ component DetailRow inherits Rectangle {
} }
} }
// Col 4 — source (80px). // Col 4 — source (80px). Plain-Image colorize (full-color Qobuz/Plex
// logos untinted; monochrome local hard-drive tinted muted) — the
// shared SourceGlyph, NOT an accent silhouette.
Rectangle { Rectangle {
width: 80px; width: 80px;
SourceIndicator { if root.item.source-kind != "": SourceGlyph {
kind: root.item.source-kind; kind: root.item.source-kind;
x: 0; x: 0;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
} }
} }
// Col 5 — quality (160px). Deferred: hidden until resolveItems. // Col 5 — quality (160px). Fed from the resolveItems pass (the row's
// album-level quality, derived from its first resolved track).
Rectangle { Rectangle {
width: 160px; width: 160px;
if root.item.quality-tier != "": QualityBadgeFull { if root.item.quality-tier != "": QualityBadgeFull {
@@ -428,7 +373,7 @@ component DetailRow inherits Rectangle {
width: 72px; width: 72px;
text: root.item.tracks-text; text: root.item.tracks-text;
color: Theme.text-secondary; color: Theme.text-secondary;
font-size: 13px; font-size: Typography.legal;
horizontal-alignment: right; horizontal-alignment: right;
vertical-alignment: center; vertical-alignment: center;
} }
@@ -438,7 +383,7 @@ component DetailRow inherits Rectangle {
width: 60px; width: 60px;
text: root.item.year-text; text: root.item.year-text;
color: Theme.text-secondary; color: Theme.text-secondary;
font-size: 13px; font-size: Typography.legal;
horizontal-alignment: right; horizontal-alignment: right;
vertical-alignment: center; vertical-alignment: center;
} }
@@ -630,13 +575,6 @@ component DetailCard inherits Rectangle {
} }
export component MixtapeDetailView inherits Rectangle { export component MixtapeDetailView inherits Rectangle {
property <string> active-sort-label:
MyQbzDetailState.sort == "name" ? @tr("Name")
: MyQbzDetailState.sort == "year" ? @tr("Year")
: MyQbzDetailState.sort == "tracks" ? @tr("Tracks")
: @tr("Position");
property <string> sort-arrow: MyQbzDetailState.sort-dir == "asc" ? "↑" : "↓";
// Grid geometry (spec 12 §7/§15): auto-fill minmax(150,1fr), gap 20. // Grid geometry (spec 12 §7/§15): auto-fill minmax(150,1fr), gap 20.
property <length> grid-min-w: 150px; property <length> grid-min-w: 150px;
property <length> grid-gap: 20px; property <length> grid-gap: 20px;
@@ -653,43 +591,17 @@ export component MixtapeDetailView inherits Rectangle {
} }
// ===================== NOT-FOUND =================================== // ===================== NOT-FOUND ===================================
// No per-view back button — the shell owns the global < > nav.
if !MyQbzDetailState.loading && !MyQbzDetailState.found: VerticalLayout { if !MyQbzDetailState.loading && !MyQbzDetailState.found: VerticalLayout {
padding: 40px; padding: 40px;
spacing: 16px; spacing: 16px;
alignment: start; alignment: center;
HorizontalLayout {
alignment: start;
nf-back := TouchArea {
width: nf-row.preferred-width;
height: 20px;
mouse-cursor: pointer;
clicked => { NavState.request-back(); }
nf-row := HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/arrow-left.svg");
width: 16px;
height: 16px;
tint: nf-back.has-hover ? Theme.text-secondary : Theme.text-muted;
}
}
Text {
text: @tr("Back");
color: nf-back.has-hover ? Theme.text-secondary : Theme.text-muted;
font-size: 14px;
vertical-alignment: center;
}
}
}
}
HorizontalLayout { HorizontalLayout {
alignment: center; alignment: center;
Text { Text {
text: @tr("Not found"); text: @tr("Not found");
color: Theme.text-muted; color: Theme.text-muted;
font-size: 14px; font-size: Typography.link;
} }
} }
} }
@@ -717,46 +629,17 @@ export component MixtapeDetailView inherits Rectangle {
page := VerticalLayout { page := VerticalLayout {
width: parent.width; width: parent.width;
// .detail-view padding: 8 8 100 18. // .detail-view padding: 8 8 100 18.
padding-top: 8px; // Canonical content padding (Favorites / LocalLibrary): 32px L/R
padding-right: 8px; // (the right pad is the scrollbar inset), 100px bottom clears the
// player bar. NO per-view back button — the shell owns the global
// < > nav.
padding-top: 16px;
padding-right: 32px;
padding-bottom: 100px; padding-bottom: 100px;
padding-left: 18px; padding-left: 32px;
spacing: 0px; spacing: 0px;
alignment: start; alignment: start;
// --- Back button (.back-btn) -------------------------------
Rectangle {
height: 16px + 8px + 24px;
back-row := HorizontalLayout {
y: 8px;
height: 16px;
width: self.preferred-width;
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/arrow-left.svg");
width: 16px;
height: 16px;
tint: back-ta.has-hover ? Theme.text-secondary : Theme.text-muted;
}
}
Text {
text: @tr("Back");
color: back-ta.has-hover ? Theme.text-secondary : Theme.text-muted;
font-size: 14px;
vertical-alignment: center;
}
}
back-ta := TouchArea {
width: back-row.preferred-width;
height: 16px;
y: 8px;
mouse-cursor: pointer;
clicked => { NavState.request-back(); }
}
}
// --- Hero header (.header-content) ------------------------- // --- Hero header (.header-content) -------------------------
HorizontalLayout { HorizontalLayout {
spacing: 32px; spacing: 32px;
@@ -800,15 +683,15 @@ export component MixtapeDetailView inherits Rectangle {
Text { Text {
text: MyQbzDetailState.kind-label; text: MyQbzDetailState.kind-label;
color: Theme.text-muted; color: Theme.text-muted;
font-size: 12px; font-size: Typography.legal;
font-weight: Typography.semibold; font-weight: Typography.semibold;
letter-spacing: 1.2px; letter-spacing: 1.2px;
} }
// Title. // Title — canonical hero title size (Album/Playlist heroes).
Text { Text {
text: MyQbzDetailState.name; text: MyQbzDetailState.name;
color: Theme.text-primary; color: Theme.text-primary;
font-size: 24px; font-size: Typography.title;
font-weight: Typography.bold; font-weight: Typography.bold;
wrap: word-wrap; wrap: word-wrap;
} }
@@ -817,14 +700,14 @@ export component MixtapeDetailView inherits Rectangle {
width: Math.min(root.width - 280px, 720px); width: Math.min(root.width - 280px, 720px);
text: MyQbzDetailState.description; text: MyQbzDetailState.description;
color: Theme.text-secondary; color: Theme.text-secondary;
font-size: 14px; font-size: Typography.link;
wrap: word-wrap; wrap: word-wrap;
} }
// Meta (item-count summary). // Meta (item-count summary).
Text { Text {
text: MyQbzDetailState.meta; text: MyQbzDetailState.meta;
color: Theme.text-muted; color: Theme.text-muted;
font-size: 13px; font-size: Typography.legal;
} }
Rectangle { height: 4px; } Rectangle { height: 4px; }
// Hero action row (spec 12 §3.6): EXACTLY four controls from // Hero action row (spec 12 §3.6): EXACTLY four controls from
@@ -971,135 +854,60 @@ export component MixtapeDetailView inherits Rectangle {
// --- Sticky toolbar (.list-controls) ------------------- // --- Sticky toolbar (.list-controls) -------------------
HorizontalLayout { HorizontalLayout {
height: 32px; height: 30px;
spacing: 8px; spacing: 8px;
// Search box. // Spacer floats the toolbar flush right.
search-box := Rectangle { Rectangle { horizontal-stretch: 1; }
width: 260px;
height: 32px; // Search box (app ExpandableSearch).
border-radius: 6px; VerticalLayout {
property <bool> has-query: MyQbzDetailState.search != ""; alignment: center;
background: (sin.has-focus || search-box.has-query) ? Theme.surface-main : Theme.surface-card; ExpandableSearch {
border-width: 1px; placeholder: @tr("Search");
border-color: (sin.has-focus || search-box.has-query) ? Theme.accent : Theme.surface-elevated; sm: true;
HorizontalLayout { query <=> MyQbzDetailState.search;
padding-left: 10px; edited(q) => { MyQbzDetailActions.search-changed(q); }
padding-right: 10px; }
spacing: 8px; }
VerticalLayout {
alignment: center; // Sort (app QbzSelect).
QbzIcon { VerticalLayout {
source: @image-url("../assets/icons/search.svg"); alignment: center;
width: 14px; QbzSelect {
height: 14px; menu-width: 170px;
tint: search-box.has-query ? Theme.accent : Theme.text-muted; sm: true;
} options: [
} @tr("Position"),
Rectangle { @tr("Name"),
horizontal-stretch: 1; @tr("Year"),
clip: true; @tr("Tracks"),
sin := TextInput { ];
width: 100%; current-index: MyQbzDetailState.sort == "name" ? 1
height: 100%; : MyQbzDetailState.sort == "year" ? 2
vertical-alignment: center; : MyQbzDetailState.sort == "tracks" ? 3
color: Theme.text-primary; : 0;
font-size: 12px; selected(i) => {
text <=> MyQbzDetailState.search; MyQbzDetailActions.set-sort(
edited => { MyQbzDetailActions.search-changed(self.text); } i == 1 ? "name"
} : i == 2 ? "year"
if MyQbzDetailState.search == "": Text { : i == 3 ? "tracks"
text: @tr("Search albums & tracks…"); : "position");
color: Theme.text-muted;
font-size: 12px;
vertical-alignment: center;
}
}
if search-box.has-query: VerticalLayout {
alignment: center;
Rectangle {
width: 18px;
height: 18px;
border-radius: 9px;
background: sclr-ta.has-hover ? Theme.surface-elevated : transparent;
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 12px;
height: 12px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: sclr-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
sclr-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
MyQbzDetailState.search = "";
MyQbzDetailActions.search-changed("");
}
}
}
} }
} }
} }
// Sort dropdown. // Type + source filter (bordered icon button + badge, the
Rectangle { // LocalLibrary quality-filter idiom; multi-section popup).
width: sort-btn.width;
sort-btn := ControlBtn {
icon: @image-url("../assets/icons/arrow-up-down.svg");
label: root.active-sort-label;
indicator: root.sort-arrow;
clicked => { sort-menu.show(); }
}
sort-menu := PopupWindow {
x: 0; y: 36px; width: 170px;
Rectangle {
background: Theme.surface-main;
border-width: 1px;
border-color: Theme.surface-elevated;
border-radius: 8px;
drop-shadow-blur: 24px;
drop-shadow-color: #00000066;
VerticalLayout {
padding: 4px;
spacing: 1px;
MenuOption {
label: @tr("Position");
selected: MyQbzDetailState.sort == "position";
indicator: MyQbzDetailState.sort == "position" ? root.sort-arrow : "";
chosen => { MyQbzDetailActions.set-sort("position"); }
}
MenuOption {
label: @tr("Name");
selected: MyQbzDetailState.sort == "name";
indicator: MyQbzDetailState.sort == "name" ? root.sort-arrow : "";
chosen => { MyQbzDetailActions.set-sort("name"); }
}
MenuOption {
label: @tr("Year");
selected: MyQbzDetailState.sort == "year";
indicator: MyQbzDetailState.sort == "year" ? root.sort-arrow : "";
chosen => { MyQbzDetailActions.set-sort("year"); }
}
MenuOption {
label: @tr("Tracks");
selected: MyQbzDetailState.sort == "tracks";
indicator: MyQbzDetailState.sort == "tracks" ? root.sort-arrow : "";
chosen => { MyQbzDetailActions.set-sort("tracks"); }
}
}
}
}
}
// Type + source filter dropdown.
Rectangle { Rectangle {
width: filter-btn.width; width: filter-btn.width;
filter-btn := ControlBtn { VerticalLayout {
icon: @image-url("../assets/icons/list-filter.svg"); alignment: center;
label: @tr("Filter"); filter-btn := ToolBtn {
badge: MyQbzDetailState.filter-count; icon: @image-url("../assets/icons/list-filter.svg");
active: MyQbzDetailState.filter-count > 0; badge: MyQbzDetailState.filter-count;
clicked => { filter-menu.show(); } active: MyQbzDetailState.filter-count > 0;
clicked => { filter-menu.show(); }
}
} }
filter-menu := PopupWindow { filter-menu := PopupWindow {
x: 0; y: 36px; width: 200px; x: 0; y: 36px; width: 200px;
@@ -1184,52 +992,55 @@ export component MixtapeDetailView inherits Rectangle {
} }
} }
// Reset (when any filter/sort differs from defaults). // Reset (when any filter/sort differs from defaults) —
if MyQbzDetailState.has-any-filter: ControlBtn { // icon-only ToolBtn (surface-elevated, never accent fill).
icon: @image-url("../assets/icons/rotate-ccw.svg"); if MyQbzDetailState.has-any-filter: VerticalLayout {
subtle: true; alignment: center;
clicked => { MyQbzDetailActions.reset-filters(); } ToolBtn {
icon: @image-url("../assets/icons/rotate-ccw.svg");
clicked => { MyQbzDetailActions.reset-filters(); }
}
} }
// Select / Cancel. // Select / Cancel.
ControlBtn { VerticalLayout {
icon: @image-url("../assets/icons/square-check-big.svg"); alignment: center;
label: MyQbzDetailState.select-mode ? @tr("Cancel") : @tr("Select"); ToolBtn {
active: MyQbzDetailState.select-mode; icon: @image-url("../assets/icons/square-check-big.svg");
clicked => { MyQbzDetailActions.toggle-select-mode(); } label: MyQbzDetailState.select-mode ? @tr("Cancel") : @tr("Select");
active: MyQbzDetailState.select-mode;
clicked => { MyQbzDetailActions.toggle-select-mode(); }
}
} }
// Spacer pushes the view-mode group flush right. // View-mode toggle (list / grid / expanded) — the app's
Rectangle { horizontal-stretch: 1; } // ToggleButton segments in a surface-elevated box (the
// 3-segment ViewToggle idiom; ADR-008, never accent-filled).
// View-mode group (list / grid / expanded).
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
Rectangle { Rectangle {
height: 32px; height: 30px;
width: 95px; width: 90px;
border-radius: 6px; border-radius: 6px;
border-width: 1px; background: Theme.surface-elevated;
border-color: Theme.surface-elevated;
background: Theme.surface-card;
clip: true; clip: true;
HorizontalLayout { HorizontalLayout {
spacing: 0px; ToggleButton {
ViewSeg {
icon: @image-url("../assets/icons/list.svg"); icon: @image-url("../assets/icons/list.svg");
active: MyQbzDetailState.view-mode == "list"; active: MyQbzDetailState.view-mode == "list";
sm: true;
clicked => { MyQbzDetailActions.set-view-mode("list"); } clicked => { MyQbzDetailActions.set-view-mode("list"); }
} }
Rectangle { width: 1px; background: Theme.surface-elevated; } ToggleButton {
ViewSeg {
icon: @image-url("../assets/icons/layout-grid.svg"); icon: @image-url("../assets/icons/layout-grid.svg");
active: MyQbzDetailState.view-mode == "grid"; active: MyQbzDetailState.view-mode == "grid";
sm: true;
clicked => { MyQbzDetailActions.set-view-mode("grid"); } clicked => { MyQbzDetailActions.set-view-mode("grid"); }
} }
Rectangle { width: 1px; background: Theme.surface-elevated; } ToggleButton {
ViewSeg {
icon: @image-url("../assets/icons/rows-3.svg"); icon: @image-url("../assets/icons/rows-3.svg");
active: MyQbzDetailState.view-mode == "expanded"; active: MyQbzDetailState.view-mode == "expanded";
sm: true;
clicked => { MyQbzDetailActions.set-view-mode("expanded"); } clicked => { MyQbzDetailActions.set-view-mode("expanded"); }
} }
} }
+69 -168
View File
@@ -1,72 +1,35 @@
// My QBZ > Mixtapes — the index/landing grid of the user's mixtapes // My QBZ > Mixtapes — the index/landing grid of the user's mixtapes
// (Tauri's MixtapesView.svelte). READ-ONLY in this slice: the grid DISPLAYS // (Tauri's MixtapesView.svelte). The grid DISPLAYS rows and routes from the
// rows and routes from the sidebar; the create CTA + card click are STUBS // sidebar; the create CTA + card click run their wired callbacks.
// (MyQbzActions.create-mixtape / open-card just log "not yet wired").
// //
// Every px / token is verbatim from spec 10. Root padding 8 8 100 18 (left // HOMOLOGATED to the app's index views (Favorites / LocalLibrary): NO per-view
// 18 lines up with sidebar-accessed views; bottom 100 clears the player bar). // back button (the shell owns the global < > nav), 32px L/R content padding so
// Grid = fixed 208px auto-fill columns, justify-start (cards never stretch). // the grid never rides under the ListScrollbar, Typography tokens, the app's
// Two distinct empty states: zero-mixtapes (big mosaic+title+CTA) vs // ExpandableSearch + QbzSelect toolbar controls and the shared ViewToggle, and
// search-matched-none (small "No results found"). // a surface-elevated "+ New" action (NewActionBtn) instead of a blue pill.
import { Theme } from "../foundation/semantic-colors.slint"; import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint"; import { Typography } from "../foundation/typography.slint";
import { MyQbzState, MyQbzActions, MixtapeCardItem, NavState } from "../state.slint"; import { MyQbzState, MyQbzActions, MixtapeCardItem, NavState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint"; import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { CollectionMosaic } from "CollectionMosaic.slint"; import { CollectionMosaic } from "CollectionMosaic.slint";
import { import { NewActionBtn, ViewToggle, MyQbzCard, MyQbzListRow } from "MyQbzShared.slint";
PrimaryCta, MyQbzSearch, ControlBtn, ViewModeGroup, MyQbzCard, MyQbzListRow,
} from "MyQbzShared.slint";
// One sort dropdown row (space-between label + trailing arrow on active).
// Defined before MixtapesView (Slint has no forward references in a file).
component SortItem inherits Rectangle {
in property <string> label;
in property <bool> selected: false;
in property <string> indicator: "";
callback activated();
height: 32px;
border-radius: 4px;
background: item-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
Text {
text: root.label;
color: root.selected ? Theme.accent : Theme.text-secondary;
font-size: 13px;
vertical-alignment: center;
horizontal-stretch: 1;
}
if root.indicator != "": Text {
text: root.indicator;
color: Theme.text-muted;
font-size: 11px;
vertical-alignment: center;
}
}
item-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.activated(); }
}
}
export component MixtapesView inherits Rectangle { export component MixtapesView inherits Rectangle {
// Fixed grid geometry (spec 10: 208px columns, 20px gap, 12px-padded // Fixed grid geometry (208px columns, 20px gap, 12px-padded card -> 184
// card -> 184 mosaic). card-h matches MyQbzCard's computed height. // mosaic). card-h matches MyQbzCard's computed height.
property <length> card-w: 208px; property <length> card-w: 208px;
property <length> grid-gap: 20px; property <length> grid-gap: 20px;
property <length> card-h: 184px + 12px + 12px + 16px + 2 * 18px + 12px + 16px + 12px; property <length> card-h: 184px + 12px + 12px + 18px + 2 * 20px + 18px + 12px;
property <string> active-sort-label: // Sort index for the QbzSelect (position / name / items / updated).
MyQbzState.mix-sort == "name" ? @tr("Name") property <int> sort-index:
: MyQbzState.mix-sort == "items" ? @tr("Items") MyQbzState.mix-sort == "name" ? 1
: MyQbzState.mix-sort == "updated" ? @tr("Recently updated") : MyQbzState.mix-sort == "items" ? 2
: @tr("Position"); : MyQbzState.mix-sort == "updated" ? 3
property <string> sort-arrow: MyQbzState.mix-sort-dir == "asc" ? "↑" : "↓"; : 0;
property <bool> has-active-filters:
MyQbzState.mix-sort != "position" || MyQbzState.mix-sort-dir != "asc";
background: Theme.surface-main; background: Theme.surface-main;
@@ -88,66 +51,36 @@ export component MixtapesView inherits Rectangle {
page := VerticalLayout { page := VerticalLayout {
width: parent.width; width: parent.width;
// .mixtapes-view padding: 8 8 100 18. // Canonical content padding (Favorites / LocalLibrary): 32px L/R
padding-top: 8px; // (the 32px right pad is the scrollbar inset), 100px bottom clears
padding-right: 8px; // the player bar.
padding-top: 16px;
padding-right: 32px;
padding-bottom: 100px; padding-bottom: 100px;
padding-left: 18px; padding-left: 32px;
spacing: 0px; spacing: 0px;
alignment: start; alignment: start;
// --- Back button (.back-btn) ------------------------------- // --- Header (.view-header) — no per-view back button -------
Rectangle {
height: 16px + 8px + 24px; // icon row + top/bottom margins
back-row := HorizontalLayout {
y: 8px;
height: 16px;
width: self.preferred-width;
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/arrow-left.svg");
width: 16px;
height: 16px;
tint: back-ta.has-hover ? Theme.text-secondary : Theme.text-muted;
}
}
Text {
text: @tr("Back");
color: back-ta.has-hover ? Theme.text-secondary : Theme.text-muted;
font-size: 14px;
vertical-alignment: center;
}
}
back-ta := TouchArea {
width: back-row.preferred-width;
height: 16px;
y: 8px;
mouse-cursor: pointer;
clicked => { NavState.request-back(); }
}
}
// --- Header (.view-header) ---------------------------------
HorizontalLayout { HorizontalLayout {
height: 38px; height: 40px;
spacing: 16px;
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
horizontal-stretch: 1; horizontal-stretch: 1;
Text { Text {
text: @tr("Mixtapes"); text: @tr("Mixtapes");
color: Theme.text-primary; color: Theme.text-primary;
font-size: 24px; font-size: Typography.section;
font-weight: Typography.bold; font-weight: Typography.bold;
vertical-alignment: center; vertical-alignment: center;
} }
} }
VerticalLayout { // Header "+ New" shows only when populated; when empty the
// empty-state CTA is the single call-to-action (no duplicate).
if MyQbzState.mixtapes.length > 0 || MyQbzState.mix-search != "": VerticalLayout {
alignment: center; alignment: center;
PrimaryCta { NewActionBtn {
label: @tr("+ New Mixtape");
show-plus: true;
clicked => { MyQbzActions.create-mixtape(); } clicked => { MyQbzActions.create-mixtape(); }
} }
} }
@@ -172,15 +105,13 @@ export component MixtapesView inherits Rectangle {
Text { Text {
text: @tr("No mixtapes yet"); text: @tr("No mixtapes yet");
color: Theme.text-secondary; color: Theme.text-secondary;
font-size: 18px; font-size: Typography.section;
font-weight: Typography.semibold; font-weight: Typography.semibold;
} }
} }
HorizontalLayout { HorizontalLayout {
alignment: center; alignment: center;
PrimaryCta { NewActionBtn {
label: @tr("+ New Mixtape");
show-plus: false;
clicked => { MyQbzActions.create-mixtape(); } clicked => { MyQbzActions.create-mixtape(); }
} }
} }
@@ -193,73 +124,43 @@ export component MixtapesView inherits Rectangle {
// --- Toolbar (.list-controls) -------------------------- // --- Toolbar (.list-controls) --------------------------
HorizontalLayout { HorizontalLayout {
height: 32px; height: 30px;
spacing: 8px; spacing: 8px;
MyQbzSearch { // Spacer floats the toolbar flush right (margin-left:auto).
placeholder: @tr("Search mixtapes…");
query <=> MyQbzState.mix-search;
edited(q) => { MyQbzActions.mix-search-changed(q); }
}
// Sort dropdown.
Rectangle {
width: sort-btn.width;
sort-btn := ControlBtn {
icon: @image-url("../assets/icons/arrow-up-down.svg");
label: root.active-sort-label;
indicator: root.sort-arrow;
clicked => { sort-menu.show(); }
}
sort-menu := PopupWindow {
x: 0; y: 36px; width: 180px;
Rectangle {
background: Theme.surface-main;
border-width: 1px;
border-color: Theme.surface-elevated;
border-radius: 8px;
drop-shadow-blur: 24px;
drop-shadow-color: #00000066;
VerticalLayout {
padding: 4px;
spacing: 1px;
SortItem {
label: @tr("Position");
selected: MyQbzState.mix-sort == "position";
indicator: MyQbzState.mix-sort == "position" ? root.sort-arrow : "";
activated => { MyQbzActions.mix-set-sort("position"); }
}
SortItem {
label: @tr("Name");
selected: MyQbzState.mix-sort == "name";
indicator: MyQbzState.mix-sort == "name" ? root.sort-arrow : "";
activated => { MyQbzActions.mix-set-sort("name"); }
}
SortItem {
label: @tr("Items");
selected: MyQbzState.mix-sort == "items";
indicator: MyQbzState.mix-sort == "items" ? root.sort-arrow : "";
activated => { MyQbzActions.mix-set-sort("items"); }
}
SortItem {
label: @tr("Recently updated");
selected: MyQbzState.mix-sort == "updated";
indicator: MyQbzState.mix-sort == "updated" ? root.sort-arrow : "";
activated => { MyQbzActions.mix-set-sort("updated"); }
}
}
}
}
}
// Reset (only when filters active).
if root.has-active-filters: ControlBtn {
icon: @image-url("../assets/icons/rotate-ccw.svg");
subtle: true;
clicked => { MyQbzActions.mix-reset(); }
}
// Spacer pushes the view-mode group flush right (margin-left:auto).
Rectangle { horizontal-stretch: 1; } Rectangle { horizontal-stretch: 1; }
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
ViewModeGroup { ExpandableSearch {
placeholder: @tr("Search");
sm: true;
query <=> MyQbzState.mix-search;
edited(q) => { MyQbzActions.mix-search-changed(q); }
}
}
VerticalLayout {
alignment: center;
QbzSelect {
menu-width: 180px;
sm: true;
options: [
@tr("Position"),
@tr("Name"),
@tr("Items"),
@tr("Recently updated"),
];
current-index: root.sort-index;
selected(i) => {
MyQbzActions.mix-set-sort(
i == 1 ? "name"
: i == 2 ? "items"
: i == 3 ? "updated"
: "position");
}
}
}
VerticalLayout {
alignment: center;
ViewToggle {
view: MyQbzState.mix-view; view: MyQbzState.mix-view;
set-view(v) => { MyQbzActions.mix-set-view(v); } set-view(v) => { MyQbzActions.mix-set-view(v); }
} }
@@ -273,7 +174,7 @@ export component MixtapesView inherits Rectangle {
Text { Text {
text: @tr("No results found"); text: @tr("No results found");
color: Theme.text-muted; color: Theme.text-muted;
font-size: 13px; font-size: Typography.legal;
horizontal-alignment: center; horizontal-alignment: center;
vertical-alignment: center; vertical-alignment: center;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
+56 -239
View File
@@ -1,278 +1,112 @@
// Shared MyQBZ index widgets — the toolbar controls + grid card + list row, // Shared MyQBZ index widgets — the grid card + list row + the two small
// reused 1:1 by both MixtapesView and CollectionsView (the Svelte vocabulary // app-homologated toolbar wrappers (the grid/list ViewToggle and the "+ New"
// is intentionally identical between the two index views). Every px / token // action button), reused 1:1 by both MixtapesView and CollectionsView.
// here is verbatim from spec 10/11. //
// HOMOLOGATION: the toolbar controls are the app's own primitives. The
// reinvented PrimaryCta (blue accent-filled pill), MyQbzSearch (fixed-width
// always-open box), ControlBtn (bordered button) and ViewSeg/ViewModeGroup
// (accent-filled segment) were DELETED — the index views now use
// ExpandableSearch + QbzSelect directly and the two small wrappers below
// (copied verbatim from Favorites/LocalLibrary). No accent-filled action pill.
import { Theme } from "../foundation/semantic-colors.slint"; import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint"; import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint"; import { Radius } from "../foundation/radius.slint";
import { MixtapeCardItem } from "../state.slint"; import { MixtapeCardItem } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ToggleButton } from "../primitives/ToggleButton.slint";
import { CollectionMosaic } from "CollectionMosaic.slint"; import { CollectionMosaic } from "CollectionMosaic.slint";
// --- Primary CTA ("+ New Mixtape" / "+ New Collection") ----------------- // --- "+ New" action button ----------------------------------------------
// Header instance shows a Plus icon AND the label (which already starts with // The app's secondary header action idiom (LocalLibrary's gear CircleIconBtn):
// "+ " — verbatim, not a bug). Empty-state instance is text-only. // a 28×28 surface-elevated circle with a plus glyph — NEVER an accent fill.
export component PrimaryCta inherits Rectangle { // Replaces the deleted blue PrimaryCta.
in property <string> label; export component NewActionBtn inherits Rectangle {
in property <bool> show-plus: true;
callback clicked(); callback clicked();
height: 38px; // padding 10px*2 + ~18 line width: 28px;
width: row.preferred-width + 40px; // 20px horizontal padding each side height: 28px;
border-radius: 8px; border-radius: 14px;
background: cta-ta.has-hover ? Theme.accent-hover : Theme.accent; background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
row := HorizontalLayout {
x: 20px;
height: 100%;
spacing: 8px;
alignment: center;
if root.show-plus: VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/plus.svg");
width: 16px;
height: 16px;
tint: Theme.accent-text;
}
}
Text {
text: root.label;
color: Theme.accent-text;
font-size: 14px;
font-weight: Typography.semibold;
vertical-alignment: center;
}
}
cta-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// --- Search box ---------------------------------------------------------
export component MyQbzSearch inherits Rectangle {
in property <string> placeholder;
in-out property <string> query;
callback edited(string);
height: 32px;
min-width: 220px;
border-radius: 6px;
property <bool> has-query: root.query != "";
background: (input.has-focus || root.has-query) ? Theme.surface-main : Theme.surface-card;
border-width: 1px;
border-color: (input.has-focus || root.has-query) ? Theme.accent : Theme.surface-elevated;
HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/search.svg");
width: 14px;
height: 14px;
tint: Theme.text-muted;
}
}
Rectangle {
horizontal-stretch: 1;
clip: true;
input := TextInput {
width: 100%;
height: 100%;
vertical-alignment: center;
color: Theme.text-primary;
font-size: 13px;
text <=> root.query;
edited => { root.edited(self.text); }
}
if root.query == "": Text {
text: root.placeholder;
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
}
}
// Clear (X) — only when query non-empty.
if root.has-query: VerticalLayout {
alignment: center;
Rectangle {
width: 18px;
height: 18px;
border-radius: 9px;
background: clear-ta.has-hover ? Theme.surface-elevated : transparent;
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 12px;
height: 12px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: clear-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
clear-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.query = "";
root.edited("");
}
}
}
}
}
}
// --- Generic control button (sort / filter / reset / view-seg base) -----
export component ControlBtn inherits Rectangle {
in property <image> icon;
in property <string> label; // "" -> icon-only
in property <string> indicator; // sort arrow "↑"/"↓" or ""
in property <bool> active: false;
in property <bool> subtle: false;
callback clicked();
height: 32px;
border-radius: 6px;
width: row.preferred-width + (root.subtle ? 16px : 20px);
background: btn-ta.has-hover ? Theme.surface-hover : Theme.surface-card;
border-width: 1px;
border-color: root.active ? Theme.accent : Theme.surface-elevated;
row := HorizontalLayout {
x: root.subtle ? 8px : 10px;
height: 100%;
spacing: 6px;
alignment: center;
VerticalLayout {
alignment: center;
QbzIcon {
source: root.icon;
width: 14px;
height: 14px;
tint: root.active
? Theme.accent
: (root.subtle ? Theme.text-muted : Theme.text-primary);
}
}
if root.label != "": Text {
text: root.label;
color: root.active ? Theme.accent : Theme.text-primary;
font-size: 13px;
vertical-alignment: center;
}
if root.indicator != "": Text {
text: root.indicator;
color: Theme.text-muted;
font-size: 11px;
vertical-alignment: center;
}
}
btn-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// --- Segmented list/grid view toggle ------------------------------------
// One segment of the toggle; defined before ViewModeGroup (no forward refs).
// Slint Rectangles have no per-side border, so the group draws ONE outer
// border + corner rounds and the segments fill it; a 1px divider sits between
// them (the `.seg` shared-border look from the PSD).
component ViewSeg inherits Rectangle {
in property <image> icon;
in property <bool> active: false;
callback clicked();
height: 30px; // 32px group - 2*1px outer border
width: 31px;
background: root.active
? Theme.accent
: (seg-ta.has-hover ? Theme.surface-hover : transparent);
QbzIcon { QbzIcon {
source: root.icon; source: @image-url("../assets/icons/plus.svg");
width: 14px; width: 15px;
height: 14px; height: 15px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active ? Theme.accent-text : Theme.text-primary; tint: Theme.text-primary;
} }
seg-ta := TouchArea { ta := TouchArea {
mouse-cursor: pointer; mouse-cursor: pointer;
clicked => { root.clicked(); } clicked => { root.clicked(); }
} }
} }
// Two joined buttons (List | Grid) reading as one pill; active fills accent. // --- Grid/list view toggle ----------------------------------------------
export component ViewModeGroup inherits Rectangle { // Verbatim from FavoritesView/LocalLibraryView's `ViewToggle`: a 60×30
// surface-elevated rounded box holding two `ToggleButton { sm: true }`.
// ADR-008: small-radius rectangle, NOT a pill; active = surface-hover, never
// accent.
export component ViewToggle inherits Rectangle {
in property <string> view; // "grid" | "list" in property <string> view; // "grid" | "list"
callback set-view(string); callback set-view(string);
height: 32px; width: 60px;
width: 64px; height: 30px;
border-radius: 6px; border-radius: 6px;
border-width: 1px; background: Theme.surface-elevated;
border-color: Theme.surface-elevated;
background: Theme.surface-card;
clip: true; clip: true;
HorizontalLayout { HorizontalLayout {
spacing: 0px; ToggleButton {
ViewSeg {
icon: @image-url("../assets/icons/list.svg"); icon: @image-url("../assets/icons/list.svg");
active: root.view == "list"; active: root.view == "list";
sm: true;
clicked => { root.set-view("list"); } clicked => { root.set-view("list"); }
} }
// 1px divider between the segments. ToggleButton {
Rectangle {
width: 1px;
background: Theme.surface-elevated;
}
ViewSeg {
icon: @image-url("../assets/icons/layout-grid.svg"); icon: @image-url("../assets/icons/layout-grid.svg");
active: root.view == "grid"; active: root.view == "grid";
sm: true;
clicked => { root.set-view("grid"); } clicked => { root.set-view("grid"); }
} }
} }
} }
// --- Grid card ---------------------------------------------------------- // --- Grid card ----------------------------------------------------------
// 208px column; 12px padding -> 184px mosaic. Eyebrow + 2-line name (reserve // 208px column; 12px padding -> 184px mosaic. Muted subtitle (Typography.legal,
// 2 lines so cards align) + album-count meta. // no accent eyebrow) + 2-line name (Typography.body) + album-count meta.
export component MyQbzCard inherits Rectangle { export component MyQbzCard inherits Rectangle {
in property <MixtapeCardItem> item; in property <MixtapeCardItem> item;
callback clicked(); callback clicked();
width: 208px; width: 208px;
height: 184px + 12px + 12px + 16px + 2 * 18px + 12px + 16px + 12px; height: 184px + 12px + 12px + 18px + 2 * 20px + 18px + 12px;
border-radius: 8px; border-radius: 8px;
background: card-ta.has-hover ? Theme.surface-hover : Theme.surface-card; background: card-ta.has-hover ? Theme.surface-hover : Theme.surface-card;
border-width: 1px; border-width: 1px;
border-color: card-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; border-color: card-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
VerticalLayout { VerticalLayout {
padding: 12px; padding: 12px;
spacing: 12px; spacing: 8px;
alignment: start; alignment: start;
CollectionMosaic { CollectionMosaic {
item: root.item; item: root.item;
size: 184px; size: 184px;
} }
// card-label (eyebrow) — accent, uppercase, +4px top margin. Rectangle { height: 2px; }
Rectangle { height: 4px; } // card-name — 2-line clamp (reserve 2 lines so cards align).
Text {
text: root.item.label;
color: Theme.accent;
font-size: 10px;
font-weight: Typography.semibold;
letter-spacing: 1.2px;
}
// card-name — 2-line clamp; reserve 2 lines (~36px at 14px/1.3).
Text { Text {
text: root.item.name; text: root.item.name;
color: Theme.text-primary; color: Theme.text-primary;
font-size: 14px; font-size: Typography.body;
font-weight: Typography.semibold; font-weight: Typography.semibold;
wrap: word-wrap; wrap: word-wrap;
overflow: elide; overflow: elide;
height: 36px; height: 40px;
width: 100%; width: 100%;
} }
// Muted subtitle: kind label + count (no uppercase accent eyebrow).
Text { Text {
text: root.item.meta; text: root.item.label + " · " + root.item.meta;
color: Theme.text-muted; color: Theme.text-muted;
font-size: 12px; font-size: Typography.legal;
} }
} }
card-ta := TouchArea { card-ta := TouchArea {
@@ -308,34 +142,17 @@ export component MyQbzListRow inherits Rectangle {
Text { Text {
text: root.item.name; text: root.item.name;
color: Theme.text-primary; color: Theme.text-primary;
font-size: 14px; font-size: Typography.body;
font-weight: Typography.semibold; font-weight: Typography.semibold;
overflow: elide; overflow: elide;
width: 100%; width: 100%;
} }
HorizontalLayout { Text {
spacing: 8px; text: root.item.label + " · " + root.item.meta;
alignment: start; color: Theme.text-muted;
Text { font-size: Typography.legal;
text: root.item.label; overflow: elide;
color: Theme.accent; width: 100%;
font-size: 10px;
font-weight: Typography.semibold;
letter-spacing: 1.2px;
vertical-alignment: center;
}
Text {
text: "·";
color: Theme.text-muted;
font-size: 12px;
vertical-alignment: center;
}
Text {
text: root.item.meta;
color: Theme.text-muted;
font-size: 12px;
vertical-alignment: center;
}
} }
} }
} }
@@ -0,0 +1,28 @@
// Shared per-source glyph — the discreet source indicator used by the album
// list row, the MyQBZ collection-detail rows and the Discography Builder rows.
//
// The load-bearing rule (homologated from AlbumListRow / TrackRow): the
// full-color Qobuz / Plex logos render UNTINTED (`colorize: transparent`); only
// the monochrome local hard-drive glyph is tinted — to MUTED TEXT color, never
// accent. Using QbzIcon (which floods every pixel with its tint) would turn the
// logos into a flat accent silhouette — the bug this primitive replaces.
//
// Inherits Image (so it IS the glyph). The caller sets `kind` — "source" is
// already Image's own property, hence the distinct name.
import { Theme } from "../foundation/semantic-colors.slint";
export component SourceGlyph inherits Image {
// "qobuz" | "qobuz_download" | "plex" | "local" (anything else = local).
in property <string> kind;
source: root.kind == "plex"
? @image-url("../assets/icons/plex-logo.svg")
: (root.kind == "qobuz" || root.kind == "qobuz_download"
? @image-url("../assets/icons/qobuz-logo-filled.svg")
: @image-url("../assets/icons/hard-drive.svg"));
colorize: (root.kind == "qobuz" || root.kind == "qobuz_download" || root.kind == "plex")
? transparent : Theme.text-muted;
width: (root.kind == "qobuz" || root.kind == "qobuz_download") ? 16px : 15px;
height: self.width;
image-fit: contain;
}