From 0a2ae6adf84d98160a7e719e4686a6d212a0af65 Mon Sep 17 00:00:00 2001 From: Karim Abdul-Samad Date: Sat, 8 Aug 2026 17:19:32 -0400 Subject: [PATCH] feat: add colour gradients --- app/build.gradle.kts | 1 + .../ca/ksamad/encore/ui/AlbumArtAccent.kt | 76 +++++++++++++++++++ .../ca/ksamad/encore/ui/NowPlayingScreen.kt | 76 ++++++++++++++++++- gradle/libs.versions.toml | 2 + 4 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/ca/ksamad/encore/ui/AlbumArtAccent.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e1e5ec8..1df68fc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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. diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumArtAccent.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumArtAccent.kt new file mode 100644 index 0000000..0200bbe --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumArtAccent.kt @@ -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(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 +} diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/NowPlayingScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/NowPlayingScreen.kt index f8f340e..92a4c7a 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/NowPlayingScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/NowPlayingScreen.kt @@ -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), ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 47fd464..f143426 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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]