# Coil 3 Memory Optimization — Android TV

## Problem: OOM from full-resolution image decoding

Coil 3 defaults to decoding images at their **original resolution** unless `.size()` is
explicitly set. For Unspooled's TMDB backdrops (`w1280` = 1280×720), each decode allocates
**3.7 MB** (1280×720×4 bytes ARGB_8888). Poster images from `w342` decode at **~700 KB**
(342×513×4 bytes). With 8-10 cards visible per LazyRow, this is **30-50 MB** of transient
decode memory before GPU upload — competing directly with the Coil memory cache and
ExoPlayer/FFmpeg native allocations on a device with 256-512 MB app heap.

## Root Causes (all fixable)

### 1. No `.size()` constraint (11 of 19 ImageRequest sites)
Coil decodes at source resolution. A w1280 backdrop decoded for a 360dp (~720px) viewport
wastes 75% of the decoded pixels.

**Diagnostic signature:** `Runtime.getRuntime().maxMemory()` near limit, `OutOfMemoryError`
in `BitmapFactory.nativeDecodeStream`, SIGKILL from lmkd.

### 2. No `.precision(Precision.INEXACT)`
`Precision.EXACT` (default) decodes at exactly the requested `.size()`. `Precision.INEXACT`
allows Coil to decode at a lower resolution (typically 50% or less) when the source image
is close in dimension. On TV, this is invisible to the viewer but cuts memory by 75%.

### 3. `.crossfade(true)` doubles memory during transition
During the crossfade animation (300ms default), Coil holds **both** the old and new drawable
in memory. On a catalog screen with 10+ cards all crossfading simultaneously (hero rotation
+ rows scrolling), this doubles the decode memory.

### 4. `coil3.ImageLoader(context)` creates a new instance
Each call constructs a brand-new `ImageLoader` with no memory cache, no disk cache, no
OkHttpClient reuse. Used in `PremiumHeroSection` for preloading the next hero image — every
hero rotation leaked a fresh `ImageLoader` instance.

**Fix:** `coil3.SingletonImageLoader.get(context)`. Uses the application-level singleton
with shared memory/disk cache and OkHttp connection pool.

### 5. `BlurTransformation` always copies the bitmap
```kotlin
// BEFORE: always copies, doubles memory during blur
val bitmap = input.copy(Bitmap.Config.ARGB_8888, true)

// AFTER: reuse if already mutable
val bitmap = if (input.isMutable && input.config == Bitmap.Config.ARGB_8888) {
    input
} else {
    input.copy(Bitmap.Config.ARGB_8888, true) ?: return input
}
```

## Fix Pattern — Every ImageRequest Builder

```kotlin
ImageRequest.Builder(context)
    .data(url)
    .size(maxWidthPx, maxHeightPx)    // match the Composer's actual render size
    .precision(Precision.INEXACT)      // allow ~50% decode for TV
    .crossfade(300)                    // explicit duration, not `true`
    .build()
```

### Dimension reference for common Unspooled components

| Component | Composer size | `.size()` | Notes |
|-----------|--------------|-----------|-------|
| Catalog poster (Standard) | 126×189 dp → ~252×378 px | `size(280, 420)` | Largest card spec; INEXACT handles downsizing |
| Hero backdrop | Full-bleed ~1920×1080 px | `size(1280, 720)` | Don't need native 4K |
| Details backdrop | 420dp → ~840 px wide | `size(854, 480)` | Half-w1280 is fine |
| HeroCarousel | 360dp → ~720 px | `size(640, 360)` | Narrower viewport |
| Netflix hero | Full-bleed | `size(960, 540)` | Don't need 1280 |
| Logo images | 256×64 dp | `size(256, 64)` | Already had this, keep it |

### When to KEEP `.crossfade(true)` / `.crossfade(300)`
- Hero/backdrop images: the crossfade transition is visually important for a premium feel.
  Keep it — the memory hit is acceptable for 1-2 hero images, just add `.size()` to
  reduce the base decode size.
- Logo images: negligible size, keep crossfade if desired.

### When to REMOVE `.crossfade()`
- **Catalog poster cards** (`CatalogPosterImage`, `PremiumContentCard`): used in
  `LazyRow` + `LazyColumn` with 20-50+ visible cards. The crossfade memory doubling
  across all visible cards is the #1 contributor to decode-memory OOM. Remove it.
  The cards load fast enough with hardware bitmaps that the visual difference is
  imperceptible on TV.

## Files that received Coil fixes (June 2026 batch)

| File | Fix |
|------|-----|
| `CatalogPosterImage.kt` | `.size(280,420)` + `.precision(INEXACT)`, removed `.crossfade(true)` |
| `PremiumContentCard.kt` | `.size(280,420)` + `.precision(INEXACT)` |
| `PremiumHeroSection.kt` | `.size(1280,720)` + `.precision(INEXACT)` + `SingletonImageLoader.get()` |
| `HeroCarousel.kt` | `.size(640,360)` + `.precision(INEXACT)` |
| `NetflixHeroSection.kt` | `.size(960,540)` + `.precision(INEXACT)` |
| `DetailsScreen.kt` | `.size(854,480)` + `.precision(INEXACT)` |
| `BlurTransformation.kt` | Reuse mutable bitmap instead of `.copy()` |

### Imports required
```kotlin
import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.size.Precision
// If using SingletonImageLoader:
import coil3.SingletonImageLoader
```

## Validation

After making Coil changes, verify with `assembleDebug` — Coil extension imports can vary
by pinned version, and missing `.size()`/`.precision()` imports will fail at compile time.

```bash
cd ~/Unspooled
JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 ./gradlew :app:compileDebugKotlin
```

**Memory estimate after fixes:** ~80-120 MB saved on startup (30 visible poster decodes ×
600KB each → ~100KB each, plus hero backdrops 3.7MB → ~800KB each, plus crossfade
double-alloc eliminated, plus BlurTransformation copy eliminated).
