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.EmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds
const val ALBUM_COVER_TAG = "album-cover" const val ALBUM_COVER_TAG = "album-cover"
@@ -62,8 +63,10 @@ fun AlbumsContent(
items(count = items.itemCount, key = items.itemKey { it.id }) { index -> items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
val album = items[index] val album = items[index]
if (album != null) { if (album != null) {
val art = coverArt(album.coverArt, true)
PrefetchArtworkSeeds(art)
CoverArt( CoverArt(
ref = coverArt(album.coverArt, true), ref = art,
name = album.name, name = album.name,
modifier = modifier =
Modifier Modifier
@@ -53,6 +53,7 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@@ -152,6 +153,7 @@ fun ArtistDetailScreen(
} }
} }
itemsIndexed(albums, key = { _, album -> album.id }) { index, album -> itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
PrefetchArtworkSeeds(coverArt(album.coverArt, true))
Column( Column(
modifier = modifier =
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 package com.subtracks.ui.theme
import android.graphics.Bitmap
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme 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.TileMode
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.luminance
import androidx.palette.graphics.Palette
import coil3.SingletonImageLoader
import coil3.compose.LocalPlatformContext 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 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 import kotlin.random.Random
private const val SEED_ART_SIZE_PX = 128
private const val PERIOD_SCREENS = 2f private const val PERIOD_SCREENS = 2f
private const val BLOB_ZONE = 0.62f private const val BLOB_ZONE = 0.62f
private const val BLOB_COUNT = 4 private const val BLOB_COUNT = 4
@@ -185,66 +173,9 @@ fun rememberArtworkColors(
private fun rememberArtworkSeed(ref: CoverArtRef?): State<Pair<Int, Int?>?> { private fun rememberArtworkSeed(ref: CoverArtRef?): State<Pair<Int, Int?>?> {
val context = LocalPlatformContext.current val context = LocalPlatformContext.current
return produceState<Pair<Int, Int?>?>(initialValue = null, ref?.cacheKey) { return produceState<Pair<Int, Int?>?>(initialValue = null, ref?.cacheKey) {
value = null value = ref?.let { ArtworkSeedCache.load(context, it).await() }
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
} }
} }
}
}
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 @Composable
fun ArtworkTheme( fun ArtworkTheme(
@@ -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 max = maxOf(red, green, blue)
val min = minOf(red, green, blue) val min = minOf(red, green, blue)
val lightness = (max + min) / 2f val lightness = (max + min) / 2f