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 {