feat: Add D-pad seeking and auto-focus seek bar in TV player

D-pad left/right now seeks -10s/+10s both when controls are hidden
and when the seek bar is focused. Controls auto-focus the seek bar
when shown, removing the need to press OK first to interact.
This commit is contained in:
2026-02-27 18:30:38 +01:00
parent 71d2ef9534
commit 06272eb08a

View File

@@ -52,8 +52,6 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -137,9 +135,17 @@ fun TvPlayerScreen(
}
val focusRequester = remember { FocusRequester() }
val seekBarFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
LaunchedEffect(controlsVisible) {
if (controlsVisible) {
seekBarFocusRequester.requestFocus()
} else {
focusRequester.requestFocus()
}
}
Box(
modifier = Modifier
@@ -149,8 +155,17 @@ fun TvPlayerScreen(
.onPreviewKeyEvent { event ->
if (!controlsVisible && event.type == KeyEventType.KeyDown) {
when (event.key) {
Key.DirectionLeft -> {
viewModel.seekBy(-10_000)
viewModel.showControls()
true
}
Key.DirectionRight -> {
viewModel.seekBy(10_000)
viewModel.showControls()
true
}
Key.DirectionUp, Key.DirectionDown,
Key.DirectionLeft, Key.DirectionRight,
Key.DirectionCenter, Key.Enter -> {
viewModel.showControls()
true
@@ -185,6 +200,7 @@ fun TvPlayerScreen(
TvPlayerControlsOverlay(
modifier = Modifier.fillMaxSize(),
uiState = uiState,
seekBarFocusRequester = seekBarFocusRequester,
onBack = onBack,
onPlayPause = { viewModel.togglePlayPause() },
onSeek = { viewModel.seekTo(it) },
@@ -250,6 +266,7 @@ private enum class TrackPanelType { AUDIO, SUBTITLES, QUALITY }
@Composable
private fun TvPlayerControlsOverlay(
uiState: PlayerUiState,
seekBarFocusRequester: FocusRequester,
onBack: () -> Unit,
onPlayPause: () -> Unit,
onSeek: (Long) -> Unit,
@@ -288,6 +305,7 @@ private fun TvPlayerControlsOverlay(
)
TvPlayerBottomSection(
uiState = uiState,
seekBarFocusRequester = seekBarFocusRequester,
onPlayPause = onPlayPause,
onSeek = onSeek,
onSeekRelative = onSeekRelative,
@@ -355,6 +373,7 @@ private fun TvPlayerTopBar(
@Composable
private fun TvPlayerBottomSection(
uiState: PlayerUiState,
seekBarFocusRequester: FocusRequester,
onPlayPause: () -> Unit,
onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit,
@@ -406,7 +425,9 @@ private fun TvPlayerBottomSection(
bufferedMs = uiState.bufferedMs,
chapterMarkers = uiState.chapters,
adMarkers = uiState.ads,
onSeek = onSeek
onSeek = onSeek,
onSeekRelative = onSeekRelative,
focusRequester = seekBarFocusRequester
)
Spacer(modifier = Modifier.height(8.dp))
Box(
@@ -484,6 +505,8 @@ private fun TvPlayerSeekBar(
chapterMarkers: List<TimedMarker>,
adMarkers: List<TimedMarker>,
onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit,
focusRequester: FocusRequester = remember { FocusRequester() },
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
@@ -493,12 +516,33 @@ private fun TvPlayerSeekBar(
val progressRatio = (position.toFloat() / safeDuration).coerceIn(0f, 1f)
val combinedMarkers = chapterMarkers.map { it.copy(type = MarkerType.CHAPTER) } +
adMarkers.map { it.copy(type = MarkerType.AD) }
var isFocused by remember { mutableStateOf(false) }
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.height(32.dp),
.height(32.dp)
.focusRequester(focusRequester)
.onFocusChanged { isFocused = it.isFocused }
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown) {
when (event.key) {
Key.DirectionLeft -> {
onSeekRelative(-10_000)
true
}
Key.DirectionRight -> {
onSeekRelative(10_000)
true
}
else -> false
}
} else {
false
}
}
.focusable(),
contentAlignment = Alignment.Center
) {
Canvas(
@@ -506,7 +550,7 @@ private fun TvPlayerSeekBar(
.fillMaxSize()
.padding(horizontal = 2.dp, vertical = 10.dp)
) {
val trackHeight = 4f
val trackHeight = if (isFocused) 6f else 4f
val trackTop = size.height / 2 - trackHeight / 2
drawRect(
color = scheme.onSurface.copy(alpha = 0.2f),
@@ -524,12 +568,19 @@ private fun TvPlayerSeekBar(
size = Size(width = progressWidth, height = trackHeight),
topLeft = Offset(0f, trackTop)
)
val thumbRadius = 7.dp.toPx()
val thumbRadius = if (isFocused) 9.dp.toPx() else 7.dp.toPx()
drawCircle(
color = scheme.primary,
color = if (isFocused) scheme.primary else scheme.primary,
radius = thumbRadius,
center = Offset(progressWidth.coerceIn(0f, size.width), size.height / 2)
)
if (isFocused) {
drawCircle(
color = scheme.primary.copy(alpha = 0.3f),
radius = thumbRadius + 4.dp.toPx(),
center = Offset(progressWidth.coerceIn(0f, size.width), size.height / 2)
)
}
combinedMarkers.forEach { marker ->
val x = (marker.positionMs.toFloat() / safeDuration) * size.width
val color = if (marker.type == MarkerType.AD) scheme.secondary else scheme.primary
@@ -540,17 +591,6 @@ private fun TvPlayerSeekBar(
)
}
}
Slider(
value = position.toFloat(),
onValueChange = { onSeek(it.toLong()) },
valueRange = 0f..safeDuration.toFloat(),
colors = SliderDefaults.colors(
thumbColor = Color.Transparent,
activeTrackColor = Color.Transparent,
inactiveTrackColor = Color.Transparent
),
modifier = Modifier.fillMaxSize()
)
}
}