Compare commits

...

10 Commits

Author SHA1 Message Date
cottongin
a09c50c302 docs: refresh README with screenshots, full feature list, and build guide
Add AI disclaimer, 4 emulator screenshots, expanded feature sections
(Android Auto, SomaFM, per-station quality, tabs, Media3 notification,
now-playing logging, blurred art background), build.sh documentation,
inline architecture diagram, and tech stack table.

Made-with: Cursor
2026-03-18 14:06:00 -04:00
cottongin
639cb99d1f chore: add ProGuard keep rules and MIT license
Add app/proguard-rules.pro with keep rules for Room entities/DAOs,
Media3 service and player classes, Coil BlurTransformation, and Kotlin
coroutines internals. Add MIT LICENSE file.

Made-with: Cursor
2026-03-18 13:41:31 -04:00
cottongin
e03e32183b build: enable minification for release builds
Enable isMinifyEnabled and add proguardFiles for the release build type.

Made-with: Cursor
2026-03-18 13:36:04 -04:00
cottongin
7cd9d249d8 docs: add implementation plan for heavy blur background
Made-with: Cursor
2026-03-18 13:35:57 -04:00
cottongin
a5057be3c7 chore: remove Cloudy blur library dependency
Made-with: Cursor
2026-03-18 11:49:33 -04:00
cottongin
bb35ec8a8b feat: replace cloudy blur with BlurTransformation on player background
Made-with: Cursor
2026-03-18 11:44:44 -04:00
cottongin
14aeeecd9c fix: guard zero-dimension bitmap and recycle on early return in BlurTransformation
Made-with: Cursor
2026-03-18 11:36:35 -04:00
cottongin
2e615850bc feat: add Stackblur BlurTransformation utility
Made-with: Cursor
2026-03-18 11:23:30 -04:00
cottongin
ada81dddd0 docs: add design doc for heavy blur background replacement
Replace Cloudy with Coil BlurTransformation for unblurred album art issue.

Made-with: Cursor
2026-03-18 11:13:16 -04:00
cottongin
5105794120 docs: add Android Auto testing summary and ignore apkm files
Documents all DHU testing attempts, root causes for failures (DHU v2.0
protocol incompatibility with AA v16+), and lessons learned. Adds
*.apkm to .gitignore to avoid accidentally committing large APK bundles.

Made-with: Cursor
2026-03-18 10:19:59 -04:00
15 changed files with 751 additions and 25 deletions

3
.gitignore vendored
View File

@@ -41,3 +41,6 @@ local.properties
# Build script output
dist/
keystore/
# APKMirror bundles
*.apkm

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 cottongin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

143
README.md
View File

