mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
prefetch artwork seeds from the grid so an album opens with its theme
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user