# Android TV Focus Navigation Patterns

Patterns discovered during UNSPOOLED (NexStream) Android TV Compose debugging and enhancement.

## Pattern 1: Sidebar → Content Focus Transition

**Problem:** On Android TV screens with a sidebar, D-pad focus enters the sidebar but cannot leave — pressing RIGHT does nothing because the sidebar has no content handoff mechanism.

**Root cause:** Sidebar composables use `onKeyEvent` (processes after children) or lack any RIGHT-key handler entirely. Without `onPreviewKeyEvent` + a focus requester handoff, focus is trapped.

**Fix — add `onNavigateToContent` callback + `onPreviewKeyEvent`:**

```kotlin
// In the sidebar composable:
fun NexSidebar(
    items: List<Item>,
    selectedRoute: String?,
    onNavigateToContent: (() -> Unit)? = null,  // NEW
    // ... other params
) {
    Column(
        modifier = Modifier
            .onPreviewKeyEvent { event ->
                if (event.type == KeyEventType.KeyUp) return@onPreviewKeyEvent false
                if (event.key == Key.DirectionRight && onNavigateToContent != null) {
                    onNavigateToContent()
                    true
                } else false
            },
    ) { /* sidebar items */ }
}

// In the screen composable — hand focus to the first content element:
NexSidebar(
    onNavigateToContent = {
        try { heroPlayButtonFocusRequester.requestFocus() }
        catch (_: Exception) {
            try { firstRowFocusRequester.requestFocus() }
            catch (_: Exception) {}
        }
    },
)
```

**Key rules:**
1. Use `onPreviewKeyEvent`, not `onKeyEvent` — preview fires before child composables, preventing double-fire or swallowed events.
2. Chain requester attempts: hero play button → first content row → silent catch. One will succeed.
3. Collapse the sidebar on RIGHT press to give visual feedback that focus moved.

## Pattern 2: Back Key Overlay Dismissal Stack

**Problem:** Pressing Back on TV should dismiss the topmost overlay (source menu, episode picker, up-next prompt) first, then navigate back. Using `onKeyEvent` causes the system to process Back first, potentially popping the entire screen.

**Fix — use `onPreviewKeyEvent` with overlay stack checking:**

```kotlin
Box(
    modifier = Modifier
        .fillMaxSize()
        .onPreviewKeyEvent { event ->
            if (event.type == KeyEventType.KeyUp && event.key == Key.Back) {
                when {
                    uiState.externalPlayerSheetOpen -> viewModel.toggleExternalPlayerSheet()
                    uiState.showRatingPrompt -> viewModel.onDismissRatingPrompt()
                    uiState.showUpNext != null -> viewModel.dismissUpNext()
                    uiState.error != null -> { navController.popBackStack(); return@onPreviewKeyEvent true }
                    else -> { navController.popBackStack(); return@onPreviewKeyEvent true }
                }
                true  // consumed
            } else false
        },
) {
    // player content + overlays
}
```

**The `return@onPreviewKeyEvent` label MUST match the lambda parameter name.** If you rename `onKeyEvent` → `onPreviewKeyEvent`, update all `return@` labels.

**Overlay dismissal order matters:**
1. Ephemeral overlays first (source menu, external player sheet)
2. Prompts (rating, up-next)
3. Error states (dismiss error → back to previous screen)
4. Finally: `popBackStack()`

## Pattern 3: edgeToEdge on Android TV

**Problem:** `enableEdgeToEdge()` draws content under system bars. On TV, this hides toolbar icons, settings icons, and clips the top/bottom of the UI.

**Fix — add window insets padding:**

```kotlin
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)
        setContent {
            NexStreamNavHost(
                modifier = Modifier
                    .fillMaxSize()
                    .windowInsetsPadding(WindowInsets.safeDrawing)
            )
        }
    }
}
```

**Import:** `androidx.compose.foundation.layout.WindowInsets` and `androidx.compose.foundation.layout.safeDrawing`

**Compatibility:** `WindowInsets.safeDrawing` requires Compose BOM 2024.06+ (1.7.0+). For older BOMs, use `WindowInsets.systemBars`.

## Pattern 4: Progress Entity Title Storage

**Problem:** Continue Watching shows raw media IDs (`tt1234567`) instead of movie/show titles because the `WatchProgress` entity only stores `mediaId`, and the display code uses it as `name`.

**Fix — add title to the entity + migration + save/load wiring:**

```kotlin
// 1. Entity: add field with default
data class WatchProgress(
    val title: String = "",  // NEW
    // ... existing fields
)

// 2. DB migration (v4 → v5):
val MIGRATION_4_5: Migration = object : Migration(4, 5) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE `watch_progress` ADD COLUMN `title` TEXT NOT NULL DEFAULT ''")
    }
}

// 3. Save path — PlayerViewModel.saveProgress():
watchProgressDao.insertOrUpdate(WatchProgress(
    title = state.mediaTitle,  // from PlayerUiState
    // ... other fields
))

// 4. Load path — HomeViewModel.loadContinueWatching():
MetaPreview(
    name = progress.title.ifBlank { progress.mediaId },  // fallback for old data
)
```

**Why this beats metadata lookups:** Storing the title at save time eliminates N network calls (one per item) on every Home load. Continue Watching loads instantly from Room. Old entries without titles gracefully fall back to the mediaId.

## Pattern 5: `onKeyEvent` → `onPreviewKeyEvent` Migration

**Rule:** Any `onKeyEvent` handler on Android TV composables that handles `Key.Back`, `Key.DirectionCenter`, `Key.DirectionLeft`, or `Key.DirectionRight` should be migrated to `onPreviewKeyEvent`.

```kotlin
// Before (event processed AFTER children, may be swallowed or double-fire):
.onKeyEvent { event ->
    if (event.key == Key.Back) { ... ; true } else false
}

// After (event intercepted BEFORE children, predictable ordering):
.onPreviewKeyEvent { event ->
    if (event.key == Key.Back) { ... ; true } else false
}
```

**Files to migrate:** Sidebar composables, player screens, detail screens, any screen with overlay dismissal logic. The import is `androidx.compose.ui.input.key.onPreviewKeyEvent`.

**Compile trap:** `onPreviewKeyEvent` requires explicit import — it's in the same package as `onKeyEvent` but Kotlin won't auto-resolve it if only `onKeyEvent` is imported.
