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 // source of truth for cache-first reads; mapping to a domain type here
// keeps Room types out of the UI layer. // keeps Room types out of the UI layer.
fun CachedArtistEntity.toDomain(): ArtistRef = fun CachedArtistEntity.toDomain(coverAlbumId: String = ""): ArtistRef =
ArtistRef( ArtistRef(
id = id, id = id,
name = name, name = name,
sortName = sortName, sortName = sortName,
coverAlbumId = coverAlbumId,
) )
fun CachedAlbumEntity.toDomain(artistName: String = ""): AlbumRef = 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.ArtistRef
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.create import retrofit2.create
@@ -43,8 +44,17 @@ class LibraryRepository @Inject constructor(
// ---- Reads (Flow, cache-first; the cache is the source of truth) ---- // ---- 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>> = 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>> = fun observeAlbums(): Flow<List<AlbumRef>> =
albumDao.observeAll().map { rows -> rows.map { it.toDomain() } } 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.User import com.composables.icons.lucide.User
import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.shared.widgets.ServerImage
/** /**
* One artist tile. Circular cover; name centered beneath. Tap fires * One artist tile. Circular cover; name centered beneath. Tap fires
@@ -61,20 +60,17 @@ fun ArtistCard(
.clip(CircleShape), .clip(CircleShape),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (artist.coverUrl.isEmpty()) { ServerImage(
url = artist.displayCoverUrl,
contentDescription = artist.name,
modifier = Modifier.fillMaxSize(),
) {
Icon( Icon(
imageVector = Lucide.User, imageVector = Lucide.User,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
) )
} else {
AsyncImage(
model = artist.coverUrl,
contentDescription = artist.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} }
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -12,6 +12,7 @@ import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -70,9 +71,12 @@ class MetadataProvider @Inject constructor(
.onEach { row -> if (row == null) fetchAlbum(id) } .onEach { row -> if (row == null) fetchAlbum(id) }
fun observeArtist(id: String): Flow<ArtistRef?> = fun observeArtist(id: String): Flow<ArtistRef?> =
artistDao.observeById(id) combine(
.map { it?.toDomain() } artistDao.observeById(id),
.onEach { row -> if (row == null) fetchArtist(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?> = fun observeTrack(id: String): Flow<TrackRef?> =
trackDao.observeById(id) trackDao.observeById(id)
@@ -14,4 +14,23 @@ data class ArtistRef(
val sortName: String = "", val sortName: String = "",
val albumCount: Int = 0, val albumCount: Int = 0,
val coverUrl: String = "", 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"), 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) val repo = LibraryRepository(artistDao, albumDao, trackDao, retrofit)
repo.observeArtists().test { repo.observeArtists().test {