feat(qt): multi-select removes tracks from a playlist
The playlist view's multi-select toolbar gains "Remove from playlist" for the owner of the open playlist, behind a confirm modal that names the count. One dispatcher (`playlist_remove_tracks`) routes the picked row ids by the open document's kind: a local playlist removes by stored position (descending, one blocking pass, then reload); a mixed playlist drops its sidecar rows and batches the remaining catalog ids into a single `remove_tracks_from_playlist` call with an optimistic document patch. Row positions are resolved before any delete so the compaction the local repo performs after each removal cannot shift a later target. cc
This commit is contained in:
13 files changed
+215
-3
No files matched your search
@@ -7719,3 +7719,7 @@ msgstr "Bild wählen…"
|
||||
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||
msgid "System wallpaper"
|
||||
msgstr "System-Hintergrundbild"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "{} Titel werden aus dieser Playlist entfernt."
|
||||
@@ -3374,3 +3374,7 @@ msgstr "Choose image…"
|
||||
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||
msgid "System wallpaper"
|
||||
msgstr "System wallpaper"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "{} tracks will be removed from this playlist."
|
||||
@@ -7725,3 +7725,7 @@ msgstr "Elegir imagen…"
|
||||
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||
msgid "System wallpaper"
|
||||
msgstr "Fondo del sistema"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "Se quitarán {} pistas de esta playlist."
|
||||
@@ -7717,3 +7717,7 @@ msgstr "Choisir une image…"
|
||||
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||
msgid "System wallpaper"
|
||||
msgstr "Fond d'écran du système"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "{} titres seront retirés de cette playlist."
|
||||
@@ -7700,3 +7700,7 @@ msgstr "画像を選択…"
|
||||
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||
msgid "System wallpaper"
|
||||
msgstr "システムの壁紙"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "{} 曲をこのプレイリストから削除します。"
|
||||
@@ -7707,3 +7707,7 @@ msgstr "Afbeelding kiezen…"
|
||||
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||
msgid "System wallpaper"
|
||||
msgstr "Systeemachtergrond"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "{} nummers worden uit deze afspeellijst verwijderd."
|
||||
@@ -7717,3 +7717,7 @@ msgstr "Escolher imagem…"
|
||||
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||
msgid "System wallpaper"
|
||||
msgstr "Papel de parede do sistema"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "{} faixas serão removidas desta playlist."
|
||||
@@ -7738,3 +7738,7 @@ msgstr "Выбрать изображение…"
|
||||
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||
msgid "System wallpaper"
|
||||
msgstr "Обои системы"
|
||||
|
||||
#: crates/qbz-qt/qml/views/PlaylistView.qml
|
||||
msgid "{} tracks will be removed from this playlist."
|
||||
msgstr "Из этого плейлиста будет удалено {} треков."
|
||||
@@ -75,6 +75,8 @@ Rectangle {
|
||||
// never reach Rust, everything else goes down as a JSON id array through
|
||||
// QbzPlayer.bulkTracksAction (bulk_tracks_qt.rs).
|
||||
property bool multiSelect: false
|
||||
/// The rows a bulk "Remove from playlist" is waiting to confirm.
|
||||
property var pendingRemoveIds: []
|
||||
property var selected: ({})
|
||||
readonly property int selectedCount: Object.keys(root.selected).length
|
||||
readonly property bool multiSelectOn: root.multiSelect
|
||||
@@ -108,6 +110,13 @@ Rectangle {
|
||||
return
|
||||
}
|
||||
if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return }
|
||||
if (action === "remove") {
|
||||
var picked = root.selectedIdsInOrder()
|
||||
if (picked.length === 0) return
|
||||
root.pendingRemoveIds = picked
|
||||
removeConfirm.open()
|
||||
return
|
||||
}
|
||||
var ids = root.selectedIdsInOrder()
|
||||
if (ids.length === 0) return
|
||||
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "playlist", String(doc.id || ""))
|
||||
@@ -873,8 +882,11 @@ Rectangle {
|
||||
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
|
||||
{ "id": "add-to-favorites", "label": QbzSession.tr("Add to Library", QbzSession.trRev), "icon": "heart", "danger": false, "needsSelection": true },
|
||||
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
|
||||
// Owner only, like the row menu's entry (2026-09-14): one
|
||||
// confirm, then one bulk remove routed per row.
|
||||
{ "id": "remove", "label": QbzSession.tr("Remove from playlist", QbzSession.trRev), "icon": "trash-2", "danger": true, "needsSelection": true, "hidden": !root.isOwner },
|
||||
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
|
||||
]
|
||||
].filter(function (a) { return a.hidden !== true })
|
||||
onAction: function (id) { root.bulkAction(id) }
|
||||
}
|
||||
|
||||
@@ -1075,4 +1087,22 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-select "Remove from playlist" (2026-09-14): one confirm for the
|
||||
// whole selection, then one bulk remove — playlist_remove_tracks routes
|
||||
// each row the way the single remove does (local repo, sidecar, Qobuz).
|
||||
QbzConfirmModal {
|
||||
id: removeConfirm
|
||||
anchors.fill: parent
|
||||
title: QbzSession.tr("Remove from playlist", QbzSession.trRev)
|
||||
body: QbzSession.tr("{} tracks will be removed from this playlist.", QbzSession.trRev)
|
||||
.replace("{}", root.pendingRemoveIds.length)
|
||||
confirmLabel: QbzSession.tr("Remove", QbzSession.trRev)
|
||||
danger: true
|
||||
onConfirmed: {
|
||||
QbzBridge.playlistRemoveTracks(JSON.stringify(root.pendingRemoveIds))
|
||||
root.pendingRemoveIds = []
|
||||
root.selected = ({})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,8 @@ pub mod qbz_bridge {
|
||||
/// invokable this replaced could not express them.
|
||||
#[qinvokable]
|
||||
fn playlist_remove_track(self: Pin<&mut QbzBridge>, row_id: QString);
|
||||
#[qinvokable]
|
||||
fn playlist_remove_tracks(self: Pin<&mut QbzBridge>, ids_json: QString);
|
||||
/// Drag-reorder drop: visible row `from` -> insertion slot `slot`.
|
||||
#[qinvokable]
|
||||
fn playlist_reorder(self: Pin<&mut QbzBridge>, from: i32, slot: i32);
|
||||
@@ -394,6 +396,10 @@ impl qbz_bridge::QbzBridge {
|
||||
crate::playlist_remove_track(row_id.to_string());
|
||||
}
|
||||
|
||||
pub fn playlist_remove_tracks(self: Pin<&mut Self>, ids_json: QString) {
|
||||
crate::playlist_remove_tracks(ids_json.to_string());
|
||||
}
|
||||
|
||||
pub fn playlist_reorder(self: Pin<&mut Self>, from: i32, slot: i32) {
|
||||
crate::playlist_reorder(from, slot);
|
||||
}
|
||||
|
||||
@@ -1580,6 +1580,48 @@ pub async fn remove_row(runtime: &Runtime, row_id: &str) {
|
||||
crate::reload_sidebar_including_local();
|
||||
}
|
||||
|
||||
/// `remove_row` for a SELECTION: the repo positions are taken up front and
|
||||
/// removed HIGHEST FIRST — `repo::remove_track` compacts the positions above
|
||||
/// the one it deletes, so any other order would shift the rows still to
|
||||
/// come — then one reload and one sidebar refresh.
|
||||
pub async fn remove_rows(runtime: &Runtime, row_ids: &[String]) {
|
||||
let Some((playlist_id, _)) = CURRENT_META.lock().ok().and_then(|m| m.clone()) else {
|
||||
return;
|
||||
};
|
||||
let mut positions: Vec<i32> = {
|
||||
let Ok(map) = ROW_POSITIONS.lock() else {
|
||||
return;
|
||||
};
|
||||
row_ids
|
||||
.iter()
|
||||
.filter_map(|id| map.get(id).copied())
|
||||
.collect()
|
||||
};
|
||||
positions.sort_unstable_by(|a, b| b.cmp(a));
|
||||
positions.dedup();
|
||||
if positions.is_empty() {
|
||||
log::warn!(
|
||||
"[qbz-qt] local playlist bulk remove: no known rows among {} id(s)",
|
||||
row_ids.len()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let pid = playlist_id.clone();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
with_db(true, |db| {
|
||||
Ok(db.with_connection(|conn| -> rusqlite::Result<()> {
|
||||
for position in positions {
|
||||
repo::remove_track(conn, &pid, position)?;
|
||||
}
|
||||
Ok(())
|
||||
}))
|
||||
})
|
||||
})
|
||||
.await;
|
||||
load(runtime, &playlist_id).await;
|
||||
crate::reload_sidebar_including_local();
|
||||
}
|
||||
|
||||
/// Reorder the open detail: move the row at `from` to `to` (repo positions),
|
||||
/// then reload.
|
||||
///
|
||||
|
||||
@@ -2174,6 +2174,37 @@ pub(crate) fn playlist_remove_track(row_id: String) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Multi-select "Remove from playlist": the three arms of
|
||||
/// `playlist_remove_track` over every selected row — the local repo rows in
|
||||
/// one pass, the sidecar rows one by one (each is its own table write), the
|
||||
/// Qobuz memberships in ONE API call.
|
||||
pub(crate) fn playlist_remove_tracks(ids_json: String) {
|
||||
let ids: Vec<String> = serde_json::from_str(&ids_json).unwrap_or_default();
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let runtime = app();
|
||||
spawn(async move {
|
||||
if local_playlist_qt::local_detail_open() {
|
||||
local_playlist_qt::remove_rows(&runtime, &ids).await;
|
||||
return;
|
||||
}
|
||||
let mut catalog: Vec<u64> = Vec::new();
|
||||
for row_id in &ids {
|
||||
if playlist_qt::is_mixed() && playlist_qt::remove_sidecar_row(&runtime, row_id).await {
|
||||
continue;
|
||||
}
|
||||
match row_id.parse::<u64>() {
|
||||
Ok(id) => catalog.push(id),
|
||||
Err(_) => log::warn!("[qbz-qt] playlist bulk remove: non-numeric row id {row_id}"),
|
||||
}
|
||||
}
|
||||
if !catalog.is_empty() {
|
||||
playlist_qt::remove_tracks(&runtime, &catalog).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Drag-reorder drop: move the visible row `from` to insertion slot `slot`.
|
||||
///
|
||||
/// Routes like the chevrons below: a LOCAL playlist writes the repo `position`
|
||||
|
||||
@@ -458,7 +458,10 @@ mod sidecar_removal_tests {
|
||||
|
||||
#[test]
|
||||
fn local_sidecar_is_the_numeric_row_id() {
|
||||
assert!(matches!(sidecar_removal("local", "4321", None), Some(SidecarRemoval::Local(4321))));
|
||||
assert!(matches!(
|
||||
sidecar_removal("local", "4321", None),
|
||||
Some(SidecarRemoval::Local(4321))
|
||||
));
|
||||
assert!(sidecar_removal("local", "local:nope", None).is_none());
|
||||
}
|
||||
|
||||
@@ -878,7 +881,12 @@ pub(crate) fn map_track(track: &Track) -> PlaylistTrackRow {
|
||||
.unwrap_or_default();
|
||||
PlaylistTrackRow {
|
||||
position: 0,
|
||||
featured: crate::album_qt::featured_list(track.performers.as_deref(), &artist, &track.title, &[]),
|
||||
featured: crate::album_qt::featured_list(
|
||||
track.performers.as_deref(),
|
||||
&artist,
|
||||
&track.title,
|
||||
&[],
|
||||
),
|
||||
// Heart state at build time, from the favourite-id cache — the same
|
||||
// O(1) read `album_qt` / `artist_qt` / `label_qt` rows use. It was
|
||||
// never stamped here, so `TrackRow.qml` saw `undefined` on every
|
||||
@@ -2067,6 +2075,69 @@ pub async fn remove_track(runtime: &Arc<AppRuntime<LoggingAdapter>>, catalog_id:
|
||||
}
|
||||
}
|
||||
|
||||
/// `remove_track` for a SELECTION: every matching membership leaves the
|
||||
/// document at once and the API is asked ONCE (`playlist/deleteTracks` takes
|
||||
/// the list). Same optimistic removal, same reconcile-by-reload on failure.
|
||||
pub async fn remove_tracks(runtime: &Arc<AppRuntime<LoggingAdapter>>, catalog_ids: &[u64]) {
|
||||
let Some((pid, memberships)) = with_doc(|d| {
|
||||
if !d.is_owner {
|
||||
return None;
|
||||
}
|
||||
let pid = d.id.parse::<u64>().ok()?;
|
||||
let memberships: Vec<u64> = d
|
||||
.tracks
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t.id.parse::<u64>()
|
||||
.ok()
|
||||
.is_some_and(|id| catalog_ids.contains(&id))
|
||||
})
|
||||
.map(|t| t.playlist_track_id)
|
||||
.collect();
|
||||
Some((pid, memberships))
|
||||
})
|
||||
.flatten() else {
|
||||
return;
|
||||
};
|
||||
if memberships.is_empty() {
|
||||
return;
|
||||
}
|
||||
with_doc(|d| {
|
||||
d.tracks
|
||||
.retain(|t| !memberships.contains(&t.playlist_track_id));
|
||||
d.track_count = d.tracks.len() as i32;
|
||||
d.total_duration = total_duration_label(&d.tracks);
|
||||
let doc = d.clone();
|
||||
publish(&doc);
|
||||
});
|
||||
match runtime
|
||||
.core()
|
||||
.remove_tracks_from_playlist(pid, &memberships)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let removed: Vec<u64> = catalog_ids.to_vec();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
crate::library_db_qt::with_db(true, |db| {
|
||||
Ok(db.with_connection(|conn| {
|
||||
qbz_library::qobuz_playlist_snapshot::apply_removed_tracks(
|
||||
conn, pid, &removed,
|
||||
)
|
||||
}))
|
||||
})
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[qbz-qt] remove {} membership(s) from playlist {pid} failed: {e}",
|
||||
memberships.len()
|
||||
);
|
||||
let _ = load(runtime, pid).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The open document's row ids, IN THE ORDER THE VIEW RENDERS THEM.
|
||||
///
|
||||
/// The Qt twin of the reference's `playlist::full_item_ids()`
|
||||
|
||||
Reference in new issue
Block a user