Android TV Development Skill
When to Use
Apply when building TV screens, implementing D-pad navigation, or handling focus management.
Focus Management Fundamentals
FocusRequester Pattern
// Create requesters for key navigation targets
val heroPlayFocusRequester = remember { FocusRequester() }
val topNavFocusRequester = remember { FocusRequester() }
val firstRowFocusRequester = remember { FocusRequester() }
// Request focus after composition settles
LaunchedEffect(Unit) {
delay(100)
heroPlayFocusRequester.requestFocus()
}
Focus Properties for D-pad Navigation
// Define explicit navigation paths
Modifier
.focusRequester(buttonFocusRequester)
.focusProperties {
up = topNavFocusRequester // D-pad UP goes here
down = firstRowFocusRequester // D-pad DOWN goes here
left = FocusRequester.Cancel // Prevent left navigation
}
Key Event Handling
Modifier.onPreviewKeyEvent { keyEvent ->
if (keyEvent.type == KeyEventType.KeyDown && keyEvent.key == Key.DirectionUp) {
if (atTopOfList) {
coroutineScope.launch {
listState.scrollToItem(0)
heroFocusRequester.requestFocus()
}
true // Consumed
} else false
} else false
}
Home Screen Pattern
Layer Structure
Box(modifier = Modifier.fillMaxSize()) {
// Layer 1: Dynamic background (blurred backdrop)
DynamicBackground(imageUrl = previewItem?.backdropUrl)
// Layer 2: Content (Hero + Rows)
Column {
HeroSection(
items = featuredItems,
playButtonFocusRequester = heroPlayFocusRequester
)
LazyColumn {
items(rows) { row ->
ItemRow(
items = row.items,
upFocusRequester = heroPlayFocusRequester
)
}
}
}
// Layer 3: Top Navigation
TopNavigationBar(
downFocusRequester = heroPlayFocusRequester,
modifier = Modifier.align(Alignment.TopCenter)
)
}
Hero Section (30% screen height)
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.30f)
) {
// Backdrop image
AsyncImage(model = backdropUrl, ...)
// Gradient overlay
Box(modifier = Modifier
.fillMaxSize()
.background(Brush.verticalGradient(
colors = listOf(Color.Transparent, TvColors.Background)
))
)
// Content (title, buttons)
Column {
Text(item.name, style = MaterialTheme.typography.displayMedium)
Row {
Button(
modifier = Modifier.focusRequester(playFocusRequester)
) { Text("Play") }
}
}
}
Item Row Pattern
@Composable
private fun ItemRow(
title: String,
items: List<JellyfinItem>,
upFocusRequester: FocusRequester? = null,
modifier: Modifier = Modifier
) {
val firstCardFocus = remember { FocusRequester() }
Column(
modifier = modifier.focusGroup() // Row as single focus unit
) {
// Header with accent bar
Row {
Box(modifier = Modifier
.width(4.dp)
.height(24.dp)
.background(accentColor))
Text(title, style = MaterialTheme.typography.titleLarge)
}
// Cards
LazyRow(
modifier = Modifier.focusRestorer(firstCardFocus),
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 10.dp)
) {
itemsIndexed(items, key = { _, item -> item.id }) { index, item ->
MediaCard(
item = item,
modifier = Modifier
.then(if (index == 0) Modifier.focusRequester(firstCardFocus) else Modifier)
.then(upFocusRequester?.let {
Modifier.focusProperties { up = it }
} ?: Modifier)
)
}
}
}
}
Navigation Rail Pattern
@Composable
fun TopNavigationBar(
selectedItem: NavItem,
onItemSelected: (NavItem) -> Unit,
downFocusRequester: FocusRequester,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.background(Brush.verticalGradient(
colors = listOf(TvColors.Background, Color.Transparent)
))
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
NavItem.entries.forEach { item ->
NavigationButton(
item = item,
isSelected = item == selectedItem,
onItemSelected(item) },
modifier = Modifier.focusProperties { down = downFocusRequester }
)
}
}
}
Long-Press Dialog Pattern
// State
var dialogParams by remember { mutableStateOf<DialogParams?>(null) }
// On long click item ->
dialogParams = DialogParams(
title = item.name,
items = buildHomeDialogItems(item, jellyfinClient)
)
}
// Dialog display
dialogParams?.let { params ->
DialogPopup(
params = params,
dialogParams = null },
action -> handleAction(action) }
)
}
TV-Specific Tips
- Focus Indicators: Use
focusedContainerColorandfocusedBorderfor clear visual feedback - Scale on Focus: Keep
focusedScale = 1fto avoid layout shifts - Scroll Position: Save/restore scroll positions during navigation
- Loading States: Show TvLoadingIndicator during data fetches
- Error Handling: Display errors in dialogs, not inline
- Background Polling: Use
POLL_INTERVAL_MS = 30_000Lfor home screen refresh