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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user