diff --git a/crates/qbz-qt/qml/views/CoverLightbox.qml b/crates/qbz-qt/qml/views/CoverLightbox.qml index 163d91a4e..6d8aa1104 100644 --- a/crates/qbz-qt/qml/views/CoverLightbox.qml +++ b/crates/qbz-qt/qml/views/CoverLightbox.qml @@ -27,6 +27,9 @@ Popup { property string artSource: "" function openWith(source) { + // Builders hand over remote URLs, file:// urls, and (for custom + // covers) bare absolute paths — normalize here, once. + if (source.charAt(0) === "/") source = "file://" + source root.artSource = source root.open() } diff --git a/crates/qbz-qt/qml/views/PlaylistView.qml b/crates/qbz-qt/qml/views/PlaylistView.qml index b0033ff34..f177b34b9 100644 --- a/crates/qbz-qt/qml/views/PlaylistView.qml +++ b/crates/qbz-qt/qml/views/PlaylistView.qml @@ -375,8 +375,87 @@ Rectangle { phase: root.skelPhase settleMs: 4000 } + // Left-click: the lightbox (the shared CoverLightbox, same as + // the album page). Right-click: the custom-cover menu (Qt + // port addition — the Slint playlist header has no menu). + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + onClicked: function (mouse) { + if (mouse.button === Qt.RightButton) { + plCoverMenu.openAtCursor(plCoverAnchor, mouse.x, mouse.y) + } else { + var src = (doc.coverUrl || "") !== "" ? doc.coverUrl + : ((doc.covers || []).length > 0 ? doc.covers[0] : "") + if (src !== "") plCoverLightbox.openWith(src) + } + } + } + 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 + // (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: [] + delegate: Rectangle { + required property var modelData + width: parent ? parent.width : 0 + height: 33 + radius: 5 + color: pcmiArea.containsMouse ? theme.surfaceHover : "transparent" + Row { + anchors.fill: parent + anchors.leftMargin: 8 + spacing: 8 + QbzIcon { name: modelData.icon; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" } + Text { + height: parent.height + width: parent.width - 23 + text: modelData.label + color: theme.textSecondary + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + MouseArea { + id: pcmiArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + plCoverMenu.close() + if (modelData.action === "add") QbzBridge.playlistCoverAdd(doc.id || "") + else if (modelData.action === "remove") QbzBridge.playlistCoverRemove(doc.id || "") + } + } + } + } + } + + CoverLightbox { id: plCoverLightbox } + // Metadata. Column { width: parent.width - 150 - 24 diff --git a/crates/qbz-qt/src/album_qt.rs b/crates/qbz-qt/src/album_qt.rs index 5911e90c3..8da80f48d 100644 --- a/crates/qbz-qt/src/album_qt.rs +++ b/crates/qbz-qt/src/album_qt.rs @@ -1026,11 +1026,14 @@ fn map_release_card(release: &qbz_models::PageArtistRelease) -> AlbumCardData { .unwrap_or_default(), quality_tier: home_qt::quality_tier_from_depth(bit_depth).to_string(), quality_detail: home_qt::quality_detail_from_parts(bit_depth, sample_rate), - art_url: release - .image - .as_ref() - .and_then(|img| img.best().cloned()) - .unwrap_or_default(), + art_url: crate::cover_artwork_qt::prefer_album_cover( + &release.id, + release + .image + .as_ref() + .and_then(|img| img.best().cloned()) + .unwrap_or_default(), + ), } } @@ -1067,6 +1070,7 @@ pub async fn load_more_from_artist( /// One Last.fm recommendation as the card shape the two Qobuz rows already use, /// so the third row reuses their delegate instead of a fourth card variant. fn reco_to_card(r: qbz_external_reco::AlbumReco) -> AlbumCardData { + let art_url = crate::cover_artwork_qt::prefer_album_cover(&r.qobuz_album_id, r.artwork_url.clone()); AlbumCardData { is_pinned: crate::sidebar_qt::is_pinned("album", &r.qobuz_album_id), is_favorite: crate::fav_cache_qt::is_album_favorite(&r.qobuz_album_id), @@ -1078,7 +1082,7 @@ fn reco_to_card(r: qbz_external_reco::AlbumReco) -> AlbumCardData { year: r.year, quality_tier: r.quality_tier, quality_detail: r.quality_label, - art_url: r.artwork_url, + art_url, } } @@ -1126,7 +1130,10 @@ pub async fn load_suggestions( year: qbz_text_utils::dates::release_label(date.as_deref()), quality_tier: home_qt::quality_tier_from_depth(bit_depth).to_string(), quality_detail: home_qt::quality_detail_from_parts(bit_depth, sample_rate), - art_url: a.image.best().cloned().unwrap_or_default(), + art_url: crate::cover_artwork_qt::prefer_album_cover( + &a.id, + a.image.best().cloned().unwrap_or_default(), + ), } }) .filter(|c| c.id != album_id) diff --git a/crates/qbz-qt/src/artist_qt.rs b/crates/qbz-qt/src/artist_qt.rs index 04aa37b82..8e0a45339 100644 --- a/crates/qbz-qt/src/artist_qt.rs +++ b/crates/qbz-qt/src/artist_qt.rs @@ -326,11 +326,14 @@ pub(crate) fn map_release(release: &PageArtistRelease) -> AlbumCardData { .unwrap_or_default(), quality_tier: home_qt::quality_tier_from_depth(bit_depth).to_string(), quality_detail: home_qt::quality_detail_from_parts(bit_depth, sample_rate), - art_url: release - .image - .as_ref() - .and_then(|img| img.best().cloned()) - .unwrap_or_default(), + art_url: crate::cover_artwork_qt::prefer_album_cover( + &release.id, + release + .image + .as_ref() + .and_then(|img| img.best().cloned()) + .unwrap_or_default(), + ), } } diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs index eb80a8006..50ee305bd 100644 --- a/crates/qbz-qt/src/bridge.rs +++ b/crates/qbz-qt/src/bridge.rs @@ -135,6 +135,13 @@ pub mod qbz_bridge { fn playlist_toggle_follow(self: Pin<&mut QbzBridge>); #[qinvokable] fn playlist_toggle_pin(self: Pin<&mut QbzBridge>); + + /// Playlist header cover menu (cover_artwork_qt.rs): pick / clear + /// the custom cover override for the OPEN playlist. + #[qinvokable] + fn playlist_cover_add(self: Pin<&mut QbzBridge>, playlist_id: QString); + #[qinvokable] + fn playlist_cover_remove(self: Pin<&mut QbzBridge>, playlist_id: QString); /// "Copy to your library" (foreign playlists). #[qinvokable] fn playlist_copy(self: Pin<&mut QbzBridge>); @@ -319,6 +326,13 @@ impl qbz_bridge::QbzBridge { crate::playlist_qt::toggle_pin(); } + pub fn playlist_cover_add(self: Pin<&mut Self>, playlist_id: QString) { + crate::cover_artwork_qt::add_custom_playlist_cover(playlist_id.to_string()); + } + pub fn playlist_cover_remove(self: Pin<&mut Self>, playlist_id: QString) { + crate::cover_artwork_qt::remove_custom_playlist_cover(playlist_id.to_string()); + } + pub fn playlist_copy(self: Pin<&mut Self>) { crate::playlist_copy(); } diff --git a/crates/qbz-qt/src/cover_artwork_qt.rs b/crates/qbz-qt/src/cover_artwork_qt.rs index 49b39954a..1604054b3 100644 --- a/crates/qbz-qt/src/cover_artwork_qt.rs +++ b/crates/qbz-qt/src/cover_artwork_qt.rs @@ -65,6 +65,19 @@ pub fn album_cover(album_id: &str) -> Option { load_store().albums.get(album_id).cloned() } +/// The art URL a card/collage builder should emit for an album: the custom +/// override's absolute path when one is registered AND the file still +/// exists, else the remote URL unchanged. The path flows through the +/// ordinary artwork pipeline (artwork_qt::classify's LocalFile arm resolves +/// it synchronously), so every surface that mounts AlbumCard picks the +/// override up with no QML change. +pub fn prefer_album_cover(album_id: &str, fallback_url: String) -> String { + match album_cover(album_id) { + Some(p) if std::path::Path::new(&p).is_file() => p, + _ => fallback_url, + } +} + fn set_album_cover(album_id: &str, path: &str) { let mut store = load_store(); store.albums.insert(album_id.to_string(), path.to_string()); @@ -181,3 +194,104 @@ pub fn save_cover_as(album_id: String, title: String, artwork_url: String) { } }); } + +// --------------------------------------------------------------------------- +// Playlist custom covers (Qt-first; no Slint counterpart yet) +// --------------------------------------------------------------------------- +// Separate file on purpose: the Slint store struct round-trips only the keys +// it knows, so a `playlists` key inside custom_artwork.json would be DROPPED +// the next time the Slint app writes an album/artist override. A Qt-owned +// file cannot be clobbered by it. + +#[derive(Default, Serialize, Deserialize)] +struct PlaylistStore { + #[serde(default)] + playlists: HashMap, +} + +fn playlist_store_path() -> Option { + Some(dirs::data_dir()?.join("qbz").join("custom_playlist_covers.json")) +} + +fn load_playlist_store() -> PlaylistStore { + let Some(path) = playlist_store_path() else { + return PlaylistStore::default(); + }; + match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(), + Err(_) => PlaylistStore::default(), + } +} + +fn write_playlist_store(store: &PlaylistStore) { + let Some(path) = playlist_store_path() else { + return; + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_vec_pretty(store) { + let _ = std::fs::write(&path, json); + } +} + +/// The absolute path of the user-picked cover for `playlist_id`, if set. +pub fn playlist_cover(playlist_id: &str) -> Option { + load_playlist_store().playlists.get(playlist_id).cloned() +} + +/// Playlist twin of [`prefer_album_cover`]. +pub fn prefer_playlist_cover(playlist_id: &str, fallback_url: String) -> String { + match playlist_cover(playlist_id) { + Some(p) if std::path::Path::new(&p).is_file() => p, + _ => fallback_url, + } +} + +/// Playlist header "Add/Change cover": native picker, persist, re-open the +/// playlist so every surface repaints from the override. +pub fn add_custom_playlist_cover(playlist_id: String) { + if playlist_id.is_empty() { + return; + } + crate::spawn(async move { + let Some(file) = rfd::AsyncFileDialog::new() + .add_filter("Images", &["png", "jpg", "jpeg", "webp"]) + .pick_file() + .await + else { + return; + }; + let path = file.path().to_string_lossy().to_string(); + let id = playlist_id.clone(); + if tokio::task::spawn_blocking(move || { + let mut store = load_playlist_store(); + store.playlists.insert(id, path); + write_playlist_store(&store); + }) + .await + .is_ok() + { + crate::open_playlist(playlist_id); + } + }); +} + +/// "Remove cover": drop the override and re-open so the header reverts to +/// the own-art/mosaic rule. +pub fn remove_custom_playlist_cover(playlist_id: String) { + if playlist_id.is_empty() { + return; + } + let id = playlist_id.clone(); + crate::spawn(async move { + let _ = tokio::task::spawn_blocking(move || { + let mut store = load_playlist_store(); + if store.playlists.remove(&id).is_some() { + write_playlist_store(&store); + } + }) + .await; + crate::open_playlist(playlist_id); + }); +} diff --git a/crates/qbz-qt/src/library_qt.rs b/crates/qbz-qt/src/library_qt.rs index ae789cc05..11aa0f082 100644 --- a/crates/qbz-qt/src/library_qt.rs +++ b/crates/qbz-qt/src/library_qt.rs @@ -443,6 +443,12 @@ fn map_artist(artist: Artist) -> FeedItem { /// how the cards ended up showing a member-album sleeve where the playlist /// graphic belongs. pub(crate) fn playlist_cover_urls(playlist: &Playlist) -> Vec { + // A custom playlist cover replaces the mosaic everywhere (one tile). + if let Some(p) = crate::cover_artwork_qt::playlist_cover(&playlist.id.to_string()) { + if std::path::Path::new(&p).is_file() { + return vec![p]; + } + } let source = [&playlist.images300, &playlist.images150, &playlist.images] .into_iter() .flatten() diff --git a/crates/qbz-qt/src/playlist_qt.rs b/crates/qbz-qt/src/playlist_qt.rs index ff40f6fe7..fcbba9e33 100644 --- a/crates/qbz-qt/src/playlist_qt.rs +++ b/crates/qbz-qt/src/playlist_qt.rs @@ -149,6 +149,10 @@ pub struct PlaylistDoc { /// urls itself and owns the empty (list-music) state. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub covers: Vec, + /// A user-picked cover override exists (custom_playlist_covers.json) — + /// the header cover menu flips its Add/Change/Remove rows on this. + #[serde(rename = "hasCustomCover")] + pub has_custom_cover: bool, pub tracks: Vec, #[serde(rename = "trackCount")] pub track_count: i32, @@ -650,6 +654,12 @@ fn playlist_own_image(playlist: &Playlist) -> String { /// mosaic everywhere), else the first four distinct track covers (what the /// Slint header's 2x2 collage reads). fn collage_urls(playlist: &Playlist, tracks: &[Track]) -> Vec { + // A custom playlist cover replaces the whole mosaic on every surface. + if let Some(p) = crate::cover_artwork_qt::playlist_cover(&playlist.id.to_string()) { + if std::path::Path::new(&p).is_file() { + return vec![p]; + } + } let mut out: Vec = Vec::new(); let push = |url: String, out: &mut Vec| { if !url.is_empty() && !out.contains(&url) { @@ -670,11 +680,18 @@ fn collage_urls(playlist: &Playlist, tracks: &[Track]) -> Vec { } if out.is_empty() { for track in tracks { - let url = track + let (album_id, url) = track .album .as_ref() - .and_then(|a| a.image.best().cloned()) + .map(|a| { + ( + a.id.clone(), + a.image.best().cloned().unwrap_or_default(), + ) + }) .unwrap_or_default(); + // A member album with a custom cover contributes THAT art. + let url = crate::cover_artwork_qt::prefer_album_cover(&album_id, url); push(url, &mut out); if out.len() == 4 { break; @@ -715,8 +732,15 @@ pub async fn load(runtime: &Arc>, playlist_id: u64) - // (and the first track's sleeve) is a MEMBER-ALBUM cover: binding it // here is exactly what rendered an album sleeve as the playlist's // artwork, and it also starved the collage of its tiles. - let cover_url = playlist_own_image(&pl); - let covers = collage_urls(&pl, &tracks); + // A custom cover beats BOTH arms (own graphic and mosaic). + let custom = crate::cover_artwork_qt::playlist_cover(&pl.id.to_string()) + .filter(|p| std::path::Path::new(p).is_file()); + let has_custom_cover = custom.is_some(); + let cover_url = custom.clone().unwrap_or_else(|| playlist_own_image(&pl)); + let covers = match custom { + Some(p) => vec![p], + None => collage_urls(&pl, &tracks), + }; let description = pl .description .map(|d| qbz_text_utils::strip_html::strip_html(&d)) @@ -799,6 +823,7 @@ pub async fn load(runtime: &Arc>, playlist_id: u64) - doc.cover_url = cover_url.clone(); doc.cover_path = cover_path; doc.covers = covers; + doc.has_custom_cover = has_custom_cover; doc.tracks = rows; doc.track_count = track_count; doc.total_duration = total_duration; diff --git a/crates/qbz-qt/src/search_qt.rs b/crates/qbz-qt/src/search_qt.rs index b306a5f39..07a346349 100644 --- a/crates/qbz-qt/src/search_qt.rs +++ b/crates/qbz-qt/src/search_qt.rs @@ -329,7 +329,10 @@ fn map_album(album: &Album) -> CardRow { year: year_of(album.release_date_original.as_deref()), quality_tier: tier(album.maximum_bit_depth).to_string(), quality_label: quality_label(album.maximum_bit_depth, album.maximum_sampling_rate), - art_url: album.image.best().cloned().unwrap_or_default(), + art_url: crate::cover_artwork_qt::prefer_album_cover( + &album.id, + album.image.best().cloned().unwrap_or_default(), + ), ..Default::default() } } diff --git a/crates/qbz-qt/src/sidebar_qt.rs b/crates/qbz-qt/src/sidebar_qt.rs index 113f0df12..f95c2fc35 100644 --- a/crates/qbz-qt/src/sidebar_qt.rs +++ b/crates/qbz-qt/src/sidebar_qt.rs @@ -229,7 +229,12 @@ pub async fn load(runtime: &Arc>) { } .into_iter() .map(|p| { - let cover_urls = { + // A custom playlist cover replaces the sidebar mosaic too. + let custom = crate::cover_artwork_qt::playlist_cover(&p.id.to_string()) + .filter(|path| std::path::Path::new(path).is_file()); + let cover_urls = if let Some(custom_path) = custom { + vec![custom_path] + } else { let source = [&p.images300, &p.images150, &p.images] .into_iter() .flatten()