fix(myqbz): final fidelity pass (uppercase, trim, expanded cache, bulk bar §13.1)

Phase-2 Slice 12d.
- Discography Builder: uppercase eyebrow + ORDER BY + 6 column labels (BUILD ARTIST
  COLLECTION / YEAR/TYPE/ALBUM/TRACKS/SOURCE/QUALITY); Create now disables on a
  whitespace-only name (name-trimmed-empty computed in the controller).
- Expanded-mode: per-item inline-track cache keyed by the stable source|source_item_id
  (survives filter/sort/search re-derive instead of re-fetching), cleared on open.
- Inline tracks: go-to-album passes the parent item_type (playlist parent routes to
  the playlist view); duration 0 -> "--:--"; track number from the resolver order.
- Detail bulk bar: completed the §13.1 set (Add to queue, Play next, Add to playlist,
  Add to Mixtape/Collection, Remove, Clear) via MultiSelectBar slots + bulk_enqueue
  (reuses the resolver) + the existing playlist picker; no invented primitive.
- Stale comments corrected (dj-mix wired; mix-modal primary = Add to queue).

Backend crates untouched. cargo check -p qbz-slint green.

cc
This commit is contained in:
vicrodh committed 2026-06-09 08:49:39 -06:00
1 parent 16e6232220
commit 4e0d1a005e
8 files changed
+349 -45

No files matched your search

