Fix Android TV scrub: left/right always seek like Netflix, polish player controls.
This commit is contained in:
parent
276467f09b
commit
d9785975e0
2 changed files with 178 additions and 88 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
when {
|
||||
!controls -> {
|
||||
showControls(navMode = true)
|
||||
true
|
||||
}
|
||||
!controlsNavMode -> {
|
||||
controlsNavMode = true
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
Key.DirectionUp, Key.Menu -> {
|
||||
if (menu != null) return@onPreviewKeyEvent false
|
||||
showControls()
|
||||
true
|
||||
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),
|
||||
) {
|
||||
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
|
||||
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(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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue