# Catalog Loading Pitfalls (2026-05-30 session)

## Critical Pattern: API-Key Check Blocking Local-Data Rows

The most severe catalog bug found: `hasApiKey()` at HomeViewModel:175 returned early,
blocking ALL rows including Continue Watching, Up Next, and Watchlist that don't need TMDB.

### Root Cause
```kotlin
// BROKEN — blocks everything, even local-data rows
if (!tmdbCatalogRepository.hasApiKey()) {
    _uiState.update { errorMessage = "..."; catalogsUnavailable = true }
    return@launch  // <-- KILLS priority rows + hero too
}
```

### Fix
```kotlin
// FIXED — load priority rows first, then check API key
val priorityState = loadPriorityRows(profile, watchedIds)  // local data first
val hasApiKey = try { tmdbCatalogRepository.hasApiKey() } catch (_: Exception) { false }
val definitions = if (hasApiKey) filterCatalogDefinitions(...) else emptyList()
// Priority rows + hero are already set in UI state before this check
```

### Rule
**Never guard local-data rows behind external API-key checks.**
- Priority rows: Continue Watching (Room DB), Watchlist (Trakt), Up Next (Trakt) — all local/fast
- Hero pool: trending from TMDB, but falls back to watchlist + continue watching items
- Only catalog definition rows need TMDB

## Error Message Blocking Anti-Pattern

Setting `errorMessage` on `HomeUiState` blocks the ENTIRE LazyColumn because
NetflixHomeScreen's `when` block prioritizes `errorMessage != null` over content.
Even if priority rows loaded successfully, the user sees only a full-screen error.

### Wrong Pattern
```kotlin
// DO NOT DO THIS — blocks all content including working priority rows
_uiState.update {
    it.copy(errorMessage = "TMDB API key missing — catalog rows unavailable...")
}
```

### Right Pattern
Use `catalogsUnavailable: Boolean` flag and render a non-blocking banner INSIDE the
LazyColumn, AFTER the hero section:
```kotlin
// In HomeViewModel: set catalogsUnavailable, NEVER errorMessage for missing key
_uiState.update { it.copy(errorMessage = null, catalogsUnavailable = true) }

// In NetflixHomeScreen: render banner inside LazyColumn
if (uiState.catalogsUnavailable) {
    item { CatalogsUnavailableBanner() }
}
```

## TMDB API Key Validation

TMDB returns 401 for invalid keys. The key in local.properties must be a TMDB API
v3 key (looks like hex: `a1b2c3d4...`), NOT a JWT token (`eyJhbG...`).

Verify with:
```bash
curl -s -w '\nHTTP:%{http_code}' \
  'https://api.themoviedb.org/3/trending/movie/day' \
  -H 'Authorization: Bearer YOUR_KEY'
# 200 = valid, 401 = invalid key, 403 = valid key but insufficient permissions (v4 required)
```

Common mistake: putting a Supabase auth token or other JWT in `TMDB_API_KEY`.
The `hasApiKey()` method only checks `isNotBlank()`, so an invalid-format key
passes the check, then every API call fails silently with 401 → empty catalogs.

## Hero Banner Not Rendering

Hero items come from `loadPriorityRows()` — if that doesn't run, hero stays empty.
Same root cause: the API-key check at the top of `loadHome()` prevented priority rows
from ever loading. Moving the check below priority rows fixed the hero too.

Hero fallback chain (HomeViewModel:311-324):
1. Items with backdrop artwork (`!background.isNullOrBlank()`)
2. Any items with poster
3. `tmdbCatalogRepository.getHeroItems()` — only works with API key
4. Watchlist + Continue Watching items — always works (local data)

## TV Focus Ring Visibility

On pure-black TV backgrounds, subtle focus indicators (e.g., white ring at alpha=0.35,
border from 2dp→3dp) are NEARLY INVISIBLE to the user.

### Wrong Pattern
```kotlin
val ringColor = lerp(Color.White.copy(alpha = 0.35f), Color.White, focusProgress) // invisible
val ringWidth = lerp(2.dp, 3.dp, focusProgress)  // barely changes
```

### Right Pattern (Lumera-style)
```kotlin
val ringColor = lerp(Color.Transparent, accent, focusProgress)  // accent on focus
val ringWidth = (3f * focusProgress).dp  // 0→3dp
val ringGlow = (8f * focusProgress).dp  // shadowElevation glow
```
Plus add `graphicsLayer { shadowElevation = ringGlow.toPx() }` on the avatar Box.

