feat(myqbz): Slint My QBZ sidebar nav + read-only Mixtapes/Collections grids

Phase-2 Slice 2 of the MyQBZ port. Wires the mixtape DB + the first
visually-reachable surface in Slint (read-only):

- Migrations: run qbz_mixtape::schema::run_mixtape_migrations in enter_shell
  (via library_db with_db -> with_connection), best-effort, on session open —
  same per-user library.db the Tauri build migrates.
- Glue: new src/myqbz.rs mirrors playlist_manager.rs — list_collections via the
  shared crate (items hydrated), model/sort/filter builders, mosaic artwork jobs,
  navigate. NavEntry::Mixtapes/Collections + back/forward + scroll-restore scopes.
- UI: ui/myqbz/{MixtapesView,CollectionsView,CollectionMosaic,MyQbzShared}.slint,
  1:1 with specs 10/11 (208px grid, mosaic custom->2x2/3x3->kind-glyph, toolbar,
  both empty states, ICU plural). MixtapeCardItem + MyQbzState/MyQbzActions in
  state.slint; mounted in AppShell. My QBZ sidebar entry routed to the two grids
  (branded mask-tinted icons), matching the port's existing flyout-nav idiom.

Read-only boundary: create/open-detail are logging stubs; titlebar My QBZ
dropdown and the Settings>Appearance rename (DQ3) are deferred to later slices.
Backend crates untouched. cargo check -p qbz-slint green.

cc
This commit is contained in:
vicrodh committed 2026-06-09 02:49:44 -06:00
1 parent 5458f3aa74
commit 698fb2fa7f
18 files changed
+1996 -5

No files matched your search

