fix(playlists): restore cover editing
cx
This commit is contained in:
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user