mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
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:
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user