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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user