prefetch artwork seeds from the grid so an album opens with its theme

This commit is contained in:
austinried
2026-10-04 00:29:53 +00:00
parent e8e2c38aaa
commit f8da91e0cc
4 changed files with 121 additions and 72 deletions
@@ -28,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds
const val ALBUM_COVER_TAG = "album-cover"
@@ -62,8 +63,10 @@ fun AlbumsContent(
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
val album = items[index]
if (album != null) {
val art = coverArt(album.coverArt, true)
PrefetchArtworkSeeds(art)
CoverArt(
ref = coverArt(album.coverArt, true),
ref = art,
name = album.name,
modifier =
Modifier
@@ -53,6 +53,7 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -152,6 +153,7 @@ fun ArtistDetailScreen(
}
}
itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
PrefetchArtworkSeeds(coverArt(album.coverArt, true))
Column(
modifier =
Modifier
@@ -0,0 +1,113 @@
package com.subtracks.ui.theme
import android.content.Context
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.palette.graphics.Palette
import coil3.SingletonImageLoader
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.toBitmap
import com.subtracks.data.model.CoverArtRef
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.abs
private const val SEED_ART_SIZE_PX = 128
private const val PREFETCH_SETTLE_MS = 250L
object ArtworkSeedCache {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val jobs = ConcurrentHashMap<String, Deferred<Pair<Int, Int?>?>>()
fun load(
context: Context,
ref: CoverArtRef,
): Deferred<Pair<Int, Int?>?> =
jobs.getOrPut(ref.cacheKey) {
scope.async {
val seeds = extract(context, ref)
if (seeds == null) jobs.remove(ref.cacheKey)
seeds
}
}
private suspend fun extract(
context: Context,
ref: CoverArtRef,
): Pair<Int, Int?>? =
try {
val request =
ImageRequest
.Builder(context)
.data(ref.url)
.memoryCacheKey(ref.cacheKey)
.diskCacheKey(ref.cacheKey)
.size(SEED_ART_SIZE_PX)
.allowHardware(false)
.build()
val image = (SingletonImageLoader.get(context).execute(request) as? SuccessResult)?.image
image?.let { seedsFrom(it.toBitmap()) }
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
private fun seedsFrom(bitmap: Bitmap): Pair<Int, Int?>? {
val palette = Palette.from(bitmap).generate()
val primary =
palette.vibrantSwatch
?: palette.lightVibrantSwatch
?: palette.darkVibrantSwatch
?: palette.mutedSwatch
?: palette.dominantSwatch
?: return null
val primaryColor = Color(primary.rgb or 0xFF000000.toInt())
val secondary =
listOfNotNull(
palette.vibrantSwatch,
palette.lightVibrantSwatch,
palette.darkVibrantSwatch,
palette.mutedSwatch,
palette.lightMutedSwatch,
palette.darkMutedSwatch,
palette.dominantSwatch,
).map { it.rgb }
.distinct()
.filter { it != primary.rgb }
.maxByOrNull { distinctness(primaryColor, Color(it or 0xFF000000.toInt())) }
return primary.rgb to secondary
}
private fun distinctness(
a: Color,
b: Color,
): Float {
val ha = a.toHsl().first
val hb = b.toHsl().first
val hueDistance = minOf(abs(ha - hb), 360f - abs(ha - hb)) / 180f
return hueDistance + abs(a.luminance() - b.luminance()) * 2f
}
}
@Composable
fun PrefetchArtworkSeeds(ref: CoverArtRef?) {
val context = LocalPlatformContext.current
LaunchedEffect(ref?.cacheKey) {
if (ref == null) return@LaunchedEffect
delay(PREFETCH_SETTLE_MS)
ArtworkSeedCache.load(context, ref)
}
}
@@ -1,6 +1,5 @@
package com.subtracks.ui.theme
import android.graphics.Bitmap
import androidx.compose.foundation.Canvas
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
@@ -17,21 +16,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.palette.graphics.Palette
import coil3.SingletonImageLoader
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.toBitmap
import com.subtracks.data.model.CoverArtRef
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.abs
import kotlin.random.Random
private const val SEED_ART_SIZE_PX = 128
private const val PERIOD_SCREENS = 2f
private const val BLOB_ZONE = 0.62f
private const val BLOB_COUNT = 4
@@ -185,67 +173,10 @@ fun rememberArtworkColors(
private fun rememberArtworkSeed(ref: CoverArtRef?): State<Pair<Int, Int?>?> {
val context = LocalPlatformContext.current
return produceState<Pair<Int, Int?>?>(initialValue = null, ref?.cacheKey) {
value = null
val art = ref ?: return@produceState
value =
withContext(Dispatchers.IO) {
try {
val request =
ImageRequest
.Builder(context)
.data(art.url)
.memoryCacheKey(art.cacheKey)
.diskCacheKey(art.cacheKey)
.size(SEED_ART_SIZE_PX)
.allowHardware(false)
.build()
val image = (SingletonImageLoader.get(context).execute(request) as? SuccessResult)?.image
image?.let { seedsFrom(it.toBitmap()) }
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
}
value = ref?.let { ArtworkSeedCache.load(context, it).await() }
}
}
private fun seedsFrom(bitmap: Bitmap): Pair<Int, Int?>? {
val palette = Palette.from(bitmap).generate()
val primary =
palette.vibrantSwatch
?: palette.lightVibrantSwatch
?: palette.darkVibrantSwatch
?: palette.mutedSwatch
?: palette.dominantSwatch
?: return null
val primaryColor = Color(primary.rgb or 0xFF000000.toInt())
val secondary =
listOfNotNull(
palette.vibrantSwatch,
palette.lightVibrantSwatch,
palette.darkVibrantSwatch,
palette.mutedSwatch,
palette.lightMutedSwatch,
palette.darkMutedSwatch,
palette.dominantSwatch,
).map { it.rgb }
.distinct()
.filter { it != primary.rgb }
.maxByOrNull { distinctness(primaryColor, Color(it or 0xFF000000.toInt())) }
return primary.rgb to secondary
}
private fun distinctness(
a: Color,
b: Color,
): Float {
val ha = a.toHsl().first
val hb = b.toHsl().first
val hueDistance = minOf(abs(ha - hb), 360f - abs(ha - hb)) / 180f
return hueDistance + abs(a.luminance() - b.luminance()) * 2f
}
@Composable
fun ArtworkTheme(
colors: ArtworkColors?,
@@ -340,7 +271,7 @@ fun heroDarkenAt(
}
}
private fun Color.toHsl(): Triple<Float, Float, Float> {
internal fun Color.toHsl(): Triple<Float, Float, Float> {
val max = maxOf(red, green, blue)
val min = minOf(red, green, blue)
val lightness = (max + min) / 2f