# Android TV Compose — Focus Patterns (Battle-Tested)

## Summary of findings from the Unspooled V5 rebuild session where focus broke entirely.

## The Two Focus Pattern Rule

### Pattern A: tv-material3 Components
**Use `onFocusChanged` — NOT `rememberTvFocusState()`**

tv-material3 components (Card, Surface, Button, ClickableSurface, NavigationDrawerItem)
manage their OWN focus internally via InteractionSource. Adding `.focusable(interactionSource)`
from `rememberTvFocusState()` creates a SECOND focus target that competes with the built-in
one — the custom interactionSource never receives focus events, and D-pad navigation
breaks entirely.

```kotlin
// CORRECT for tv-material3:
var isFocused by remember { mutableStateOf(false) }
Surface(onClick = { action() }) {
    modifier = Modifier.onFocusChanged { isFocused = it.isFocused }
    // Use isFocused for visual indication (border, scale, color)
}
```

### Pattern B: Custom Composables
**Use `rememberTvFocusState()` + `.focusable(interactionSource)`**

For plain Box/Row/Column where YOU control all modifiers:

```kotlin
// CORRECT for custom composables:
val (interactionSource, focused) = rememberTvFocusState()
Box(
    modifier = Modifier
        .focusable(interactionSource = interactionSource)
        .onPreviewKeyEvent { event ->
            if (event.type == KeyEventType.KeyUp &&
                (event.key == Key.DirectionCenter || event.key == Key.Enter)) {
                onClick(); true
            } else false
        }
) {
    if (focused.value) { /* visual highlight */ }
}
```

## Common Bugs (and fixes)

### Bug 1: `.focusable(interactionSource)` on ClickableSurface
**Symptom**: Remote stops working entirely on that screen. No D-pad movement, no focus indication.
**Cause**: ClickableSurface (Surface with onClick) already manages its own focus.
**Fix**: Remove `.focusable(interactionSource)`, use `onFocusChanged` for visual state.

### Bug 2: `.focusable(interactionSource)` + `.clickable(onClick)` conflict
**Symptom**: Element doesn't respond to D-pad CENTER/ENTER, or focus indication stuck.
**Cause**: `clickable` adds its own focusable layer that competes with the custom one.
**Fix**: Use ONLY one approach: either `focusable + onPreviewKeyEvent` OR `clickable + onFocusChanged`.

### Bug 3: `rememberTvFocusState()` double-wrapping
**Symptom**: `focused.value` always returns `false`, even when the element clearly has focus.
**Cause**: Original implementation wrapped `collectIsFocusedAsState()` in `remember(focused) { mutableStateOf(focused) }` — the inner mutableStateOf was initialized once and never updated.
**Fix**: Return `collectIsFocusedAsState()` directly:
```kotlin
fun rememberTvFocusState(): Pair<MutableInteractionSource, State<Boolean>> {
    val interactionSource = remember { MutableInteractionSource() }
    return Pair(interactionSource, interactionSource.collectIsFocusedAsState())
}
```

### Bug 4: Focus-stealing in sidebar Columns
**Symptom**: UP/DOWN in sidebar jumps to non-navigation elements (profile avatar, header).
**Cause**: Non-nav elements (profile header) made focusable inside the sidebar Column compete
with actual nav items during D-pad traversal.
**Fix**: Only sidebar navigation items should use `.focusable()` or `focusRequester`.

## TvLazyRow Myth

The V5 blueprint demanded `androidx.tv.foundation.lazy.list.TvLazyRow` with `pivotOffsets`.

**Reality**: `TvLazyRow`, `TvLazyColumn`, `TvLazyVerticalGrid` do NOT exist in any published
version of `androidx.tv:tv-foundation` (through 1.0.0 stable). The library only contains:
- `ExperimentalTvFoundationApi`
- `TvImeOptionsKt`
- `TvKeyboardAlignment`

The official Google TV Compose samples use standard `LazyRow`/`LazyColumn` with
`Modifier.focusGroup()` on row containers. This is the correct approach.

## DebridHttpDataSourceFactory

For authenticated Debrid streams via Media3, the PlayerManager in Unspooled already
handles header injection through `buildPlaybackHttpDataSourceFactory(headers)`. The
standalone `DebridHttpDataSourceFactory` wraps `DefaultHttpDataSource.Factory` and
injects Bearer tokens via a `tokenProvider` lambda. Token is set in `open()` to support
live token refresh mid-session.

