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:
parent
1c8fff7eca
commit
c19024355c
1 changed files with 297 additions and 25 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue