Files
Android-247-Radio/docs/plans/2026-03-18-heavy-blur-background.md
2026-03-18 13:35:57 -04:00

10 KiB
Raw Permalink Blame History

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

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

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

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 ~368378):

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:

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:

import com.skydoves.cloudy.cloudy

Add (if not auto-resolved by IDE):

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

./gradlew :app:compileDebugKotlin 2>&1 | tail -20

Expected: BUILD SUCCESSFUL

Step 4: Commit

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:

implementation(libs.cloudy)

Step 2: Remove from gradle/libs.versions.toml

Remove the version entry:

cloudy = "0.2.7"

Remove the library entry:

cloudy = { group = "com.github.skydoves", name = "cloudy", version.ref = "cloudy" }

Step 3: Sync and verify full build

./gradlew :app:assembleDebug 2>&1 | tail -30

Expected: BUILD SUCCESSFUL

Step 4: Commit

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).