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:
vicrodh committed 2026-09-14 02:29:16 -06:00
1 parent 1c45c7f2b9
commit e0e1bdfbee
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 "Из этого плейлиста будет удалено {} треков."
+31 -1
View File
@@ -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 = ({})
}
}
}
+6
View File
@@ -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);
}
+42
View File
@@ -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.
///
+31
View File
@@ -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`
+73 -2
View File
@@ -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()`