feat(myqbz): AddToMixtapeModal + app-wide add-to-collection entry points
Phase-2 Slice 8. Adds "Add to Mixtape/Collection" everywhere "Add to playlist" lives, so collections can be populated: - ui/myqbz/AddToMixtapeModal.slint: the picker (search + collection list with kind icon/name/count + "Already added" via item_exists + kind tag) and the create-new-inline panel (name + kind), per spec 21. Mounted once at root. - src/myqbz_add.rs: list_collections (kind-restricted: track/playlist->mixtapes, album->mixtapes+collections, never artist_collection; recency-sorted); add via add_item_with(allow_duplicate=false) counting added/skipped-duplicates + toast; create_and_add reuses create_collection. All via with_db -> with_connection. - Entry points wired: per-track (universal via TrackContextMenu/TrackRow -> search, favorites, album rows, local tracks, now-playing), Qobuz album, local/plex album, playlist (PM), bulk local tracks. Replaced the PlaylistManager add-to-mixtape no-op. Qobuz bulk omitted by design (spec 50 negative-space). Note: adversarial reviewers were blocked by a session limit; verified manually (cargo green, confined to qbz-slint, backend untouched, ADR-005/006, coverage per spec 50). cc
This commit is contained in:
9 files changed
+1337
-5
No files matched your search
@@ -36,6 +36,7 @@ mod location_view;
|
||||
mod mix;
|
||||
mod musician;
|
||||
mod myqbz;
|
||||
mod myqbz_add;
|
||||
mod myqbz_cover;
|
||||
mod myqbz_detail;
|
||||
mod myqbz_edit;
|
||||
@@ -386,6 +387,47 @@ fn set_row_favorite(window: &AppWindow, track_id: &str, favorite: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up the display name of an "Add to Mixtape/Collection" picker row by id
|
||||
/// (for the post-add toast). Returns "" if not found.
|
||||
fn myqbz_add_row_name(window: &AppWindow, collection_id: &str) -> String {
|
||||
use slint::Model;
|
||||
let model = window.global::<MyQbzAddState>().get_rows();
|
||||
(0..model.row_count())
|
||||
.filter_map(|i| model.row_data(i))
|
||||
.find(|r| r.id == collection_id)
|
||||
.map(|r| r.name.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Open the global "Add to Mixtape/Collection" picker for `items` (mirrors
|
||||
/// Tauri's `openAddToMixtape`). Hops onto the event loop to show the modal,
|
||||
/// then loads the picker rows (kind-restricted + recency-sorted +
|
||||
/// `item_exists`-resolved) on a blocking worker. Empty `items` is a no-op
|
||||
/// (the controller guards too). Callable from any thread.
|
||||
fn open_add_to_mixtape(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
items: Vec<myqbz_add::AddItem>,
|
||||
) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let restrict = items.iter().any(|it| it.item_type != "album");
|
||||
let items_for_open = items.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
myqbz_add::open(&w, items_for_open);
|
||||
});
|
||||
handle.spawn(async move {
|
||||
let rows =
|
||||
tokio::task::spawn_blocking(move || myqbz_add::load_rows(restrict, &items))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
myqbz_add::apply_rows(&w, rows);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Update the offline cache-status (+ progress) of every visible row matching
|
||||
/// `track_id`. Mirrors `set_row_favorite`. status: 0 none / 1 queued / 2
|
||||
/// downloading / 3 ready / 4 failed; `progress` is 0.0..1.0.
|
||||
@@ -1556,12 +1598,36 @@ fn wire_playlist_manager(
|
||||
});
|
||||
}
|
||||
{
|
||||
// Add-to-mixtape — the mixtape system is not yet ported to Slint;
|
||||
// log and no-op (matches the deferred mixtape surface).
|
||||
// Add a whole playlist to a Mixtape/Collection (callsite O). Builds the
|
||||
// `playlist` payload from the PM grid row (id / name / track count /
|
||||
// first cover); the owner subtitle isn't carried in the PM model, so it
|
||||
// is omitted (optional in the contract).
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window
|
||||
.global::<PlaylistManagerActions>()
|
||||
.on_add_to_mixtape(move |id| {
|
||||
log::info!("[qbz-slint] add-to-mixtape not yet implemented (playlist {id})");
|
||||
use slint::Model;
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let model = w.global::<PlaylistManagerState>().get_playlists();
|
||||
let Some(row) = (0..model.row_count())
|
||||
.filter_map(|i| model.row_data(i))
|
||||
.find(|it| it.id == id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let artwork = row.url1.to_string();
|
||||
let item = myqbz_add::AddItem {
|
||||
item_type: "playlist".into(),
|
||||
source: "qobuz".into(),
|
||||
source_item_id: id.to_string(),
|
||||
title: row.name.to_string(),
|
||||
subtitle: None,
|
||||
artwork_url: (!artwork.is_empty()).then_some(artwork),
|
||||
year: None,
|
||||
track_count: (row.total_count > 0).then_some(row.total_count),
|
||||
};
|
||||
open_add_to_mixtape(weak.clone(), handle.clone(), vec![item]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1984,6 +2050,132 @@ fn wire_myqbz(
|
||||
});
|
||||
}
|
||||
|
||||
// --- Add to Mixtape/Collection picker (global singleton) ------------
|
||||
{
|
||||
// close — clear the pending payload + hide.
|
||||
let weak = window.as_weak();
|
||||
window.global::<MyQbzAddActions>().on_close(move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
myqbz_add::close(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// search — re-filter the loaded rows client-side.
|
||||
let weak = window.as_weak();
|
||||
window
|
||||
.global::<MyQbzAddActions>()
|
||||
.on_search_changed(move |_query| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
myqbz_add::rebuild(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// show-create — open the create sub-panel preset to a kind.
|
||||
let weak = window.as_weak();
|
||||
window
|
||||
.global::<MyQbzAddActions>()
|
||||
.on_show_create(move |kind| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
let st = w.global::<MyQbzAddState>();
|
||||
st.set_create_kind(kind);
|
||||
st.set_create_name("".into());
|
||||
st.set_creating(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// create-back — return to the picker list.
|
||||
let weak = window.as_weak();
|
||||
window.global::<MyQbzAddActions>().on_create_back(move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.global::<MyQbzAddState>().set_creating(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// pick — add the pending items to the chosen collection.
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window
|
||||
.global::<MyQbzAddActions>()
|
||||
.on_pick(move |collection_id| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let st = w.global::<MyQbzAddState>();
|
||||
if st.get_busy_id() != "" {
|
||||
return;
|
||||
}
|
||||
st.set_busy_id(collection_id.clone());
|
||||
// The chosen collection's display name (for the toast).
|
||||
let name = myqbz_add_row_name(&w, collection_id.as_str());
|
||||
let items = myqbz_add::take_pending();
|
||||
let cid = collection_id.to_string();
|
||||
|
||||
let weak = weak.clone();
|
||||
handle.spawn(async move {
|
||||
let outcome = tokio::task::spawn_blocking(move || {
|
||||
myqbz_add::add_items(&cid, &items)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(myqbz_add::AddOutcome { added: 0, skipped: 0 });
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
myqbz_add::toast_outcome(&w, &name, &outcome);
|
||||
myqbz_add::close(&w);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
{
|
||||
// create-and-add — create a new collection then add the items.
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window.global::<MyQbzAddActions>().on_create_and_add(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let st = w.global::<MyQbzAddState>();
|
||||
let name = st.get_create_name().trim().to_string();
|
||||
if name.is_empty() || st.get_create_busy() {
|
||||
return;
|
||||
}
|
||||
let kind = st.get_create_kind().to_string();
|
||||
st.set_create_busy(true);
|
||||
let items = myqbz_add::take_pending();
|
||||
|
||||
let weak = weak.clone();
|
||||
handle.spawn(async move {
|
||||
let created = {
|
||||
let kind = kind.clone();
|
||||
let name = name.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
myqbz_add::create_collection(&kind, &name)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
};
|
||||
match created {
|
||||
Some((cid, cname)) => {
|
||||
let outcome = tokio::task::spawn_blocking(move || {
|
||||
myqbz_add::add_items(&cid, &items)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(myqbz_add::AddOutcome { added: 0, skipped: 0 });
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
myqbz_add::toast_outcome(&w, &cname, &outcome);
|
||||
myqbz_add::close(&w);
|
||||
});
|
||||
}
|
||||
None => {
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
w.global::<MyQbzAddState>().set_create_busy(false);
|
||||
crate::toast::error(&w, "Failed to create");
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Mixtapes toolbar -----------------------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
@@ -3241,6 +3433,79 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// for folder-grouped local albums).
|
||||
tag_editor::open_tag_editor(weak.clone(), handle.clone(), id.clone(), id);
|
||||
}
|
||||
("album", "add-to-mixtape") => {
|
||||
// The cassette button on the album header. Local albums
|
||||
// (incl. Plex, stored as source "local") build the payload
|
||||
// from AlbumState + the loaded tracks; Qobuz albums resolve
|
||||
// via get_album (the proven fail-safe resolver).
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let st = w.global::<AlbumState>();
|
||||
if st.get_is_local() {
|
||||
let item = myqbz_add::AddItem {
|
||||
item_type: "album".into(),
|
||||
source: "local".into(),
|
||||
source_item_id: st.get_id().to_string(),
|
||||
title: st.get_title().to_string(),
|
||||
subtitle: {
|
||||
let a = st.get_artist().to_string();
|
||||
(!a.is_empty()).then_some(a)
|
||||
},
|
||||
artwork_url: None, // local albums omit artwork_url (1:1 PSD)
|
||||
year: None,
|
||||
track_count: {
|
||||
use slint::Model;
|
||||
let n = st.get_tracks().row_count();
|
||||
(n > 0).then_some(n as i32)
|
||||
},
|
||||
};
|
||||
open_add_to_mixtape(weak.clone(), handle.clone(), vec![item]);
|
||||
} else {
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
let handle2 = handle.clone();
|
||||
let album_id = id.clone();
|
||||
handle.spawn(async move {
|
||||
let item = match runtime.core().get_album(&album_id).await {
|
||||
Ok(album) => {
|
||||
let artwork_url = album
|
||||
.image
|
||||
.thumbnail
|
||||
.clone()
|
||||
.or_else(|| album.image.small.clone());
|
||||
let year = album
|
||||
.release_date_original
|
||||
.as_deref()
|
||||
.and_then(|d| d.get(0..4))
|
||||
.and_then(|y| y.parse::<i32>().ok());
|
||||
let track_count = album
|
||||
.tracks_count
|
||||
.or(album.track_count)
|
||||
.map(|n| n as i32);
|
||||
myqbz_add::AddItem {
|
||||
item_type: "album".into(),
|
||||
source: "qobuz".into(),
|
||||
source_item_id: album.id.clone(),
|
||||
title: album.title.clone(),
|
||||
subtitle: {
|
||||
let a = album.artist.name.clone();
|
||||
(!a.is_empty()).then_some(a)
|
||||
},
|
||||
artwork_url,
|
||||
year,
|
||||
track_count,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[qbz-slint] add-to-mixtape: get_album {album_id} failed: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
open_add_to_mixtape(weak, handle2, vec![item]);
|
||||
});
|
||||
}
|
||||
}
|
||||
("album", "radio") => playback::play_album_radio(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
@@ -3360,6 +3625,60 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
});
|
||||
}
|
||||
("track", "add-to-mixtape") => {
|
||||
// The menu only carries the track id; resolve the Qobuz
|
||||
// track (this entry is gated to Qobuz/offline in the menu)
|
||||
// to build the AddToMixtape payload, then open the picker.
|
||||
if let Ok(track_id) = id.parse::<u64>() {
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
let handle2 = handle.clone();
|
||||
handle.spawn(async move {
|
||||
let item = match runtime.core().get_track(track_id).await {
|
||||
Ok(track) => {
|
||||
let artist = track
|
||||
.performer
|
||||
.as_ref()
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_default();
|
||||
let album = track
|
||||
.album
|
||||
.as_ref()
|
||||
.map(|a| a.title.clone())
|
||||
.unwrap_or_default();
|
||||
let subtitle = [artist, album]
|
||||
.into_iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
let artwork_url = track.album.as_ref().and_then(|a| {
|
||||
a.image
|
||||
.thumbnail
|
||||
.clone()
|
||||
.or_else(|| a.image.small.clone())
|
||||
});
|
||||
myqbz_add::AddItem {
|
||||
item_type: "track".into(),
|
||||
source: "qobuz".into(),
|
||||
source_item_id: track_id.to_string(),
|
||||
title: track.title.clone(),
|
||||
subtitle: (!subtitle.is_empty()).then_some(subtitle),
|
||||
artwork_url,
|
||||
year: None,
|
||||
track_count: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[qbz-slint] add-to-mixtape: get_track {track_id} failed: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
open_add_to_mixtape(weak, handle2, vec![item]);
|
||||
});
|
||||
}
|
||||
}
|
||||
("track", "share-qobuz") => {
|
||||
share::copy_to_clipboard(share::qobuz_track_url(&id));
|
||||
log::info!("[qbz-slint] copied Qobuz link for track {id}");
|
||||
@@ -5619,6 +5938,37 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// Add the whole local/Plex album to a Mixtape/Collection. Builds the
|
||||
// `album` payload (source "local", no artwork_url — 1:1 PSD) from the
|
||||
// LocalAlbumState header + the current version's track count.
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window.global::<LocalAlbumActions>().on_add_to_mixtape(move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
let st = w.global::<LocalAlbumState>();
|
||||
let id = st.get_id().to_string();
|
||||
if id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let tracks = local_library::current_album_version_tracks(&w);
|
||||
let item = myqbz_add::AddItem {
|
||||
item_type: "album".into(),
|
||||
source: "local".into(),
|
||||
source_item_id: id,
|
||||
title: st.get_title().to_string(),
|
||||
subtitle: {
|
||||
let a = st.get_artist().to_string();
|
||||
(!a.is_empty()).then_some(a)
|
||||
},
|
||||
artwork_url: None,
|
||||
year: None,
|
||||
track_count: (!tracks.is_empty()).then_some(tracks.len() as i32),
|
||||
};
|
||||
open_add_to_mixtape(weak.clone(), handle.clone(), vec![item]);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<LocalAlbumActions>().on_select_version(move |i| {
|
||||
@@ -5913,6 +6263,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
}
|
||||
"add-to-mixtape" => {
|
||||
// All selected tracks (Plex INCLUDED — Plex rows are
|
||||
// stored as source "local" in the mixtape contract).
|
||||
let rows = local_library::selected_local_tracks(&w);
|
||||
let items = myqbz_add::track_items_from_local(&rows);
|
||||
if !items.is_empty() {
|
||||
open_add_to_mixtape(weak.clone(), handle.clone(), items);
|
||||
local_library::clear_tracks_selection(&w);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
@@ -6011,6 +6371,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
}
|
||||
"add-to-mixtape" => {
|
||||
// All selected tracks (Plex included — stored as "local").
|
||||
let rows = local_library::tree_selected_snapshot();
|
||||
let items = myqbz_add::track_items_from_local(&rows);
|
||||
if !items.is_empty() {
|
||||
open_add_to_mixtape(weak.clone(), handle.clone(), items);
|
||||
local_library::tree_clear_selection(&w);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
//! "Add to Mixtape/Collection" controller — the Rust side of the global
|
||||
//! `AddToMixtapeModal` (spec 21/50). Every app-wide "Add to Mixtape/Collection"
|
||||
//! trigger builds an [`AddItem`] payload (or a batch) and calls [`open`]; this
|
||||
//! module holds the pending items in a process-global, loads the picker list
|
||||
//! (kind-restricted + recency-sorted + `item_exists`-resolved), and on pick /
|
||||
//! create-and-add writes the items into the chosen collection via the shared
|
||||
//! `qbz_mixtape::repo` (reached through `crate::library_db::with_db` +
|
||||
//! `with_connection` — no Tauri command wrappers, ADR-005/006).
|
||||
//!
|
||||
//! Dedup is the backend's job: `add_item_with(allow_duplicate=false)` returns
|
||||
//! `false` for an exact `(collection_id, source, source_item_id)` duplicate
|
||||
//! (not an error). We count `added` vs `skipped` and surface the outcome via a
|
||||
//! toast ("Added N to {name}" / "Already in {name}"), mirroring Tauri's
|
||||
//! `toastBatchResult` + the dup flow's net result.
|
||||
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use qbz_models::mixtape::{AlbumSource, CollectionKind, ItemType, MixtapeCollection};
|
||||
use slint::{ComponentHandle, ModelRc, VecModel};
|
||||
|
||||
use crate::{AppWindow, MyQbzAddRow, MyQbzAddState, ToastKind};
|
||||
|
||||
/// One pending item to add. Built by each callsite from its row/album/playlist
|
||||
/// data (spec 50 §0.2). `source_item_id` is ALWAYS a string (numeric track ids
|
||||
/// are stringified by the caller).
|
||||
#[derive(Clone)]
|
||||
pub struct AddItem {
|
||||
/// "album" | "track" | "playlist".
|
||||
pub item_type: String,
|
||||
/// "qobuz" | "local" (Plex rows pass "local" — there is no "plex" source).
|
||||
pub source: String,
|
||||
pub source_item_id: String,
|
||||
pub title: String,
|
||||
pub subtitle: Option<String>,
|
||||
pub artwork_url: Option<String>,
|
||||
pub year: Option<i32>,
|
||||
pub track_count: Option<i32>,
|
||||
}
|
||||
|
||||
/// Pending items for the currently-open picker. Set by [`open`], read by the
|
||||
/// add/create handlers. Cleared on close.
|
||||
static PENDING: LazyLock<Mutex<Vec<AddItem>>> = LazyLock::new(|| Mutex::new(Vec::new()));
|
||||
|
||||
fn pending_snapshot() -> Vec<AddItem> {
|
||||
PENDING.lock().map(|p| p.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
// ──────────────────────────── enum mapping ────────────────────────────
|
||||
|
||||
fn item_type_from_str(s: &str) -> ItemType {
|
||||
match s {
|
||||
"track" => ItemType::Track,
|
||||
"playlist" => ItemType::Playlist,
|
||||
_ => ItemType::Album,
|
||||
}
|
||||
}
|
||||
|
||||
fn source_from_str(s: &str) -> AlbumSource {
|
||||
match s {
|
||||
"local" => AlbumSource::Local,
|
||||
_ => AlbumSource::Qobuz,
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────── open / payload ──────────────────────────
|
||||
|
||||
/// Open the picker for one or more items. Empty input is a no-op (mirrors
|
||||
/// `openAddToMixtape([])`). Stores the payload, computes the header strings +
|
||||
/// kind-restriction, marks loading, and shows the modal. UI thread.
|
||||
///
|
||||
/// The caller is responsible for spawning the row load afterwards (it needs the
|
||||
/// tokio handle + a worker thread for the DB read); the wiring in `main.rs`
|
||||
/// does `open(...)` then spawns [`load_rows`] → [`apply_rows`].
|
||||
pub fn open(window: &AppWindow, items: Vec<AddItem>) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let bulk = items.len() > 1;
|
||||
// restrict to mixtapes if ANY pending item is a track/playlist (collections
|
||||
// hold whole albums only).
|
||||
let restrict = items.iter().any(|it| it.item_type != "album");
|
||||
|
||||
let first = &items[0];
|
||||
let header_title: String = if bulk {
|
||||
format!("{} items", items.len())
|
||||
} else {
|
||||
first.title.clone()
|
||||
};
|
||||
let header_subtitle: String = if bulk {
|
||||
// "{first title} + N more" — hardcoded English suffix (1:1 with PSD).
|
||||
format!("{} + {} more", first.title, items.len() - 1)
|
||||
} else {
|
||||
first.subtitle.clone().unwrap_or_default()
|
||||
};
|
||||
|
||||
if let Ok(mut p) = PENDING.lock() {
|
||||
*p = items;
|
||||
}
|
||||
|
||||
let state = window.global::<MyQbzAddState>();
|
||||
state.set_rows(ModelRc::new(VecModel::from(Vec::<MyQbzAddRow>::new())));
|
||||
state.set_header_title(header_title.into());
|
||||
state.set_header_subtitle(header_subtitle.into());
|
||||
state.set_bulk_mode(bulk);
|
||||
state.set_restrict_to_mixtape(restrict);
|
||||
state.set_search("".into());
|
||||
state.set_busy_id("".into());
|
||||
state.set_creating(false);
|
||||
state.set_create_name("".into());
|
||||
state.set_create_kind("mixtape".into());
|
||||
state.set_loading(true);
|
||||
state.set_open(true);
|
||||
}
|
||||
|
||||
/// Close the picker + clear the pending payload. UI thread.
|
||||
pub fn close(window: &AppWindow) {
|
||||
if let Ok(mut p) = PENDING.lock() {
|
||||
p.clear();
|
||||
}
|
||||
let state = window.global::<MyQbzAddState>();
|
||||
state.set_open(false);
|
||||
state.set_creating(false);
|
||||
state.set_busy_id("".into());
|
||||
}
|
||||
|
||||
// ──────────────────────────── row loading ─────────────────────────────
|
||||
|
||||
/// A loaded picker row (the collection + whether it already contains every
|
||||
/// pending item). Built on a worker thread by [`load_rows`].
|
||||
pub struct LoadedRow {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: CollectionKind,
|
||||
pub item_count: usize,
|
||||
/// True when EVERY pending item already exists in this collection.
|
||||
pub already_has: bool,
|
||||
}
|
||||
|
||||
/// Load the collections offered as targets, kind-restricted + recency-sorted +
|
||||
/// `item_exists`-resolved. Blocking (DB) — run on a worker thread.
|
||||
///
|
||||
/// - `restrict_to_mixtape` → only `kind == mixtape` (excludes collections AND
|
||||
/// artist_collections, the latter never a user target).
|
||||
/// - sort = `last_played_at ?? updated_at` DESC (most-recently-played, then
|
||||
/// most-recently-updated), matching `sortedCollections` in the PSD.
|
||||
/// - `already_has` = every pending item's `(source, source_item_id)` already in
|
||||
/// the collection (so the row can show an "already added" hint).
|
||||
pub fn load_rows(restrict_to_mixtape: bool, items: &[AddItem]) -> Vec<LoadedRow> {
|
||||
crate::library_db::with_db(|db| {
|
||||
Ok(db.with_connection(|conn| {
|
||||
let mut cols: Vec<MixtapeCollection> =
|
||||
qbz_mixtape::repo::list_collections(conn, None).unwrap_or_else(|e| {
|
||||
log::warn!("[qbz-slint] myqbz_add list_collections failed: {e}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
// Kind restriction.
|
||||
if restrict_to_mixtape {
|
||||
cols.retain(|c| c.kind == CollectionKind::Mixtape);
|
||||
} else {
|
||||
// Albums can land in a Mixtape or a Collection, but NEVER an
|
||||
// auto-generated ArtistCollection.
|
||||
cols.retain(|c| c.kind != CollectionKind::ArtistCollection);
|
||||
}
|
||||
|
||||
// Sort by last_played_at ?? updated_at DESC.
|
||||
cols.sort_by(|a, b| {
|
||||
let ra = a.last_played_at.unwrap_or(a.updated_at);
|
||||
let rb = b.last_played_at.unwrap_or(b.updated_at);
|
||||
rb.cmp(&ra)
|
||||
});
|
||||
|
||||
cols.into_iter()
|
||||
.map(|c| {
|
||||
// already_has = every pending item is already present.
|
||||
let already_has = !items.is_empty()
|
||||
&& items.iter().all(|it| {
|
||||
qbz_mixtape::repo::item_exists(
|
||||
conn,
|
||||
&c.id,
|
||||
source_from_str(&it.source),
|
||||
&it.source_item_id,
|
||||
)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
LoadedRow {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
kind: c.kind,
|
||||
item_count: c.items.len(),
|
||||
already_has,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn kind_icon(kind: CollectionKind) -> &'static str {
|
||||
match kind {
|
||||
CollectionKind::Mixtape => "cassette",
|
||||
CollectionKind::ArtistCollection => "user",
|
||||
CollectionKind::Collection => "library-big",
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_label(kind: CollectionKind) -> &'static str {
|
||||
match kind {
|
||||
CollectionKind::Mixtape => "MIXTAPE",
|
||||
CollectionKind::Collection => "COLLECTION",
|
||||
CollectionKind::ArtistCollection => "ARTIST",
|
||||
}
|
||||
}
|
||||
|
||||
/// "N albums" / "1 album" (always "album(s)" regardless of item_type — 1:1 PSD).
|
||||
fn album_count_label(count: usize) -> String {
|
||||
if count == 1 {
|
||||
"1 album".to_string()
|
||||
} else {
|
||||
format!("{count} albums")
|
||||
}
|
||||
}
|
||||
|
||||
/// Render loaded rows into `MyQbzAddState`, applying the active search filter.
|
||||
/// UI thread.
|
||||
pub fn apply_rows(window: &AppWindow, rows: Vec<LoadedRow>) {
|
||||
// Stash so a later search re-filters without a DB refetch.
|
||||
if let Ok(mut c) = ROWS_CACHE.lock() {
|
||||
*c = rows;
|
||||
}
|
||||
rebuild(window);
|
||||
window.global::<MyQbzAddState>().set_loading(false);
|
||||
}
|
||||
|
||||
/// Last-loaded rows (so search filters client-side, no refetch).
|
||||
static ROWS_CACHE: LazyLock<Mutex<Vec<LoadedRow>>> = LazyLock::new(|| Mutex::new(Vec::new()));
|
||||
|
||||
/// Rebuild the visible row model from the cache honoring the search filter.
|
||||
pub fn rebuild(window: &AppWindow) {
|
||||
let state = window.global::<MyQbzAddState>();
|
||||
let query = state.get_search().trim().to_lowercase();
|
||||
let cache = ROWS_CACHE.lock();
|
||||
let items: Vec<MyQbzAddRow> = cache
|
||||
.as_ref()
|
||||
.map(|rows| {
|
||||
rows.iter()
|
||||
.filter(|r| query.is_empty() || r.name.to_lowercase().contains(&query))
|
||||
.map(|r| MyQbzAddRow {
|
||||
id: r.id.clone().into(),
|
||||
name: r.name.clone().into(),
|
||||
kind: match r.kind {
|
||||
CollectionKind::Mixtape => "mixtape",
|
||||
CollectionKind::Collection => "collection",
|
||||
CollectionKind::ArtistCollection => "artist_collection",
|
||||
}
|
||||
.into(),
|
||||
icon: kind_icon(r.kind).into(),
|
||||
kind_label: kind_label(r.kind).into(),
|
||||
meta: album_count_label(r.item_count).into(),
|
||||
already_has: r.already_has,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
state.set_rows(ModelRc::new(VecModel::from(items)));
|
||||
}
|
||||
|
||||
// ──────────────────────────── add / create ────────────────────────────
|
||||
|
||||
/// Result of a batch insert: how many were inserted and how many were skipped
|
||||
/// as duplicates.
|
||||
pub struct AddOutcome {
|
||||
pub added: usize,
|
||||
pub skipped: usize,
|
||||
}
|
||||
|
||||
/// Insert every pending item into `collection_id` with `allow_duplicate=false`.
|
||||
/// Blocking (DB). Returns the added/skipped tally (a `false` return from the
|
||||
/// repo = a dedup-rejected duplicate, NOT an error).
|
||||
pub fn add_items(collection_id: &str, items: &[AddItem]) -> AddOutcome {
|
||||
let mut added = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
crate::library_db::with_db(|db| {
|
||||
Ok(db.with_connection(|conn| {
|
||||
for it in items {
|
||||
match qbz_mixtape::repo::add_item_with(
|
||||
conn,
|
||||
collection_id,
|
||||
item_type_from_str(&it.item_type),
|
||||
source_from_str(&it.source),
|
||||
&it.source_item_id,
|
||||
&it.title,
|
||||
it.subtitle.as_deref(),
|
||||
it.artwork_url.as_deref(),
|
||||
it.year,
|
||||
it.track_count,
|
||||
false,
|
||||
) {
|
||||
Ok(true) => added += 1,
|
||||
Ok(false) => skipped += 1,
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] myqbz_add add_item failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
});
|
||||
AddOutcome { added, skipped }
|
||||
}
|
||||
|
||||
/// Surface the add outcome via a toast (matches Tauri's `toastBatchResult` net
|
||||
/// behavior). `name` is the collection name.
|
||||
pub fn toast_outcome(window: &AppWindow, name: &str, outcome: &AddOutcome) {
|
||||
let msg = if outcome.added == 0 {
|
||||
// Nothing inserted -> everything was a duplicate ("Already in {name}").
|
||||
format!("Already in {name}")
|
||||
} else if outcome.skipped > 0 {
|
||||
format!(
|
||||
"Added {} to {name} ({} duplicate(s) skipped)",
|
||||
outcome.added, outcome.skipped
|
||||
)
|
||||
} else {
|
||||
format!("Added {} to {name}", outcome.added)
|
||||
};
|
||||
let kind = if outcome.added == 0 {
|
||||
ToastKind::Info
|
||||
} else {
|
||||
ToastKind::Success
|
||||
};
|
||||
crate::toast::show(window, msg, kind);
|
||||
}
|
||||
|
||||
/// Take a snapshot of the pending items (clone). Used by the action handlers in
|
||||
/// `main.rs` to hand the payload to a blocking worker.
|
||||
pub fn take_pending() -> Vec<AddItem> {
|
||||
pending_snapshot()
|
||||
}
|
||||
|
||||
/// Build `track` payloads from a batch of LocalTracks (source "local" — Plex
|
||||
/// rows included, per spec 50: there is no "plex" source). Subtitle =
|
||||
/// "artist · album"; no artwork_url / year / track_count (1:1 PSD §R).
|
||||
pub fn track_items_from_local(tracks: &[qbz_library::LocalTrack]) -> Vec<AddItem> {
|
||||
tracks
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let subtitle = [t.artist.clone(), t.album.clone()]
|
||||
.into_iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
AddItem {
|
||||
item_type: "track".into(),
|
||||
source: "local".into(),
|
||||
source_item_id: t.id.to_string(),
|
||||
title: t.title.clone(),
|
||||
subtitle: (!subtitle.is_empty()).then_some(subtitle),
|
||||
artwork_url: None,
|
||||
year: None,
|
||||
track_count: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Create a new manual collection of `kind` named `name`, returning
|
||||
/// `(id, name)` on success. Blocking (DB). `kind` is "mixtape" | "collection".
|
||||
pub fn create_collection(kind: &str, name: &str) -> Option<(String, String)> {
|
||||
let kind = match kind {
|
||||
"collection" => CollectionKind::Collection,
|
||||
_ => CollectionKind::Mixtape,
|
||||
};
|
||||
crate::myqbz::create_collection(kind, name).map(|c| (c.id, c.name))
|
||||
}
|
||||
@@ -420,7 +420,12 @@ export component AlbumPageView inherits Rectangle {
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
CircleAction { icon: @image-url("../assets/icons/cassette-tape.svg"); }
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/cassette-tape.svg");
|
||||
clicked => {
|
||||
root.media-action("album", AlbumState.id, "add-to-mixtape");
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
|
||||
@@ -304,6 +304,15 @@ export component LocalAlbumView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/cassette-tape.svg");
|
||||
clicked => {
|
||||
LocalAlbumActions.add-to-mixtape();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Version picker (only with multiple physical copies).
|
||||
|
||||
@@ -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, 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, 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`.
|
||||
|
||||
@@ -1330,6 +1330,7 @@ export component LocalLibraryView inherits Rectangle {
|
||||
{ id: "queue", label: @tr("Add to queue"), danger: false, needs-selection: true },
|
||||
{ id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true },
|
||||
{ id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
|
||||
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), danger: false, needs-selection: true },
|
||||
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
|
||||
];
|
||||
action(id) => {
|
||||
@@ -1739,6 +1740,14 @@ export component LocalLibraryView inherits Rectangle {
|
||||
clicked => { LocalLibraryActions.folders-bulk-action("add-to-playlist"); }
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
RailBulkBtn {
|
||||
icon: @image-url("../assets/icons/cassette-tape.svg");
|
||||
tooltip: @tr("Add to Mixtape/Collection");
|
||||
clicked => { LocalLibraryActions.folders-bulk-action("add-to-mixtape"); }
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
RailBulkBtn {
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
// "Add to Mixtape/Collection" picker modal — the global singleton that adds
|
||||
// one or more items (album / track / playlist, from qobuz or local) into an
|
||||
// existing Mixtape/Collection, or creates a brand-new one inline and adds into
|
||||
// it. 1:1 with Tauri's `AddToMixtapeModal.svelte` (spec 21 §A) chrome:
|
||||
//
|
||||
// - own backdrop (z9998) + 480px card (z9999), NOT the shared modal chrome.
|
||||
// - header eyebrow ("ADD TO MIXTAPE/COLLECTION") + title/subtitle (single
|
||||
// item -> title+subtitle; bulk -> "{count} items" + "{first} + N more").
|
||||
// - TWO mutually-exclusive body panels: the PICKER (search row + collection
|
||||
// list + create buttons) and the CREATE-NEW panel (name + kind + footer).
|
||||
// - per-row kind icon, "N albums" meta, kind tag, and an "already added"
|
||||
// hint when the collection already contains every pending item.
|
||||
//
|
||||
// Mounted ONCE at the window root (AppShell). The Rust controller
|
||||
// (crate::myqbz_add) holds the pending items + drives every action; the
|
||||
// "added N / skipped M duplicates" outcome is surfaced via toast.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { MyQbzAddState, MyQbzAddActions, MyQbzAddRow } from "../state.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { LineEdit } from "std-widgets.slint";
|
||||
|
||||
// A single labelled radio option in the create-panel Kind toggle.
|
||||
component KindRadio inherits TouchArea {
|
||||
in property <string> label;
|
||||
in property <bool> selected;
|
||||
mouse-cursor: pointer;
|
||||
horizontal-stretch: 0;
|
||||
HorizontalLayout {
|
||||
spacing: 6px;
|
||||
Rectangle {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
border-radius: 9px;
|
||||
border-width: root.selected ? 0px : 1.5px;
|
||||
border-color: Theme.text-muted;
|
||||
background: root.selected ? Theme.accent : transparent;
|
||||
Rectangle {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
border-radius: 3.5px;
|
||||
background: #ffffff;
|
||||
visible: root.selected;
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: root.label;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One collection row in the picker list.
|
||||
component AddPickRow inherits Rectangle {
|
||||
in property <MyQbzAddRow> data;
|
||||
in property <int> index;
|
||||
in property <bool> busy;
|
||||
callback clicked(string);
|
||||
height: 52px;
|
||||
border-radius: 8px;
|
||||
opacity: root.busy ? 0.5 : 1.0;
|
||||
background: ta.has-hover && !root.busy
|
||||
? #ffffff16
|
||||
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
||||
HorizontalLayout {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
spacing: 12px;
|
||||
// Kind icon.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
QbzIcon {
|
||||
source: root.data.icon == "user"
|
||||
? @image-url("../assets/icons/user.svg")
|
||||
: (root.data.icon == "library-big"
|
||||
? @image-url("../assets/icons/library-big.svg")
|
||||
: @image-url("../assets/icons/cassette-tape.svg"));
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Name + meta.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
Text {
|
||||
text: root.data.name;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.semibold;
|
||||
overflow: elide;
|
||||
}
|
||||
HorizontalLayout {
|
||||
spacing: 6px;
|
||||
Text {
|
||||
text: root.data.meta;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
// "Already added" hint (item_exists for all pending items).
|
||||
if root.data.already-has: HorizontalLayout {
|
||||
spacing: 3px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/circle-check-big.svg");
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
tint: Theme.accent;
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: @tr("Already added");
|
||||
color: Theme.accent;
|
||||
font-size: Typography.legal;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Right-aligned kind tag.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: root.data.kind-label;
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
ta := TouchArea {
|
||||
mouse-cursor: root.busy ? default : pointer;
|
||||
clicked => {
|
||||
if (!root.busy) {
|
||||
root.clicked(root.data.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A small footer button (Cancel / Back / create-buttons).
|
||||
component FooterButton inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> primary: false;
|
||||
in property <bool> enabled-look: true;
|
||||
in property <image> icon;
|
||||
in property <bool> has-icon: false;
|
||||
callback clicked();
|
||||
height: 38px;
|
||||
border-radius: Radius.sm;
|
||||
opacity: root.enabled-look ? 1.0 : 0.5;
|
||||
background: root.primary
|
||||
? (ta.has-hover ? #ffffffd6 : Theme.accent)
|
||||
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
|
||||
border-width: root.primary ? 0px : 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
HorizontalLayout {
|
||||
padding-left: root.primary ? 20px : 16px;
|
||||
padding-right: root.primary ? 20px : 16px;
|
||||
spacing: 6px;
|
||||
alignment: center;
|
||||
if root.has-icon: VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: root.icon;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
tint: root.primary ? #ffffff : Theme.text-primary;
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: root.label;
|
||||
color: root.primary ? #ffffff : Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.semibold;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
ta := TouchArea {
|
||||
mouse-cursor: root.enabled-look ? pointer : default;
|
||||
clicked => {
|
||||
if (root.enabled-look) {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component AddToMixtapeModal inherits Rectangle {
|
||||
visible: MyQbzAddState.open;
|
||||
|
||||
property <bool> create-mixtape: MyQbzAddState.create-kind == "mixtape";
|
||||
property <bool> can-create: MyQbzAddState.create-name != "" && !MyQbzAddState.create-busy;
|
||||
|
||||
if MyQbzAddState.open: Rectangle {
|
||||
background: #00000099; // rgba(0,0,0,0.6)
|
||||
// Scrim — click outside to dismiss.
|
||||
TouchArea {
|
||||
mouse-cursor: default;
|
||||
clicked => {
|
||||
MyQbzAddActions.close();
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: Math.min(root.width - 80px, 480px);
|
||||
height: Math.min(root.height - 120px, 560px);
|
||||
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 so they don't reach the scrim.
|
||||
TouchArea { }
|
||||
|
||||
VerticalLayout {
|
||||
// === Header (always rendered) ===
|
||||
Rectangle {
|
||||
height: header-row.preferred-height + 36px;
|
||||
HorizontalLayout {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-top: 20px;
|
||||
padding-bottom: 16px;
|
||||
header-row := HorizontalLayout {
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
spacing: 2px;
|
||||
Text {
|
||||
text: @tr("ADD TO MIXTAPE/COLLECTION");
|
||||
color: Theme.text-muted;
|
||||
font-size: 10px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
Text {
|
||||
text: MyQbzAddState.header-title;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.section;
|
||||
font-weight: Typography.bold;
|
||||
overflow: elide;
|
||||
}
|
||||
if MyQbzAddState.header-subtitle != "": Text {
|
||||
text: MyQbzAddState.header-subtitle;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
close-x := TouchArea {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
MyQbzAddActions.close();
|
||||
}
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/x.svg");
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Header bottom border.
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
y: parent.height - 1px;
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
// === Body: PICKER panel (default) ===
|
||||
if !MyQbzAddState.creating: VerticalLayout {
|
||||
// Search row.
|
||||
Rectangle {
|
||||
height: 46px;
|
||||
HorizontalLayout {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
spacing: 8px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/search.svg");
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
}
|
||||
search-input := LineEdit {
|
||||
placeholder-text: @tr("Search…");
|
||||
text <=> MyQbzAddState.search;
|
||||
edited(t) => {
|
||||
MyQbzAddActions.search-changed(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
y: parent.height - 1px;
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading spinner.
|
||||
if MyQbzAddState.loading: HorizontalLayout {
|
||||
alignment: center;
|
||||
padding: 24px;
|
||||
LoadingSpinner { size: 30px; }
|
||||
}
|
||||
// Empty state.
|
||||
if !MyQbzAddState.loading && MyQbzAddState.rows.length == 0: Rectangle {
|
||||
height: 80px;
|
||||
Text {
|
||||
text: MyQbzAddState.search != ""
|
||||
? @tr("No matches.")
|
||||
: @tr("No mixtapes or collections yet.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Collection list.
|
||||
Flickable {
|
||||
vertical-stretch: 1;
|
||||
viewport-width: self.width;
|
||||
viewport-height: list.preferred-height;
|
||||
list := VerticalLayout {
|
||||
padding: 8px;
|
||||
spacing: 2px;
|
||||
for row[i] in MyQbzAddState.rows: AddPickRow {
|
||||
data: row;
|
||||
index: i;
|
||||
busy: MyQbzAddState.busy-id != "";
|
||||
clicked(id) => {
|
||||
MyQbzAddActions.pick(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer: create buttons.
|
||||
Rectangle {
|
||||
height: 60px;
|
||||
background: Theme.surface-elevated;
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
HorizontalLayout {
|
||||
padding: 12px;
|
||||
spacing: 8px;
|
||||
FooterButton {
|
||||
horizontal-stretch: 1;
|
||||
label: @tr("Mixtapes");
|
||||
has-icon: true;
|
||||
icon: @image-url("../assets/icons/plus.svg");
|
||||
clicked => {
|
||||
MyQbzAddActions.show-create("mixtape");
|
||||
}
|
||||
}
|
||||
// Hidden for track/playlist payloads (collections hold
|
||||
// whole albums only).
|
||||
if !MyQbzAddState.restrict-to-mixtape: FooterButton {
|
||||
horizontal-stretch: 1;
|
||||
label: @tr("Collections");
|
||||
has-icon: true;
|
||||
icon: @image-url("../assets/icons/plus.svg");
|
||||
clicked => {
|
||||
MyQbzAddActions.show-create("collection");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Body: CREATE-NEW panel ===
|
||||
if MyQbzAddState.creating: VerticalLayout {
|
||||
padding: 20px;
|
||||
spacing: 16px;
|
||||
// Name.
|
||||
VerticalLayout {
|
||||
spacing: 8px;
|
||||
Text {
|
||||
text: @tr("NAME");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
create-name-input := LineEdit {
|
||||
placeholder-text: root.create-mixtape ? "90s Cassettes" : "Jazz Library";
|
||||
text <=> MyQbzAddState.create-name;
|
||||
enabled: !MyQbzAddState.create-busy;
|
||||
accepted => {
|
||||
if (root.can-create) {
|
||||
MyQbzAddActions.create-and-add();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Kind toggle.
|
||||
VerticalLayout {
|
||||
spacing: 8px;
|
||||
Text {
|
||||
text: @tr("KIND");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
HorizontalLayout {
|
||||
spacing: 16px;
|
||||
alignment: start;
|
||||
KindRadio {
|
||||
label: @tr("Mixtapes");
|
||||
selected: root.create-mixtape;
|
||||
clicked => {
|
||||
if (!MyQbzAddState.create-busy) {
|
||||
MyQbzAddState.create-kind = "mixtape";
|
||||
}
|
||||
}
|
||||
}
|
||||
KindRadio {
|
||||
label: @tr("Collections");
|
||||
selected: !root.create-mixtape;
|
||||
clicked => {
|
||||
if (!MyQbzAddState.create-busy) {
|
||||
MyQbzAddState.create-kind = "collection";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Spacer pushes the footer down.
|
||||
Rectangle { vertical-stretch: 1; }
|
||||
// Footer: Back + Create & Add.
|
||||
HorizontalLayout {
|
||||
alignment: end;
|
||||
spacing: 8px;
|
||||
FooterButton {
|
||||
label: @tr("Back");
|
||||
enabled-look: !MyQbzAddState.create-busy;
|
||||
clicked => {
|
||||
MyQbzAddActions.create-back();
|
||||
}
|
||||
}
|
||||
FooterButton {
|
||||
label: @tr("Create & Add");
|
||||
primary: true;
|
||||
enabled-look: root.can-create;
|
||||
clicked => {
|
||||
if (root.can-create) {
|
||||
MyQbzAddActions.create-and-add();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { AddToMixtapeModal } from "../myqbz/AddToMixtapeModal.slint";
|
||||
import { EphemeralPlayChoiceModal } from "../primitives/EphemeralPlayChoiceModal.slint";
|
||||
import { FolderEditModal } from "../primitives/FolderEditModal.slint";
|
||||
import { LibFolderEditModal } from "../settings/LibFolderEditModal.slint";
|
||||
@@ -389,6 +390,10 @@ export component AppShell inherits Rectangle {
|
||||
// Global My QBZ detail edit modals (rename / description / delete-confirm).
|
||||
MyQbzEditModal { }
|
||||
|
||||
// Global "Add to Mixtape/Collection" picker, opened from any track /
|
||||
// album / playlist / bulk-selection "Add to Mixtape/Collection" trigger.
|
||||
AddToMixtapeModal { }
|
||||
|
||||
// Ephemeral "already playing" add-vs-replace choice.
|
||||
EphemeralPlayChoiceModal { }
|
||||
|
||||
|
||||
@@ -697,6 +697,7 @@ export global LocalAlbumActions {
|
||||
callback shuffle();
|
||||
callback edit-tags();
|
||||
callback add-to-playlist();
|
||||
callback add-to-mixtape();
|
||||
callback play-track(string /* track id */);
|
||||
callback select-version(int);
|
||||
// Client-side track filter (mirrors AlbumActions.search), applied over the
|
||||
@@ -1125,6 +1126,76 @@ export global MyQbzCreateActions {
|
||||
callback close();
|
||||
}
|
||||
|
||||
// === "Add to Mixtape/Collection" picker modal (global singleton) =========
|
||||
// Spec 21/50. A global overlay (mounted ONCE at the window root) that adds one
|
||||
// or more items (album / track / playlist, from qobuz or local) into an
|
||||
// existing Mixtape/Collection — or creates a brand-new one inline and adds
|
||||
// into it. Every app-wide "Add to Mixtape/Collection" trigger calls
|
||||
// MyQbzAddActions.open(...) with a payload it built; the Rust controller
|
||||
// (crate::myqbz_add) holds the pending items and drives the picker.
|
||||
//
|
||||
// One picker row = one collection (mixtape | collection | artist_collection),
|
||||
// with its kind label, "N albums" meta, and an `already-has` flag computed
|
||||
// from `item_exists` so the row can show an "already added" hint.
|
||||
export struct MyQbzAddRow {
|
||||
id: string,
|
||||
name: string,
|
||||
// "mixtape" | "collection" | "artist_collection".
|
||||
kind: string,
|
||||
// Row icon by kind ("mixtape" -> cassette, "artist_collection" -> user,
|
||||
// else library-big). Resolved in Rust to a key the row maps to a glyph.
|
||||
icon: string,
|
||||
// Right-aligned kind tag, uppercase ("MIXTAPE" | "COLLECTION" | "ARTIST").
|
||||
kind-label: string,
|
||||
// "N albums" / "1 album" — ICU plural rendered in Rust (always "album(s)").
|
||||
meta: string,
|
||||
// True when this collection already contains ALL of the pending items
|
||||
// (per item_exists). Drives the per-row "already added" hint.
|
||||
already-has: bool,
|
||||
}
|
||||
|
||||
export global MyQbzAddState {
|
||||
in-out property <bool> open: false;
|
||||
// Whether the picker is loading the collection list.
|
||||
in property <bool> loading: false;
|
||||
// The collections offered as targets (already kind-restricted +
|
||||
// recency-sorted + item_exists-resolved by the Rust controller).
|
||||
in property <[MyQbzAddRow]> rows: [];
|
||||
// Header title + subtitle (precomputed: single-item title or bulk
|
||||
// "{count} items" / "{first} + N more").
|
||||
in property <string> header-title;
|
||||
in property <string> header-subtitle;
|
||||
// True when >1 pending item (bulk add).
|
||||
in property <bool> bulk-mode: false;
|
||||
// True when ANY pending item is a track/playlist -> targets restricted to
|
||||
// Mixtapes; the "Create Collection" button is hidden.
|
||||
in property <bool> restrict-to-mixtape: false;
|
||||
// Free-text filter over collection names.
|
||||
in-out property <string> search;
|
||||
// Id of the collection currently being written to (disables rows/buttons).
|
||||
in property <string> busy-id;
|
||||
// --- Create-new sub-panel ---
|
||||
in-out property <bool> creating: false;
|
||||
in-out property <string> create-name;
|
||||
// "mixtape" | "collection".
|
||||
in-out property <string> create-kind: "mixtape";
|
||||
in property <bool> create-busy: false;
|
||||
}
|
||||
|
||||
export global MyQbzAddActions {
|
||||
// Click a collection row -> add the pending items to it.
|
||||
callback pick(string /* collection id */);
|
||||
// Filter the list by name.
|
||||
callback search-changed(string /* query */);
|
||||
// Open the create-new sub-panel preset to a kind ("mixtape"|"collection").
|
||||
callback show-create(string /* kind */);
|
||||
// Back out of the create sub-panel to the picker.
|
||||
callback create-back();
|
||||
// Create a new collection (create-kind/create-name) then add the items.
|
||||
callback create-and-add();
|
||||
callback close();
|
||||
}
|
||||
|
||||
// Ephemeral "play with active queue" choice — shown when the user triggers an
|
||||
// ephemeral play action while a queue is already playing. Two outcomes:
|
||||
// add to the queue, or clear the queue and start playing.
|
||||
|
||||
Reference in new issue
Block a user