## tv-foundation Version

Latest stable: `1.0.0`. Upgrade from alpha12 was smooth — no API breakage.
The classpath at 1.0.0 is identical to 1.0.0-alpha12 (5 classes total).

---

## D-Pad Key Event Dispatch Order (Critical)

Understanding event dispatch order is the #1 cause of "focus stuck in sidebar" bugs.

### The correct order when a D-pad key is pressed:

```
1. Compose focus SYSTEM processes focusProperties.{direction}
   → Moves focus to the target FocusRequester (if target is focusable)
   → Fires onFocusChanged on old and new elements

2. KEY EVENT dispatch begins — onPreviewKeyEvent fires (parent → child)
   → Returns true → consumed, child never sees event
   → Returns false → continues to child

3. onKeyEvent fires (child → parent)
```

### Why this matters for sidebar → content routing

**WRONG pattern** (was in code, got stuck):
```
Sidebar Column has onPreviewKeyEvent { if Right → onRequestClose(); true }
Each NavIcon has focusProperties.right = contentFocusRequester
Content Box has canFocus = false when sidebar open
```
Flow: focus system tries focusProperties.right → fails (target unfocusable) → 
onPreviewKeyEvent fires → returns true → event consumed → focus STILL on sidebar.
User presses Right again → same cycle → stuck forever.

**CORRECT pattern** (how the fix works):
```
Sidebar Column: NO onPreviewKeyEvent for Right
Each NavIcon has focusProperties.right = contentFocusRequester
Content Box: ALWAYS focusable (no canFocus gate)
Content Box has onFocusChanged → closes sidebar
Content Box has blockContentFocusWhenInactive → blocks internal nav when sidebar open
```
Flow: focus system tries focusProperties.right → SUCCEEDS → focus moves to content →
onFocusChanged fires → onContentFocused() → closeSidebar() →
sidebarOpen = false → blockContentFocusWhenInactive lifts → user can navigate.

### Rule: `onPreviewKeyEvent` returning `true` does NOT block focus navigation

The Compose focus system processes direction keys before key event dispatching.
If the target element has `canFocus = false`, the move fails silently even though
the event was never dispatched to key event handlers.

### Rule: When `onPreviewKeyEvent` handles a direction key, return `true`

```kotlin
// WRONG — event continues to focus system after handler:
onPreviewKeyEvent { if (Right) { onMove(); false } }

// CORRECT — consumed, focus system does not double-process:
onPreviewKeyEvent { if (Right) { onMove(); true } }
```

Returning `false` after handling a direction key means the focus system ALSO tries
to process the same direction press, potentially moving focus to a different element
than intended (settings rail → detail panel split focus bug).

---

## TV Button Pattern: clickable vs onClick

When adding clickable/focusable UI elements for TV d-pad navigation, use `Modifier.clickable {}` from `androidx.compose.foundation.clickable`. Do NOT use `Modifier.onClick {}` from `androidx.compose.foundation.onClick` — it requires `@OptIn(ExperimentalFoundationApi::class)` which causes compile failures without explicit opt-in.

`Modifier.clickable() + Modifier.focusable()` on a `Row` or `Box` works correctly for TV: the d-pad Enter/OK button triggers the click handler. This is the correct pattern for simple TV buttons that don't need the full `Card` composable styling.

Example:
```kotlin
Row(
    modifier = Modifier
        .focusable()
        .clickable { navController.popBackStack() },
) {
    Text("← Back", color = NuvioTheme.colors.Accent)
}
```

## Focus Restoration: Preserving Requesters Across Data Changes

### The bug

```kotlin
// BAD — recreates ALL requesters on every data change:
val requesters = remember(data.size) {
    List(data.size) { FocusRequester() }
}
```
Focus on the currently-focused item is lost because its FocusRequester object is
replaced. The old requester is garbage-collected.

### The fix — mutable map with synchronous sync

```kotlin
val resultRequesters: List<FocusRequester> = remember { mutableListOf() }
remember(data.size) {
    val list = resultRequesters as MutableList
    while (list.size < targetSize) list.add(FocusRequester())
    while (list.size > targetSize) list.removeLastOrNull()
}
```

This preserves existing FocusRequesters so the currently-focused item keeps its
focus after data refresh.

### For per-row card requesters (2D grid):

