TV es fundamentalmente diferente
Android TV no es "la misma app en una pantalla más grande". Es una plataforma con restricciones radicalmente distintas:
# Lo que NO existe en TV:
# ✗ Touchscreen — el usuario no puede tocar la pantalla
# ✗ Gestos — swipe, pinch-to-zoom, long press táctil
# ✗ Teclado físico siempre disponible (puede haber uno Bluetooth, pero no asumirlo)
# ✗ Notificaciones push visibles en tiempo real
# Lo que SÍ hay en TV:
# ✓ D-pad: arriba, abajo, izquierda, derecha, OK/Select
# ✓ Botones de control: Back, Home, Play/Pause, Rebobinar, Adelantar
# ✓ Control remoto de voz en algunos dispositivos
# ✓ La app ocupa siempre la pantalla completa (sin ventanas)
# El paradigma central de TV:
# → El FOCO es todo — el elemento enfocado debe ser OBVIO
# → El usuario navega con las flechas del D-pad
# → Enter/OK activa el elemento enfocado
# → Back cierra o vuelve atrás
Manifest para TV
<!-- AndroidManifest.xml — declarar soporte para TV -->
<manifest>
<!-- Declarar que la app funciona en TV -->
<uses-feature
android:name="android.software.leanback"
android:required="false" /> <!-- false = también funciona en móvil -->
<!-- TV no requiere touchscreen -->
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application>
<!-- Activity principal para TV (puede ser la misma que móvil o diferente) -->
<activity android:name=".MainActivity">
<!-- Intent filter para TV -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- Intent filter para móvil -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Focus management — el trabajo central en TV
// En TV, el focus se mueve con las flechas del D-pad
// Compose maneja el focus automáticamente entre componentes focusables
// Pero hay casos donde necesitás controlarlo manualmente
// Hacer un composable focusable:
Box(
modifier = Modifier
.focusable() // participa en la navegación por focus
.onFocusChanged { focusState ->
if (focusState.isFocused) {
// El elemento está enfocado — mostrar indicador visual
}
}
) { /* contenido */ }
// Indicador visual de focus — CRÍTICO en TV:
@Composable
fun BotonTV(texto: String, onClick: () -> Unit) {
val focusState = remember { mutableStateOf(false) }
Button(
onClick = onClick,
modifier = Modifier
.onFocusChanged { focusState.value = it.isFocused }
.border(
width = if (focusState.value) 3.dp else 0.dp,
color = if (focusState.value) Color.White else Color.Transparent,
shape = RoundedCornerShape(8.dp)
)
.scale(if (focusState.value) 1.1f else 1.0f) // leve zoom al enfocar
) {
Text(texto, fontSize = if (focusState.value) 20.sp else 18.sp)
}
}
FocusRequester — focus programático
// Pedir el focus cuando una pantalla aparece:
@Composable
fun PantallaTV() {
val primerBotonFocus = remember { FocusRequester() }
LaunchedEffect(Unit) {
// Dar focus al primer elemento al entrar a la pantalla
primerBotonFocus.requestFocus()
}
Column {
Button(
onClick = { /* acción */ },
modifier = Modifier.focusRequester(primerBotonFocus)
) {
Text("Reproducir")
}
Button(onClick = { /* acción */ }) {
Text("Más información")
}
}
}
// Capturar eventos específicos del D-pad:
Box(
modifier = Modifier
.focusable()
.onKeyEvent { evento ->
when (evento.key) {
Key.DirectionUp -> { /* flecha arriba */ true }
Key.DirectionDown -> { /* flecha abajo */ true }
Key.DirectionLeft -> { /* flecha izq */ true }
Key.DirectionRight -> { /* flecha der */ true }
Key.Enter -> { onClick(); true }
Key.Back -> { onVolver(); true }
Key.MediaPlayPause -> { onPlayPause(); true }
else -> false // no consumido
}
}
) { /* contenido */ }
Compose para TV — la librería específica
// implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
// implementation("androidx.tv:tv-material:1.0.0-alpha11")
// Componentes TV de Compose con focus correcto out-of-the-box:
// TvLazyRow — lista horizontal típica de TV (Netflix, YouTube)
@Composable
fun FilaContenido(items: List<Contenido>) {
TvLazyRow(
contentPadding = PaddingValues(horizontal = 48.dp)
) {
items(items, key = { it.id }) { contenido ->
Card(
onClick = { /* reproducir */ },
modifier = Modifier
.size(width = 200.dp, height = 120.dp)
.padding(8.dp)
) {
// Thumbnail del contenido
AsyncImage(
model = contenido.thumbnailUrl,
contentDescription = contenido.titulo,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
}
}
}
// ImmersiveList — elemento destacado con lista debajo (patrón Hero)
@Composable
fun PantallaInicio(contenidos: List<Contenido>) {
ImmersiveList(
background = { index, _ ->
// Imagen de fondo del elemento actual
AsyncImage(
model = contenidos[index].backdropUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
) {
TvLazyRow {
itemsIndexed(contenidos) { index, contenido ->
Card(onClick = { /* reproducir */ }) {
Text(contenido.titulo)
}
}
}
}
}
Diseño para la distancia — reglas de TV
# TEXTO — legible a 3 metros de distancia:
# → Tamaño mínimo: 24sp para texto de cuerpo
# → Títulos: 32sp o más
# → No usar fuentes ligeras (weight 300 o menos)
# TOUCH TARGETS — aunque no haya touch:
# → Elementos interactivos: mínimo 60x60dp
# → Espaciado entre elementos: mínimo 12dp
# MÁRGENES — la zona segura de la pantalla (overscan):
# → Las TV modernas no tienen overscan, pero el estándar es 48dp de margen
# → Nunca poner contenido crítico en los bordes
# FOCO — siempre visible:
# → El elemento enfocado debe destacar claramente del resto
# → Usar escala (1.05-1.15x), borde blanco o sombra para indicar focus
# → El foco debe moverse de forma predecible con el D-pad
# COLORES:
# → Fondo oscuro (no blanco) — la TV emite luz, el blanco cansa los ojos
# → Evitar gradientes complejos — pueden verse mal en TVs de baja calidad
# → Alto contraste texto / fondo