From f8da91e0ccdeb03423d735610c272dd6bbd08f88 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:30:45 +0000 Subject: [PATCH] prefetch artwork seeds from the grid so an album opens with its theme --- .../com/subtracks/ui/library/AlbumsContent.kt | 5 +- .../ui/library/ArtistDetailScreen.kt | 2 + .../subtracks/ui/theme/ArtworkSeedCache.kt | 113 ++++++++++++++++++ .../com/subtracks/ui/theme/ArtworkTheme.kt | 73 +---------- 4 files changed, 121 insertions(+), 72 deletions(-) create mode 100644 app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt index 0c3f37f6..c8cc9a24 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt @@ -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 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 e239880c..42ada660 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -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 diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt new file mode 100644 index 00000000..34ead2ee --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt @@ -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?>>() + + fun load( + context: Context, + ref: CoverArtRef, + ): Deferred?> = + 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? = + 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? { + 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) + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt index 184f7672..c1ac1a37 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt @@ -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?> { val context = LocalPlatformContext.current return produceState?>(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? { - 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 { +internal fun Color.toHsl(): Triple { val max = maxOf(red, green, blue) val min = minOf(red, green, blue) val lightness = (max + min) / 2f