feat: add colour gradients
This commit is contained in:
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user