# Player Side-Panel Focus Graph Pattern

*Session: 2026-06-03 — Unspooled player panel focus wiring*

## Problem

Player side panels (subtitles, audio tracks, source streams, episodes) render inside
`PlayerOverlayScaffold` which traps directional focus at the Box boundary:

```kotlin
.focusProperties {
    up = FocusRequester.Cancel
    down = FocusRequester.Cancel
    left = FocusRequester.Cancel
    right = FocusRequester.Cancel
}
```

But the panel content (LazyColumn items) had **zero** FocusRequesters or focusProperties.
Result: the overlay trapped focus, but there was nothing navigable inside it. D-pad was dead
inside every player panel.

Additionally, when the panel was dismissed, focus did not return to the transport button
that opened it — it landed wherever Compose guessed.

## Solution: Three-Layer Focus Graph

### Layer 1: Lift FocusRequesters to survive lifecycle

Transport button FocusRequesters (`ccRequester`, `audioRequester`, `sourceRequester`, `epRequester`)
must be created at `PlayerControls` level, NOT inside `BottomControlsBar`. When a panel opens
and the parent recomposes, requesters created in `BottomControlsBar` are destroyed and
re-created — the `returnFocusRequester` pointer becomes stale.

```kotlin
// PlayerControls.kt — requesters survive panel open/close
val ccRequester = remember { FocusRequester() }
val audioRequester = remember { FocusRequester() }
val sourceRequester = remember { FocusRequester() }
val epRequester = remember { FocusRequester() }

// Pass them down to BottomControlsBar
BottomControlsBar(
    ccRequester = ccRequester,
    audioRequester = audioRequester,
    sourceRequester = sourceRequester,
    epRequester = epRequester,
)

// Pass them as returnFocusRequester to panels
NuvioSubtitlePanel(
    ...,
    returnFocusRequester = ccRequester,
)
```

### Layer 2: Per-item FocusRequesters with sequential up/down

Each panel item gets its own `FocusRequester`. Up/down wiring is sequential:

```kotlin
val itemRequesters = remember(items.size) {
    List(items.size) { FocusRequester() }
}

itemsIndexed(items) { index, item ->
    NuvioSelectableEntry(
        focusRequester = itemRequesters[index],
        upRequester = if (index == 0) closeRequester
            else itemRequesters.getOrNull(index - 1) ?: FocusRequester.Cancel,
        downRequester = if (index == items.lastIndex) FocusRequester.Cancel
            else itemRequesters.getOrNull(index + 1) ?: FocusRequester.Cancel,
    )
}
```

Each panel entry composable applies the wiring:

```kotlin
@Composable
private fun NuvioSelectableEntry(
    focusRequester: FocusRequester,
    upRequester: FocusRequester = FocusRequester.Cancel,
    downRequester: FocusRequester = FocusRequester.Cancel,
) {
    Surface(
        modifier = Modifier
            .focusRequester(focusRequester)
            .focusProperties {
                up = upRequester
                down = downRequester
                left = FocusRequester.Cancel   // trapped in panel
                right = FocusRequester.Cancel  // trapped in panel
            },
    )
}
```

### Layer 3: Initial focus + return-focus via LaunchedEffect

On panel open: focus the current item (or first item, or Close button if empty).
On panel close: focus the transport button that opened the panel.

```kotlin
LaunchedEffect(visible) {
    if (visible) {
        if (items.isNotEmpty()) {
            val currentIdx = items.indexOfFirst { it.id == currentItem?.id }
            (if (currentIdx >= 0) itemRequesters[currentIdx]
                else itemRequesters.first()).requestFocus()
        } else {
            closeRequester.requestFocus()
        }
    } else {
        returnFocusRequester?.requestFocus()
    }
}
```

## Complete Graph Per Panel Type

### Audio panel
```
Row: [Close] (down=firstTrack)
Track 0  (up=Close, down=Track1)
Track 1  (up=Track0, down=Track2)
Track N  (up=TrackN-1, down=Cancel)
```

### Subtitle panel (extra complexity: delay buttons + Off entry)
```
Row: [Close] (down=OffEntry)
Row: [−100ms] [delay text] [+100ms] (down=OffEntry each)
Off    (up=Close, down=Track0 or Cancel)
Track 0  (up=Off, down=Track1)
Track 1  (up=Track0, down=Track2)
Track N  (up=TrackN-1, down=Cancel)
```

### Source panel (header buttons)
```
Row: [Reload] [Close] (left/right wired, down=firstStream each)
Stream 0  (up=Close, down=Stream1)
Stream 1  (up=Stream0, down=Stream2)
Stream N  (up=StreamN-1, down=Cancel)
```

### Episode panel
```
Row: [Close] (down=firstEpisode)
Episode 0  (up=Close, down=Episode1)
Episode N  (up=EpisodeN-1, down=Cancel)
→ Initial focus: current episode if found, else first
```

## Files Modified (Unspooled, 2026-06-03)

- `NuvioSelectionPanels.kt` — 318→424 lines, complete focus rewrite
  - `NuvioAudioPanel`, `NuvioSubtitlePanel`, `NuvioEpisodePanel` all accept `returnFocusRequester`
  - `NuvioSelectableEntry` now accepts `focusRequester`, `upRequester`, `downRequester`
- `NuvioSourceSidePanel.kt` — 271→362 lines, complete focus rewrite
  - `NuvioStreamEntry` now accepts focus parameters
  - `NuvioPanelButton` now accepts full focus parameters
- `PlayerControls.kt` — lifted FocusRequesters, wired return-focus
  - FocusRequesters created at PlayerControls level
  - Passed through BottomControlsBar params
  - All panel calls receive `returnFocusRequester`

## Before/After Comparison

**Before (zero focus):**
```kotlin
itemsIndexed(tracks) { index, track ->
    NuvioSelectableEntry(
        label = track.label,
        isSelected = track.isSelected,
        onClick = { onAudioSelected(index) },
    )
}
```

**After (full focus graph):**
```kotlin
val itemRequesters = remember(tracks.size) { List(tracks.size) { FocusRequester() } }

LaunchedEffect(visible) {
    if (visible) itemRequesters.first().requestFocus()
    else returnFocusRequester?.requestFocus()
}

itemsIndexed(tracks) { index, track ->
    NuvioSelectableEntry(
        label = track.label,
        isSelected = track.isSelected,
        onClick = { onAudioSelected(index) },
        focusRequester = itemRequesters[index],
        upRequester = if (index == 0) closeRequester
            else itemRequesters.getOrNull(index - 1) ?: FocusRequester.Cancel,
        downRequester = if (index == tracks.lastIndex) FocusRequester.Cancel
            else itemRequesters.getOrNull(index + 1) ?: FocusRequester.Cancel,
    )
}
```
