---
name: tv-app-build
description: "TV-optimized UI patterns: web TV shell (React/Vite) AND native Android TV Compose sidebar/rail architecture, focus management, D-pad routing, and accessibility."
version: 1.0.0
metadata:
  hermes:
    requires_toolsets: [terminal, file, web]
---

# TV App Build

## Overview

The TV app is a web-based TV-optimized interface built inside the existing React/Vite frontend at `frontend/src/pages/tv/`. It provides:

- D-pad focus management (arrow keys + Enter/OK)
- Left vertical rail navigation
- TV-optimized home, search, details, and player pages
- HLS.js video playback with overlay controls
- Source resolution via existing backend API

## Architecture

### Relationship to Streaming Backend
The TV app consumes the same backend API as the web app — it's a different frontend layer, not a separate backend. See `streaming-app-architecture` for the backend patterns (stream resolution, debrid integration, TMDB metadata, caching).

### Focus System
- `frontend/src/lib/tv-focus.ts` — singleton `TVFocusManager` class
- `frontend/src/lib/tv-focus-context.tsx` — React context + hooks
- Elements register with `useTVFocus(id, {group, direction})`
- Arrow keys navigate; Enter clicks; Tab = up/down fallback
- Focus memory per route (back returns to correct position)

### Pages (all under `frontend/src/pages/tv/`)
- `tv-layout.tsx` — shell with sidebar + page router
- `tv-home.tsx` — hero card + media rows (Trending, Popular)
- `tv-search.tsx` — D-pad keyboard + voice search + results grid
- `tv-details.tsx` — full-bleed backdrop + metadata + Play/Watchlist
- `tv-player.tsx` — full-screen HLS player with seek, overlay, source chips
- `tv.css` — 10-foot font/button/focus/spacing rules
- `index.ts` — barrel exports

### Activation
- DevPanel has "📺 TV Mode" button that dispatches CustomEvent
- App.tsx listens for it, renders `<TVLayout>`
- TVLayout has "Exit TV Mode" button in Settings

## Key Files

| File | Purpose |
|------|---------|
| `frontend/src/lib/tv-focus.ts` | D-pad focus manager singleton |
| `frontend/src/lib/tv-focus-context.tsx` | React context + useTVFocus/useTVRouteFocus hooks |
| `frontend/src/pages/tv/tv.css` | All TV styles (safe area, focus rings, cards, hero, overlay) |
| `frontend/src/pages/tv/tv-layout.tsx` | Shell: sidebar + page routing |
| `frontend/src/pages/tv/tv-home.tsx` | Hero + media rows |
| `frontend/src/pages/tv/tv-search.tsx` | Search with voice + D-pad keyboard |
| `frontend/src/pages/tv/tv-details.tsx` | Details with backdrop + Play |
| `frontend/src/pages/tv/tv-player.tsx` | Full-screen player with HLS, seek, source chips |
| `frontend/src/pages/tv/index.ts` | Barrel exports |
| `frontend/src/App.tsx` | App.tsx adds TV mode routing |
| `frontend/src/components/dev-panel.tsx` | TV Mode button |

## Usage

1. Start dev server: `cd frontend && npx vite`
2. Login normally
3. Click "DEV" bottom-right → "📺 TV Mode"
4. Navigate with arrow keys + Enter

Key mappings:
- Arrow keys: move focus
- Enter/Space: click focused element
- Tab: vertical fallback
- F: fullscreen toggle (in player)
- Escape: back to home (in player)
- Left/Right: seek ±10s (in player)
- Space: play/pause (in player)

## Android TV Native Sidebar / Rail Architecture

When building a native Android TV (Kotlin + Compose) sidebar or navigation rail:

