diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 52a9c84..043b4a3 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -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") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt index 6e8af39..59a3a1d 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -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(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,