feat: add colour gradients

This commit is contained in:
2026-08-08 17:21:17 -04:00
parent 163fa6c87e
commit 0a2ae6adf8
4 changed files with 152 additions and 3 deletions
+1
View File
@@ -137,6 +137,7 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.media)
implementation(libs.coil.compose)
implementation(libs.androidx.palette)
// The Compose BOM aligns every Compose artifact to one tested version set,
// so the individual Compose deps below are declared without versions.
@@ -0,0 +1,76 @@
package ca.ksamad.encore.ui
import android.util.LruCache
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.palette.graphics.Palette
import ca.ksamad.encore.playback.SongArt
import coil3.SingletonImageLoader
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.size.Size
import coil3.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
// Small in-memory cache of extracted accent colours (ARGB ints), keyed by the art's cache key, so
// revisiting a song — or a recomposition — never re-runs Palette.
private val accentCache = LruCache<String, Int>(64)
/**
* The dominant accent colour of [art]'s cover, used for the now-playing background bleed.
*
* Pulls a small bitmap from Coil (served from its memory/disk cache, so no extra network request),
* runs [Palette] off the main thread, and memoises the result. Returns [fallback] until it resolves,
* and whenever there's no art or extraction fails — so callers can just use the value directly.
*/
@Composable
fun rememberArtAccentColor(
art: SongArt?,
fallback: Color,
): Color {
val context = LocalContext.current
return produceState(initialValue = fallback, art) {
if (art == null) {
value = fallback
return@produceState
}
val key = "${art.uri}@${art.version.orEmpty()}"
accentCache.get(key)?.let {
value = Color(it)
return@produceState
}
val argb =
withContext(Dispatchers.Default) {
runCatching {
val loader = SingletonImageLoader.get(context)
val result =
loader.execute(
ImageRequest.Builder(context)
.data(art)
.size(Size(128, 128)) // tiny: Palette downsamples anyway
.allowHardware(false) // Palette must read pixels on the CPU
.build()
)
val bitmap =
(result as? SuccessResult)?.image?.toBitmap() ?: return@runCatching null
val palette = Palette.from(bitmap).maximumColorCount(16).generate()
val swatch =
palette.dominantSwatch ?: palette.vibrantSwatch ?: palette.mutedSwatch
swatch?.let { 0xFF000000.toInt() or it.rgb } // force opaque
}
.getOrNull()
}
if (argb != null) {
accentCache.put(key, argb)
value = Color(argb)
} else {
value = fallback
}
}
.value
}
@@ -1,5 +1,7 @@
package ca.ksamad.encore.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
@@ -46,6 +48,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -66,6 +76,25 @@ private val ART_MAX_SIZE = 480.dp
*/
private val FLEX_MIN_HEIGHT = 740.dp
// --- Album-art colour-bleed tuning ---
// A 3-stop radial fade (centre → mid → base) with no flat plateau, so the colour fades continuously
// out from the cover. The strengths are how far the accent is blended toward the base (0 = base,
// 1 = full accent).
/** Accent blend at the cover centre (strongest). */
private const val BLEED_STRENGTH = 0.55f
/** Accent blend at [BLEED_MID_STOP] — the tail of the fade. */
private const val BLEED_MID_STRENGTH = 0.22f
/** Radius fraction at which the mid colour sits (shapes the falloff curve). */
private const val BLEED_MID_STOP = 0.5f
/** Gradient radius as a multiple of the cover's larger side — larger = more gradual, wider spread. */
private const val BLEED_RADIUS_FACTOR = 1.9f
/** Crossfade duration (ms) when the accent changes on a new song. */
private const val BLEED_ANIM_MS = 700
/**
* The now-playing screen: current track, a live seek bar, transport controls, volume, and the
* repeat/random toggles. Everything reads from the [PlayerViewModel] flows, so it updates whenever
@@ -82,12 +111,49 @@ fun NowPlayingScreen(
val song by vm.currentSong.collectAsStateWithLifecycle()
var showDisconnectConfirm by remember { mutableStateOf(false) }
// --- Album-art colour bleed ------------------------------------------------
// Pull the cover's dominant colour and radiate it from the cover's on-screen position so the
// artwork looks like it bleeds into the page. Centre/radius come from the art's measured bounds
// (captureCover); the gradient draws edge-to-edge behind everything (before systemBarsPadding).
val base = MaterialTheme.colorScheme.background
val accent =
rememberArtAccentColor(song?.let { SongArt(it.uri, it.lastModified) }, fallback = base)
val animatedAccent by animateColorAsState(accent, tween(BLEED_ANIM_MS), label = "artAccent")
var coverCenter by remember { mutableStateOf(Offset.Unspecified) }
var coverRadius by remember { mutableFloatStateOf(0f) }
fun captureCover(coords: LayoutCoordinates) {
val pos = coords.positionInRoot()
val w = coords.size.width.toFloat()
val h = coords.size.height.toFloat()
coverCenter = Offset(pos.x + w / 2f, pos.y + h / 2f)
coverRadius = maxOf(w, h) * BLEED_RADIUS_FACTOR
}
// On a tall enough screen, lay everything out without scrolling and let the album art
// flex into the leftover vertical space (capped at ART_MAX_SIZE) — bigger displays show
// bigger art, smaller ones just shrink it. On a short viewport (landscape, small phones)
// fall back to a scrolling column with a fixed art size so the lower controls stay reachable.
val scrollState = rememberScrollState()
BoxWithConstraints(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
BoxWithConstraints(
modifier =
Modifier.fillMaxSize()
.drawBehind {
val c = if (coverCenter.isSpecified) coverCenter else center
val r = if (coverRadius > 0f) coverRadius else size.minDimension
val centerColor = lerp(base, animatedAccent, BLEED_STRENGTH)
val midColor = lerp(base, animatedAccent, BLEED_MID_STRENGTH)
drawRect(
Brush.radialGradient(
0f to centerColor,
BLEED_MID_STOP to midColor,
1f to base,
center = c,
radius = r,
)
)
}
.systemBarsPadding()
) {
val flexible = maxHeight >= FLEX_MIN_HEIGHT
// top = 0: the icon buttons carry their own internal padding, and systemBarsPadding
// already clears the status bar — any extra top padding just pushes them down.
@@ -130,7 +196,10 @@ fun NowPlayingScreen(
ArtImage(
model = artModel,
iconSize = 96.dp,
modifier = Modifier.size(side).clip(RoundedCornerShape(16.dp)),
modifier =
Modifier.size(side)
.clip(RoundedCornerShape(16.dp))
.onGloballyPositioned(::captureCover),
)
}
} else {
@@ -141,7 +210,8 @@ fun NowPlayingScreen(
Modifier.padding(vertical = 16.dp)
.fillMaxWidth(0.9f)
.aspectRatio(1f)
.clip(RoundedCornerShape(16.dp)),
.clip(RoundedCornerShape(16.dp))
.onGloballyPositioned(::captureCover),
)
}
+2
View File
@@ -13,6 +13,7 @@ datastore = "1.1.1" # Jetpack DataStore (persisting connection sett
media = "1.7.0" # MediaSessionCompat: OS media session, notification, cast-style volume
coil = "3.0.4" # Coil: image loading + memory/disk caching for album art
# (pinned to a 3.x that targets compileSdk 35; 3.5 needs 36)
palette = "1.0.0" # AndroidX Palette: extract cover-art colours for the bleed effect
junit = "4.13.2"
[libraries]
@@ -32,6 +33,7 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
androidx-media = { group = "androidx.media", name = "media", version.ref = "media" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
androidx-palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
[plugins]