# TV Focus System — Audit & Implementation Reference

## Part 1: Audit Methodology

### Problem Diagnosis
D-pad navigation jumps unexpectedly, gets stuck in sidebar, focus indicators don't show on cards, only certain UI areas work.

### Root Causes (3 Layers)

**Layer 1: Competing Focus Handlers**
Four D-pad handling systems running simultaneously:
1. `handleDPadKeyEvents` — `onPreviewKeyEvent` intercepts left/right/enter
2. `dpadRepeatThrottle` — throttles repeats, calls `focusManager.moveFocus()` manually
3. `dpadVerticalFastScroll` — takes over vertical repeats for fast-scroll
4. Manual `onKeyEvent` in sidebar, Netflix nav, top bar

**Layer 2: No Unified Focus Graph**
Each screen manages focus independently. tv-material3 components use `onFocusChanged { isFocused = it.isFocused }` which is CORRECT for those components — but custom composables should use `rememberTvFocusState()` + `focusable(interactionSource)` for unified focus state.

**Layer 3: Multiple Navigation Chrome**
Three sidebars coexisting without coordination + separate top bar.

### DebridStream (FudgeTV) Reference
- Apache 2.0 licensed
- Uses `ModalNavigationDrawer` from `androidx.tv.material3` — built-in focus management
- `NavigationDrawerItem` with explicit `interactionSource` + `collectIsFocusedAsState()`
- `FudgeTvFocusRequester` — delayed focus request with retry via `LaunchedEffect`

### Card Size Comparison
| App | Portrait | Landscape | Focus Scale | Focus Border |
|-----|----------|-----------|-------------|--------------|
| **DebridStream** | 268×150dp (35:12) | same | N/A | 2dp solid |
| **NuvioTV** | 126×189dp | 260×148dp | 1.02× | 2dp glow |
| **Lumera** | 140×210dp | 190×107dp | 1.05× | 2-3dp glow |
| **Unspooled** | 126×189dp | 260×148dp | 1.08× | 3dp solid |

### License Constraints (CRITICAL)
Always verify before copying third-party code:
- **FudgeTV**: Apache 2.0 → CAN copy freely
- **NuvioTV**: GPL v3 → CANNOT copy, must implement patterns from scratch
- **Lumera**: GPL v3 → CANNOT copy, must implement patterns from scratch

---

## Part 2: Unified Focus Graph Architecture

### Core Principle
One active `FocusZone` at a time. Focus cannot move between zones through ordinary D-pad — only via explicit `FocusRequester.requestFocus()` during lifecycle events (drawer open/close, dialog show/dismiss, navigation).

### FocusZone Enum
```kotlin
enum class FocusZone {
    Sidebar, Content, Dialog, PlayerControls, SettingsLeftPane, SettingsRightPane
}
```

### FocusRegistry — Keyed FocusRequester Factory
```kotlin
@Stable
class FocusRegistry {
    private val requesters = mutableMapOf<String, FocusRequester>()
    fun requester(key: String): FocusRequester
    fun removeMissing(validKeys: Set<String>)
}
```
Usage: `val registry = rememberFocusRegistry(); val req = registry.requester("row:$rowKey:item:$itemKey")`

### FocusLinks — Directional Focus Targets
```kotlin
data class FocusLinks(val up, down, left, right: FocusRequester?)
companion object { val Trapped = FocusLinks(Cancel, Cancel, Cancel, Cancel) }
```
Apply via `Modifier.focusProperties { links.up?.let { up = it } ... }`

### FocusVelocityTracker — Detect Fast Scrolling
Tracks consecutive focus events within 180ms window. After 3 rapid events, `fastScrollActive = true`. Suppress expensive work (metadata, trailers, palette) during fast scroll.

### FocusUtils — Canonical Focus State (for CUSTOM composables ONLY)

```kotlin
// CORRECTED implementation — no double-wrapping
@Composable
fun rememberTvFocusState(): Pair<MutableInteractionSource, State<Boolean>> {
    val interactionSource = remember { MutableInteractionSource() }
    return Pair(interactionSource, interactionSource.collectIsFocusedAsState())
}
```

