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:
vicrodh committed 2026-06-09 06:42:39 -06:00
1 parent 5da3b8bfd5
commit 1db40d87ef
12 files changed
+739 -17

No files matched your search

+1
View File
@@ -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",
+3
View File
@@ -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
View File
@@ -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) ---
+45 -2
View File
@@ -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
+53
View File
@@ -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)
+242
View File
@@ -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}"));
}
});
}
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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();
}
}
}
}
}
}
}
}
}
+4
View File
@@ -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 { }
+44
View File
@@ -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 ───────────────────────