feat(myqbz): Slint collection/mixtape detail view (read-only)
Phase-2 Slice 3. Opening a grid card navigates to the detail view: - NavEntry::MixtapeDetail(id) + back/forward + scroll-restore; nav-in un-stubs the grid card click. - src/myqbz_detail.rs: get_collection via the shared crate (hydrated items), load/apply/refresh deriving the render model (filter -> search -> sort). - ui/myqbz/MixtapeDetailView.slint: hero (CollectionMosaic/custom cover, title, kind eyebrow, meta), sticky toolbar (search/sort/type+source filter/reset/ select/view-mode), the item list in list + grid modes -- exact 8-col grid (40 1fr 140 80 160 72 60 40), 56px rows, per item_type, source/quality cells. Empty/loading/not-found states. Read-only boundary: hero CTAs + per-row play/menu actions are logging stubs; expanded-mode track expansion, in-detail modals, DJ-mix, bulk bar, and live resolveItems (quality/plex source) are deferred. KNOWN DEVIATION: the hero action row shows flat circles; the spec-3.6 layout (Play/Shuffle/DJ-mix + MoreHorizontal overflow menu) is folded into Slice 7 (its overflow items are the edit actions). Backend crates untouched. cargo check -p qbz-slint green. cc
This commit is contained in:
9 files changed
+2284
-8
No files matched your search
@@ -148,6 +148,13 @@ pub enum ArtworkTarget {
|
||||
/// One mosaic cover slot (0-8) of a My QBZ Collections-grid card
|
||||
/// (`MyQbzState.collections[index]`).
|
||||
MyQbzCollectionCover { index: usize, slot: usize },
|
||||
/// A row thumbnail in the My QBZ collection-detail item list
|
||||
/// (`MyQbzDetailState.items[index]`). Matched by item position on apply so
|
||||
/// a later sort/filter keeps the cover (the rendered model is re-derived).
|
||||
MyQbzDetailRow { position: i32 },
|
||||
/// One hero-mosaic cover slot (0-8) of the My QBZ collection-detail view
|
||||
/// (`MyQbzDetailState.cover{N}`).
|
||||
MyQbzDetailCover { slot: usize },
|
||||
}
|
||||
|
||||
impl ArtworkTarget {
|
||||
@@ -168,7 +175,11 @@ impl ArtworkTarget {
|
||||
ArtworkTarget::PmPlaylistCover { .. } | ArtworkTarget::PmTreeCover { .. } => 160,
|
||||
// My QBZ mosaic tiles render at ~60-92px (184/2 or 184/3 grid).
|
||||
ArtworkTarget::MyQbzMixtapeCover { .. }
|
||||
| ArtworkTarget::MyQbzCollectionCover { .. } => 160,
|
||||
| ArtworkTarget::MyQbzCollectionCover { .. }
|
||||
// Hero mosaic tiles render at ~62-93px (186/2 or 186/3 grid).
|
||||
| ArtworkTarget::MyQbzDetailCover { .. } => 160,
|
||||
// Detail list-row thumbnails render at 36px.
|
||||
ArtworkTarget::MyQbzDetailRow { .. } => 96,
|
||||
_ => DECODE_SIZE,
|
||||
}
|
||||
}
|
||||
@@ -918,5 +929,11 @@ fn apply_artwork(
|
||||
model.set_row_data(index, item);
|
||||
}
|
||||
}
|
||||
ArtworkTarget::MyQbzDetailRow { position } => {
|
||||
crate::myqbz_detail::set_row_artwork(window, position, image);
|
||||
}
|
||||
ArtworkTarget::MyQbzDetailCover { slot } => {
|
||||
crate::myqbz_detail::set_hero_cover(window, slot, image);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ mod location_view;
|
||||
mod mix;
|
||||
mod musician;
|
||||
mod myqbz;
|
||||
mod myqbz_detail;
|
||||
mod nav;
|
||||
mod play_history;
|
||||
mod strip_html;
|
||||
@@ -731,6 +732,7 @@ fn scope_for(entry: &nav::NavEntry) -> String {
|
||||
nav::NavEntry::OfflineManager => "offline-manager".into(),
|
||||
nav::NavEntry::Mixtapes => "mixtapes".into(),
|
||||
nav::NavEntry::Collections => "collections".into(),
|
||||
nav::NavEntry::MixtapeDetail(_) => "mixtape-detail".into(),
|
||||
nav::NavEntry::Album(_) => "album".into(),
|
||||
nav::NavEntry::LocalAlbum(_) => "local-album".into(),
|
||||
nav::NavEntry::Artist(_) => "artist".into(),
|
||||
@@ -892,6 +894,9 @@ fn apply_entry(
|
||||
qbz_models::mixtape::CollectionKind::Collection,
|
||||
);
|
||||
}
|
||||
nav::NavEntry::MixtapeDetail(id) => {
|
||||
myqbz_detail::navigate(weak.clone(), handle.clone(), image_cache.clone(), id);
|
||||
}
|
||||
nav::NavEntry::Location {
|
||||
mbid,
|
||||
area_id,
|
||||
@@ -1849,7 +1854,11 @@ fn wire_playlist_manager(
|
||||
/// `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) {
|
||||
fn wire_myqbz(
|
||||
window: &AppWindow,
|
||||
tokio_rt: &tokio::runtime::Runtime,
|
||||
image_cache: &artwork::ImageCache,
|
||||
) {
|
||||
use myqbz::Grid;
|
||||
|
||||
// Re-issue mosaic artwork jobs for a grid after a toolbar rebuild (the
|
||||
@@ -1859,12 +1868,25 @@ fn wire_myqbz(window: &AppWindow, image_cache: &artwork::ImageCache) {
|
||||
artwork::spawn_loads(jobs, window.as_weak(), image_cache.clone());
|
||||
}
|
||||
|
||||
// --- STUBS (read-only boundary) -------------------------------------
|
||||
// --- Open a card -> the collection-detail view (Phase-2 Slice 3) -----
|
||||
// NAV-IN: record history + navigate (loads via myqbz_detail::navigate),
|
||||
// mirroring the grid's own nav and the album/playlist detail openers.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<MyQbzActions>().on_open_card(move |id| {
|
||||
log::info!("[qbz-slint] myqbz open-card({id}) — detail not yet wired (read-only slice)");
|
||||
nav::record(nav::NavEntry::MixtapeDetail(id.to_string()));
|
||||
myqbz_detail::navigate(
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
image_cache.clone(),
|
||||
id.to_string(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Create CTAs: STUBS (read-only boundary) ------------------------
|
||||
{
|
||||
window.global::<MyQbzActions>().on_create_mixtape(move || {
|
||||
log::info!("[qbz-slint] myqbz create-mixtape — not yet wired (read-only slice)");
|
||||
@@ -1976,6 +1998,200 @@ fn wire_myqbz(window: &AppWindow, image_cache: &artwork::ImageCache) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire the My QBZ collection-DETAIL view (Phase-2 Slice 3, read-only). The
|
||||
/// toolbar callbacks (search / sort / type-filter / source-filter / view-mode /
|
||||
/// select / reset) drive `crate::myqbz_detail` re-derives + re-issue row
|
||||
/// artwork; `open-item` / `open-artist` route to the existing
|
||||
/// album/playlist/artist navigators (reusing the top-level open-album /
|
||||
/// open-artist callbacks so local-vs-qobuz routing + history stay in one
|
||||
/// place). Every hero CTA + per-row context action is a logging STUB — the
|
||||
/// read-only boundary for this slice.
|
||||
fn wire_myqbz_detail(
|
||||
window: &AppWindow,
|
||||
app_runtime: &Arc<AppRuntime<SlintAdapter>>,
|
||||
tokio_rt: &tokio::runtime::Runtime,
|
||||
image_cache: &artwork::ImageCache,
|
||||
) {
|
||||
use MyQbzDetailActions as Act;
|
||||
|
||||
// After a toolbar re-derive the rendered model changed, so the visible
|
||||
// rows need their thumbnails reloaded.
|
||||
fn refresh_row_covers(window: &AppWindow, image_cache: &artwork::ImageCache) {
|
||||
let jobs = myqbz_detail::artwork_jobs(window);
|
||||
artwork::spawn_loads(jobs, window.as_weak(), image_cache.clone());
|
||||
}
|
||||
|
||||
// --- Toolbar (client-side re-derive) --------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<Act>().on_search_changed(move |q| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
myqbz_detail::search(&w, q.as_str());
|
||||
refresh_row_covers(&w, &image_cache);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<Act>().on_set_sort(move |field| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
myqbz_detail::set_sort(&w, field.as_str());
|
||||
refresh_row_covers(&w, &image_cache);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<Act>().on_set_type_filter(move |value| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
myqbz_detail::set_type_filter(&w, value.as_str());
|
||||
refresh_row_covers(&w, &image_cache);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<Act>().on_toggle_source_filter(move |kind| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
myqbz_detail::toggle_source_filter(&w, kind.as_str());
|
||||
refresh_row_covers(&w, &image_cache);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<Act>().on_set_view_mode(move |mode| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.global::<MyQbzDetailState>().set_view_mode(mode);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<Act>().on_toggle_select_mode(move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
myqbz_detail::toggle_select_mode(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<Act>().on_toggle_item_select(move |position| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
myqbz_detail::toggle_item_select(&w, position);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<Act>().on_reset_filters(move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
myqbz_detail::reset_filters(&w);
|
||||
refresh_row_covers(&w, &image_cache);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Open an item -> album / local-album / playlist -----------------
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window
|
||||
.global::<Act>()
|
||||
.on_open_item(move |_source, item_type, source_item_id| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let id = source_item_id.to_string();
|
||||
match item_type.as_str() {
|
||||
// Album / track items both open an album view; the top-level
|
||||
// open-album callback handles Qobuz-vs-local routing + history.
|
||||
"album" | "track" => {
|
||||
w.invoke_open_album(id.into());
|
||||
}
|
||||
"playlist" => {
|
||||
nav::record(nav::NavEntry::Playlist(id.clone()));
|
||||
navigate_playlist(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
&handle,
|
||||
image_cache.clone(),
|
||||
id,
|
||||
);
|
||||
update_nav_flags(&w);
|
||||
}
|
||||
other => {
|
||||
log::warn!("[qbz-slint] myqbz_detail open-item: unknown type {other}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Open an item's artist (qobuz items only) -----------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window
|
||||
.global::<Act>()
|
||||
.on_open_artist(move |_source, artist_name| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
// The top-level open-artist callback routes a name to the
|
||||
// Local Library Artists tab and a numeric id to the Qobuz
|
||||
// artist page. Collection items carry an artist NAME.
|
||||
if !artist_name.trim().is_empty() {
|
||||
w.invoke_open_artist(artist_name);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- READ-ONLY STUBS (the slice boundary) ---------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let log_stub = move |what: &str| {
|
||||
let id = weak
|
||||
.upgrade()
|
||||
.map(|w| w.global::<MyQbzDetailState>().get_id().to_string())
|
||||
.unwrap_or_default();
|
||||
log::info!(
|
||||
"[qbz-slint] myqbz_detail {what}({id}) — not yet wired (read-only slice)"
|
||||
);
|
||||
};
|
||||
let s = log_stub.clone();
|
||||
window.global::<Act>().on_play_all(move || s("play-all"));
|
||||
let s = log_stub.clone();
|
||||
window.global::<Act>().on_shuffle(move || s("shuffle"));
|
||||
let s = log_stub.clone();
|
||||
window.global::<Act>().on_dj_mix(move || s("dj-mix"));
|
||||
let s = log_stub.clone();
|
||||
window.global::<Act>().on_edit(move || s("edit"));
|
||||
let s = log_stub.clone();
|
||||
window.global::<Act>().on_delete(move || s("delete"));
|
||||
let s = log_stub.clone();
|
||||
window.global::<Act>().on_sync(move || s("sync"));
|
||||
}
|
||||
{
|
||||
window.global::<Act>().on_play_item(move |source_item_id| {
|
||||
log::info!(
|
||||
"[qbz-slint] myqbz_detail play-item({source_item_id}) — not yet wired (read-only slice)"
|
||||
);
|
||||
});
|
||||
}
|
||||
{
|
||||
window
|
||||
.global::<Act>()
|
||||
.on_item_action(move |source_item_id, action| {
|
||||
log::info!(
|
||||
"[qbz-slint] myqbz_detail item-action({source_item_id}, {action}) — not yet wired (read-only slice)"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -6457,7 +6673,8 @@ 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);
|
||||
wire_myqbz(&window, &tokio_rt, &image_cache);
|
||||
wire_myqbz_detail(&window, &app_runtime, &tokio_rt, &image_cache);
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window
|
||||
|
||||
@@ -87,7 +87,7 @@ fn album_count_label(count: usize) -> String {
|
||||
/// 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 {
|
||||
pub fn small_qobuz_url(url: &str, target: u32) -> String {
|
||||
if url.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
//! My QBZ — Collection / Mixtape DETAIL view controller (read-only slice).
|
||||
//!
|
||||
//! Mirrors `crate::playlist` (a cached full item list backs a client-side
|
||||
//! filter -> search -> sort that re-derives the visible model) and reuses the
|
||||
//! grid controller's mosaic + URL-downscale helpers from `crate::myqbz`. It
|
||||
//! loads ONE `MixtapeCollection` (items come hydrated) via
|
||||
//! `qbz_mixtape::repo::get_collection` through `library_db::with_db` +
|
||||
//! `with_connection`, precomputes every display string (type label, source
|
||||
//! kind, quality detail, tracks/year columns, downscaled `_50` row artwork
|
||||
//! URL, up-to-9 hero-mosaic URLs), and pushes ready-to-render
|
||||
//! `MixtapeDetailItem`s into `MyQbzDetailState`. The view does NO per-row
|
||||
//! lookups.
|
||||
//!
|
||||
//! READ-ONLY SCOPE (Phase-2 Slice 3): nav-in (the grid card click) routes here
|
||||
//! and loads real data — that is the testable path. The hero CTAs
|
||||
//! (play/shuffle/dj-mix/edit/delete/sync), per-row context-menu items, and the
|
||||
//! select-mode bulk bar are VISIBLE 1:1 but their handlers are logging stubs
|
||||
//! (wired in main.rs). DEFERRED to a later slice: the live source/quality
|
||||
//! `resolveItems` resolution (so quality badges + plex/local source kinds are
|
||||
//! placeholders here, derived only from the stored `source`), the per-item
|
||||
//! inline track expansion (the "expanded" view-mode renders its toggle + shell
|
||||
//! only), the rename/description/delete/cover/DJ-mix modals, and persisted
|
||||
//! per-collection view-prefs.
|
||||
//!
|
||||
//! The backend (`qbz-mixtape`) is reused directly — no Tauri command wrappers
|
||||
//! (ADR-005), headless (ADR-006).
|
||||
|
||||
use qbz_models::mixtape::{
|
||||
AlbumSource, CollectionKind, CollectionPlayMode, ItemType, MixtapeCollection,
|
||||
MixtapeCollectionItem,
|
||||
};
|
||||
use slint::{ComponentHandle, Model, ModelRc, VecModel};
|
||||
|
||||
use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache};
|
||||
use crate::{AppWindow, ContentView, MixtapeDetailItem, MyQbzDetailState, NavState};
|
||||
|
||||
thread_local! {
|
||||
/// The full, original-order item list for the open collection — the
|
||||
/// canonical source the toolbar derives the visible list from. UI thread
|
||||
/// only (mirrors `playlist::FULL_ITEMS`).
|
||||
static FULL_ITEMS: std::cell::RefCell<Vec<MixtapeCollectionItem>> =
|
||||
const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
// ──────────────────────────── DB read path ────────────────────────────
|
||||
|
||||
/// Load one collection (items hydrated by the repo) from the per-user
|
||||
/// library.db. Returns `None` when the DB is unavailable or the id is unknown.
|
||||
pub fn get_collection(id: &str) -> Option<MixtapeCollection> {
|
||||
crate::library_db::with_db(|db| {
|
||||
Ok(db.with_connection(|conn| {
|
||||
qbz_mixtape::repo::get_collection(conn, id).unwrap_or_else(|e| {
|
||||
log::warn!("[qbz-slint] myqbz_detail get_collection({id}) failed: {e}");
|
||||
None
|
||||
})
|
||||
}))
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
// ──────────────────────────── string helpers ──────────────────────────
|
||||
|
||||
fn kind_str(kind: CollectionKind) -> &'static str {
|
||||
match kind {
|
||||
CollectionKind::Mixtape => "mixtape",
|
||||
CollectionKind::Collection => "collection",
|
||||
CollectionKind::ArtistCollection => "artist_collection",
|
||||
}
|
||||
}
|
||||
|
||||
/// Eyebrow label (Tauri `kindLabel`): mixtapes.label / collections.artistLabel
|
||||
/// / collections.label, uppercased to match the grid card eyebrow.
|
||||
fn kind_label(kind: CollectionKind) -> &'static str {
|
||||
match kind {
|
||||
CollectionKind::Mixtape => "MIXTAPE",
|
||||
CollectionKind::ArtistCollection => "ARTIST",
|
||||
CollectionKind::Collection => "COLLECTION",
|
||||
}
|
||||
}
|
||||
|
||||
fn play_mode_str(mode: CollectionPlayMode) -> &'static str {
|
||||
match mode {
|
||||
CollectionPlayMode::InOrder => "in_order",
|
||||
CollectionPlayMode::AlbumShuffle => "album_shuffle",
|
||||
}
|
||||
}
|
||||
|
||||
fn source_str(source: AlbumSource) -> &'static str {
|
||||
match source {
|
||||
AlbumSource::Qobuz => "qobuz",
|
||||
AlbumSource::Local => "local",
|
||||
}
|
||||
}
|
||||
|
||||
fn item_type_str(t: ItemType) -> &'static str {
|
||||
match t {
|
||||
ItemType::Album => "album",
|
||||
ItemType::Track => "track",
|
||||
ItemType::Playlist => "playlist",
|
||||
}
|
||||
}
|
||||
|
||||
/// `mixtapes.albumCount` ICU plural — always "album(s)" regardless of
|
||||
/// item_type (1:1 with the PSD / the grid card meta).
|
||||
fn album_count_label(count: usize) -> String {
|
||||
if count == 1 {
|
||||
"1 album".to_string()
|
||||
} else {
|
||||
format!("{count} albums")
|
||||
}
|
||||
}
|
||||
|
||||
/// Type-cell label, uppercase (spec 12 §6.3 col-3 `itemTypeLabel`). Release-type
|
||||
/// overrides (album rows showing EP/Single/…) are a later slice — albums render
|
||||
/// "ALBUM" here.
|
||||
fn type_label(t: ItemType) -> &'static str {
|
||||
match t {
|
||||
ItemType::Album => "ALBUM",
|
||||
ItemType::Track => "TRACK",
|
||||
ItemType::Playlist => "PLAYLIST",
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACKS column (spec 12 §6.3 col-6 `itemTracks`): "1" for a track, else the
|
||||
/// count or an em-dash.
|
||||
fn tracks_text(item: &MixtapeCollectionItem) -> String {
|
||||
match item.item_type {
|
||||
ItemType::Track => "1".to_string(),
|
||||
_ => match item.track_count {
|
||||
Some(n) => n.to_string(),
|
||||
None => "—".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// YEAR column (spec 12 §6.3 col-7 `itemYear`): the year or "".
|
||||
fn year_text(item: &MixtapeCollectionItem) -> String {
|
||||
item.year.map(|y| y.to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
// ──────────────────────────── model builder ───────────────────────────
|
||||
|
||||
/// Build one ready-to-render row. The `_50` row-artwork downscale reuses the
|
||||
/// grid controller's `small_qobuz_url`. Source kind defaults from the stored
|
||||
/// `source` (the live plex-vs-local-vs-qobuz `resolveItems` resolution is
|
||||
/// DEFERRED, so quality badge inputs stay empty here).
|
||||
fn to_item(item: &MixtapeCollectionItem) -> MixtapeDetailItem {
|
||||
let source = source_str(item.source);
|
||||
let artwork_url = item
|
||||
.artwork_url
|
||||
.as_deref()
|
||||
.filter(|u| !u.is_empty())
|
||||
.map(|u| crate::myqbz::small_qobuz_url(u, 50))
|
||||
.unwrap_or_default();
|
||||
|
||||
MixtapeDetailItem {
|
||||
position: item.position,
|
||||
item_type: item_type_str(item.item_type).into(),
|
||||
source: source.into(),
|
||||
source_item_id: item.source_item_id.clone().into(),
|
||||
title: item.title.clone().into(),
|
||||
subtitle: item.subtitle.clone().unwrap_or_default().into(),
|
||||
// Only qobuz items get a clickable artist subtitle (spec 12 §6.3).
|
||||
subtitle_is_link: item.source == AlbumSource::Qobuz
|
||||
&& item.subtitle.as_deref().map(|s| !s.is_empty()).unwrap_or(false),
|
||||
// Resolved source kind — defaults to the raw source until resolveItems
|
||||
// lands (a later slice). qobuz -> "qobuz"; local -> "local".
|
||||
source_kind: source.into(),
|
||||
type_label: type_label(item.item_type).into(),
|
||||
// Quality resolution is deferred; no badge until then.
|
||||
quality_tier: "".into(),
|
||||
quality_detail: "".into(),
|
||||
tracks_text: tracks_text(item).into(),
|
||||
year_text: year_text(item).into(),
|
||||
artwork_url: artwork_url.into(),
|
||||
artwork: slint::Image::default(),
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────── hero mosaic ─────────────────────────────
|
||||
|
||||
/// Decide the hero-mosaic cover-count (0 / 4 / 9) + downscaled cell URLs, and
|
||||
/// push them into `MyQbzDetailState`. Mirrors the grid card's mosaic rule
|
||||
/// (3x3 only for a Collection with >= 9 items; else 2x2) but at the hero
|
||||
/// `size = 186` (so the downscale target differs: 2x2 -> 150, 3x3 -> 50).
|
||||
fn apply_hero_mosaic(state: &MyQbzDetailState, c: &MixtapeCollection) {
|
||||
let item_count = c.items.len();
|
||||
let has_custom = c.custom_artwork_path.is_some();
|
||||
|
||||
let cols: usize = if c.kind == CollectionKind::Collection && item_count >= 9 {
|
||||
3
|
||||
} else {
|
||||
2
|
||||
};
|
||||
let cell_count = cols * cols;
|
||||
let cover_count = if has_custom || item_count == 0 {
|
||||
0
|
||||
} else {
|
||||
cell_count
|
||||
};
|
||||
// Hero renders at 186px; cell ~93 (2x2) -> 150, ~62 (3x3) -> 50.
|
||||
let target: u32 = if cols == 3 { 50 } else { 150 };
|
||||
|
||||
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() => crate::myqbz::small_qobuz_url(u, target).into(),
|
||||
_ => slint::SharedString::default(),
|
||||
}
|
||||
};
|
||||
|
||||
state.set_cover_count(cover_count as i32);
|
||||
state.set_url1(url(0));
|
||||
state.set_url2(url(1));
|
||||
state.set_url3(url(2));
|
||||
state.set_url4(url(3));
|
||||
state.set_url5(url(4));
|
||||
state.set_url6(url(5));
|
||||
state.set_url7(url(6));
|
||||
state.set_url8(url(7));
|
||||
state.set_url9(url(8));
|
||||
// Reset the decoded covers so a re-open does not show stale tiles.
|
||||
state.set_cover1(slint::Image::default());
|
||||
state.set_cover2(slint::Image::default());
|
||||
state.set_cover3(slint::Image::default());
|
||||
state.set_cover4(slint::Image::default());
|
||||
state.set_cover5(slint::Image::default());
|
||||
state.set_cover6(slint::Image::default());
|
||||
state.set_cover7(slint::Image::default());
|
||||
state.set_cover8(slint::Image::default());
|
||||
state.set_cover9(slint::Image::default());
|
||||
}
|
||||
|
||||
// ──────────────────────────── sort / filter / search ──────────────────
|
||||
|
||||
/// Apply the active toolbar (type filter -> source filter -> search -> sort)
|
||||
/// over `FULL_ITEMS` and push the resulting render model. Non-destructive (the
|
||||
/// persisted order is untouched). UI thread only. Mirrors spec 12 §19.
|
||||
pub fn refresh_view(window: &AppWindow) {
|
||||
let state = window.global::<MyQbzDetailState>();
|
||||
let query = state.get_search().trim().to_lowercase();
|
||||
let type_filter = state.get_type_filter().to_string();
|
||||
let (sq, sp, sl) = (
|
||||
state.get_src_qobuz(),
|
||||
state.get_src_plex(),
|
||||
state.get_src_local(),
|
||||
);
|
||||
let any_source = sq || sp || sl;
|
||||
let sort = state.get_sort().to_string();
|
||||
let desc = state.get_sort_dir().to_string() == "desc";
|
||||
|
||||
let mut view: Vec<MixtapeCollectionItem> = FULL_ITEMS.with(|cell| {
|
||||
cell.borrow()
|
||||
.iter()
|
||||
.filter(|it| {
|
||||
// Type filter (single-select).
|
||||
type_filter == "all" || item_type_str(it.item_type) == type_filter
|
||||
})
|
||||
.filter(|it| {
|
||||
// Source filter (multi-select). source_kind currently equals
|
||||
// the raw source (resolveItems deferred) — qobuz / local.
|
||||
if !any_source {
|
||||
return true;
|
||||
}
|
||||
let kind = source_str(it.source);
|
||||
(sq && kind == "qobuz") || (sp && kind == "plex") || (sl && kind == "local")
|
||||
})
|
||||
.filter(|it| {
|
||||
if query.is_empty() {
|
||||
return true;
|
||||
}
|
||||
it.title.to_lowercase().contains(&query)
|
||||
|| it
|
||||
.subtitle
|
||||
.as_deref()
|
||||
.map(|s| s.to_lowercase().contains(&query))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
});
|
||||
|
||||
match sort.as_str() {
|
||||
"name" => view.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())),
|
||||
"year" => view.sort_by(|a, b| a.year.unwrap_or(0).cmp(&b.year.unwrap_or(0))),
|
||||
"tracks" => {
|
||||
view.sort_by(|a, b| a.track_count.unwrap_or(0).cmp(&b.track_count.unwrap_or(0)))
|
||||
}
|
||||
// default "position"
|
||||
_ => view.sort_by(|a, b| a.position.cmp(&b.position)),
|
||||
}
|
||||
if desc {
|
||||
view.reverse();
|
||||
}
|
||||
|
||||
let items: Vec<MixtapeDetailItem> = view.iter().map(to_item).collect();
|
||||
state.set_items(ModelRc::new(VecModel::from(items)));
|
||||
|
||||
// Derived toolbar badges (Rust-computed; the view only reads them).
|
||||
let source_count = (sq as i32) + (sp as i32) + (sl as i32);
|
||||
state.set_filter_count(source_count + if type_filter != "all" { 1 } else { 0 });
|
||||
state.set_has_any_filter(
|
||||
type_filter != "all" || any_source || sort != "position" || desc,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update the search query and re-render.
|
||||
pub fn search(window: &AppWindow, query: &str) {
|
||||
window.global::<MyQbzDetailState>().set_search(query.into());
|
||||
refresh_view(window);
|
||||
}
|
||||
|
||||
/// Set the sort field. Re-selecting the active field flips asc/desc; a new
|
||||
/// field resets to asc (spec 12 §5.4 `selectSort`).
|
||||
pub fn set_sort(window: &AppWindow, field: &str) {
|
||||
let state = window.global::<MyQbzDetailState>();
|
||||
if state.get_sort() == field {
|
||||
let dir = if state.get_sort_dir() == "asc" { "desc" } else { "asc" };
|
||||
state.set_sort_dir(dir.into());
|
||||
} else {
|
||||
state.set_sort(field.into());
|
||||
state.set_sort_dir("asc".into());
|
||||
}
|
||||
refresh_view(window);
|
||||
}
|
||||
|
||||
/// Single-select the type filter.
|
||||
pub fn set_type_filter(window: &AppWindow, value: &str) {
|
||||
window.global::<MyQbzDetailState>().set_type_filter(value.into());
|
||||
refresh_view(window);
|
||||
}
|
||||
|
||||
/// Toggle one source-filter flag (multi-select; menu stays open in the view).
|
||||
pub fn toggle_source_filter(window: &AppWindow, kind: &str) {
|
||||
let state = window.global::<MyQbzDetailState>();
|
||||
match kind {
|
||||
"qobuz" => state.set_src_qobuz(!state.get_src_qobuz()),
|
||||
"plex" => state.set_src_plex(!state.get_src_plex()),
|
||||
"local" => state.set_src_local(!state.get_src_local()),
|
||||
_ => {}
|
||||
}
|
||||
refresh_view(window);
|
||||
}
|
||||
|
||||
/// Reset filters + sort (spec 12 §5.6 reset: type 'all', no sources, sort
|
||||
/// 'position' asc). Search query is left intact (Tauri's reset doesn't clear
|
||||
/// it; `hasAnyFilter` excludes search).
|
||||
pub fn reset_filters(window: &AppWindow) {
|
||||
let state = window.global::<MyQbzDetailState>();
|
||||
state.set_type_filter("all".into());
|
||||
state.set_src_qobuz(false);
|
||||
state.set_src_plex(false);
|
||||
state.set_src_local(false);
|
||||
state.set_sort("position".into());
|
||||
state.set_sort_dir("asc".into());
|
||||
refresh_view(window);
|
||||
}
|
||||
|
||||
/// Toggle multi-select edit mode. Leaving clears any selection.
|
||||
pub fn toggle_select_mode(window: &AppWindow) {
|
||||
let state = window.global::<MyQbzDetailState>();
|
||||
let on = !state.get_select_mode();
|
||||
if !on {
|
||||
let model = state.get_items();
|
||||
for i in 0..model.row_count() {
|
||||
if let Some(mut it) = model.row_data(i) {
|
||||
if it.selected {
|
||||
it.selected = false;
|
||||
model.set_row_data(i, it);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.set_selected_count(0);
|
||||
}
|
||||
state.set_select_mode(on);
|
||||
}
|
||||
|
||||
/// Toggle one row's selection by position. Recounts the selection.
|
||||
pub fn toggle_item_select(window: &AppWindow, position: i32) {
|
||||
let state = window.global::<MyQbzDetailState>();
|
||||
let model = state.get_items();
|
||||
for i in 0..model.row_count() {
|
||||
if let Some(mut it) = model.row_data(i) {
|
||||
if it.position == position {
|
||||
it.selected = !it.selected;
|
||||
model.set_row_data(i, it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let count = (0..model.row_count())
|
||||
.filter(|&i| model.row_data(i).map(|it| it.selected).unwrap_or(false))
|
||||
.count() as i32;
|
||||
state.set_selected_count(count);
|
||||
}
|
||||
|
||||
// ──────────────────────────── reset / apply ───────────────────────────
|
||||
|
||||
/// Clear the view to its loading state before a fresh load (so a re-open does
|
||||
/// not flash the previous collection's hero + rows).
|
||||
pub fn reset(window: &AppWindow) {
|
||||
FULL_ITEMS.with(|cell| cell.borrow_mut().clear());
|
||||
let state = window.global::<MyQbzDetailState>();
|
||||
state.set_loading(true);
|
||||
state.set_found(true);
|
||||
state.set_items(ModelRc::new(VecModel::from(Vec::<MixtapeDetailItem>::new())));
|
||||
state.set_name("".into());
|
||||
state.set_description("".into());
|
||||
state.set_meta("".into());
|
||||
state.set_item_count(0);
|
||||
state.set_has_custom_cover(false);
|
||||
state.set_custom_cover(slint::Image::default());
|
||||
state.set_cover_count(0);
|
||||
state.set_selected_count(0);
|
||||
state.set_select_mode(false);
|
||||
// Toolbar is session-scoped per the slice spec: reset to defaults on open.
|
||||
state.set_search("".into());
|
||||
state.set_sort("position".into());
|
||||
state.set_sort_dir("asc".into());
|
||||
state.set_type_filter("all".into());
|
||||
state.set_src_qobuz(false);
|
||||
state.set_src_plex(false);
|
||||
state.set_src_local(false);
|
||||
state.set_view_mode("list".into());
|
||||
state.set_filter_count(0);
|
||||
state.set_has_any_filter(false);
|
||||
}
|
||||
|
||||
/// Apply a freshly-loaded collection: header strings, hero mosaic, the full
|
||||
/// item list (-> FULL_ITEMS), then render through the (reset) toolbar.
|
||||
pub fn apply(window: &AppWindow, c: MixtapeCollection) {
|
||||
let state = window.global::<MyQbzDetailState>();
|
||||
let item_count = c.items.len();
|
||||
|
||||
state.set_id(c.id.clone().into());
|
||||
state.set_kind(kind_str(c.kind).into());
|
||||
state.set_kind_label(kind_label(c.kind).into());
|
||||
state.set_name(c.name.clone().into());
|
||||
state.set_description(c.description.clone().unwrap_or_default().into());
|
||||
state.set_meta(album_count_label(item_count).into());
|
||||
state.set_item_count(item_count as i32);
|
||||
state.set_play_mode(play_mode_str(c.play_mode).into());
|
||||
state.set_found(true);
|
||||
|
||||
// Custom cover (overrides the mosaic) — load the local file directly (it
|
||||
// lives in the artwork cache on disk; same as the playlist controller).
|
||||
let has_custom = c
|
||||
.custom_artwork_path
|
||||
.as_ref()
|
||||
.filter(|p| !p.is_empty())
|
||||
.filter(|p| std::path::Path::new(p).exists())
|
||||
.and_then(|p| slint::Image::load_from_path(std::path::Path::new(p)).ok());
|
||||
if let Some(img) = has_custom {
|
||||
state.set_has_custom_cover(true);
|
||||
state.set_custom_cover(img);
|
||||
} else {
|
||||
state.set_has_custom_cover(false);
|
||||
state.set_custom_cover(slint::Image::default());
|
||||
}
|
||||
|
||||
apply_hero_mosaic(&state, &c);
|
||||
|
||||
FULL_ITEMS.with(|cell| *cell.borrow_mut() = c.items);
|
||||
refresh_view(window);
|
||||
state.set_loading(false);
|
||||
}
|
||||
|
||||
/// Mark the load as not-found (the id resolved to no collection).
|
||||
pub fn apply_not_found(window: &AppWindow) {
|
||||
let state = window.global::<MyQbzDetailState>();
|
||||
state.set_loading(false);
|
||||
state.set_found(false);
|
||||
}
|
||||
|
||||
// ──────────────────────────── artwork jobs ────────────────────────────
|
||||
|
||||
/// Build artwork jobs for the loaded collection: the up-to-9 hero-mosaic cells
|
||||
/// (only when no custom cover) + one thumbnail per visible item row.
|
||||
pub fn artwork_jobs(window: &AppWindow) -> Vec<ArtworkJob> {
|
||||
let state = window.global::<MyQbzDetailState>();
|
||||
let mut jobs = Vec::new();
|
||||
|
||||
// Hero mosaic cells.
|
||||
if !state.get_has_custom_cover() {
|
||||
let urls = [
|
||||
state.get_url1(),
|
||||
state.get_url2(),
|
||||
state.get_url3(),
|
||||
state.get_url4(),
|
||||
state.get_url5(),
|
||||
state.get_url6(),
|
||||
state.get_url7(),
|
||||
state.get_url8(),
|
||||
state.get_url9(),
|
||||
];
|
||||
for (slot, url) in urls.iter().enumerate() {
|
||||
if !url.is_empty() {
|
||||
jobs.push(ArtworkJob {
|
||||
target: ArtworkTarget::MyQbzDetailCover { slot },
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Row thumbnails (the rendered model — matched back by position on apply).
|
||||
let model = state.get_items();
|
||||
for i in 0..model.row_count() {
|
||||
let Some(item) = model.row_data(i) else { continue };
|
||||
if !item.artwork_url.is_empty() {
|
||||
jobs.push(ArtworkJob {
|
||||
target: ArtworkTarget::MyQbzDetailRow { position: item.position },
|
||||
url: item.artwork_url.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
jobs
|
||||
}
|
||||
|
||||
/// Set a decoded row thumbnail by item position (the rendered model order may
|
||||
/// differ from FULL_ITEMS after a sort, so match by the stable position).
|
||||
pub fn set_row_artwork(window: &AppWindow, position: i32, image: slint::Image) {
|
||||
let model = window.global::<MyQbzDetailState>().get_items();
|
||||
for i in 0..model.row_count() {
|
||||
if let Some(mut it) = model.row_data(i) {
|
||||
if it.position == position {
|
||||
it.artwork = image;
|
||||
model.set_row_data(i, it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a decoded hero-mosaic cover by slot (0-8).
|
||||
pub fn set_hero_cover(window: &AppWindow, slot: usize, image: slint::Image) {
|
||||
let state = window.global::<MyQbzDetailState>();
|
||||
match slot {
|
||||
0 => state.set_cover1(image),
|
||||
1 => state.set_cover2(image),
|
||||
2 => state.set_cover3(image),
|
||||
3 => state.set_cover4(image),
|
||||
4 => state.set_cover5(image),
|
||||
5 => state.set_cover6(image),
|
||||
6 => state.set_cover7(image),
|
||||
7 => state.set_cover8(image),
|
||||
8 => state.set_cover9(image),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────── navigation ──────────────────────────────
|
||||
|
||||
/// Open the collection-detail view for `id`: switch the ContentView + loading
|
||||
/// state immediately, fetch the collection on a blocking worker, then apply +
|
||||
/// render + spawn artwork. Mirrors `myqbz::navigate` (load/apply/render) and
|
||||
/// the album/playlist detail navigators.
|
||||
pub fn navigate(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
image_cache: ImageCache,
|
||||
id: String,
|
||||
) {
|
||||
handle.clone().spawn(async move {
|
||||
{
|
||||
let weak = weak.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
reset(&w);
|
||||
w.global::<NavState>().set_view(ContentView::MixtapeDetail);
|
||||
});
|
||||
}
|
||||
|
||||
let fetch_id = id.clone();
|
||||
let collection =
|
||||
tokio::task::spawn_blocking(move || get_collection(&fetch_id)).await.ok().flatten();
|
||||
|
||||
let _ = weak.upgrade_in_event_loop(move |w| match collection {
|
||||
Some(c) => {
|
||||
apply(&w, c);
|
||||
let jobs = artwork_jobs(&w);
|
||||
artwork::spawn_loads(jobs, w.as_weak(), image_cache.clone());
|
||||
}
|
||||
None => {
|
||||
log::warn!("[qbz-slint] myqbz_detail navigate({id}): collection not found");
|
||||
apply_not_found(&w);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -66,6 +66,12 @@ pub enum NavEntry {
|
||||
/// The My QBZ > Collections index grid (read-only in this slice). Same
|
||||
/// session-scoped toolbar; no payload.
|
||||
Collections,
|
||||
/// A Mixtape / Collection / Artist-Collection DETAIL page (read-only in
|
||||
/// this slice); the string is the collection id. Mirrors `Album` /
|
||||
/// `Playlist` — the in-detail toolbar state (search / sort / type-filter /
|
||||
/// view-mode) is session-scoped in the controller, so the entry carries
|
||||
/// only the id.
|
||||
MixtapeDetail(String),
|
||||
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, 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";
|
||||
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, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, 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`.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -45,6 +45,7 @@ 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 { MixtapeDetailView } from "../myqbz/MixtapeDetailView.slint";
|
||||
import { OfflineManagerView } from "../offline/OfflineManagerView.slint";
|
||||
import { LocalLibraryView } from "../locallibrary/LocalLibraryView.slint";
|
||||
import { SettingsView } from "../settings/SettingsView.slint";
|
||||
@@ -233,6 +234,7 @@ 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.mixtape-detail: MixtapeDetailView { }
|
||||
if NavState.view == ContentView.offline-manager: OfflineManagerView { }
|
||||
if NavState.view == ContentView.local-library: LocalLibraryView { }
|
||||
if NavState.view == ContentView.mix: MixView {
|
||||
|
||||
@@ -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, mixtapes, collections }
|
||||
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, mixtape-detail }
|
||||
|
||||
// In-app toast (transient notification). Mirrors Tauri's toastStore:
|
||||
// a single toast at a time (a new one replaces the current), auto-hidden
|
||||
@@ -1467,6 +1467,140 @@ export global MyQbzActions {
|
||||
callback col-reset();
|
||||
}
|
||||
|
||||
// === My QBZ — Collection / Mixtape DETAIL view (read-only) ==============
|
||||
// Spec 12. One row per collection item (album / track / playlist), rendered
|
||||
// in the 8-col list / the grid. The Rust controller (crate::myqbz_detail)
|
||||
// precomputes every display string (type label, source kind, quality detail,
|
||||
// tracks/year columns, downscaled artwork URL) so the view does no lookups.
|
||||
// READ-ONLY SCOPE (Phase-2 Slice 3): the hero action buttons + per-row
|
||||
// context-menu items + grid/list view modes render 1:1, but every click is a
|
||||
// logging STUB. The "expanded" view-mode toggle + shell render; per-item
|
||||
// inline track expansion is DEFERRED (needs the enqueue resolver — a later
|
||||
// slice). Edit/rename/delete/cover/DJ-mix modals + the BulkActionBar are also
|
||||
// deferred.
|
||||
export struct MixtapeDetailItem {
|
||||
// 0-based persisted position (the row's stable key; display is +1).
|
||||
position: int,
|
||||
// "album" | "track" | "playlist".
|
||||
item-type: string,
|
||||
// "qobuz" | "local".
|
||||
source: string,
|
||||
source-item-id: string,
|
||||
title: string,
|
||||
subtitle: string,
|
||||
// True for qobuz items whose subtitle is a clickable artist link
|
||||
// (spec 12 §6.3 col-2 — local subtitles are plain text).
|
||||
subtitle-is-link: bool,
|
||||
// Resolved source kind for the source indicator + source filter
|
||||
// ("qobuz" | "plex" | "local"). Defaults from `source` until resolveItems
|
||||
// lands (deferred — for now equals the raw source mapping).
|
||||
source-kind: string,
|
||||
// Type-cell label (already localized): "ALBUM" / "TRACK" / "PLAYLIST"
|
||||
// (or an EP/Single/… release-type override — overrides are a later slice).
|
||||
type-label: string,
|
||||
// Quality badge inputs (resolved; empty/0 until resolveItems — deferred).
|
||||
quality-tier: string, // "hires" | "cd" | ""
|
||||
quality-detail: string, // "24-bit / 96 kHz" | ""
|
||||
// TRACKS column: "1" for a track, else the count or "—".
|
||||
tracks-text: string,
|
||||
// YEAR column: the year or "".
|
||||
year-text: string,
|
||||
// Downscaled (_50) row artwork; decoded into `artwork` by the pipeline.
|
||||
artwork-url: string,
|
||||
artwork: image,
|
||||
// UI-owned multi-select state (select-mode — deferred wiring; the column
|
||||
// renders when select-mode is on so geometry matches the PSD).
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
export global MyQbzDetailState {
|
||||
in property <string> id;
|
||||
// "mixtape" | "collection" | "artist_collection".
|
||||
in property <string> kind;
|
||||
// Eyebrow label, uppercase ("MIXTAPE" | "COLLECTION" | "ARTIST").
|
||||
in property <string> kind-label;
|
||||
in property <string> name;
|
||||
in property <string> description;
|
||||
// "5 albums" / "1 album" meta line (ICU plural, rendered in Rust).
|
||||
in property <string> meta;
|
||||
in property <int> item-count: 0;
|
||||
// play_mode ("in_order" | "album_shuffle") — drives the overflow toggle
|
||||
// label (deferred wiring; shown for parity).
|
||||
in property <string> play-mode: "in_order";
|
||||
// True when the user set a custom cover (full-bleeds the hero mosaic).
|
||||
in property <bool> has-custom-cover: false;
|
||||
in property <image> custom-cover;
|
||||
in property <bool> loading: false;
|
||||
// False once the load completes with no collection (not-found state).
|
||||
in property <bool> found: true;
|
||||
|
||||
// --- Hero mosaic (CollectionMosaic inputs, same scheme as the card) ----
|
||||
// cover-count 0 (empty -> kind glyph) / 4 / 9; up-to-9 downscaled URLs +
|
||||
// decoded covers. Mirrors MixtapeCardItem so CollectionMosaic renders it.
|
||||
in property <int> cover-count: 0;
|
||||
in property <string> url1; in property <string> url2; in property <string> url3;
|
||||
in property <string> url4; in property <string> url5; in property <string> url6;
|
||||
in property <string> url7; in property <string> url8; in property <string> url9;
|
||||
in property <image> cover1; in property <image> cover2; in property <image> cover3;
|
||||
in property <image> cover4; in property <image> cover5; in property <image> cover6;
|
||||
in property <image> cover7; in property <image> cover8; in property <image> cover9;
|
||||
|
||||
// --- Item list (the render model: filter -> search -> sort applied) ----
|
||||
in property <[MixtapeDetailItem]> items: [];
|
||||
|
||||
// --- Toolbar (session-scoped) ------------------------------------------
|
||||
in-out property <string> search;
|
||||
// "position" | "name" | "year" | "tracks".
|
||||
in-out property <string> sort: "position";
|
||||
in-out property <string> sort-dir: "asc";
|
||||
// "all" | "album" | "track" | "playlist" (single-select).
|
||||
in-out property <string> type-filter: "all";
|
||||
// Multi-select source filter membership (spec 12 §5.5). Three flags
|
||||
// (Slint has no Set); Rust derives the badge count.
|
||||
in-out property <bool> src-qobuz: false;
|
||||
in-out property <bool> src-plex: false;
|
||||
in-out property <bool> src-local: false;
|
||||
// "list" | "grid" | "expanded".
|
||||
in-out property <string> view-mode: "list";
|
||||
// Multi-select edit mode (renders the per-row checkbox column; bulk-bar
|
||||
// wiring is DEFERRED).
|
||||
in-out property <bool> select-mode: false;
|
||||
in property <int> selected-count: 0;
|
||||
// Rust-computed filter badge count (sources + (type-filter != all)).
|
||||
in property <int> filter-count: 0;
|
||||
// Whether any filter/sort differs from the defaults (drives the reset btn).
|
||||
in property <bool> has-any-filter: false;
|
||||
}
|
||||
|
||||
export global MyQbzDetailActions {
|
||||
// Client-side toolbar — re-derive the visible item list (UI thread).
|
||||
callback search-changed(string /* query */);
|
||||
callback set-sort(string /* field */);
|
||||
callback set-type-filter(string /* all|album|track|playlist */);
|
||||
callback toggle-source-filter(string /* qobuz|plex|local */);
|
||||
callback set-view-mode(string /* list|grid|expanded */);
|
||||
callback toggle-select-mode();
|
||||
callback reset-filters();
|
||||
// Open an item's detail (album / track-album / playlist). Routed to the
|
||||
// existing open-album / open-playlist paths in main.rs.
|
||||
callback open-item(string /* source */, string /* item-type */, string /* source-item-id */);
|
||||
// Open an item's artist page by name (qobuz items only).
|
||||
callback open-artist(string /* source */, string /* artist-name */);
|
||||
// --- READ-ONLY STUBS (logged; not yet wired) --------------------------
|
||||
// Hero CTAs.
|
||||
callback play-all();
|
||||
callback shuffle();
|
||||
callback dj-mix();
|
||||
callback edit(); // overflow: rename / description / cover / convert
|
||||
callback delete();
|
||||
callback sync();
|
||||
// Per-row affordances.
|
||||
callback play-item(string /* source-item-id */);
|
||||
callback item-action(string /* source-item-id */, string /* action */);
|
||||
// Toggle one row's selection (select-mode).
|
||||
callback toggle-item-select(int /* position */);
|
||||
}
|
||||
|
||||
// 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