# Android TV Focus Patterns (NuvioTV → NexStream)

## Critical Rule: Never combine `.focusable()` + `.clickable()` on the same modifier chain

Both modifiers track focus independently in Compose TV. `clickable` handles its own internal focus state, but when `.focusable()` is also present, Compose creates two competing focus targets. The result: `onFocusChanged` fires inconsistently, and focus visual state (borders, rings, scale) becomes invisible or flickers.

**Fix:** Use only `.clickable(interactionSource = MutableInteractionSource(), indication = null)`. The `interactionSource` provides proper focus tracking through `collectIsFocusedAsState()`.

```kotlin
// ❌ BROKEN — focus visuals invisible
Column(
    modifier = modifier
        .onFocusChanged { isFocused = it.isFocused }
        .focusable()                    // ← conflicts with clickable
        .clickable { onClick() }
)

// ✅ CORRECT
val interactionSource = remember { MutableInteractionSource() }
Column(
    modifier = modifier
        .focusRequester(focusRequester) // from caller
        .onFocusChanged { isFocused = it.isFocused }
        .clickable(interactionSource = interactionSource, indication = null) { onClick() }
)
```

## Always-Visible Focus Ring Pattern

Cards should show a **1dp ring at 75% Border opacity** when unfocused, transitioning to **3dp accent ring** on focus. Never animate from `0.dp` / `Color.Transparent` — the transition is invisible on many TVs.

```kotlin
// ❌ BROKEN — ring invisible until focused
val ringColor = lerp(Color.Transparent, accent, focusProgress)
val ringWidth = (4f * focusProgress).dp

// ✅ CORRECT — always visible, color changes on focus
val ringColor = lerp(
    NuvioTheme.colors.Border.copy(alpha = 0.75f),  // unfocused: subtle
    accent,                                          // focused: bright
    focusProgress
)
val ringWidth = lerp(1.dp, 3.dp, focusProgress)     // 1dp→3dp
```

## Content Box Should NOT Be `.focusable()`

When a content area sits next to a sidebar/navigation rail, the content wrapper **must not** be `.focusable()`. Instead, use `onKeyEvent` that tries `focusManager.moveFocus(Left)` first, and only expands the sidebar as a fallback.

```kotlin
// ✅ NuvioTV pattern
Box(
    modifier = Modifier
        .fillMaxSize()
        // NO .focusable() here
        .onKeyEvent { event ->
            if (event.key == Key.DirectionLeft) {
                if (focusManager.moveFocus(FocusDirection.Left)) {
                    true  // moved left within content — don't open sidebar
                } else {
                    expandSidebar()  // nothing to the left — open sidebar
                    true
                }
            } else false
        }
) { content() }
```

## NuvioTV Focus State Machine (Sidebar)

```kotlin
// State flags
var isExpanded by remember { mutableStateOf(false) }
var collapsePending by remember { mutableStateOf(false) }
var pendingContentFocusTransfer by remember { mutableStateOf(false) }
var pendingSidebarFocusRequest by remember { mutableStateOf(false) }
var focusedDrawerIndex by remember { mutableStateOf(-1) }

// Key blocking: when sidebar expanded, eat D-pad from content
val sidebarBlocksContentKeys = derivedStateOf { isExpanded && !collapsePending }

// Auto-collapse timer: 4s after last focus change
LaunchedEffect(isExpanded, focusedDrawerIndex, collapsePending) {
    if (!isExpanded || collapsePending) return@LaunchedEffect
    delay(4_000L)
    pendingContentFocusTransfer = false
    collapsePending = true
}

// Boundary detection: prevent vertical wrap on sidebar
sidebar.onPreviewKeyEvent { event ->
    when (event.key) {
        Key.DirectionUp -> focusedDrawerIndex == 0 // eat if at top
        Key.DirectionDown -> focusedDrawerIndex == items.lastIndex // eat if at bottom
        Key.DirectionRight -> { collapse(); true }  // collapse to content
        Key.DirectionLeft -> true  // eat (nothing left of sidebar)
    }
}

// Focus transfer: 2-frame delay for layout stability
LaunchedEffect(pendingContentFocusTransfer) {
    repeat(2) { withFrameNanos {} }
    contentFocusRequester.requestFocus()
    pendingContentFocusTransfer = false
}
```

## `requestFocusAfterFrames()` Utility

Reliable focus restoration utility that waits for composition frames and retries. Essential after navigation or animation-driven layout changes.

```kotlin
// File: app/src/main/java/com/nexstream/app/ui/util/FocusRestoreUtils.kt
suspend fun FocusRequester.requestFocusAfterFrames(frames: Int = 2) {
    repeat(frames.coerceAtLeast(0)) { withFrameNanos { } }
    repeat(4) { attempt ->
        val requested = runCatching { requestFocus(); true }.getOrDefault(false)
        if (requested) return
        if (attempt < 3) withFrameNanos { }
    }
}
```

Usage:
```kotlin
LaunchedEffect(Unit) { myFocusRequester.requestFocusAfterFrames() }
```

## Settings Two-Pane Focus Pattern

For settings screens with left rail + right detail pane:

1. Rail uses `FocusRequester` per item, detail uses `contentFocusRequesters` map
2. RIGHT key sets `allowDetailFocus = true` + `pendingContentFocusRequestId += 1L`
3. `LaunchedEffect(pendingContentFocusRequestId)` with 120ms delay requests content focus
4. Fallback: if content `FocusRequester` misses, use `focusManager.moveFocus(FocusDirection.Right)`
5. LEFT key on detail pane: try `moveFocus(Left)` first, then fall back to rail
6. Bounce-back: `onFocusChanged` on detail pane sends focus to rail if `!allowDetailFocus`
