fix(android): derive artist tile cover from first cached album

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 12:35:09 -04:00
parent 1b243347cb
commit d7dda2fcef
6 changed files with 49 additions and 16 deletions
@@ -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 =
@@ -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<List<ArtistRef>> =
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<List<AlbumRef>> =
albumDao.observeAll().map { rows -> rows.map { it.toDomain() } }
@@ -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))
@@ -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<ArtistRef?> =
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<TrackRef?> =
trackDao.observeById(id)
@@ -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
}
}
@@ -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 {