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:
18 files changed
+1996
-5
No files matched your search
Generated
+1
@@ -5487,6 +5487,7 @@ dependencies = [
|
||||
"qbz-integrations",
|
||||
"qbz-library",
|
||||
"qbz-media-controls",
|
||||
"qbz-mixtape",
|
||||
"qbz-models",
|
||||
"qbz-offline-cache",
|
||||
"qbz-player",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user