diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 87d52cf9..2535edf2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/DominantColor.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/DominantColor.kt new file mode 100644 index 00000000..558ab019 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/DominantColor.kt @@ -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 +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index 1385d33b..13a05c3a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -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 diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index d19e6fdd..8a2be34f 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -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