From 68891f1df91bad73cacdda475d2edcc3810899a7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 15:12:33 -0400 Subject: [PATCH] fix(android): hold previous NowPlaying gradient color across track change rememberDominantColor was keyed on coverUrl, so each track change reset the extracted color to Transparent and the gradient dipped toward the fallback before tweening to the new color. Drop the key so the held color stays on the previous track's dominant until the new palette resolves -- the gradient now tweens directly to the new color. Combined with CoverPrefetcher warming the next cover, this matches Flutter's preload-then-atomic-swap UX (no flash on track change) via native Compose mechanisms rather than a literal preload pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/player/ui/DominantColor.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) 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 index 558ab019..4298a2dc 100644 --- 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 @@ -27,15 +27,21 @@ private const val GRADIENT_TWEEN_MS = 600 * 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. + * The held color is NOT reset when [coverUrl] changes — it stays on + * the previous track's dominant until the new palette resolves, so the + * gradient tweens old→new directly instead of dipping toward the + * fallback mid-swap. Mirrors `now_playing_screen.dart`'s preload-then- + * swap ("keep the previous dominant"); the cover image swaps smoothly + * via `CoverPrefetcher`, which warms the next track's bytes into Coil. + * + * Starts at [Color.Transparent] (cold mount) and resets to it only + * when a track has no cover — callers layer a base color underneath so + * the screen never sits on flat black. */ @Composable fun rememberDominantColor(coverUrl: String?): Color { val context = LocalContext.current - var extracted by remember(coverUrl) { mutableStateOf(Color.Transparent) } + var extracted by remember { mutableStateOf(Color.Transparent) } LaunchedEffect(coverUrl) { if (coverUrl.isNullOrEmpty()) { @@ -47,6 +53,7 @@ fun rememberDominantColor(coverUrl: String?): Color { .allowHardware(false) .build() val result = SingletonImageLoader.get(context).execute(request) + // Keep the previous dominant on load failure rather than dropping. if (result !is SuccessResult) return@LaunchedEffect val bitmap = result.image.toBitmap() val palette = withContext(Dispatchers.Default) {