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
|
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||||
msgid "System wallpaper"
|
msgid "System wallpaper"
|
||||||
msgstr "System-Hintergrundbild"
|
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
|
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||||
msgid "System wallpaper"
|
msgid "System wallpaper"
|
||||||
msgstr "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
|
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||||
msgid "System wallpaper"
|
msgid "System wallpaper"
|
||||||
msgstr "Fondo del sistema"
|
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
|
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||||
msgid "System wallpaper"
|
msgid "System wallpaper"
|
||||||
msgstr "Fond d'écran du système"
|
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
|
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||||
msgid "System wallpaper"
|
msgid "System wallpaper"
|
||||||
msgstr "システムの壁紙"
|
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
|
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||||
msgid "System wallpaper"
|
msgid "System wallpaper"
|
||||||
msgstr "Systeemachtergrond"
|
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
|
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||||
msgid "System wallpaper"
|
msgid "System wallpaper"
|
||||||
msgstr "Papel de parede do sistema"
|
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
|
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
|
||||||
msgid "System wallpaper"
|
msgid "System wallpaper"
|
||||||
msgstr "Обои системы"
|
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
|
// never reach Rust, everything else goes down as a JSON id array through
|
||||||
// QbzPlayer.bulkTracksAction (bulk_tracks_qt.rs).
|
// QbzPlayer.bulkTracksAction (bulk_tracks_qt.rs).
|
||||||
property bool multiSelect: false
|
property bool multiSelect: false
|
||||||
|
/// The rows a bulk "Remove from playlist" is waiting to confirm.
|
||||||
|
property var pendingRemoveIds: []
|
||||||
property var selected: ({})
|
property var selected: ({})
|
||||||
readonly property int selectedCount: Object.keys(root.selected).length
|
readonly property int selectedCount: Object.keys(root.selected).length
|
||||||
readonly property bool multiSelectOn: root.multiSelect
|
readonly property bool multiSelectOn: root.multiSelect
|
||||||
@@ -108,6 +110,13 @@ Rectangle {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === "clear") { root.selected = ({}); sel.anchorId = ""; 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()
|
var ids = root.selectedIdsInOrder()
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "playlist", String(doc.id || ""))
|
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-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": "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 },
|
{ "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 }
|
{ "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) }
|
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.
|
/// invokable this replaced could not express them.
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn playlist_remove_track(self: Pin<&mut QbzBridge>, row_id: QString);
|
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`.
|
/// Drag-reorder drop: visible row `from` -> insertion slot `slot`.
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn playlist_reorder(self: Pin<&mut QbzBridge>, from: i32, slot: i32);
|
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());
|
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) {
|
pub fn playlist_reorder(self: Pin<&mut Self>, from: i32, slot: i32) {
|
||||||
crate::playlist_reorder(from, slot);
|
crate::playlist_reorder(from, slot);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1580,6 +1580,48 @@ pub async fn remove_row(runtime: &Runtime, row_id: &str) {
|
|||||||
crate::reload_sidebar_including_local();
|
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),
|
/// Reorder the open detail: move the row at `from` to `to` (repo positions),
|
||||||
/// then reload.
|
/// 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`.
|
/// Drag-reorder drop: move the visible row `from` to insertion slot `slot`.
|
||||||
///
|
///
|
||||||
/// Routes like the chevrons below: a LOCAL playlist writes the repo `position`
|
/// Routes like the chevrons below: a LOCAL playlist writes the repo `position`
|
||||||
|
|||||||
@@ -458,7 +458,10 @@ mod sidecar_removal_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_sidecar_is_the_numeric_row_id() {
|
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());
|
assert!(sidecar_removal("local", "local:nope", None).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,7 +881,12 @@ pub(crate) fn map_track(track: &Track) -> PlaylistTrackRow {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
PlaylistTrackRow {
|
PlaylistTrackRow {
|
||||||
position: 0,
|
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
|
// 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
|
// O(1) read `album_qt` / `artist_qt` / `label_qt` rows use. It was
|
||||||
// never stamped here, so `TrackRow.qml` saw `undefined` on every
|
// 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 open document's row ids, IN THE ORDER THE VIEW RENDERS THEM.
|
||||||
///
|
///
|
||||||
/// The Qt twin of the reference's `playlist::full_item_ids()`
|
/// The Qt twin of the reference's `playlist::full_item_ids()`
|
||||||
|
|||||||
Reference in new issue
Block a user