From 7cd9d249d873e3f8de19a22d9f7bae736184a7e0 Mon Sep 17 00:00:00 2001 From: cottongin Date: Wed, 18 Mar 2026 13:28:17 -0400 Subject: [PATCH] docs: add implementation plan for heavy blur background Made-with: Cursor --- .../plans/2026-03-18-heavy-blur-background.md | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 docs/plans/2026-03-18-heavy-blur-background.md diff --git a/docs/plans/2026-03-18-heavy-blur-background.md b/docs/plans/2026-03-18-heavy-blur-background.md new file mode 100644 index 0000000..ee22718 --- /dev/null +++ b/docs/plans/2026-03-18-heavy-blur-background.md @@ -0,0 +1,291 @@ +# Heavy Blur Background Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the Cloudy blur library with an inlined Stackblur `BlurTransformation` applied via Coil's pipeline, producing a heavily blurred background where album art text is completely illegible. + +**Architecture:** A self-contained `BlurTransformation` class is added to the project's util package. It is applied as a Coil `ImageRequest` transformation in `BlurredBackground`, replacing the `.cloudy()` modifier. Cloudy is then removed from all dependency files. + +**Tech Stack:** Kotlin, Jetpack Compose, Coil 3 (`io.coil-kt.coil3`), Android Bitmap APIs + +--- + +### Task 1: Add BlurTransformation utility class + +**Files:** +- Create: `app/src/main/java/xyz/cottongin/radio247/ui/util/BlurTransformation.kt` + +**Step 1: Create the file with the Stackblur implementation** + +```kotlin +package xyz.cottongin.radio247.ui.util + +import android.graphics.Bitmap +import coil3.size.Size +import coil3.transform.Transformation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.math.abs +import kotlin.math.roundToInt + +class BlurTransformation( + private val radius: Int = 25, + private val scale: Float = 0.1f +) : Transformation() { + + override val cacheKey: String = "${javaClass.name}-$radius-$scale" + + override suspend fun transform(input: Bitmap, size: Size): Bitmap = + input.stackBlur(scale, radius) ?: input + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is BlurTransformation && radius == other.radius && scale == other.scale + } + + override fun hashCode(): Int = 31 * radius.hashCode() + scale.hashCode() +} + +private suspend fun Bitmap.stackBlur(scale: Float, radius: Int): Bitmap? = + withContext(Dispatchers.IO) { + var sentBitmap = this@stackBlur + val width = (sentBitmap.width * scale).roundToInt() + val height = (sentBitmap.height * scale).roundToInt() + sentBitmap = Bitmap.createScaledBitmap(sentBitmap, width, height, false) + val bitmap = sentBitmap.copy(sentBitmap.config ?: Bitmap.Config.ARGB_8888, true) + if (radius < 1) return@withContext null + val w = bitmap.width + val h = bitmap.height + val pix = IntArray(w * h) + bitmap.getPixels(pix, 0, w, 0, 0, w, h) + val wm = w - 1 + val hm = h - 1 + val wh = w * h + val div = radius + radius + 1 + val r = IntArray(wh) + val g = IntArray(wh) + val b = IntArray(wh) + var rsum: Int; var gsum: Int; var bsum: Int + var x: Int; var y: Int; var i: Int; var p: Int; var yp: Int; var yi: Int + val vmin = IntArray(w.coerceAtLeast(h)) + var divsum = div + 1 shr 1 + divsum *= divsum + val dv = IntArray(256 * divsum) { it / divsum } + yi = 0; var yw = 0 + val stack = Array(div) { IntArray(3) } + var stackpointer: Int; var stackstart: Int; var sir: IntArray; var rbs: Int + val r1 = radius + 1 + var routsum: Int; var goutsum: Int; var boutsum: Int + var rinsum: Int; var ginsum: Int; var binsum: Int + y = 0 + while (y < h) { + bsum = 0; gsum = 0; rsum = 0; boutsum = 0; goutsum = 0; routsum = 0 + binsum = 0; ginsum = 0; rinsum = 0 + i = -radius + while (i <= radius) { + p = pix[yi + wm.coerceAtMost(i.coerceAtLeast(0))] + sir = stack[i + radius] + sir[0] = p and 0xff0000 shr 16; sir[1] = p and 0x00ff00 shr 8; sir[2] = p and 0x0000ff + rbs = r1 - abs(i) + rsum += sir[0] * rbs; gsum += sir[1] * rbs; bsum += sir[2] * rbs + if (i > 0) { rinsum += sir[0]; ginsum += sir[1]; binsum += sir[2] } + else { routsum += sir[0]; goutsum += sir[1]; boutsum += sir[2] } + i++ + } + stackpointer = radius; x = 0 + while (x < w) { + r[yi] = dv[rsum]; g[yi] = dv[gsum]; b[yi] = dv[bsum] + rsum -= routsum; gsum -= goutsum; bsum -= boutsum + stackstart = stackpointer - radius + div + sir = stack[stackstart % div] + routsum -= sir[0]; goutsum -= sir[1]; boutsum -= sir[2] + if (y == 0) vmin[x] = (x + radius + 1).coerceAtMost(wm) + p = pix[yw + vmin[x]] + sir[0] = p and 0xff0000 shr 16; sir[1] = p and 0x00ff00 shr 8; sir[2] = p and 0x0000ff + rinsum += sir[0]; ginsum += sir[1]; binsum += sir[2] + rsum += rinsum; gsum += ginsum; bsum += binsum + stackpointer = (stackpointer + 1) % div + sir = stack[stackpointer % div] + routsum += sir[0]; goutsum += sir[1]; boutsum += sir[2] + rinsum -= sir[0]; ginsum -= sir[1]; binsum -= sir[2] + yi++; x++ + } + yw += w; y++ + } + x = 0 + while (x < w) { + bsum = 0; gsum = 0; rsum = 0; boutsum = 0; goutsum = 0; routsum = 0 + binsum = 0; ginsum = 0; rinsum = 0 + yp = -radius * w; i = -radius + while (i <= radius) { + yi = 0.coerceAtLeast(yp) + x + sir = stack[i + radius] + sir[0] = r[yi]; sir[1] = g[yi]; sir[2] = b[yi] + rbs = r1 - abs(i) + rsum += r[yi] * rbs; gsum += g[yi] * rbs; bsum += b[yi] * rbs + if (i > 0) { rinsum += sir[0]; ginsum += sir[1]; binsum += sir[2] } + else { routsum += sir[0]; goutsum += sir[1]; boutsum += sir[2] } + if (i < hm) yp += w + i++ + } + yi = x; stackpointer = radius; y = 0 + while (y < h) { + pix[yi] = -0x1000000 and pix[yi] or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum] + rsum -= routsum; gsum -= goutsum; bsum -= boutsum + stackstart = stackpointer - radius + div + sir = stack[stackstart % div] + routsum -= sir[0]; goutsum -= sir[1]; boutsum -= sir[2] + if (x == 0) vmin[y] = (y + r1).coerceAtMost(hm) * w + p = x + vmin[y] + sir[0] = r[p]; sir[1] = g[p]; sir[2] = b[p] + rinsum += sir[0]; ginsum += sir[1]; binsum += sir[2] + rsum += rinsum; gsum += ginsum; bsum += binsum + stackpointer = (stackpointer + 1) % div + sir = stack[stackpointer] + routsum += sir[0]; goutsum += sir[1]; boutsum += sir[2] + rinsum -= sir[0]; ginsum -= sir[1]; binsum -= sir[2] + yi += w; y++ + } + x++ + } + bitmap.setPixels(pix, 0, w, 0, 0, w, h) + sentBitmap.recycle() + bitmap + } +``` + +> **Note on Coil 3 API:** Coil 3 uses `coil3.transform.Transformation` (abstract class, not interface) and `coil3.size.Size`. The `transform()` method signature is `suspend fun transform(input: Bitmap, size: Size): Bitmap`. Verify these imports compile — if `Transformation` is an interface in the version in use, remove the `()` after `Transformation`. + +**Step 2: Verify the file compiles** + +```bash +cd /path/to/Android-247-Radio +./gradlew :app:compileDebugKotlin 2>&1 | tail -20 +``` + +Expected: BUILD SUCCESSFUL (or only pre-existing warnings, no new errors) + +**Step 3: Commit** + +```bash +git add app/src/main/java/xyz/cottongin/radio247/ui/util/BlurTransformation.kt +git commit -m "feat: add Stackblur BlurTransformation utility" +``` + +--- + +### Task 2: Update BlurredBackground to use BlurTransformation + +**Files:** +- Modify: `app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt:359-388` + +**Step 1: Update the `BlurredBackground` composable** + +Find this block (lines ~368–378): + +```kotlin +AsyncImage( + model = ImageRequest.Builder(context) + .data(artworkUrl) + .size(Size(10, 10)) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .cloudy(radius = 25) +) +``` + +Replace with: + +```kotlin +AsyncImage( + model = ImageRequest.Builder(context) + .data(artworkUrl) + .transformations(BlurTransformation(radius = 25, scale = 0.1f)) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() +) +``` + +**Step 2: Update imports in `NowPlayingScreen.kt`** + +Remove: +```kotlin +import com.skydoves.cloudy.cloudy +``` + +Add (if not auto-resolved by IDE): +```kotlin +import xyz.cottongin.radio247.ui.util.BlurTransformation +``` + +Also remove the `coil.size.Size` import if it was only used for the `size(10, 10)` call and is no longer referenced. Check for any remaining `Size` usage in the file first. + +**Step 3: Verify the file compiles** + +```bash +./gradlew :app:compileDebugKotlin 2>&1 | tail -20 +``` + +Expected: BUILD SUCCESSFUL + +**Step 4: Commit** + +```bash +git add app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt +git commit -m "feat: replace cloudy blur with BlurTransformation on player background" +``` + +--- + +### Task 3: Remove Cloudy dependency + +**Files:** +- Modify: `app/build.gradle.kts` +- Modify: `gradle/libs.versions.toml` + +**Step 1: Remove from `app/build.gradle.kts`** + +Remove this line: +```kotlin +implementation(libs.cloudy) +``` + +**Step 2: Remove from `gradle/libs.versions.toml`** + +Remove the version entry: +```toml +cloudy = "0.2.7" +``` + +Remove the library entry: +```toml +cloudy = { group = "com.github.skydoves", name = "cloudy", version.ref = "cloudy" } +``` + +**Step 3: Sync and verify full build** + +```bash +./gradlew :app:assembleDebug 2>&1 | tail -30 +``` + +Expected: BUILD SUCCESSFUL + +**Step 4: Commit** + +```bash +git add app/build.gradle.kts gradle/libs.versions.toml +git commit -m "chore: remove Cloudy blur library dependency" +``` + +--- + +## Verification + +After all tasks complete, install on device/emulator and navigate to the Now Playing screen. Album art text should be completely illegible and no sharp pixel edges should be visible in the background. + +If the blur is still not strong enough, increase `scale` down (e.g. `0.05f`) or increase `radius` (e.g. `radius = 50`) in `BlurTransformation(radius = 25, scale = 0.1f)`.