fix(myqbz): context menus — z-index, Remove/queue wiring, sidebar + album->collection
- Detail row ⋯ menu (and the hero overflow + filter popups) were PopupWindows
with no explicit height, so they collapsed and the rows below painted over them.
Gave each an explicit height (+ close-on-click) like every other app menu, so
they paint above the rows.
- Row menu Remove now actually removes: it was a no-op ("remove" wasn't a RowMode)
-> wired to remove the item (repo::remove_item by position, reload). Add-to-queue
was also silently broken (emitted "play-later", unparsed) -> now "add-to-queue".
- Sidebar playlist right-click menu: added "Add to Mixtape/Collection" (opens the
picker with the playlist payload).
- Album -> Collection: an album can now be added to ANY collection kind incl.
artist_collection (removed the ArtistCollection exclusion in load_rows; Tauri
allows adding an album to an artist discography).
Offline in the row menu is net-new (Tauri's collection row menu has none; it would
be a batch resolve-then-cache) and is NOT added here. Backend crates untouched.
cargo check -p qbz-slint green.
cc
This commit is contained in:
6 files changed
+125
-5
No files matched your search
@@ -2989,6 +2989,31 @@ fn wire_myqbz_detail(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Per-row REMOVE (single item) -----------------------------------
|
||||||
|
// Routes ONE position through the audited bulk remover (remove-highest-
|
||||||
|
// first compaction + clear-selection + toast + reload) with a 1-element
|
||||||
|
// vec, so single-row remove reuses the exact same code path as the bulk
|
||||||
|
// "remove-selected" action — no duplicated removal logic.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
let image_cache = image_cache.clone();
|
||||||
|
window.global::<Act>().on_remove_item(move |position| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
||||||
|
if id.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
myqbz_edit::remove_selected(
|
||||||
|
weak.clone(),
|
||||||
|
handle.clone(),
|
||||||
|
image_cache.clone(),
|
||||||
|
id,
|
||||||
|
vec![position],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Expanded view-mode: inline tracks under every album/playlist (§8) -
|
// --- Expanded view-mode: inline tracks under every album/playlist (§8) -
|
||||||
// Fired when the expanded view-mode becomes active; fetches each
|
// Fired when the expanded view-mode becomes active; fetches each
|
||||||
// expandable item's tracks (skipping already-cached rows).
|
// expandable item's tracks (skipping already-cached rows).
|
||||||
@@ -7852,6 +7877,47 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
es.set_open(true);
|
es.set_open(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// Add to Mixtape/Collection (sidebar playlist context menu) — build a
|
||||||
|
// 1-item playlist payload from the cached SidebarEntry row + the cached
|
||||||
|
// track count, then open the global AddToMixtapeModal. Because the
|
||||||
|
// item_type is "playlist", `open_add_to_mixtape` computes restrict=true
|
||||||
|
// → the picker lists mixtapes only and hides the "+ Collections" chip (a
|
||||||
|
// playlist can't live in a Collection). Mirrors the PlaylistManager path.
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
window
|
||||||
|
.global::<SidebarActions>()
|
||||||
|
.on_add_to_mixtape(move |id| {
|
||||||
|
use slint::Model;
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let model = w.global::<SidebarState>().get_entries();
|
||||||
|
let Some(row) = (0..model.row_count())
|
||||||
|
.filter_map(|i| model.row_data(i))
|
||||||
|
.find(|e| e.kind == "playlist" && e.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,
|
||||||
|
// SidebarEntry doesn't carry the count; pull it from the
|
||||||
|
// sidebar cache by id (None if unknown — it's optional).
|
||||||
|
track_count: id
|
||||||
|
.parse::<u64>()
|
||||||
|
.ok()
|
||||||
|
.and_then(sidebar::playlist_track_count)
|
||||||
|
.map(|n| n as i32),
|
||||||
|
};
|
||||||
|
open_add_to_mixtape(weak.clone(), handle.clone(), vec![item]);
|
||||||
|
});
|
||||||
|
}
|
||||||
{
|
{
|
||||||
// Edit folder (sidebar context menu) — open the folder editor.
|
// Edit folder (sidebar context menu) — open the folder editor.
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
|
|||||||
@@ -159,9 +159,10 @@ pub fn load_rows(restrict_to_mixtape: bool, items: &[AddItem]) -> Vec<LoadedRow>
|
|||||||
if restrict_to_mixtape {
|
if restrict_to_mixtape {
|
||||||
cols.retain(|c| c.kind == CollectionKind::Mixtape);
|
cols.retain(|c| c.kind == CollectionKind::Mixtape);
|
||||||
} else {
|
} else {
|
||||||
// Albums can land in a Mixtape or a Collection, but NEVER an
|
// An album can be added to ANY collection kind — Mixtape,
|
||||||
// auto-generated ArtistCollection.
|
// Collection, OR Artist Collection. Tauri allows adding an album
|
||||||
cols.retain(|c| c.kind != CollectionKind::ArtistCollection);
|
// to an artist_collection (the user can augment a built
|
||||||
|
// discography), so no kind restriction here.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by last_played_at ?? updated_at DESC.
|
// Sort by last_played_at ?? updated_at DESC.
|
||||||
|
|||||||
@@ -203,6 +203,17 @@ pub fn playlist_name_desc(id: u64) -> Option<(String, String)> {
|
|||||||
NAME_DESC.lock().ok().and_then(|nd| nd.get(&id).cloned())
|
NAME_DESC.lock().ok().and_then(|nd| nd.get(&id).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Total track count for `id`, from the last loaded playlist cache. Used by the
|
||||||
|
/// sidebar "Add to Mixtape/Collection" context action to populate the AddItem
|
||||||
|
/// `track_count` (the SidebarEntry struct doesn't carry it). Returns None when
|
||||||
|
/// the playlist is unknown.
|
||||||
|
pub fn playlist_track_count(id: u64) -> Option<u32> {
|
||||||
|
CACHE
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|c| c.playlists.iter().find(|p| p.id == id).map(|p| p.tracks_count))
|
||||||
|
}
|
||||||
|
|
||||||
/// Store the freshly-loaded data and render it.
|
/// Store the freshly-loaded data and render it.
|
||||||
pub fn apply(window: &AppWindow, data: SidebarData) {
|
pub fn apply(window: &AppWindow, data: SidebarData) {
|
||||||
if let Ok(mut cache) = CACHE.lock() {
|
if let Ok(mut cache) = CACHE.lock() {
|
||||||
|
|||||||
@@ -422,6 +422,14 @@ component DetailRow inherits Rectangle {
|
|||||||
x: menu-btn.width - 184px;
|
x: menu-btn.width - 184px;
|
||||||
y: 28px;
|
y: 28px;
|
||||||
width: 184px;
|
width: 184px;
|
||||||
|
// Explicit height: without it the PopupWindow's box does not
|
||||||
|
// resolve and the in-flow rows below paint over it (the only
|
||||||
|
// PopupWindow in the codebase that omitted this). 4 items
|
||||||
|
// (32px) + 1 separator (1px) + padding (8px) + spacing (4px).
|
||||||
|
height: 145px;
|
||||||
|
// Act-and-dismiss menu — match the app idiom (close on any
|
||||||
|
// click; items also act before the implicit close).
|
||||||
|
close-policy: PopupClosePolicy.close-on-click;
|
||||||
Rectangle {
|
Rectangle {
|
||||||
background: Theme.surface-main;
|
background: Theme.surface-main;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
@@ -445,14 +453,14 @@ component DetailRow inherits Rectangle {
|
|||||||
RowMenuItem {
|
RowMenuItem {
|
||||||
icon: @image-url("../assets/icons/list-music.svg");
|
icon: @image-url("../assets/icons/list-music.svg");
|
||||||
label: @tr("Add to queue");
|
label: @tr("Add to queue");
|
||||||
chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "play-later"); }
|
chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "add-to-queue"); }
|
||||||
}
|
}
|
||||||
Rectangle { height: 1px; background: Theme.surface-elevated; }
|
Rectangle { height: 1px; background: Theme.surface-elevated; }
|
||||||
RowMenuItem {
|
RowMenuItem {
|
||||||
icon: @image-url("../assets/icons/trash-2.svg");
|
icon: @image-url("../assets/icons/trash-2.svg");
|
||||||
label: @tr("Remove from collection");
|
label: @tr("Remove from collection");
|
||||||
destructive: true;
|
destructive: true;
|
||||||
chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "remove"); }
|
chosen => { MyQbzDetailActions.remove-item(root.item.position); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -765,6 +773,11 @@ export component MixtapeDetailView inherits Rectangle {
|
|||||||
x: 0;
|
x: 0;
|
||||||
y: 44px;
|
y: 44px;
|
||||||
width: 220px;
|
width: 220px;
|
||||||
|
// Explicit height (same fix as row-menu) sized
|
||||||
|
// to the MAX item set: 7 items (32px) + 2
|
||||||
|
// separators (1px) + padding (8px) + spacing.
|
||||||
|
height: 250px;
|
||||||
|
close-policy: PopupClosePolicy.close-on-click;
|
||||||
Rectangle {
|
Rectangle {
|
||||||
background: Theme.surface-main;
|
background: Theme.surface-main;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
@@ -918,6 +931,15 @@ export component MixtapeDetailView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
filter-menu := PopupWindow {
|
filter-menu := PopupWindow {
|
||||||
x: 0; y: 36px; width: 200px;
|
x: 0; y: 36px; width: 200px;
|
||||||
|
// Explicit height (same fix as row-menu): 2 section
|
||||||
|
// headers (20px) + 7 options (30px) + 1 separator
|
||||||
|
// (1px) + padding (8px) + spacing.
|
||||||
|
height: 280px;
|
||||||
|
// The Type/Source toggles act-and-keep-open is NOT
|
||||||
|
// wanted here — these are radio/checkbox options that
|
||||||
|
// should stay open while you toggle several, so use
|
||||||
|
// close-on-click-outside (matches the sidebar menu).
|
||||||
|
close-policy: PopupClosePolicy.close-on-click-outside;
|
||||||
Rectangle {
|
Rectangle {
|
||||||
background: Theme.surface-main;
|
background: Theme.surface-main;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ component SidebarRow inherits Rectangle {
|
|||||||
callback edit-folder(string);
|
callback edit-folder(string);
|
||||||
callback hide-playlist(string);
|
callback hide-playlist(string);
|
||||||
callback hide-folder(string);
|
callback hide-folder(string);
|
||||||
|
callback add-to-mixtape(string);
|
||||||
// Fires on row hover enter/leave so the sidebar can reveal the scrollbar
|
// Fires on row hover enter/leave so the sidebar can reveal the scrollbar
|
||||||
// while the pointer is anywhere over the scrollable list.
|
// while the pointer is anywhere over the scrollable list.
|
||||||
callback hover-changed(bool);
|
callback hover-changed(bool);
|
||||||
@@ -404,6 +405,14 @@ component SidebarRow inherits Rectangle {
|
|||||||
menu.close();
|
menu.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !root.is-folder: ContextMenuItem {
|
||||||
|
icon: @image-url("../assets/icons/cassette-tape.svg");
|
||||||
|
label: @tr("Add to Mixtape/Collection");
|
||||||
|
clicked => {
|
||||||
|
root.add-to-mixtape(root.entry.id);
|
||||||
|
menu.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
// Divider before the move-to-folder section.
|
// Divider before the move-to-folder section.
|
||||||
if !root.is-folder: Rectangle {
|
if !root.is-folder: Rectangle {
|
||||||
height: 7px;
|
height: 7px;
|
||||||
@@ -867,6 +876,9 @@ export component Sidebar inherits Rectangle {
|
|||||||
hide-folder(id) => {
|
hide-folder(id) => {
|
||||||
SidebarActions.hide-folder(id);
|
SidebarActions.hide-folder(id);
|
||||||
}
|
}
|
||||||
|
add-to-mixtape(id) => {
|
||||||
|
SidebarActions.add-to-mixtape(id);
|
||||||
|
}
|
||||||
hover-changed(h) => {
|
hover-changed(h) => {
|
||||||
pl-sb.hover-rows = h ? pl-sb.hover-rows + 1 : Math.max(0, pl-sb.hover-rows - 1);
|
pl-sb.hover-rows = h ? pl-sb.hover-rows + 1 : Math.max(0, pl-sb.hover-rows - 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1061,6 +1061,10 @@ export global SidebarActions {
|
|||||||
callback edit-folder(string /* id */);
|
callback edit-folder(string /* id */);
|
||||||
callback hide-playlist(string /* id */);
|
callback hide-playlist(string /* id */);
|
||||||
callback hide-folder(string /* id */);
|
callback hide-folder(string /* id */);
|
||||||
|
// Add a playlist to a Mixtape/Collection (opens the AddToMixtapeModal with
|
||||||
|
// the playlist as a 1-item payload; restricts to mixtapes since a playlist
|
||||||
|
// can't go in a Collection).
|
||||||
|
callback add-to-mixtape(string /* playlist id */);
|
||||||
// Filter `menu-folders` by a case-insensitive substring of the
|
// Filter `menu-folders` by a case-insensitive substring of the
|
||||||
// move-to-folder search box (Rust does the substring match).
|
// move-to-folder search box (Rust does the substring match).
|
||||||
callback search-folders(string /* query */);
|
callback search-folders(string /* query */);
|
||||||
@@ -1711,6 +1715,10 @@ export global MyQbzDetailActions {
|
|||||||
// Per-row affordances.
|
// Per-row affordances.
|
||||||
callback play-item(string /* source-item-id */);
|
callback play-item(string /* source-item-id */);
|
||||||
callback item-action(string /* source-item-id */, string /* action */);
|
callback item-action(string /* source-item-id */, string /* action */);
|
||||||
|
// Remove ONE item from the collection by its 0-based persisted position
|
||||||
|
// (the row menu's "Remove from collection"). Routes to the audited bulk
|
||||||
|
// remover with a single position so compaction + reload + toast are reused.
|
||||||
|
callback remove-item(int /* position */);
|
||||||
// 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 */);
|
||||||
// --- Expanded view-mode inline tracks (spec 12 §8) --------------------
|
// --- Expanded view-mode inline tracks (spec 12 §8) --------------------
|
||||||
|
|||||||
Reference in new issue
Block a user