feat: add colour gradients
This commit is contained in:
@@ -137,6 +137,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.media)
|
implementation(libs.androidx.media)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.androidx.palette)
|
||||||
|
|
||||||
// The Compose BOM aligns every Compose artifact to one tested version set,
|
// The Compose BOM aligns every Compose artifact to one tested version set,
|
||||||
// so the individual Compose deps below are declared without versions.
|
// 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
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -46,6 +48,14 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -66,6 +76,25 @@ private val ART_MAX_SIZE = 480.dp
|
|||||||
*/
|
*/
|
||||||
private val FLEX_MIN_HEIGHT = 740.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
|
* 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
|
* 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()
|
val song by vm.currentSong.collectAsStateWithLifecycle()
|
||||||
var showDisconnectConfirm by remember { mutableStateOf(false) }
|
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
|
// 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
|
// 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)
|
// 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.
|
// fall back to a scrolling column with a fixed art size so the lower controls stay reachable.
|
||||||
val scrollState = rememberScrollState()
|
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
|
val flexible = maxHeight >= FLEX_MIN_HEIGHT
|
||||||
// top = 0: the icon buttons carry their own internal padding, and systemBarsPadding
|
// 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.
|
// already clears the status bar — any extra top padding just pushes them down.
|
||||||
@@ -130,7 +196,10 @@ fun NowPlayingScreen(
|
|||||||
ArtImage(
|
ArtImage(
|
||||||
model = artModel,
|
model = artModel,
|
||||||
iconSize = 96.dp,
|
iconSize = 96.dp,
|
||||||
modifier = Modifier.size(side).clip(RoundedCornerShape(16.dp)),
|
modifier =
|
||||||
|
Modifier.size(side)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.onGloballyPositioned(::captureCover),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -141,7 +210,8 @@ fun NowPlayingScreen(
|
|||||||
Modifier.padding(vertical = 16.dp)
|
Modifier.padding(vertical = 16.dp)
|
||||||
.fillMaxWidth(0.9f)
|
.fillMaxWidth(0.9f)
|
||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
.clip(RoundedCornerShape(16.dp)),
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.onGloballyPositioned(::captureCover),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
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)
|
# (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"
|
junit = "4.13.2"
|
||||||
|
|
||||||
[libraries]
|
[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-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
androidx-media = { group = "androidx.media", name = "media", version.ref = "media" }
|
androidx-media = { group = "androidx.media", name = "media", version.ref = "media" }
|
||||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
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" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
Reference in New Issue
Block a user