From 4a85b2338325dc6bdeb73b51286d5dfa2a8f5503 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:48:18 +0000 Subject: [PATCH] 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. --- .../com/subtracks/data/db/LibraryDao.kt | 20 ++ .../subtracks/data/repo/LibraryRepository.kt | 10 + .../ui/library/ArtistDetailScreen.kt | 197 +++++++++++++++++- .../ui/library/ArtistDetailViewModel.kt | 31 ++- 4 files changed, 244 insertions(+), 14 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index 946a812a..b4f891ac 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -1070,6 +1070,26 @@ interface LibraryDao { artistId: String, ): Flow + @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 + @Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId") fun artistBio( sourceId: Long, diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index 6ba3d6b1..0a60accb 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -353,6 +353,16 @@ class LibraryRepository( artistId: String, ): Flow = 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 = db.libraryDao().artistFallbackCoverArts(sourceId, artistId) + fun artistName( sourceId: Long, artistId: String, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index 5d704966..97d7e869 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -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 = 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, + 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, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt index 52e82ad9..01390919 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt @@ -136,28 +136,39 @@ class ArtistDetailViewModel( private val _artThumbnail = MutableStateFlow(null) val artThumbnail: StateFlow = _artThumbnail + private val _artCovers = MutableStateFlow>(emptyList()) + val artCovers: StateFlow> = _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 { - 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> { + 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(