feat(qt): move a playlist to a folder from its editor, delete one from any context menu
The editor gains a Folder row — the same folders the sidebar and the manager file playlists into, "No folder" for the root — seeded from the playlist's current folder and applied on Save through the manager's own move verb, so an existing playlist no longer needs the sidebar's row menu to be filed (#785). "Delete playlist" joins the sidebar row menu (own and local playlists; sidebar entries now carry isOwner) and the playlist card menu (own playlists), behind a shell-level "Delete playlist?" confirmation that runs the same delete the editor's button runs and leaves the detail page only when it was the deleted one (#776). Eight catalogs updated. cc
This commit is contained in:
17 files changed
+445
-35
No files matched your search
@@ -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."
|
||||
@@ -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"
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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 "「{}」を削除します。この操作は取り消せません。"
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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 "«{}» будет удалён. Это действие нельзя отменить."
|
||||
@@ -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",
|
||||
|
||||
@@ -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 || ""))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<Mutex<EditState>> = 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<Mutex<DeleteAsk>> =
|
||||
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::<u64>() {
|
||||
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::<u64>() {
|
||||
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());
|
||||
|
||||
@@ -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:<uuid>`).
|
||||
@@ -155,6 +159,8 @@ struct SidebarPlaylist {
|
||||
tracks_count: u32,
|
||||
cover_urls: Vec<String>,
|
||||
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<AppRuntime<LoggingAdapter>>) {
|
||||
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<AppRuntime<LoggingAdapter>>) {
|
||||
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<SidebarEntry> {
|
||||
|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<SidebarEntry> {
|
||||
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<SidebarEntry> {
|
||||
// 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,
|
||||
|
||||
Reference in new issue
Block a user