diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt index 60709e1..8254ee1 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -9,17 +9,26 @@ import android.view.ViewGroup import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.media3.ui.AspectRatioFrameLayout +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Forward10 +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Replay10 import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,7 +44,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale @@ -64,6 +77,7 @@ import net.jeena.jtubetv.R import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.api.VideoDetailDto import net.jeena.jtubetv.data.auth.TokenStore +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable sealed interface PlayerUiState { data object Loading : PlayerUiState @@ -147,11 +161,12 @@ class PlayerViewModel( } /** - * Fullscreen ExoPlayer that accepts the remote (UX report BUG-2): focus lives on a - * Compose key handler — OK toggles play/pause, LEFT/RIGHT seek ±10 s, anything else - * shows the controller. No controller flash on start (user request); when paused an - * info overlay (channel, title, views, date) appears instead. BACK sends a final - * watching report and returns to the list (BUG-3/6). + * Fullscreen ExoPlayer that accepts the remote (UX report BUG-2). While PLAYING the + * focus lives on a Compose key handler — OK toggles play/pause, LEFT/RIGHT seek + * ±10 s, anything else shows the controller. While PAUSED the key handler steps + * aside and a SmartTube-style control screen appears (#4): a big seek bar above an + * info bar, with focusable Rewind/Play/Forward buttons — arrows pick, OK activates. + * BACK sends a final watching report and returns to the list (BUG-3/6). */ @Composable fun PlayerScreen( @@ -241,6 +256,13 @@ private fun PlayerSurface( val keyFocus = remember { FocusRequester() } var paused by remember { mutableStateOf(false) } var playerViewRef by remember { mutableStateOf(null) } + // Key press that was consumed at ACTION_DOWN — its trailing ACTION_UP must not + // re-reveal the native controller right after a play/pause toggle. + var consumedDownKey by remember { mutableStateOf(0) } + // Overlay focus/position state for the paused controls (#4). + val playPauseFocus = remember { FocusRequester() } + val seekBarFocus = remember { FocusRequester() } + val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) } val player = remember { val hostIsInstance = viewModel.isInstanceMedia(s.playUrl) @@ -268,7 +290,16 @@ private fun PlayerSurface( .build() } - LaunchedEffect(Unit) { runCatching { keyFocus.requestFocus() } } + // Pause: move focus into the overlay (the Play button, like SmartTube); resume: + // back to the plain key handler so the next OK pauses again. + LaunchedEffect(paused) { + if (paused) { + positionMs.longValue = player.currentPosition + runCatching { playPauseFocus.requestFocus() } + } else { + runCatching { keyFocus.requestFocus() } + } + } // Leaving the app (HOME key, task switch) must not keep the sound going: // pause on ON_PAUSE, report the position, and resume on return (issue #6). @@ -383,14 +414,34 @@ private fun PlayerSurface( .focusRequester(keyFocus) .focusable() .onPreviewKeyEvent { ke -> - val handled = handleKey(ke.nativeKeyEvent) - if (!handled) { - // UP/DOWN etc. reveal the controls briefly - playerViewRef?.showController() - true - } else { - if (player.playWhenReady) playerViewRef?.hideController() - true + val code = ke.nativeKeyEvent.keyCode + when { + paused && (code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE) -> { + onBack(); true + } + // While paused the overlay handles arrows + CENTER (#4); unhandled + // keys must not wake the native bar either. + paused -> false + else -> { + val handled = handleKey(ke.nativeKeyEvent) + if (!handled) { + if (ke.nativeKeyEvent.action == KeyEvent.ACTION_UP && + ke.nativeKeyEvent.keyCode == consumedDownKey + ) { + consumedDownKey = 0 // trailing UP of a handled press + } else { + // UP/DOWN etc. reveal the controls briefly + playerViewRef?.showController() + } + true + } else { + if (ke.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) { + consumedDownKey = ke.nativeKeyEvent.keyCode + } + if (player.playWhenReady) playerViewRef?.hideController() + true + } + } } }, ) { @@ -405,28 +456,250 @@ private fun PlayerSurface( resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT this.player = player isFocusable = false + // We draw our own pause controls (#4); the native bar must only + // appear where we call showController explicitly. + setControllerAutoShow(false) descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS playerViewRef = this } }, modifier = Modifier.fillMaxSize(), ) - // Paused → info overlay with channel/title/views/date (user request). - if (paused) PauseOverlay(s.detail, player, Modifier.align(Alignment.BottomCenter)) + // Paused → SmartTube-style controls: seek bar over transport buttons over info. + if (paused) { + PausedControls( + detail = s.detail, + player = player, + positionMs = positionMs, + playPauseFocus = playPauseFocus, + seekBarFocus = seekBarFocus, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } } } -/** Info panel while paused: channel (icon + name), title, views, upload date, position. */ +/** Seek ±[deltaMs] inside the video bounds; updates the overlay's position state. */ +private fun seekOverlay( + player: ExoPlayer, + positionMs: androidx.compose.runtime.MutableLongState, + deltaMs: Long, + durationMs: Long, +) { + val target = (player.currentPosition + deltaMs).coerceIn(0L, (durationMs - 500).coerceAtLeast(0L)) + player.seekTo(target) + positionMs.longValue = target +} + +/** + * SmartTube-style paused controls (#4): a big always-visible seek bar ABOVE the info + * bar (it used to hide behind the semi-transparent info panel), transport buttons the + * D-pad can actually select (arrows pick, OK activates), then the info panel. + */ @Composable -private fun PauseOverlay(detail: VideoDetailDto, player: ExoPlayer, modifier: Modifier = Modifier) { - val positionText = "%d:%02d".format(player.currentPosition / 60000, (player.currentPosition / 1000) % 60) - Row( +private fun PausedControls( + detail: VideoDetailDto, + player: ExoPlayer, + positionMs: androidx.compose.runtime.MutableLongState, + playPauseFocus: FocusRequester, + seekBarFocus: FocusRequester, + modifier: Modifier = Modifier, +) { + val durationMs = detail.duration * 1000L + Column( modifier = modifier .fillMaxWidth() - .background(Color(0xB3000000)) - .padding(20.dp), - verticalAlignment = Alignment.CenterVertically, + .background(Color(0xDC000000)) + .padding(horizontal = 24.dp, vertical = 18.dp), ) { + SeekBarRow( + positionMs = positionMs, + durationMs = durationMs, + seekBarFocus = seekBarFocus, + playPauseFocus = playPauseFocus, + player = player, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 14.dp), + ) { + PauseControl( + icon = Icons.Filled.Replay10, + label = "Back 10 s", + seekBarFocus = seekBarFocus, + isLeftEdge = true, + isActive = false, + onClick = { seekOverlay(player, positionMs, -10_000L, durationMs) }, + ) + PauseControl( + icon = Icons.Filled.PlayArrow, + label = "Play", + seekBarFocus = seekBarFocus, + focusRequester = playPauseFocus, + isActive = true, + onClick = { player.playWhenReady = true }, + ) + PauseControl( + icon = Icons.Filled.Forward10, + label = "Forward 10 s", + seekBarFocus = seekBarFocus, + isRightEdge = true, + isActive = false, + onClick = { seekOverlay(player, positionMs, 10_000L, durationMs) }, + ) + } + InfoRow(detail, positionMs.longValue, Modifier.padding(top = 16.dp)) + } +} + +/** Focusable + seekable progress bar: arrows scrub ±10 s, OK starts playback. */ +@Composable +private fun SeekBarRow( + positionMs: androidx.compose.runtime.MutableLongState, + durationMs: Long, + seekBarFocus: FocusRequester, + playPauseFocus: FocusRequester, + player: ExoPlayer, +) { + var barFocused by remember { mutableStateOf(false) } + val fraction = + if (durationMs > 0) (positionMs.longValue.toFloat() / durationMs).coerceIn(0f, 1f) else 0f + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = formatDuration((positionMs.longValue / 1000).toInt()), + color = Color.White, + style = MaterialTheme.typography.labelMedium, + ) + Box( + modifier = Modifier + .weight(1f) + .height(44.dp) + .focusRequester(seekBarFocus) + .onFocusChanged { barFocused = it.isFocused } + .onPreviewKeyEvent { ke -> + if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (ke.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_REWIND -> { + seekOverlay(player, positionMs, -10_000L, durationMs); true + } + KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + seekOverlay(player, positionMs, 10_000L, durationMs); true + } + KeyEvent.KEYCODE_DPAD_UP -> true // topmost control — stay here + KeyEvent.KEYCODE_DPAD_DOWN -> { + runCatching { playPauseFocus.requestFocus() }; true + } + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + KeyEvent.KEYCODE_MEDIA_PLAY -> { + player.playWhenReady = true // OK on the bar starts playback + true + } + else -> false + } + } + .focusable() + .background( + if (barFocused) Color(0x22FFFFFF) else Color.Transparent, + RoundedCornerShape(22.dp), + ) + .padding(horizontal = 12.dp), + ) { + Canvas( + modifier = Modifier + .align(Alignment.CenterStart) + .fillMaxWidth() + .height(if (barFocused) 14.dp else 10.dp) + // rounded ends for track and progress + .clip(RoundedCornerShape(7.dp)), + ) { + drawRoundRect(color = Color(0x44FFFFFF), cornerRadius = CornerRadius(99f)) + val w = size.width * fraction + if (w > 1f) { + drawRoundRect( + color = Color(0xFFCE2B2B), + size = Size(w, size.height), + cornerRadius = CornerRadius(99f), + ) + } + } + } + Text( + text = formatDuration((durationMs / 1000).toInt()), + color = Color(0xFFAAAAAA), + style = MaterialTheme.typography.labelMedium, + ) + } +} + +/** Round transport button; arrows move focus (bar above via [seekBarFocus]), OK clicks. */ +@Composable +private fun PauseControl( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + seekBarFocus: FocusRequester, + onClick: () -> Unit, + focusRequester: FocusRequester? = null, + isLeftEdge: Boolean = false, + isRightEdge: Boolean = false, + isActive: Boolean = false, +) { + var focused by remember { mutableStateOf(false) } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + var m: Modifier = Modifier.size(92.dp) + if (focusRequester != null) m = m.focusRequester(focusRequester) + Box( + modifier = m + .onFocusChanged { focused = it.isFocused } + .onPreviewKeyEvent { ke -> + if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (ke.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> { + runCatching { seekBarFocus.requestFocus() }; true + } + KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> true + KeyEvent.KEYCODE_DPAD_LEFT -> isLeftEdge + KeyEvent.KEYCODE_DPAD_RIGHT -> isRightEdge + else -> false + } + } + .dpadCenterActivatable(onClick) + .focusable() + .background( + when { + focused && isActive -> Color(0xFFCE2B2B) + focused -> Color.White + else -> Color(0xFF2A2A2A) + }, + RoundedCornerShape(14.dp), + ), + contentAlignment = Alignment.Center, + ) { + androidx.compose.material3.Icon( + imageVector = icon, + contentDescription = null, + tint = when { + focused && isActive -> Color.White + focused -> Color(0xFF101010) + else -> Color.White + }, + modifier = Modifier.size(42.dp), + ) + } + Text( + text = label, + color = if (focused) Color.White else Color(0xFFB0B0B0), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(top = 6.dp), + ) + } +} + +/** Bottom info panel: channel (icon + name), title, views, date, position. */ +@Composable +private fun InfoRow(detail: VideoDetailDto, positionMs: Long, modifier: Modifier = Modifier) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { val avatar = detail.channel.avatars.maxByOrNull { it.width } if (avatar != null) { coil.compose.AsyncImage( @@ -462,7 +735,7 @@ private fun PauseOverlay(detail: VideoDetailDto, player: ExoPlayer, modifier: Mo } Column(horizontalAlignment = Alignment.End) { Text( - text = positionText + " · " + formatDuration(detail.duration), + text = formatDuration((positionMs / 1000).toInt()) + " · " + formatDuration(detail.duration), color = Color(0xFFCCCCCC), style = MaterialTheme.typography.bodyMedium, ) @@ -470,7 +743,6 @@ private fun PauseOverlay(detail: VideoDetailDto, player: ExoPlayer, modifier: Mo } } } - private fun formatViews(views: Long): String = when { views >= 1_000_000 -> "%.1fM views".format(views / 1_000_000.0)