+1
View File
@@ -5487,6 +5487,7 @@ dependencies = [
"qbz-integrations",
"qbz-library",
"qbz-media-controls",
"qbz-mixtape",
"qbz-models",
"qbz-offline-cache",
"qbz-player",
+1
View File
@@ -55,6 +55,7 @@ qbz-library = { path = "../qbz-library" }
qbz-plex = { path = "../qbz-plex" }
qbz-offline-cache = { path = "../qbz-offline-cache" }
qbz-models = { path = "../qbz-models" }
qbz-mixtape = { path = "../qbz-mixtape" }
qbz-qobuz = { path = "../qbz-qobuz" }
qbz-integrations = { path = "../qbz-integrations" }
rusqlite = { version = "0.31", features = ["bundled"] }
+24
View File
@@ -141,6 +141,13 @@ pub enum ArtworkTarget {
/// One collage cover slot (0-3) of a tree row's playlist
/// (`PlaylistManagerState.tree[index].playlist`).
PmTreeCover { index: usize, slot: usize },
/// One mosaic cover slot (0-8) of a My QBZ Mixtapes-grid card
/// (`MyQbzState.mixtapes[index]`). Up to 9 slots (3x3 Collections);
/// mixtapes use only 0-3.
MyQbzMixtapeCover { index: usize, slot: usize },
/// One mosaic cover slot (0-8) of a My QBZ Collections-grid card
/// (`MyQbzState.collections[index]`).
MyQbzCollectionCover { index: usize, slot: usize },
}
impl ArtworkTarget {
@@ -159,6 +166,9 @@ impl ArtworkTarget {
ArtworkTarget::SidebarPlaylistCover { .. } => 48,
// Playlist Manager collage tiles render at ~70-140px.
ArtworkTarget::PmPlaylistCover { .. } | ArtworkTarget::PmTreeCover { .. } => 160,
// My QBZ mosaic tiles render at ~60-92px (184/2 or 184/3 grid).
ArtworkTarget::MyQbzMixtapeCover { .. }
| ArtworkTarget::MyQbzCollectionCover { .. } => 160,
_ => DECODE_SIZE,
}
}
@@ -894,5 +904,19 @@ fn apply_artwork(
model.set_row_data(index, row);
}
}
ArtworkTarget::MyQbzMixtapeCover { index, slot } => {
let model = window.global::<crate::MyQbzState>().get_mixtapes();
if let Some(mut item) = model.row_data(index) {
crate::myqbz::set_mosaic_cover(&mut item, slot, image);
model.set_row_data(index, item);
}
}
ArtworkTarget::MyQbzCollectionCover { index, slot } => {
let model = window.global::<crate::MyQbzState>().get_collections();
if let Some(mut item) = model.row_data(index) {
crate::myqbz::set_mosaic_cover(&mut item, slot, image);
model.set_row_data(index, item);
}
}
}
}
+192
View File
@@ -35,6 +35,7 @@ mod label;
mod location_view;
mod mix;
mod musician;
mod myqbz;
mod nav;
mod play_history;
mod strip_html;
@@ -101,6 +102,18 @@ async fn enter_shell(
// settings live in the per-user library.db).
library_db::set_user(session.user_id);
// Run the Mixtapes & Collections schema migrations against the same
// per-user library.db (the mixtape tables live in that file). Mirrors
// the Tauri build's session_lifecycle.rs `run_mixtape_migrations`.
// Best-effort: log on error, never block shell entry.
library_db::with_db(|db| {
Ok(db.with_connection(|conn| {
if let Err(e) = qbz_mixtape::schema::run_mixtape_migrations(conn) {
log::error!("[qbz-slint] mixtape migrations failed: {e}");
}
}))
});
// Bind tray settings to this user (per-user tray_settings.db, shared with
// the Tauri build) and snapshot them to seed the settings UI.
tray_settings::init_for_user(session.user_id);
@@ -716,6 +729,8 @@ fn scope_for(entry: &nav::NavEntry) -> String {
nav::NavEntry::Playlist(_) => "playlist".into(),
nav::NavEntry::PlaylistManager => "playlist-manager".into(),
nav::NavEntry::OfflineManager => "offline-manager".into(),
nav::NavEntry::Mixtapes => "mixtapes".into(),
nav::NavEntry::Collections => "collections".into(),
nav::NavEntry::Album(_) => "album".into(),
nav::NavEntry::LocalAlbum(_) => "local-album".into(),
nav::NavEntry::Artist(_) => "artist".into(),
@@ -861,6 +876,22 @@ fn apply_entry(
});
offline_manager::load(w2, handle.clone());
}
nav::NavEntry::Mixtapes => {
myqbz::navigate(
weak.clone(),
handle.clone(),
image_cache.clone(),
qbz_models::mixtape::CollectionKind::Mixtape,
);
}
nav::NavEntry::Collections => {
myqbz::navigate(
weak.clone(),
handle.clone(),
image_cache.clone(),
qbz_models::mixtape::CollectionKind::Collection,
);
}
nav::NavEntry::Location {
mbid,
area_id,
@@ -1814,6 +1845,137 @@ fn wire_playlist_manager(
}
}
/// Wire the My QBZ (Mixtapes & Collections) index grids. READ-ONLY slice:
/// `open-card` / `create-*` are logging STUBS; the toolbar callbacks
/// (search / sort / view / kind-filter / reset) drive `crate::myqbz` rebuilds
/// + re-issue mosaic artwork jobs. Mirrors `wire_playlist_manager`.
fn wire_myqbz(window: &AppWindow, image_cache: &artwork::ImageCache) {
use myqbz::Grid;
// Re-issue mosaic artwork jobs for a grid after a toolbar rebuild (the
// row set / order changed, so visible cards need their covers reloaded).
fn refresh_covers(window: &AppWindow, grid: Grid, image_cache: &artwork::ImageCache) {
let jobs = myqbz::artwork_jobs(window, grid);
artwork::spawn_loads(jobs, window.as_weak(), image_cache.clone());
}
// --- STUBS (read-only boundary) -------------------------------------
{
window.global::<MyQbzActions>().on_open_card(move |id| {
log::info!("[qbz-slint] myqbz open-card({id}) — detail not yet wired (read-only slice)");
});
}
{
window.global::<MyQbzActions>().on_create_mixtape(move || {
log::info!("[qbz-slint] myqbz create-mixtape — not yet wired (read-only slice)");
});
}
{
window.global::<MyQbzActions>().on_create_collection(move || {
log::info!("[qbz-slint] myqbz create-collection — not yet wired (read-only slice)");
});
}
// --- Mixtapes toolbar -----------------------------------------------
{
let weak = window.as_weak();
let image_cache = image_cache.clone();
window
.global::<MyQbzActions>()
.on_mix_search_changed(move |query| {
if let Some(w) = weak.upgrade() {
w.global::<MyQbzState>().set_mix_search(query);
myqbz::rebuild(&w, Grid::Mixtapes);
refresh_covers(&w, Grid::Mixtapes, &image_cache);
}
});
}
{
let weak = window.as_weak();
let image_cache = image_cache.clone();
window.global::<MyQbzActions>().on_mix_set_sort(move |field| {
if let Some(w) = weak.upgrade() {
myqbz::set_sort(&w, Grid::Mixtapes, field.as_str());
refresh_covers(&w, Grid::Mixtapes, &image_cache);
}
});
}
{
let weak = window.as_weak();
window.global::<MyQbzActions>().on_mix_set_view(move |view| {
if let Some(w) = weak.upgrade() {
w.global::<MyQbzState>().set_mix_view(view);
}
});
}
{
let weak = window.as_weak();
let image_cache = image_cache.clone();
window.global::<MyQbzActions>().on_mix_reset(move || {
if let Some(w) = weak.upgrade() {
myqbz::reset(&w, Grid::Mixtapes);
refresh_covers(&w, Grid::Mixtapes, &image_cache);
}
});
}
// --- Collections toolbar --------------------------------------------
{
let weak = window.as_weak();
let image_cache = image_cache.clone();
window
.global::<MyQbzActions>()
.on_col_search_changed(move |query| {
if let Some(w) = weak.upgrade() {
w.global::<MyQbzState>().set_col_search(query);
myqbz::rebuild(&w, Grid::Collections);
refresh_covers(&w, Grid::Collections, &image_cache);
}
});
}
{
let weak = window.as_weak();
let image_cache = image_cache.clone();
window.global::<MyQbzActions>().on_col_set_sort(move |field| {
if let Some(w) = weak.upgrade() {
myqbz::set_sort(&w, Grid::Collections, field.as_str());
refresh_covers(&w, Grid::Collections, &image_cache);
}
});
}
{
let weak = window.as_weak();
window.global::<MyQbzActions>().on_col_set_view(move |view| {
if let Some(w) = weak.upgrade() {
w.global::<MyQbzState>().set_col_view(view);
}
});
}
{
let weak = window.as_weak();
let image_cache = image_cache.clone();
window
.global::<MyQbzActions>()
.on_col_set_kind_filter(move |kind| {
if let Some(w) = weak.upgrade() {
w.global::<MyQbzState>().set_col_kind_filter(kind);
myqbz::rebuild(&w, Grid::Collections);
refresh_covers(&w, Grid::Collections, &image_cache);
}
});
}
{
let weak = window.as_weak();
let image_cache = image_cache.clone();
window.global::<MyQbzActions>().on_col_reset(move || {
if let Some(w) = weak.upgrade() {
myqbz::reset(&w, Grid::Collections);
refresh_covers(&w, Grid::Collections, &image_cache);
}
});
}
}
/// Open a MusicianPageView for `name + role`. Routes to the artist
/// page instead when the resolved musician has a Confirmed Qobuz
/// match (Tauri's `confidence === 'confirmed'` shortcut). Fetches
@@ -4445,6 +4607,35 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
return;
}
// My QBZ — Mixtapes / Collections index grids (read-only slice).
// Record history + navigate (loads via myqbz::navigate), mirroring
// the Favorites / Local Library per-route pattern.
if route == "myqbz-mixtapes" {
nav::record(nav::NavEntry::Mixtapes);
if let Some(w) = weak.upgrade() {
update_nav_flags(&w);
}
myqbz::navigate(
weak.clone(),
handle.clone(),
image_cache.clone(),
qbz_models::mixtape::CollectionKind::Mixtape,
);
return;
}
if route == "myqbz-collections" {
nav::record(nav::NavEntry::Collections);
if let Some(w) = weak.upgrade() {
update_nav_flags(&w);
}
myqbz::navigate(
weak.clone(),
handle.clone(),
image_cache.clone(),
qbz_models::mixtape::CollectionKind::Collection,
);
return;
}
// Discover tabs — switch to Home and select the tab. The
// section sets are already cached from the initial load,
// so this just swaps the visible set + re-fires artwork.
@@ -6266,6 +6457,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, &image_cache);
{
let weak = window.as_weak();
window
+441
View File
@@ -0,0 +1,441 @@
//! My QBZ controller — the Mixtapes & Collections index grids (read-only in
//! this slice). Mirrors `crate::playlist_manager`: it loads `MixtapeCollection`
//! rows from the per-user `library.db` via `qbz_mixtape::repo::list_collections`
//! (called through `crate::library_db::with_db` + `with_connection`), precomputes
//! every display string (eyebrow label, "N albums" ICU plural, pre-downscaled
//! mosaic cover URLs) and pushes ready-to-render `MixtapeCardItem`s into
//! `MyQbzState`. The views do NO per-row lookups.
//!
//! READ-ONLY SCOPE (Phase-2 Slice 2): create-new + open-detail are wired as
//! logging STUBS (`open_card` / `create_*`). The sidebar nav routes here and
//! loads real data; that is the testable path for this slice.
//!
//! The backend (`qbz-mixtape`) is reused directly — no Tauri command wrappers
//! (ADR-005), headless (ADR-006). The repo hydrates each collection's items so
//! counts + mosaic artwork are accurate (`repo.rs` `list_collections`).
use std::sync::{LazyLock, Mutex};
use qbz_models::mixtape::{CollectionKind, MixtapeCollection};
use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache};
use crate::{AppWindow, ContentView, MixtapeCardItem, MyQbzState, NavState};
/// Last-loaded data per kind-group (so toolbar changes rebuild from cache,
/// no DB refetch). Mirrors `playlist_manager::CACHE`.
static MIXTAPES_CACHE: LazyLock<Mutex<Vec<MixtapeCollection>>> =
LazyLock::new(|| Mutex::new(Vec::new()));
static COLLECTIONS_CACHE: LazyLock<Mutex<Vec<MixtapeCollection>>> =
LazyLock::new(|| Mutex::new(Vec::new()));
/// Which grid a navigation targets.
#[derive(Clone, Copy, PartialEq)]
pub enum Grid {
Mixtapes,
Collections,
}
// ──────────────────────────── DB read path ────────────────────────────
/// List collections of the given kind filter from the per-user library.db.
/// `None` returns all kinds. Items come hydrated (the repo loads them) so
/// counts + mosaic artwork are accurate. Returns an empty Vec when the DB is
/// unavailable (logged by `with_db`).
pub fn list_collections(kind: Option<CollectionKind>) -> Vec<MixtapeCollection> {
crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| {
qbz_mixtape::repo::list_collections(conn, kind).unwrap_or_else(|e| {
log::warn!("[qbz-slint] myqbz list_collections failed: {e}");
Vec::new()
})
}))
})
.unwrap_or_default()
}
// ──────────────────────────── string helpers ──────────────────────────
fn kind_str(kind: CollectionKind) -> &'static str {
match kind {
CollectionKind::Mixtape => "mixtape",
CollectionKind::Collection => "collection",
CollectionKind::ArtistCollection => "artist_collection",
}
}
/// Eyebrow label, uppercase (Tauri `labelFor` / `mixtapes.label`).
fn label_for(kind: CollectionKind) -> &'static str {
match kind {
CollectionKind::Mixtape => "MIXTAPE",
CollectionKind::Collection => "COLLECTION",
CollectionKind::ArtistCollection => "ARTIST",
}
}
/// `mixtapes.albumCount` ICU plural — "1 album" / "N albums". Always "album(s)"
/// regardless of item_type (1:1 with the PSD).
fn album_count_label(count: usize) -> String {
if count == 1 {
"1 album".to_string()
} else {
format!("{count} albums")
}
}
/// Pre-downscale a Qobuz cover URL to a per-cell target size, mirroring the
/// mosaic's `smallQobuzUrl` (regex-swap `_<old>.jpg` → `_<target>.jpg`). Used
/// before handing URLs to the image loader so we never pull 600px covers for
/// ~60-92px cells. Non-Qobuz URLs (local/plex) pass through unchanged.
fn small_qobuz_url(url: &str, target: u32) -> String {
if url.is_empty() {
return String::new();
}
// Lowercase scan for the size token; rewrite in place keeping original case
// of the rest. Old tokens: 50|100|150|230|300|600|max|org.
const TOKENS: [&str; 8] = ["_50.jpg", "_100.jpg", "_150.jpg", "_230.jpg", "_300.jpg", "_600.jpg", "_max.jpg", "_org.jpg"];
let lower = url.to_lowercase();
for tok in TOKENS {
if let Some(pos) = lower.rfind(tok) {
let mut out = String::with_capacity(url.len());
out.push_str(&url[..pos]);
out.push_str(&format!("_{target}.jpg"));
out.push_str(&url[pos + tok.len()..]);
return out;
}
}
url.to_string()
}
/// Per-cell target size given the mosaic `size` and column count
/// (`cellPx = round(size/cols)`; `<=80 → 50`, `<=200 → 150`, else 300). The
/// grid card mosaic is 184px (2x2 → cell 92 → 150; 3x3 → cell ~61 → 50).
fn cell_target(size: u32, cols: u32) -> u32 {
let cell_px = ((size as f32) / (cols as f32)).round() as u32;
if cell_px <= 80 {
50
} else if cell_px <= 200 {
150
} else {
300
}
}
// ──────────────────────────── model builders ──────────────────────────
/// Build one ready-to-render card. Decides cover-count (0 / 4 / 9) per the
/// 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();
// cols: 3x3 only for a Collection with >= 9 items; else 2x2.
let cols: u32 = if c.kind == CollectionKind::Collection && item_count >= 9 {
3
} else {
2
};
let cell_count = (cols * cols) as usize;
// cover-count is the number of mosaic cells actually used (0 when empty or
// when a custom cover full-bleeds; the view checks has-custom-cover first).
let cover_count = if has_custom || item_count == 0 {
0
} else {
cell_count
};
// Grid-card mosaic renders at 184px; size the downscale to that.
let target = cell_target(184, cols);
// Up-to-9 cell URLs: the first `cell_count` items' artwork, padded "".
let url = |i: usize| -> slint::SharedString {
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() => small_qobuz_url(u, target).into(),
_ => slint::SharedString::default(),
}
};
let custom_image = slint::Image::default();
MixtapeCardItem {
id: c.id.clone().into(),
name: c.name.clone().into(),
kind: kind_str(c.kind).into(),
label: label_for(c.kind).into(),
meta: album_count_label(item_count).into(),
item_count: item_count as i32,
play_count: c.play_count,
updated_at: c.updated_at as i32,
custom_cover: custom_image,
has_custom_cover: has_custom,
cover_count: cover_count as i32,
url1: url(0),
url2: url(1),
url3: url(2),
url4: url(3),
url5: url(4),
url6: url(5),
url7: url(6),
url8: url(7),
url9: url(8),
cover1: slint::Image::default(),
cover2: slint::Image::default(),
cover3: slint::Image::default(),
cover4: slint::Image::default(),
cover5: slint::Image::default(),
cover6: slint::Image::default(),
cover7: slint::Image::default(),
cover8: slint::Image::default(),
cover9: slint::Image::default(),
}
}
/// Set a decoded mosaic cover onto a card item by slot (0-8). Called from the
/// artwork apply arm.
pub fn set_mosaic_cover(item: &mut MixtapeCardItem, slot: usize, image: slint::Image) {
match slot {
0 => item.cover1 = image,
1 => item.cover2 = image,
2 => item.cover3 = image,
3 => item.cover4 = image,
4 => item.cover5 = image,
5 => item.cover6 = image,
6 => item.cover7 = image,
7 => item.cover8 = image,
8 => item.cover9 = image,
_ => {}
}
}
// ──────────────────────────── sort / filter ───────────────────────────
/// Sort a collection list by the active toolbar sort (mirrors `visibleX`):
/// name (locale-ish), items (count), updated (updated_at), position (default).
/// `dir` = "asc"/"desc".
fn sort_collections(list: &mut [MixtapeCollection], sort: &str, dir: &str) {
let desc = dir == "desc";
match sort {
"name" => list.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
"items" => list.sort_by(|a, b| a.items.len().cmp(&b.items.len())),
"updated" => list.sort_by(|a, b| a.updated_at.cmp(&b.updated_at)),
// default "position"
_ => list.sort_by(|a, b| a.position.cmp(&b.position)),
}
if desc {
list.reverse();
}
}
/// Whether a collection passes the search filter (name OR description,
/// case-insensitive substring). Empty query = pass.
fn passes_search(c: &MixtapeCollection, query: &str) -> bool {
if query.is_empty() {
return true;
}
if c.name.to_lowercase().contains(query) {
return true;
}
c.description
.as_deref()
.map(|d| d.to_lowercase().contains(query))
.unwrap_or(false)
}
// ──────────────────────────── apply / rebuild ─────────────────────────
pub fn set_loading(window: &AppWindow, loading: bool) {
window.global::<MyQbzState>().set_loading(loading);
}
/// Store freshly-loaded rows for `grid` and render them through the active
/// toolbar state.
pub fn apply(window: &AppWindow, grid: Grid, rows: Vec<MixtapeCollection>) {
match grid {
Grid::Mixtapes => {
if let Ok(mut c) = MIXTAPES_CACHE.lock() {
*c = rows;
}
}
Grid::Collections => {
if let Ok(mut c) = COLLECTIONS_CACHE.lock() {
*c = rows;
}
}
}
rebuild(window, grid);
}
/// Rebuild the visible card model for one grid from its cache, honoring the
/// active toolbar state (search / sort / kind-filter). UI thread only.
pub fn rebuild(window: &AppWindow, grid: Grid) {
let state = window.global::<MyQbzState>();
match grid {
Grid::Mixtapes => {
let data = MIXTAPES_CACHE.lock().map(|c| c.clone()).unwrap_or_default();
let query = state.get_mix_search().trim().to_lowercase();
let sort = state.get_mix_sort().to_string();
let dir = state.get_mix_sort_dir().to_string();
let mut filtered: Vec<MixtapeCollection> =
data.into_iter().filter(|c| passes_search(c, &query)).collect();
sort_collections(&mut filtered, &sort, &dir);
let items: Vec<MixtapeCardItem> = filtered.iter().map(card_item).collect();
state.set_mixtapes(ModelRc::new(VecModel::from(items)));
}
Grid::Collections => {
let data = COLLECTIONS_CACHE.lock().map(|c| c.clone()).unwrap_or_default();
let query = state.get_col_search().trim().to_lowercase();
let sort = state.get_col_sort().to_string();
let dir = state.get_col_sort_dir().to_string();
let kind_filter = state.get_col_kind_filter().to_string();
let mut filtered: Vec<MixtapeCollection> = data
.into_iter()
.filter(|c| match kind_filter.as_str() {
"collection" => c.kind == CollectionKind::Collection,
"artist_collection" => c.kind == CollectionKind::ArtistCollection,
_ => true,
})
.filter(|c| passes_search(c, &query))
.collect();
sort_collections(&mut filtered, &sort, &dir);
let items: Vec<MixtapeCardItem> = filtered.iter().map(card_item).collect();
state.set_collections(ModelRc::new(VecModel::from(items)));
}
}
state.set_loading(false);
}
/// Re-clicking the active sort field flips direction; a new field resets to
/// asc. Mirrors `selectSort`.
pub fn set_sort(window: &AppWindow, grid: Grid, field: &str) {
let state = window.global::<MyQbzState>();
let (cur_sort, cur_dir) = match grid {
Grid::Mixtapes => (state.get_mix_sort().to_string(), state.get_mix_sort_dir().to_string()),
Grid::Collections => (state.get_col_sort().to_string(), state.get_col_sort_dir().to_string()),
};
let new_dir = if cur_sort == field {
if cur_dir == "asc" { "desc" } else { "asc" }
} else {
"asc"
};
match grid {
Grid::Mixtapes => {
state.set_mix_sort(field.into());
state.set_mix_sort_dir(new_dir.into());
}
Grid::Collections => {
state.set_col_sort(field.into());
state.set_col_sort_dir(new_dir.into());
}
}
rebuild(window, grid);
}
/// Reset toolbar filters/sort (search too, like Tauri's `resetFilters`).
pub fn reset(window: &AppWindow, grid: Grid) {
let state = window.global::<MyQbzState>();
match grid {
Grid::Mixtapes => {
state.set_mix_sort("position".into());
state.set_mix_sort_dir("asc".into());
state.set_mix_search("".into());
}
Grid::Collections => {
state.set_col_sort("position".into());
state.set_col_sort_dir("asc".into());
state.set_col_kind_filter("all".into());
state.set_col_search("".into());
}
}
rebuild(window, grid);
}
// ──────────────────────────── artwork jobs ────────────────────────────
/// Build mosaic-cover artwork jobs for every visible card of `grid`.
pub fn artwork_jobs(window: &AppWindow, grid: Grid) -> Vec<ArtworkJob> {
let state = window.global::<MyQbzState>();
let model = match grid {
Grid::Mixtapes => state.get_mixtapes(),
Grid::Collections => state.get_collections(),
};
let mut jobs = Vec::new();
for index in 0..model.row_count() {
let Some(card) = model.row_data(index) else { continue };
let urls = [
card.url1, card.url2, card.url3, card.url4, card.url5, card.url6, card.url7,
card.url8, card.url9,
];
for (slot, url) in urls.iter().enumerate() {
if url.is_empty() {
continue;
}
let target = match grid {
Grid::Mixtapes => ArtworkTarget::MyQbzMixtapeCover { index, slot },
Grid::Collections => ArtworkTarget::MyQbzCollectionCover { index, slot },
};
jobs.push(ArtworkJob {
target,
url: url.to_string(),
});
}
}
jobs
}
// ──────────────────────────── navigation ──────────────────────────────
/// Open a My QBZ grid (Mixtapes or Collections) and load its rows from the
/// per-user library.db on a blocking worker, then render + spawn mosaic
/// artwork. `kind` selects the grid (Mixtape → Mixtapes; Collection → the
/// Collections grid, which displays collection + artist_collection).
pub fn navigate(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
kind: CollectionKind,
) {
let grid = match kind {
CollectionKind::Mixtape => Grid::Mixtapes,
_ => Grid::Collections,
};
let view = match grid {
Grid::Mixtapes => ContentView::Mixtapes,
Grid::Collections => ContentView::Collections,
};
handle.clone().spawn(async move {
let _ = weak.upgrade_in_event_loop(move |w| {
set_loading(&w, true);
w.global::<NavState>().set_view(view);
});
// The Mixtapes grid wants kind == mixtape; the Collections grid loads
// ALL kinds and filters locally (collection | artist_collection) so the
// kind-filter dropdown can switch between them without a refetch.
let kind_arg = match grid {
Grid::Mixtapes => Some(CollectionKind::Mixtape),
Grid::Collections => None,
};
let rows = tokio::task::spawn_blocking(move || list_collections(kind_arg))
.await
.unwrap_or_default();
// For the Collections grid, drop mixtapes (load returned all kinds).
let rows: Vec<MixtapeCollection> = match grid {
Grid::Mixtapes => rows,
Grid::Collections => rows
.into_iter()
.filter(|c| c.kind != CollectionKind::Mixtape)
.collect(),
};
let _ = weak.upgrade_in_event_loop(move |w| {
apply(&w, grid, rows);
let jobs = artwork_jobs(&w, grid);
artwork::spawn_loads(jobs, w.as_weak(), image_cache.clone());
});
});
}
+7
View File
@@ -59,6 +59,13 @@ pub enum NavEntry {
/// The Offline Cache Manager — the manage-downloads surface (Tauri's
/// OfflineCacheManagerView). Session-scoped; no payload.
OfflineManager,
/// The My QBZ > Mixtapes index grid (read-only in this slice). Toolbar
/// state (sort/view/search) is session-scoped in the controller, so the
/// entry carries no payload.
Mixtapes,
/// The My QBZ > Collections index grid (read-only in this slice). Same
/// session-scoped toolbar; no payload.
Collections,
Album(String),
/// A Local Library album detail page (dedicated view, separate from the
/// Qobuz Album view); the string is the metadata group key.
+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint";
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
@@ -0,0 +1,2 @@
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:serif="http://www.serif.com/" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M25.338,6l-1.559,5.285c-0.126,0.425 -0.516,0.717 -0.96,0.717l-14.138,-0c-0.444,-0 -0.834,-0.292 -0.96,-0.717l-1.559,-5.285l-1.162,0c-0.796,0 -1.559,0.316 -2.121,0.879c-0.563,0.562 -0.879,1.325 -0.879,2.121c0,3.462 -0,10.533 0,13.995c-0,0.796 0.316,1.559 0.879,2.122c0.562,0.562 1.325,0.878 2.121,0.878l22,0c0.796,0 1.559,-0.316 2.121,-0.878c0.563,-0.563 0.879,-1.326 0.879,-2.122l-0,-13.995c0,-0.796 -0.316,-1.559 -0.879,-2.121c-0.562,-0.563 -1.325,-0.879 -2.121,-0.879l-1.662,-0Zm-6.756,14l-5.118,0.001c0.341,-0.588 0.536,-1.272 0.536,-2c0,-2.207 -1.792,-3.999 -3.999,-3.999c-2.207,-0 -3.999,1.792 -3.999,3.999c-0,2.185 1.757,3.964 3.934,3.998c0.022,0.002 0.043,0.003 0.065,0.003l12.044,-0.002c2.207,-0 3.999,-1.792 3.999,-3.999c0,-2.207 -1.792,-3.999 -3.999,-3.999c-2.207,-0 -3.999,1.792 -3.999,3.999c-0,0.728 0.195,1.411 0.536,1.999Zm-8.581,-3.998c1.103,-0 1.999,0.895 1.999,1.999c-0,1.103 -0.896,1.999 -1.999,1.999c-1.104,0 -1.999,-0.896 -1.999,-1.999c-0,-1.104 0.895,-1.999 1.999,-1.999Zm12.044,-0c1.104,-0 1.999,0.895 1.999,1.999c0,1.103 -0.895,1.999 -1.999,1.999c-1.103,0 -1.999,-0.896 -1.999,-1.999c-0,-1.104 0.896,-1.999 1.999,-1.999Zm1.207,-10.002l-15.004,-0l1.18,4.002c0,-0 12.644,-0 12.644,-0l1.18,-4.002Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 60.02 60.02" xml:space="preserve">
<g>
<path d="M59.766,15.896l-0.363-0.386h-1.837l0.409-3.34c0.043-0.424-0.097-0.848-0.382-1.163c-0.286-0.315-0.694-0.497-1.12-0.497
h-2.437V7c0-0.821-0.669-1.49-1.491-1.49h-2.509V2c0-0.821-0.669-1.49-1.491-1.49H11.526c-0.821,0-1.49,0.669-1.49,1.49v3.51h-2.51
c-0.821,0-1.49,0.669-1.49,1.49v3.51H3.599c-0.426,0-0.834,0.182-1.12,0.497c-0.285,0.315-0.425,0.739-0.379,1.185l0.406,3.318
h-1.37c-0.321,0-0.623,0.141-0.83,0.386L0,16.306l5.959,41.856c0.138,0.781,0.813,1.348,1.605,1.348h44.942
c0.791,0,1.467-0.565,1.611-1.38l5.886-41.352C60.059,16.462,59.972,16.141,59.766,15.896z M12.036,2.51h36v3h-36V2.51z
M8.036,7.51h2h40h2v3h-44V7.51z M4.153,12.51h1.883h48h1.882l-0.367,3H4.521L4.153,12.51z M29.536,54.51
c-11.304,0-20.5-7.626-20.5-17s9.196-17,20.5-17s20.5,7.626,20.5,17S40.84,54.51,29.536,54.51z"/>
<path d="M30.536,25.449c0-0.553-0.447-1-1-1c-3.993,0-7.778,1.21-10.946,3.499c-0.447,0.323-0.548,0.948-0.225,1.396
c0.195,0.271,0.501,0.414,0.812,0.414c0.203,0,0.407-0.062,0.585-0.189c2.824-2.041,6.204-3.12,9.774-3.12
C30.089,26.449,30.536,26.002,30.536,25.449z"/>
<path d="M30.536,29.2c0-0.553-0.447-1-1-1c-3.105,0-6.049,0.941-8.512,2.723c-0.447,0.324-0.548,0.949-0.225,1.396
c0.196,0.271,0.501,0.414,0.812,0.414c0.203,0,0.408-0.062,0.585-0.189c2.12-1.533,4.658-2.344,7.34-2.344
C30.089,30.2,30.536,29.753,30.536,29.2z"/>
<path d="M30.536,32.95c0-0.553-0.447-1-1-1c-2.229,0-4.339,0.68-6.104,1.967c-0.446,0.325-0.545,0.95-0.219,1.396
c0.195,0.269,0.5,0.411,0.809,0.411c0.204,0,0.41-0.063,0.588-0.192c1.42-1.035,3.124-1.582,4.926-1.582
C30.089,33.95,30.536,33.503,30.536,32.95z"/>
<path d="M39.4,45.638c-2.824,2.042-6.204,3.121-9.774,3.121c-0.553,0-1,0.447-1,1s0.447,1,1,1c3.994,0,7.779-1.21,10.946-3.5
c0.447-0.324,0.548-0.949,0.225-1.396C40.474,45.414,39.847,45.314,39.4,45.638z"/>
<path d="M28.626,46.008c0,0.553,0.447,1,1,1c3.106,0,6.05-0.942,8.512-2.724c0.448-0.324,0.548-0.949,0.225-1.396
c-0.325-0.448-0.95-0.548-1.396-0.225c-2.119,1.534-4.657,2.345-7.34,2.345C29.073,45.008,28.626,45.455,28.626,46.008z"/>
<path d="M28.626,42.257c0,0.553,0.447,1,1,1c2.23,0,4.341-0.68,6.104-1.967c0.445-0.325,0.544-0.951,0.218-1.397
c-0.324-0.444-0.949-0.544-1.397-0.218c-1.418,1.035-3.121,1.582-4.925,1.582C29.073,41.257,28.626,41.704,28.626,42.257z"/>
<ellipse cx="29.932" cy="37.494" rx="2.327" ry="1.689"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<g>
<g>
<path d="M166.892,99.747c-12.889-27.663-43.766-40.842-71.335-27.967c-14.258,6.651-22.198,17.733-26.243,29.223
c-11.397-4.285-25.003-5.346-39.251,1.316C3.213,114.837-7.522,146.742,5.627,174.954c16.116,34.534,55.622,38.495,111.4,54.09
c7.76,2.181,16.246-1.56,19.707-9.19C160.718,166.918,182.991,134.249,166.892,99.747z"/>
</g>
</g>
<g>
<g>
<path d="M481.909,35.499c-14.247-6.651-27.842-5.606-39.251-1.316c-4.046-11.49-11.974-22.573-26.243-29.223
c-27.527-12.855-58.426,0.268-71.335,27.961c-16.117,34.549,6.257,67.359,30.158,120.113c3.46,7.63,11.948,11.371,19.707,9.19
c56.133-15.678,95.338-19.642,111.4-54.09C519.237,80.514,509.537,48.382,481.909,35.499z"/>
</g>
</g>
<g>
<g>
<path d="M348.952,180.767c-4.589-3.105-10.419-3.746-15.564-1.675l-167.052,66.821c-6.35,2.534-10.505,8.679-10.505,15.509v185.81
c-5.25-1.248-10.818-2.052-16.705-2.052c-27.635,0-50.116,14.987-50.116,33.41c0,18.424,22.481,33.41,50.116,33.41
c27.635,0,50.116-14.987,50.116-33.41c0-0.762,0-172.449,0-172.449l133.642-53.454v127.725c-5.25-1.248-10.818-2.052-16.705-2.052
c-27.635,0-50.116,14.987-50.116,33.41s22.481,33.41,50.116,33.41c27.635,0,50.116-14.987,50.116-33.41
c0-0.762,0-217.168,0-217.168C356.294,189.06,353.542,183.878,348.952,180.767z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 273 B

@@ -0,0 +1,121 @@
// CollectionMosaic — the artwork renderer for Mixtapes & Collections cards,
// list rows and empty states (Tauri's CollectionMosaic.svelte). Ported as a
// new component modeled on PmCollage's layout discipline (clip + radius +
// 1px gaps), but with the MyQBZ-specific rules:
//
// - custom-cover (has-custom-cover) -> single full-bleed cover (precedence)
// - else empty (cover-count == 0) -> centered kind glyph
// mixtape -> cassette, artist_collection -> user, collection -> library-big
// icon size = round(size * 0.4), muted, opacity 0.6
// - else collage:
// cols = (kind == "collection" && item-count >= 9) ? 3 : 2
// 2x2 (4 cells) for everything else; cells padded with empty squares
//
// The covers + decoded images live on the MixtapeCardItem (cover-count,
// url1..url9 / cover1..cover9). Rust pre-decides cover-count (0 / 4 / 9) and
// pre-downscales the Qobuz URLs per cell. This component only lays them out.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { MixtapeCardItem } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
// One mosaic cell: the cover when present, else an empty --bg-tertiary square.
// Defined before CollectionMosaic (Slint has no forward references within a
// file).
component MosaicCell inherits Rectangle {
in property <string> url;
in property <image> cover-image;
in property <length> cell;
width: root.cell;
height: root.cell;
background: Theme.surface-elevated; // empty-cell var(--bg-tertiary)
clip: true;
if root.url != "": Image {
width: 100%;
height: 100%;
source: root.cover-image;
image-fit: cover;
}
}
export component CollectionMosaic inherits Rectangle {
in property <MixtapeCardItem> item;
in property <length> size: 184px;
// For the empty state the caller passes an explicit kind (e.g. "mixtape"
// / "collection") when the item itself is empty; falls back to item.kind.
in property <string> empty-kind: "";
width: root.size;
height: root.size;
border-radius: Radius.sm; // 8px outer radius (Tauri).
clip: true;
background: Theme.surface-elevated; // var(--bg-tertiary)
property <int> n: root.item.cover-count;
property <string> kind: root.empty-kind != "" ? root.empty-kind : root.item.kind;
// 3x3 only for a Collection with >= 9 items; everything else 2x2.
property <bool> grid3: root.kind == "collection" && root.item.item-count >= 9;
property <length> gap: 2px;
// --- custom cover (precedence) -> single full-bleed image -----------
if root.item.has-custom-cover: Image {
width: 100%;
height: 100%;
source: root.item.custom-cover;
image-fit: cover;
}
// --- empty -> kind glyph --------------------------------------------
if !root.item.has-custom-cover && root.n == 0: QbzIcon {
source: root.kind == "mixtape"
? @image-url("../assets/icons/cassette-tape.svg")
: root.kind == "artist_collection"
? @image-url("../assets/icons/user.svg")
: @image-url("../assets/icons/library-big.svg");
width: Math.round(root.size * 0.4 / 1px) * 1px;
height: Math.round(root.size * 0.4 / 1px) * 1px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
opacity: 0.6;
}
// --- collage: 2x2 ---------------------------------------------------
if !root.item.has-custom-cover && root.n > 0 && !root.grid3: VerticalLayout {
spacing: root.gap;
HorizontalLayout {
spacing: root.gap;
MosaicCell { url: root.item.url1; cover-image: root.item.cover1; cell: (root.size - root.gap) / 2; }
MosaicCell { url: root.item.url2; cover-image: root.item.cover2; cell: (root.size - root.gap) / 2; }
}
HorizontalLayout {
spacing: root.gap;
MosaicCell { url: root.item.url3; cover-image: root.item.cover3; cell: (root.size - root.gap) / 2; }
MosaicCell { url: root.item.url4; cover-image: root.item.cover4; cell: (root.size - root.gap) / 2; }
}
}
// --- collage: 3x3 (collection + >=9 items) --------------------------
if !root.item.has-custom-cover && root.n > 0 && root.grid3: VerticalLayout {
spacing: root.gap;
HorizontalLayout {
spacing: root.gap;
MosaicCell { url: root.item.url1; cover-image: root.item.cover1; cell: (root.size - 2 * root.gap) / 3; }
MosaicCell { url: root.item.url2; cover-image: root.item.cover2; cell: (root.size - 2 * root.gap) / 3; }
MosaicCell { url: root.item.url3; cover-image: root.item.cover3; cell: (root.size - 2 * root.gap) / 3; }
}
HorizontalLayout {
spacing: root.gap;
MosaicCell { url: root.item.url4; cover-image: root.item.cover4; cell: (root.size - 2 * root.gap) / 3; }
MosaicCell { url: root.item.url5; cover-image: root.item.cover5; cell: (root.size - 2 * root.gap) / 3; }
MosaicCell { url: root.item.url6; cover-image: root.item.cover6; cell: (root.size - 2 * root.gap) / 3; }
}
HorizontalLayout {
spacing: root.gap;
MosaicCell { url: root.item.url7; cover-image: root.item.cover7; cell: (root.size - 2 * root.gap) / 3; }
MosaicCell { url: root.item.url8; cover-image: root.item.cover8; cell: (root.size - 2 * root.gap) / 3; }
MosaicCell { url: root.item.url9; cover-image: root.item.cover9; cell: (root.size - 2 * root.gap) / 3; }
}
}
}
@@ -0,0 +1,387 @@
// 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.
//
// Every px / token verbatim from spec 11. Root padding 8 8 100 18.
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 { 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(); }
}
}
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 <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";
background: Theme.surface-main;
flick := Flickable {
viewport-width: self.width;
viewport-height: page.preferred-height;
property <string> sr-armed: NavState.restore-scope;
function sr-restore() {
if (NavState.restore-scope == "collections" && self.viewport-height > self.height) {
self.viewport-y = NavState.scroll-restore;
NavState.restore-scope = "";
}
}
init => { self.sr-restore(); }
changed sr-armed => { self.sr-restore(); }
changed viewport-height => { self.sr-restore(); }
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
page := VerticalLayout {
width: parent.width;
padding-top: 8px;
padding-right: 8px;
padding-bottom: 100px;
padding-left: 18px;
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 ------------------------------------------------
HorizontalLayout {
height: 38px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: @tr("Collections");
color: Theme.text-primary;
font-size: 24px;
font-weight: Typography.bold;
vertical-alignment: center;
}
}
VerticalLayout {
alignment: center;
PrimaryCta {
label: @tr("+ New Collection");
show-plus: true;
clicked => { MyQbzActions.create-collection(); }
}
}
}
Rectangle { height: 16px; }
// ====================== EMPTY STATE ========================
if MyQbzState.collections.length == 0 && MyQbzState.col-search == "" && MyQbzState.col-kind-filter == "all" && !MyQbzState.loading: VerticalLayout {
padding-top: 80px;
padding-bottom: 80px;
spacing: 16px;
alignment: start;
HorizontalLayout {
alignment: center;
CollectionMosaic {
size: 160px;
empty-kind: "collection";
}
}
HorizontalLayout {
alignment: center;
Text {
text: @tr("No collections yet");
color: Theme.text-secondary;
font-size: 18px;
font-weight: Typography.semibold;
}
}
HorizontalLayout {
alignment: center;
PrimaryCta {
label: @tr("+ New Collection");
show-plus: false;
clicked => { MyQbzActions.create-collection(); }
}
}
}
// ====================== POPULATED ==========================
if MyQbzState.collections.length > 0 || MyQbzState.col-search != "" || MyQbzState.col-kind-filter != "all": VerticalLayout {
spacing: 0px;
alignment: start;
// --- Toolbar -------------------------------------------
HorizontalLayout {
height: 32px;
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 {
view: MyQbzState.col-view;
set-view(v) => { MyQbzActions.col-set-view(v); }
}
}
}
Rectangle { height: 16px; }
// --- Body: search/filter-empty -------------------------
if MyQbzState.collections.length == 0: Rectangle {
height: 80px;
Text {
text: @tr("No results found");
color: Theme.text-muted;
font-size: 13px;
horizontal-alignment: center;
vertical-alignment: center;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// --- Body: GRID ---------------------------------------
if MyQbzState.collections.length > 0 && MyQbzState.col-view == "grid": grid := Rectangle {
property <int> columns: Math.max(1,
Math.floor((self.width + root.grid-gap) / (root.card-w + root.grid-gap)));
property <int> rows: Math.ceil(MyQbzState.collections.length / self.columns);
height: self.rows > 0
? self.rows * root.card-h + Math.max(0, self.rows - 1) * root.grid-gap
: 0px;
for item[i] in MyQbzState.collections: MyQbzCard {
x: Math.mod(i, grid.columns) * (root.card-w + root.grid-gap);
y: Math.floor(i / grid.columns) * (root.card-h + root.grid-gap);
item: item;
clicked => { MyQbzActions.open-card(item.id); }
}
}
// --- Body: LIST ---------------------------------------
if MyQbzState.collections.length > 0 && MyQbzState.col-view == "list": VerticalLayout {
spacing: 4px;
for item in MyQbzState.collections: MyQbzListRow {
item: item;
clicked => { MyQbzActions.open-card(item.id); }
}
}
}
}
}
ListScrollbar {
x: parent.width - self.width - 4px;
y: 0px;
width: 14px;
height: parent.height;
viewport-height: flick.viewport-height;
visible-height: flick.height;
viewport-y <=> flick.viewport-y;
}
}
@@ -0,0 +1,321 @@
// 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").
//
// 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").
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 { 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(); }
}
}
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.
property <length> card-w: 208px;
property <length> grid-gap: 20px;
property <length> card-h: 184px + 12px + 12px + 16px + 2 * 18px + 12px + 16px + 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";
background: Theme.surface-main;
flick := Flickable {
viewport-width: self.width;
viewport-height: page.preferred-height;
// Scroll-position restore on back/forward (see nav::scope_for).
property <string> sr-armed: NavState.restore-scope;
function sr-restore() {
if (NavState.restore-scope == "mixtapes" && self.viewport-height > self.height) {
self.viewport-y = NavState.scroll-restore;
NavState.restore-scope = "";
}
}
init => { self.sr-restore(); }
changed sr-armed => { self.sr-restore(); }
changed viewport-height => { self.sr-restore(); }
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
page := VerticalLayout {
width: parent.width;
// .mixtapes-view padding: 8 8 100 18.
padding-top: 8px;
padding-right: 8px;
padding-bottom: 100px;
padding-left: 18px;
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) ---------------------------------
HorizontalLayout {
height: 38px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: @tr("Mixtapes");
color: Theme.text-primary;
font-size: 24px;
font-weight: Typography.bold;
vertical-alignment: center;
}
}
VerticalLayout {
alignment: center;
PrimaryCta {
label: @tr("+ New Mixtape");
show-plus: true;
clicked => { MyQbzActions.create-mixtape(); }
}
}
}
Rectangle { height: 16px; }
// ====================== EMPTY STATE ========================
if MyQbzState.mixtapes.length == 0 && MyQbzState.mix-search == "" && !MyQbzState.loading: VerticalLayout {
padding-top: 80px;
padding-bottom: 80px;
spacing: 16px;
alignment: start;
HorizontalLayout {
alignment: center;
CollectionMosaic {
size: 160px;
empty-kind: "mixtape";
}
}
HorizontalLayout {
alignment: center;
Text {
text: @tr("No mixtapes yet");
color: Theme.text-secondary;
font-size: 18px;
font-weight: Typography.semibold;
}
}
HorizontalLayout {
alignment: center;
PrimaryCta {
label: @tr("+ New Mixtape");
show-plus: false;
clicked => { MyQbzActions.create-mixtape(); }
}
}
}
// ====================== POPULATED ==========================
if MyQbzState.mixtapes.length > 0 || MyQbzState.mix-search != "": VerticalLayout {
spacing: 0px;
alignment: start;
// --- Toolbar (.list-controls) --------------------------
HorizontalLayout {
height: 32px;
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).
Rectangle { horizontal-stretch: 1; }
VerticalLayout {
alignment: center;
ViewModeGroup {
view: MyQbzState.mix-view;
set-view(v) => { MyQbzActions.mix-set-view(v); }
}
}
}
Rectangle { height: 16px; }
// --- Body: search-empty -------------------------------
if MyQbzState.mixtapes.length == 0 && MyQbzState.mix-search != "": Rectangle {
height: 80px;
Text {
text: @tr("No results found");
color: Theme.text-muted;
font-size: 13px;
horizontal-alignment: center;
vertical-alignment: center;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// --- Body: GRID ---------------------------------------
if MyQbzState.mixtapes.length > 0 && MyQbzState.mix-view == "grid": grid := Rectangle {
property <int> columns: Math.max(1,
Math.floor((self.width + root.grid-gap) / (root.card-w + root.grid-gap)));
property <int> rows: Math.ceil(MyQbzState.mixtapes.length / self.columns);
height: self.rows > 0
? self.rows * root.card-h + Math.max(0, self.rows - 1) * root.grid-gap
: 0px;
for item[i] in MyQbzState.mixtapes: MyQbzCard {
x: Math.mod(i, grid.columns) * (root.card-w + root.grid-gap);
y: Math.floor(i / grid.columns) * (root.card-h + root.grid-gap);
item: item;
clicked => { MyQbzActions.open-card(item.id); }
}
}
// --- Body: LIST ---------------------------------------
if MyQbzState.mixtapes.length > 0 && MyQbzState.mix-view == "list": VerticalLayout {
spacing: 4px;
for item in MyQbzState.mixtapes: MyQbzListRow {
item: item;
clicked => { MyQbzActions.open-card(item.id); }
}
}
}
}
}
ListScrollbar {
x: parent.width - self.width - 4px;
y: 0px;
width: 14px;
height: parent.height;
viewport-height: flick.viewport-height;
visible-height: flick.height;
viewport-y <=> flick.viewport-y;
}
}
+346
View File
@@ -0,0 +1,346 @@
// 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.
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 { 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;
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);
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;
}
seg-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 {
in property <string> view; // "grid" | "list"
callback set-view(string);
height: 32px;
width: 64px;
border-radius: 6px;
border-width: 1px;
border-color: Theme.surface-elevated;
background: Theme.surface-card;
clip: true;
HorizontalLayout {
spacing: 0px;
ViewSeg {
icon: @image-url("../assets/icons/list.svg");
active: root.view == "list";
clicked => { root.set-view("list"); }
}
// 1px divider between the segments.
Rectangle {
width: 1px;
background: Theme.surface-elevated;
}
ViewSeg {
icon: @image-url("../assets/icons/layout-grid.svg");
active: root.view == "grid";
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.
export component MyQbzCard inherits Rectangle {
in property <MixtapeCardItem> item;
callback clicked();
width: 208px;
height: 184px + 12px + 12px + 16px + 2 * 18px + 12px + 16px + 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;
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).
Text {
text: root.item.name;
color: Theme.text-primary;
font-size: 14px;
font-weight: Typography.semibold;
wrap: word-wrap;
overflow: elide;
height: 36px;
width: 100%;
}
Text {
text: root.item.meta;
color: Theme.text-muted;
font-size: 12px;
}
}
card-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// --- List row -----------------------------------------------------------
// 48px cover + name (single-line ellipsis) + sub (label · count).
export component MyQbzListRow inherits Rectangle {
in property <MixtapeCardItem> item;
callback clicked();
height: 64px;
border-radius: 6px;
background: row-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 12px;
alignment: start;
VerticalLayout {
alignment: center;
CollectionMosaic {
item: root.item;
size: 48px;
}
}
VerticalLayout {
alignment: center;
spacing: 2px;
horizontal-stretch: 1;
Text {
text: root.item.name;
color: Theme.text-primary;
font-size: 14px;
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;
}
}
}
}
row-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
+4
View File
@@ -43,6 +43,8 @@ import { FavoritesView } from "../favorites/FavoritesView.slint";
import { MixView } from "../mix/MixView.slint";
import { PlaylistView } from "../playlist/PlaylistView.slint";
import { PlaylistManagerView } from "../playlist/PlaylistManagerView.slint";
import { MixtapesView } from "../myqbz/MixtapesView.slint";
import { CollectionsView } from "../myqbz/CollectionsView.slint";
import { OfflineManagerView } from "../offline/OfflineManagerView.slint";
import { LocalLibraryView } from "../locallibrary/LocalLibraryView.slint";
import { SettingsView } from "../settings/SettingsView.slint";
@@ -229,6 +231,8 @@ export component AppShell inherits Rectangle {
}
}
if NavState.view == ContentView.playlist-manager: PlaylistManagerView { }
if NavState.view == ContentView.mixtapes: MixtapesView { }
if NavState.view == ContentView.collections: CollectionsView { }
if NavState.view == ContentView.offline-manager: OfflineManagerView { }
if NavState.view == ContentView.local-library: LocalLibraryView { }
if NavState.view == ContentView.mix: MixView {
+3 -3
View File
@@ -616,11 +616,11 @@ export component Sidebar inherits Rectangle {
}
SidebarNavRow {
tab-index: 3;
icon: @image-url("../assets/icons/cassette-tape.svg");
icon: @image-url("../assets/icons/my-qbz.svg");
label: @tr("My QBZ");
items: [
{ label: @tr("Collections"), icon: @image-url("../assets/icons/library-big.svg"), has-glyph: true },
{ label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true },
{ label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette.svg"), has-glyph: true, route: "myqbz-mixtapes" },
{ label: @tr("Collections"), icon: @image-url("../assets/icons/collection.svg"), has-glyph: true, route: "myqbz-collections" },
];
}
}
+83 -1
View File
@@ -345,7 +345,7 @@ export global DragActions {
}
// Which content view the shell shows.
export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager, offline-manager, local-library, local-album }
export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager, offline-manager, local-library, local-album, mixtapes, collections }
// In-app toast (transient notification). Mirrors Tauri's toastStore:
// a single toast at a time (a new one replaces the current), auto-hidden
@@ -1385,6 +1385,88 @@ export global OfflineManagerActions {
callback play-track(string /* track id */);
}
// === My QBZ — Mixtapes & Collections (read-only index grids) ============
// Mirrors PlaylistManagerState/PmPlaylistItem. Both the Mixtapes grid and the
// Collections grid render `MixtapeCardItem`s; the Rust controller (crate::myqbz)
// filters by kind and precomputes every display string, so the views do no
// per-row lookups. Mosaic covers (up to 4, or 9 for a 3x3 Collection) are
// decoded into the item by the artwork pipeline.
//
// READ-ONLY SCOPE (Phase-2 Slice 2): the grids DISPLAY rows + route from the
// sidebar. Card click (open detail) and "+ New" (create) are STUBS that log
// "not yet wired" — wired in later slices.
export struct MixtapeCardItem {
id: string,
name: string,
// "mixtape" | "collection" | "artist_collection".
kind: string,
// Eyebrow label, uppercase ("MIXTAPE" | "COLLECTION" | "ARTIST").
label: string,
// "5 albums" / "1 album" — ICU-plural rendered in Rust (always "album(s)"
// regardless of item_type, matching the PSD).
meta: string,
// Item count (drives the 3x3-vs-2x2 mosaic rule + sort).
item-count: int,
play-count: int,
// updated_at epoch ms (sort key).
updated-at: int,
// Custom user-uploaded cover (decoded local file); empty when none. When
// present it full-bleeds the mosaic (precedence over the collage).
custom-cover: image,
has-custom-cover: bool,
// Mosaic cell count actually used: 0 (empty -> kind glyph), or 4 / 9.
cover-count: int,
// Up to 9 collage cover slots (only 0-3 used for 2x2; 0-8 for a 3x3
// Collection with >=9 items). URLs are pre-downscaled in Rust.
url1: string, url2: string, url3: string, url4: string,
url5: string, url6: string, url7: string, url8: string, url9: string,
cover1: image, cover2: image, cover3: image, cover4: image,
cover5: image, cover6: image, cover7: image, cover8: image, cover9: image,
}
export global MyQbzState {
// The Mixtapes grid set (kind == mixtape), filtered + sorted.
in property <[MixtapeCardItem]> mixtapes: [];
// The Collections grid set (kind == collection | artist_collection),
// filtered (by kind-filter) + sorted.
in property <[MixtapeCardItem]> collections: [];
in property <bool> loading: false;
// --- Mixtapes toolbar (session-scoped) ---
in-out property <string> mix-search;
// "position" | "name" | "items" | "updated".
in-out property <string> mix-sort: "position";
// "asc" | "desc".
in-out property <string> mix-sort-dir: "asc";
// "grid" | "list".
in-out property <string> mix-view: "grid";
// --- Collections toolbar (session-scoped) ---
in-out property <string> col-search;
in-out property <string> col-sort: "position";
in-out property <string> col-sort-dir: "asc";
in-out property <string> col-view: "grid";
// "all" | "collection" | "artist_collection".
in-out property <string> col-kind-filter: "all";
}
export global MyQbzActions {
// Card click -> open detail (STUB: logs in this slice).
callback open-card(string /* id */);
// "+ New Mixtape" / "+ New Collection" (STUB: logs in this slice).
callback create-mixtape();
callback create-collection();
// --- Mixtapes toolbar ---
callback mix-search-changed(string /* query */);
callback mix-set-sort(string /* field */);
callback mix-set-view(string /* grid|list */);
callback mix-reset();
// --- Collections toolbar ---
callback col-search-changed(string /* query */);
callback col-set-sort(string /* field */);
callback col-set-view(string /* grid|list */);
callback col-set-kind-filter(string /* all|collection|artist_collection */);
callback col-reset();
}
// Folder editor modal — create + edit (icon preset, solid color, custom
// image, hidden toggle, delete). Ports Tauri's FolderEditModal. Gradient
// color presets are intentionally omitted (Slint brush limitation).