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:
8 files changed
+349
-45
No files matched your search
@@ -2657,17 +2657,66 @@ fn wire_myqbz_detail(
|
||||
});
|
||||
}
|
||||
|
||||
// --- Bulk action bar (select-mode, spec 12 §13) --------------------
|
||||
// "add-to-mixtape" opens the global AddToMixtapeModal with the selected
|
||||
// items' payloads; "remove-selected" removes each selected position
|
||||
// (highest-first) then reloads the detail + clears selection.
|
||||
// --- Bulk action bar (select-mode, spec 12 §13.1) ------------------
|
||||
// The full §13.1 group set:
|
||||
// - "add-to-queue" / "play-next": resolve the selected items via the shared
|
||||
// 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 handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
let runtime = app_runtime.clone();
|
||||
window.global::<Act>().on_bulk_action(move |id| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
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" => {
|
||||
let selected = myqbz_detail::selected_full_items(&w);
|
||||
let items: Vec<myqbz_add::AddItem> = selected
|
||||
@@ -2696,6 +2745,11 @@ fn wire_myqbz_detail(
|
||||
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 => {
|
||||
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 };
|
||||
// go-to-album routes through the existing open-item path (Qobuz
|
||||
// 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" {
|
||||
w.global::<Act>()
|
||||
.invoke_open_item("".into(), "album".into(), item_source_item_id);
|
||||
let parent_type = {
|
||||
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;
|
||||
}
|
||||
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
||||
|
||||
@@ -663,6 +663,9 @@ pub fn reset(window: &AppWindow, artist_id: &str) {
|
||||
state.set_creating(false);
|
||||
state.set_open_type_menu_key("".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_artist_id(artist_id.into());
|
||||
state.set_artist_name("".into());
|
||||
@@ -711,6 +714,10 @@ pub fn install(
|
||||
};
|
||||
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);
|
||||
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
|
||||
/// 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) {
|
||||
window
|
||||
.global::<DiscoBuilderState>()
|
||||
.set_collection_name(name.into());
|
||||
let state = window.global::<DiscoBuilderState>();
|
||||
state.set_collection_name(name.into());
|
||||
state.set_name_trimmed_empty(name.trim().is_empty());
|
||||
}
|
||||
|
||||
/// Apply a release-type override (persisted) + re-render.
|
||||
|
||||
@@ -40,6 +40,26 @@ thread_local! {
|
||||
/// only (mirrors `playlist::FULL_ITEMS`).
|
||||
static FULL_ITEMS: std::cell::RefCell<Vec<MixtapeCollectionItem>> =
|
||||
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 ────────────────────────────
|
||||
@@ -153,6 +173,18 @@ fn to_item(item: &MixtapeCollectionItem) -> MixtapeDetailItem {
|
||||
.map(|u| crate::myqbz::small_qobuz_url(u, 50))
|
||||
.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 {
|
||||
position: item.position,
|
||||
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
|
||||
// host inline tracks; a bare track item is itself (no expansion).
|
||||
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,
|
||||
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;
|
||||
/// the inline resolver always yields a concrete `duration_secs`).
|
||||
/// "m:ss" track duration (spec 12 §8 `formatSec`). A zero/missing duration
|
||||
/// 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 {
|
||||
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`).
|
||||
@@ -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
|
||||
/// matches every other surface. `source` drives the per-source `TrackRow`
|
||||
/// 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 {
|
||||
Some(d) if d >= 24 => "hires",
|
||||
Some(_) => "cd",
|
||||
@@ -482,7 +529,7 @@ fn track_to_item(track: &qbz_models::QueueTrack) -> TrackItem {
|
||||
|
||||
TrackItem {
|
||||
id: track.id.to_string().into(),
|
||||
number: String::new().into(),
|
||||
number: (resolver_index + 1).to_string().into(),
|
||||
title: inline_track_title(track).into(),
|
||||
artist: track.artist.clone().into(),
|
||||
album: String::new().into(),
|
||||
@@ -538,22 +585,25 @@ pub fn ensure_expanded(
|
||||
let Some(window) = weak.upgrade() else { return };
|
||||
let model = window.global::<MyQbzDetailState>().get_items();
|
||||
|
||||
// Snapshot the rows that still need a fetch (source-item-ids), then mark
|
||||
// them loading in one pass (mutating the model while iterating is fine —
|
||||
// we set_row_data the same index we read).
|
||||
let mut pending: Vec<String> = Vec::new();
|
||||
// Snapshot the rows that still need a fetch (source + source-item-id), then
|
||||
// mark them loading in one pass (mutating the model while iterating is fine
|
||||
// — we set_row_data the same index we read). `tracks_loaded` rows are
|
||||
// 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() {
|
||||
if let Some(mut it) = model.row_data(i) {
|
||||
if it.can_expand && !it.tracks_loaded && !it.expand_loading {
|
||||
it.expand_loading = true;
|
||||
let source = it.source.to_string();
|
||||
let id = it.source_item_id.to_string();
|
||||
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 {
|
||||
// No backing item (shouldn't happen) — clear the spinner.
|
||||
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.
|
||||
let tracks = crate::myqbz_play::fetch_item_tracks(&runtime, &full_item).await;
|
||||
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| {
|
||||
it.expand_loading = false;
|
||||
it.tracks_loaded = true;
|
||||
@@ -600,6 +662,9 @@ pub fn clear_selection(window: &AppWindow) {
|
||||
/// not flash the previous collection's hero + rows).
|
||||
pub fn reset(window: &AppWindow) {
|
||||
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>();
|
||||
state.set_loading(true);
|
||||
state.set_found(true);
|
||||
|
||||
@@ -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
|
||||
/// `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,
|
||||
|
||||
@@ -523,12 +523,17 @@ export component DiscographyBuilderView inherits Rectangle {
|
||||
spacing: 4px;
|
||||
horizontal-stretch: 1;
|
||||
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;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.5px;
|
||||
// uppercase via the literal (en already caps the eyebrow).
|
||||
}
|
||||
Text {
|
||||
text: DiscoBuilderState.artist-name == ""
|
||||
@@ -585,7 +590,9 @@ export component DiscographyBuilderView inherits Rectangle {
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
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;
|
||||
font-size: 10px;
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
spacing: 8px;
|
||||
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:
|
||||
DiscoBuilderState.creating
|
||||
|| DiscoBuilderState.selected-count == 0
|
||||
|| DiscoBuilderState.collection-name == "";
|
||||
|| DiscoBuilderState.name-trimmed-empty;
|
||||
// Cancel (secondary).
|
||||
SecondaryButton {
|
||||
label: @tr("Cancel");
|
||||
|
||||
@@ -854,7 +854,8 @@ export component MixtapeDetailView inherits Rectangle {
|
||||
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 {
|
||||
alignment: center;
|
||||
CircleAction {
|
||||
@@ -1386,10 +1387,13 @@ export component MixtapeDetailView inherits Rectangle {
|
||||
// ===================== BULK ACTION BAR =============================
|
||||
// Spec 12 §13: a bottom-docked bar that appears while items are selected
|
||||
// (placement='bottom' default). Mirrors the local-library MultiSelectBar
|
||||
// chrome. The action set in THIS view (§13.1, minus the split-queue and
|
||||
// add-to-playlist/favorites buttons not threaded by this slice): Add to
|
||||
// Mixtape/Collection + Remove from this collection (danger). The bar emits
|
||||
// the id; the Rust side routes it (open AddToMixtapeModal / remove + reload).
|
||||
// chrome (a flat [BulkAction] row — the §13.1 split-queue dropdown isn't
|
||||
// expressible in MultiSelectBar, so its two actions are flattened to the
|
||||
// adjacent "Add to queue" + "Play next" buttons, same idiom the Local
|
||||
// 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 {
|
||||
x: 18px;
|
||||
// Sit just above the fixed player bar (the scroller reserves 100px at
|
||||
@@ -1401,8 +1405,12 @@ export component MixtapeDetailView inherits Rectangle {
|
||||
width: 100%;
|
||||
selected-count: MyQbzDetailState.selected-count;
|
||||
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: "remove-selected", label: @tr("Remove"), danger: true, needs-selection: true },
|
||||
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
|
||||
];
|
||||
action(id) => {
|
||||
MyQbzDetailActions.bulk-action(id);
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
// hybrid_sample and replaces the queue.
|
||||
//
|
||||
// Chrome mirrors MyQbzEditModal: an own backdrop (#000000bf) + a 440px panel,
|
||||
// h2 title (18px bold), and a footer with secondary Cancel + a primary Shuffle.
|
||||
// Backdrop click + Cancel close without committing; Shuffle is disabled while
|
||||
// loading / busy or when no size is available.
|
||||
// h2 title (18px bold), and a footer with secondary Cancel + a primary
|
||||
// "Add to queue" button. Backdrop click + Cancel close without committing;
|
||||
// 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
|
||||
// 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 {
|
||||
visible: MyQbzMixState.open;
|
||||
|
||||
// Shuffle is allowed once a count is loaded (a non-empty options list) and
|
||||
// nothing is in flight.
|
||||
// The primary "Add to queue" action is allowed once a count is loaded (a
|
||||
// non-empty options list) and nothing is in flight.
|
||||
property <bool> can-shuffle:
|
||||
!MyQbzMixState.loading
|
||||
&& !MyQbzMixState.busy
|
||||
|
||||
@@ -1797,6 +1797,11 @@ export global DiscoBuilderState {
|
||||
|
||||
// The collection-name input (defaulted to "<artist> — Complete Discography").
|
||||
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".
|
||||
in-out property <string> order-by: "release_date";
|
||||
|
||||
Reference in new issue
Block a user