diff --git a/crates/qbz-qt/qml/controls/PlaylistEditModal.qml b/crates/qbz-qt/qml/controls/PlaylistEditModal.qml index dfb0f9bdd..9a8f4b21f 100644 --- a/crates/qbz-qt/qml/controls/PlaylistEditModal.qml +++ b/crates/qbz-qt/qml/controls/PlaylistEditModal.qml @@ -251,6 +251,43 @@ Item { } } + // ================= Cover ================================ + // The shared editor is reached from the manager, sidebar and + // detail header, so cover editing belongs here too. Actions + // stay on the trailing modal grid; the native picker owns its + // own preview and the playlist surfaces repaint after write. + Item { + width: parent.width + height: 34 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: root.t("Cover") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + SettingsButton { + minWidth: 0 + text: root.t("Change cover") + iconName: "image-plus" + enabled: !root.busy + onClicked: QbzPlaylistEdit.chooseCover() + } + SettingsButton { + minWidth: 0 + text: root.t("Remove cover") + iconName: "trash-2" + danger: true + enabled: !root.busy + onClicked: QbzPlaylistEdit.removeCover() + } + } + } + // ================= Description ========================== // Rendered ONLY when Rust resolved the real one. A Column // skips invisible children, so this leaves no gap. diff --git a/crates/qbz-qt/qml/views/PlaylistView.qml b/crates/qbz-qt/qml/views/PlaylistView.qml index 0fe7a510c..6ec1973af 100644 --- a/crates/qbz-qt/qml/views/PlaylistView.qml +++ b/crates/qbz-qt/qml/views/PlaylistView.qml @@ -275,6 +275,20 @@ Rectangle { // playlist's own cover lands, independently of the track rows. readonly property bool coverPending: root.hasOwnArt && (doc.coverPath || "") === "" + // Keep the popup model declarative. The old builder lived on the header + // Row but the popup called it through `root`, so the right-click reached + // an undefined JS method and opened a correctly positioned EMPTY panel. + readonly property var playlistCoverMenuModel: doc.hasCustomCover === true + ? [ + { "label": QbzSession.tr("Change cover", QbzSession.trRev), + "icon": "image-plus", "action": "add" }, + { "label": QbzSession.tr("Remove cover", QbzSession.trRev), + "icon": "trash-2", "action": "remove" } + ] + : [ + { "label": QbzSession.tr("Add cover", QbzSession.trRev), + "icon": "image-plus", "action": "add" } + ] // The header has no data at all until the first document lands // (playlist_qt.rs publishes `{ loading: true }` with empty fields). readonly property bool headerPending: root.loading && (doc.name || "") === "" @@ -469,29 +483,17 @@ Rectangle { Item { id: plCoverAnchor; anchors.fill: parent } } - // The playlist cover menu's rows, rebuilt per open (Add vs - // Change+Remove flips on hasCustomCover). The store is Qt-first + // The playlist cover menu's rows (Add vs Change+Remove flips on + // hasCustomCover). The store is Qt-first // (custom_playlist_covers.json — a `playlists` key inside the // shared custom_artwork.json would be dropped on the Slint // app's next write). - function buildPlCoverMenuModel() { - var rows = [] - if (doc.hasCustomCover === true) { - rows.push({ "label": QbzSession.tr("Change cover", QbzSession.trRev), "icon": "image-plus", "action": "add" }) - rows.push({ "label": QbzSession.tr("Remove cover", QbzSession.trRev), "icon": "trash-2", "action": "remove" }) - } else { - rows.push({ "label": QbzSession.tr("Add cover", QbzSession.trRev), "icon": "image-plus", "action": "add" }) - } - return rows - } - QbzContextMenu { id: plCoverMenu menuWidth: 196 - onAboutToShow: plCoverRepeater.model = root.buildPlCoverMenuModel() Repeater { id: plCoverRepeater - model: [] + model: root.playlistCoverMenuModel delegate: Rectangle { required property var modelData width: parent ? parent.width : 0 diff --git a/crates/qbz-qt/src/cover_artwork_qt.rs b/crates/qbz-qt/src/cover_artwork_qt.rs index 5783d9887..2c2ea691a 100644 --- a/crates/qbz-qt/src/cover_artwork_qt.rs +++ b/crates/qbz-qt/src/cover_artwork_qt.rs @@ -449,14 +449,26 @@ pub fn playlist_cover(playlist_id: &str) -> Option { load_playlist_store().playlists.get(playlist_id).cloned() } +/// Refresh every live surface that owns playlist artwork after the override +/// store changes. Re-opening through the public router would navigate from the +/// Playlist Manager/sidebar into the playlist and would duplicate Back-stack +/// entries when already on the detail page, so the detail refresh is +/// explicitly conditional and navigation-free. +fn refresh_playlist_cover_surfaces(playlist_id: String) { + crate::reload_playlist_if_showing(playlist_id.clone()); + crate::playlist_manager_qt::reload_if_loaded(); + crate::reload_sidebar_including_local(); +} + // No `prefer_playlist_cover` twin of `prefer_album_cover`: the one consumer // that would want it (`library_qt::playlist_cover_urls`) returns a Vec of // cover refs, not a single fallback string, so it applies the same // override-then-is_file rule itself over the mosaic. A wrapper nothing can // call is the shape the album twin has six real call sites for. -/// Playlist header "Add/Change cover": native picker, persist, re-open the -/// playlist so every surface repaints from the override. +/// Playlist header/editor "Add/Change cover": native picker, persist, then +/// refresh the detail (only when visible), manager and sidebar without +/// navigating away from the surface that opened the picker. pub fn add_custom_playlist_cover(playlist_id: String) { if playlist_id.is_empty() { return; @@ -479,13 +491,13 @@ pub fn add_custom_playlist_cover(playlist_id: String) { .await .is_ok() { - crate::open_playlist(playlist_id); + refresh_playlist_cover_surfaces(playlist_id); } }); } -/// "Remove cover": drop the override and re-open so the header reverts to -/// the own-art/mosaic rule. +/// "Remove cover": drop the override and refresh the live playlist surfaces +/// so they revert to the own-art/mosaic rule. pub fn remove_custom_playlist_cover(playlist_id: String) { if playlist_id.is_empty() { return; @@ -506,7 +518,7 @@ pub fn remove_custom_playlist_cover(playlist_id: String) { } }) .await; - crate::open_playlist(playlist_id); + refresh_playlist_cover_surfaces(playlist_id); }); } diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 2f8278480..e94a6872c 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -1709,6 +1709,48 @@ pub(crate) fn open_playlist(playlist_id: String) { }); } +/// Reload the currently visible playlist without recording another +/// navigation entry or search interaction. Cover edits can originate from the +/// detail header, the shared editor in Playlist Manager, or the sidebar; only +/// the first case should refresh this page, and none should look like a fresh +/// user navigation. +pub(crate) fn reload_playlist_if_showing(playlist_id: String) { + if nav_qt::current_view() != "playlist" + || playlist_qt::open_doc_id().as_deref() != Some(playlist_id.as_str()) + { + return; + } + + if local_playlist_qt::is_local_id(&playlist_id) { + let runtime = app(); + spawn(async move { + if !local_playlist_qt::load(&runtime, &playlist_id).await { + log::warn!("[qbz-qt] local playlist {playlist_id} reload failed"); + } + }); + return; + } + + let Ok(pid) = playlist_id.parse::() else { + return; + }; + if offline_fwd::engine().status().is_offline() { + spawn(async move { + if !local_playlist_qt::load_qobuz_offline(pid).await { + log::warn!("[qbz-qt] offline playlist {pid} reload failed"); + } + }); + return; + } + + let runtime = app(); + spawn(async move { + if let Err(e) = playlist_qt::load(&runtime, pid).await { + log::warn!("[qbz-qt] playlist reload failed: {e}"); + } + }); +} + // ======================= MyQBZ (crate-level forwards) ===================== // // W6: the ONLY crate-root forward the MyQBZ domain needs. Everything else the diff --git a/crates/qbz-qt/src/playlist_edit_bridge.rs b/crates/qbz-qt/src/playlist_edit_bridge.rs index 80e26380e..5fb8165ef 100644 --- a/crates/qbz-qt/src/playlist_edit_bridge.rs +++ b/crates/qbz-qt/src/playlist_edit_bridge.rs @@ -120,6 +120,16 @@ pub mod qbz_playlist_edit_bridge { offline_only: bool, ); + /// Open the native image picker for the playlist held by the Rust + /// editor state. The id is intentionally not accepted from QML: cover + /// edits follow the same stale-document protection as Save/Delete. + #[qinvokable] + fn choose_cover(self: Pin<&mut QbzPlaylistEdit>); + + /// Remove the custom cover from the playlist currently being edited. + #[qinvokable] + fn remove_cover(self: Pin<&mut QbzPlaylistEdit>); + /// Delete the open playlist. `delete` is a C++ keyword, hence the /// name. A Qobuz playlist re-derives ownership first and a FOLLOWED /// one is unsubscribed instead (§5.1); the back-navigation is @@ -216,6 +226,14 @@ impl qbz_playlist_edit_bridge::QbzPlaylistEdit { crate::playlist_edit_qt::save(&name.to_string(), &description.to_string(), offline_only); } + pub fn choose_cover(self: Pin<&mut Self>) { + crate::playlist_edit_qt::choose_cover(); + } + + pub fn remove_cover(self: Pin<&mut Self>) { + crate::playlist_edit_qt::remove_cover(); + } + pub fn delete_playlist(self: Pin<&mut Self>) { crate::playlist_edit_qt::delete_playlist(); } diff --git a/crates/qbz-qt/src/playlist_edit_qt.rs b/crates/qbz-qt/src/playlist_edit_qt.rs index 513fdeddc..fb23f153f 100644 --- a/crates/qbz-qt/src/playlist_edit_qt.rs +++ b/crates/qbz-qt/src/playlist_edit_qt.rs @@ -332,6 +332,38 @@ pub(crate) fn save(name: &str, description: &str, offline_only: bool) { }); } +// --------------------------------------------------------------------------- +// Cover actions +// --------------------------------------------------------------------------- + +/// Open the native cover picker for the playlist currently held by the +/// editor. QML deliberately supplies no id: a stale modal document must not +/// be able to redirect a cover write to another playlist. +pub(crate) fn choose_cover() { + let id = { + let st = state(); + if !st.open || st.busy || st.id.is_empty() { + return; + } + st.id.clone() + }; + crate::cover_artwork_qt::add_custom_playlist_cover(id); +} + +/// Remove the custom cover from the playlist currently held by the editor. +/// This is harmless when no override exists and also clears the legacy local +/// playlist artwork column through `cover_artwork_qt`. +pub(crate) fn remove_cover() { + let id = { + let st = state(); + if !st.open || st.busy || st.id.is_empty() { + return; + } + st.id.clone() + }; + crate::cover_artwork_qt::remove_custom_playlist_cover(id); +} + async fn save_local(id: &str, name: &str, desc: Option, offline_only: bool) -> bool { let id = id.to_string(); let name = name.to_string(); diff --git a/crates/qbz-qt/src/playlist_manager_qt.rs b/crates/qbz-qt/src/playlist_manager_qt.rs index 987332740..448b735de 100644 --- a/crates/qbz-qt/src/playlist_manager_qt.rs +++ b/crates/qbz-qt/src/playlist_manager_qt.rs @@ -599,17 +599,27 @@ async fn load(runtime: &Runtime) -> PmData { // opens the DB per call. let locals: Vec = crate::local_playlist_qt::list_blocking() .into_iter() - .map(|p| PmLocalPlaylist { - cover_urls: crate::sidebar_qt::local_covers(&p.id).unwrap_or_else(|| { - crate::local_playlist_qt::resolve_cover_urls_blocking(&p.id, 4) - }), - id: p.id, - name: p.name, - offline_only: p.offline_only, - track_count: p.track_count, - is_favorite: p.favorite, - is_hidden: p.hidden, - folder_id: p.folder_id, + .map(|p| { + // The editor/header writes the Qt-owned override store. + // Read it BEFORE the sidebar cache so concurrent surface + // refreshes cannot republish the old mosaic here. + let custom = crate::cover_artwork_qt::playlist_cover(&p.id) + .filter(|path| std::path::Path::new(path).is_file()); + let cover_urls = custom.map(|path| vec![path]).unwrap_or_else(|| { + crate::sidebar_qt::local_covers(&p.id).unwrap_or_else(|| { + crate::local_playlist_qt::resolve_cover_urls_blocking(&p.id, 4) + }) + }); + PmLocalPlaylist { + cover_urls, + id: p.id, + name: p.name, + offline_only: p.offline_only, + track_count: p.track_count, + is_favorite: p.favorite, + is_hidden: p.hidden, + folder_id: p.folder_id, + } }) .collect(); ( @@ -758,6 +768,13 @@ async fn load(runtime: &Runtime) -> PmData { /// "images150 > images300" and is wrong about its own code; this matches the /// code, and `sidebar_qt.rs:186-188`. fn cover_urls(p: &qbz_models::Playlist) -> Vec { + // A user override is one full-bleed tile and must win in the manager just + // as it already does in PlaylistView, Library and the sidebar. + if let Some(path) = crate::cover_artwork_qt::playlist_cover(&p.id.to_string()) { + if std::path::Path::new(&path).is_file() { + return vec![path]; + } + } let source = [&p.images300, &p.images150, &p.images] .into_iter() .flatten()