Use ONLY on custom composables (plain Box, Row, Column) with `Modifier.focusable(interactionSource = interactionSource)`.

**Do NOT use on tv-material3 components** (Card, Surface, Button, ClickableSurface) — they manage
their own focus and this will create a conflict that blocks D-pad navigation entirely.

**Do NOT use with `clickable`** — `clickable` adds its own focus handling. Use `onFocusChanged` for
visual state with `clickable`.

**Pitfall**: The original implementation wrapped `collectIsFocusedAsState()` result in another
`mutableStateOf`, causing focus state to permanently desync (always false). The fix is to return
the `State<Boolean>` from `collectIsFocusedAsState()` directly.

### AppFocusMemory — Persisted Focus State
```kotlin
data class AppFocusMemory(
    val activeZone: FocusZone,
    val lastSidebarKey: String,
    val lastContentRequesterKey: String?,
)
```

### HomeScreenFocusState — Persisted Scroll + Focus
```kotlin
data class HomeScreenFocusState(
    val verticalScrollIndex: Int,
    val verticalScrollOffset: Int,
    val focusedRowKey: String?,
    val focusedItemKeyByRow: Map<String, String>,
    val catalogRowScrollStates: Map<String, Int>,
)
```

### Focus Graph Topology
```
Sidebar:
  LEFT → Cancel | RIGHT → Content | UP/DOWN → within sidebar | BACK → collapse

Content:
  LEFT from edge → Sidebar | RIGHT from sidebar → Content
  UP/DOWN between rows → nearest card at same index, clamped

Dialog:
  All directions → Cancel (trapped) | BACK → dismiss

Settings:
  LEFT pane: RIGHT → last focused setting in category
  RIGHT pane: LEFT → selected category | BACK → restore content
```

### Settled Focus (Two-Tier Debounce)
- **140ms** — lightweight UI updates (hero title, row/item selection)
- **1.5s** — heavy work (expanded card metadata, trailer preview, network enrichment)
Use `rememberSettledFocus(focusKey, velocityTracker)`.

### D-Pad Key Handler (FudgeTV-derived, Apache 2.0)
Two modes:
- `Modifier.handleDPadKeyEvents(onLeft, onRight, onEnter)` — `onPreviewKeyEvent` (intercept)
- `Modifier.handleDPadKeyEvents(onLeft, onRight, onUp, onDown, onEnter)` — `onKeyEvent` (observe)

### ContentFocusEntry
Focusable container that requests focus after layout with retry. Adapted from `FudgeTvFocusRequester`.
```kotlin
suspend fun FocusRequester.requestFocusDelayed(delayMs = 200L, maxRetries = 4)
```

---

## Part 3: Unified Sidebar (Lumera-inspired visual, NuvioTV focus)

### Specification
- Collapsed: 80dp | Expanded: 220dp (smooth `animateDpAsState`)
- Profile avatar at top with scale-on-focus (1.12×)
- Left accent bar (3dp, RoundedCornerShape(50)) on focused/selected items
- Icons always visible (20dp), labels shown when expanded
- Badge count in collapsed mode (16dp circle)
- Proper FocusProperties: `left = Cancel, right = contentEntry, up/down = prev/next`
- Right arrow → `contentEntryRequester.requestFocus()`
- BackHandler: expanded → collapse; collapsed + content focused → expand

### Scaffold (UnifiedChromeScaffold)
Three layers:
1. Content area (bottom)
2. Gradient mask (visible on expand, Black 0.55→0.35→0.15→Transparent)
3. Unified sidebar (top, zIndex=3)

Key wiring:
```kotlin
Box(contentEntry).onKeyEvent { DirectionLeft -> sidebarRequester.requestFocus() }
Box(sidebarEntry).onFocusChanged { if focused → expand }
```

### SidebarItem Model
```kotlin
data class SidebarItem(key, route, label, icon, badgeCount?, isPrimary)
```

