Fix Android TV scrub: left/right always seek like Netflix, polish player controls.

This commit is contained in:
Jos Vooges | STH 2026-09-16 00:57:54 +02:00
parent 276467f09b
commit d9785975e0
2 changed files with 178 additions and 88 deletions

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 109 versionCode = 110
versionName = "0.14.46" versionName = "0.14.47"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -123,6 +123,10 @@ fun PlayerScreen(
var showNextOverlay by remember { mutableStateOf(false) } var showNextOverlay by remember { mutableStateOf(false) }
var nextCountdown by remember { mutableIntStateOf(10) } var nextCountdown by remember { mutableIntStateOf(10) }
var nextOverlayConsumed by remember { mutableStateOf(false) } var nextOverlayConsumed by remember { mutableStateOf(false) }
/** true = knoppenrij heeft focus (↓); false = scrub-modus (←/→ seeken). */
var controlsNavMode by remember { mutableStateOf(false) }
var scrubHint by remember { mutableStateOf<String?>(null) }
var scrubToken by remember { mutableIntStateOf(0) }
BindCastPlayerRemote( BindCastPlayerRemote(
api = api, api = api,
@ -133,12 +137,29 @@ fun PlayerScreen(
onStop = onBack, onStop = onBack,
) )
fun showControls() { fun showControls(navMode: Boolean = controlsNavMode) {
controls = true controls = true
controlsNavMode = navMode
} }
fun hideControls() { fun hideControls() {
if (menu == null && !showNextOverlay) controls = false if (menu == null && !showNextOverlay) {
controls = false
controlsNavMode = false
scrubHint = null
}
}
fun scrubBy(deltaMs: Long) {
val dur = player.duration.takeIf { it > 0 } ?: Long.MAX_VALUE
val target = (player.currentPosition + deltaMs).coerceIn(0L, dur)
player.seekTo(target)
positionMs = target
val sec = kotlin.math.abs(deltaMs) / 1000
scrubHint = if (deltaMs < 0) "−${sec}s" else "+${sec}s"
scrubToken++
// Netflix: scrub houdt bediening zichtbaar, maar ←/→ blijven seeken (geen knop-focus).
showControls(navMode = false)
} }
fun goNext() { fun goNext() {
@ -156,7 +177,7 @@ fun PlayerScreen(
fun togglePlay() { fun togglePlay() {
if (player.isPlaying) player.pause() else player.play() if (player.isPlaying) player.pause() else player.play()
showControls() showControls(navMode = true)
} }
fun openOpenSubtitles() { fun openOpenSubtitles() {
@ -383,20 +404,29 @@ fun PlayerScreen(
} }
} }
LaunchedEffect(controls, menu, playing) { LaunchedEffect(controls, menu, playing, scrubToken) {
if (controls && menu == null && playing) { if (controls && menu == null && playing && scrubHint == null) {
delay(4_500) delay(4_500)
hideControls() hideControls()
} }
} }
LaunchedEffect(controls, menu) { LaunchedEffect(scrubToken) {
if (scrubHint == null) return@LaunchedEffect
delay(900)
scrubHint = null
if (controls && menu == null && playing) {
delay(3_200)
hideControls()
}
}
LaunchedEffect(controls, menu, controlsNavMode) {
delay(40) delay(40)
if (menu != null) return@LaunchedEffect if (menu != null) return@LaunchedEffect
if (controls) { when {
runCatching { controlsFocus.requestFocus() } controls && controlsNavMode -> runCatching { controlsFocus.requestFocus() }
} else { else -> runCatching { catcherFocus.requestFocus() }
runCatching { catcherFocus.requestFocus() }
} }
} }
@ -409,7 +439,7 @@ fun PlayerScreen(
when (ev.key) { when (ev.key) {
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
if (menu != null) return@onPreviewKeyEvent false if (menu != null) return@onPreviewKeyEvent false
if (!controls) { if (!controls || !controlsNavMode) {
// Netflix: OK toont bediening én pauzeert/hervat // Netflix: OK toont bediening én pauzeert/hervat
togglePlay() togglePlay()
true true
@ -425,53 +455,59 @@ fun PlayerScreen(
Key.MediaPlay -> { Key.MediaPlay -> {
if (menu != null) return@onPreviewKeyEvent false if (menu != null) return@onPreviewKeyEvent false
player.play() player.play()
showControls() showControls(navMode = true)
true true
} }
Key.MediaPause -> { Key.MediaPause -> {
if (menu != null) return@onPreviewKeyEvent false if (menu != null) return@onPreviewKeyEvent false
player.pause() player.pause()
showControls() showControls(navMode = true)
true true
} }
Key.MediaRewind -> { Key.MediaRewind -> {
if (menu != null) return@onPreviewKeyEvent false if (menu != null) return@onPreviewKeyEvent false
player.seekBack() scrubBy(-10_000)
showControls()
true true
} }
Key.MediaFastForward -> { Key.MediaFastForward -> {
if (menu != null) return@onPreviewKeyEvent false if (menu != null) return@onPreviewKeyEvent false
player.seekForward() scrubBy(10_000)
showControls()
true true
} }
Key.DirectionLeft -> { Key.DirectionLeft -> {
// Alleen seeken zonder controls; mét controls = navigatie in menu // Altijd scrubben (ook mét controls zichtbaar) — nooit door knoppen navigeren.
if (menu != null || controls) return@onPreviewKeyEvent false if (menu != null) return@onPreviewKeyEvent false
player.seekBack() scrubBy(-10_000)
showControls()
true true
} }
Key.DirectionRight -> { Key.DirectionRight -> {
if (menu != null || controls) return@onPreviewKeyEvent false if (menu != null) return@onPreviewKeyEvent false
player.seekForward() scrubBy(10_000)
showControls()
true true
} }
Key.DirectionDown -> { Key.DirectionDown -> {
if (menu != null) return@onPreviewKeyEvent false if (menu != null) return@onPreviewKeyEvent false
if (!controls) { when {
showControls() !controls -> {
showControls(navMode = true)
true true
} else { }
false !controlsNavMode -> {
controlsNavMode = true
true
}
else -> false
} }
} }
Key.DirectionUp, Key.Menu -> { Key.DirectionUp, Key.Menu -> {
if (menu != null) return@onPreviewKeyEvent false if (menu != null) return@onPreviewKeyEvent false
showControls() if (controls && controlsNavMode) {
controlsNavMode = false
true true
} else {
showControls(navMode = false)
true
}
} }
else -> false else -> false
} }
@ -496,8 +532,8 @@ fun PlayerScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
// Houd altijd focus wanneer controls weg zijn, anders bereiken D-pad/OK Compose niet // Focus-catcher: actief in scrub-modus of zonder controls
if (!controls && menu == null && error == null) { if ((!controls || !controlsNavMode) && menu == null && error == null) {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@ -526,48 +562,66 @@ fun PlayerScreen(
exit = fadeOut(), exit = fadeOut(),
) { ) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
// Top / bottom scrim zoals Netflix
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
listOf( 0f to Color(0xB3000000),
Color(0xCC000000), 0.28f to Color.Transparent,
Color.Transparent, 0.55f to Color.Transparent,
Color.Transparent, 1f to Color(0xF2000000),
Color(0xF2000000),
),
), ),
), ),
) )
// Terug linksboven
Row( Row(
Modifier Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.padding(28.dp), .padding(horizontal = 36.dp, vertical = 28.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
NetflixTextButton("← Terug", onClick = onBack) NetflixTextButton("← Terug", onClick = onBack)
} }
// Grote play/pause in het midden als gepauzeerd // Scrub-hint midden (Netflix-stijl)
if (!playing) { AnimatedVisibility(
visible = scrubHint != null,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center),
) {
Box(
Modifier
.clip(RoundedCornerShape(40.dp))
.background(Color(0xCC000000))
.padding(horizontal = 28.dp, vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
scrubHint.orEmpty(),
color = Color.White,
fontSize = 28.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
if (!playing && scrubHint == null) {
Surface( Surface(
onClick = { togglePlay() }, onClick = { togglePlay() },
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
shape = ClickableSurfaceDefaults.shape(shape = CircleShape), shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0x55FFFFFF), containerColor = Color(0x66FFFFFF),
focusedContainerColor = Color.White, focusedContainerColor = Color.White,
contentColor = Color.White, contentColor = Color.White,
focusedContentColor = Color.Black, focusedContentColor = Color.Black,
), ),
) { ) {
Box(Modifier.size(88.dp), contentAlignment = Alignment.Center) { Box(Modifier.size(92.dp), contentAlignment = Alignment.Center) {
Text("▶", color = Color.Unspecified, fontSize = 36.sp) Text("▶", color = Color.Unspecified, fontSize = 38.sp)
} }
} }
} }
@ -576,68 +630,92 @@ fun PlayerScreen(
Modifier Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 48.dp, vertical = 28.dp), .padding(horizontal = 48.dp, vertical = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
val pct = if (durationMs > 0) {
(positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
// Progress met scrub-knop
Box(Modifier.fillMaxWidth().height(18.dp), contentAlignment = Alignment.CenterStart) {
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(3.dp) .height(if (scrubHint != null) 5.dp else 4.dp)
.clip(RoundedCornerShape(2.dp)) .clip(RoundedCornerShape(3.dp))
.background(Color(0x44FFFFFF)), .background(Color(0x55FFFFFF)),
) { )
val pct = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
Box( Box(
Modifier Modifier
.fillMaxWidth(pct) .fillMaxWidth(pct)
.height(3.dp) .height(if (scrubHint != null) 5.dp else 4.dp)
.clip(RoundedCornerShape(3.dp))
.background(McColors.Brand), .background(McColors.Brand),
) )
Box(
Modifier
.fillMaxWidth(pct)
.height(18.dp),
contentAlignment = Alignment.CenterEnd,
) {
Box(
Modifier
.size(if (scrubHint != null) 16.dp else 12.dp)
.clip(CircleShape)
.background(Color.White),
)
}
} }
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) { ) {
Text(formatTime(positionMs), color = Color(0xFFB3B3B3), fontSize = 13.sp) Text(formatTime(positionMs), color = Color(0xFFE8E8E8), fontSize = 14.sp, fontWeight = FontWeight.Medium)
val endsAtBar = remainingMsFromDuration(durationMs, positionMs) val endsAtBar = remainingMsFromDuration(durationMs, positionMs)
?.let { formatEndsAt(it) } ?.let { formatEndsAt(it) }
if (endsAtBar != null) { if (endsAtBar != null) {
Text(endsAtBar, color = Color(0xFFFFD54F), fontSize = 13.sp) Text(endsAtBar, color = Color(0xFFB0B0B0), fontSize = 13.sp)
} }
Text(formatTime(durationMs), color = Color(0xFFB3B3B3), fontSize = 13.sp) Text(formatTime(durationMs), color = Color(0xFFE8E8E8), fontSize = 14.sp, fontWeight = FontWeight.Medium)
} }
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(controlsFocus), .focusRequester(controlsFocus),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
NetflixIconButton(if (playing) "❚❚" else "▶", primary = true) { togglePlay() } NetflixIconButton(if (playing) "❚❚" else "▶", primary = true) { togglePlay() }
NetflixIconButton("−10") {
scrubBy(-10_000)
}
NetflixIconButton("+10") {
scrubBy(10_000)
}
if (nextEp != null && onPlayNext != null) { if (nextEp != null && onPlayNext != null) {
NetflixIconButton("⏭") { NetflixIconButton("⏭") { goNext() }
goNext()
}
}
NetflixIconButton("↺ 10") {
player.seekBack()
showControls()
}
NetflixIconButton("10 ↻") {
player.seekForward()
showControls()
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
NetflixTextButton("Audio") { NetflixTextButton("Audio") {
menu = "audio" menu = "audio"
showControls() showControls(navMode = true)
} }
NetflixTextButton("Ondertiteling") { NetflixTextButton("Ondertiteling") {
menu = "text" menu = "text"
showControls() showControls(navMode = true)
} }
} }
if (!controlsNavMode && scrubHint == null) {
Text(
"← → spoelen · ↓ knoppen · OK pauze",
color = Color(0x88FFFFFF),
fontSize = 12.sp,
)
}
} }
} }
} }
@ -804,19 +882,25 @@ private fun NetflixIconButton(
Surface( Surface(
onClick = onClick, onClick = onClick,
shape = ClickableSurfaceDefaults.shape(shape = CircleShape), shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.15f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
border = ClickableSurfaceDefaults.border(
focusedBorder = androidx.tv.material3.Border(
border = androidx.compose.foundation.BorderStroke(2.dp, Color.White),
shape = CircleShape,
),
),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (primary) Color.White else Color(0x22FFFFFF), containerColor = if (primary) Color.White else Color(0x33FFFFFF),
focusedContainerColor = Color.White, focusedContainerColor = Color.White,
contentColor = if (primary) Color.Black else Color.White, contentColor = if (primary) Color.Black else Color.White,
focusedContentColor = Color.Black, focusedContentColor = Color.Black,
), ),
) { ) {
Box( Box(
Modifier.size(52.dp), Modifier.size(56.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text(label, color = Color.Unspecified, fontSize = 14.sp, fontWeight = FontWeight.Bold) Text(label, color = Color.Unspecified, fontSize = 15.sp, fontWeight = FontWeight.Bold)
} }
} }
} }
@ -826,18 +910,24 @@ private fun NetflixIconButton(
private fun NetflixTextButton(label: String, onClick: () -> Unit) { private fun NetflixTextButton(label: String, onClick: () -> Unit) {
Surface( Surface(
onClick = onClick, onClick = onClick,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
border = ClickableSurfaceDefaults.border(
focusedBorder = androidx.tv.material3.Border(
border = androidx.compose.foundation.BorderStroke(2.dp, Color.White),
shape = RoundedCornerShape(6.dp),
),
),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent, containerColor = Color(0x22FFFFFF),
focusedContainerColor = Color(0x33FFFFFF), focusedContainerColor = Color.White,
contentColor = Color(0xFFE5E5E5), contentColor = Color(0xFFF0F0F0),
focusedContentColor = Color.White, focusedContentColor = Color.Black,
), ),
) { ) {
Text( Text(
label, label,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
color = Color.Unspecified, color = Color.Unspecified,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,