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, artistId: String,
): Flow<Artist?> ): 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") @Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
fun artistBio( fun artistBio(
sourceId: Long, sourceId: Long,
@@ -353,6 +353,16 @@ class LibraryRepository(
artistId: String, artistId: String,
): Flow<Artist?> = db.libraryDao().artist(sourceId, artistId) ): 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( fun artistName(
sourceId: Long, sourceId: Long,
artistId: String, artistId: String,
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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.Shadow
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onSizeChanged 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.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -121,7 +128,11 @@ import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import kotlin.math.PI
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
private val ART_HEIGHT = 420.dp private val ART_HEIGHT = 420.dp
private val TITLE_INSET = 16.dp private val TITLE_INSET = 16.dp
@@ -193,6 +204,7 @@ fun ArtistDetailRoute(
LaunchedEffect(artistId) { viewModel.refreshBio() } LaunchedEffect(artistId) { viewModel.refreshBio() }
val art by viewModel.art.collectAsStateWithLifecycle() val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle() val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
val artCovers by viewModel.artCovers.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle()
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack) CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
@@ -232,6 +244,7 @@ fun ArtistDetailRoute(
bioExpanded = bioExpanded, bioExpanded = bioExpanded,
art = art, art = art,
artThumbnail = artThumbnail, artThumbnail = artThumbnail,
artCovers = artCovers,
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName), artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
onBack = onBack, onBack = onBack,
@@ -271,6 +284,7 @@ fun ArtistDetailScreen(
bioExpanded: Boolean = false, bioExpanded: Boolean = false,
art: CoverArtRef?, art: CoverArtRef?,
artThumbnail: CoverArtRef? = null, artThumbnail: CoverArtRef? = null,
artCovers: List<CoverArtRef> = emptyList(),
artwork: ArtworkColors? = null, artwork: ArtworkColors? = null,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit, onBack: () -> Unit,
@@ -388,11 +402,11 @@ fun ArtistDetailScreen(
.onSizeChanged { imageHeightPx = it.height.toFloat() }, .onSizeChanged { imageHeightPx = it.height.toFloat() },
) { ) {
Box(Modifier.fillMaxWidth()) { Box(Modifier.fillMaxWidth()) {
CoverArt( ArtistHeroArt(
ref = art, covers = artCovers,
fallback = art,
fallbackThumbnail = artThumbnail,
name = displayName, name = displayName,
thumbnailRef = artThumbnail,
showPlaceholder = art == null,
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT), modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
) )
if (nameScrimAlpha > 0f) { if (nameScrimAlpha > 0f) {
@@ -819,6 +833,181 @@ internal fun splitBioCollapsed(
return BioLines(lines, more = true) 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 @Composable
private fun ArtistSectionHeader( private fun ArtistSectionHeader(
text: String, text: String,
@@ -136,28 +136,39 @@ class ArtistDetailViewModel(
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null) private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
private val _artCovers = MutableStateFlow<List<CoverArtRef>>(emptyList())
val artCovers: StateFlow<List<CoverArtRef>> = _artCovers
init { init {
viewModelScope.launch { viewModelScope.launch {
artist artist
.filterNotNull() .filterNotNull()
.collectLatest { loaded -> .collectLatest { loaded ->
val (full, thumbnail) = resolveArt(loaded) val (full, thumbnail, covers) = resolveArt(loaded)
_art.value = full _art.value = full
_artThumbnail.value = thumbnail _artThumbnail.value = thumbnail
_artCovers.value = covers
} }
} }
} }
private suspend fun resolveArt(artist: Artist): Pair<CoverArtRef?, CoverArtRef?> { private suspend fun resolveArt(artist: Artist): Triple<CoverArtRef?, CoverArtRef?, List<CoverArtRef>> {
if (artist.coverArt == null) return null to null val id = sourceId.first()
repeat(3) { attempt -> val own = libraryRepository.artistOwnCoverArt(id, artistId)
val full = sourceRepository.coverArt(artist.coverArt) val coverIds =
if (full != null) { if (own != null) {
return full to sourceRepository.coverArt(artist.coverArt, thumbnail = true) listOf(own)
} else {
libraryRepository.artistFallbackCoverArts(id, artistId).distinct()
} }
if (attempt < 2) delay(300) if (coverIds.isEmpty()) return Triple(null, null, emptyList())
}
return null to null 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( fun coverArt(