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 9106acd0e..948b158cb 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -7626,3 +7626,15 @@ msgstr "In diesem Netzwerk" #: qbz-qt/qml/shell/QconnectFlyout.qml msgid "Pair" msgstr "Koppeln" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist" +msgstr "Playlist löschen" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist?" +msgstr "Playlist löschen?" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "“{}” will be deleted. This cannot be undone." +msgstr "„{}“ wird gelöscht. Das lässt sich nicht rückgängig machen." 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 f522c6b34..b4a6c5170 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -3265,3 +3265,27 @@ msgstr "On this network" #: qbz-qt/qml/shell/QconnectFlyout.qml msgid "Pair" msgstr "Pair" + +#: qbz-qt/qml/controls/PlaylistEditModal.qml +msgid "Folder" +msgstr "Folder" + +#: qbz-qt/qml/controls/PlaylistEditModal.qml +msgid "No folder" +msgstr "No folder" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist" +msgstr "Delete playlist" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist?" +msgstr "Delete playlist?" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "“{}” will be deleted. This cannot be undone." +msgstr "“{}” will be deleted. This cannot be undone." + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete" +msgstr "Delete" 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 ab4ddf21b..95d975d43 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -7632,3 +7632,15 @@ msgstr "En esta red" #: qbz-qt/qml/shell/QconnectFlyout.qml msgid "Pair" msgstr "Vincular" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist" +msgstr "Eliminar lista de reproducción" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist?" +msgstr "¿Eliminar la lista de reproducción?" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "“{}” will be deleted. This cannot be undone." +msgstr "«{}» se eliminará. Esto no se puede deshacer." 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 3dc0b742d..7b680af39 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -7624,3 +7624,15 @@ msgstr "Sur ce réseau" #: qbz-qt/qml/shell/QconnectFlyout.qml msgid "Pair" msgstr "Associer" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist" +msgstr "Supprimer la playlist" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist?" +msgstr "Supprimer la playlist ?" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "“{}” will be deleted. This cannot be undone." +msgstr "« {} » sera supprimée. Cette action est irréversible." 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 a02834193..60536dc07 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -7607,3 +7607,15 @@ msgstr "このネットワーク上" #: qbz-qt/qml/shell/QconnectFlyout.qml msgid "Pair" msgstr "ペアリング" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist" +msgstr "プレイリストを削除" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist?" +msgstr "プレイリストを削除しますか?" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "“{}” will be deleted. This cannot be undone." +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 b14c69e6b..550c6bbe5 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -7614,3 +7614,15 @@ msgstr "In dit netwerk" #: qbz-qt/qml/shell/QconnectFlyout.qml msgid "Pair" msgstr "Koppelen" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist" +msgstr "Afspeellijst verwijderen" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist?" +msgstr "Afspeellijst verwijderen?" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "“{}” will be deleted. This cannot be undone." +msgstr "“{}” wordt verwijderd. Dit kan niet ongedaan worden gemaakt." 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 d0b77843a..aac3b12c9 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -7624,3 +7624,15 @@ msgstr "Nesta rede" #: qbz-qt/qml/shell/QconnectFlyout.qml msgid "Pair" msgstr "Parear" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist" +msgstr "Excluir playlist" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist?" +msgstr "Excluir a playlist?" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "“{}” will be deleted. This cannot be undone." +msgstr "“{}” será excluída. Isso não pode ser desfeito." 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 3df97f405..f5496a52d 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -7645,3 +7645,15 @@ msgstr "В этой сети" #: qbz-qt/qml/shell/QconnectFlyout.qml msgid "Pair" msgstr "Подключить" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist" +msgstr "Удалить плейлист" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "Delete playlist?" +msgstr "Удалить плейлист?" + +#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml +msgid "“{}” will be deleted. This cannot be undone." +msgstr "«{}» будет удалён. Это действие нельзя отменить." diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 4c62da8a2..1d193521d 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -570,6 +570,7 @@ fn main() { "qml/controls/MyQbzModals.qml", "qml/controls/PlaylistCreateModal.qml", "qml/controls/PlaylistEditModal.qml", + "qml/controls/PlaylistDeleteConfirm.qml", "qml/controls/PlaylistImportModal.qml", "qml/controls/PlaylistPickerModal.qml", "qml/controls/PmFolderIcon.qml", diff --git a/crates/qbz-qt/qml/cards/PlaylistCard.qml b/crates/qbz-qt/qml/cards/PlaylistCard.qml index 33733e3de..d90fe7cde 100644 --- a/crates/qbz-qt/qml/cards/PlaylistCard.qml +++ b/crates/qbz-qt/qml/cards/PlaylistCard.qml @@ -392,6 +392,11 @@ Rectangle { m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" }) if (root.hasCopyByIdSeam && !owned && root.item.playlistCopied !== true) m.push({ "label": t("Copy to your library", r), "icon": "copy", "action": "copy" }) + // Delete (2026-09-13): the fast path from search / library / browse + // cards, behind the shell-level confirmation. Owned playlists only — + // a followed one has Unfollow above. + if (owned) + m.push({ "label": t("Delete playlist", r), "icon": "trash-2", "action": "delete", "danger": true }) return m } @@ -415,5 +420,6 @@ Rectangle { }])) } else if (a === "cache") QbzOffline.cachePlaylist(String(root.item.id)) + else if (a === "delete") QbzPlaylistEdit.askDelete(String(root.item.id), String(root.item.title || "")) } } diff --git a/crates/qbz-qt/qml/controls/PlaylistDeleteConfirm.qml b/crates/qbz-qt/qml/controls/PlaylistDeleteConfirm.qml new file mode 100644 index 000000000..d4ff9ba5a --- /dev/null +++ b/crates/qbz-qt/qml/controls/PlaylistDeleteConfirm.qml @@ -0,0 +1,42 @@ +// The "Delete playlist?" confirmation summoned from context menus — the +// sidebar row menu and the playlist card (2026-09-13). Self-gates on +// QbzPlaylistEdit.deleteJson ({open, id, name, isLocal}) the way FolderModals +// gates on editJson, and lives at the AppShell level for the same reason: the +// sidebar clips to width 0 when collapsed, so a modal parented into it would +// vanish with it. Delete / Cancel go back through the bridge; Rust runs the +// same delete the editor's own button runs and refreshes the surfaces. +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + QbzTheme { id: theme } + + function t(s) { return QbzSession.tr(s, QbzSession.trRev) } + + readonly property var doc: { + try { return JSON.parse(QbzPlaylistEdit.deleteJson) } catch (e) { return ({}) } + } + readonly property bool askOpen: root.doc.open === true + + onAskOpenChanged: { + if (root.askOpen) + confirm.open() + else if (confirm.opened) + confirm.close() + } + + QbzConfirmModal { + id: confirm + anchors.fill: parent + danger: true + title: root.t("Delete playlist?") + body: root.t("\u201c{}\u201d will be deleted. This cannot be undone.") + .replace("{}", root.doc.name || "") + confirmLabel: root.t("Delete") + onConfirmed: QbzPlaylistEdit.confirmDelete() + onCancelled: QbzPlaylistEdit.cancelDelete() + } +} diff --git a/crates/qbz-qt/qml/controls/PlaylistEditModal.qml b/crates/qbz-qt/qml/controls/PlaylistEditModal.qml index 6d85f9cd7..55e7cf195 100644 --- a/crates/qbz-qt/qml/controls/PlaylistEditModal.qml +++ b/crates/qbz-qt/qml/controls/PlaylistEditModal.qml @@ -98,6 +98,25 @@ Item { property string draftName: "" property string draftDescription: "" property bool draftOfflineOnly: false + /// The folder the playlist will be filed in on Save ("" = root). Seeded + /// from `doc.folderId`; the move rides QbzPlaylistManager.moveToFolder, + /// the same verb the sidebar row menu uses (2026-09-13). + property string draftFolderId: "" + readonly property var folders: { + try { + var f = JSON.parse(QbzPlaylistManager.foldersJson || "[]") + return Array.isArray(f) ? f : [] + } catch (e) { + return [] + } + } + readonly property var folderOptions: [root.t("No folder")].concat( + root.folders.map(function (f) { return String(f.name) })) + readonly property int folderIndex: { + for (var i = 0; i < root.folders.length; i++) + if (String(root.folders[i].id) === root.draftFolderId) return i + 1 + return 0 + } readonly property bool canSave: root.draftName.trim() !== "" && !root.busy @@ -115,6 +134,7 @@ Item { root.draftName = root.doc.name || "" root.draftDescription = root.doc.description || "" root.draftOfflineOnly = root.doc.offlineOnly === true + root.draftFolderId = root.doc.folderId || "" scope.forceActiveFocus() nameField.focusField() } @@ -138,9 +158,14 @@ Item { } function submit() { - if (root.canSave) - QbzPlaylistEdit.save(root.draftName, root.draftDescription, - root.draftOfflineOnly) + if (!root.canSave) + return + // The folder is a separate write (the sidebar's own move verb, with + // its optimistic patch); only when it changed. + if (root.draftFolderId !== (root.doc.folderId || "")) + QbzPlaylistManager.moveToFolder(String(root.doc.id), root.draftFolderId) + QbzPlaylistEdit.save(root.draftName, root.draftDescription, + root.draftOfflineOnly) } FocusScope { @@ -323,6 +348,27 @@ Item { // from its tallest child, so anchoring a child to // `parent.verticalCenter` inside one is a binding loop. Same // shape FolderEditPanel.qml's hidden row uses. + // Folder (2026-09-13): the same folders the sidebar and the + // manager file playlists into; "No folder" = the root. + Column { + width: parent.width + spacing: 6 + Text { + text: root.t("Folder") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + QbzSelect { + menuWidth: 260 + options: root.folderOptions + currentIndex: root.folderIndex + enabled: !root.busy + onSelected: function (i) { + root.draftFolderId = i <= 0 ? "" : String(root.folders[i - 1].id) + } + } + } + Item { width: parent.width height: 20 diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index 09b6a3ced..12c811950 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -950,6 +950,13 @@ Rectangle { PlaylistEditModal { anchors.fill: parent } + // PlaylistDeleteConfirm — the "Delete playlist?" confirmation the sidebar + // row menu and the playlist cards summon (QbzPlaylistEdit.askDelete). + // Self-gates on QbzPlaylistEdit.deleteJson; invisible and inert while + // closed. Out here for the same reason as FolderModals above. + PlaylistDeleteConfirm { + anchors.fill: parent + } // PlaylistCreateModal — "New playlist" (name · description · folder · // public · offline-only), raised by the sidebar's "+" and, like its // neighbour, self-gating on its own document (QbzPlaylistEdit.createJson). diff --git a/crates/qbz-qt/qml/shell/SidebarRowMenu.qml b/crates/qbz-qt/qml/shell/SidebarRowMenu.qml index fb5163be3..da0b12c6e 100644 --- a/crates/qbz-qt/qml/shell/SidebarRowMenu.qml +++ b/crates/qbz-qt/qml/shell/SidebarRowMenu.qml @@ -89,6 +89,10 @@ QbzContextMenu { readonly property bool isFolder: root.entry !== null && root.entry.kind === "folder" readonly property bool isLocal: root.entry !== null && root.entry.isLocal === true + /// The signed-in user owns the Qobuz playlist (sidebar_qt stamps it); + /// a local playlist is always the user's own. + readonly property bool isOwner: root.entry !== null + && (root.entry.isLocal === true || root.entry.isOwner === true) readonly property string entryId: root.entry !== null ? String(root.entry.id) : "" readonly property string entryFolderId: (root.entry !== null && root.entry.folderId) ? String(root.entry.folderId) : "" @@ -261,6 +265,19 @@ QbzContextMenu { } } // Divider before the move-to-folder section — Qobuz only. + // Delete (2026-09-13): the fast path the editor's danger button already + // offers, one click from the row, behind the shell-level confirmation + // (PlaylistDeleteConfirm.qml). Owned Qobuz playlists and local ones only; + // a followed playlist keeps its Unfollow elsewhere. + MenuRow { + visible: !root.isFolder && root.isOwner + icon: "trash-2" + label: QbzSession.tr("Delete playlist", QbzSession.trRev) + onActivated: { + root.close() + QbzPlaylistEdit.askDelete(root.entryId, String(root.entry.name)) + } + } Item { visible: !root.isFolder && !root.isLocal width: parent ? parent.width : 0 diff --git a/crates/qbz-qt/src/playlist_edit_bridge.rs b/crates/qbz-qt/src/playlist_edit_bridge.rs index 5fb8165ef..1ff1d7430 100644 --- a/crates/qbz-qt/src/playlist_edit_bridge.rs +++ b/crates/qbz-qt/src/playlist_edit_bridge.rs @@ -75,6 +75,9 @@ pub mod qbz_playlist_edit_bridge { // descLoaded / isLocal / offlineOnly / busy. Parseable default so a // binding reading `doc.open` on the pre-publish frame cannot throw. #[qproperty(QString, edit_json)] + /// The "Delete playlist?" confirmation summoned from context menus: + /// `{open, id, name, isLocal}` (`playlist_edit_qt::DeleteDoc`). + #[qproperty(QString, delete_json)] /// The "New playlist" document (`playlist_create_qt`): open / busy / /// offlineLocked / folders. Same parseable-default rule as `edit_json`. /// @@ -138,6 +141,16 @@ pub mod qbz_playlist_edit_bridge { #[qinvokable] fn delete_playlist(self: Pin<&mut QbzPlaylistEdit>); + /// Context menus: open the shell-level "Delete playlist?" + /// confirmation for `id` (Qobuz id or local ref), showing `name`. + #[qinvokable] + fn ask_delete(self: Pin<&mut QbzPlaylistEdit>, id: QString, name: QString); + /// The confirmation's Delete / Cancel. + #[qinvokable] + fn confirm_delete(self: Pin<&mut QbzPlaylistEdit>); + #[qinvokable] + fn cancel_delete(self: Pin<&mut QbzPlaylistEdit>); + /// Dismiss. Refused while a save or delete is in flight (D22). #[qinvokable] fn close(self: Pin<&mut QbzPlaylistEdit>); @@ -180,6 +193,7 @@ use qbz_playlist_edit_bridge::QbzPlaylistEdit; pub struct QbzPlaylistEditRust { edit_json: QString, create_json: QString, + delete_json: QString, } impl Default for QbzPlaylistEditRust { @@ -189,6 +203,7 @@ impl Default for QbzPlaylistEditRust { // and no binding in the modal throws before the first publish. edit_json: QString::from("{\"open\":false}"), create_json: QString::from("{\"open\":false}"), + delete_json: QString::from("{\"open\":false}"), } } } @@ -216,6 +231,7 @@ impl qbz_playlist_edit_bridge::QbzPlaylistEdit { // full shape rather than the terser Default literal. crate::playlist_edit_qt::publish(); crate::playlist_create_qt::publish(); + crate::playlist_edit_qt::publish_delete_ask(); } pub fn open(self: Pin<&mut Self>, id: QString) { @@ -238,6 +254,18 @@ impl qbz_playlist_edit_bridge::QbzPlaylistEdit { crate::playlist_edit_qt::delete_playlist(); } + pub fn ask_delete(self: Pin<&mut Self>, id: QString, name: QString) { + crate::playlist_edit_qt::ask_delete(&id.to_string(), &name.to_string()); + } + + pub fn confirm_delete(self: Pin<&mut Self>) { + crate::playlist_edit_qt::confirm_delete(); + } + + pub fn cancel_delete(self: Pin<&mut Self>) { + crate::playlist_edit_qt::cancel_delete(); + } + pub fn close(self: Pin<&mut Self>) { crate::playlist_edit_qt::close(); } diff --git a/crates/qbz-qt/src/playlist_edit_qt.rs b/crates/qbz-qt/src/playlist_edit_qt.rs index fb23f153f..43aa88924 100644 --- a/crates/qbz-qt/src/playlist_edit_qt.rs +++ b/crates/qbz-qt/src/playlist_edit_qt.rs @@ -85,10 +85,128 @@ struct EditState { is_local: bool, offline_only: bool, busy: bool, + /// The folder the playlist is filed in ("" = root) — the editor's + /// "Folder" row (2026-09-13). + folder_id: String, } static EDIT: LazyLock> = LazyLock::new(|| Mutex::new(EditState::default())); +/// The "Delete playlist?" confirmation summoned from a context menu (sidebar +/// row, playlist card) — the same delete the editor's button runs, minus the +/// editor. Published as `QbzPlaylistEdit.deleteJson`; the shell-level modal +/// self-gates on `open` (2026-09-13). +#[derive(Clone, Debug, Default)] +struct DeleteAsk { + open: bool, + id: String, + name: String, + is_local: bool, +} + +static DELETE_ASK: LazyLock> = + LazyLock::new(|| Mutex::new(DeleteAsk::default())); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DeleteDoc { + open: bool, + id: String, + name: String, + is_local: bool, +} + +pub(crate) fn publish_delete_ask() { + let ask = DELETE_ASK.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let json = serde_json::to_string(&DeleteDoc { + open: ask.open, + id: ask.id, + name: ask.name, + is_local: ask.is_local, + }) + .unwrap_or_else(|_| "{\"open\":false}".to_string()); + crate::playlist_edit_bridge::ui(move |mut b| { + b.as_mut().set_delete_json(QString::from(json.as_str())); + }); +} + +/// Open the confirmation for `id` (a Qobuz id or a local ref), showing `name`. +pub(crate) fn ask_delete(id: &str, name: &str) { + let id = id.trim().to_string(); + if id.is_empty() { + return; + } + let is_local = matches!( + crate::local_playlist_qt::PlaylistRef::parse(&id), + Some(crate::local_playlist_qt::PlaylistRef::Local(_)) + ); + { + let mut ask = DELETE_ASK.lock().unwrap_or_else(|e| e.into_inner()); + *ask = DeleteAsk { + open: true, + id, + name: name.to_string(), + is_local, + }; + } + publish_delete_ask(); +} + +pub(crate) fn cancel_delete() { + DELETE_ASK.lock().unwrap_or_else(|e| e.into_inner()).open = false; + publish_delete_ask(); +} + +/// The modal's Delete: run the same delete the editor runs, refresh the +/// surfaces, and leave the detail page only if it was the deleted one. +pub(crate) fn confirm_delete() { + let (id, is_local) = { + let mut ask = DELETE_ASK.lock().unwrap_or_else(|e| e.into_inner()); + if !ask.open || ask.id.is_empty() { + return; + } + ask.open = false; + (std::mem::take(&mut ask.id), ask.is_local) + }; + publish_delete_ask(); + crate::spawn(async move { + let ok = delete_ref(&id, is_local).await; + if ok { + crate::playlist_qt::back_if_showing(&id); + after_write(); + } else { + log::warn!("[qbz-qt] playlist delete (from menu) failed for {id}"); + crate::toast_qt::error(qbz_i18n::t("Failed to delete playlist")); + } + }); +} + +/// Delete a playlist by ref: the local repo for a local one, the catalog +/// (delete when owned, unsubscribe otherwise) for a Qobuz one. +async fn delete_ref(id: &str, is_local: bool) -> bool { + if is_local { + let target = id.to_string(); + return tokio::task::spawn_blocking(move || { + crate::local_playlist_qt::delete_blocking(&target) + }) + .await + .unwrap_or(false); + } + match id.parse::() { + Ok(pid) => { + let runtime = crate::app(); + match crate::playlist_qt::delete_by_id(&runtime, pid).await { + Ok(()) => true, + Err(e) => { + log::error!("[qbz-qt] {e}"); + false + } + } + } + Err(_) => false, + } +} + fn state() -> std::sync::MutexGuard<'static, EditState> { EDIT.lock().unwrap_or_else(|e| e.into_inner()) } @@ -108,6 +226,9 @@ struct EditDoc { is_local: bool, offline_only: bool, busy: bool, + /// The folder the playlist is filed in ("" = root), so the editor can + /// offer "Folder" next to the name (2026-09-13). + folder_id: String, } /// Serialize a snapshot. Split from [`edit_doc`] so the shape is testable @@ -123,6 +244,7 @@ fn edit_doc_of(st: EditState) -> String { is_local: st.is_local, offline_only: st.offline_only, busy: st.busy, + folder_id: st.folder_id, }) .unwrap_or_else(|_| "{\"open\":false}".to_string()) } @@ -171,6 +293,27 @@ struct Seed { desc_loaded: bool, is_local: bool, offline_only: bool, + folder_id: String, +} + +/// The folder a playlist is filed in, read off the same tables the sidebar +/// reads: `local_playlists.folder_id` for a local one, the per-playlist +/// settings for a Qobuz one. Blocking (SQLite); call off the UI thread. +fn folder_of_blocking(id: &str) -> String { + match crate::local_playlist_qt::PlaylistRef::parse(id) { + Some(crate::local_playlist_qt::PlaylistRef::Local(local)) => { + crate::local_playlist_qt::get_blocking(&local) + .and_then(|p| p.folder_id) + .unwrap_or_default() + } + Some(crate::local_playlist_qt::PlaylistRef::Qobuz(pid)) => { + crate::folders_qt::playlist_settings_map() + .get(&pid) + .and_then(|s| s.folder_id.clone()) + .unwrap_or_default() + } + None => String::new(), + } } /// Open the editor on `id`. @@ -218,6 +361,7 @@ fn open_local(id: String) { seat( id, Seed { + folder_id: p.folder_id.clone().unwrap_or_default(), name: p.name, description: p.description.unwrap_or_default(), // A local read either answered or we returned above. @@ -233,20 +377,32 @@ fn open_qobuz(id: String, pid: u64) { // Warm manager cache first: it is free, it is already merged, and it // carries the description precisely so this lookup does not need a fetch. if let Some((name, description)) = crate::playlist_manager_qt::cached_playlist_seed(pid) { - seat( - id, - Seed { - name, - description: description.unwrap_or_default(), - desc_loaded: true, - is_local: false, - offline_only: false, - }, - ); + // The folder is one SQLite read; keep it off the UI thread. + crate::spawn(async move { + let lookup = id.clone(); + let folder_id = tokio::task::spawn_blocking(move || folder_of_blocking(&lookup)) + .await + .unwrap_or_default(); + seat( + id, + Seed { + name, + description: description.unwrap_or_default(), + desc_loaded: true, + is_local: false, + offline_only: false, + folder_id, + }, + ); + }); return; } let runtime = crate::app(); crate::spawn(async move { + let lookup = id.clone(); + let folder_id = tokio::task::spawn_blocking(move || folder_of_blocking(&lookup)) + .await + .unwrap_or_default(); let seed = match runtime.core().get_playlist(pid).await { Ok(p) => Seed { name: p.name, @@ -254,6 +410,7 @@ fn open_qobuz(id: String, pid: u64) { desc_loaded: true, is_local: false, offline_only: false, + folder_id: folder_id.clone(), }, Err(e) => { // Offline, or the playlist is gone. The editor still opens — @@ -268,6 +425,7 @@ fn open_qobuz(id: String, pid: u64) { desc_loaded: false, is_local: false, offline_only: false, + folder_id, } } }; @@ -288,6 +446,7 @@ fn seat(id: String, seed: Seed) { is_local: seed.is_local, offline_only: seed.offline_only, busy: false, + folder_id: seed.folder_id, }; } publish(); @@ -421,28 +580,7 @@ pub(crate) fn delete_playlist() { publish(); crate::spawn(async move { - let ok = if is_local { - let target = id.clone(); - tokio::task::spawn_blocking(move || crate::local_playlist_qt::delete_blocking(&target)) - .await - .unwrap_or(false) - } else { - match id.parse::() { - Ok(pid) => { - let runtime = crate::app(); - // The ownership re-derivation and the unsubscribe branch - // live in there (§5.1); it NEVER navigates. - match crate::playlist_qt::delete_by_id(&runtime, pid).await { - Ok(()) => true, - Err(e) => { - log::error!("[qbz-qt] {e}"); - false - } - } - } - Err(_) => false, - } - }; + let ok = delete_ref(&id, is_local).await; if ok { // CONDITIONAL (§5.1): only when the user is actually standing on @@ -529,10 +667,12 @@ mod tests { is_local: true, offline_only: true, busy: false, + folder_id: "folder-1".into(), }); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(v["id"], serde_json::Value::String("local:abc-def".into())); assert_eq!(v["isLocal"], serde_json::Value::Bool(true)); + assert_eq!(v["folderId"], serde_json::Value::String("folder-1".into())); assert_eq!(v["offlineOnly"], serde_json::Value::Bool(true)); assert_eq!(v["descLoaded"], serde_json::Value::Bool(true)); assert!(v.get("is_local").is_none()); diff --git a/crates/qbz-qt/src/sidebar_qt.rs b/crates/qbz-qt/src/sidebar_qt.rs index e8110c8bc..5f1ea8bb4 100644 --- a/crates/qbz-qt/src/sidebar_qt.rs +++ b/crates/qbz-qt/src/sidebar_qt.rs @@ -132,6 +132,10 @@ pub struct SidebarEntry { skip_serializing_if = "std::ops::Not::not" )] pub is_local: bool, + /// The signed-in user owns it (a local playlist always does): the row + /// menu offers Delete only then (2026-09-13). + #[serde(default, rename = "isOwner")] + pub is_owner: bool, } /// One LOCAL playlist listed in the sidebar (library.db, ids `local:`). @@ -155,6 +159,8 @@ struct SidebarPlaylist { tracks_count: u32, cover_urls: Vec, position: i32, + /// `owner.id == the signed-in user` at load; false when unknown. + is_owner: bool, } #[derive(Default, Clone)] @@ -292,6 +298,7 @@ pub async fn load(runtime: &Arc>) { out }; SidebarPlaylist { + is_owner: crate::playlist_qt::current_user_id().is_some_and(|uid| uid == p.owner.id), id: p.id, name: p.name, tracks_count: p.tracks_count, @@ -357,6 +364,8 @@ pub async fn load(runtime: &Arc>) { tracks_count, cover_urls: Vec::new(), position: 0, + // An offline header row: ownership unknown, no Delete offered. + is_owner: false, }); } } @@ -508,6 +517,7 @@ pub fn rebuild() -> Vec { |p: &SidebarPlaylist| !searching || p.name.to_lowercase().contains(query.as_str()); let entry_for = |p: &SidebarPlaylist, indent: bool, folder_id: &str| SidebarEntry { + is_owner: p.is_owner, kind: "playlist".into(), id: p.id.to_string(), name: p.name.clone(), @@ -519,6 +529,7 @@ pub fn rebuild() -> Vec { is_local: false, }; let local_entry_for = |p: &SidebarLocal, indent: bool, folder_id: &str| SidebarEntry { + is_owner: true, kind: "playlist".into(), id: p.id.clone(), name: p.name.clone(), @@ -570,6 +581,7 @@ pub fn rebuild() -> Vec { // When searching, force-expand so matches inside are visible. let is_exp = searching || expanded.contains(fid); entries.push(SidebarEntry { + is_owner: false, kind: "folder".into(), id: fid.clone(), name: fname.clone(), @@ -777,6 +789,8 @@ pub fn insert_qobuz_entry(id: u64, name: &str, tracks_count: u32, covers: &[Stri tracks_count, cover_urls: covers.iter().take(4).cloned().collect(), position: 0, + // Just created by this user. + is_owner: true, }, ); data.recent_qobuz.insert(id, std::time::Instant::now()); @@ -951,6 +965,7 @@ mod creation_regressions { let now = std::time::Instant::now(); let mut previous = SidebarData::default(); previous.playlists.push(SidebarPlaylist { + is_owner: true, id: 42, name: "Created".into(), tracks_count: 0,