feat(myqbz): DJ-mix modal + detail multi-select bulk bar
Phase-2 Slice 10. - ui/myqbz/MyQbzMixModal.slint + src/myqbz_mix.rs: the DJ-mix "random queue" modal. Open resolves the collection in-order + shuffle::unique_track_count (deterministic) for the slider max (buildSizeOptions). Shuffle re-resolves, runs dedup_by_similarity + hybrid_sample with a thread RNG kept in a SYNC scope (dropped before the next .await -- !Send confinement), then replace-plays + stamps queue-source + touch_play; toasts "Playing N of M" when the per-album cap shrinks the pool. Wired on_dj_mix. - Detail bulk bar (MultiSelectBar, bottom-docked): add-selected -> AddToMixtapeModal with the selected payloads; remove-selected -> repo::remove_item (highest position first so compaction does not invalidate pending positions) -> reload + clear selection + toast. Deferred to polish: the bulk bar's queue / add-to-playlist / clear-X groups (§13.1). Backend crates untouched. cargo check -p qbz-slint green. cc
This commit is contained in:
12 files changed
+739
-17
No files matched your search
Generated
+1
@@ -5495,6 +5495,7 @@ dependencies = [
|
||||
"qbz-qobuz",
|
||||
"qconnect-app",
|
||||
"qconnect-transport-ws",
|
||||
"rand 0.10.1",
|
||||
"reqwest",
|
||||
"rfd",
|
||||
"rusqlite",
|
||||
|
||||
@@ -59,6 +59,9 @@ qbz-mixtape = { path = "../qbz-mixtape" }
|
||||
qbz-qobuz = { path = "../qbz-qobuz" }
|
||||
qbz-integrations = { path = "../qbz-integrations" }
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
# DJ-mix sampler thread RNG (rand::rng()), confined to a sync scope — the
|
||||
# same crate qbz-mixtape::shuffle is generic over (spec 40 §6).
|
||||
rand = { workspace = true }
|
||||
qbz-cache = { path = "../qbz-cache" }
|
||||
qbz-credentials = { path = "../qbz-credentials" }
|
||||
|
||||
|
||||
+101
-11
@@ -40,6 +40,7 @@ mod myqbz_add;
|
||||
mod myqbz_cover;
|
||||
mod myqbz_detail;
|
||||
mod myqbz_edit;
|
||||
mod myqbz_mix;
|
||||
mod myqbz_play;
|
||||
mod nav;
|
||||
mod play_history;
|
||||
@@ -2458,25 +2459,114 @@ fn wire_myqbz_detail(
|
||||
});
|
||||
}
|
||||
|
||||
// --- STILL-STUBBED hero CTAs (DJ-mix modal + discography sync) -------
|
||||
// DJ-mix: the TrackMixModal is a later slice — keep the existing stub.
|
||||
// --- Hero DJ-mix CTA — open the "Random queue" sampler modal --------
|
||||
// Resolves the collection in-order + counts unique tracks (the slider max),
|
||||
// then the modal samples + replace-plays on confirm (myqbz_mix).
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window.global::<Act>().on_dj_mix(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
||||
if id.is_empty() {
|
||||
return;
|
||||
}
|
||||
myqbz_mix::open(runtime.clone(), weak.clone(), handle.clone(), id);
|
||||
});
|
||||
}
|
||||
|
||||
// --- STILL-STUBBED hero CTA: discography sync -----------------------
|
||||
// Sync: artist_discography has NO sync impl (spec §8) — no-op stub (the
|
||||
// hero button is shown only for artist_collection for Tauri parity).
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let log_stub = move |what: &str| {
|
||||
window.global::<Act>().on_sync(move || {
|
||||
let id = weak
|
||||
.upgrade()
|
||||
.map(|w| w.global::<MyQbzDetailState>().get_id().to_string())
|
||||
.unwrap_or_default();
|
||||
log::info!(
|
||||
"[qbz-slint] myqbz_detail {what}({id}) — not yet wired (later slice / no impl)"
|
||||
);
|
||||
};
|
||||
let s = log_stub.clone();
|
||||
window.global::<Act>().on_dj_mix(move || s("dj-mix"));
|
||||
let s = log_stub.clone();
|
||||
window.global::<Act>().on_sync(move || s("sync"));
|
||||
log::info!("[qbz-slint] myqbz_detail sync({id}) — no discography sync impl (spec §8)");
|
||||
});
|
||||
}
|
||||
|
||||
// --- DJ-mix modal actions (slider / cancel / confirm) ---------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<MyQbzMixActions>().on_close(move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
myqbz_mix::close(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<MyQbzMixActions>().on_set_index(move |index| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
myqbz_mix::apply_index(&w, index);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window.global::<MyQbzMixActions>().on_shuffle(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let ms = w.global::<MyQbzMixState>();
|
||||
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
||||
let size = ms.get_selected_size();
|
||||
if id.is_empty() || size <= 0 {
|
||||
return;
|
||||
}
|
||||
myqbz_mix::shuffle(runtime.clone(), weak.clone(), handle.clone(), id, size);
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<Act>().on_bulk_action(move |id| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
match id.as_str() {
|
||||
"add-to-mixtape" => {
|
||||
let selected = myqbz_detail::selected_full_items(&w);
|
||||
let items: Vec<myqbz_add::AddItem> = selected
|
||||
.iter()
|
||||
.map(|it| myqbz_add::AddItem {
|
||||
item_type: myqbz_detail::item_type_str(it.item_type).to_string(),
|
||||
source: myqbz_detail::source_str(it.source).to_string(),
|
||||
source_item_id: it.source_item_id.clone(),
|
||||
title: it.title.clone(),
|
||||
subtitle: it.subtitle.clone(),
|
||||
artwork_url: it.artwork_url.clone(),
|
||||
year: it.year,
|
||||
track_count: it.track_count,
|
||||
})
|
||||
.collect();
|
||||
open_add_to_mixtape(weak.clone(), handle.clone(), items);
|
||||
}
|
||||
"remove-selected" => {
|
||||
let cid = w.global::<MyQbzDetailState>().get_id().to_string();
|
||||
let positions = myqbz_detail::selected_positions(&w);
|
||||
myqbz_edit::remove_selected(
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
image_cache.clone(),
|
||||
cid,
|
||||
positions,
|
||||
);
|
||||
}
|
||||
other => {
|
||||
log::warn!("[qbz-slint] myqbz_detail bulk-action: unknown id {other}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Hero overflow (⋯) menu — open the edit modals (spec 12 §10/§11) ---
|
||||
|
||||
@@ -85,14 +85,14 @@ fn play_mode_str(mode: CollectionPlayMode) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn source_str(source: AlbumSource) -> &'static str {
|
||||
pub fn source_str(source: AlbumSource) -> &'static str {
|
||||
match source {
|
||||
AlbumSource::Qobuz => "qobuz",
|
||||
AlbumSource::Local => "local",
|
||||
}
|
||||
}
|
||||
|
||||
fn item_type_str(t: ItemType) -> &'static str {
|
||||
pub fn item_type_str(t: ItemType) -> &'static str {
|
||||
match t {
|
||||
ItemType::Album => "album",
|
||||
ItemType::Track => "track",
|
||||
@@ -397,6 +397,49 @@ pub fn toggle_item_select(window: &AppWindow, position: i32) {
|
||||
state.set_selected_count(count);
|
||||
}
|
||||
|
||||
/// The set of currently-selected row positions (select-mode), read off the
|
||||
/// rendered item model. UI thread.
|
||||
pub fn selected_positions(window: &AppWindow) -> Vec<i32> {
|
||||
let model = window.global::<MyQbzDetailState>().get_items();
|
||||
(0..model.row_count())
|
||||
.filter_map(|i| model.row_data(i))
|
||||
.filter(|it| it.selected)
|
||||
.map(|it| it.position)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The full `MixtapeCollectionItem`s (with year / track_count) for the
|
||||
/// currently-selected positions, in ascending position order. Sourced from
|
||||
/// `FULL_ITEMS` (the slint `MixtapeDetailItem` carries only display text, not
|
||||
/// the numeric year/track_count the add payload needs). UI thread.
|
||||
pub fn selected_full_items(window: &AppWindow) -> Vec<MixtapeCollectionItem> {
|
||||
let mut positions = selected_positions(window);
|
||||
positions.sort_unstable();
|
||||
FULL_ITEMS.with(|cell| {
|
||||
let items = cell.borrow();
|
||||
positions
|
||||
.iter()
|
||||
.filter_map(|p| items.iter().find(|it| it.position == *p).cloned())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Clear the current selection (uncheck every row + zero the count), staying in
|
||||
/// select-mode. Used after a bulk action completes. UI thread.
|
||||
pub fn clear_selection(window: &AppWindow) {
|
||||
let state = window.global::<MyQbzDetailState>();
|
||||
let model = state.get_items();
|
||||
for i in 0..model.row_count() {
|
||||
if let Some(mut it) = model.row_data(i) {
|
||||
if it.selected {
|
||||
it.selected = false;
|
||||
model.set_row_data(i, it);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.set_selected_count(0);
|
||||
}
|
||||
|
||||
// ──────────────────────────── reset / apply ───────────────────────────
|
||||
|
||||
/// Clear the view to its loading state before a fresh load (so a re-open does
|
||||
|
||||
@@ -237,6 +237,59 @@ pub fn delete(
|
||||
});
|
||||
}
|
||||
|
||||
/// Bulk-remove the selected items from the collection (spec 12 §13.3). The
|
||||
/// positions are removed **highest-first** so each `repo::remove_item`'s
|
||||
/// position-compaction (spec 40 §3.10) never shifts a position we still have to
|
||||
/// delete. After the batch: reload the detail (re-fetches the now-compacted
|
||||
/// list), clear the selection, and toast "Removed {n}". A repo error per item is
|
||||
/// logged and the batch continues; only a hard DB-unavailable surfaces the
|
||||
/// "Failed to remove items" error.
|
||||
pub fn remove_selected(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
image_cache: ImageCache,
|
||||
id: String,
|
||||
mut positions: Vec<i32>,
|
||||
) {
|
||||
if id.is_empty() || positions.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Highest position first (descending) so compaction is harmless.
|
||||
positions.sort_unstable_by(|a, b| b.cmp(a));
|
||||
let count = positions.len();
|
||||
handle.clone().spawn(async move {
|
||||
let write_id = id.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
with_repo(|conn| {
|
||||
for pos in &positions {
|
||||
if let Err(e) = qbz_mixtape::repo::remove_item(conn, &write_id, *pos) {
|
||||
log::warn!(
|
||||
"[qbz-slint] myqbz_edit remove_item({write_id}, {pos}) failed: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("bulk-remove task panicked: {e}")));
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
crate::myqbz_detail::clear_selection(&w);
|
||||
});
|
||||
crate::toast::info_weak(&weak, format!("Removed {count}"));
|
||||
reload(&weak, &handle, &image_cache, id);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] myqbz_edit bulk-remove failed: {e}");
|
||||
crate::toast::error_weak(&weak, "Failed to remove items");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────── modal-state helpers ─────────────────────
|
||||
|
||||
/// On a successful mutation: close the modal (if any), reload, and (when given)
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! My QBZ — DJ-mix "Random queue" sampler (Phase-2 Slice 10).
|
||||
//!
|
||||
//! The Rust side of the [`MyQbzMixModal`] (spec 21 §C / spec 12 §12). Replaces
|
||||
//! Tauri's `v2_collection_unique_track_count` + `v2_collection_shuffle_tracks`
|
||||
//! commands (spec 40 §6) with direct calls into the shared `qbz-mixtape`
|
||||
//! shuffle pipeline + the `qbz-core` queue — no Tauri wrappers (ADR-005/006).
|
||||
//!
|
||||
//! ## Data path
|
||||
//!
|
||||
//! - **On open** ([`open`]): resolve the collection's items **in-order**
|
||||
//! (`play_mode` is ignored for DJ-mix, spec §4.B — always InOrder) via the
|
||||
//! shared resolver reused from [`crate::myqbz_play::resolve_collection`], then
|
||||
//! run the DETERMINISTIC [`qbz_mixtape::shuffle::unique_track_count`] (no RNG)
|
||||
//! to get the slider's max. The discrete size set is [`build_size_options`]
|
||||
//! (50,100,150,…,All(N)); the slider indexes into it. A "Loading…" state shows
|
||||
//! while resolving.
|
||||
//! - **On shuffle** ([`shuffle`]): re-resolve in-order, then — in a SYNC scope
|
||||
//! that ends BEFORE any `.await` — run [`qbz_mixtape::shuffle::dedup_by_similarity`]
|
||||
//! + [`qbz_mixtape::shuffle::hybrid_sample`] with `rand::rng()` (the thread
|
||||
//! RNG). The sampled queue is then handed to
|
||||
//! [`crate::myqbz_play::play_all_tracks`] (replace + start at 0 + stamp the
|
||||
//! queue-source collection + best-effort `touch_play`). When the sampled
|
||||
//! `actual` count is below the requested size (the per-album cap can shrink the
|
||||
//! pool, spec §9.16) a "Playing N of M" info toast is shown.
|
||||
//!
|
||||
//! ## RNG confinement (load-bearing, spec 40 §6)
|
||||
//!
|
||||
//! `rand::rng()` returns a `ThreadRng`, which is `!Send`. Holding it across an
|
||||
//! `.await` would make the spawned future non-`Send` and fail to compile. So the
|
||||
//! resolve (`.await`) happens FIRST into an owned `Vec<QueueTrack>`; the
|
||||
//! dedup+sample then run inside a plain synchronous block `{ … }` that creates,
|
||||
//! uses, and DROPS the `ThreadRng` entirely before the next `.await`
|
||||
//! (`play_all_tracks`). The RNG never crosses an await point.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use qbz_models::QueueTrack;
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::adapter::SlintAdapter;
|
||||
use crate::{AppWindow, MyQbzMixState};
|
||||
use qbz_app::shell::AppRuntime;
|
||||
|
||||
type Runtime = Arc<AppRuntime<SlintAdapter>>;
|
||||
|
||||
/// Below this unique-count the only option is a single "All (N)" entry.
|
||||
const SMALL_THRESHOLD: i32 = 50;
|
||||
/// Intermediate options step (50, 100, 150, …).
|
||||
const STEP: i32 = 50;
|
||||
|
||||
/// Build the discrete size options for `unique_count` (spec 21 §C.4 /
|
||||
/// `buildSizeOptions`). The returned vec is the ordered size list; the LAST
|
||||
/// entry is ALWAYS the "All (unique_count)" option (so `index == len - 1` ⇒ the
|
||||
/// "All" entry).
|
||||
///
|
||||
/// - `unique_count <= 0` → `[]` (no options; the modal stays empty).
|
||||
/// - `unique_count < 50` → `[unique_count]` (one "All (N)" entry).
|
||||
/// - else → `[50,100,150,…]` for each `s < unique_count`, then a trailing
|
||||
/// `unique_count`. If `unique_count` is itself a multiple of 50 the loop stops
|
||||
/// `< unique_count`, so there is no duplicate intermediate entry
|
||||
/// (e.g. 100 → `[50, All(100)]`, NOT `[50, 100, All(100)]`).
|
||||
pub fn build_size_options(unique_count: i32) -> Vec<i32> {
|
||||
if unique_count <= 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
if unique_count < SMALL_THRESHOLD {
|
||||
return vec![unique_count];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut s = STEP;
|
||||
while s < unique_count {
|
||||
out.push(s);
|
||||
s += STEP;
|
||||
}
|
||||
out.push(unique_count); // trailing "All (N)".
|
||||
out
|
||||
}
|
||||
|
||||
/// Push the resolved size options into `MyQbzMixState` for `unique_count`,
|
||||
/// defaulting the selection to the FIRST option (= 50 for large collections, or
|
||||
/// the only "All (N)" entry for small ones). UI thread.
|
||||
fn apply_options(window: &AppWindow, unique_count: i32) {
|
||||
let options = build_size_options(unique_count);
|
||||
let state = window.global::<MyQbzMixState>();
|
||||
state.set_unique_count(unique_count);
|
||||
state.set_size_options(slint::ModelRc::new(slint::VecModel::from(options.clone())));
|
||||
state.set_loading(false);
|
||||
// Default selection = first option.
|
||||
apply_index(window, 0);
|
||||
}
|
||||
|
||||
/// Set the slider index and derive the selected size + is-all flag from the
|
||||
/// current options. Clamps `index` to the valid range. UI thread.
|
||||
pub fn apply_index(window: &AppWindow, index: i32) {
|
||||
use slint::Model;
|
||||
let state = window.global::<MyQbzMixState>();
|
||||
let options = state.get_size_options();
|
||||
let len = options.row_count() as i32;
|
||||
if len == 0 {
|
||||
state.set_selected_index(0);
|
||||
state.set_selected_size(0);
|
||||
state.set_selected_is_all(false);
|
||||
return;
|
||||
}
|
||||
let idx = index.clamp(0, len - 1);
|
||||
let size = options.row_data(idx as usize).unwrap_or(0);
|
||||
state.set_selected_index(idx);
|
||||
state.set_selected_size(size);
|
||||
// The trailing entry is always the "All (N)" option.
|
||||
state.set_selected_is_all(idx == len - 1);
|
||||
}
|
||||
|
||||
// ──────────────────────────── open / close ────────────────────────────
|
||||
|
||||
/// Open the DJ-mix modal for the collection currently shown in the detail view
|
||||
/// (`collection_id`). Shows the modal in a "computing…" state, then resolves the
|
||||
/// collection in-order on a worker + counts unique tracks (deterministic) and
|
||||
/// fills the slider. On a resolve failure the modal closes with an error toast.
|
||||
pub fn open(
|
||||
runtime: Runtime,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
collection_id: String,
|
||||
) {
|
||||
if collection_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Show the modal immediately in its loading state.
|
||||
{
|
||||
let weak = weak.clone();
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
let state = w.global::<MyQbzMixState>();
|
||||
state.set_loading(true);
|
||||
state.set_busy(false);
|
||||
state.set_unique_count(0);
|
||||
state.set_size_options(slint::ModelRc::new(slint::VecModel::from(Vec::<i32>::new())));
|
||||
state.set_selected_index(0);
|
||||
state.set_selected_size(0);
|
||||
state.set_selected_is_all(false);
|
||||
state.set_open(true);
|
||||
});
|
||||
}
|
||||
|
||||
handle.spawn(async move {
|
||||
let Some(collection) = crate::myqbz_play::load_collection(&collection_id).await else {
|
||||
close_with_error(&weak, "Couldn't load this collection");
|
||||
return;
|
||||
};
|
||||
// Always InOrder for DJ-mix (force_shuffle = false): the sampler does its
|
||||
// own randomization; the resolve only needs the full track pool.
|
||||
let tracks = crate::myqbz_play::resolve_collection(&runtime, &collection, false).await;
|
||||
// Deterministic count (no RNG) — the slider max + "All" size.
|
||||
let unique = qbz_mixtape::shuffle::unique_track_count(&tracks) as i32;
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
if unique <= 0 {
|
||||
// Nothing playable — close with a hint (mirrors the resolve-empty
|
||||
// toast on the play paths).
|
||||
close(&w);
|
||||
crate::toast::error(&w, "This collection resolved to 0 playable tracks");
|
||||
} else {
|
||||
apply_options(&w, unique);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Close the modal (UI thread hop, callable from any thread).
|
||||
fn close_with_error(weak: &slint::Weak<AppWindow>, msg: &'static str) {
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
close(&w);
|
||||
crate::toast::error(&w, msg);
|
||||
});
|
||||
}
|
||||
|
||||
/// Close the modal + clear its busy flag. UI thread.
|
||||
pub fn close(window: &AppWindow) {
|
||||
let state = window.global::<MyQbzMixState>();
|
||||
state.set_open(false);
|
||||
state.set_busy(false);
|
||||
state.set_loading(false);
|
||||
}
|
||||
|
||||
// ──────────────────────────── confirm / sample ────────────────────────
|
||||
|
||||
/// Confirm: sample `sample_size` songs from the collection and replace-play the
|
||||
/// queue. Re-resolves the collection in-order, runs dedup+sample with the thread
|
||||
/// RNG (confined to a sync scope — see the module doc), then replaces the queue
|
||||
/// (stamp queue-source + touch_play via [`crate::myqbz_play::play_all_tracks`]).
|
||||
/// `requested < actual` ⇒ a "Playing N of M" info toast (spec §9.16).
|
||||
pub fn shuffle(
|
||||
runtime: Runtime,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
collection_id: String,
|
||||
sample_size: i32,
|
||||
) {
|
||||
if collection_id.is_empty() || sample_size <= 0 {
|
||||
return;
|
||||
}
|
||||
// Disable the Shuffle button while in flight.
|
||||
{
|
||||
let weak = weak.clone();
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
w.global::<MyQbzMixState>().set_busy(true);
|
||||
});
|
||||
}
|
||||
|
||||
handle.spawn(async move {
|
||||
let Some(collection) = crate::myqbz_play::load_collection(&collection_id).await else {
|
||||
close_with_error(&weak, "Couldn't load this collection");
|
||||
return;
|
||||
};
|
||||
// Resolve in-order (await completes BEFORE the RNG is created).
|
||||
let resolved = crate::myqbz_play::resolve_collection(&runtime, &collection, false).await;
|
||||
|
||||
// ── RNG-confined sync scope (spec 40 §6): create, use, and DROP the
|
||||
// !Send ThreadRng entirely here — it never crosses the `.await` below.
|
||||
let requested = sample_size as usize;
|
||||
let sampled: Vec<QueueTrack> = {
|
||||
let mut rng = rand::rng();
|
||||
let deduped = qbz_mixtape::shuffle::dedup_by_similarity(resolved, &mut rng);
|
||||
qbz_mixtape::shuffle::hybrid_sample(deduped, requested, &mut rng)
|
||||
};
|
||||
// `rng` is dropped; from here on the future is `Send` again.
|
||||
|
||||
let actual = sampled.len();
|
||||
|
||||
// Close the modal before playback starts (mirrors handleConfirmMix).
|
||||
{
|
||||
let weak = weak.clone();
|
||||
let _ = weak.upgrade_in_event_loop(|w| close(&w));
|
||||
}
|
||||
|
||||
// Replace-play: set_queue + start at 0 + stamp queue-source + touch_play.
|
||||
crate::myqbz_play::play_all_tracks(&runtime, &weak, &collection_id, sampled).await;
|
||||
|
||||
// DJ-mix actualCount can be < requested (per-album cap) — surface it.
|
||||
if actual > 0 && actual < requested {
|
||||
crate::toast::info_weak(&weak, format!("Playing {actual} of {requested}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -90,7 +90,7 @@ fn resolve_local(item: &MixtapeCollectionItem) -> Result<Vec<QueueTrack>, String
|
||||
/// `resolve_collection_tracks`. `force_shuffle` overrides the persisted mode
|
||||
/// with `AlbumShuffle` (time-seeded whole-item shuffle) for the hero Shuffle
|
||||
/// CTA; otherwise the collection's persisted `play_mode` is used.
|
||||
async fn resolve_collection(
|
||||
pub(crate) async fn resolve_collection(
|
||||
runtime: &Runtime,
|
||||
collection: &MixtapeCollection,
|
||||
force_shuffle: bool,
|
||||
@@ -186,7 +186,7 @@ fn touch_play(collection_id: &str) {
|
||||
/// Replace the queue with `tracks`, start at index 0, stamp the queue-source
|
||||
/// collection, and `touch_play`. Shared by hero Play + hero Shuffle (the two
|
||||
/// whole-collection replace paths). Empty `tracks` → toast + no-op.
|
||||
async fn play_all_tracks(
|
||||
pub(crate) async fn play_all_tracks(
|
||||
runtime: &Runtime,
|
||||
weak: &slint::Weak<AppWindow>,
|
||||
collection_id: &str,
|
||||
@@ -329,7 +329,7 @@ pub fn item_action(
|
||||
/// Load a collection (items hydrated) off the UI/event-loop thread, on a
|
||||
/// blocking worker, reusing the detail module's read path. Returns `None` when
|
||||
/// the DB is unavailable or the id is unknown.
|
||||
async fn load_collection(collection_id: &str) -> Option<MixtapeCollection> {
|
||||
pub(crate) async fn load_collection(collection_id: &str) -> Option<MixtapeCollection> {
|
||||
let id = collection_id.to_string();
|
||||
tokio::task::spawn_blocking(move || crate::myqbz_detail::get_collection(&id))
|
||||
.await
|
||||
|
||||
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
|
||||
import { Toast } from "primitives/Toast.slint";
|
||||
|
||||
// Re-export the state globals so the Rust layer can populate them.
|
||||
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint";
|
||||
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint";
|
||||
|
||||
// Which top-level screen is shown. The app starts on `splash` while it
|
||||
// restores a saved session, then resolves to `shell` or `login`.
|
||||
|
||||
@@ -28,6 +28,7 @@ import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
|
||||
import { CollectionMosaic } from "CollectionMosaic.slint";
|
||||
|
||||
// --- Hero action button (.action-btn-circle family) ---------------------
|
||||
@@ -1365,4 +1366,31 @@ export component MixtapeDetailView inherits Rectangle {
|
||||
visible-height: flick.height;
|
||||
viewport-y <=> flick.viewport-y;
|
||||
}
|
||||
|
||||
// ===================== 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).
|
||||
if MyQbzDetailState.select-mode && MyQbzDetailState.selected-count > 0: Rectangle {
|
||||
x: 18px;
|
||||
// Sit just above the fixed player bar (the scroller reserves 100px at
|
||||
// its bottom; the bar floats over that gutter).
|
||||
y: parent.height - self.height - 12px;
|
||||
width: parent.width - 18px - 22px;
|
||||
height: 44px;
|
||||
MultiSelectBar {
|
||||
width: 100%;
|
||||
selected-count: MyQbzDetailState.selected-count;
|
||||
actions: [
|
||||
{ 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 },
|
||||
];
|
||||
action(id) => {
|
||||
MyQbzDetailActions.bulk-action(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// My QBZ — DJ-mix "Random queue" sampler modal (TrackMixModal, spec 21 §C /
|
||||
// spec 12 §12).
|
||||
//
|
||||
// Opened from the hero DJ-mix CTA. Lets the user pick how many randomly-sampled
|
||||
// songs to enqueue from this collection. The size choices come from the
|
||||
// backend's buildSizeOptions math (50,100,150,…,All(N)) where N is the deduped
|
||||
// unique-track count; the modal exposes them through a snapping slider (the
|
||||
// prompt's directive — mirror QbzSlider + the modal idiom). On confirm it hands
|
||||
// the chosen size to the Rust controller, which runs dedup_by_similarity +
|
||||
// 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.
|
||||
//
|
||||
// Per spec §23 the button labels are HARDCODED English in the Tauri source; we
|
||||
// keep them @tr here (translate hardcoded UI strings on port).
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { MyQbzMixState, MyQbzMixActions } from "../state.slint";
|
||||
import { QbzSlider } from "../primitives/QbzSlider.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.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.
|
||||
property <bool> can-shuffle:
|
||||
!MyQbzMixState.loading
|
||||
&& !MyQbzMixState.busy
|
||||
&& MyQbzMixState.size-options.length > 0
|
||||
&& MyQbzMixState.selected-size > 0;
|
||||
// Slider travels over option INDICES (0..options-1). A single option (small
|
||||
// collection -> only "All (N)") still renders a degenerate 0..0 track.
|
||||
property <int> slider-max: Math.max(0, MyQbzMixState.size-options.length - 1);
|
||||
// The dropdown-style display value for the current size: "All (N)" for the
|
||||
// trailing entry, else the plain number.
|
||||
property <string> size-label:
|
||||
MyQbzMixState.selected-is-all
|
||||
? @tr("All ({})", MyQbzMixState.selected-size)
|
||||
: "\{MyQbzMixState.selected-size}";
|
||||
|
||||
if MyQbzMixState.open: Rectangle {
|
||||
background: #000000bf;
|
||||
TouchArea {
|
||||
mouse-cursor: default;
|
||||
clicked => {
|
||||
MyQbzMixActions.close();
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: Math.min(root.width - 80px, 440px);
|
||||
height: panel.preferred-height;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
border-radius: Radius.md;
|
||||
background: Theme.surface-card;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
drop-shadow-blur: 32px;
|
||||
drop-shadow-color: #00000080;
|
||||
// Swallow clicks on the panel (so they don't reach the backdrop).
|
||||
TouchArea { }
|
||||
|
||||
panel := VerticalLayout {
|
||||
padding: 24px;
|
||||
spacing: 18px;
|
||||
|
||||
// --- Title (h2) — mixModal.title = "Random queue" --------
|
||||
Text {
|
||||
text: @tr("Random queue");
|
||||
color: Theme.text-primary;
|
||||
font-size: 18px;
|
||||
font-weight: Typography.bold;
|
||||
}
|
||||
|
||||
// --- Body copy (mixModal.body) ---------------------------
|
||||
Text {
|
||||
text: @tr("Create a random queue with songs from this collection. How many songs do you want?");
|
||||
color: Theme.text-secondary;
|
||||
font-size: 14px;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
// --- Loading state (mixModal.computing) ------------------
|
||||
if MyQbzMixState.loading: HorizontalLayout {
|
||||
spacing: 8px;
|
||||
alignment: start;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
LoadingSpinner { size: 18px; }
|
||||
}
|
||||
Text {
|
||||
text: @tr("Loading…");
|
||||
color: Theme.text-secondary;
|
||||
font-size: 14px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Size field (slider + count labels) ------------------
|
||||
if !MyQbzMixState.loading && MyQbzMixState.size-options.length > 0: VerticalLayout {
|
||||
spacing: 8px;
|
||||
|
||||
// Field label (mixModal.numberOfSongs).
|
||||
Text {
|
||||
text: @tr("Number of songs");
|
||||
color: Theme.text-primary;
|
||||
font-size: 13px;
|
||||
font-weight: Typography.medium;
|
||||
}
|
||||
|
||||
// Selected size + slider on one row.
|
||||
HorizontalLayout {
|
||||
spacing: 12px;
|
||||
// The current pick (big, accent), so the slider's effect
|
||||
// is legible.
|
||||
Text {
|
||||
text: root.size-label;
|
||||
color: Theme.accent;
|
||||
font-size: 15px;
|
||||
font-weight: Typography.semibold;
|
||||
vertical-alignment: center;
|
||||
min-width: 64px;
|
||||
}
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
alignment: center;
|
||||
QbzSlider {
|
||||
width: 100%;
|
||||
minimum: 0;
|
||||
maximum: root.slider-max;
|
||||
value: MyQbzMixState.selected-index;
|
||||
changed(v) => {
|
||||
MyQbzMixActions.set-index(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper (mixModal.songsAvailable) = "{n} songs available".
|
||||
Text {
|
||||
text: @tr("{} songs available", MyQbzMixState.unique-count);
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Footer (.tm-actions, right-aligned) -----------------
|
||||
HorizontalLayout {
|
||||
alignment: end;
|
||||
spacing: 12px;
|
||||
|
||||
// Cancel (secondary).
|
||||
Rectangle {
|
||||
width: cancel-label.preferred-width + 32px;
|
||||
height: 38px;
|
||||
border-radius: Radius.sm;
|
||||
background: cancel-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
cancel-label := Text {
|
||||
text: @tr("Cancel");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.semibold;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
}
|
||||
cancel-ta := TouchArea {
|
||||
mouse-cursor: MyQbzMixState.busy ? default : pointer;
|
||||
clicked => {
|
||||
if (!MyQbzMixState.busy) {
|
||||
MyQbzMixActions.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle / Add to queue (primary) — mixModal.confirm.
|
||||
Rectangle {
|
||||
width: confirm-label.preferred-width + 40px;
|
||||
height: 38px;
|
||||
border-radius: Radius.sm;
|
||||
opacity: root.can-shuffle ? 1.0 : 0.5;
|
||||
background: confirm-ta.has-hover && root.can-shuffle
|
||||
? #ffffffd6
|
||||
: Theme.accent;
|
||||
confirm-label := Text {
|
||||
text: @tr("Add to queue");
|
||||
color: #ffffff;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.semibold;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
}
|
||||
confirm-ta := TouchArea {
|
||||
mouse-cursor: root.can-shuffle ? pointer : default;
|
||||
clicked => {
|
||||
if (root.can-shuffle) {
|
||||
MyQbzMixActions.shuffle();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { EditPlaylistModal } from "../primitives/EditPlaylistModal.slint";
|
||||
import { CreateFolderModal } from "../primitives/CreateFolderModal.slint";
|
||||
import { CreateMyQbzModal } from "../myqbz/CreateMyQbzModal.slint";
|
||||
import { MyQbzEditModal } from "../myqbz/MyQbzEditModal.slint";
|
||||
import { MyQbzMixModal } from "../myqbz/MyQbzMixModal.slint";
|
||||
import { AddToMixtapeModal } from "../myqbz/AddToMixtapeModal.slint";
|
||||
import { EphemeralPlayChoiceModal } from "../primitives/EphemeralPlayChoiceModal.slint";
|
||||
import { FolderEditModal } from "../primitives/FolderEditModal.slint";
|
||||
@@ -390,6 +391,9 @@ export component AppShell inherits Rectangle {
|
||||
// Global My QBZ detail edit modals (rename / description / delete-confirm).
|
||||
MyQbzEditModal { }
|
||||
|
||||
// Global My QBZ "DJ-mix / Random queue" sampler modal (TrackMixModal).
|
||||
MyQbzMixModal { }
|
||||
|
||||
// Global "Add to Mixtape/Collection" picker, opened from any track /
|
||||
// album / playlist / bulk-selection "Add to Mixtape/Collection" trigger.
|
||||
AddToMixtapeModal { }
|
||||
|
||||
@@ -1693,6 +1693,50 @@ export global MyQbzDetailActions {
|
||||
callback item-action(string /* source-item-id */, string /* action */);
|
||||
// Toggle one row's selection (select-mode).
|
||||
callback toggle-item-select(int /* position */);
|
||||
// --- Bulk action bar (select-mode, spec 12 §13) -----------------------
|
||||
// The bar emits the chosen action id verbatim; the Rust side maps it:
|
||||
// "add-to-mixtape" -> open the global AddToMixtapeModal with the
|
||||
// selected items' payloads.
|
||||
// "remove-selected" -> repo::remove_item each selected position
|
||||
// (highest-first so compaction doesn't shift),
|
||||
// then reload the detail + clear selection.
|
||||
callback bulk-action(string /* id */);
|
||||
}
|
||||
|
||||
// ──────────────────────────── DJ-mix modal ────────────────────────────
|
||||
// The "Random queue" sampler (TrackMixModal, spec 21 §C / spec 12 §12).
|
||||
// Opened from the hero DJ-mix CTA. On open the Rust controller resolves the
|
||||
// collection in-order + computes shuffle::unique_track_count (the slider max);
|
||||
// the slider snaps to the discrete buildSizeOptions set (50,100,150,…,All).
|
||||
// On confirm it runs dedup_by_similarity + hybrid_sample (thread RNG, sync
|
||||
// scope) -> set_queue + play -> stamp queue-source + touch_play, toasting
|
||||
// "Playing N of M" when the sampled count is below the requested size.
|
||||
export global MyQbzMixState {
|
||||
in property <bool> open: false;
|
||||
// True while resolving + counting unique tracks ("Loading…" state).
|
||||
in property <bool> loading: false;
|
||||
// Deduped unique-track count = the slider MAX (and the "All" size).
|
||||
in property <int> unique-count: 0;
|
||||
// The discrete size options (buildSizeOptions): the slider index maps into
|
||||
// this list. e.g. [50,100,150,All(187)]. Empty while loading / for an
|
||||
// empty collection.
|
||||
in property <[int]> size-options: [];
|
||||
// Current slider index into `size-options` (0-based).
|
||||
in-out property <int> selected-index: 0;
|
||||
// The size at `selected-index` (the value handed to the sampler).
|
||||
in property <int> selected-size: 0;
|
||||
// True when the selected option is the trailing "All (N)" entry.
|
||||
in property <bool> selected-is-all: false;
|
||||
// Disables Shuffle while a sample + play is in flight.
|
||||
in property <bool> busy: false;
|
||||
}
|
||||
|
||||
export global MyQbzMixActions {
|
||||
callback close();
|
||||
// Slider moved: the new index into size-options (Rust re-derives the size).
|
||||
callback set-index(int /* index */);
|
||||
// Confirm -> run the sample + play.
|
||||
callback shuffle();
|
||||
}
|
||||
|
||||
// ──────────────────────────── MyQBZ edit modals ───────────────────────
|
||||
|
||||
Reference in new issue
Block a user