## Sidebar: Lumera 4-Layer Overlay

The existing `NetflixSideNavigation.kt` already had the Lumera NavDrawer 1:1.
The bug was `NavigationRailWrapper` still using the old `NexSidebar` instead of it.

Layer stack (all in one Box, zIndex ordered):
1. **Content** (z=0): Full-screen content slot
2. **Static Hero Mask** (z=1): Horizontal gradient from left, only on Home/Movies/Series
3. **Dynamic Expansion Shadow** (z=1.5): AnimatedVisibility fade, wider gradient when sidebar focused
4. **Interactive Drawer** (z=2): 80dp→200dp animated width, focusGroup, focus-based expansion

Key: sidebar expansion tracked by `onFocusChanged { isMenuFocused = it.hasFocus }` on the drawer Box,
not by hover/manual toggle.

## Compose Compilation Pitfalls

### `lerp(Dp, Dp, Float)` is internal
```kotlin
// COMPILE ERROR: Cannot access internal lerp
val ringWidth = lerp(0.dp, 3.dp, focusProgress)

// FIX: Use arithmetic
val ringWidth = (3f * focusProgress).dp
```

### `Modifier.weight()` scope
```kotlin
// COMPILE ERROR: Unresolved reference 'weight'
Column { Modifier.weight(1f) }

// FIX: weight() is only available in RowScope/ColumnScope
Row { Box(Modifier.weight(1f)) }
// OR inside a Row/Column lambda receiver
```

## NuvioTV Addon-Driven Catalog Fallback

When TMDB catalog rows are all empty (invalid key, rate-limited, network error),
load catalogs directly from installed Stremio addon manifests — the NuvioTV pattern.

### Pattern
```kotlin
// In HomeViewModel.loadHome(), after TMDB catalog rows load:
if (!hasCatalogItems) {
    val addonRows = loadAddonCatalogRows(profile, watchedIds)
    if (addonRows.isNotEmpty()) {
        _uiState.update { state ->
            state.copy(catalogRows = state.catalogRows + addonRows, catalogsUnavailable = false)
        }
    }
}
```

### loadAddonCatalogRows Implementation
```kotlin
private suspend fun loadAddonCatalogRows(...): List<RowLoadResult> = coroutineScope {
    val catalogAddons = addonRepository.getCatalogAddons().filter { it.manifest != null }
    for (addon in catalogAddons.take(3)) {
        for (catalog in addon.manifest!!.catalogs.take(5)) {
            launch {
                catalogLoadSemaphore.withPermit {
                    val row = loadCatalogRow(rowId, title, rowType, catalog.type, catalog.id,
                        extra = catalog.extra.associate { it.name to (it.options?.firstOrNull() ?: "") })
                    synchronized(results) { results.add(row) }
                }
            }
        }
    }
}
```

### Why This Works
- Cinemeta (`https://v3-cinemeta.strem.io/manifest.json`) is seeded as system addon (enabledByDefault=true)
- Its manifest has catalogs: `top`, `movies.popular`, `series.popular`
- When TMDB is down, addon catalog IDs flow through `fetchCatalogSafe()` → `addonRepository.getCatalogRows()` → Tier 4 (Stremio addons)

### fetchCatalogSafe Tiered Chain (refactored)
```kotlin
// BEFORE: TMDB-only, no fallback for tmdb.* IDs
if (catalogId.startsWith("tmdb.")) {
    return tmdbCatalogRepository.getCatalogRow(type, catalogId, extra) // fails alone
}

// AFTER: addon-first tiered chain
val addonResult = addonRepository.getCatalogRows(type, catalogId, extra)
    .getOrElse { emptyList() }
if (addonResult.isNotEmpty()) return dedup(addonResult)
// Last-resort for tmdb.* IDs:
return tmdbCatalogRepository.getCatalogRow(type, catalogId, extra)
```

### Key Insight
TMDB catalog IDs (`tmdb.trending_day`) DON'T map to Stremio addon catalogs (`cinemeta.top`).
Must query addons with THEIR native catalog IDs, not TMDB's. NuvioTV uses addon catalogs
as PRIMARY source; Nexstream uses them as FALLBACK when TMDB is down.
