player: Usable D-pad controls when paused

The paused state was unusable (#4): the semi-transparent info overlay
covered the seek line, and the key handler kept owning CENTER/LEFT/RIGHT
(they toggled/seeked directly), so the transport buttons could never be
selected at all.

Replace it with a SmartTube-style pause screen: a big seek bar above an
info bar (never covered again — the panel below it is near-opaque) and
focusable Back 10 s / Play / Forward 10 s buttons. While paused the
arrows pick a control and OK activates it; the bar itself scrubs ±10 s
with arrows and OK starts playback. Focus moves into the overlay on
pause (Play button) and back to the plain key handler on resume, so
while playing, OK still toggles pause and LEFT/RIGHT still seek.

Refs #4
This commit is contained in:
Jeena 2026-10-12 09:54:58 +00:00
parent 1c8fff7eca
commit c19024355c

View file

@ -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<PlayerView?>(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)