diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index eee42d59f..b38c5f4f7 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -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::().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 = 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::() - .invoke_open_item("".into(), "album".into(), item_source_item_id); + let parent_type = { + let model = w.global::().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::().invoke_open_item( + "".into(), + parent_type.into(), + item_source_item_id, + ); return; } let id = w.global::().get_id().to_string(); diff --git a/crates/qbz-slint/src/myqbz_builder.rs b/crates/qbz-slint/src/myqbz_builder.rs index 4661776e2..c8d0b6eda 100644 --- a/crates/qbz-slint/src/myqbz_builder.rs +++ b/crates/qbz-slint/src/myqbz_builder.rs @@ -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::() - .set_collection_name(name.into()); + let state = window.global::(); + state.set_collection_name(name.into()); + state.set_name_trimmed_empty(name.trim().is_empty()); } /// Apply a release-type override (persisted) + re-render. diff --git a/crates/qbz-slint/src/myqbz_detail.rs b/crates/qbz-slint/src/myqbz_detail.rs index 29defabf0..0a30e7a74 100644 --- a/crates/qbz-slint/src/myqbz_detail.rs +++ b/crates/qbz-slint/src/myqbz_detail.rs @@ -40,6 +40,26 @@ thread_local! { /// only (mirrors `playlist::FULL_ITEMS`). static FULL_ITEMS: std::cell::RefCell> = 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` 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::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::::new())), + inline_tracks: ModelRc::new(VecModel::from(cached_tracks)), } } @@ -444,10 +478,16 @@ fn full_item_by_source_id(source_item_id: &str) -> Option }) } -/// "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::().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 = 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 = tracks.iter().map(track_to_item).collect(); + let items: Vec = 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::(); state.set_loading(true); state.set_found(true); diff --git a/crates/qbz-slint/src/myqbz_play.rs b/crates/qbz-slint/src/myqbz_play.rs index 3ea14f9cc..fe1047258 100644 --- a/crates/qbz-slint/src/myqbz_play.rs +++ b/crates/qbz-slint/src/myqbz_play.rs @@ -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, + handle: tokio::runtime::Handle, + items: Vec, + 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 = 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 { + 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 = 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, diff --git a/crates/qbz-slint/ui/myqbz/DiscographyBuilderView.slint b/crates/qbz-slint/ui/myqbz/DiscographyBuilderView.slint index 245dcc790..6ddc37875 100644 --- a/crates/qbz-slint/ui/myqbz/DiscographyBuilderView.slint +++ b/crates/qbz-slint/ui/myqbz/DiscographyBuilderView.slint @@ -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 create-disabled: DiscoBuilderState.creating || DiscoBuilderState.selected-count == 0 - || DiscoBuilderState.collection-name == ""; + || DiscoBuilderState.name-trimmed-empty; // Cancel (secondary). SecondaryButton { label: @tr("Cancel"); diff --git a/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint b/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint index e45685c66..52a76e163 100644 --- a/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint +++ b/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint @@ -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); diff --git a/crates/qbz-slint/ui/myqbz/MyQbzMixModal.slint b/crates/qbz-slint/ui/myqbz/MyQbzMixModal.slint index 98ea6b0cb..2d7721e3d 100644 --- a/crates/qbz-slint/ui/myqbz/MyQbzMixModal.slint +++ b/crates/qbz-slint/ui/myqbz/MyQbzMixModal.slint @@ -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 can-shuffle: !MyQbzMixState.loading && !MyQbzMixState.busy diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 42ae1a4e7..6cbfb1766 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -1797,6 +1797,11 @@ export global DiscoBuilderState { // The collection-name input (defaulted to " — Complete Discography"). in-out property 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 name-trimmed-empty: true; // Order segmented control: "release_date" | "title" | "manual". in-out property order-by: "release_date";