---

## Part 4: Banned vs Allowed Patterns (CORRECTED — session 2026-06-01)

### CRITICAL: tv-material3 vs Custom Composable distinction

The focus pattern you use DEPENDS on the component type:

**tv-material3 components** (Card, Surface, Button, ClickableSurface, NavigationDrawerItem):
These manage their OWN focus internally. DO NOT add `.focusable(interactionSource)` — it creates a
conflicting second focus target that blocks the built-in focus system. Use `onFocusChanged`:
```kotlin
var isFocused by remember { mutableStateOf(false) }
Surface(onClick = ...) {
    modifier = Modifier.onFocusChanged { isFocused = it.isFocused }
}
```

**Custom composables** (plain Box, Row, Column with your own Modifier chain):
These have NO built-in focus. Use `rememberTvFocusState()` + `.focusable(interactionSource)`:
```kotlin
val (interactionSource, focused) = rememberTvFocusState()
Box(Modifier.focusable(interactionSource = interactionSource)) { ... }
```

**The V5 blanket ban on `var isFocused` was WRONG.** It broke all tv-material3 components.
The ban applies ONLY to custom composables where you control `.focusable()`.

### Banned Patterns (Do Not Use)

- `focusManager.moveFocus()` for ordinary navigation
- `dpadRepeatThrottle` or `dpadVerticalFastScroll`
- `var isFocused by remember { mutableStateOf(false) }` **on custom composables** — use `rememberTvFocusState()` instead
- `.focusable(interactionSource)` on tv-material3 components (Card, Surface(onClick), Button, ClickableSurface) — creates focus conflict that blocks D-pad completely
- `.focusable(interactionSource)` + `.clickable(onClick)` on the same modifier chain — `clickable` adds its own focus handling, they compete
- `TvLazy*` components — never shipped in any published `tv-foundation` version (through 1.0.0 stable). Use standard `LazyRow`/`LazyColumn` with `focusGroup()`
- `onPreviewKeyEvent` that competes with Compose's default focus traversal
- Local mutable focus state without `InteractionSource` **on custom composables**

### Allowed Patterns

- `FocusRequester.requestFocus()` for lifecycle, restoration, drawer transfer, dialog trap, recovery
- `InteractionSource.collectIsFocusedAsState()` for visual focus state **on custom composables**
- `Modifier.onFocusChanged { isFocused = it.isFocused }` for visual focus state **on tv-material3 components**
- `focusProperties { enter = { lastRequester } }` for focus restoration
- `LazyColumn`/`LazyRow`/`LazyVerticalGrid` from Compose Foundation with stable keys
- `FocusRequester.Cancel` (OptIn required) for directional traps
- `.clickable(onClick)` for D-pad center handling on custom composables (handles Enter/Center natively)

### Known Pitfalls (from real bugfixes)

1. **rememberTvFocusState() double-wrapping bug**: The original implementation wrapped
   `collectIsFocusedAsState()` result in another `mutableStateOf`, causing focus state to desync.
   FIX: return `collectIsFocusedAsState()` directly — `Pair(interactionSource, interactionSource.collectIsFocusedAsState())`

2. **ClickableSurface conflict**: Adding `.focusable(interactionSource)` on a tv-material3
   `Surface(onClick = ...)` (ClickableSurface) blocks ALL D-pad navigation on that element.
   ClickableSurface has its own focus system — just use `onFocusChanged` for visual state.

3. **Sidebar focus stealing**: Adding `.focusable()` to non-navigation elements inside a sidebar
   Column (like a profile header) steals focus from the actual nav items during UP/DOWN traversal.
   Only sidebar navigation items should be focusable.

4. **Missing D-pad center handling**: When replacing `onPreviewKeyEvent` with `clickable`,
   verify that D-pad CENTER/ENTER still works. `clickable` from compose.foundation handles
   it natively, but custom `focusable()` without clickable needs explicit `onPreviewKeyEvent`.