1. **Architecture decision** — M3 (May 2025) deprecates NavigationDrawer in favour of NavigationRail. A custom ~72dp icon rail with labels appearing on focus is the Netflix/premium standard. See reference file for production app comparison.
2. **Focus routing** — Sidebar items `focusProperties { right = entryRequester }` → content entry point. First content card `focusProperties { left = sidebarRequester }` ← back to sidebar. Right arrow on sidebar closes it.
3. **Focus restoration** — Save `listState.firstVisibleItemIndex` on sidebar close, restore on reopen. Without this, focus always resets to the selected route (bad UX).
4. **Accessibility** — Every sidebar item needs: `role = Role.Button`, `stateDescription` for selected state, `mergeDescendants = true` on the item Column (with `contentDescription = null` on child Icon), `isTraversalGroup = true` on the sidebar container, minimum 48dp touch target (`.heightIn(min = 48.dp)`). Resolve string resources OUTSIDE the semantics block (`.semantics {}` is not @Composable).
5. **Navigation dispatch** — One centralized `sidebarNavigate()` function, never per-screen duplicates.
6. **EntryFocusRequesterMap** — Each screen only maps its own route; all others fall through to `contentFocusRequester`.
7. **Haze/blur** — Requires both parent `haze(state)` AND child `hazeChild(state)`. Without the parent call the blur is a no-op.
8. **Performance** — Gate per-item shimmer by locking `targetValue = if (focused) 1f else 0f` on a single `InfiniteRepeatableSpec` (don't recreate the spec conditionally — type mismatch). Guard `animateScrollToItem` with a layout-ready check via `snapshotFlow`.
9. **Menu key** — Add `onPreviewKeyEvent(Key.Menu) → controller.openSidebar()` at the scaffold root Box to support TV remote Menu buttons.
10. **Focus wrap** — Top sidebar item wraps to bottom (`up = lastRequester`), bottom wraps to top (`down = firstRequester`). Prevents focus from disappearing at the ends.

See `references/sidebar-architecture-production-standards.md` for the full checklist, production app research, and accessibility specs.

## Adding New TV Pages

1. Create file in `frontend/src/pages/tv/`
2. Use `useTVRouteFocus("your-route")` for focus memory
3. Use `useTVFocus("unique-id", {group, direction})` on interactive elements
4. Add to `index.ts` barrel
5. Add case in `tv-layout.tsx` navigate/page rendering

## Pitfalls

### Web (React/Vite) TV
- Focus management only works within elements that have `data-tv-id` attribute
- Must use `tabIndex={-1}` or `tabIndex={0}` on interactive elements
- The focus manager does NOT handle native browser Tab well — reserve Tab for vertical nav
- TV CSS uses `--tv-safe-top/bottom/left/right` for overscan margins
- Player overlay auto-hides after 4 seconds of inactivity
- The focus system currently uses native focus events + CSS classes for indicators

### Android TV Compose — D-Pad Focus

**Dispatch order trap:** Compose focus system processes `focusProperties.{direction}` BEFORE
key event dispatch. If `focusProperties.right` target has `canFocus = false`, the move fails
silently — even though a parent `onPreviewKeyEvent` consumes the event. See
`references/android-tv-compose-focus-patterns.md` section "D-Pad Key Event Dispatch Order".

**`onPreviewKeyEvent` must return `true` when handling direction keys:**
Returning `false` after calling `onMoveToDetail()` or similar causes the focus system to
double-process the direction key, splitting focus between two elements. This is the "settings
rail → detail panel split focus" bug.

**Content Box always focusable:** The content wrapper Box that `focusProperties.right` from
sidebar items targets must ALWAYS be focusable — never gate it with `canFocus =
contentAllowed()`. Use `blockContentFocusWhenInactive` to block internal D-pad when sidebar
is open (that blocks navigation INSIDE content, not focus ARRIVAL at content).

**Preserve FocusRequesters across data changes:** Never `remember(data.size) { List(size) {
FocusRequester() } }` — this recreates all requesters on every data load, losing focus on
the previously-focused item. Use `mutableListOf()` + `remember(data.size)` sync pattern
(see reference file "Focus Restoration" section).

**Hero UP → sidebar, not Cancel:** Top-of-screen elements should route UP to the sidebar
rail via `LocalSidebarFocusRequester.current`, not `FocusRequester.Cancel`. Cancel at the
top of content creates a dead end that traps the user.

**Full audit methodology in `references/android-tv-compose-focus-patterns.md`** — 5-phase
process (map files, check entryFocusRequesterMap, trace focusProperties chains, verify
onPreviewKeyEvent return values, check content focusability gates).

## Settings Screen Design (Android TV Compose)

Three-column settings layout (icon sidebar → text category rail → content panel) is a Netflix/Disney+ standard pattern. Key patterns from production use:

### Layout Structure

```
Row (fillMaxSize, spacedBy 48dp)
 ├── LazyColumn (width=~220dp) — text-only category rail
 │   └── SettingsRailItem (transparent Card, 17sp text, red when selected)
 └── Column (weight=1f) — content panel
     ├── Text("Settings", 40sp Bold, White)
     ├── Text(category.label, 17sp, alpha 0.6f)
     └── verticalScroll Column — sub-screen content
```

### Focus Routing (Rail ↔ Detail)

- **Rail → Detail:** `onPreviewKeyEvent` on LazyColumn catches `DirectionRight` → calls `onMoveToDetail()` → first content item auto-focuses
- **Detail → Rail:** `onPreviewKeyEvent` on content Box catches `DirectionLeft` → calls `onMoveToRail()` → selected category item re-focuses
- **Zone tracking:** Use `TvFocusZone.SettingsCategory` / `SettingsDetail` in your focus controller. This enables Back key to respect settings-specific navigation (Back → category rail, not sidebar).
- **Auto-focus delay:** 80ms `LaunchedEffect` delay on initial render to let the layout settle before requesting focus. Without this, `FocusRequester.requestFocus()` races with composition.
- **FocusRequesters:** Pre-compute stable maps (`remember { categories.associateWith { FocusRequester() } }`). Never recreate on category change — that loses focus position.

### Visual Components (Gradient Background + White-Pill Focus)

- **Gradient background:** `Brush.radialGradient(radius=1800f, center=Offset(800f, -400f), colors=[crimson, navy, dark-blue])` on the root Box. On Android TV GPUs, large radial gradients are typically fine at 1920×1080. For older chipsets (Mali-400, Adreno 306+), consider caching via `remember` + `ImageBitmap` to avoid per-frame shader cost.
- **Rail items:** Text-only `Card` with `containerColor=Color.Transparent`. When selected: `ComposeColor(0xFFE50914)` (Netflix red). When focused (not selected): `Color.White`. No borders, no scale.
- **Content rows (toggle):** `Card(onClick, containerColor=transparent → White on focus)`. NetFlix-style toggle: 44×26dp, `CircleShape` thumb (22dp), red (`0xFFE50914`) when ON, dim white when OFF.
- **Content rows (action):** Same focus glow. Chevron `Icons.Default.ChevronRight` on right edge. Value text between title and chevron.
- **Hardcoded vs theme colors:** New components often start with hardcoded hex for pixel-perfect match to mockups. Plan a follow-up migration to theme tokens once the visual is approved.

### Sub-screen Consistency Pattern

Sub-screens (PlaybackSettingsScreen, DebridServicesScreen, etc.) embedded inside the content panel MUST match the parent's visual language. Common inconsistency pattern:

| Aspect | Parent Container | Old Sub-screen |
|--------|-----------------|----------------|
| Background | Transparent (gradient shows through) | `SurfaceCard` (dark rectangle, 1dp border) |
| Rows | White pill on focus | Card with `BorderStroke(2dp, FocusRing)` |
| Toggles | 44×26dp red pill | Different size/shape/color |
| Headers | 40sp "Settings" + 17sp category | `SettingsDetailHeader` or none |

**Fix:** Give sub-screens an `embedded: Boolean` parameter. When `true`, skip all card backgrounds, drop `SettingsGroupCard` wrapper, and use the parent-design components (`SettingsContentToggleRow` / `SettingsContentActionRow`) instead of `SettingsActionRow` / `SettingsToggleRow`.

### Design System File Organization

Avoid dual `SettingsDesignSystem.kt` files in different packages with overlapping component names:

- **Screen-package** (`ui/screens/settings/`): Components specific to the settings screen's visual identity (rail items, content rows, gradient background). Can use hardcoded colors.
- **Shared-package** (`ui/components/`): Generic reusable components used by screens outside settings (e.g., AIOStreamsConfigScreen, FocusedSettingRow). Should use theme tokens.
- **Naming:** If both files exist, suffix shared components differently or alias imports to avoid ambiguity. Two `SettingsTogglePill` functions with different sizes (44×26dp vs 46×24dp) create visual drift.

### Sub-screen Embedded Migration Pattern

When converting old card-based sub-screens to render inside a gradient/transparent container:

1. **Add `embedded: Boolean = false`** parameter to the sub-screen composable
2. **Gate all backgrounds**: Replace `.background(NuvioTheme.colors.X)` with `.then(if (embedded) Modifier else Modifier.background(NuvioTheme.colors.X))`
3. **Replace card wrappers**: `SettingsGroupCard` → bare Column when embedded
4. **Replace row components**: `SettingsActionRow` → `SettingsContentActionRow(icon, title, ...)`, `SettingsToggleRow` → `SettingsContentToggleRow(icon, title, ...)`, `SettingsChoiceChip` → `SettingsContentChoiceChip(label, ...)`
5. **Thread `embedded` through** to private helper composables (AddonRow, DebridProviderCard, TraktCard) — they also need to drop card backgrounds when embedded
6. **Wire in parent `when` block**: `SubScreen(embedded = true, ...)` in the SettingsCategoryContent when branch

**Pitfall:** Custom composables with their own surfaces (AddonActionButton, AddonSmallButton, custom cards) need individual `embedded` params because they hold their own `Surface`/`Card` backgrounds directly.

### Settings FocusRequester Safety

"FocusRequester not initialized" fires when `requestFocus()` is called on a requester whose composable hasn't been composed/layout yet. Three patterns with different safety profiles:

**Safe: `requestFocusAfterFrames()` (preferred)**
```kotlin
LaunchedEffect(Unit) {
    focusRequester.requestFocusAfterFrames()  // waits 2 frames, retries 4x
}
```
Use this for ALL `LaunchedEffect`-based focus requests. The extension function (`core/src/.../FocusRestoreUtils.kt`) waits for frame callbacks before attempting, then retries up to 4 times with `runCatching`.

**Safe: `runCatching { requestFocus() }`**
```kotlin
onFocusChanged { state ->
    if (state.hasFocus) {
        runCatching { requester.requestFocus() }  // catches IllegalStateException
    }
}
```
Use this in callback contexts (onFocusChanged, onKeyEvent, onClick) where coroutines aren't available. `FocusRequester.requestFocus()` throws `IllegalStateException` when the node isn't attached — `runCatching` handles this.

**Unsafe (do not use):**
```kotlin
LaunchedEffect(Unit) {
    focusRequester.requestFocus()  // races with composition, throws if not laid out
}
```

**D-pad navigation root causes:** The most common trigger is `focusProperties { right = entryRequester }` in the sidebar when `entryRequester` targets a composable (e.g., first search result) that isn't one of the always-present scaffold wrappers. Ensure the `contentFocusRequester` (which targets the always-composed content Box in `TvAppScaffold`) is always the fallback, and route-specific entries are only used when their target is guaranteed composed.

### Sidebar Visual Affordance Standards

Premium TV sidebar requirements (matching Netflix/Disney+/Google TV):

**Icon visibility:**
- Default: `Color.White.copy(alpha = 0.55f)` — soft white, not medium gray (`#6D6D6D` = 3.5:1 contrast failure)
- Focused: `Color.White` (full bright)
- Selected: `Color.White` (same as focused — selected state is conveyed by other means)

**Selected indicator:** Left accent bar at item edge — 3dp wide, `#E50914` (Netflix red). Drawn via `Modifier.drawBehind { drawRect(color, topLeft, Size(barWidth, height)) }`. This matches native Netflix/Disney+ sidebar selected state.

**Focus background:**
- Focused: `Color.White.copy(alpha = 0.12f)` — subtle brightening
- Selected: `Color.White.copy(alpha = 0.06f)` — very subtle, just enough to distinguish from default

**Animation:** Use `animateColorAsState(180ms)` for background + icon tint changes. Snappy but not instant.

**Pitfall:** The `drawBehind` lambda uses `toPx()` on a Dp value — this only works inside `DrawScope` where density is available. The `size` parameter in `drawRect` refers to `DrawScope.size`, not `androidx.compose.ui.geometry.Size`.

### Scroll Gating Pattern

When auto-focusing content after a rail scroll animation, chain them explicitly to avoid race conditions:

```kotlin
LaunchedEffect(selectedCategoryIndex) {
    railListState.animateScrollToItem(selectedCategoryIndex)
    if (allowAutofocus) {
        delay(80)
        pendingFocusId += 1L
    }
}
LaunchedEffect(pendingFocusId) {
    if (pendingFocusId == 0L) return@LaunchedEffect
    firstPanelRequester?.requestFocusAfterFrames()
}
```

**Key:** `animateScrollToItem` is a suspend function that only returns after animation completes. By placing the content focus trigger AFTER it, we guarantee the rail is fully scrolled before content focus fires. Without this, the rail and content focus race concurrently, potentially landing focus on a stale position.

### Dead-end Prevention (SOURCE_PREFS Pattern)

When a `SettingsPanel` enum entry exists and is listed in a category's `panels` list, it MUST have a corresponding `when` branch in the content renderer. Grep both the enum and the `when` block during code review. Dead-end panels allow D-pad RIGHT to reach a focusable element that renders nothing — user gets stuck.

### Settings Focus Controller Methods

```kotlin
fun enterSettingsCategory()  // zone = SettingsCategory, rail receives Left/Right
fun enterSettingsDetail()     // zone = SettingsDetail, content receives Left/Right
fun recordSettingsCategory(category: String)  // save last-selected category
fun recordSettingsDetailKey(key: String)      // save scroll position within sub-screen
```

Back key handling should check `SettingsDetail` zone first → return to category rail, not close the entire settings page.

## Related

- **`tv-ui-audit`** — Iterative TV UI/UX audit and fix loop: audit → scorecard → Top 20 → OpenCode pass → verify → repeat. Score progression tracked across builds. Target 90+/100 for production.
- **`references/tv-focus-system-audit.md`** — Comprehensive TV focus system reference. Part 1: Audit methodology for diagnosing D-pad navigation issues (3-layer conflict diagnosis, FudgeTV reference, card size comparison, license constraints). Part 2: Unified Focus Graph Architecture implementation (FocusZone, FocusRegistry, FocusLinks, FocusVelocityTracker, HomeScreenFocusState, settled focus, focus graph topology). Part 3: Unified Sidebar specification (Lumera-inspired 80→220dp expandable rail with gradient mask). Part 4: Banned vs allowed patterns for Android TV Compose (corrected — tv-material3 vs custom composable distinction).
- **`references/android-tv-compose-focus-patterns.md`** — Battle-tested Android TV Compose focus patterns from the Unspooled V5 rebuild. Two-pattern rule (A: tv-material3 → onFocusChanged, B: custom → rememberTvFocusState), 4 common bugs with symptoms+causes+fixes (ClickableSurface conflict, clickable+focusable conflict, double-wrapping bug, sidebar focus-stealing), TvLazyRow myth (API never shipped), DebridHttpDataSourceFactory pattern, tv-foundation version notes.
- **`references/sidebar-architecture-production-standards.md`** — Production-grade Android TV sidebar/rail architecture patterns. Covers Netflix/Disney+/YouTube navigation UX analysis, M3 NavigationRail spec (May 2025), TV accessibility checklist (WCAG + Google), focus management architecture, D-pad routing patterns, haze/blur integration, navigation dispatch patterns, and key pitfalls.
- **`references/ui-improvement-iteration-pattern.md`** — Iterative UI improvement loop: research → patch → build → verify → refine → build again. Two passes per tier. Pitfalls: `Modifier.scale()` unavailable in Compose 1.10.3 (use `graphicsLayer` + expand parent Box height), hero auto-rotation pauses on focus, sidebar contrast fix (TextSecondary over TextTertiary), animation duration tokens, backdrop delay cap at 1500ms, player title 22sp for 65" TV, seek button icon-only, poster size 0.85f with shadow.
