ui: prototype a multi-album artist hero fallback

When an artist has no portrait, build a 1-4 cover hero from album credits on demand. Uses full-res for one cover and thumbnails for 2-4; three covers use a Y split.
This commit is contained in:
austinried
2026-10-20 02:48:18 +00:00
parent d038a33b36
commit 4a85b23383
4 changed files with 244 additions and 14 deletions
@@ -1070,6 +1070,26 @@ interface LibraryDao {
artistId: String,
): Flow<Artist?>
@Query("SELECT coverArt FROM artists WHERE sourceId = :sourceId AND id = :artistId")
suspend fun artistOwnCoverArt(
sourceId: Long,
artistId: String,
): String?
@Query(
"SELECT albums.coverArt FROM (" +
"SELECT albumId, MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
"FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId GROUP BY albumId" +
") credits " +
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
"WHERE albums.coverArt IS NOT NULL " +
"ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 4",
)
suspend fun artistFallbackCoverArts(
sourceId: Long,
artistId: String,
): List<String>
@Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
fun artistBio(
sourceId: Long,
@@ -353,6 +353,16 @@ class LibraryRepository(
artistId: String,
): Flow<Artist?> = db.libraryDao().artist(sourceId, artistId)
suspend fun artistOwnCoverArt(
sourceId: Long,
artistId: String,
): String? = db.libraryDao().artistOwnCoverArt(sourceId, artistId)
suspend fun artistFallbackCoverArts(
sourceId: Long,
artistId: String,
): List<String> = db.libraryDao().artistFallbackCoverArts(sourceId, artistId)
fun artistName(
sourceId: Long,
artistId: String,
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -63,9 +64,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onSizeChanged
@@ -82,7 +87,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -121,7 +128,11 @@ import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import kotlin.math.PI
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
private val ART_HEIGHT = 420.dp
private val TITLE_INSET = 16.dp
@@ -193,6 +204,7 @@ fun ArtistDetailRoute(
LaunchedEffect(artistId) { viewModel.refreshBio() }
val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
val artCovers by viewModel.artCovers.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
@@ -232,6 +244,7 @@ fun ArtistDetailRoute(
bioExpanded = bioExpanded,
art = art,
artThumbnail = artThumbnail,
artCovers = artCovers,
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
coverArt = viewModel::coverArt,
onBack = onBack,
@@ -271,6 +284,7 @@ fun ArtistDetailScreen(
bioExpanded: Boolean = false,
art: CoverArtRef?,
artThumbnail: CoverArtRef? = null,
artCovers: List<CoverArtRef> = emptyList(),
artwork: ArtworkColors? = null,
coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit,
@@ -388,11 +402,11 @@ fun ArtistDetailScreen(
.onSizeChanged { imageHeightPx = it.height.toFloat() },
) {
Box(Modifier.fillMaxWidth()) {
CoverArt(
ref = art,
ArtistHeroArt(
covers = artCovers,
fallback = art,
fallbackThumbnail = artThumbnail,
name = displayName,
thumbnailRef = artThumbnail,
showPlaceholder = art == null,
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
)
if (nameScrimAlpha > 0f) {
@@ -819,6 +833,181 @@ internal fun splitBioCollapsed(
return BioLines(lines, more = true)
}
@Composable
private fun ArtistHeroArt(
covers: List<CoverArtRef>,
fallback: CoverArtRef?,
fallbackThumbnail: CoverArtRef?,
name: String,
modifier: Modifier = Modifier,
) {
when {
covers.size >= 4 -> {
Column(modifier) {
Row(Modifier.weight(1f).fillMaxWidth()) {
covers.take(2).forEach { ref ->
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
}
}
Row(Modifier.weight(1f).fillMaxWidth()) {
covers.drop(2).take(2).forEach { ref ->
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
}
}
}
}
covers.size == 3 -> {
Box(modifier) {
covers.forEachIndexed { index, ref ->
CoverArt(
ref = ref,
name = name,
modifier = Modifier.matchParentSize().clip(YRegionShape(index)),
)
}
}
}
covers.size == 2 -> {
Row(modifier) {
covers.forEach { ref ->
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
}
}
}
covers.size == 1 -> {
CoverArt(
ref = covers.first(),
name = name,
thumbnailRef = fallbackThumbnail,
showPlaceholder = false,
modifier = modifier,
)
}
else -> {
CoverArt(
ref = fallback,
name = name,
thumbnailRef = fallbackThumbnail,
showPlaceholder = fallback == null,
modifier = modifier,
)
}
}
}
private class YRegionShape(
private val region: Int,
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
val center = Offset(size.width / 2f, size.height / 2f)
val left = rayPoint(size, 210.0)
val right = rayPoint(size, 330.0)
val bottom = Offset(size.width / 2f, size.height)
val path = Path()
when (region) {
0 -> {
path.moveTo(center.x, center.y)
path.lineTo(left.x, left.y)
addClockwise(path, size, left, right)
}
1 -> {
path.moveTo(center.x, center.y)
path.lineTo(bottom.x, bottom.y)
addClockwise(path, size, bottom, left)
}
else -> {
path.moveTo(center.x, center.y)
path.lineTo(right.x, right.y)
addClockwise(path, size, right, bottom)
}
}
path.lineTo(center.x, center.y)
path.close()
return Outline.Generic(path)
}
}
private fun rayPoint(
size: Size,
angleDegrees: Double,
): Offset {
val cx = size.width / 2.0
val cy = size.height / 2.0
val radians = angleDegrees * PI / 180.0
val dx = cos(radians)
val dy = sin(radians)
val tx =
when {
dx > 0 -> (size.width - cx) / dx
dx < 0 -> -cx / dx
else -> Double.POSITIVE_INFINITY
}
val ty =
when {
dy > 0 -> (size.height - cy) / dy
dy < 0 -> -cy / dy
else -> Double.POSITIVE_INFINITY
}
val t = min(tx, ty)
return Offset((cx + dx * t).toFloat(), (cy + dy * t).toFloat())
}
private fun addClockwise(
path: Path,
size: Size,
from: Offset,
to: Offset,
) {
val w = size.width
val h = size.height
val corners =
listOf(
Offset(0f, 0f),
Offset(w, 0f),
Offset(w, h),
Offset(0f, h),
)
val fromParam = perimeterParam(from, size)
val toParam = perimeterParam(to, size)
if (fromParam <= toParam) {
corners
.filter { perimeterParam(it, size) > fromParam && perimeterParam(it, size) < toParam }
.forEach { path.lineTo(it.x, it.y) }
} else {
corners
.filter { perimeterParam(it, size) > fromParam }
.forEach { path.lineTo(it.x, it.y) }
corners
.filter { perimeterParam(it, size) < toParam }
.forEach { path.lineTo(it.x, it.y) }
}
path.lineTo(to.x, to.y)
}
private fun perimeterParam(
point: Offset,
size: Size,
): Float {
val w = size.width
val h = size.height
return when {
point.y <= 0f -> point.x
point.x >= w -> w + point.y
point.y >= h -> w + h + (w - point.x)
else -> 2f * w + h + (h - point.y)
}
}
@Composable
private fun ArtistSectionHeader(
text: String,
@@ -136,28 +136,39 @@ class ArtistDetailViewModel(
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
private val _artCovers = MutableStateFlow<List<CoverArtRef>>(emptyList())
val artCovers: StateFlow<List<CoverArtRef>> = _artCovers
init {
viewModelScope.launch {
artist
.filterNotNull()
.collectLatest { loaded ->
val (full, thumbnail) = resolveArt(loaded)
val (full, thumbnail, covers) = resolveArt(loaded)
_art.value = full
_artThumbnail.value = thumbnail
_artCovers.value = covers
}
}
}
private suspend fun resolveArt(artist: Artist): Pair<CoverArtRef?, CoverArtRef?> {
if (artist.coverArt == null) return null to null
repeat(3) { attempt ->
val full = sourceRepository.coverArt(artist.coverArt)
if (full != null) {
return full to sourceRepository.coverArt(artist.coverArt, thumbnail = true)
private suspend fun resolveArt(artist: Artist): Triple<CoverArtRef?, CoverArtRef?, List<CoverArtRef>> {
val id = sourceId.first()
val own = libraryRepository.artistOwnCoverArt(id, artistId)
val coverIds =
if (own != null) {
listOf(own)
} else {
libraryRepository.artistFallbackCoverArts(id, artistId).distinct()
}
if (attempt < 2) delay(300)
}
return null to null
if (coverIds.isEmpty()) return Triple(null, null, emptyList())
val thumbnails = coverIds.mapNotNull { sourceRepository.coverArt(it, thumbnail = true) }
val fulls = coverIds.mapNotNull { sourceRepository.coverArt(it) }
val full = fulls.firstOrNull()
val thumbnail = thumbnails.firstOrNull()
val covers = if (coverIds.size == 1) listOfNotNull(full ?: thumbnail) else thumbnails
return Triple(full ?: thumbnail, thumbnail, covers)
}
fun coverArt(