# Cinematic UI + TMDB Fallback — Session Reference (2026-05-20)

## What Was Built

Complete Phase 2–4 implementation: Netflix-style cinematic Android TV UI, native TMDB fallback catalog provider, and reference-architecture integration patterns.

---

## 1. Cinematic UI Components

### FocusScalingCard

160dp-wide poster card with dramatic focus scaling (1.0 → 1.08) and backdrop callback.

```kotlin
@Composable
fun FocusScalingCard(
    item: MetaPreview,
    onClick: () -> Unit,
    onItemFocused: (MetaPreview) -> Unit,  // Drives backdrop swap
    progress: Float? = null,
)
```

Key implementation details:
- Uses `CardDefaults.scale(focusedScale = 1.0f)` but handles actual scale via `graphicsLayer { scaleX = animatedScale; scaleY = animatedScale }` with `animateFloatAsState(targetValue = if (isFocused) 1.08f else 1.0f)`
- Glow border: `CardDefaults.border(focusedBorder = Border(BorderStroke(3.dp, NexColors.AccentPulse), shape = cardShape))`
- `FocusRequester` + `onFocusChanged` → calls `onItemFocused(item)` when gaining focus
- Title below poster; rating + year revealed on focus via `AnimatedVisibility`
- Progress bar at bottom edge (3dp track + accent fill) for resume playback

### CinematicMediaRow

