From e0e1bdfbee28351f4c763a0e069f1d1ef505b258 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 02:29:16 -0600 Subject: [PATCH] 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 --- .../translations/de/LC_MESSAGES/qbz-ui.po | 4 + .../translations/en/LC_MESSAGES/qbz-ui.po | 4 + .../translations/es/LC_MESSAGES/qbz-ui.po | 4 + .../translations/fr/LC_MESSAGES/qbz-ui.po | 4 + .../translations/ja/LC_MESSAGES/qbz-ui.po | 4 + .../translations/nl/LC_MESSAGES/qbz-ui.po | 4 + .../translations/pt/LC_MESSAGES/qbz-ui.po | 4 + .../translations/ru/LC_MESSAGES/qbz-ui.po | 4 + crates/qbz-qt/qml/views/PlaylistView.qml | 32 +++++++- crates/qbz-qt/src/bridge.rs | 6 ++ crates/qbz-qt/src/local_playlist_qt.rs | 42 +++++++++++ crates/qbz-qt/src/main.rs | 31 ++++++++ crates/qbz-qt/src/playlist_qt.rs | 75 ++++++++++++++++++- 13 files changed, 215 insertions(+), 3 deletions(-) diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index 0d6ffcf84..9350ec72b 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -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." diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index 28bb38d97..89d4a7dfb 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -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." diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index 41a5faa7b..71487a0ae 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -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." diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index 373e941ab..cf8555f94 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -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." diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 8f3db3988..1ff5a9aaf 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -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 "{} 曲をこのプレイリストから削除します。" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index ed59317fc..442e84d83 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -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." diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index b4b3dbd79..a862cf6c7 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -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." diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index b72c4540c..e17cb65d9 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -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 "Из этого плейлиста будет удалено {} треков." diff --git a/crates/qbz-qt/qml/views/PlaylistView.qml b/crates/qbz-qt/qml/views/PlaylistView.qml index 551ce3763..c936e4602 100644 --- a/crates/qbz-qt/qml/views/PlaylistView.qml +++ b/crates/qbz-qt/qml/views/PlaylistView.qml @@ -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 = ({}) + } + } } diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs index 6e5903bc8..f1294a38f 100644 --- a/crates/qbz-qt/src/bridge.rs +++ b/crates/qbz-qt/src/bridge.rs @@ -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); } diff --git a/crates/qbz-qt/src/local_playlist_qt.rs b/crates/qbz-qt/src/local_playlist_qt.rs index 0ed121c29..22f9ac1ab 100644 --- a/crates/qbz-qt/src/local_playlist_qt.rs +++ b/crates/qbz-qt/src/local_playlist_qt.rs @@ -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 = { + 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. /// diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 2bd778fb8..0f338c58a 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -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 = 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 = 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::() { + 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` diff --git a/crates/qbz-qt/src/playlist_qt.rs b/crates/qbz-qt/src/playlist_qt.rs index e28286a9c..b19d6bf51 100644 --- a/crates/qbz-qt/src/playlist_qt.rs +++ b/crates/qbz-qt/src/playlist_qt.rs @@ -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>, 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>, catalog_ids: &[u64]) { + let Some((pid, memberships)) = with_doc(|d| { + if !d.is_owner { + return None; + } + let pid = d.id.parse::().ok()?; + let memberships: Vec = d + .tracks + .iter() + .filter(|t| { + t.id.parse::() + .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 = 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()`