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"
minSdk = 24
targetSdk = 36
versionCode = 109
versionName = "0.14.46"
versionCode = 110
versionName = "0.14.47"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -123,6 +123,10 @@ fun PlayerScreen(
var showNextOverlay by remember { mutableStateOf(false) }
var nextCountdown by remember { mutableIntStateOf(10) }
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(
api = api,
@ -133,12 +137,29 @@ fun PlayerScreen(
onStop = onBack,
)
fun showControls() {
fun showControls(navMode: Boolean = controlsNavMode) {
controls = true
controlsNavMode = navMode
}
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() {
@ -156,7 +177,7 @@ fun PlayerScreen(
fun togglePlay() {
if (player.isPlaying) player.pause() else player.play()
showControls()
showControls(navMode = true)
}
fun openOpenSubtitles() {
@ -383,20 +404,29 @@ fun PlayerScreen(
}
}
LaunchedEffect(controls, menu, playing) {
if (controls && menu == null && playing) {
LaunchedEffect(controls, menu, playing, scrubToken) {
if (controls && menu == null && playing && scrubHint == null) {
delay(4_500)
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)
if (menu != null) return@LaunchedEffect
if (controls) {
runCatching { controlsFocus.requestFocus() }
} else {
runCatching { catcherFocus.requestFocus() }
when {
controls && controlsNavMode -> runCatching { controlsFocus.requestFocus() }
else -> runCatching { catcherFocus.requestFocus() }
}
}
@ -409,7 +439,7 @@ fun PlayerScreen(
when (ev.key) {
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
if (menu != null) return@onPreviewKeyEvent false
if (!controls) {
if (!controls || !controlsNavMode) {
// Netflix: OK toont bediening én pauzeert/hervat
togglePlay()
true
@ -425,53 +455,59 @@ fun PlayerScreen(
Key.MediaPlay -> {
if (menu != null) return@onPreviewKeyEvent false
player.play()
showControls()
showControls(navMode = true)
true
}
Key.MediaPause -> {
if (menu != null) return@onPreviewKeyEvent false
player.pause()
showControls()
showControls(navMode = true)
true
}
Key.MediaRewind -> {
if (menu != null) return@onPreviewKeyEvent false
player.seekBack()
showControls()
scrubBy(-10_000)
true
}
Key.MediaFastForward -> {
if (menu != null) return@onPreviewKeyEvent false
player.seekForward()
showControls()
scrubBy(10_000)
true
}
Key.DirectionLeft -> {
// Alleen seeken zonder controls; mét controls = navigatie in menu
if (menu != null || controls) return@onPreviewKeyEvent false
player.seekBack()
showControls()
// Altijd scrubben (ook mét controls zichtbaar) — nooit door knoppen navigeren.
if (menu != null) return@onPreviewKeyEvent false
scrubBy(-10_000)
true
}
Key.DirectionRight -> {
if (menu != null || controls) return@onPreviewKeyEvent false
player.seekForward()
showControls()
if (menu != null) return@onPreviewKeyEvent false
scrubBy(10_000)
true
}
Key.DirectionDown -> {
if (menu != null) return@onPreviewKeyEvent false
if (!controls) {
showControls()
when {
!controls -> {
showControls(navMode = true)
true
} else {
false
}
!controlsNavMode -> {
controlsNavMode = true
true
}
else -> false
}
}
Key.DirectionUp, Key.Menu -> {
if (menu != null) return@onPreviewKeyEvent false
showControls()
if (controls && controlsNavMode) {
controlsNavMode = false
true
} else {
showControls(navMode = false)
true
}
}
else -> false
}
@ -496,8 +532,8 @@ fun PlayerScreen(
modifier = Modifier.fillMaxSize(),
)
// Houd altijd focus wanneer controls weg zijn, anders bereiken D-pad/OK Compose niet
if (!controls && menu == null && error == null) {
// Focus-catcher: actief in scrub-modus of zonder controls
if ((!controls || !controlsNavMode) && menu == null && error == null) {
Box(
Modifier
.fillMaxSize()
@ -526,48 +562,66 @@ fun PlayerScreen(
exit = fadeOut(),
) {
Box(Modifier.fillMaxSize()) {
// Top / bottom scrim zoals Netflix
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(
Color(0xCC000000),
Color.Transparent,
Color.Transparent,
Color(0xF2000000),
),
0f to Color(0xB3000000),
0.28f to Color.Transparent,
0.55f to Color.Transparent,
1f to Color(0xF2000000),
),
),
)
// Terug linksboven
Row(
Modifier
.align(Alignment.TopStart)
.padding(28.dp),
.padding(horizontal = 36.dp, vertical = 28.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NetflixTextButton("← Terug", onClick = onBack)
}
// Grote play/pause in het midden als gepauzeerd
if (!playing) {
// Scrub-hint midden (Netflix-stijl)
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(
onClick = { togglePlay() },
modifier = Modifier.align(Alignment.Center),
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0x55FFFFFF),
containerColor = Color(0x66FFFFFF),
focusedContainerColor = Color.White,
contentColor = Color.White,
focusedContentColor = Color.Black,
),
) {
Box(Modifier.size(88.dp), contentAlignment = Alignment.Center) {
Text("▶", color = Color.Unspecified, fontSize = 36.sp)
Box(Modifier.size(92.dp), contentAlignment = Alignment.Center) {
Text("▶", color = Color.Unspecified, fontSize = 38.sp)
}
}
}
@ -576,68 +630,92 @@ fun PlayerScreen(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(horizontal = 48.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
.padding(horizontal = 48.dp, vertical = 32.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(
Modifier
.fillMaxWidth()
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(Color(0x44FFFFFF)),
) {
val pct = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
.height(if (scrubHint != null) 5.dp else 4.dp)
.clip(RoundedCornerShape(3.dp))
.background(Color(0x55FFFFFF)),
)
Box(
Modifier
.fillMaxWidth(pct)
.height(3.dp)
.height(if (scrubHint != null) 5.dp else 4.dp)
.clip(RoundedCornerShape(3.dp))
.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(
Modifier.fillMaxWidth(),
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)
?.let { formatEndsAt(it) }
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(
Modifier
.fillMaxWidth()
.focusRequester(controlsFocus),
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NetflixIconButton(if (playing) "❚❚" else "▶", primary = true) { togglePlay() }
NetflixIconButton("−10") {
scrubBy(-10_000)
}
NetflixIconButton("+10") {
scrubBy(10_000)
}
if (nextEp != null && onPlayNext != null) {
NetflixIconButton("⏭") {
goNext()
}
}
NetflixIconButton("↺ 10") {
player.seekBack()
showControls()
}
NetflixIconButton("10 ↻") {
player.seekForward()
showControls()
NetflixIconButton("⏭") { goNext() }
}
Spacer(Modifier.weight(1f))
NetflixTextButton("Audio") {
menu = "audio"
showControls()
showControls(navMode = true)
}
NetflixTextButton("Ondertiteling") {
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(
onClick = onClick,
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(
containerColor = if (primary) Color.White else Color(0x22FFFFFF),
containerColor = if (primary) Color.White else Color(0x33FFFFFF),
focusedContainerColor = Color.White,
contentColor = if (primary) Color.Black else Color.White,
focusedContentColor = Color.Black,
),
) {
Box(
Modifier.size(52.dp),
Modifier.size(56.dp),
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) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)),
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(
containerColor = Color.Transparent,
focusedContainerColor = Color(0x33FFFFFF),
contentColor = Color(0xFFE5E5E5),
focusedContentColor = Color.White,
containerColor = Color(0x22FFFFFF),
focusedContainerColor = Color.White,
contentColor = Color(0xFFF0F0F0),
focusedContentColor = Color.Black,
),
) {
Text(
label,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
color = Color.Unspecified,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,