feat(android): NowPlaying dominant-color gradient backdrop (audit v2 #17, slice 1)

Adds androidx.palette dependency and a rememberDominantColor()
helper that pulls the cover bitmap via Coil's singleton loader
(shares the cache with the on-screen AsyncImage), runs Palette
extraction on Dispatchers.Default, and animates the resulting
color with animateColorAsState so track changes tween smoothly.

NowPlayingScreen wraps the body in a Box with a vertical gradient
(0% dominant @ 55%α → 45% dominant @ 18%α → 100% scheme.background)
and lets the Scaffold's containerColor go transparent so the
gradient shows through. Falls back to a near-transparent gradient
on bitmap-load failure so the screen never sits on flat black.

Hero transition (MiniPlayer → NowPlaying cover) and cover
preload still pending — those land in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 15:08:00 -04:00
parent f9b0c267e3
commit 01fdd2f380
4 changed files with 104 additions and 8 deletions
+1
View File
@@ -165,6 +165,7 @@ dependencies {
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.androidx.palette)
implementation(libs.icons.lucide)
implementation(libs.timber)
@@ -0,0 +1,69 @@
package com.fabledsword.minstrel.player.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.palette.graphics.Palette
import coil3.SingletonImageLoader
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val GRADIENT_TWEEN_MS = 600
/**
* Animated dominant-color extractor for the NowPlaying gradient
* backdrop. Fetches [coverUrl] via Coil's singleton loader (so the
* bitmap is shared with the on-screen AsyncImage cache), runs
* androidx.palette on a background thread, and animates the result
* via [animateColorAsState] so track changes tween smoothly.
*
* Returns [Color.Transparent] while the bitmap is loading or on
* any failure — callers should layer a fallback (e.g.
* `MaterialTheme.colorScheme.surface`) underneath the gradient
* tint so the screen never sits on a flat black.
*/
@Composable
fun rememberDominantColor(coverUrl: String?): Color {
val context = LocalContext.current
var extracted by remember(coverUrl) { mutableStateOf(Color.Transparent) }
LaunchedEffect(coverUrl) {
if (coverUrl.isNullOrEmpty()) {
extracted = Color.Transparent
return@LaunchedEffect
}
val request = ImageRequest.Builder(context)
.data(coverUrl)
.allowHardware(false)
.build()
val result = SingletonImageLoader.get(context).execute(request)
if (result !is SuccessResult) return@LaunchedEffect
val bitmap = result.image.toBitmap()
val palette = withContext(Dispatchers.Default) {
Palette.from(bitmap).generate()
}
val swatch = palette.vibrantSwatch
?: palette.mutedSwatch
?: palette.dominantSwatch
if (swatch != null) {
extracted = Color(swatch.rgb)
}
}
val animated by animateColorAsState(
targetValue = extracted,
animationSpec = androidx.compose.animation.core.tween(GRADIENT_TWEEN_MS),
label = "now-playing-dominant",
)
return animated
}
@@ -1,3 +1,5 @@
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
package com.fabledsword.minstrel.player.ui
import androidx.compose.foundation.background
@@ -23,6 +25,7 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.material3.SliderDefaults
@@ -111,22 +114,43 @@ fun NowPlayingScreen(
}
return
}
val dominant = rememberDominantColor(track.coverUrl)
Scaffold(
modifier = Modifier.fillMaxSize().nowPlayingDragDismiss(navController),
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.background,
containerColor = Color.Transparent,
) { inner ->
NowPlayingBody(
inner = inner,
state = state,
track = track,
navController = navController,
viewModel = viewModel,
)
Box(
modifier = Modifier
.fillMaxSize()
.background(dominantGradient(dominant)),
) {
NowPlayingBody(
inner = inner,
state = state,
track = track,
navController = navController,
viewModel = viewModel,
)
}
}
}
@Composable
private fun dominantGradient(top: Color): Brush {
val base = MaterialTheme.colorScheme.background
return Brush.verticalGradient(
0f to top.copy(alpha = DOMINANT_TINT_TOP),
DOMINANT_TINT_MID_STOP to top.copy(alpha = DOMINANT_TINT_MID),
1f to base,
)
}
private const val DOMINANT_TINT_TOP = 0.55f
private const val DOMINANT_TINT_MID = 0.18f
private const val DOMINANT_TINT_MID_STOP = 0.45f
/**
* Drag-down to dismiss. Mirrors Flutter's modal-page gesture: a
* downward drag past the threshold pops the screen, returning the
+2
View File
@@ -23,6 +23,7 @@ kotlinx-datetime = "0.6.1"
kotlinx-serialization-converter = "1.0.0"
media3 = "1.10.1"
coil = "3.0.0-rc02"
palette = "1.0.0"
timber = "5.0.1"
work = "2.10.0"
lifecycle = "2.8.7"
@@ -72,6 +73,7 @@ media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp"
media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
androidx-palette = { module = "androidx.palette:palette-ktx", version.ref = "palette" }
# CMP (Compose Multiplatform) variant — provides icons as ImageVector
# objects (e.g. `Icon(Lucide.Disc3, ...)`). The -android variant ships
# Vector Drawable XML accessed via painterResource(), which is less