fix(myqbz): visual homologation — app components, colors, scrollbar, resolveItems
Per the user's visual review. Replaces the reinvented MyQBZ controls with the app's existing primitives and resolves the per-item data left unresolved. Cross-cutting (all MyQBZ views): - Removed the per-view "Back" header (the app uses the global < > nav). - 32px L/R content padding so the ListScrollbar never rides over rows/cards. - Homologated page padding + Typography tokens to Favorites/LocalLibrary. - No more blue accent-filled pills / non-app colors. Component homologation (deleted the reinvented locals + dead code): - Grid/list + detail view-mode toggle -> the app ViewToggle (ToggleButton, surface-hover active, never accent). Builder ORDER-BY segmented de-blued too. - Sort/filter -> QbzSelect (sm); search -> ExpandableSearch (sm); "+ New" -> NewActionBtn (surface-elevated circle, single centered glyph). Header "+ New" hidden when empty so the empty-state CTA is the only one (no duplicate). - Deleted PrimaryCta / MyQbzSearch / ControlBtn / ViewSeg / ViewModeGroup / SortItem / ColMenuItem + the hand-rolled sort/filter popups + reset. Data (resolveItems, was deferred): - Quality column populated from the resolved tracks (QualityBadgeFull). - New ui/primitives/SourceGlyph.slint: full-color Qobuz/Plex glyphs + a text-muted local hard-drive -- distinguishes Local vs Plex, no accent blob. Reused in the detail + builder rows. - Type column shows the real release type (ALBUM/EP/SINGLE), not always ALBUM. - Local/Plex album art now loads in the detail rows (split artwork dispatch). - Collection grid card renders custom_artwork_path. - Builder rows: 44px + a hairline so quality badges read separated. Backend crates untouched. cargo check -p qbz-slint green. cc
This commit is contained in:
12 files changed
+811
-1140
No files matched your search
@@ -1037,7 +1037,13 @@ fn apply_entry(
|
||||
);
|
||||
}
|
||||
nav::NavEntry::MixtapeDetail(id) => {
|
||||
myqbz_detail::navigate(weak.clone(), handle.clone(), image_cache.clone(), id);
|
||||
myqbz_detail::navigate(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
image_cache.clone(),
|
||||
id,
|
||||
);
|
||||
}
|
||||
nav::NavEntry::DiscographyBuilder(artist_id) => {
|
||||
navigate_discography_builder(
|
||||
@@ -2031,6 +2037,7 @@ fn wire_playlist_manager(
|
||||
/// + re-issue mosaic artwork jobs. Mirrors `wire_playlist_manager`.
|
||||
fn wire_myqbz(
|
||||
window: &AppWindow,
|
||||
app_runtime: &Arc<AppRuntime<SlintAdapter>>,
|
||||
tokio_rt: &tokio::runtime::Runtime,
|
||||
image_cache: &artwork::ImageCache,
|
||||
) {
|
||||
@@ -2050,9 +2057,11 @@ fn wire_myqbz(
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
let runtime = app_runtime.clone();
|
||||
window.global::<MyQbzActions>().on_open_card(move |id| {
|
||||
nav::record(nav::NavEntry::MixtapeDetail(id.to_string()));
|
||||
myqbz_detail::navigate(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
image_cache.clone(),
|
||||
@@ -2107,6 +2116,7 @@ fn wire_myqbz(
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
let runtime = app_runtime.clone();
|
||||
window.global::<MyQbzCreateActions>().on_submit(move || {
|
||||
let Some(w) = weak.upgrade() else { return; };
|
||||
let st = w.global::<MyQbzCreateState>();
|
||||
@@ -2120,6 +2130,7 @@ fn wire_myqbz(
|
||||
let weak = weak.clone();
|
||||
let handle = handle.clone();
|
||||
let image_cache = image_cache.clone();
|
||||
let runtime = runtime.clone();
|
||||
handle.clone().spawn(async move {
|
||||
let nm = name.trim().to_string();
|
||||
let created =
|
||||
@@ -2131,6 +2142,7 @@ fn wire_myqbz(
|
||||
let weak2 = weak.clone();
|
||||
let handle2 = handle.clone();
|
||||
let image_cache2 = image_cache.clone();
|
||||
let runtime2 = runtime.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let st = w.global::<MyQbzCreateState>();
|
||||
st.set_creating(false);
|
||||
@@ -2141,6 +2153,7 @@ fn wire_myqbz(
|
||||
// Drop into the new collection's detail view.
|
||||
nav::record(nav::NavEntry::MixtapeDetail(c.id.clone()));
|
||||
myqbz_detail::navigate(
|
||||
runtime2.clone(),
|
||||
weak2.clone(),
|
||||
handle2.clone(),
|
||||
image_cache2.clone(),
|
||||
@@ -2398,11 +2411,17 @@ fn wire_myqbz_detail(
|
||||
) {
|
||||
use MyQbzDetailActions as Act;
|
||||
|
||||
// Stash the runtime for the mutation-reload paths (cover/edit) that re-run
|
||||
// `myqbz_detail::navigate` (whose resolveItems pass needs it) without
|
||||
// threading it through every entry point.
|
||||
myqbz_detail::set_runtime(app_runtime.clone());
|
||||
|
||||
// After a toolbar re-derive the rendered model changed, so the visible
|
||||
// rows need their thumbnails reloaded.
|
||||
// rows need their thumbnails reloaded — through the SOURCE-SPLIT dispatch
|
||||
// (Qobuz CDN urls via HTTP; local/Plex paths via the source-aware decoder).
|
||||
fn refresh_row_covers(window: &AppWindow, image_cache: &artwork::ImageCache) {
|
||||
let jobs = myqbz_detail::artwork_jobs(window);
|
||||
artwork::spawn_loads(jobs, window.as_weak(), image_cache.clone());
|
||||
let split = myqbz_detail::artwork_jobs(window);
|
||||
myqbz_detail::dispatch_artwork(split, window.as_weak(), image_cache.clone());
|
||||
}
|
||||
|
||||
// A toolbar re-derive rebuilds the rendered model with fresh rows
|
||||
@@ -3162,12 +3181,15 @@ fn wire_disco_builder(
|
||||
myqbz_builder::toast_created(&w);
|
||||
// Navigate to the new collection's detail.
|
||||
nav::record(nav::NavEntry::MixtapeDetail(collection_id.clone()));
|
||||
myqbz_detail::navigate(
|
||||
w.as_weak(),
|
||||
handle.clone(),
|
||||
image_cache.clone(),
|
||||
collection_id,
|
||||
);
|
||||
if let Some(runtime) = myqbz_detail::global_runtime() {
|
||||
myqbz_detail::navigate(
|
||||
runtime,
|
||||
w.as_weak(),
|
||||
handle.clone(),
|
||||
image_cache.clone(),
|
||||
collection_id,
|
||||
);
|
||||
}
|
||||
update_nav_flags(&w);
|
||||
}
|
||||
None => {
|
||||
@@ -7894,7 +7916,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// === Playlist Manager actions ======================================
|
||||
wire_playlist_manager(&window, &app_runtime, &tokio_rt, &image_cache);
|
||||
wire_myqbz(&window, &tokio_rt, &image_cache);
|
||||
wire_myqbz(&window, &app_runtime, &tokio_rt, &image_cache);
|
||||
wire_myqbz_detail(&window, &app_runtime, &tokio_rt, &image_cache);
|
||||
wire_disco_builder(&window, &tokio_rt, &image_cache);
|
||||
{
|
||||
|
||||
@@ -166,7 +166,23 @@ fn cell_target(size: u32, cols: u32) -> u32 {
|
||||
/// 2x2-vs-3x3 rule, and pre-downscales the up-to-9 cover URLs per cell.
|
||||
fn card_item(c: &MixtapeCollection) -> MixtapeCardItem {
|
||||
let item_count = c.items.len();
|
||||
let has_custom = c.custom_artwork_path.is_some();
|
||||
|
||||
// Decode the custom cover from disk so a custom-art mixtape/collection
|
||||
// renders its real image in the grid (NOT a blank square). Same source-aware
|
||||
// load as the detail view (the path is the on-disk artwork-cache file). A
|
||||
// missing/undecodable path is treated as "no custom cover" so the mosaic
|
||||
// shows instead of an empty full-bleed square — and so `has_custom` drives
|
||||
// cover_count + the URL closure consistently below.
|
||||
let decoded_custom = c
|
||||
.custom_artwork_path
|
||||
.as_ref()
|
||||
.filter(|p| !p.is_empty())
|
||||
.filter(|p| std::path::Path::new(p).exists())
|
||||
.and_then(|p| slint::Image::load_from_path(std::path::Path::new(p)).ok());
|
||||
let (custom_image, has_custom) = match decoded_custom {
|
||||
Some(img) => (img, true),
|
||||
None => (slint::Image::default(), false),
|
||||
};
|
||||
|
||||
// cols: 3x3 only for a Collection with >= 9 items; else 2x2.
|
||||
let cols: u32 = if c.kind == CollectionKind::Collection && item_count >= 9 {
|
||||
@@ -197,8 +213,6 @@ fn card_item(c: &MixtapeCollection) -> MixtapeCardItem {
|
||||
}
|
||||
};
|
||||
|
||||
let custom_image = slint::Image::default();
|
||||
|
||||
MixtapeCardItem {
|
||||
id: c.id.clone().into(),
|
||||
name: c.name.clone().into(),
|
||||
|
||||
@@ -224,8 +224,9 @@ fn reload(
|
||||
image_cache: ImageCache,
|
||||
id: String,
|
||||
) {
|
||||
let Some(runtime) = crate::myqbz_detail::global_runtime() else { return };
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let _ = &w; // keep the closure's capture explicit.
|
||||
crate::myqbz_detail::navigate(w.as_weak(), handle.clone(), image_cache.clone(), id);
|
||||
crate::myqbz_detail::navigate(runtime, w.as_weak(), handle.clone(), image_cache.clone(), id);
|
||||
});
|
||||
}
|
||||
@@ -34,6 +34,32 @@ use slint::{ComponentHandle, Model, ModelRc, VecModel};
|
||||
use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache};
|
||||
use crate::{AppWindow, ContentView, MixtapeDetailItem, MyQbzDetailState, NavState, TrackItem};
|
||||
|
||||
/// Process-global runtime handle, set ONCE during startup wiring. The
|
||||
/// mutation-reload paths (cover upload/remove, rename/description/convert/
|
||||
/// remove-selected) re-run `navigate` to refresh the open detail, and
|
||||
/// `navigate`'s resolveItems pass needs the runtime; rather than thread the
|
||||
/// `Arc<AppRuntime>` through every one of those entry points + their main.rs
|
||||
/// callsites, they pull it from here. The primary nav-in path still passes the
|
||||
/// runtime explicitly. Set by `set_runtime` at wiring time.
|
||||
static GLOBAL_RUNTIME: std::sync::OnceLock<
|
||||
std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
/// Store the shared runtime for the global reload paths (idempotent — a second
|
||||
/// call is ignored). Called once during startup wiring.
|
||||
pub fn set_runtime(
|
||||
runtime: std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
|
||||
) {
|
||||
let _ = GLOBAL_RUNTIME.set(runtime);
|
||||
}
|
||||
|
||||
/// The shared runtime for the reload paths. `None` only before wiring (never in
|
||||
/// practice, since reloads happen after the UI is up).
|
||||
pub fn global_runtime(
|
||||
) -> Option<std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>> {
|
||||
GLOBAL_RUNTIME.get().cloned()
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The full, original-order item list for the open collection — the
|
||||
/// canonical source the toolbar derives the visible list from. UI thread
|
||||
@@ -59,6 +85,31 @@ thread_local! {
|
||||
/// stored prefs; while `false` every toolbar persist is suppressed so an
|
||||
/// early setter can't clobber the about-to-be-restored prefs. UI thread.
|
||||
static PREFS_HYDRATED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
|
||||
/// resolveItems cache, keyed by the STABLE per-item key
|
||||
/// (`source|source_item_id`). Holds the LIVE-resolved per-row display values
|
||||
/// that `MixtapeCollectionItem` alone can't carry: the resolved source kind
|
||||
/// (qobuz / plex / local), the album-level quality tier + detail (derived
|
||||
/// from the item's first resolved track), and the resolved TYPE label
|
||||
/// (album -> EP/Single/Album by track count). Populated once per item by the
|
||||
/// `resolve_items` pass (spawned after `apply`), re-hydrated in `to_item` on
|
||||
/// every filter/sort/search re-derive so the columns stay populated without
|
||||
/// re-fetching. Cleared on `reset`. UI thread only.
|
||||
static RESOLVE_CACHE: std::cell::RefCell<std::collections::HashMap<String, ResolvedItem>> =
|
||||
std::cell::RefCell::new(std::collections::HashMap::new());
|
||||
}
|
||||
|
||||
/// The live-resolved per-row display values cached by `RESOLVE_CACHE`.
|
||||
#[derive(Clone, Default)]
|
||||
struct ResolvedItem {
|
||||
/// "qobuz" | "plex" | "local".
|
||||
source_kind: String,
|
||||
/// "hires" | "cd" | "" — the row's `QualityBadgeFull` tier.
|
||||
quality_tier: String,
|
||||
/// "24-bit / 96 kHz" etc; "" when tier is "".
|
||||
quality_detail: String,
|
||||
/// Uppercased TYPE-column label (ALBUM / EP / SINGLE / TRACK / PLAYLIST).
|
||||
type_label: String,
|
||||
}
|
||||
|
||||
/// Persist the open collection's current toolbar prefs (spec 12 §18), gated on
|
||||
@@ -198,13 +249,40 @@ fn year_text(item: &MixtapeCollectionItem) -> String {
|
||||
/// DEFERRED, so quality badge inputs stay empty here).
|
||||
fn to_item(item: &MixtapeCollectionItem) -> MixtapeDetailItem {
|
||||
let source = source_str(item.source);
|
||||
// `small_qobuz_url` only rewrites Qobuz CDN `_<size>.jpg` URLs; running it on
|
||||
// a LOCAL filesystem path (or a Plex `/library/...` path) corrupts/no-ops it.
|
||||
// Gate the rewrite to Qobuz items; local/plex artwork passes through raw so
|
||||
// the source-aware artwork dispatch can read it as a file/Plex thumb.
|
||||
let artwork_url = item
|
||||
.artwork_url
|
||||
.as_deref()
|
||||
.filter(|u| !u.is_empty())
|
||||
.map(|u| crate::myqbz::small_qobuz_url(u, 50))
|
||||
.map(|u| {
|
||||
if item.source == AlbumSource::Qobuz {
|
||||
crate::myqbz::small_qobuz_url(u, 50)
|
||||
} else {
|
||||
u.to_string()
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Re-hydrate the live-resolved display values (source kind, quality
|
||||
// tier/detail, type label) from the resolveItems cache so a filter/sort/
|
||||
// search re-derive keeps the columns populated without re-fetching. A miss
|
||||
// falls back to the stored-source defaults (qobuz/local + no quality), which
|
||||
// the `resolve_items` pass then fills in.
|
||||
let cache_key = inline_cache_key(source, &item.source_item_id);
|
||||
let resolved = RESOLVE_CACHE.with(|cell| cell.borrow().get(&cache_key).cloned());
|
||||
let (source_kind, quality_tier, quality_detail, type_label) = match resolved {
|
||||
Some(r) => (r.source_kind, r.quality_tier, r.quality_detail, r.type_label),
|
||||
None => (
|
||||
source.to_string(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
type_label(item.item_type).to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
// Re-hydrate inline tracks from the controller-level cache (keyed
|
||||
// `source|source_item_id`) so a filter/sort/search re-derive does NOT lose
|
||||
// already-resolved tracks or trigger a re-fetch (spec 12 §8 — the cache
|
||||
@@ -227,13 +305,12 @@ fn to_item(item: &MixtapeCollectionItem) -> MixtapeDetailItem {
|
||||
// Only qobuz items get a clickable artist subtitle (spec 12 §6.3).
|
||||
subtitle_is_link: item.source == AlbumSource::Qobuz
|
||||
&& item.subtitle.as_deref().map(|s| !s.is_empty()).unwrap_or(false),
|
||||
// Resolved source kind — defaults to the raw source until resolveItems
|
||||
// lands (a later slice). qobuz -> "qobuz"; local -> "local".
|
||||
source_kind: source.into(),
|
||||
type_label: type_label(item.item_type).into(),
|
||||
// Quality resolution is deferred; no badge until then.
|
||||
quality_tier: "".into(),
|
||||
quality_detail: "".into(),
|
||||
// Resolved source kind / quality / type label — from the resolveItems
|
||||
// cache (above) when resolved; else the stored-source defaults.
|
||||
source_kind: source_kind.into(),
|
||||
type_label: type_label.into(),
|
||||
quality_tier: quality_tier.into(),
|
||||
quality_detail: quality_detail.into(),
|
||||
tracks_text: tracks_text(item).into(),
|
||||
year_text: year_text(item).into(),
|
||||
artwork_url: artwork_url.into(),
|
||||
@@ -278,8 +355,17 @@ fn apply_hero_mosaic(state: &MyQbzDetailState, c: &MixtapeCollection) {
|
||||
if has_custom || item_count == 0 || i >= cell_count {
|
||||
return slint::SharedString::default();
|
||||
}
|
||||
match c.items.get(i).and_then(|it| it.artwork_url.as_deref()) {
|
||||
Some(u) if !u.is_empty() => crate::myqbz::small_qobuz_url(u, target).into(),
|
||||
let Some(it) = c.items.get(i) else {
|
||||
return slint::SharedString::default();
|
||||
};
|
||||
match it.artwork_url.as_deref() {
|
||||
// `small_qobuz_url` is Qobuz-CDN-specific; only rewrite Qobuz cells.
|
||||
// Local/Plex artwork paths pass through raw for the source-aware
|
||||
// dispatch.
|
||||
Some(u) if !u.is_empty() && it.source == AlbumSource::Qobuz => {
|
||||
crate::myqbz::small_qobuz_url(u, target).into()
|
||||
}
|
||||
Some(u) if !u.is_empty() => u.to_string().into(),
|
||||
_ => slint::SharedString::default(),
|
||||
}
|
||||
};
|
||||
@@ -685,6 +771,119 @@ pub fn ensure_expanded(
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a row's resolved display values from its resolved tracks (spec §17
|
||||
/// resolveItems). Album-level quality = the item's first resolved track's
|
||||
/// quality (24-bit+ = Hi-Res); the same tier/detail rule every other surface
|
||||
/// uses. Source kind = the first track's `source` (or `is_local` fallback) —
|
||||
/// this is how a Plex item (stored as `AlbumSource::Local`) is finally
|
||||
/// classified `"plex"`. Type label: non-album rows keep their stored type;
|
||||
/// album rows resolve to ALBUM/EP/SINGLE by the resolved track count (the
|
||||
/// `QueueTrack` payload carries no release_type, so the track-count heuristic
|
||||
/// — the same one favorites/labels use — applies).
|
||||
fn resolve_from_tracks(
|
||||
item: &MixtapeCollectionItem,
|
||||
tracks: &[qbz_models::QueueTrack],
|
||||
) -> ResolvedItem {
|
||||
let stored = source_str(item.source);
|
||||
let first = tracks.first();
|
||||
|
||||
let source_kind = match first {
|
||||
Some(t) => t
|
||||
.source
|
||||
.clone()
|
||||
.unwrap_or_else(|| if t.is_local { "local".into() } else { "qobuz".into() }),
|
||||
None => stored.to_string(),
|
||||
};
|
||||
|
||||
let quality_tier = match first {
|
||||
Some(t) => match t.bit_depth {
|
||||
Some(d) if d >= 24 => "hires",
|
||||
Some(_) => "cd",
|
||||
None if t.hires => "hires",
|
||||
None => "",
|
||||
},
|
||||
None => "",
|
||||
};
|
||||
let quality_detail = match (first, quality_tier.is_empty()) {
|
||||
(Some(t), false) => crate::quality::detail(t.bit_depth, t.sample_rate),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
// Type label: albums resolve their release type from the resolved track
|
||||
// count; tracks/playlists keep their stored type. Uppercased to match the
|
||||
// column eyebrow.
|
||||
let type_label = match item.item_type {
|
||||
ItemType::Album => {
|
||||
crate::album_map::classify_release_type(Some(tracks.len() as u32)).to_uppercase()
|
||||
}
|
||||
other => type_label(other).to_string(),
|
||||
};
|
||||
|
||||
ResolvedItem {
|
||||
source_kind,
|
||||
quality_tier: quality_tier.to_string(),
|
||||
quality_detail,
|
||||
type_label,
|
||||
}
|
||||
}
|
||||
|
||||
/// resolveItems pass (spec §17): resolve every rendered row's tracks via the
|
||||
/// shared enqueue resolver (`myqbz_play::fetch_item_tracks` — the SAME
|
||||
/// qobuz/local/plex backends), derive the row's source kind + album-level
|
||||
/// quality + type label from the first resolved track, push the values into the
|
||||
/// row, and cache them (keyed `source|source_item_id`) so a later filter/sort/
|
||||
/// search re-derive re-hydrates instead of re-fetching. Spawned once after
|
||||
/// `apply` (alongside the artwork jobs); already-cached rows are skipped, so a
|
||||
/// re-derive is instant. Fire-and-forget: failures leave the stored-source
|
||||
/// defaults in place.
|
||||
pub fn resolve_items(
|
||||
runtime: std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
) {
|
||||
let Some(window) = weak.upgrade() else { return };
|
||||
|
||||
// Snapshot the items needing resolution (every full item not already
|
||||
// cached). Sourced from FULL_ITEMS so the resolver gets the typed
|
||||
// item_type/source + numeric fields.
|
||||
let pending: Vec<MixtapeCollectionItem> = FULL_ITEMS.with(|cell| {
|
||||
cell.borrow()
|
||||
.iter()
|
||||
.filter(|it| {
|
||||
let key = inline_cache_key(source_str(it.source), &it.source_item_id);
|
||||
!RESOLVE_CACHE.with(|c| c.borrow().contains_key(&key))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
});
|
||||
drop(window);
|
||||
|
||||
for full_item in pending {
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
handle.spawn(async move {
|
||||
let tracks = crate::myqbz_play::fetch_item_tracks(&runtime, &full_item).await;
|
||||
let resolved = resolve_from_tracks(&full_item, &tracks);
|
||||
let source = source_str(full_item.source).to_string();
|
||||
let source_item_id = full_item.source_item_id.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let key = inline_cache_key(&source, &source_item_id);
|
||||
RESOLVE_CACHE.with(|cell| {
|
||||
cell.borrow_mut().insert(key, resolved.clone());
|
||||
});
|
||||
// Push the resolved values into the currently-rendered row (if
|
||||
// still present after any re-derive).
|
||||
with_row_by_source_id(&w, &source_item_id, |it| {
|
||||
it.source_kind = resolved.source_kind.clone().into();
|
||||
it.quality_tier = resolved.quality_tier.clone().into();
|
||||
it.quality_detail = resolved.quality_detail.clone().into();
|
||||
it.type_label = resolved.type_label.clone().into();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the current selection (uncheck every row + zero the count), staying in
|
||||
/// select-mode. Used after a bulk action completes. UI thread.
|
||||
pub fn clear_selection(window: &AppWindow) {
|
||||
@@ -710,6 +909,9 @@ pub fn reset(window: &AppWindow) {
|
||||
// Drop the inline-tracks cache — a different collection's tracks must not
|
||||
// leak into the freshly-opened one.
|
||||
INLINE_CACHE.with(|cell| cell.borrow_mut().clear());
|
||||
// Drop the resolveItems cache too (same reason — the resolved source/
|
||||
// quality/type of a different collection's items must not leak).
|
||||
RESOLVE_CACHE.with(|cell| cell.borrow_mut().clear());
|
||||
// Close the persist gate until `apply` restores this collection's prefs —
|
||||
// any toolbar setter that fires meanwhile must NOT overwrite stored prefs
|
||||
// with the in-flight defaults (mirrors Tauri's prefsHydrated).
|
||||
@@ -805,13 +1007,31 @@ pub fn apply_not_found(window: &AppWindow) {
|
||||
|
||||
// ──────────────────────────── artwork jobs ────────────────────────────
|
||||
|
||||
/// Build artwork jobs for the loaded collection: the up-to-9 hero-mosaic cells
|
||||
/// (only when no custom cover) + one thumbnail per visible item row.
|
||||
pub fn artwork_jobs(window: &AppWindow) -> Vec<ArtworkJob> {
|
||||
let state = window.global::<MyQbzDetailState>();
|
||||
let mut jobs = Vec::new();
|
||||
/// The artwork jobs for the loaded collection, SPLIT by source so each is
|
||||
/// dispatched through the correct decoder (spec §17 fallback chain): Qobuz items
|
||||
/// carry an HTTP CDN url → the Remote/HTTP path (`spawn_loads`); local/Plex
|
||||
/// items carry a filesystem path or a Plex `/library/...` path → the
|
||||
/// source-aware path (`spawn_local_or_plex_loads`). Mixing them (the old single
|
||||
/// `spawn_loads`) broke local/Plex covers — a filesystem path was fetched as an
|
||||
/// HTTP url and failed silently, leaving the row/hero cell blank.
|
||||
#[derive(Default)]
|
||||
pub struct ArtworkJobSplit {
|
||||
/// Qobuz CDN urls — HTTP fetch via the disk cache.
|
||||
pub remote: Vec<ArtworkJob>,
|
||||
/// Local filesystem paths + Plex thumb paths — source-aware decode.
|
||||
pub local_or_plex: Vec<ArtworkJob>,
|
||||
}
|
||||
|
||||
// Hero mosaic cells.
|
||||
/// Build the (remote, local/plex) artwork jobs for the loaded collection: the
|
||||
/// up-to-9 hero-mosaic cells (only when no custom cover) + one thumbnail per
|
||||
/// visible item row. Each job is routed to the `remote` bucket for Qobuz items
|
||||
/// and the `local_or_plex` bucket otherwise.
|
||||
pub fn artwork_jobs(window: &AppWindow) -> ArtworkJobSplit {
|
||||
let state = window.global::<MyQbzDetailState>();
|
||||
let mut split = ArtworkJobSplit::default();
|
||||
|
||||
// Hero mosaic cells: classify each cell by the corresponding FULL_ITEMS
|
||||
// item's source (the cells map 1:1 to the first N items in original order).
|
||||
if !state.get_has_custom_cover() {
|
||||
let urls = [
|
||||
state.get_url1(),
|
||||
@@ -824,28 +1044,68 @@ pub fn artwork_jobs(window: &AppWindow) -> Vec<ArtworkJob> {
|
||||
state.get_url8(),
|
||||
state.get_url9(),
|
||||
];
|
||||
let cell_sources: Vec<AlbumSource> =
|
||||
FULL_ITEMS.with(|cell| cell.borrow().iter().map(|it| it.source).collect());
|
||||
for (slot, url) in urls.iter().enumerate() {
|
||||
if !url.is_empty() {
|
||||
jobs.push(ArtworkJob {
|
||||
target: ArtworkTarget::MyQbzDetailCover { slot },
|
||||
url: url.to_string(),
|
||||
});
|
||||
if url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let job = ArtworkJob {
|
||||
target: ArtworkTarget::MyQbzDetailCover { slot },
|
||||
url: url.to_string(),
|
||||
};
|
||||
match cell_sources.get(slot) {
|
||||
Some(AlbumSource::Qobuz) => split.remote.push(job),
|
||||
_ => split.local_or_plex.push(job),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Row thumbnails (the rendered model — matched back by position on apply).
|
||||
// Route by the row's resolved source-kind (qobuz -> remote; plex/local ->
|
||||
// source-aware). A not-yet-resolved row defaults to its stored kind.
|
||||
let model = state.get_items();
|
||||
for i in 0..model.row_count() {
|
||||
let Some(item) = model.row_data(i) else { continue };
|
||||
if !item.artwork_url.is_empty() {
|
||||
jobs.push(ArtworkJob {
|
||||
target: ArtworkTarget::MyQbzDetailRow { position: item.position },
|
||||
url: item.artwork_url.to_string(),
|
||||
});
|
||||
if item.artwork_url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let job = ArtworkJob {
|
||||
target: ArtworkTarget::MyQbzDetailRow { position: item.position },
|
||||
url: item.artwork_url.to_string(),
|
||||
};
|
||||
if item.source_kind == "qobuz" {
|
||||
split.remote.push(job);
|
||||
} else {
|
||||
split.local_or_plex.push(job);
|
||||
}
|
||||
}
|
||||
jobs
|
||||
split
|
||||
}
|
||||
|
||||
/// Dispatch a built `ArtworkJobSplit` through the correct decoders: Qobuz CDN
|
||||
/// urls via the HTTP path (`spawn_loads`), local/Plex paths via the source-aware
|
||||
/// path (`spawn_local_or_plex_loads`, threading the live Plex creds). The single
|
||||
/// entry point both `navigate` (initial load) and the toolbar re-derive
|
||||
/// (`refresh_row_covers`) use, so the source-split routing lives in ONE place.
|
||||
pub fn dispatch_artwork(
|
||||
split: ArtworkJobSplit,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
image_cache: ImageCache,
|
||||
) {
|
||||
if !split.remote.is_empty() {
|
||||
artwork::spawn_loads(split.remote, weak.clone(), image_cache.clone());
|
||||
}
|
||||
if !split.local_or_plex.is_empty() {
|
||||
let plex = crate::plex_settings::get();
|
||||
artwork::spawn_local_or_plex_loads(
|
||||
split.local_or_plex,
|
||||
plex.base_url,
|
||||
plex.token,
|
||||
weak,
|
||||
image_cache,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a decoded row thumbnail by item position (the rendered model order may
|
||||
@@ -884,9 +1144,12 @@ pub fn set_hero_cover(window: &AppWindow, slot: usize, image: slint::Image) {
|
||||
|
||||
/// Open the collection-detail view for `id`: switch the ContentView + loading
|
||||
/// state immediately, fetch the collection on a blocking worker, then apply +
|
||||
/// render + spawn artwork. Mirrors `myqbz::navigate` (load/apply/render) and
|
||||
/// the album/playlist detail navigators.
|
||||
/// render + spawn (source-split) artwork + the resolveItems pass. Mirrors
|
||||
/// `myqbz::navigate` (load/apply/render) and the album/playlist detail
|
||||
/// navigators. The `runtime` drives the resolveItems backend resolution
|
||||
/// (quality / source-kind / type per item).
|
||||
pub fn navigate(
|
||||
runtime: std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
image_cache: ImageCache,
|
||||
@@ -905,11 +1168,15 @@ pub fn navigate(
|
||||
let collection =
|
||||
tokio::task::spawn_blocking(move || get_collection(&fetch_id)).await.ok().flatten();
|
||||
|
||||
let resolve_handle = handle.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| match collection {
|
||||
Some(c) => {
|
||||
apply(&w, c);
|
||||
let jobs = artwork_jobs(&w);
|
||||
artwork::spawn_loads(jobs, w.as_weak(), image_cache.clone());
|
||||
let split = artwork_jobs(&w);
|
||||
dispatch_artwork(split, w.as_weak(), image_cache.clone());
|
||||
// resolveItems (spec §17): resolve each item's quality / source
|
||||
// kind / type from the backends and hydrate the rows.
|
||||
resolve_items(runtime, w.as_weak(), resolve_handle);
|
||||
}
|
||||
None => {
|
||||
log::warn!("[qbz-slint] myqbz_detail navigate({id}): collection not found");
|
||||
|
||||
@@ -54,10 +54,11 @@ fn reload(
|
||||
image_cache: &ImageCache,
|
||||
id: String,
|
||||
) {
|
||||
let Some(runtime) = crate::myqbz_detail::global_runtime() else { return };
|
||||
let handle = handle.clone();
|
||||
let image_cache = image_cache.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
crate::myqbz_detail::navigate(w.as_weak(), handle.clone(), image_cache.clone(), id);
|
||||
crate::myqbz_detail::navigate(runtime, w.as_weak(), handle.clone(), image_cache.clone(), id);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -302,7 +302,7 @@ pub fn shuffle(
|
||||
// collection wasn't already album_shuffle, and reload the open detail so
|
||||
// the overflow toggle label reflects the new mode.
|
||||
if collection.play_mode != CollectionPlayMode::AlbumShuffle {
|
||||
persist_album_shuffle(&weak, &handle, &image_cache, &collection_id);
|
||||
persist_album_shuffle(&runtime, &weak, &handle, &image_cache, &collection_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -314,6 +314,7 @@ pub fn shuffle(
|
||||
/// back to the event loop. Failures are logged, not surfaced — the shuffle
|
||||
/// already played, so a failed persist must not toast an error.
|
||||
fn persist_album_shuffle(
|
||||
runtime: &Runtime,
|
||||
weak: &slint::Weak<AppWindow>,
|
||||
handle: &tokio::runtime::Handle,
|
||||
image_cache: &crate::artwork::ImageCache,
|
||||
@@ -342,11 +343,12 @@ fn persist_album_shuffle(
|
||||
// no reload — their next open restores from the DB.
|
||||
let handle = handle.clone();
|
||||
let image_cache = image_cache.clone();
|
||||
let runtime = runtime.clone();
|
||||
let id = collection_id.to_string();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
use slint::ComponentHandle;
|
||||
if w.global::<crate::MyQbzDetailState>().get_id().as_str() == id {
|
||||
crate::myqbz_detail::navigate(w.as_weak(), handle, image_cache, id);
|
||||
crate::myqbz_detail::navigate(runtime, w.as_weak(), handle, image_cache, id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user