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) --------------------
|
// --- 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();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in new issue
Block a user