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:
@@ -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 =
|
||||
|
||||
+11
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user