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) => {
myqbz_detail::navigate(weak.clone(), handle.clone(), image_cache.clone(), id);
myqbz_detail::navigate(
runtime.clone(),
weak.clone(),
handle.clone(),
image_cache.clone(),
id,
);
}
nav::NavEntry::DiscographyBuilder(artist_id) => {
navigate_discography_builder(
@@ -2031,6 +2037,7 @@ fn wire_playlist_manager(
/// + re-issue mosaic artwork jobs. Mirrors `wire_playlist_manager`.
fn wire_myqbz(
window: &AppWindow,
app_runtime: &Arc<AppRuntime<SlintAdapter>>,
tokio_rt: &tokio::runtime::Runtime,
image_cache: &artwork::ImageCache,
) {
@@ -2050,9 +2057,11 @@ fn wire_myqbz(
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
let runtime = app_runtime.clone();
window.global::<MyQbzActions>().on_open_card(move |id| {
nav::record(nav::NavEntry::MixtapeDetail(id.to_string()));
myqbz_detail::navigate(
runtime.clone(),
weak.clone(),
handle.clone(),
image_cache.clone(),
@@ -2107,6 +2116,7 @@ fn wire_myqbz(
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
let runtime = app_runtime.clone();
window.global::<MyQbzCreateActions>().on_submit(move || {
let Some(w) = weak.upgrade() else { return; };
let st = w.global::<MyQbzCreateState>();
@@ -2120,6 +2130,7 @@ fn wire_myqbz(
let weak = weak.clone();
let handle = handle.clone();
let image_cache = image_cache.clone();
let runtime = runtime.clone();
handle.clone().spawn(async move {
let nm = name.trim().to_string();
let created =
@@ -2131,6 +2142,7 @@ fn wire_myqbz(
let weak2 = weak.clone();
let handle2 = handle.clone();
let image_cache2 = image_cache.clone();
let runtime2 = runtime.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
let st = w.global::<MyQbzCreateState>();
st.set_creating(false);
@@ -2141,6 +2153,7 @@ fn wire_myqbz(
// Drop into the new collection's detail view.
nav::record(nav::NavEntry::MixtapeDetail(c.id.clone()));
myqbz_detail::navigate(
runtime2.clone(),
weak2.clone(),
handle2.clone(),
image_cache2.clone(),
@@ -2398,11 +2411,17 @@ fn wire_myqbz_detail(
) {
use MyQbzDetailActions as Act;
// Stash the runtime for the mutation-reload paths (cover/edit) that re-run
// `myqbz_detail::navigate` (whose resolveItems pass needs it) without
// threading it through every entry point.
myqbz_detail::set_runtime(app_runtime.clone());
// After a toolbar re-derive the rendered model changed, so the visible
// rows need their thumbnails reloaded.
// rows need their thumbnails reloaded — through the SOURCE-SPLIT dispatch
// (Qobuz CDN urls via HTTP; local/Plex paths via the source-aware decoder).
fn refresh_row_covers(window: &AppWindow, image_cache: &artwork::ImageCache) {
let jobs = myqbz_detail::artwork_jobs(window);
artwork::spawn_loads(jobs, window.as_weak(), image_cache.clone());
let split = myqbz_detail::artwork_jobs(window);
myqbz_detail::dispatch_artwork(split, window.as_weak(), image_cache.clone());
}
// A toolbar re-derive rebuilds the rendered model with fresh rows
@@ -3162,12 +3181,15 @@ fn wire_disco_builder(
myqbz_builder::toast_created(&w);
// Navigate to the new collection's detail.
nav::record(nav::NavEntry::MixtapeDetail(collection_id.clone()));
myqbz_detail::navigate(
w.as_weak(),
handle.clone(),
image_cache.clone(),
collection_id,
);
if let Some(runtime) = myqbz_detail::global_runtime() {
myqbz_detail::navigate(
runtime,
w.as_weak(),
handle.clone(),
image_cache.clone(),
collection_id,
);
}
update_nav_flags(&w);
}
None => {
@@ -7894,7 +7916,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// === Playlist Manager actions ======================================
wire_playlist_manager(&window, &app_runtime, &tokio_rt, &image_cache);
wire_myqbz(&window, &tokio_rt, &image_cache);
wire_myqbz(&window, &app_runtime, &tokio_rt, &image_cache);
wire_myqbz_detail(&window, &app_runtime, &tokio_rt, &image_cache);
wire_disco_builder(&window, &tokio_rt, &image_cache);
{
+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.
fn card_item(c: &MixtapeCollection) -> MixtapeCardItem {
let item_count = c.items.len();
let has_custom = c.custom_artwork_path.is_some();
// Decode the custom cover from disk so a custom-art mixtape/collection
// renders its real image in the grid (NOT a blank square). Same source-aware
// load as the detail view (the path is the on-disk artwork-cache file). A
// missing/undecodable path is treated as "no custom cover" so the mosaic
// shows instead of an empty full-bleed square — and so `has_custom` drives
// cover_count + the URL closure consistently below.
let decoded_custom = c
.custom_artwork_path
.as_ref()
.filter(|p| !p.is_empty())
.filter(|p| std::path::Path::new(p).exists())
.and_then(|p| slint::Image::load_from_path(std::path::Path::new(p)).ok());
let (custom_image, has_custom) = match decoded_custom {
Some(img) => (img, true),
None => (slint::Image::default(), false),
};
// cols: 3x3 only for a Collection with >= 9 items; else 2x2.
let cols: u32 = if c.kind == CollectionKind::Collection && item_count >= 9 {
@@ -197,8 +213,6 @@ fn card_item(c: &MixtapeCollection) -> MixtapeCardItem {
}
};
let custom_image = slint::Image::default();
MixtapeCardItem {
id: c.id.clone().into(),
name: c.name.clone().into(),
+2 -1
View File
@@ -224,8 +224,9 @@ fn reload(
image_cache: ImageCache,
id: String,
) {
let Some(runtime) = crate::myqbz_detail::global_runtime() else { return };
let _ = weak.upgrade_in_event_loop(move |w| {
let _ = &w; // keep the closure's capture explicit.
crate::myqbz_detail::navigate(w.as_weak(), handle.clone(), image_cache.clone(), id);
crate::myqbz_detail::navigate(runtime, w.as_weak(), handle.clone(), image_cache.clone(), id);
});
}
+298 -31
View File
@@ -34,6 +34,32 @@ use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache};
use crate::{AppWindow, ContentView, MixtapeDetailItem, MyQbzDetailState, NavState, TrackItem};
/// Process-global runtime handle, set ONCE during startup wiring. The
/// mutation-reload paths (cover upload/remove, rename/description/convert/
/// remove-selected) re-run `navigate` to refresh the open detail, and
/// `navigate`'s resolveItems pass needs the runtime; rather than thread the
/// `Arc<AppRuntime>` through every one of those entry points + their main.rs
/// callsites, they pull it from here. The primary nav-in path still passes the
/// runtime explicitly. Set by `set_runtime` at wiring time.
static GLOBAL_RUNTIME: std::sync::OnceLock<
std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
> = std::sync::OnceLock::new();
/// Store the shared runtime for the global reload paths (idempotent — a second
/// call is ignored). Called once during startup wiring.
pub fn set_runtime(
runtime: std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
) {
let _ = GLOBAL_RUNTIME.set(runtime);
}
/// The shared runtime for the reload paths. `None` only before wiring (never in
/// practice, since reloads happen after the UI is up).
pub fn global_runtime(
) -> Option<std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>> {
GLOBAL_RUNTIME.get().cloned()
}
thread_local! {
/// The full, original-order item list for the open collection — the
/// canonical source the toolbar derives the visible list from. UI thread
@@ -59,6 +85,31 @@ thread_local! {
/// stored prefs; while `false` every toolbar persist is suppressed so an
/// early setter can't clobber the about-to-be-restored prefs. UI thread.
static PREFS_HYDRATED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// resolveItems cache, keyed by the STABLE per-item key
/// (`source|source_item_id`). Holds the LIVE-resolved per-row display values
/// that `MixtapeCollectionItem` alone can't carry: the resolved source kind
/// (qobuz / plex / local), the album-level quality tier + detail (derived
/// from the item's first resolved track), and the resolved TYPE label
/// (album -> EP/Single/Album by track count). Populated once per item by the
/// `resolve_items` pass (spawned after `apply`), re-hydrated in `to_item` on
/// every filter/sort/search re-derive so the columns stay populated without
/// re-fetching. Cleared on `reset`. UI thread only.
static RESOLVE_CACHE: std::cell::RefCell<std::collections::HashMap<String, ResolvedItem>> =
std::cell::RefCell::new(std::collections::HashMap::new());
}
/// The live-resolved per-row display values cached by `RESOLVE_CACHE`.
#[derive(Clone, Default)]
struct ResolvedItem {
/// "qobuz" | "plex" | "local".
source_kind: String,
/// "hires" | "cd" | "" — the row's `QualityBadgeFull` tier.
quality_tier: String,
/// "24-bit / 96 kHz" etc; "" when tier is "".
quality_detail: String,
/// Uppercased TYPE-column label (ALBUM / EP / SINGLE / TRACK / PLAYLIST).
type_label: String,
}
/// Persist the open collection's current toolbar prefs (spec 12 §18), gated on
@@ -198,13 +249,40 @@ fn year_text(item: &MixtapeCollectionItem) -> String {
/// DEFERRED, so quality badge inputs stay empty here).
fn to_item(item: &MixtapeCollectionItem) -> MixtapeDetailItem {
let source = source_str(item.source);
// `small_qobuz_url` only rewrites Qobuz CDN `_<size>.jpg` URLs; running it on
// a LOCAL filesystem path (or a Plex `/library/...` path) corrupts/no-ops it.
// Gate the rewrite to Qobuz items; local/plex artwork passes through raw so
// the source-aware artwork dispatch can read it as a file/Plex thumb.
let artwork_url = item
.artwork_url
.as_deref()
.filter(|u| !u.is_empty())
.map(|u| crate::myqbz::small_qobuz_url(u, 50))
.map(|u| {
if item.source == AlbumSource::Qobuz {
crate::myqbz::small_qobuz_url(u, 50)
} else {
u.to_string()
}
})
.unwrap_or_default();
// Re-hydrate the live-resolved display values (source kind, quality
// tier/detail, type label) from the resolveItems cache so a filter/sort/
// search re-derive keeps the columns populated without re-fetching. A miss
// falls back to the stored-source defaults (qobuz/local + no quality), which
// the `resolve_items` pass then fills in.
let cache_key = inline_cache_key(source, &item.source_item_id);
let resolved = RESOLVE_CACHE.with(|cell| cell.borrow().get(&cache_key).cloned());
let (source_kind, quality_tier, quality_detail, type_label) = match resolved {
Some(r) => (r.source_kind, r.quality_tier, r.quality_detail, r.type_label),
None => (
source.to_string(),
String::new(),
String::new(),
type_label(item.item_type).to_string(),
),
};
// Re-hydrate inline tracks from the controller-level cache (keyed
// `source|source_item_id`) so a filter/sort/search re-derive does NOT lose
// already-resolved tracks or trigger a re-fetch (spec 12 §8 — the cache
@@ -227,13 +305,12 @@ fn to_item(item: &MixtapeCollectionItem) -> MixtapeDetailItem {
// Only qobuz items get a clickable artist subtitle (spec 12 §6.3).
subtitle_is_link: item.source == AlbumSource::Qobuz
&& item.subtitle.as_deref().map(|s| !s.is_empty()).unwrap_or(false),
// Resolved source kind — defaults to the raw source until resolveItems
// lands (a later slice). qobuz -> "qobuz"; local -> "local".
source_kind: source.into(),
type_label: type_label(item.item_type).into(),
// Quality resolution is deferred; no badge until then.
quality_tier: "".into(),
quality_detail: "".into(),
// Resolved source kind / quality / type label — from the resolveItems
// cache (above) when resolved; else the stored-source defaults.
source_kind: source_kind.into(),
type_label: type_label.into(),
quality_tier: quality_tier.into(),
quality_detail: quality_detail.into(),
tracks_text: tracks_text(item).into(),
year_text: year_text(item).into(),
artwork_url: artwork_url.into(),
@@ -278,8 +355,17 @@ fn apply_hero_mosaic(state: &MyQbzDetailState, c: &MixtapeCollection) {
if has_custom || item_count == 0 || i >= cell_count {
return slint::SharedString::default();
}
match c.items.get(i).and_then(|it| it.artwork_url.as_deref()) {
Some(u) if !u.is_empty() => crate::myqbz::small_qobuz_url(u, target).into(),
let Some(it) = c.items.get(i) else {
return slint::SharedString::default();
};
match it.artwork_url.as_deref() {
// `small_qobuz_url` is Qobuz-CDN-specific; only rewrite Qobuz cells.
// Local/Plex artwork paths pass through raw for the source-aware
// dispatch.
Some(u) if !u.is_empty() && it.source == AlbumSource::Qobuz => {
crate::myqbz::small_qobuz_url(u, target).into()
}
Some(u) if !u.is_empty() => u.to_string().into(),
_ => slint::SharedString::default(),
}
};
@@ -685,6 +771,119 @@ pub fn ensure_expanded(
}
}
/// Derive a row's resolved display values from its resolved tracks (spec §17
/// resolveItems). Album-level quality = the item's first resolved track's
/// quality (24-bit+ = Hi-Res); the same tier/detail rule every other surface
/// uses. Source kind = the first track's `source` (or `is_local` fallback) —
/// this is how a Plex item (stored as `AlbumSource::Local`) is finally
/// classified `"plex"`. Type label: non-album rows keep their stored type;
/// album rows resolve to ALBUM/EP/SINGLE by the resolved track count (the
/// `QueueTrack` payload carries no release_type, so the track-count heuristic
/// — the same one favorites/labels use — applies).
fn resolve_from_tracks(
item: &MixtapeCollectionItem,
tracks: &[qbz_models::QueueTrack],
) -> ResolvedItem {
let stored = source_str(item.source);
let first = tracks.first();
let source_kind = match first {
Some(t) => t
.source
.clone()
.unwrap_or_else(|| if t.is_local { "local".into() } else { "qobuz".into() }),
None => stored.to_string(),
};
let quality_tier = match first {
Some(t) => match t.bit_depth {
Some(d) if d >= 24 => "hires",
Some(_) => "cd",
None if t.hires => "hires",
None => "",
},
None => "",
};
let quality_detail = match (first, quality_tier.is_empty()) {
(Some(t), false) => crate::quality::detail(t.bit_depth, t.sample_rate),
_ => String::new(),
};
// Type label: albums resolve their release type from the resolved track
// count; tracks/playlists keep their stored type. Uppercased to match the
// column eyebrow.
let type_label = match item.item_type {
ItemType::Album => {
crate::album_map::classify_release_type(Some(tracks.len() as u32)).to_uppercase()
}
other => type_label(other).to_string(),
};
ResolvedItem {
source_kind,
quality_tier: quality_tier.to_string(),
quality_detail,
type_label,
}
}
/// resolveItems pass (spec §17): resolve every rendered row's tracks via the
/// shared enqueue resolver (`myqbz_play::fetch_item_tracks` — the SAME
/// qobuz/local/plex backends), derive the row's source kind + album-level
/// quality + type label from the first resolved track, push the values into the
/// row, and cache them (keyed `source|source_item_id`) so a later filter/sort/
/// search re-derive re-hydrates instead of re-fetching. Spawned once after
/// `apply` (alongside the artwork jobs); already-cached rows are skipped, so a
/// re-derive is instant. Fire-and-forget: failures leave the stored-source
/// defaults in place.
pub fn resolve_items(
runtime: std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
) {
let Some(window) = weak.upgrade() else { return };
// Snapshot the items needing resolution (every full item not already
// cached). Sourced from FULL_ITEMS so the resolver gets the typed
// item_type/source + numeric fields.
let pending: Vec<MixtapeCollectionItem> = FULL_ITEMS.with(|cell| {
cell.borrow()
.iter()
.filter(|it| {
let key = inline_cache_key(source_str(it.source), &it.source_item_id);
!RESOLVE_CACHE.with(|c| c.borrow().contains_key(&key))
})
.cloned()
.collect()
});
drop(window);
for full_item in pending {
let runtime = runtime.clone();
let weak = weak.clone();
handle.spawn(async move {
let tracks = crate::myqbz_play::fetch_item_tracks(&runtime, &full_item).await;
let resolved = resolve_from_tracks(&full_item, &tracks);
let source = source_str(full_item.source).to_string();
let source_item_id = full_item.source_item_id.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
let key = inline_cache_key(&source, &source_item_id);
RESOLVE_CACHE.with(|cell| {
cell.borrow_mut().insert(key, resolved.clone());
});
// Push the resolved values into the currently-rendered row (if
// still present after any re-derive).
with_row_by_source_id(&w, &source_item_id, |it| {
it.source_kind = resolved.source_kind.clone().into();
it.quality_tier = resolved.quality_tier.clone().into();
it.quality_detail = resolved.quality_detail.clone().into();
it.type_label = resolved.type_label.clone().into();
});
});
});
}
}
/// Clear the current selection (uncheck every row + zero the count), staying in
/// select-mode. Used after a bulk action completes. UI thread.
pub fn clear_selection(window: &AppWindow) {
@@ -710,6 +909,9 @@ pub fn reset(window: &AppWindow) {
// Drop the inline-tracks cache — a different collection's tracks must not
// leak into the freshly-opened one.
INLINE_CACHE.with(|cell| cell.borrow_mut().clear());
// Drop the resolveItems cache too (same reason — the resolved source/
// quality/type of a different collection's items must not leak).
RESOLVE_CACHE.with(|cell| cell.borrow_mut().clear());
// Close the persist gate until `apply` restores this collection's prefs —
// any toolbar setter that fires meanwhile must NOT overwrite stored prefs
// with the in-flight defaults (mirrors Tauri's prefsHydrated).
@@ -805,13 +1007,31 @@ pub fn apply_not_found(window: &AppWindow) {
// ──────────────────────────── artwork jobs ────────────────────────────
/// Build artwork jobs for the loaded collection: the up-to-9 hero-mosaic cells
/// (only when no custom cover) + one thumbnail per visible item row.
pub fn artwork_jobs(window: &AppWindow) -> Vec<ArtworkJob> {
let state = window.global::<MyQbzDetailState>();
let mut jobs = Vec::new();
/// The artwork jobs for the loaded collection, SPLIT by source so each is
/// dispatched through the correct decoder (spec §17 fallback chain): Qobuz items
/// carry an HTTP CDN url → the Remote/HTTP path (`spawn_loads`); local/Plex
/// items carry a filesystem path or a Plex `/library/...` path → the
/// source-aware path (`spawn_local_or_plex_loads`). Mixing them (the old single
/// `spawn_loads`) broke local/Plex covers — a filesystem path was fetched as an
/// HTTP url and failed silently, leaving the row/hero cell blank.
#[derive(Default)]
pub struct ArtworkJobSplit {
/// Qobuz CDN urls — HTTP fetch via the disk cache.
pub remote: Vec<ArtworkJob>,
/// Local filesystem paths + Plex thumb paths — source-aware decode.
pub local_or_plex: Vec<ArtworkJob>,
}
// Hero mosaic cells.
/// Build the (remote, local/plex) artwork jobs for the loaded collection: the
/// up-to-9 hero-mosaic cells (only when no custom cover) + one thumbnail per
/// visible item row. Each job is routed to the `remote` bucket for Qobuz items
/// and the `local_or_plex` bucket otherwise.
pub fn artwork_jobs(window: &AppWindow) -> ArtworkJobSplit {
let state = window.global::<MyQbzDetailState>();
let mut split = ArtworkJobSplit::default();
// Hero mosaic cells: classify each cell by the corresponding FULL_ITEMS
// item's source (the cells map 1:1 to the first N items in original order).
if !state.get_has_custom_cover() {
let urls = [
state.get_url1(),
@@ -824,28 +1044,68 @@ pub fn artwork_jobs(window: &AppWindow) -> Vec<ArtworkJob> {
state.get_url8(),
state.get_url9(),
];
let cell_sources: Vec<AlbumSource> =
FULL_ITEMS.with(|cell| cell.borrow().iter().map(|it| it.source).collect());
for (slot, url) in urls.iter().enumerate() {
if !url.is_empty() {
jobs.push(ArtworkJob {
target: ArtworkTarget::MyQbzDetailCover { slot },
url: url.to_string(),
});
if url.is_empty() {
continue;
}
let job = ArtworkJob {
target: ArtworkTarget::MyQbzDetailCover { slot },
url: url.to_string(),
};
match cell_sources.get(slot) {
Some(AlbumSource::Qobuz) => split.remote.push(job),
_ => split.local_or_plex.push(job),
}
}
}
// Row thumbnails (the rendered model — matched back by position on apply).
// Route by the row's resolved source-kind (qobuz -> remote; plex/local ->
// source-aware). A not-yet-resolved row defaults to its stored kind.
let model = state.get_items();
for i in 0..model.row_count() {
let Some(item) = model.row_data(i) else { continue };
if !item.artwork_url.is_empty() {
jobs.push(ArtworkJob {
target: ArtworkTarget::MyQbzDetailRow { position: item.position },
url: item.artwork_url.to_string(),
});
if item.artwork_url.is_empty() {
continue;
}
let job = ArtworkJob {
target: ArtworkTarget::MyQbzDetailRow { position: item.position },
url: item.artwork_url.to_string(),
};
if item.source_kind == "qobuz" {
split.remote.push(job);
} else {
split.local_or_plex.push(job);
}
}
jobs
split
}
/// Dispatch a built `ArtworkJobSplit` through the correct decoders: Qobuz CDN
/// urls via the HTTP path (`spawn_loads`), local/Plex paths via the source-aware
/// path (`spawn_local_or_plex_loads`, threading the live Plex creds). The single
/// entry point both `navigate` (initial load) and the toolbar re-derive
/// (`refresh_row_covers`) use, so the source-split routing lives in ONE place.
pub fn dispatch_artwork(
split: ArtworkJobSplit,
weak: slint::Weak<AppWindow>,
image_cache: ImageCache,
) {
if !split.remote.is_empty() {
artwork::spawn_loads(split.remote, weak.clone(), image_cache.clone());
}
if !split.local_or_plex.is_empty() {
let plex = crate::plex_settings::get();
artwork::spawn_local_or_plex_loads(
split.local_or_plex,
plex.base_url,
plex.token,
weak,
image_cache,
);
}
}
/// Set a decoded row thumbnail by item position (the rendered model order may
@@ -884,9 +1144,12 @@ pub fn set_hero_cover(window: &AppWindow, slot: usize, image: slint::Image) {
/// Open the collection-detail view for `id`: switch the ContentView + loading
/// state immediately, fetch the collection on a blocking worker, then apply +
/// render + spawn artwork. Mirrors `myqbz::navigate` (load/apply/render) and
/// the album/playlist detail navigators.
/// render + spawn (source-split) artwork + the resolveItems pass. Mirrors
/// `myqbz::navigate` (load/apply/render) and the album/playlist detail
/// navigators. The `runtime` drives the resolveItems backend resolution
/// (quality / source-kind / type per item).
pub fn navigate(
runtime: std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
@@ -905,11 +1168,15 @@ pub fn navigate(
let collection =
tokio::task::spawn_blocking(move || get_collection(&fetch_id)).await.ok().flatten();
let resolve_handle = handle.clone();
let _ = weak.upgrade_in_event_loop(move |w| match collection {
Some(c) => {
apply(&w, c);
let jobs = artwork_jobs(&w);
artwork::spawn_loads(jobs, w.as_weak(), image_cache.clone());
let split = artwork_jobs(&w);
dispatch_artwork(split, w.as_weak(), image_cache.clone());
// resolveItems (spec §17): resolve each item's quality / source
// kind / type from the backends and hydrate the rows.
resolve_items(runtime, w.as_weak(), resolve_handle);
}
None => {
log::warn!("[qbz-slint] myqbz_detail navigate({id}): collection not found");
+2 -1
View File
@@ -54,10 +54,11 @@ fn reload(
image_cache: &ImageCache,
id: String,
) {
let Some(runtime) = crate::myqbz_detail::global_runtime() else { return };
let handle = handle.clone();
let image_cache = image_cache.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
crate::myqbz_detail::navigate(w.as_weak(), handle.clone(), image_cache.clone(), id);
crate::myqbz_detail::navigate(runtime, w.as_weak(), handle.clone(), image_cache.clone(), id);
});
}
+4 -2
View File
@@ -302,7 +302,7 @@ pub fn shuffle(
// collection wasn't already album_shuffle, and reload the open detail so
// the overflow toggle label reflects the new mode.
if collection.play_mode != CollectionPlayMode::AlbumShuffle {
persist_album_shuffle(&weak, &handle, &image_cache, &collection_id);
persist_album_shuffle(&runtime, &weak, &handle, &image_cache, &collection_id);
}
});
}
@@ -314,6 +314,7 @@ pub fn shuffle(
/// back to the event loop. Failures are logged, not surfaced — the shuffle
/// already played, so a failed persist must not toast an error.
fn persist_album_shuffle(
runtime: &Runtime,
weak: &slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: &crate::artwork::ImageCache,
@@ -342,11 +343,12 @@ fn persist_album_shuffle(
// no reload — their next open restores from the DB.
let handle = handle.clone();
let image_cache = image_cache.clone();
let runtime = runtime.clone();
let id = collection_id.to_string();
let _ = weak.upgrade_in_event_loop(move |w| {
use slint::ComponentHandle;
if w.global::<crate::MyQbzDetailState>().get_id().as_str() == id {
crate::myqbz_detail::navigate(w.as_weak(), handle, image_cache, id);
crate::myqbz_detail::navigate(runtime, w.as_weak(), handle, image_cache, id);
}
});
}
+84 -234
View File
@@ -1,81 +1,35 @@
// My QBZ > Collections — the index grid of the user's Collections + Artist
// Collections (Tauri's CollectionsView.svelte). Same vocabulary as Mixtapes
// plus the kind-filter dropdown (.filter-count badge + 3-item menu). The 3x3
// mosaic appears only for a `collection` with >= 9 items (handled inside
// CollectionMosaic). READ-ONLY in this slice: create CTA + card click STUB.
// plus the kind-filter (a second QbzSelect). The 3x3 mosaic appears only for a
// `collection` with >= 9 items (handled inside CollectionMosaic).
//
// 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 { Typography } from "../foundation/typography.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 { CollectionMosaic } from "CollectionMosaic.slint";
import {
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(); }
}
}
import { NewActionBtn, ViewToggle, MyQbzCard, MyQbzListRow } from "MyQbzShared.slint";
export component CollectionsView inherits Rectangle {
property <length> card-w: 208px;
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:
MyQbzState.col-sort == "name" ? @tr("Name")
: MyQbzState.col-sort == "items" ? @tr("Items")
: MyQbzState.col-sort == "updated" ? @tr("Recently updated")
: @tr("Position");
property <string> sort-arrow: MyQbzState.col-sort-dir == "asc" ? "↑" : "↓";
property <bool> has-active-filters:
MyQbzState.col-kind-filter != "all"
|| MyQbzState.col-sort != "position"
|| MyQbzState.col-sort-dir != "asc";
property <int> sort-index:
MyQbzState.col-sort == "name" ? 1
: MyQbzState.col-sort == "items" ? 2
: MyQbzState.col-sort == "updated" ? 3
: 0;
property <int> kind-index:
MyQbzState.col-kind-filter == "collection" ? 1
: MyQbzState.col-kind-filter == "artist_collection" ? 2
: 0;
background: Theme.surface-main;
@@ -96,65 +50,33 @@ export component CollectionsView inherits Rectangle {
page := VerticalLayout {
width: parent.width;
padding-top: 8px;
padding-right: 8px;
padding-top: 16px;
padding-right: 32px;
padding-bottom: 100px;
padding-left: 18px;
padding-left: 32px;
spacing: 0px;
alignment: start;
// --- 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 ------------------------------------------------
// --- Header — no per-view back button ----------------------
HorizontalLayout {
height: 38px;
height: 40px;
spacing: 16px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: @tr("Collections");
color: Theme.text-primary;
font-size: 24px;
font-size: Typography.section;
font-weight: Typography.bold;
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;
PrimaryCta {
label: @tr("+ New Collection");
show-plus: true;
NewActionBtn {
clicked => { MyQbzActions.create-collection(); }
}
}
@@ -179,15 +101,13 @@ export component CollectionsView inherits Rectangle {
Text {
text: @tr("No collections yet");
color: Theme.text-secondary;
font-size: 18px;
font-size: Typography.section;
font-weight: Typography.semibold;
}
}
HorizontalLayout {
alignment: center;
PrimaryCta {
label: @tr("+ New Collection");
show-plus: false;
NewActionBtn {
clicked => { MyQbzActions.create-collection(); }
}
}
@@ -200,132 +120,62 @@ export component CollectionsView inherits Rectangle {
// --- Toolbar -------------------------------------------
HorizontalLayout {
height: 32px;
height: 30px;
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; }
VerticalLayout {
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;
set-view(v) => { MyQbzActions.col-set-view(v); }
}
@@ -339,7 +189,7 @@ export component CollectionsView inherits Rectangle {
Text {
text: @tr("No results found");
color: Theme.text-muted;
font-size: 13px;
font-size: Typography.legal;
horizontal-alignment: center;
vertical-alignment: center;
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
// sticky-inside-scroll) so last rows never peek past the footer.
//
// CSS variable values are resolved to the concrete dark-theme values from
// spec §0 (this view hardcodes them rather than going through the Theme
// global so the port stays pixel-exact to the quoted PSD).
// HOMOLOGATED: all chrome colors go through the app's `Theme.*` tokens (so the
// accent matches every sibling view); the only hardcoded palette left is the
// 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 { DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint";
import { SecondaryButton } from "../primitives/SecondaryButton.slint";
import { SourceGlyph } from "../primitives/SourceGlyph.slint";
// ── Concrete dark-theme values (spec §0) ──────────────────────────────────
// Resolved here as global consts so the whole view shares one source.
global D {
out property <color> bg-primary: #0f0f0f; // footer bg
out property <color> bg-secondary: #1a1a1a; // input/segment/menu/secondary-btn
out property <color> bg-tertiary: #2a2a2a; // avatar placeholder, hairlines
out property <color> bg-hover: #1f1f1f; // row/segment/menu-item hover
out property <color> text-primary: #ffffff;
out property <color> text-secondary: #cccccc;
out property <color> text-muted: #888888;
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
// ── Release-type label tints (spec §0) ────────────────────────────────────
// The ONE legitimately-hardcoded palette in this view: the per-release-type
// accent colors. Everything else now goes through the app's `Theme.*` tokens
// (the old `global D` — which hardcoded the dark-theme literals AND a non-app
// `#4285F4` accent — was deleted; that blue mismatched every sibling view).
global DiscoTint {
out property <color> compilation: #f59e0b; // amber
out property <color> live: #e91e63; // pink
out property <color> ep: #60a5fa; // blue
out property <color> single: #a78bfa; // violet
}
// Resolve a release-type to its tint (plain "album" = muted, no tint).
component TypeColor {
in property <string> kind;
out property <color> color:
root.kind == "compilation" ? D.tint-compilation
: root.kind == "live" ? D.tint-live
: root.kind == "ep" ? D.tint-ep
: root.kind == "single" ? D.tint-single
: D.text-muted;
root.kind == "compilation" ? DiscoTint.compilation
: root.kind == "live" ? DiscoTint.live
: root.kind == "ep" ? DiscoTint.ep
: root.kind == "single" ? DiscoTint.single
: 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 {
in property <string> source;
width: 28px;
height: 28px;
opacity: 0.9;
HorizontalLayout {
alignment: center;
VerticalLayout {
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;
}
}
SourceGlyph {
kind: root.source;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
@@ -97,7 +73,7 @@ component TypeOverride inherits Rectangle {
type-btn := Rectangle {
// .type-btn negative margin (0 -4px) is replicated by a -4px touch
// 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;
HorizontalLayout {
padding-left: 4px;
@@ -152,9 +128,9 @@ component TypeOverride inherits Rectangle {
x: 0;
y: parent.height + 4px;
width: 160px;
background: D.bg-secondary;
background: Theme.surface-card;
border-width: 1px;
border-color: D.bg-tertiary;
border-color: Theme.surface-elevated;
border-radius: 6px;
drop-shadow-blur: 12px;
drop-shadow-color: #00000059; // rgba(0,0,0,0.35)
@@ -171,7 +147,7 @@ component TypeOverride inherits Rectangle {
]: Rectangle {
height: 28px;
border-radius: 4px;
background: item-ta.has-hover ? D.bg-hover : transparent;
background: item-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
@@ -185,7 +161,7 @@ component TypeOverride inherits Rectangle {
source: @image-url("../assets/icons/check.svg");
width: 12px;
height: 12px;
tint: D.accent;
tint: Theme.accent;
}
if root.candidate.release-type != choice.id: Rectangle {
width: 12px;
@@ -195,7 +171,7 @@ component TypeOverride inherits Rectangle {
Text {
text: choice.lbl;
color: root.candidate.release-type == choice.id
? D.accent : D.text-primary;
? Theme.accent : Theme.text-primary;
font-size: 12px;
vertical-alignment: center;
horizontal-stretch: 1;
@@ -218,13 +194,13 @@ component TypeOverride inherits Rectangle {
Rectangle {
y: 4px;
height: 1px;
background: D.bg-tertiary;
background: Theme.surface-elevated;
}
}
if root.candidate.is-overridden: Rectangle {
height: 28px;
border-radius: 4px;
background: reset-ta.has-hover ? D.bg-hover : transparent;
background: reset-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
@@ -236,12 +212,12 @@ component TypeOverride inherits Rectangle {
source: @image-url("../assets/icons/rotate-ccw.svg");
width: 12px;
height: 12px;
tint: D.text-muted;
tint: Theme.text-muted;
}
}
Text {
text: @tr("Use auto-detected");
color: D.text-muted;
color: Theme.text-muted;
font-size: 12px;
vertical-alignment: center;
horizontal-stretch: 1;
@@ -280,13 +256,21 @@ component CandidateRow inherits Rectangle {
// on hover. The parent passes the resting opacity.
in property <float> rest-opacity: 1.0;
height: 36px;
height: 44px;
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;
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 {
padding-left: 8px;
padding-right: 8px;
@@ -299,7 +283,7 @@ component CandidateRow inherits Rectangle {
spacing: 3px;
if root.alt: Text {
text: "↳";
color: D.text-muted;
color: Theme.text-muted;
font-size: 12px;
vertical-alignment: center;
}
@@ -309,16 +293,16 @@ component CandidateRow inherits Rectangle {
width: 15px;
height: 15px;
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-color: D.text-muted;
border-color: Theme.text-muted;
if root.candidate.checked: QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 11px;
height: 11px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: D.btn-primary-text;
tint: Theme.accent-text;
}
TouchArea {
mouse-cursor: pointer;
@@ -336,7 +320,7 @@ component CandidateRow inherits Rectangle {
width: 56px;
Text {
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;
vertical-alignment: center;
horizontal-alignment: left;
@@ -373,8 +357,8 @@ component CandidateRow inherits Rectangle {
title := Text {
text: root.candidate.title;
color: title-ta.has-hover
? D.accent
: (root.alt ? D.text-muted : D.text-primary);
? Theme.accent
: (root.alt ? Theme.text-muted : Theme.text-primary);
font-size: root.alt ? 13px : 14px;
overflow: elide;
vertical-alignment: center;
@@ -388,7 +372,7 @@ component CandidateRow inherits Rectangle {
// row kind + the candidate's own type.
if root.alt: Text {
text: @tr("alternate");
color: D.text-muted;
color: Theme.text-muted;
font-size: 10px;
font-weight: 500;
font-italic: true;
@@ -397,7 +381,7 @@ component CandidateRow inherits Rectangle {
}
if !root.alt && root.group-is-compilation: Text {
text: @tr("compilation");
color: D.text-muted;
color: Theme.text-muted;
font-size: 10px;
font-weight: 500;
font-italic: true;
@@ -411,7 +395,7 @@ component CandidateRow inherits Rectangle {
width: 72px;
Text {
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;
horizontal-alignment: center;
vertical-alignment: center;
@@ -466,38 +450,8 @@ export component DiscographyBuilderView inherits Rectangle {
spacing: 0px;
alignment: start;
// ── .back-btn ──
Rectangle {
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; }
// NO per-view back button — the shell owns the global < > nav
// (the footer Cancel covers exit). Vertical space reclaimed.
// ── header.builder-header ──
HorizontalLayout {
@@ -508,7 +462,7 @@ export component DiscographyBuilderView inherits Rectangle {
width: 72px;
height: 72px;
border-radius: 36px;
background: D.bg-tertiary;
background: Theme.surface-elevated;
clip: true;
if DiscoBuilderState.artist-avatar-url != "": Image {
width: 100%;
@@ -530,7 +484,7 @@ export component DiscographyBuilderView inherits Rectangle {
// AddToMixtapeModal "ADD TO MIXTAPE/COLLECTION", the
// grid card label from Rust kind_label).
text: @tr("BUILD ARTIST COLLECTION");
color: D.text-muted;
color: Theme.text-muted;
font-size: 10px;
font-weight: 600;
letter-spacing: 1.5px;
@@ -538,7 +492,7 @@ export component DiscographyBuilderView inherits Rectangle {
Text {
text: DiscoBuilderState.artist-name == ""
? "—" : DiscoBuilderState.artist-name;
color: D.text-primary;
color: Theme.text-primary;
font-size: 28px;
font-weight: 700;
overflow: elide;
@@ -553,7 +507,7 @@ export component DiscographyBuilderView inherits Rectangle {
alignment: start;
Text {
text: @tr("NAME");
color: D.text-muted;
color: Theme.text-muted;
font-size: 10px;
font-weight: 600;
letter-spacing: 1.5px;
@@ -561,15 +515,15 @@ export component DiscographyBuilderView inherits Rectangle {
Rectangle {
width: 540px;
height: 40px;
background: D.bg-secondary;
background: Theme.surface-card;
border-radius: 8px;
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 {
padding-left: 12px;
padding-right: 12px;
name-input := TextInput {
color: D.text-primary;
color: Theme.text-primary;
font-size: 14px;
vertical-alignment: center;
single-line: true;
@@ -593,7 +547,7 @@ export component DiscographyBuilderView inherits Rectangle {
// .field-label is uppercased (spec §2.6 — same
// text-transform:uppercase as the eyebrow / NAME label).
text: @tr("ORDER BY");
color: D.text-muted;
color: Theme.text-muted;
font-size: 10px;
font-weight: 600;
letter-spacing: 1.5px;
@@ -605,7 +559,7 @@ export component DiscographyBuilderView inherits Rectangle {
height: 32px;
border-radius: 8px;
border-width: 1px;
border-color: D.bg-tertiary;
border-color: Theme.surface-elevated;
clip: true;
seg := HorizontalLayout {
spacing: 0px;
@@ -615,15 +569,18 @@ export component DiscographyBuilderView inherits Rectangle {
{ id: "manual", lbl: "Manual" },
]: seg-rect := Rectangle {
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
? D.accent
: (seg-ta.has-hover ? D.bg-hover : D.bg-secondary);
? Theme.surface-hover
: (seg-ta.has-hover ? Theme.surface-elevated : transparent);
// Divider between segments.
if i > 0: Rectangle {
x: 0;
width: 1px;
height: 100%;
background: D.bg-tertiary;
background: Theme.surface-elevated;
}
HorizontalLayout {
padding-left: 14px;
@@ -632,10 +589,10 @@ export component DiscographyBuilderView inherits Rectangle {
padding-bottom: 7px;
Text {
text: s.lbl;
color: seg-rect.active ? D.btn-primary-text
: (seg-ta.has-hover ? D.text-primary : D.text-secondary);
color: seg-rect.active ? Theme.text-primary
: (seg-ta.has-hover ? Theme.text-primary : Theme.text-secondary);
font-size: 12px;
font-weight: 500;
font-weight: seg-rect.active ? 600 : 500;
vertical-alignment: center;
}
}
@@ -657,7 +614,7 @@ export component DiscographyBuilderView inherits Rectangle {
height: 96px;
Text {
text: @tr("Loading...");
color: D.text-muted;
color: Theme.text-muted;
font-size: 14px;
horizontal-alignment: center;
vertical-alignment: center;
@@ -668,7 +625,7 @@ export component DiscographyBuilderView inherits Rectangle {
height: 96px;
Text {
text: DiscoBuilderState.load-error;
color: D.error;
color: Theme.favorite;
font-size: 14px;
horizontal-alignment: center;
vertical-alignment: center;
@@ -681,7 +638,7 @@ export component DiscographyBuilderView inherits Rectangle {
height: 96px;
Text {
text: @tr("No results found");
color: D.text-muted;
color: Theme.text-muted;
font-size: 14px;
horizontal-alignment: center;
vertical-alignment: center;
@@ -693,7 +650,7 @@ export component DiscographyBuilderView inherits Rectangle {
&& DiscoBuilderState.groups.length > 0: VerticalLayout {
spacing: 0px;
// border-top + 4px pad-top.
Rectangle { height: 1px; background: D.bg-tertiary; }
Rectangle { height: 1px; background: Theme.surface-elevated; }
Rectangle { height: 4px; }
// ── header row (.row.header-row) ──
@@ -702,7 +659,7 @@ export component DiscographyBuilderView inherits Rectangle {
Rectangle {
y: parent.height - 1px;
height: 1px;
background: D.bg-tertiary;
background: Theme.surface-elevated;
}
HorizontalLayout {
padding-left: 8px;
@@ -720,23 +677,23 @@ export component DiscographyBuilderView inherits Rectangle {
height: 15px;
border-radius: 3px;
background: DiscoBuilderState.all-checked
? D.accent : transparent;
? Theme.accent : transparent;
border-width: DiscoBuilderState.all-checked ? 0px : 1.5px;
border-color: D.text-muted;
border-color: Theme.text-muted;
if DiscoBuilderState.all-checked: QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 11px;
height: 11px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: D.btn-primary-text;
tint: Theme.accent-text;
}
// Indeterminate dash.
if !DiscoBuilderState.all-checked
&& DiscoBuilderState.some-checked: Rectangle {
width: 9px;
height: 2px;
background: D.accent;
background: Theme.accent;
x: Math.round((parent.width - self.width) / 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).
Rectangle {
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 {
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 {
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 {
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 {
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 {
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 {
vertical-stretch: 0;
height: 60px;
background: D.bg-primary;
background: Theme.surface-main;
Rectangle {
y: 0;
height: 1px;
background: D.bg-tertiary;
background: Theme.surface-elevated;
}
HorizontalLayout {
padding-left: 32px;
@@ -825,7 +782,7 @@ export component DiscographyBuilderView inherits Rectangle {
Text {
text: DiscoBuilderState.selected-count + " of "
+ DiscoBuilderState.group-count + " albums selected";
color: D.text-secondary;
color: Theme.text-secondary;
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 { QbzIcon } from "../primitives/QbzIcon.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 { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { SourceGlyph } from "../primitives/SourceGlyph.slint";
import { CollectionMosaic } from "CollectionMosaic.slint";
// --- One sort / type-filter dropdown row --------------------------------
// --- One type / source filter dropdown row ------------------------------
component MenuOption inherits Rectangle {
in property <string> label;
in property <bool> selected: false;
in property <string> indicator: ""; // sort arrow on the active sort
in property <bool> check: false; // trailing Check (filter selection)
callback chosen();
height: 30px;
@@ -50,16 +53,10 @@ component MenuOption inherits Rectangle {
Text {
text: root.label;
color: root.selected ? Theme.accent : Theme.text-primary;
font-size: 12px;
font-size: Typography.legal;
vertical-alignment: center;
horizontal-stretch: 1;
}
if root.indicator != "": Text {
text: root.indicator;
color: Theme.text-muted;
font-size: 11px;
vertical-alignment: center;
}
if root.check && root.selected: VerticalLayout {
alignment: center;
QbzIcon {
@@ -76,25 +73,26 @@ component MenuOption inherits Rectangle {
}
}
// --- A generic toolbar control button (sort / filter / reset / select) --
component ControlBtn inherits Rectangle {
// --- A homologated toolbar trigger button (filter / select) -------------
// 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 <string> label; // "" -> icon-only
in property <string> indicator; // sort arrow "↑"/"↓" or ""
in property <int> badge: 0; // filter count badge (0 = hidden)
in property <bool> active: false;
in property <bool> subtle: false;
in property <bool> enabled: true;
callback clicked();
height: 32px;
height: 30px;
border-radius: 6px;
width: row.preferred-width + (root.subtle ? 16px : 20px);
opacity: root.enabled ? 1.0 : 0.5;
background: cb-ta.has-hover ? Theme.surface-hover : Theme.surface-card;
width: row.preferred-width + (root.label == "" ? 16px : 20px);
background: cb-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
border-width: 1px;
border-color: root.active ? Theme.accent : Theme.surface-elevated;
border-color: root.active ? Theme.accent : Theme.border-subtle;
row := HorizontalLayout {
x: root.subtle ? 8px : 10px;
x: root.label == "" ? 8px : 10px;
height: 100%;
spacing: 6px;
alignment: center;
@@ -102,25 +100,17 @@ component ControlBtn inherits Rectangle {
alignment: center;
QbzIcon {
source: root.icon;
width: 14px;
height: 14px;
tint: root.active
? Theme.accent
: (root.subtle ? Theme.text-muted : Theme.text-primary);
width: 15px;
height: 15px;
tint: root.active ? Theme.text-primary : Theme.text-muted;
}
}
if root.label != "": Text {
text: root.label;
color: root.active ? Theme.accent : Theme.text-primary;
color: root.active ? Theme.text-primary : Theme.text-secondary;
font-size: 12px;
vertical-alignment: center;
}
if root.indicator != "": Text {
text: root.indicator;
color: Theme.text-muted;
font-size: 11px;
vertical-alignment: center;
}
if root.badge > 0: Rectangle {
width: 16px;
height: 16px;
@@ -137,59 +127,11 @@ component ControlBtn inherits Rectangle {
}
}
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;
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) -------------
component RowMenuItem inherits Rectangle {
in property <image> icon;
@@ -210,12 +152,12 @@ component RowMenuItem inherits Rectangle {
source: root.icon;
width: 13px;
height: 13px;
tint: root.destructive ? #e57373 : Theme.text-secondary;
tint: root.destructive ? Theme.favorite : Theme.text-secondary;
}
}
Text {
text: root.label;
color: root.destructive ? #e57373 : Theme.text-primary;
color: root.destructive ? Theme.favorite : Theme.text-primary;
font-size: 13px;
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 {
width: 80px;
SourceIndicator {
if root.item.source-kind != "": SourceGlyph {
kind: root.item.source-kind;
x: 0;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// Col 5 — quality (160px). Deferred: hidden until resolveItems.
// Col 5 — quality (160px). Fed from the resolveItems pass (the row's
// album-level quality, derived from its first resolved track).
Rectangle {
width: 160px;
if root.item.quality-tier != "": QualityBadgeFull {
@@ -428,7 +373,7 @@ component DetailRow inherits Rectangle {
width: 72px;
text: root.item.tracks-text;
color: Theme.text-secondary;
font-size: 13px;
font-size: Typography.legal;
horizontal-alignment: right;
vertical-alignment: center;
}
@@ -438,7 +383,7 @@ component DetailRow inherits Rectangle {
width: 60px;
text: root.item.year-text;
color: Theme.text-secondary;
font-size: 13px;
font-size: Typography.legal;
horizontal-alignment: right;
vertical-alignment: center;
}
@@ -630,13 +575,6 @@ component DetailCard 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.
property <length> grid-min-w: 150px;
property <length> grid-gap: 20px;
@@ -653,43 +591,17 @@ export component MixtapeDetailView inherits Rectangle {
}
// ===================== NOT-FOUND ===================================
// No per-view back button — the shell owns the global < > nav.
if !MyQbzDetailState.loading && !MyQbzDetailState.found: VerticalLayout {
padding: 40px;
spacing: 16px;
alignment: start;
HorizontalLayout {
alignment: start;
nf-back := TouchArea {
width: nf-row.preferred-width;
height: 20px;
mouse-cursor: pointer;
clicked => { NavState.request-back(); }
nf-row := HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/arrow-left.svg");
width: 16px;
height: 16px;
tint: nf-back.has-hover ? Theme.text-secondary : Theme.text-muted;
}
}
Text {
text: @tr("Back");
color: nf-back.has-hover ? Theme.text-secondary : Theme.text-muted;
font-size: 14px;
vertical-alignment: center;
}
}
}
}
alignment: center;
HorizontalLayout {
alignment: center;
Text {
text: @tr("Not found");
color: Theme.text-muted;
font-size: 14px;
font-size: Typography.link;
}
}
}
@@ -717,46 +629,17 @@ export component MixtapeDetailView inherits Rectangle {
page := VerticalLayout {
width: parent.width;
// .detail-view padding: 8 8 100 18.
padding-top: 8px;
padding-right: 8px;
// Canonical content padding (Favorites / LocalLibrary): 32px L/R
// (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-left: 18px;
padding-left: 32px;
spacing: 0px;
alignment: start;
// --- Back button (.back-btn) -------------------------------
Rectangle {
height: 16px + 8px + 24px;
back-row := HorizontalLayout {
y: 8px;
height: 16px;
width: self.preferred-width;
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/arrow-left.svg");
width: 16px;
height: 16px;
tint: back-ta.has-hover ? Theme.text-secondary : Theme.text-muted;
}
}
Text {
text: @tr("Back");
color: back-ta.has-hover ? Theme.text-secondary : Theme.text-muted;
font-size: 14px;
vertical-alignment: center;
}
}
back-ta := TouchArea {
width: back-row.preferred-width;
height: 16px;
y: 8px;
mouse-cursor: pointer;
clicked => { NavState.request-back(); }
}
}
// --- Hero header (.header-content) -------------------------
HorizontalLayout {
spacing: 32px;
@@ -800,15 +683,15 @@ export component MixtapeDetailView inherits Rectangle {
Text {
text: MyQbzDetailState.kind-label;
color: Theme.text-muted;
font-size: 12px;
font-size: Typography.legal;
font-weight: Typography.semibold;
letter-spacing: 1.2px;
}
// Title.
// Title — canonical hero title size (Album/Playlist heroes).
Text {
text: MyQbzDetailState.name;
color: Theme.text-primary;
font-size: 24px;
font-size: Typography.title;
font-weight: Typography.bold;
wrap: word-wrap;
}
@@ -817,14 +700,14 @@ export component MixtapeDetailView inherits Rectangle {
width: Math.min(root.width - 280px, 720px);
text: MyQbzDetailState.description;
color: Theme.text-secondary;
font-size: 14px;
font-size: Typography.link;
wrap: word-wrap;
}
// Meta (item-count summary).
Text {
text: MyQbzDetailState.meta;
color: Theme.text-muted;
font-size: 13px;
font-size: Typography.legal;
}
Rectangle { height: 4px; }
// Hero action row (spec 12 §3.6): EXACTLY four controls from
@@ -971,135 +854,60 @@ export component MixtapeDetailView inherits Rectangle {
// --- Sticky toolbar (.list-controls) -------------------
HorizontalLayout {
height: 32px;
height: 30px;
spacing: 8px;
// Search box.
search-box := Rectangle {
width: 260px;
height: 32px;
border-radius: 6px;
property <bool> has-query: MyQbzDetailState.search != "";
background: (sin.has-focus || search-box.has-query) ? Theme.surface-main : Theme.surface-card;
border-width: 1px;
border-color: (sin.has-focus || search-box.has-query) ? Theme.accent : Theme.surface-elevated;
HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/search.svg");
width: 14px;
height: 14px;
tint: search-box.has-query ? Theme.accent : Theme.text-muted;
}
}
Rectangle {
horizontal-stretch: 1;
clip: true;
sin := TextInput {
width: 100%;
height: 100%;
vertical-alignment: center;
color: Theme.text-primary;
font-size: 12px;
text <=> MyQbzDetailState.search;
edited => { MyQbzDetailActions.search-changed(self.text); }
}
if MyQbzDetailState.search == "": Text {
text: @tr("Search albums & tracks…");
color: Theme.text-muted;
font-size: 12px;
vertical-alignment: center;
}
}
if search-box.has-query: VerticalLayout {
alignment: center;
Rectangle {
width: 18px;
height: 18px;
border-radius: 9px;
background: sclr-ta.has-hover ? Theme.surface-elevated : transparent;
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 12px;
height: 12px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: sclr-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
sclr-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
MyQbzDetailState.search = "";
MyQbzDetailActions.search-changed("");
}
}
}
// Spacer floats the toolbar flush right.
Rectangle { horizontal-stretch: 1; }
// Search box (app ExpandableSearch).
VerticalLayout {
alignment: center;
ExpandableSearch {
placeholder: @tr("Search");
sm: true;
query <=> MyQbzDetailState.search;
edited(q) => { MyQbzDetailActions.search-changed(q); }
}
}
// Sort (app QbzSelect).
VerticalLayout {
alignment: center;
QbzSelect {
menu-width: 170px;
sm: true;
options: [
@tr("Position"),
@tr("Name"),
@tr("Year"),
@tr("Tracks"),
];
current-index: MyQbzDetailState.sort == "name" ? 1
: MyQbzDetailState.sort == "year" ? 2
: MyQbzDetailState.sort == "tracks" ? 3
: 0;
selected(i) => {
MyQbzDetailActions.set-sort(
i == 1 ? "name"
: i == 2 ? "year"
: i == 3 ? "tracks"
: "position");
}
}
}
// Sort dropdown.
Rectangle {
width: sort-btn.width;
sort-btn := ControlBtn {
icon: @image-url("../assets/icons/arrow-up-down.svg");
label: root.active-sort-label;
indicator: root.sort-arrow;
clicked => { sort-menu.show(); }
}
sort-menu := PopupWindow {
x: 0; y: 36px; width: 170px;
Rectangle {
background: Theme.surface-main;
border-width: 1px;
border-color: Theme.surface-elevated;
border-radius: 8px;
drop-shadow-blur: 24px;
drop-shadow-color: #00000066;
VerticalLayout {
padding: 4px;
spacing: 1px;
MenuOption {
label: @tr("Position");
selected: MyQbzDetailState.sort == "position";
indicator: MyQbzDetailState.sort == "position" ? root.sort-arrow : "";
chosen => { MyQbzDetailActions.set-sort("position"); }
}
MenuOption {
label: @tr("Name");
selected: MyQbzDetailState.sort == "name";
indicator: MyQbzDetailState.sort == "name" ? root.sort-arrow : "";
chosen => { MyQbzDetailActions.set-sort("name"); }
}
MenuOption {
label: @tr("Year");
selected: MyQbzDetailState.sort == "year";
indicator: MyQbzDetailState.sort == "year" ? root.sort-arrow : "";
chosen => { MyQbzDetailActions.set-sort("year"); }
}
MenuOption {
label: @tr("Tracks");
selected: MyQbzDetailState.sort == "tracks";
indicator: MyQbzDetailState.sort == "tracks" ? root.sort-arrow : "";
chosen => { MyQbzDetailActions.set-sort("tracks"); }
}
}
}
}
}
// Type + source filter dropdown.
// Type + source filter (bordered icon button + badge, the
// LocalLibrary quality-filter idiom; multi-section popup).
Rectangle {
width: filter-btn.width;
filter-btn := ControlBtn {
icon: @image-url("../assets/icons/list-filter.svg");
label: @tr("Filter");
badge: MyQbzDetailState.filter-count;
active: MyQbzDetailState.filter-count > 0;
clicked => { filter-menu.show(); }
VerticalLayout {
alignment: center;
filter-btn := ToolBtn {
icon: @image-url("../assets/icons/list-filter.svg");
badge: MyQbzDetailState.filter-count;
active: MyQbzDetailState.filter-count > 0;
clicked => { filter-menu.show(); }
}
}
filter-menu := PopupWindow {
x: 0; y: 36px; width: 200px;
@@ -1184,52 +992,55 @@ export component MixtapeDetailView inherits Rectangle {
}
}
// Reset (when any filter/sort differs from defaults).
if MyQbzDetailState.has-any-filter: ControlBtn {
icon: @image-url("../assets/icons/rotate-ccw.svg");
subtle: true;
clicked => { MyQbzDetailActions.reset-filters(); }
// Reset (when any filter/sort differs from defaults) —
// icon-only ToolBtn (surface-elevated, never accent fill).
if MyQbzDetailState.has-any-filter: VerticalLayout {
alignment: center;
ToolBtn {
icon: @image-url("../assets/icons/rotate-ccw.svg");
clicked => { MyQbzDetailActions.reset-filters(); }
}
}
// Select / Cancel.
ControlBtn {
icon: @image-url("../assets/icons/square-check-big.svg");
label: MyQbzDetailState.select-mode ? @tr("Cancel") : @tr("Select");
active: MyQbzDetailState.select-mode;
clicked => { MyQbzDetailActions.toggle-select-mode(); }
VerticalLayout {
alignment: center;
ToolBtn {
icon: @image-url("../assets/icons/square-check-big.svg");
label: MyQbzDetailState.select-mode ? @tr("Cancel") : @tr("Select");
active: MyQbzDetailState.select-mode;
clicked => { MyQbzDetailActions.toggle-select-mode(); }
}
}
// Spacer pushes the view-mode group flush right.
Rectangle { horizontal-stretch: 1; }
// View-mode group (list / grid / expanded).
// View-mode toggle (list / grid / expanded) — the app's
// ToggleButton segments in a surface-elevated box (the
// 3-segment ViewToggle idiom; ADR-008, never accent-filled).
VerticalLayout {
alignment: center;
Rectangle {
height: 32px;
width: 95px;
height: 30px;
width: 90px;
border-radius: 6px;
border-width: 1px;
border-color: Theme.surface-elevated;
background: Theme.surface-card;
background: Theme.surface-elevated;
clip: true;
HorizontalLayout {
spacing: 0px;
ViewSeg {
ToggleButton {
icon: @image-url("../assets/icons/list.svg");
active: MyQbzDetailState.view-mode == "list";
sm: true;
clicked => { MyQbzDetailActions.set-view-mode("list"); }
}
Rectangle { width: 1px; background: Theme.surface-elevated; }
ViewSeg {
ToggleButton {
icon: @image-url("../assets/icons/layout-grid.svg");
active: MyQbzDetailState.view-mode == "grid";
sm: true;
clicked => { MyQbzDetailActions.set-view-mode("grid"); }
}
Rectangle { width: 1px; background: Theme.surface-elevated; }
ViewSeg {
ToggleButton {
icon: @image-url("../assets/icons/rows-3.svg");
active: MyQbzDetailState.view-mode == "expanded";
sm: true;
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
// (Tauri's MixtapesView.svelte). READ-ONLY in this slice: the grid DISPLAYS
// rows and routes from the sidebar; the create CTA + card click are STUBS
// (MyQbzActions.create-mixtape / open-card just log "not yet wired").
// (Tauri's MixtapesView.svelte). The grid DISPLAYS rows and routes from the
// sidebar; the create CTA + card click run their wired callbacks.
//
// Every px / token is verbatim from spec 10. Root padding 8 8 100 18 (left
// 18 lines up with sidebar-accessed views; bottom 100 clears the player bar).
// Grid = fixed 208px auto-fill columns, justify-start (cards never stretch).
// Two distinct empty states: zero-mixtapes (big mosaic+title+CTA) vs
// search-matched-none (small "No results found").
// HOMOLOGATED to the app's index views (Favorites / LocalLibrary): NO per-view
// back button (the shell owns the global < > nav), 32px L/R content padding so
// the grid never rides under the ListScrollbar, Typography tokens, the app's
// ExpandableSearch + QbzSelect toolbar controls and the shared ViewToggle, and
// a surface-elevated "+ New" action (NewActionBtn) instead of a blue pill.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.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 { CollectionMosaic } from "CollectionMosaic.slint";
import {
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(); }
}
}
import { NewActionBtn, ViewToggle, MyQbzCard, MyQbzListRow } from "MyQbzShared.slint";
export component MixtapesView inherits Rectangle {
// Fixed grid geometry (spec 10: 208px columns, 20px gap, 12px-padded
// card -> 184 mosaic). card-h matches MyQbzCard's computed height.
// Fixed grid geometry (208px columns, 20px gap, 12px-padded card -> 184
// mosaic). card-h matches MyQbzCard's computed height.
property <length> card-w: 208px;
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:
MyQbzState.mix-sort == "name" ? @tr("Name")
: MyQbzState.mix-sort == "items" ? @tr("Items")
: MyQbzState.mix-sort == "updated" ? @tr("Recently updated")
: @tr("Position");
property <string> sort-arrow: MyQbzState.mix-sort-dir == "asc" ? "↑" : "↓";
property <bool> has-active-filters:
MyQbzState.mix-sort != "position" || MyQbzState.mix-sort-dir != "asc";
// Sort index for the QbzSelect (position / name / items / updated).
property <int> sort-index:
MyQbzState.mix-sort == "name" ? 1
: MyQbzState.mix-sort == "items" ? 2
: MyQbzState.mix-sort == "updated" ? 3
: 0;
background: Theme.surface-main;
@@ -88,66 +51,36 @@ export component MixtapesView inherits Rectangle {
page := VerticalLayout {
width: parent.width;
// .mixtapes-view padding: 8 8 100 18.
padding-top: 8px;
padding-right: 8px;
// Canonical content padding (Favorites / LocalLibrary): 32px L/R
// (the 32px right pad is the scrollbar inset), 100px bottom clears
// the player bar.
padding-top: 16px;
padding-right: 32px;
padding-bottom: 100px;
padding-left: 18px;
padding-left: 32px;
spacing: 0px;
alignment: start;
// --- Back button (.back-btn) -------------------------------
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) ---------------------------------
// --- Header (.view-header) — no per-view back button -------
HorizontalLayout {
height: 38px;
height: 40px;
spacing: 16px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: @tr("Mixtapes");
color: Theme.text-primary;
font-size: 24px;
font-size: Typography.section;
font-weight: Typography.bold;
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;
PrimaryCta {
label: @tr("+ New Mixtape");
show-plus: true;
NewActionBtn {
clicked => { MyQbzActions.create-mixtape(); }
}
}
@@ -172,15 +105,13 @@ export component MixtapesView inherits Rectangle {
Text {
text: @tr("No mixtapes yet");
color: Theme.text-secondary;
font-size: 18px;
font-size: Typography.section;
font-weight: Typography.semibold;
}
}
HorizontalLayout {
alignment: center;
PrimaryCta {
label: @tr("+ New Mixtape");
show-plus: false;
NewActionBtn {
clicked => { MyQbzActions.create-mixtape(); }
}
}
@@ -193,73 +124,43 @@ export component MixtapesView inherits Rectangle {
// --- Toolbar (.list-controls) --------------------------
HorizontalLayout {
height: 32px;
height: 30px;
spacing: 8px;
MyQbzSearch {
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).
// Spacer floats the toolbar flush right (margin-left:auto).
Rectangle { horizontal-stretch: 1; }
VerticalLayout {
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;
set-view(v) => { MyQbzActions.mix-set-view(v); }
}
@@ -273,7 +174,7 @@ export component MixtapesView inherits Rectangle {
Text {
text: @tr("No results found");
color: Theme.text-muted;
font-size: 13px;
font-size: Typography.legal;
horizontal-alignment: center;
vertical-alignment: center;
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,
// reused 1:1 by both MixtapesView and CollectionsView (the Svelte vocabulary
// is intentionally identical between the two index views). Every px / token
// here is verbatim from spec 10/11.
// Shared MyQBZ index widgets — the grid card + list row + the two small
// app-homologated toolbar wrappers (the grid/list ViewToggle and the "+ New"
// action button), reused 1:1 by both MixtapesView and CollectionsView.
//
// 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 { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { MixtapeCardItem } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ToggleButton } from "../primitives/ToggleButton.slint";
import { CollectionMosaic } from "CollectionMosaic.slint";
// --- Primary CTA ("+ New Mixtape" / "+ New Collection") -----------------
// Header instance shows a Plus icon AND the label (which already starts with
// "+ " — verbatim, not a bug). Empty-state instance is text-only.
export component PrimaryCta inherits Rectangle {
in property <string> label;
in property <bool> show-plus: true;
// --- "+ New" action button ----------------------------------------------
// The app's secondary header action idiom (LocalLibrary's gear CircleIconBtn):
// a 28×28 surface-elevated circle with a plus glyph — NEVER an accent fill.
// Replaces the deleted blue PrimaryCta.
export component NewActionBtn inherits Rectangle {
callback clicked();
height: 38px; // padding 10px*2 + ~18 line
width: row.preferred-width + 40px; // 20px horizontal padding each side
border-radius: 8px;
background: cta-ta.has-hover ? Theme.accent-hover : Theme.accent;
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);
width: 28px;
height: 28px;
border-radius: 14px;
background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
QbzIcon {
source: root.icon;
width: 14px;
height: 14px;
source: @image-url("../assets/icons/plus.svg");
width: 15px;
height: 15px;
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;
tint: Theme.text-primary;
}
seg-ta := TouchArea {
ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// Two joined buttons (List | Grid) reading as one pill; active fills accent.
export component ViewModeGroup inherits Rectangle {
// --- Grid/list view toggle ----------------------------------------------
// 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"
callback set-view(string);
height: 32px;
width: 64px;
width: 60px;
height: 30px;
border-radius: 6px;
border-width: 1px;
border-color: Theme.surface-elevated;
background: Theme.surface-card;
background: Theme.surface-elevated;
clip: true;
HorizontalLayout {
spacing: 0px;
ViewSeg {
ToggleButton {
icon: @image-url("../assets/icons/list.svg");
active: root.view == "list";
sm: true;
clicked => { root.set-view("list"); }
}
// 1px divider between the segments.
Rectangle {
width: 1px;
background: Theme.surface-elevated;
}
ViewSeg {
ToggleButton {
icon: @image-url("../assets/icons/layout-grid.svg");
active: root.view == "grid";
sm: true;
clicked => { root.set-view("grid"); }
}
}
}
// --- Grid card ----------------------------------------------------------
// 208px column; 12px padding -> 184px mosaic. Eyebrow + 2-line name (reserve
// 2 lines so cards align) + album-count meta.
// 208px column; 12px padding -> 184px mosaic. Muted subtitle (Typography.legal,
// no accent eyebrow) + 2-line name (Typography.body) + album-count meta.
export component MyQbzCard inherits Rectangle {
in property <MixtapeCardItem> item;
callback clicked();
width: 208px;
height: 184px + 12px + 12px + 16px + 2 * 18px + 12px + 16px + 12px;
height: 184px + 12px + 12px + 18px + 2 * 20px + 18px + 12px;
border-radius: 8px;
background: card-ta.has-hover ? Theme.surface-hover : Theme.surface-card;
border-width: 1px;
border-color: card-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
VerticalLayout {
padding: 12px;
spacing: 12px;
spacing: 8px;
alignment: start;
CollectionMosaic {
item: root.item;
size: 184px;
}
// card-label (eyebrow) — accent, uppercase, +4px top margin.
Rectangle { height: 4px; }
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).
Rectangle { height: 2px; }
// card-name — 2-line clamp (reserve 2 lines so cards align).
Text {
text: root.item.name;
color: Theme.text-primary;
font-size: 14px;
font-size: Typography.body;
font-weight: Typography.semibold;
wrap: word-wrap;
overflow: elide;
height: 36px;
height: 40px;
width: 100%;
}
// Muted subtitle: kind label + count (no uppercase accent eyebrow).
Text {
text: root.item.meta;
text: root.item.label + " · " + root.item.meta;
color: Theme.text-muted;
font-size: 12px;
font-size: Typography.legal;
}
}
card-ta := TouchArea {
@@ -308,34 +142,17 @@ export component MyQbzListRow inherits Rectangle {
Text {
text: root.item.name;
color: Theme.text-primary;
font-size: 14px;
font-size: Typography.body;
font-weight: Typography.semibold;
overflow: elide;
width: 100%;
}
HorizontalLayout {
spacing: 8px;
alignment: start;
Text {
text: root.item.label;
color: Theme.accent;
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;
}
Text {
text: root.item.label + " · " + root.item.meta;
color: Theme.text-muted;
font-size: Typography.legal;
overflow: elide;
width: 100%;
}
}
}
@@ -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;
}