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",
|
"qbz-qobuz",
|
||||||
"qconnect-app",
|
"qconnect-app",
|
||||||
"qconnect-transport-ws",
|
"qconnect-transport-ws",
|
||||||
|
"rand 0.10.1",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rfd",
|
"rfd",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ qbz-mixtape = { path = "../qbz-mixtape" }
|
|||||||
qbz-qobuz = { path = "../qbz-qobuz" }
|
qbz-qobuz = { path = "../qbz-qobuz" }
|
||||||
qbz-integrations = { path = "../qbz-integrations" }
|
qbz-integrations = { path = "../qbz-integrations" }
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
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-cache = { path = "../qbz-cache" }
|
||||||
qbz-credentials = { path = "../qbz-credentials" }
|
qbz-credentials = { path = "../qbz-credentials" }
|
||||||
|
|
||||||
|
|||||||
+101
-11
@@ -40,6 +40,7 @@ mod myqbz_add;
|
|||||||
mod myqbz_cover;
|
mod myqbz_cover;
|
||||||
mod myqbz_detail;
|
mod myqbz_detail;
|
||||||
mod myqbz_edit;
|
mod myqbz_edit;
|
||||||
|
mod myqbz_mix;
|
||||||
mod myqbz_play;
|
mod myqbz_play;
|
||||||
mod nav;
|
mod nav;
|
||||||
mod play_history;
|
mod play_history;
|
||||||
@@ -2458,25 +2459,114 @@ fn wire_myqbz_detail(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- STILL-STUBBED hero CTAs (DJ-mix modal + discography sync) -------
|
// --- Hero DJ-mix CTA — open the "Random queue" sampler modal --------
|
||||||
// DJ-mix: the TrackMixModal is a later slice — keep the existing stub.
|
// 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
|
// Sync: artist_discography has NO sync impl (spec §8) — no-op stub (the
|
||||||
// hero button is shown only for artist_collection for Tauri parity).
|
// hero button is shown only for artist_collection for Tauri parity).
|
||||||
{
|
{
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
let log_stub = move |what: &str| {
|
window.global::<Act>().on_sync(move || {
|
||||||
let id = weak
|
let id = weak
|
||||||
.upgrade()
|
.upgrade()
|
||||||
.map(|w| w.global::<MyQbzDetailState>().get_id().to_string())
|
.map(|w| w.global::<MyQbzDetailState>().get_id().to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
log::info!(
|
log::info!("[qbz-slint] myqbz_detail sync({id}) — no discography sync impl (spec §8)");
|
||||||
"[qbz-slint] myqbz_detail {what}({id}) — not yet wired (later slice / no impl)"
|
});
|
||||||
);
|
}
|
||||||
};
|
|
||||||
let s = log_stub.clone();
|
// --- DJ-mix modal actions (slider / cancel / confirm) ---------------
|
||||||
window.global::<Act>().on_dj_mix(move || s("dj-mix"));
|
{
|
||||||
let s = log_stub.clone();
|
let weak = window.as_weak();
|
||||||
window.global::<Act>().on_sync(move || s("sync"));
|
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) ---
|
// --- 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 {
|
match source {
|
||||||
AlbumSource::Qobuz => "qobuz",
|
AlbumSource::Qobuz => "qobuz",
|
||||||
AlbumSource::Local => "local",
|
AlbumSource::Local => "local",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn item_type_str(t: ItemType) -> &'static str {
|
pub fn item_type_str(t: ItemType) -> &'static str {
|
||||||
match t {
|
match t {
|
||||||
ItemType::Album => "album",
|
ItemType::Album => "album",
|
||||||
ItemType::Track => "track",
|
ItemType::Track => "track",
|
||||||
@@ -397,6 +397,49 @@ pub fn toggle_item_select(window: &AppWindow, position: i32) {
|
|||||||
state.set_selected_count(count);
|
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 ───────────────────────────
|
// ──────────────────────────── reset / apply ───────────────────────────
|
||||||
|
|
||||||
/// Clear the view to its loading state before a fresh load (so a re-open does
|
/// 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 ─────────────────────
|
// ──────────────────────────── modal-state helpers ─────────────────────
|
||||||
|
|
||||||
/// On a successful mutation: close the modal (if any), reload, and (when given)
|
/// 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
|
/// `resolve_collection_tracks`. `force_shuffle` overrides the persisted mode
|
||||||
/// with `AlbumShuffle` (time-seeded whole-item shuffle) for the hero Shuffle
|
/// with `AlbumShuffle` (time-seeded whole-item shuffle) for the hero Shuffle
|
||||||
/// CTA; otherwise the collection's persisted `play_mode` is used.
|
/// CTA; otherwise the collection's persisted `play_mode` is used.
|
||||||
async fn resolve_collection(
|
pub(crate) async fn resolve_collection(
|
||||||
runtime: &Runtime,
|
runtime: &Runtime,
|
||||||
collection: &MixtapeCollection,
|
collection: &MixtapeCollection,
|
||||||
force_shuffle: bool,
|
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
|
/// 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
|
/// collection, and `touch_play`. Shared by hero Play + hero Shuffle (the two
|
||||||
/// whole-collection replace paths). Empty `tracks` → toast + no-op.
|
/// whole-collection replace paths). Empty `tracks` → toast + no-op.
|
||||||
async fn play_all_tracks(
|
pub(crate) async fn play_all_tracks(
|
||||||
runtime: &Runtime,
|
runtime: &Runtime,
|
||||||
weak: &slint::Weak<AppWindow>,
|
weak: &slint::Weak<AppWindow>,
|
||||||
collection_id: &str,
|
collection_id: &str,
|
||||||
@@ -329,7 +329,7 @@ pub fn item_action(
|
|||||||
/// Load a collection (items hydrated) off the UI/event-loop thread, on a
|
/// 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
|
/// blocking worker, reusing the detail module's read path. Returns `None` when
|
||||||
/// the DB is unavailable or the id is unknown.
|
/// 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();
|
let id = collection_id.to_string();
|
||||||
tokio::task::spawn_blocking(move || crate::myqbz_detail::get_collection(&id))
|
tokio::task::spawn_blocking(move || crate::myqbz_detail::get_collection(&id))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
|
|||||||
import { Toast } from "primitives/Toast.slint";
|
import { Toast } from "primitives/Toast.slint";
|
||||||
|
|
||||||
// Re-export the state globals so the Rust layer can populate them.
|
// 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
|
// Which top-level screen is shown. The app starts on `splash` while it
|
||||||
// restores a saved session, then resolves to `shell` or `login`.
|
// 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 { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
|
||||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||||
|
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
|
||||||
import { CollectionMosaic } from "CollectionMosaic.slint";
|
import { CollectionMosaic } from "CollectionMosaic.slint";
|
||||||
|
|
||||||
// --- Hero action button (.action-btn-circle family) ---------------------
|
// --- Hero action button (.action-btn-circle family) ---------------------
|
||||||
@@ -1365,4 +1366,31 @@ export component MixtapeDetailView inherits Rectangle {
|
|||||||
visible-height: flick.height;
|
visible-height: flick.height;
|
||||||
viewport-y <=> flick.viewport-y;
|
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 { CreateFolderModal } from "../primitives/CreateFolderModal.slint";
|
||||||
import { CreateMyQbzModal } from "../myqbz/CreateMyQbzModal.slint";
|
import { CreateMyQbzModal } from "../myqbz/CreateMyQbzModal.slint";
|
||||||
import { MyQbzEditModal } from "../myqbz/MyQbzEditModal.slint";
|
import { MyQbzEditModal } from "../myqbz/MyQbzEditModal.slint";
|
||||||
|
import { MyQbzMixModal } from "../myqbz/MyQbzMixModal.slint";
|
||||||
import { AddToMixtapeModal } from "../myqbz/AddToMixtapeModal.slint";
|
import { AddToMixtapeModal } from "../myqbz/AddToMixtapeModal.slint";
|
||||||
import { EphemeralPlayChoiceModal } from "../primitives/EphemeralPlayChoiceModal.slint";
|
import { EphemeralPlayChoiceModal } from "../primitives/EphemeralPlayChoiceModal.slint";
|
||||||
import { FolderEditModal } from "../primitives/FolderEditModal.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).
|
// Global My QBZ detail edit modals (rename / description / delete-confirm).
|
||||||
MyQbzEditModal { }
|
MyQbzEditModal { }
|
||||||
|
|
||||||
|
// Global My QBZ "DJ-mix / Random queue" sampler modal (TrackMixModal).
|
||||||
|
MyQbzMixModal { }
|
||||||
|
|
||||||
// Global "Add to Mixtape/Collection" picker, opened from any track /
|
// Global "Add to Mixtape/Collection" picker, opened from any track /
|
||||||
// album / playlist / bulk-selection "Add to Mixtape/Collection" trigger.
|
// album / playlist / bulk-selection "Add to Mixtape/Collection" trigger.
|
||||||
AddToMixtapeModal { }
|
AddToMixtapeModal { }
|
||||||
|
|||||||
@@ -1693,6 +1693,50 @@ export global MyQbzDetailActions {
|
|||||||
callback item-action(string /* source-item-id */, string /* action */);
|
callback item-action(string /* source-item-id */, string /* action */);
|
||||||
// Toggle one row's selection (select-mode).
|
// Toggle one row's selection (select-mode).
|
||||||
callback toggle-item-select(int /* position */);
|
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 ───────────────────────
|
// ──────────────────────────── MyQBZ edit modals ───────────────────────
|
||||||
|
|||||||
Reference in new issue
Block a user