mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +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.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,67 +173,10 @@ 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(
|
||||||
colors: ArtworkColors?,
|
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 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
|
||||||
|
|||||||
Reference in New Issue
Block a user