+76 -6
View File
@@ -2657,17 +2657,66 @@ fn wire_myqbz_detail(
}); });
} }
// --- Bulk action bar (select-mode, spec 12 §13) -------------------- // --- Bulk action bar (select-mode, spec 12 §13.1) ------------------
// "add-to-mixtape" opens the global AddToMixtapeModal with the selected // The full §13.1 group set:
// items' payloads; "remove-selected" removes each selected position // - "add-to-queue" / "play-next": resolve the selected items via the shared
// (highest-first) then reloads the detail + clears selection. // enqueue resolver + append / insert-next (no replace, no queue-source
// stamp — mirrors the per-row contract).
// - "add-to-playlist": resolve the selected items to their Qobuz track ids
// and open the existing playlist picker (Qobuz mode) with them.
// - "add-to-mixtape": open the global AddToMixtapeModal with the payloads.
// - "remove-selected": remove each selected position (highest-first) then
// reload the detail + clear selection.
// - "clear": clear the selection (exit-select / uncheck all).
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let handle = tokio_rt.handle().clone(); let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone(); let image_cache = image_cache.clone();
let runtime = app_runtime.clone();
window.global::<Act>().on_bulk_action(move |id| { window.global::<Act>().on_bulk_action(move |id| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
match id.as_str() { match id.as_str() {
"add-to-queue" | "play-next" => {
let selected = myqbz_detail::selected_full_items(&w);
if selected.is_empty() {
return;
}
myqbz_play::bulk_enqueue(
runtime.clone(),
weak.clone(),
handle.clone(),
selected,
id.as_str() == "play-next",
);
}
"add-to-playlist" => {
let selected = myqbz_detail::selected_full_items(&w);
if selected.is_empty() {
return;
}
// Resolve to Qobuz track ids on a worker, then open the
// global picker (Qobuz mode) + load the user's playlists.
let runtime = runtime.clone();
let weak = weak.clone();
handle.spawn(async move {
let ids =
myqbz_play::resolve_bulk_qobuz_track_ids(&runtime, &selected).await;
if ids.is_empty() {
crate::toast::error_weak(
&weak,
"No Qobuz tracks in the selection to add to a playlist",
);
return;
}
let _ = weak.upgrade_in_event_loop(move |w| {
playlist_picker::open_multi(&w, &ids, false);
});
let playlists = playlist_picker::load(&runtime).await;
let _ = weak.upgrade_in_event_loop(move |w| {
playlist_picker::apply(&w, playlists);
});
});
}
"add-to-mixtape" => { "add-to-mixtape" => {
let selected = myqbz_detail::selected_full_items(&w); let selected = myqbz_detail::selected_full_items(&w);
let items: Vec<myqbz_add::AddItem> = selected let items: Vec<myqbz_add::AddItem> = selected
@@ -2696,6 +2745,11 @@ fn wire_myqbz_detail(
positions, positions,
); );
} }
"clear" => {
// Clear-X: uncheck every row + zero the count, staying in
// select-mode (spec §13.1 clear control).
myqbz_detail::clear_selection(&w);
}
other => { other => {
log::warn!("[qbz-slint] myqbz_detail bulk-action: unknown id {other}"); log::warn!("[qbz-slint] myqbz_detail bulk-action: unknown id {other}");
} }
@@ -2924,9 +2978,25 @@ fn wire_myqbz_detail(
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
// go-to-album routes through the existing open-item path (Qobuz // go-to-album routes through the existing open-item path (Qobuz
// album view vs local-album by id), keeping nav in one place. // album view vs local-album by id), keeping nav in one place.
// It must open the PARENT item (spec 12 §8) — so route with the
// parent's REAL item_type (album/playlist), not a hardcoded
// "album": a playlist parent must reach the playlist view, not
// be mis-routed to the album view. The parent's type is read off
// the rendered row carrying this source-item-id.
if action == "go-to-album" { if action == "go-to-album" {
w.global::<Act>() let parent_type = {
.invoke_open_item("".into(), "album".into(), item_source_item_id); let model = w.global::<MyQbzDetailState>().get_items();
(0..model.row_count())
.filter_map(|i| model.row_data(i))
.find(|it| it.source_item_id == item_source_item_id)
.map(|it| it.item_type.to_string())
.unwrap_or_else(|| "album".to_string())
};
w.global::<Act>().invoke_open_item(
"".into(),
parent_type.into(),
item_source_item_id,
);
return; return;
} }
let id = w.global::<MyQbzDetailState>().get_id().to_string(); let id = w.global::<MyQbzDetailState>().get_id().to_string();
+13 -4
View File
@@ -663,6 +663,9 @@ pub fn reset(window: &AppWindow, artist_id: &str) {
state.set_creating(false); state.set_creating(false);
state.set_open_type_menu_key("".into()); state.set_open_type_menu_key("".into());
state.set_collection_name("".into()); state.set_collection_name("".into());
// Empty name -> trimmed-empty (Create disabled until a name is typed or the
// default is installed).
state.set_name_trimmed_empty(true);
state.set_order_by("release_date".into()); state.set_order_by("release_date".into());
state.set_artist_id(artist_id.into()); state.set_artist_id(artist_id.into());
state.set_artist_name("".into()); state.set_artist_name("".into());
@@ -711,6 +714,10 @@ pub fn install(
}; };
state.set_collection_name(format!("{base} — Complete Discography").into()); state.set_collection_name(format!("{base} — Complete Discography").into());
} }
// Keep the trimmed-empty gate in sync with whatever the name now holds
// (the default name is non-empty; a pre-existing whitespace name stays
// disabled).
state.set_name_trimmed_empty(state.get_collection_name().trim().is_empty());
state.set_loading(false); state.set_loading(false);
apply(window); apply(window);
} }
@@ -774,11 +781,13 @@ pub fn set_order(window: &AppWindow, order_by: &str) {
} }
/// Sync the collection-name into the state (the input is bound but we mirror /// Sync the collection-name into the state (the input is bound but we mirror
/// it so the save flow reads a single source). /// it so the save flow reads a single source). Also recomputes the
/// trimmed-empty flag that gates the Create button (Slint can't trim, so a
/// whitespace-only name must be detected here — spec §10.19).
pub fn name_changed(window: &AppWindow, name: &str) { pub fn name_changed(window: &AppWindow, name: &str) {
window let state = window.global::<DiscoBuilderState>();
.global::<DiscoBuilderState>() state.set_collection_name(name.into());
.set_collection_name(name.into()); state.set_name_trimmed_empty(name.trim().is_empty());
} }
/// Apply a release-type override (persisted) + re-render. /// Apply a release-type override (persisted) + re-render.
+79 -14
View File
@@ -40,6 +40,26 @@ thread_local! {
/// only (mirrors `playlist::FULL_ITEMS`). /// only (mirrors `playlist::FULL_ITEMS`).
static FULL_ITEMS: std::cell::RefCell<Vec<MixtapeCollectionItem>> = static FULL_ITEMS: std::cell::RefCell<Vec<MixtapeCollectionItem>> =
const { std::cell::RefCell::new(Vec::new()) }; const { std::cell::RefCell::new(Vec::new()) };
/// Expanded-mode inline-tracks cache, keyed by a STABLE per-item key
/// (`source|source_item_id`). Populated ONCE per item when its inline tracks
/// are first resolved (spec 12 §8). It is the durable home of the resolved
/// tracks: `refresh_view` rebuilds the `MixtapeDetailItem` render rows on
/// every filter/sort/search (so the per-row `inline_tracks` would be wiped),
/// so after each re-derive we re-hydrate the rows from THIS cache instead of
/// re-fetching. The cached `Vec<TrackItem>` carries `slint::Image`s (`!Send`)
/// — safe here because the cache lives on the UI thread only. Cleared on
/// `reset` (a fresh collection open). Mirrors the Tauri per-item track cache
/// that survives the client-side re-derive.
static INLINE_CACHE: std::cell::RefCell<std::collections::HashMap<String, Vec<TrackItem>>> =
std::cell::RefCell::new(std::collections::HashMap::new());
}
/// Stable per-item key for the inline-tracks cache (`source|source_item_id`).
/// `source_item_id` alone is the row's logical key, but pairing it with the
/// source keeps qobuz-vs-local collisions impossible.
fn inline_cache_key(source: &str, source_item_id: &str) -> String {
format!("{source}|{source_item_id}")
} }
// ──────────────────────────── DB read path ──────────────────────────── // ──────────────────────────── DB read path ────────────────────────────
@@ -153,6 +173,18 @@ fn to_item(item: &MixtapeCollectionItem) -> MixtapeDetailItem {
.map(|u| crate::myqbz::small_qobuz_url(u, 50)) .map(|u| crate::myqbz::small_qobuz_url(u, 50))
.unwrap_or_default(); .unwrap_or_default();
// Re-hydrate inline tracks from the controller-level cache (keyed
// `source|source_item_id`) so a filter/sort/search re-derive does NOT lose
// already-resolved tracks or trigger a re-fetch (spec 12 §8 — the cache
// survives the re-derive). A cache hit marks the row loaded.
let cache_key = inline_cache_key(source, &item.source_item_id);
let (cached_tracks, tracks_loaded) = INLINE_CACHE.with(|cell| {
match cell.borrow().get(&cache_key) {
Some(tracks) => (tracks.clone(), true),
None => (Vec::new(), false),
}
});
MixtapeDetailItem { MixtapeDetailItem {
position: item.position, position: item.position,
item_type: item_type_str(item.item_type).into(), item_type: item_type_str(item.item_type).into(),
@@ -178,9 +210,11 @@ fn to_item(item: &MixtapeCollectionItem) -> MixtapeDetailItem {
// Expanded-mode inline tracks (spec 12 §8): albums and playlists can // Expanded-mode inline tracks (spec 12 §8): albums and playlists can
// host inline tracks; a bare track item is itself (no expansion). // host inline tracks; a bare track item is itself (no expansion).
can_expand: matches!(item.item_type, ItemType::Album | ItemType::Playlist), can_expand: matches!(item.item_type, ItemType::Album | ItemType::Playlist),
tracks_loaded: false, // Loaded/tracks come from the per-item cache (above) so the re-derive
// keeps previously-resolved tracks instead of re-fetching.
tracks_loaded,
expand_loading: false, expand_loading: false,
inline_tracks: ModelRc::new(VecModel::from(Vec::<TrackItem>::new())), inline_tracks: ModelRc::new(VecModel::from(cached_tracks)),
} }
} }
@@ -444,10 +478,16 @@ fn full_item_by_source_id(source_item_id: &str) -> Option<MixtapeCollectionItem>
}) })
} }
/// "m:ss" track duration (spec 12 §8 `formatSec`, the common positive case; /// "m:ss" track duration (spec 12 §8 `formatSec`). A zero/missing duration
/// the inline resolver always yields a concrete `duration_secs`). /// renders the placeholder "--:--" (NOT "0:00") so an unresolved length reads as
/// unknown, matching the Tauri formatter. (`duration_secs` is `u64`, so the
/// "negative" case collapses to the zero case.)
fn track_duration_str(secs: u64) -> String { fn track_duration_str(secs: u64) -> String {
format!("{}:{:02}", secs / 60, secs % 60) if secs == 0 {
"--:--".to_string()
} else {
format!("{}:{:02}", secs / 60, secs % 60)
}
} }
/// Title + parenthesized Qobuz version suffix (spec 12 §8 `formatTrackTitle`). /// Title + parenthesized Qobuz version suffix (spec 12 §8 `formatTrackTitle`).
@@ -463,7 +503,14 @@ fn inline_track_title(track: &qbz_models::QueueTrack) -> String {
/// now-playing + album-row badges (24-bit+ = Hi-Res), so the inline badge /// now-playing + album-row badges (24-bit+ = Hi-Res), so the inline badge
/// matches every other surface. `source` drives the per-source `TrackRow` /// matches every other surface. `source` drives the per-source `TrackRow`
/// affordances (Plex/local rows hide the favorite + offline columns). /// affordances (Plex/local rows hide the favorite + offline columns).
fn track_to_item(track: &qbz_models::QueueTrack) -> TrackItem { ///
/// `resolver_index` is the 0-based position of this track in the resolver's
/// output. The resolved `QueueTrack` carries no explicit album track number, so
/// the displayed number is the resolver's order (1-based) — i.e. "use the
/// resolver's track number when present", which for this model is the resolved
/// sequence position. (`TrackRow` would otherwise index-fall-back, but baking
/// the number here keeps the row number correct regardless of the caller.)
fn track_to_item(track: &qbz_models::QueueTrack, resolver_index: usize) -> TrackItem {
let quality_tier = match track.bit_depth { let quality_tier = match track.bit_depth {
Some(d) if d >= 24 => "hires", Some(d) if d >= 24 => "hires",
Some(_) => "cd", Some(_) => "cd",
@@ -482,7 +529,7 @@ fn track_to_item(track: &qbz_models::QueueTrack) -> TrackItem {
TrackItem { TrackItem {
id: track.id.to_string().into(), id: track.id.to_string().into(),
number: String::new().into(), number: (resolver_index + 1).to_string().into(),
title: inline_track_title(track).into(), title: inline_track_title(track).into(),
artist: track.artist.clone().into(), artist: track.artist.clone().into(),
album: String::new().into(), album: String::new().into(),
@@ -538,22 +585,25 @@ pub fn ensure_expanded(
let Some(window) = weak.upgrade() else { return }; let Some(window) = weak.upgrade() else { return };
let model = window.global::<MyQbzDetailState>().get_items(); let model = window.global::<MyQbzDetailState>().get_items();
// Snapshot the rows that still need a fetch (source-item-ids), then mark // Snapshot the rows that still need a fetch (source + source-item-id), then
// them loading in one pass (mutating the model while iterating is fine — // mark them loading in one pass (mutating the model while iterating is fine
// we set_row_data the same index we read). // — we set_row_data the same index we read). `tracks_loaded` rows are
let mut pending: Vec<String> = Vec::new(); // skipped: the cache already re-hydrated them in `to_item`, so a re-derive
// is instant (no re-fetch).
let mut pending: Vec<(String, String)> = Vec::new();
for i in 0..model.row_count() { for i in 0..model.row_count() {
if let Some(mut it) = model.row_data(i) { if let Some(mut it) = model.row_data(i) {
if it.can_expand && !it.tracks_loaded && !it.expand_loading { if it.can_expand && !it.tracks_loaded && !it.expand_loading {
it.expand_loading = true; it.expand_loading = true;
let source = it.source.to_string();
let id = it.source_item_id.to_string(); let id = it.source_item_id.to_string();
model.set_row_data(i, it); model.set_row_data(i, it);
pending.push(id); pending.push((source, id));
} }
} }
} }
for source_item_id in pending { for (source, source_item_id) in pending {
let Some(full_item) = full_item_by_source_id(&source_item_id) else { let Some(full_item) = full_item_by_source_id(&source_item_id) else {
// No backing item (shouldn't happen) — clear the spinner. // No backing item (shouldn't happen) — clear the spinner.
with_row_by_source_id(&window, &source_item_id, |it| it.expand_loading = false); with_row_by_source_id(&window, &source_item_id, |it| it.expand_loading = false);
@@ -567,7 +617,19 @@ pub fn ensure_expanded(
// loop, not moved across the thread boundary. // loop, not moved across the thread boundary.
let tracks = crate::myqbz_play::fetch_item_tracks(&runtime, &full_item).await; let tracks = crate::myqbz_play::fetch_item_tracks(&runtime, &full_item).await;
let _ = weak.upgrade_in_event_loop(move |w| { let _ = weak.upgrade_in_event_loop(move |w| {
let items: Vec<TrackItem> = tracks.iter().map(track_to_item).collect(); let items: Vec<TrackItem> = tracks
.iter()
.enumerate()
.map(|(i, t)| track_to_item(t, i))
.collect();
// Persist into the controller-level cache (keyed
// `source|source_item_id`) so a later filter/sort/search
// re-derive re-hydrates this row from the cache instead of
// re-fetching (spec 12 §8 — cache survives the re-derive).
let cache_key = inline_cache_key(&source, &source_item_id);
INLINE_CACHE.with(|cell| {
cell.borrow_mut().insert(cache_key, items.clone());
});
with_row_by_source_id(&w, &source_item_id, |it| { with_row_by_source_id(&w, &source_item_id, |it| {
it.expand_loading = false; it.expand_loading = false;
it.tracks_loaded = true; it.tracks_loaded = true;
@@ -600,6 +662,9 @@ pub fn clear_selection(window: &AppWindow) {
/// not flash the previous collection's hero + rows). /// not flash the previous collection's hero + rows).
pub fn reset(window: &AppWindow) { pub fn reset(window: &AppWindow) {
FULL_ITEMS.with(|cell| cell.borrow_mut().clear()); FULL_ITEMS.with(|cell| cell.borrow_mut().clear());
// Drop the inline-tracks cache — a different collection's tracks must not
// leak into the freshly-opened one.
INLINE_CACHE.with(|cell| cell.borrow_mut().clear());
let state = window.global::<MyQbzDetailState>(); let state = window.global::<MyQbzDetailState>();
state.set_loading(true); state.set_loading(true);
state.set_found(true); state.set_found(true);
+134
View File
@@ -372,6 +372,140 @@ pub fn item_action(
}); });
} }
/// **Bulk** enqueue for the detail select-mode bulk bar (spec 12 §13.1 Add to
/// queue / Play next). Resolves EACH selected `MixtapeCollectionItem` through
/// the same `ProdItemResolver` the per-row path uses (so Qobuz albums/tracks/
/// playlists + local/Plex all resolve), flattens them in selection order, then:
/// - **play_next = true**: insert the whole batch immediately after the current
/// track, in REVERSE so the first resolved track lands first (same rule as the
/// per-row `PlayNext`).
/// - **play_next = false**: append the batch at the end of the queue.
///
/// Never replaces the queue and never stamps the queue-source collection
/// (append/play-next preserve context, mirroring the per-row contract). Items
/// that resolve to nothing are logged + skipped; an all-empty batch toasts.
pub fn bulk_enqueue(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
items: Vec<MixtapeCollectionItem>,
play_next: bool,
) {
if items.is_empty() {
return;
}
handle.spawn(async move {
let client_lock = runtime.core().client();
let client = {
let guard = client_lock.read().await;
match guard.as_ref() {
Some(c) => c.clone(),
None => {
log::warn!("[qbz-slint] myqbz_play: no Qobuz client; cannot bulk-enqueue");
crate::toast::error_weak(&weak, "These items resolved to 0 playable tracks");
return;
}
}
};
let resolver = ProdItemResolver::new(&client, resolve_local);
// Resolve each item in selection order, stamping the per-item boundary
// hint inline (this path bypasses resolve_collection_tracks).
use qbz_mixtape::enqueue::ItemResolver;
let mut tracks: Vec<QueueTrack> = Vec::new();
for item in &items {
match resolver.resolve(item).await {
Ok(mut resolved) => {
let hint = item.source_item_id.clone();
for t in &mut resolved {
t.source_item_id_hint = Some(hint.clone());
}
tracks.extend(resolved);
}
Err(e) => {
log::warn!(
"[qbz-slint] myqbz_play: bulk item {}/{} resolve failed: {}",
item.source_item_id,
item.title,
e
);
}
}
}
if tracks.is_empty() {
crate::toast::error_weak(&weak, "These items resolved to 0 playable tracks");
return;
}
if play_next {
// REVERSE so the first resolved track lands immediately after the
// current track (spec §9.8).
for track in tracks.into_iter().rev() {
runtime.core().add_track_next(track).await;
}
refresh_sidebar(false);
crate::toast::success_weak(&weak, "Playing next");
} else {
runtime.core().add_tracks(tracks).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, "Added to queue");
}
});
}
/// Resolve the selected items' Qobuz track IDs for the bulk "Add to playlist"
/// flow (spec 12 §13.1). Qobuz playlists only accept Qobuz track ids, so each
/// item is resolved and only `source == "qobuz"` tracks contribute their ids
/// (local/Plex tracks are skipped — same constraint the Local Library bulk
/// add-to-playlist applies). Returns the ids in resolution order; an empty
/// result means nothing playable-to-a-Qobuz-playlist was selected.
pub async fn resolve_bulk_qobuz_track_ids(
runtime: &Runtime,
items: &[MixtapeCollectionItem],
) -> Vec<String> {
use qbz_mixtape::enqueue::ItemResolver;
let client_lock = runtime.core().client();
let client = {
let guard = client_lock.read().await;
match guard.as_ref() {
Some(c) => c.clone(),
None => {
log::warn!("[qbz-slint] myqbz_play: no Qobuz client; cannot resolve bulk ids");
return Vec::new();
}
}
};
let resolver = ProdItemResolver::new(&client, resolve_local);
let mut ids: Vec<String> = Vec::new();
for item in items {
match resolver.resolve(item).await {
Ok(tracks) => {
for t in tracks {
// Qobuz-only: a local/Plex track id is not a Qobuz playlist
// member. `source` is the resolver's per-track stamp.
let is_qobuz = t.source.as_deref() == Some("qobuz")
|| (t.source.is_none() && !t.is_local);
if is_qobuz {
ids.push(t.id.to_string());
}
}
}
Err(e) => {
log::warn!(
"[qbz-slint] myqbz_play: bulk add-to-playlist resolve {}/{} failed: {}",
item.source_item_id,
item.title,
e
);
}
}
}
ids
}
/// The inline-track menu mode (expanded view-mode TrackRow actions, spec §8 /// The inline-track menu mode (expanded view-mode TrackRow actions, spec §8
/// `menuActions`): play-now / play-next / play-later for ONE track resolved /// `menuActions`): play-now / play-next / play-later for ONE track resolved
/// from its parent item. (go-to-album routes through `open-item` in main.rs, /// from its parent item. (go-to-album routes through `open-item` in main.rs,
@@ -523,12 +523,17 @@ export component DiscographyBuilderView inherits Rectangle {
spacing: 4px; spacing: 4px;
horizontal-stretch: 1; horizontal-stretch: 1;
Text { Text {
text: @tr("Build Artist Collection"); // Eyebrow is uppercased (spec 13 §0: eyebrow/field-
// label/col-label use text-transform:uppercase). Slint
// has no text-transform, so the literal is written in
// caps (same idiom as the other MyQBZ eyebrows —
// AddToMixtapeModal "ADD TO MIXTAPE/COLLECTION", the
// grid card label from Rust kind_label).
text: @tr("BUILD ARTIST COLLECTION");
color: D.text-muted; color: D.text-muted;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
letter-spacing: 1.5px; letter-spacing: 1.5px;
// uppercase via the literal (en already caps the eyebrow).
} }
Text { Text {
text: DiscoBuilderState.artist-name == "" text: DiscoBuilderState.artist-name == ""
@@ -585,7 +590,9 @@ export component DiscographyBuilderView inherits Rectangle {
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
Text { Text {
text: @tr("Order by"); // .field-label is uppercased (spec §2.6 — same
// text-transform:uppercase as the eyebrow / NAME label).
text: @tr("ORDER BY");
color: D.text-muted; color: D.text-muted;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@@ -743,30 +750,31 @@ export component DiscographyBuilderView inherits Rectangle {
} }
} }
} }
// [1..6] column labels. // [1..6] column labels — uppercased (spec §2.9
// header-row text-transform:uppercase; literal caps).
Rectangle { Rectangle {
width: 56px; width: 56px;
Text { text: @tr("Year"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: left; } Text { text: @tr("YEAR"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: left; }
} }
Rectangle { Rectangle {
width: 72px; width: 72px;
Text { text: @tr("Type"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: left; } Text { text: @tr("TYPE"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: left; }
} }
Rectangle { Rectangle {
horizontal-stretch: 1; horizontal-stretch: 1;
Text { text: @tr("Album"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; horizontal-alignment: left; } Text { text: @tr("ALBUM"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; horizontal-alignment: left; }
} }
Rectangle { Rectangle {
width: 72px; width: 72px;
Text { text: @tr("Tracks"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; } Text { text: @tr("TRACKS"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; }
} }
Rectangle { Rectangle {
width: 60px; width: 60px;
Text { text: @tr("Source"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; } Text { text: @tr("SOURCE"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; }
} }
Rectangle { Rectangle {
width: 156px; width: 156px;
Text { text: @tr("Quality"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; } Text { text: @tr("QUALITY"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; }
} }
} }
} }
@@ -825,10 +833,14 @@ export component DiscographyBuilderView inherits Rectangle {
HorizontalLayout { HorizontalLayout {
spacing: 8px; spacing: 8px;
alignment: end; alignment: end;
// disabled = creating || selectedCount===0 ||
// !collectionName.trim() (spec §2.13 / §10.19). The trim is
// computed in the controller (Slint can't trim) and exposed
// as `name-trimmed-empty`, so a whitespace-only name disables.
property <bool> create-disabled: property <bool> create-disabled:
DiscoBuilderState.creating DiscoBuilderState.creating
|| DiscoBuilderState.selected-count == 0 || DiscoBuilderState.selected-count == 0
|| DiscoBuilderState.collection-name == ""; || DiscoBuilderState.name-trimmed-empty;
// Cancel (secondary). // Cancel (secondary).
SecondaryButton { SecondaryButton {
label: @tr("Cancel"); label: @tr("Cancel");
@@ -854,7 +854,8 @@ export component MixtapeDetailView inherits Rectangle {
clicked => { MyQbzDetailActions.shuffle(); } clicked => { MyQbzDetailActions.shuffle(); }
} }
} }
// (3) DJ-mix (DjIcon) — STUB click (modal is a later slice). // (3) DJ-mix (DjIcon) — opens the MyQbzMixModal (the
// "Random queue" sampler) via the dj-mix action.
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
CircleAction { CircleAction {
@@ -1386,10 +1387,13 @@ export component MixtapeDetailView inherits Rectangle {
// ===================== BULK ACTION BAR ============================= // ===================== BULK ACTION BAR =============================
// Spec 12 §13: a bottom-docked bar that appears while items are selected // Spec 12 §13: a bottom-docked bar that appears while items are selected
// (placement='bottom' default). Mirrors the local-library MultiSelectBar // (placement='bottom' default). Mirrors the local-library MultiSelectBar
// chrome. The action set in THIS view (§13.1, minus the split-queue and // chrome (a flat [BulkAction] row — the §13.1 split-queue dropdown isn't
// add-to-playlist/favorites buttons not threaded by this slice): Add to // expressible in MultiSelectBar, so its two actions are flattened to the
// Mixtape/Collection + Remove from this collection (danger). The bar emits // adjacent "Add to queue" + "Play next" buttons, same idiom the Local
// the id; the Rust side routes it (open AddToMixtapeModal / remove + reload). // Library tracks bar uses). The §13.1 action set, in order: Add to queue
// (play-later), Play next, Add to playlist, Add to Mixtape/Collection,
// Remove from this collection (danger), Clear (exit-select). The bar emits
// the id; the Rust side routes it.
if MyQbzDetailState.select-mode && MyQbzDetailState.selected-count > 0: Rectangle { if MyQbzDetailState.select-mode && MyQbzDetailState.selected-count > 0: Rectangle {
x: 18px; x: 18px;
// Sit just above the fixed player bar (the scroller reserves 100px at // Sit just above the fixed player bar (the scroller reserves 100px at
@@ -1401,8 +1405,12 @@ export component MixtapeDetailView inherits Rectangle {
width: 100%; width: 100%;
selected-count: MyQbzDetailState.selected-count; selected-count: MyQbzDetailState.selected-count;
actions: [ actions: [
{ id: "add-to-queue", label: @tr("Add to queue"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), danger: false, needs-selection: true }, { id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove"), danger: true, needs-selection: true }, { id: "remove-selected", label: @tr("Remove"), danger: true, needs-selection: true },
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
]; ];
action(id) => { action(id) => {
MyQbzDetailActions.bulk-action(id); MyQbzDetailActions.bulk-action(id);
@@ -10,9 +10,10 @@
// hybrid_sample and replaces the queue. // hybrid_sample and replaces the queue.
// //
// Chrome mirrors MyQbzEditModal: an own backdrop (#000000bf) + a 440px panel, // Chrome mirrors MyQbzEditModal: an own backdrop (#000000bf) + a 440px panel,
// h2 title (18px bold), and a footer with secondary Cancel + a primary Shuffle. // h2 title (18px bold), and a footer with secondary Cancel + a primary
// Backdrop click + Cancel close without committing; Shuffle is disabled while // "Add to queue" button. Backdrop click + Cancel close without committing;
// loading / busy or when no size is available. // the primary action is disabled while loading / busy or when no size is
// available.
// //
// Per spec §23 the button labels are HARDCODED English in the Tauri source; we // Per spec §23 the button labels are HARDCODED English in the Tauri source; we
// keep them @tr here (translate hardcoded UI strings on port). // keep them @tr here (translate hardcoded UI strings on port).
@@ -29,8 +30,8 @@ import { SecondaryButton } from "../primitives/SecondaryButton.slint";
export component MyQbzMixModal inherits Rectangle { export component MyQbzMixModal inherits Rectangle {
visible: MyQbzMixState.open; visible: MyQbzMixState.open;
// Shuffle is allowed once a count is loaded (a non-empty options list) and // The primary "Add to queue" action is allowed once a count is loaded (a
// nothing is in flight. // non-empty options list) and nothing is in flight.
property <bool> can-shuffle: property <bool> can-shuffle:
!MyQbzMixState.loading !MyQbzMixState.loading
&& !MyQbzMixState.busy && !MyQbzMixState.busy
+5
View File
@@ -1797,6 +1797,11 @@ export global DiscoBuilderState {
// The collection-name input (defaulted to "<artist> — Complete Discography"). // The collection-name input (defaulted to "<artist> — Complete Discography").
in-out property <string> collection-name: ""; in-out property <string> collection-name: "";
// Whether the name is empty AFTER trimming whitespace (Slint can't trim, so
// the controller computes this on every name change). Gates the Create
// button so a whitespace-only name disables it (spec 13 §2.13 / §10.19:
// disabled = creating || selectedCount===0 || !collectionName.trim()).
in property <bool> name-trimmed-empty: true;
// Order segmented control: "release_date" | "title" | "manual". // Order segmented control: "release_date" | "title" | "manual".
in-out property <string> order-by: "release_date"; in-out property <string> order-by: "release_date";