@@ -1,36 +1,149 @@
# 24/7 Radio
Personal-use Android app for 24/7 internet radio streaming.
Android app for 24/7 internet radio streaming with minimum latency, aggressive reconnection, and full Icecast/Shoutcast metadata support.
> [!IMPORTANT]
> This project was built with the assistance of [Cursor](https://cursor.com) (Claude Opus/Sonnet 4.6).
---
## Screenshots
| Station List | Now Playing | Mini-Player | Station Art Fallback |
|:---:|:---:|:---:|:---:|
| ![Station list showing SomaFM tab](docs/screenshots/01-station-list.jpg) | ![Now Playing with album art](docs/screenshots/02-now-playing-art.jpg) | ![Station list with mini-player bar](docs/screenshots/03-station-list-miniplayer.jpg) | ![Now Playing with station art fallback](docs/screenshots/04-now-playing-station-art.jpg) |
---
## Features
- **Custom Raw Audio Pipeline** — OkHttp → IcyParser → Mp3FrameSync → MediaCodec → AudioTrack for absolute minimum latency (~26ms per MP3 frame)
- **Stay Connected Mode** — Aggressive reconnection with exponential backoff, never gives up
- **Dual Timers** — Session elapsed time and connection elapsed time
- **Latency Indicator** — Estimated stream-to-speaker latency
- **Icecast/Shoutcast Metadata** — Track title, artist extraction from ICY protocol
- **Album Art** — MusicBrainz/Cover Art Archive lookup with fallback chain
- **Playlist Management** — PLS/M3U import/export with #EXTIMG support
- **Station Organization** — Playlists, starring/favoriting, manual reorder
### Playback
- **Custom raw audio pipeline** — OkHttp → IcyParser → Mp3FrameSync → MediaCodec → AudioTrack for minimum latency (~26ms per MP3 frame)
- **Stay Connected mode** — aggressive reconnection with exponential backoff; never gives up until you say stop
- **Configurable buffer** — 0500ms slider; 0ms keeps you as live as possible, higher values smooth out spotty connections
- **Icecast/Shoutcast metadata** — ICY protocol track title and artist extraction
- **Album art** — MusicBrainz/Cover Art Archive lookup with a fallback chain (ICY stream URL → station default art → station logo → placeholder)
### Station Management
- **SomaFM built-in** — full SomaFM catalog pre-loaded, sorted by listener count
- **Per-station stream quality** — choose 128/256 kbps and SSL/non-SSL per station, or set a global preference order
- **PLS/M3U import/export** — with `#EXTIMG` support for station artwork URLs
- **Tabs (playlists)** — pin/unpin, rename, and drag-to-reorder tabs and stations within them
- **Starring/favoriting** — starred stations sort to the top of their tab
### Now Playing Screen
- **Blurred album art background** — art fills the screen behind the player controls
- **Session timer** — total elapsed time since you hit play, not reset on reconnect
- **Connection timer** — elapsed time for the current TCP connection, resets on each reconnect
- **Latency indicator** — estimated stream-to-speaker latency in ms
- **Track history** — every track change is logged to the database with station and timestamp
- **Now Playing file logging** — optionally write track history to CSV, JSON Lines, or plain text
### System Integration
- **Media3 notification** — system media notification with album art, transport controls (play/stop + seek-to-live), lockscreen and Bluetooth headset support
- **Android Auto** — app registers as a media source; browse your playlists and stations from the car display
### Build Tooling
- **`build.sh`** — interactive menu: release APK (signed), debug APK, keystore management, and clean
---
## Requirements
- Android 9.0+ (API 28)
- Internet connection
---
## Build
The recommended path is the interactive build script:
```bash
./gradlew assembleDebug
./build.sh
```
## Import Stations
Menu options:
1. **Build Release APK** — signed, ready to sideload
2. **Build Debug APK** — quick, for testing
3. **Manage Keystore** — create or inspect the signing key
4. **Clean** — remove build artifacts
On first run, choose option 3 to create a keystore before building a release APK. Output lands in `dist/`.
Alternatively, build directly with Gradle:
```bash
./gradlew assembleDebug # debug
./gradlew assembleRelease # release (requires keystore configured in build.gradle.kts)
```
---
## Importing Stations
1. Open the app
2. Tap the import icon in the top bar
3. Select a .m3u or .pls file
4. Stations are added to your list
2. Tap the settings gear → Import
3. Select a `.m3u` or `.pls` file
4. Stations are added to your library
Exported files include `#EXTIMG` tags for stations that have a default artwork URL set.
---
## SomaFM
The SomaFM catalog is pre-loaded on first launch — no import needed. Stations are sorted by current listener count (refreshable with the sync button). You can set a global stream quality preference (256 kbps SSL recommended) or override it per station via long-press → Quality.
---
## Architecture
See [Design Document](docs/plans/2026-03-09-android-247-radio-design.md) and [Implementation Plan](docs/plans/2026-03-09-android-247-radio-implementation.md).
Four layers:
```
┌─────────────────────────────────────────┐
│ UI Layer │
│ Compose screens, ViewModel, StateFlow │
├─────────────────────────────────────────┤
│ Service Layer │
│ RadioPlaybackService (MediaLibrary │
│ Service), RadioPlayerAdapter (Media3 │
│ Player facade), wake + wifi locks │
├─────────────────────────────────────────┤
│ Audio Engine │
│ StreamConnection → IcyParser → │
│ Mp3FrameSync → MediaCodec → AudioTrack │
├─────────────────────────────────────────┤
│ Data Layer │
│ Room DB, PLS/M3U import/export, │
│ DataStore preferences │
└─────────────────────────────────────────┘
```
The audio engine is a standalone component with no Android framework dependencies. The service owns reconnection policy, Android Auto browse tree, and the Media3 session. The UI observes state via `StateFlow` and never touches the engine directly.
For full detail see the [design document](docs/plans/2026-03-09-android-247-radio-design.md) and [Media3/Android Auto design](docs/plans/2026-03-18-media3-android-auto-design.md).
---
## Tech Stack
| Layer | Library |
|---|---|
| Language | Kotlin |
| UI | Jetpack Compose, Material Design 3 |
| Media session / Auto | AndroidX Media3 (media3-session, media3-common) |
| HTTP streaming | OkHttp |
| Audio decoding | MediaCodec (hardware-accelerated) |
| Audio output | AudioTrack |
| Database | Room |
| Preferences | DataStore |
| Image loading | Coil |
| Album art lookup | MusicBrainz / Cover Art Archive (no API key required) |
---
## License
[MIT](LICENSE)

View File

@@ -24,7 +24,11 @@ android {
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
@@ -69,7 +73,6 @@ dependencies {
implementation(libs.material)
implementation(libs.coil.compose)
implementation(libs.coil.network)
implementation(libs.cloudy)
implementation(libs.palette)
testImplementation(libs.junit)

38
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,38 @@
# Room
# Room generates implementation classes via KSP; the generated impls are accessed
# reflectively at runtime, so the abstract database and annotated types must be kept.
-keep class * extends androidx.room.RoomDatabase { *; }
-keepclassmembers class * extends androidx.room.RoomDatabase {
abstract *;
}
-keep @androidx.room.Entity class * { *; }
-keep @androidx.room.Dao interface * { *; }
# Media3
# RadioPlaybackService is declared in AndroidManifest.xml and instantiated by the system.
-keep class xyz.cottongin.radio247.service.RadioPlaybackService { *; }
# RadioPlayerAdapter extends SimpleBasePlayer; keep it so the player state machine
# callbacks are not stripped.
-keep class xyz.cottongin.radio247.service.RadioPlayerAdapter { *; }
-keep class * extends androidx.media3.session.MediaLibraryService { *; }
-keep class * extends androidx.media3.session.MediaSession$Callback { *; }
-keep class * extends androidx.media3.session.MediaLibrarySession$Callback { *; }
# Coil 3 - custom Transformation subclass
-keep class xyz.cottongin.radio247.ui.util.BlurTransformation { *; }
# Kotlin coroutines
# MainDispatcherFactory and CoroutineExceptionHandler are loaded via ServiceLoader /
# reflection; keepnames prevents renaming while still allowing shrinking.
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembernames class kotlinx.** {
volatile <fields>;
}
# Suppress warnings for optional/transitive dependencies that are not present at runtime
-dontwarn org.slf4j.**
-dontwarn javax.annotation.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**

View File

@@ -106,14 +106,14 @@ import androidx.palette.graphics.Palette
import coil3.compose.AsyncImage
import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
import coil3.size.Size
import coil3.request.transformations
import xyz.cottongin.radio247.R
import xyz.cottongin.radio247.RadioApplication
import xyz.cottongin.radio247.ui.util.BlurTransformation
import xyz.cottongin.radio247.audio.IcyMetadata
import xyz.cottongin.radio247.audio.MetadataFormatter
import xyz.cottongin.radio247.audio.StreamInfo
import xyz.cottongin.radio247.service.PlaybackState
import com.skydoves.cloudy.cloudy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
@@ -368,13 +368,11 @@ private fun BlurredBackground(
AsyncImage(
model = ImageRequest.Builder(context)
.data(artworkUrl)
.size(Size(10, 10))
.transformations(BlurTransformation(radius = 25, scale = 0.1f))
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.cloudy(radius = 25)
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier

View File

@@ -0,0 +1,138 @@
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().coerceAtLeast(1)
val height = (sentBitmap.height * scale).roundToInt().coerceAtLeast(1)
sentBitmap = Bitmap.createScaledBitmap(sentBitmap, width, height, false)
val bitmap = sentBitmap.copy(sentBitmap.config ?: Bitmap.Config.ARGB_8888, true)
if (radius < 1) {
sentBitmap.recycle()
bitmap.recycle()
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
}

View File

@@ -0,0 +1,75 @@
# Android Auto Testing Attempts & Lessons Learned
**Date:** 2026-03-18
**Context:** Follow-up to the Media3 / Android Auto migration session. All implementation was already complete and verified via `dumpsys media_session`. This session focused entirely on visually verifying the Android Auto browse tree using the Desktop Head Unit (DHU).
---
## What Was Already Confirmed Working
Before any testing attempts, `adb shell dumpsys media_session` confirmed:
- `RadioPlaybackService` registered under both `androidx.media3.session.MediaLibraryService` and `android.media.browse.MediaBrowserService` intent filters
- Media session active (`state=3` / playing)
- ICY metadata visible to the OS (e.g. "Deep Shit Pt.3, Black Soyls")
- Custom "Live" action (Seek to Live) present in the session
- Media button receiver correctly configured
---
## Testing Attempts
### 1. Android Auto APK on API 28 Emulator (emulator-5554)
- Installed Android Auto v15.6 (x86_64 split) — **failed**: emulator is arm64-v8a, ABI mismatch
- Installed Android Auto v16.3 (arm64-v8a split) — **succeeded**
- Enabled developer mode by tapping Version 10× in AA settings
- Wrote `gearhead_config.xml` directly via `adb root` to persist developer mode
- `Start head unit server` appeared in overflow menu and was activated
- Port 5277 confirmed listening via `/proc/net/tcp6`
- DHU launched, connected (`[I]: connected`), then **immediately disconnected**
- **Root cause:** DHU v2.0 (released 2022) uses protocol version 1.7; Android Auto v16.3 (2025) requires a newer protocol version. Google has not released a DHU update since 2022.
### 2. Android Auto APK on API 36 Play Store Emulator (emulator-5556)
- Android Auto v16.3 installed successfully (arm64-v8a)
- Could not enable developer mode via `adb root` — production build, root denied
- Enabled developer mode via UI (tapped Version 10×, confirmed dialog)
- `Start head unit server` appeared in overflow, tapped it
- Port 5277 **never opened** — AA silently refused to start HUS
- **Root cause:** Android Auto v16.3 requires a signed-in Google account to start the head unit server. The Play Store emulator could not complete Google sign-in due to captcha blocking on emulators.
### 3. Physical Lenovo Tablet (HA26DKYS, Android 15)
- Android Auto v16.3 installed via `adb install-multiple` — succeeded
- Radio app installed via `adb install`
- Developer mode already enabled, "Unknown sources" already checked
- Head unit server started manually — port 5277 confirmed listening
- `adb forward tcp:5277 tcp:5277` established
- DHU launched, connected, then **immediately disconnected**
- **Root cause:** Same protocol version mismatch as attempt 1. DHU v2.0 is incompatible with Android Auto v16.3 regardless of device.
### 4. Head Unit Reloaded (SELF MODE) on Tablet
- App `gb.xxy.hr` (Head Unit Reloaded) already installed on tablet
- SELF MODE is intended to run Android Auto's car UI locally on the same device
- App **crashed** on SELF MODE tap — likely incompatibility with Android 15 or the sideloaded AA version
- No further investigation pursued
---
## Lessons Learned
1. **DHU v2.0 is effectively end-of-life for modern Android Auto.** Google stopped updating the DHU via SDK Manager after the 2022 release. Android Auto v16.x uses a newer protocol that DHU v2.0 cannot negotiate. The only confirmed-working approach from a [March 7, 2026 blog post](https://helw.net/2026/03/07/running-android-auto-on-an-emulator/) uses the same DHU method — meaning even the community has no working solution for newer AA versions.
2. **Android Auto requires a signed-in Google account to start the head unit server on modern versions.** This makes purely emulator-based testing (without Google Play sign-in) impossible for v16+.
3. **Android Automotive OS emulator is a different product.** AAOS emulator (available via SDK Manager) tests built-in car OS apps, not phone-projection Android Auto apps. It does not help test `MediaBrowserService` integrations.
4. **`dumpsys media_session` is reliable validation for MediaBrowserService.** The OS-level session dump confirms registration, state, metadata, and custom commands are all correctly exposed — which is exactly what Android Auto reads. Visual DHU testing adds UI confidence but is not required to verify the integration is correct.
5. **APKM bundles are ZIP archives.** APKMirror's `.apkm` files can be extracted with `unzip` and installed with `adb install-multiple`, selecting the correct architecture (`arm64_v8a`), density (`xxhdpi`), and language (`en`) splits for the target device.
---
## Current Status
All implementation is complete and functionally verified. Visual Android Auto UI testing requires either:
- A physical car head unit running Android Auto
- Google releasing a DHU update compatible with Android Auto v16+

View File

@@ -0,0 +1,48 @@
# Design: Heavy Blur Background via Coil BlurTransformation
**Date:** 2026-03-18
**Status:** Approved
## Problem
The player screen's blurred album art background uses the Cloudy library at its maximum radius of 25. This is insufficient — album art with text remains legible and fine details are still visible, breaking the intended "washed out background" aesthetic.
## Solution
Replace Cloudy with a self-contained Stackblur `BlurTransformation` applied via Coil's image loading pipeline. Blur is baked into the decoded bitmap on a background thread, producing a heavily smeared result with no legible text or sharp edges.
## Changes
### 1. Add `BlurTransformation.kt`
Create `app/src/main/java/xyz/cottongin/radio247/ui/util/BlurTransformation.kt`.
A ~130-line pure Kotlin + Android bitmap Stackblur implementation (no new library dependency). Accepts:
- `radius: Int` — blur radius (25 is already very aggressive at low scale)
- `scale: Float` — downsample factor applied before blurring (0.1f = 10% of original size)
### 2. Update `BlurredBackground` in `NowPlayingScreen.kt`
- Remove `.cloudy(radius = 25)` modifier
- Remove `size(10, 10)` from the `ImageRequest` (scale parameter in the transformation handles downsampling)
- Add `transformations(BlurTransformation(radius = 25, scale = 0.1f))` to the `ImageRequest`
- Remove Cloudy import
### 3. Remove Cloudy dependency
- Remove `implementation(libs.cloudy)` from `app/build.gradle.kts`
- Remove `cloudy` version and library entries from `gradle/libs.versions.toml`
## Parameters
| Parameter | Value | Rationale |
|-----------|-------|-----------|
| `radius` | 25 | Maximum effective Stackblur radius before diminishing returns |
| `scale` | 0.1f | Downsamples to 10% before blurring, destroying fine detail and text |
## Trade-offs
- **No new dependency** — implementation is inlined as a project file
- **All API levels** — pure bitmap manipulation, no RenderEffect/RenderScript
- **Background thread** — blur computed by Coil pipeline, zero per-frame GPU cost
- **Not animatable** — blur is baked at load time; cannot be animated at runtime (acceptable for this use case)

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

View File

@@ -14,7 +14,6 @@ junit = "4.13.2"
mockk = "1.13.16"
turbine = "1.2.0"
coil = "3.1.0"
cloudy = "0.2.7"
[libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
@@ -48,7 +47,6 @@ turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
json = { group = "org.json", name = "json", version = "20240303" }
cloudy = { group = "com.github.skydoves", name = "cloudy", version.ref = "cloudy" }
palette = { group = "androidx.palette", name = "palette-ktx", version = "1.0.0" }
[plugins]