Netflix-style horizontal row using `LazyRow` (tv-foundation's `TvLazyRow` is alpha and frequently fails to resolve even with the dependency declared).

```kotlin
@Composable
fun CinematicMediaRow(
    title: String,
    items: List<MetaPreview>,
    onItemClick: (MetaPreview) -> Unit,
    onItemFocus: (MetaPreview) -> Unit,  // Propagates to parent for backdrop
    onSeeAll: (() -> Unit)? = null,
)
```

Key implementation details:
- `rememberLazyListState()` + `rememberSaveable { mutableIntStateOf(0) }` for focus memory
- `LaunchedEffect(lastFocusedIndex)` → `listState.animateScrollToItem(lastFocusedIndex)`
- `contentPadding = PaddingValues(horizontal = 48.dp)` for TV-safe margins
- `horizontalArrangement = Arrangement.spacedBy(16.dp)` for breathing room
- Row title header with "See All" suffix on clickable Surface

### DynamicBackdrop

Full-screen background image with crossfade and dark gradient overlays.

```kotlin
@Composable
fun DynamicBackdrop(
    backdropUrl: String?,
    content: @Composable () -> Unit,
)
```

Key implementation details:
- `Crossfade(targetState = backdropUrl, animationSpec = spring(...))` wrapping `AsyncImage`
- Top gradient: `black 30% → transparent` (protects top bar)
- Bottom gradient: `transparent → black 95%` (protects row readability)
- Content rendered inside same Box on top of gradients

### HomeScreen Integration Pattern

```kotlin
var focusedBackdropItem by remember { mutableStateOf<MetaPreview?>(null) }
val backdropUrl = focusedBackdropItem?.background
    ?: uiState.heroItems.getOrNull(uiState.heroIndex)?.background

DynamicBackdrop(backdropUrl = backdropUrl) {
    Row(modifier = Modifier.fillMaxSize()) {
        NavRail(...)
        Column(modifier = Modifier.weight(1f)) {
            HomeScreenTopBar(...)
            // All rows use CinematicMediaRow with onItemFocus callback
            HomeContent(
                onItemFocus = { item -> focusedBackdropItem = item },
                ...
            )
        }
    }
}
```

**Critical:** The outer `Row` background must be transparent (`alpha = 0.0f`) so the `DynamicBackdrop` shows through.

---

## 2. TMDB Native Fallback Architecture

### Problem
When no Stremio catalog addons are installed/healthy, the home screen is completely blank. The app relied entirely on `addonRepository.getCatalogRows()` which silently swallowed errors via `.getOrElse { emptyList() }`.

### Solution
Transparent fallback: when addon catalogs are empty or fail, automatically serve TMDB-native rows using the same `HomeRow` / `MetaPreview` contract.

### API Extensions (`TmdbApi.kt`)

Added endpoints to the existing Retrofit interface:
- `getTrending(mediaType, apiKey, page)` → `/3/trending/{media_type}/week`
- `getPopularMovies(apiKey, page)` → `/3/movie/popular`
- `getPopularTv(apiKey, page)` → `/3/tv/popular`
- `discoverMovies(apiKey, genreIds, watchProviders, watchRegion, sortBy, page)` → `/3/discover/movie`
- `discoverTv(apiKey, genreIds, sortBy, page)` → `/3/discover/tv`

New response model: `TmdbPagedResponse` with `List<TmdbResultItem>` where each item has `toMetaPreview(typeHint)` converter.

### TmdbCatalogRepository

```kotlin
@Singleton
class TmdbCatalogRepository @Inject constructor(
    private val tmdbApi: TmdbApi,
    private val tmdbApiKeyProvider: TmdbApiKeyProvider,
    private val genreRepository: TmdbGenreRepository,
)
```

Public API:
- `getFallbackHomeRows(): List<HomeRow>` — builds complete home screen from TMDB (trending movies, trending TV, popular movies, popular TV, genre rows, OTT simulation rows)
- `getCatalogRow(catalogType, catalogId, extra): List<MetaPreview>` — mirrors addon contract for seamless fallback
- `getHeroItems(): List<MetaPreview>` — hero banner content from trending movies

Genre rows use `discoverMovies` with `with_genres` param. OTT simulation uses `discoverMovies` with `with_watch_providers` (Netflix=8, Prime=9, Disney+=337, HBO Max=384, Apple TV+=350) + `watch_region=US`.

### TmdbGenreRepository

Static in-memory genre ID mapping (no network call needed):
- 20 movie genres (Action=28, Comedy=35, Drama=18, Horror=27, Sci-Fi=878, etc.)
- 16 TV genres
- `getGenreId(name, mediaType)` and `getGenreName(id, mediaType)`

### HomeViewModel Integration

```kotlin
@HiltViewModel
class HomeViewModel @Inject constructor(
    private val addonRepository: AddonRepository,
    private val tmdbCatalogRepository: TmdbCatalogRepository,  // NEW
    ...
)
```

Changes:
1. `buildHomeContent()` checks `addonRepository.getCatalogAddons().isNotEmpty()` before loading catalog rows
2. If no catalog addons: calls `tmdbCatalogRepository.getFallbackHomeRows()` and wraps each `HomeRow` in `RowLoadResult(isLoading=false, error=null)`
3. Hero items: `if (trendingMovies.isNotEmpty()) trendingMovies.take(10) else tmdbCatalogRepository.getHeroItems()`
4. `fetchCatalogSafe()` now tries TMDB fallback when addon returns empty or crashes:
   ```kotlin
   val addonResult = addonRepository.getCatalogRows(...).getOrElse { emptyList() }
   if (addonResult.isNotEmpty()) addonResult
   else tmdbCatalogRepository.getCatalogRow(type, catalogId, extra)
   ```
5. `catalogsUnavailable` flag only shows banner when **both** addons **and** TMDB fallback fail completely

---

## 3. Reference Architecture Patterns

### 3a. Exponential Backoff in Auth Polling (`DebridAuthManager`)

Problem: Polling auth endpoints at fixed intervals hammers the server during errors.

Solution:
```kotlin
private fun nextIntervalWithBackoff(baseInterval: Int, consecutiveErrors: Int): Long {
    val multiplier = 1 shl consecutiveErrors.coerceAtMost(4)  // 1, 2, 4, 8, 16
    val backoff = baseInterval * multiplier
    return kotlin.math.min(backoff, MAX_BACKOFF_INTERVAL_S).toLong() * 1000L
}
```

- Real-Debrid: HTTP 400 (`authorization_pending`) resets error counter (normal state). Other errors increment counter.
- AllDebrid: Normal polling state resets counter; exceptions increment it.
- Capped at 30s max interval.

### 3b. Provider Timeout Penalties (`SourceRanker`)

Problem: Slow debrid/addon providers delay stream resolution but get equal ranking.

Solution:
```kotlin
private val providerResponseTime = ConcurrentHashMap<String, Long>()
private val providerCallCount = ConcurrentHashMap<String, Int>()

fun recordProviderResponseTime(providerName: String, responseTimeMs: Long)

private fun providerTimeoutPenalty(providerName: String?): Float = when {
    avg > 15_000 -> -200f
    avg > 5_000 -> -80f
    else -> 0f
}
```

- Call `recordProviderResponseTime()` after every provider API call
- Penalty integrated into `rankStreams()` total score
- Labels show `"slow addon"` / `"sluggish"` on UI
- `RankBreakdown` exposes `timeoutPenalty` field

### 3c. Trakt Sync Conflict Resolution (`TraktSyncManager`)

Problem: Two-way sync has no strategy for items existing in both local and remote.

Solution:
```kotlin
enum class ConflictResolution { REMOTE_WINS, LOCAL_WINS, NEWEST_WINS }

suspend fun syncWatchlist(
    profileId: Int,
    conflictResolution: ConflictResolution = ConflictResolution.REMOTE_WINS,
): Result<WatchlistSyncResult>
```

- `REMOTE_WINS` (default): push missing local items, pull missing remote items
- `LOCAL_WINS`: push ALL local items to Trakt, skip pulling remote
- `NEWEST_WINS`: reserved for timestamp-aware merge (requires DB schema addition)

---

## New / Modified Files

| File | Action |
|------|--------|
| `ui/components/FocusScalingCard.kt` | Created — cinematic focus-scaling card with backdrop callback |
| `ui/components/CinematicMediaRow.kt` | Created — Netflix-style row with focus memory |
| `ui/components/DynamicBackdrop.kt` | Created — crossfade backdrop with gradient overlays |
| `data/tmdb/TmdbApi.kt` | Extended — added trending, popular, discover endpoints + `TmdbPagedResponse` / `TmdbResultItem` |
| `data/tmdb/TmdbCatalogRepository.kt` | Created — native fallback catalog provider |
| `data/tmdb/TmdbGenreRepository.kt` | Created — static genre ID mapping |
| `ui/screens/home/HomeScreen.kt` | Rewritten — integrated DynamicBackdrop, CinematicMediaRow, onItemFocus callback |
| `ui/screens/home/HomeViewModel.kt` | Modified — injected TmdbCatalogRepository, fallback logic in buildHomeContent + fetchCatalogSafe |
| `data/debrid/DebridAuthManager.kt` | Modified — exponential backoff with error counting |
| `data/source/SourceRanker.kt` | Modified — provider timeout penalties + labels |
| `data/trakt/TraktSyncManager.kt` | Modified — `ConflictResolution` enum + parameterized syncWatchlist |

**Build status:** `compileDebugKotlin` + `testDebugUnitTest` + `assembleDebug` — all green.

---

## Pitfalls from This Session

### TvLazyRow does not resolve even with tv-foundation dependency
The `androidx.tv.foundation.lazy.list.TvLazyRow` import fails at compile time despite `androidx.tv:tv-foundation:1.0.0-alpha12` being in the classpath. The alpha API is unstable and may be stripped or renamed between versions. **Use standard `LazyRow` instead** — it is fully D-pad compatible on Android TV and compiles reliably.

### `kotlin.math.pow` is not available in Kotlin stdlib on Android
Using `kotlin.math.pow(2.0, consecutiveErrors.toDouble())` produces `Unresolved reference 'pow'`. **Use bit-shift instead:** `1 shl consecutiveErrors.coerceAtMost(4)` for exponential backoff. This is integer math, no floating-point imports needed.

### FocusScalingCard scale animation must use `graphicsLayer`, not just `CardDefaults.scale`
`CardDefaults.scale(focusedScale = 1.08f)` applies a scale but the TV Card composable's internal focus handling can interfere with smooth animation. For reliable spring-physics scaling, set `CardDefaults.scale(focusedScale = 1.0f)` and handle scale via `Modifier.graphicsLayer { scaleX = animatedScale; scaleY = animatedScale }`.

### DynamicBackdrop was rejected by user — prefer static background + clean rows

The initial implementation used `DynamicBackdrop` with crossfading background images driven by card focus. The user explicitly said the UI "doesn't look crisp or clean" and still felt "the same." After removing the backdrop and simplifying the cards, the UI improved significantly.

**Lesson:** On Android TV, subtle focus indication beats dramatic animation. A static dark background with clean poster rows and minimal 1.04x focus scale is more polished than full-bleed backdrop swapping. The content (poster artwork) should be the star, not the UI chrome.

**Corrected pattern:**
```kotlin
// HomeScreen — NO DynamicBackdrop
Column(modifier = Modifier.fillMaxSize().background(NexColors.Background)) {
    HomeScreenTopBar(...)
    LazyColumn {
        item { HeroBanner(...) }
        items(rows) { row ->
            CinematicMediaRow(
                title = row.title,
                items = row.items,
                onItemClick = { ... },
                onItemFocus = { /* optional, no backdrop */ },
            )
        }
    }
}

// FocusScalingCard — subtle, no glow
@Composable
fun FocusScalingCard(item: MetaPreview, onClick: () -> Unit, onFocus: () -> Unit) {
    var isFocused by remember { mutableStateOf(false) }
    val scale by animateFloatAsState(targetValue = if (isFocused) 1.04f else 1.0f)
    Column(modifier = Modifier.scale(scale)) {
        Card(
            onClick = onClick,
            modifier = Modifier.onFocusChanged {
                isFocused = it.isFocused
                if (isFocused) onFocus()
            },
        ) { /* poster image */ }
        Text(text = item.name, fontSize = 13.sp)
    }
}
```