```kotlin
val rowCardRequesters: Map<String, List<FocusRequester>> = remember { mutableMapOf() }
remember(rowFocusSpecs) {
    val map = rowCardRequesters as MutableMap<String, List<FocusRequester>>
    val validKeys = rowFocusSpecs.map { it.first }.toSet()
    map.keys.retainAll(validKeys)
    rowFocusSpecs.forEach { (key, size) ->
        if (map[key] == null || map[key]?.size != size) {
            map[key] = List(size) { FocusRequester() }
        }
    }
}
```

Note: `remember(rowFocusSpecs)` mutates the outer mutable map synchronously.
This runs during composition before any layout pass, so all requesters exist
when the layout references them.

---

## Hero Section UP Navigation

Top-of-screen elements (hero Play/More Info buttons) should route UP to the sidebar
rail — not use `FocusRequester.Cancel`:

```kotlin
// WRONG — creates a dead end at top of screen:
Row(modifier = Modifier.focusProperties { up = FocusRequester.Cancel })

// CORRECT — routes to sidebar rail:
val sidebarRequester = LocalSidebarFocusRequester.current
Row(modifier = Modifier.focusProperties { up = sidebarRequester })
```

The sidebar FocusRequester is provided via `LocalSidebarFocusRequester` CompositionLocal
from `TvAppScaffold`. It's available to any composable inside the scaffold content lambda.

---

## Content Box Must Always Be Reachable

The content wrapper Box should always accept focus so `focusProperties.right` from
sidebar items always succeeds:

```kotlin
// WRONG — blocks focusProperties.right when sidebar is open:
Box(
    modifier = Modifier
        .focusRequester(contentFocusRequester)
        .focusable(enabled = contentAllowed())      // ← false when sidebar open
        .focusProperties { canFocus = contentAllowed() }  // ← false when sidebar open
)

// CORRECT — always focusable, navigation blocked at child level:
Box(
    modifier = Modifier
        .focusRequester(contentFocusRequester)
        .blockContentFocusWhenInactive(contentAllowed)  // blocks internal nav only
)
```

`blockContentFocusWhenInactive` uses `onPreviewKeyEvent` to block D-pad keys
WITHIN the content subtree when the sidebar/modal is open. It does NOT prevent
focus from MOVING TO the content Box itself. This is the key distinction.

---

## Full D-Pad Audit Methodology

When diagnosing "stuck focus" bugs, trace systematically:

### Phase 1: Map all focus-related files
```
find . -name "*.kt" | xargs grep -l "focusProperties\|focusable\|FocusRequester\|
  onPreviewKeyEvent\|onKeyEvent\|focusGroup\|focusRestorer"
```

### Phase 2: Check each screen's entryFocusRequesterMap
Every route in the sidebar items list should have a corresponding entry in the
`entryFocusRequesterMap` passed to `TvAppScaffold`. Missing entries fall through to
`contentFocusRequester` (the invisible content wrapper Box — no visual focus).

### Phase 3: Trace focusProperties chains
For each focusable element, verify:
- `focusProperties.right` target exists and is focusable
- `focusProperties.left` target exists (Cancel is OK at edges)
- `focusProperties.up/down` form a complete chain (no dead ends at Cancel
  unless intentional — e.g., top-of-page should route to sidebar, not Cancel)

### Phase 4: Check onPreviewKeyEvent return values
Every direction-handling `onPreviewKeyEvent` should return `true` to consume
the event. Returning `false` after handling a direction key causes the focus
system to double-process.

### Phase 5: Verify content focusability gates
Content entry points must ALWAYS be focusable. Use `blockContentFocusWhenInactive`
(which blocks internal D-pad, not focus arrival via focusProperties).

---

## Key Event Interception Patterns

| Pattern | When to use | Example |
|---------|------------|---------|
| `onPreviewKeyEvent` + consume (`true`) | Parent-level catch-all (Menu key, modal trap) | `onPreviewKeyEvent { if (Menu) openSidebar(); true }` |
| `onPreviewKeyEvent` + pass through (`false`) | Observing keys without interfering | `onPreviewKeyEvent { dpadNonce++; false }` |
| `onKeyEvent` + consume (`true`) | Child-level action (Back to dismiss, Left to exit detail) | `onKeyEvent { if (Left) onMoveToRail(); true }` |
| `BackHandler` | Standard Back behavior per Compose docs | `BackHandler(enabled=sidebarOpen) { closeSidebar() }` |
| `focusProperties.{direction} = Target` | Focus routing between siblings (sidebar→content, row→row) | `focusProperties { right = contentEntryRequester }` |
