From d7dda2fcefa3f9e9a8dcf80458975711614b4838 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 29 May 2026 12:35:09 -0400 Subject: [PATCH] fix(android): derive artist tile cover from first cached album MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library + Home artist tiles were blank: cached_artists stores no cover and the sync payload (SyncArtistWire) carries none. Mirror Flutter's artistTileProvider JOIN — ArtistRef gains coverAlbumId + a displayCoverUrl getter; LibraryRepository.observeArtists and MetadataProvider.observeArtist combine artists with albums to supply the first album (by sort title) as the cover source. ArtistCard renders displayCoverUrl through ServerImage. Updated LibraryRepositoryTest to stub albumDao.observeAll so combine emits. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/library/data/LibraryMappers.kt | 3 ++- .../library/data/LibraryRepository.kt | 12 ++++++++++- .../minstrel/library/widgets/ArtistCard.kt | 16 ++++++-------- .../minstrel/metadata/MetadataProvider.kt | 10 ++++++--- .../fabledsword/minstrel/models/ArtistRef.kt | 21 ++++++++++++++++++- .../library/data/LibraryRepositoryTest.kt | 3 +++ 6 files changed, 49 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryMappers.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryMappers.kt index d58aaea9..4c3c214a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryMappers.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryMappers.kt @@ -64,11 +64,12 @@ fun AlbumDetailWire.toAlbumEntity(): CachedAlbumEntity = // source of truth for cache-first reads; mapping to a domain type here // keeps Room types out of the UI layer. -fun CachedArtistEntity.toDomain(): ArtistRef = +fun CachedArtistEntity.toDomain(coverAlbumId: String = ""): ArtistRef = ArtistRef( id = id, name = name, sortName = sortName, + coverAlbumId = coverAlbumId, ) fun CachedAlbumEntity.toDomain(artistName: String = ""): AlbumRef = diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt index f25d779c..de6f2c48 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt @@ -10,6 +10,7 @@ import com.fabledsword.minstrel.models.ArtistDetailRef import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.models.TrackRef import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import retrofit2.Retrofit import retrofit2.create @@ -43,8 +44,17 @@ class LibraryRepository @Inject constructor( // ---- Reads (Flow, cache-first; the cache is the source of truth) ---- + // Joins each artist to its first cached album (by sort title) so the + // tile cover derives from /api/albums/{id}/cover — cached_artists + // stores no cover and sync carries none. Mirrors Flutter's + // artistTileProvider JOIN. fun observeArtists(): Flow> = - artistDao.observeAll().map { rows -> rows.map { it.toDomain() } } + combine(artistDao.observeAll(), albumDao.observeAll()) { artists, albums -> + val firstAlbumByArtist = albums + .groupBy { it.artistId } + .mapValues { (_, list) -> list.minByOrNull { it.sortTitle }?.id ?: "" } + artists.map { it.toDomain(coverAlbumId = firstAlbumByArtist[it.id] ?: "") } + } fun observeAlbums(): Flow> = albumDao.observeAll().map { rows -> rows.map { it.toDomain() } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt index e6b5a69b..fd99b137 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt @@ -19,15 +19,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.User import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.nav.LocalDetailSeedCache +import com.fabledsword.minstrel.shared.widgets.ServerImage /** * One artist tile. Circular cover; name centered beneath. Tap fires @@ -61,20 +60,17 @@ fun ArtistCard( .clip(CircleShape), contentAlignment = Alignment.Center, ) { - if (artist.coverUrl.isEmpty()) { + ServerImage( + url = artist.displayCoverUrl, + contentDescription = artist.name, + modifier = Modifier.fillMaxSize(), + ) { Icon( imageVector = Lucide.User, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(56.dp), ) - } else { - AsyncImage( - model = artist.coverUrl, - contentDescription = artist.name, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) } } Spacer(Modifier.height(8.dp)) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/metadata/MetadataProvider.kt b/android/app/src/main/java/com/fabledsword/minstrel/metadata/MetadataProvider.kt index cc038a39..24290d88 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/metadata/MetadataProvider.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/metadata/MetadataProvider.kt @@ -12,6 +12,7 @@ import com.fabledsword.minstrel.models.TrackRef import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -70,9 +71,12 @@ class MetadataProvider @Inject constructor( .onEach { row -> if (row == null) fetchAlbum(id) } fun observeArtist(id: String): Flow = - artistDao.observeById(id) - .map { it?.toDomain() } - .onEach { row -> if (row == null) fetchArtist(id) } + combine( + artistDao.observeById(id), + albumDao.observeByArtist(id), + ) { artist, albums -> + artist?.toDomain(coverAlbumId = albums.minByOrNull { it.sortTitle }?.id ?: "") + }.onEach { row -> if (row == null) fetchArtist(id) } fun observeTrack(id: String): Flow = trackDao.observeById(id) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistRef.kt index be1614ec..010d6ca3 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistRef.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistRef.kt @@ -14,4 +14,23 @@ data class ArtistRef( val sortName: String = "", val albumCount: Int = 0, val coverUrl: String = "", -) + val coverAlbumId: String = "", +) { + /** + * Cover URL the UI should render, or null when the artist has no + * representative cover. Prefers the server-provided `coverUrl` + * (relative, e.g. `/api/artists` ships it); otherwise derives the + * first cached album's `/api/albums/{id}/cover` via [coverAlbumId]. + * + * The fallback matters because cached_artists stores no cover and + * the sync payload carries none — so for cache-sourced artist tiles + * we mirror Flutter's `artistTileProvider`, which JOINs the first + * album to supply the cover. Resolve through `ServerImage`. + */ + val displayCoverUrl: String? + get() = when { + coverUrl.isNotEmpty() -> coverUrl + coverAlbumId.isNotEmpty() -> "/api/albums/$coverAlbumId/cover" + else -> null + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/library/data/LibraryRepositoryTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/library/data/LibraryRepositoryTest.kt index cf096803..c4c459af 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/library/data/LibraryRepositoryTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/library/data/LibraryRepositoryTest.kt @@ -58,6 +58,9 @@ class LibraryRepositoryTest { CachedArtistEntity(id = "a1", name = "Boards of Canada", sortName = "Boards of Canada"), ), ) + // observeArtists now combines with albums (to derive the tile + // cover from the first album); stub it so combine can emit. + every { albumDao.observeAll() } returns flowOf(emptyList()) val repo = LibraryRepository(artistDao, albumDao, trackDao, retrofit) repo.observeArtists().test {