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.compose)
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation(libs.coil.network.okhttp)
|
||||||
|
implementation(libs.androidx.palette)
|
||||||
implementation(libs.icons.lucide)
|
implementation(libs.icons.lucide)
|
||||||
|
|
||||||
implementation(libs.timber)
|
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
|
package com.fabledsword.minstrel.player.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -23,6 +25,7 @@ import androidx.compose.material3.Slider
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.material3.SliderDefaults
|
import androidx.compose.material3.SliderDefaults
|
||||||
@@ -111,22 +114,43 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val dominant = rememberDominantColor(track.coverUrl)
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize().nowPlayingDragDismiss(navController),
|
modifier = Modifier.fillMaxSize().nowPlayingDragDismiss(navController),
|
||||||
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
|
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = Color.Transparent,
|
||||||
) { inner ->
|
) { inner ->
|
||||||
NowPlayingBody(
|
Box(
|
||||||
inner = inner,
|
modifier = Modifier
|
||||||
state = state,
|
.fillMaxSize()
|
||||||
track = track,
|
.background(dominantGradient(dominant)),
|
||||||
navController = navController,
|
) {
|
||||||
viewModel = viewModel,
|
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
|
* Drag-down to dismiss. Mirrors Flutter's modal-page gesture: a
|
||||||
* downward drag past the threshold pops the screen, returning the
|
* 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"
|
kotlinx-serialization-converter = "1.0.0"
|
||||||
media3 = "1.10.1"
|
media3 = "1.10.1"
|
||||||
coil = "3.0.0-rc02"
|
coil = "3.0.0-rc02"
|
||||||
|
palette = "1.0.0"
|
||||||
timber = "5.0.1"
|
timber = "5.0.1"
|
||||||
work = "2.10.0"
|
work = "2.10.0"
|
||||||
lifecycle = "2.8.7"
|
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" }
|
media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
|
||||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
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" }
|
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
|
# CMP (Compose Multiplatform) variant — provides icons as ImageVector
|
||||||
# objects (e.g. `Icon(Lucide.Disc3, ...)`). The -android variant ships
|
# objects (e.g. `Icon(Lucide.Disc3, ...)`). The -android variant ships
|
||||||
# Vector Drawable XML accessed via painterResource(), which is less
|
# Vector Drawable XML accessed via painterResource(), which is less
|
||||||
|
|||||||
Reference in New Issue
Block a user