diff --git a/crates/qbz-library/src/database.rs b/crates/qbz-library/src/database.rs index ac0442b5e..4ac8ace89 100644 --- a/crates/qbz-library/src/database.rs +++ b/crates/qbz-library/src/database.rs @@ -1958,6 +1958,32 @@ impl LibraryDatabase { result } + /// Resolve a folder cover for an album that has no `artwork_path` in the + /// index — e.g. an offline-cached (CMAF) album whose downloader wrote a + /// `cover.jpg` into the track folder but didn't backfill `artwork_path`. + /// Looks up a representative track for the metadata group, derives its + /// containing folder (the path itself when it is a directory, as for CMAF + /// bundles, else the parent dir), and returns `/cover.jpg` when + /// that file exists. Frontend-agnostic (no `tauri::State`). + pub fn resolve_album_cover_fallback(&self, group_key: &str) -> Option { + let expr = crate::album_grouping::metadata_group_key_sql_expression(); + let query = format!("SELECT file_path FROM local_tracks WHERE ({expr}) = ?1 LIMIT 1"); + let file_path: String = self + .conn + .query_row(&query, rusqlite::params![group_key], |row| row.get(0)) + .ok()?; + let p = std::path::Path::new(&file_path); + let folder = if p.is_dir() { + p.to_path_buf() + } else { + p.parent()?.to_path_buf() + }; + let cover = folder.join("cover.jpg"); + cover + .is_file() + .then(|| cover.to_string_lossy().into_owned()) + } + fn get_albums_metadata_page_inner( &self, offset: u64, diff --git a/crates/qbz-slint/src/album_map.rs b/crates/qbz-slint/src/album_map.rs index 6e08af6d7..0dbbae8bf 100644 --- a/crates/qbz-slint/src/album_map.rs +++ b/crates/qbz-slint/src/album_map.rs @@ -26,6 +26,9 @@ pub struct AlbumCard { pub artwork_url: String, // List-row extras (AlbumListRow columns; ignored by the grid card). pub release_type: String, // "Album" | "EP" | "Single" (TYPE column) + // "local" | "qobuz_download" | "plex" | "" — SOURCE column + the + // always-visible source badge on the Local Library grid card. + pub source: String, pub quality_detail: String, // "24-bit / 96 kHz" pub track_count: String, // "12" pub plain_year: String, // "1973" @@ -94,6 +97,9 @@ pub fn map_album(album: Album) -> AlbumCard { quality_label, artwork_url: album.image.best().cloned().unwrap_or_default(), release_type, + // Qobuz album surfaces (Discover / Favorites / Label) hide the SOURCE + // column and the badge, so leave it empty (preserves prior behavior). + source: String::new(), quality_detail, track_count, plain_year, @@ -189,6 +195,7 @@ pub fn to_item(card: AlbumCard) -> AlbumCardItem { // List-row extras — feed the AlbumListRow columns (TYPE / QUALITY / // TRACKS / YEAR) for the list view toggle. release_type: card.release_type.into(), + source: card.source.into(), quality_detail: card.quality_detail.into(), track_count: card.track_count.into(), plain_year: card.plain_year.into(), diff --git a/crates/qbz-slint/src/local_library.rs b/crates/qbz-slint/src/local_library.rs index 5edadd770..3dafb4eef 100644 --- a/crates/qbz-slint/src/local_library.rs +++ b/crates/qbz-slint/src/local_library.rs @@ -109,6 +109,14 @@ pub fn map_local_album(a: qbz_library::LocalAlbum) -> crate::album_map::AlbumCar } else { String::new() }; + // Real source for the SOURCE column + the always-visible card badge: + // user files -> local, offline copies -> qobuz_download, Plex -> plex. + let source = match a.source.as_str() { + "plex" => "plex", + "qobuz_download" => "qobuz_download", + _ => "local", + } + .to_string(); crate::album_map::AlbumCard { id: a.id, title: a.title, @@ -120,6 +128,7 @@ pub fn map_local_album(a: qbz_library::LocalAlbum) -> crate::album_map::AlbumCar quality_label, artwork_url: a.artwork_path.unwrap_or_default(), release_type: crate::album_map::classify_release_type(Some(a.track_count)).to_string(), + source, quality_detail, track_count, plain_year: year, @@ -178,8 +187,8 @@ fn fetch_albums_page( } else { Some(trimmed) }; - let page = crate::library_db::with_db(|db| { - db.get_albums_metadata_page( + crate::library_db::with_db(|db| { + let page = db.get_albums_metadata_page( offset, ALBUMS_PAGE, search_opt, @@ -193,12 +202,27 @@ fn fetch_albums_page( // filtering and Plex are their own later slices. false, None, - ) - })?; - let total = page.total; - let has_more = offset + (page.albums.len() as u64) < total; - let cards = page.albums.into_iter().map(map_local_album).collect(); - Some((cards, total, has_more)) + )?; + let total = page.total; + let has_more = offset + (page.albums.len() as u64) < total; + let cards: Vec = page + .albums + .into_iter() + .map(|a| { + let mut card = map_local_album(a); + // Offline-cached / folder albums sometimes have no backfilled + // artwork_path though a cover.jpg sits in the track folder — + // resolve it so the cover that exists on disk actually shows. + if card.artwork_url.is_empty() { + if let Some(cover) = db.resolve_album_cover_fallback(&card.id) { + card.artwork_url = cover; + } + } + card + }) + .collect(); + Ok((cards, total, has_more)) + }) } /// Build local-cover artwork jobs for a page of cards. The job url carries diff --git a/crates/qbz-slint/ui/assets/icons/plex-logo.svg b/crates/qbz-slint/ui/assets/icons/plex-logo.svg new file mode 100644 index 000000000..64d4795fb --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/plex-logo.svg @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/crates/qbz-slint/ui/assets/icons/qobuz-logo-filled.svg b/crates/qbz-slint/ui/assets/icons/qobuz-logo-filled.svg new file mode 100644 index 000000000..d666c354e --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/qobuz-logo-filled.svg @@ -0,0 +1,723 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/qbz-slint/ui/discover/AlbumCard.slint b/crates/qbz-slint/ui/discover/AlbumCard.slint index 6b201eefc..dbb66459a 100644 --- a/crates/qbz-slint/ui/discover/AlbumCard.slint +++ b/crates/qbz-slint/ui/discover/AlbumCard.slint @@ -64,6 +64,9 @@ export component AlbumCard inherits Rectangle { // Hide the favorite heart + its menu item in non-favoritable contexts // (e.g. Local Library albums). Mirrors Tauri's AlbumCardLibraryLite. in property show-favorite: true; + // Always-visible source badge on the artwork (Local Library: local | + // qobuz-offline | plex). Off elsewhere (single-source catalogs). + in property show-source-badge: false; // Fade out when the card is being removed (favorites un-favorite). opacity: root.album.removing ? 0.0 : 1.0; animate opacity { duration: 250ms; } @@ -232,6 +235,33 @@ export component AlbumCard inherits Rectangle { } } + // Always-visible source badge (Local Library) — bottom-right of + // the art, on top of the scrim/buttons. Small rounded square + // (ADR-008: not a pill). Logos render in their own colors; the + // hard-drive glyph is tinted white. + if root.show-source-badge && root.album.source != "": Rectangle { + x: parent.width - self.width - 6px; + y: parent.height - self.height - 6px; + width: 24px; + height: 24px; + border-radius: 4px; + background: root.album.source == "qobuz_download" ? transparent : #000000b3; + Image { + source: root.album.source == "plex" + ? @image-url("../assets/icons/plex-logo.svg") + : (root.album.source == "qobuz_download" + ? @image-url("../assets/icons/qobuz-logo-filled.svg") + : @image-url("../assets/icons/hard-drive.svg")); + colorize: root.album.source == "local" ? #ffffff : transparent; + width: root.album.source == "plex" ? 16px + : (root.album.source == "qobuz_download" ? 22px : 14px); + height: self.width; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + image-fit: contain; + } + } + // Context menu for the "more" button. album-menu := PopupWindow { x: 26px; diff --git a/crates/qbz-slint/ui/discover/AlbumCollectionView.slint b/crates/qbz-slint/ui/discover/AlbumCollectionView.slint index baabe594d..de98e3a58 100644 --- a/crates/qbz-slint/ui/discover/AlbumCollectionView.slint +++ b/crates/qbz-slint/ui/discover/AlbumCollectionView.slint @@ -19,6 +19,7 @@ component AlbumGrid inherits Rectangle { in property card-height: 266px; in property card-gap: 24px; in property show-favorite: true; + in property show-source-badge: false; callback open-album(string); callback open-artist(string); callback media-action(string, string, string); @@ -38,6 +39,7 @@ component AlbumGrid inherits Rectangle { height: root.card-height; album: album; show-favorite: root.show-favorite; + show-source-badge: root.show-source-badge; clicked(id) => { root.open-album(id); } @@ -60,6 +62,9 @@ export component AlbumCollectionView inherits VerticalLayout { // Forwarded to AlbumCard — hide the favorite heart in non-favoritable // contexts (Local Library albums). in property show-favorite: true; + // Forwarded to AlbumCard — show the always-visible source badge on the + // grid card art (Local Library: local | qobuz-offline | plex). + in property show-source-badge: false; in property card-width: 220px; in property card-height: 266px; in property card-gap: 24px; @@ -109,6 +114,7 @@ export component AlbumCollectionView inherits VerticalLayout { if root.view-mode != "list": AlbumGrid { albums: section.albums; show-favorite: root.show-favorite; + show-source-badge: root.show-source-badge; card-width: root.card-width; card-height: root.card-height; card-gap: root.card-gap; @@ -129,6 +135,7 @@ export component AlbumCollectionView inherits VerticalLayout { if !root.is-grouped && root.view-mode == "grid" && root.albums.length > 0: AlbumGrid { albums: root.albums; show-favorite: root.show-favorite; + show-source-badge: root.show-source-badge; card-width: root.card-width; card-height: root.card-height; card-gap: root.card-gap; diff --git a/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint b/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint index 4c58f0769..04aa6ec10 100644 --- a/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint +++ b/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint @@ -364,6 +364,8 @@ export component LocalLibraryView inherits Rectangle { show-source: false; // Local albums aren't Qobuz-favoritable — hide the heart. show-favorite: false; + // Always-visible source badge (local | qobuz-offline | plex). + show-source-badge: true; open-album(id) => { LocalLibraryActions.open-album(id); }