Files
qbz/crates/qbz-slint/ui/myqbz/CollectionMosaic.slint
T
vicrodh 698fb2fa7f 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
2026-06-09 02:49:44 -06:00

122 lines
5.7 KiB
Plaintext

// 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; }
}
}
}