fix(playlists): restore cover editing

cx
This commit is contained in:
vicrodh committed 2026-08-26 16:29:04 -06:00
1 parent 8b77f1ae08
commit aa66c58ea4
7 files changed
+192 -32

No files matched your search

@@ -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.
+17 -15
View File
@@ -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
+18 -6
View File
@@ -449,14 +449,26 @@ pub fn playlist_cover(playlist_id: &str) -> Option<String> {
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);
});
}
+42
View File
@@ -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::<u64>() 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
+18
View File
@@ -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();
}
+32
View File
@@ -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<String>, offline_only: bool) -> bool {
let id = id.to_string();
let name = name.to_string();
+28 -11
View File
@@ -599,17 +599,27 @@ async fn load(runtime: &Runtime) -> PmData {
// opens the DB per call.
let locals: Vec<PmLocalPlaylist> = 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<String> {
// 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()