player: Own the controls UI end to end
Space and Enter now toggle playback while playing (the remote OK always did), and the native PlayerView controller is switched off completely: hovering the video with a mouse revealed the old play UI with skip buttons and a settings gear, which must never appear. Our SmartTube-style overlay is the only controls surface — persistent when paused, shown transiently for 5 s while playing — with the transport icon row dropped, position/duration in one style right of the bar, the absolute published date under the title and a clickable channel icon that opens the channel's video page. The loading indicator now tracks the player's real buffering state (STATE_READY) instead of ending with metadata resolution, so the window is never black without an indicator. Closes #8
This commit is contained in:
parent
15a481b7c0
commit
a6dff3435d
1 changed files with 233 additions and 243 deletions
|
|
@ -11,6 +11,7 @@ import androidx.activity.compose.BackHandler
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -25,6 +26,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Forward10
|
import androidx.compose.material.icons.filled.Forward10
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Replay10
|
import androidx.compose.material.icons.filled.Replay10
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
|
@ -45,12 +47,15 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -172,6 +177,9 @@ class PlayerViewModel(
|
||||||
fun PlayerScreen(
|
fun PlayerScreen(
|
||||||
shortUuid: String,
|
shortUuid: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
// From the paused/controls overlay's channel icon: (handle, displayName),
|
||||||
|
// must also exit the player screen first.
|
||||||
|
onOpenChannel: (String, String) -> Unit = { _, _ -> },
|
||||||
viewModel: PlayerViewModel = viewModel(
|
viewModel: PlayerViewModel = viewModel(
|
||||||
key = "player$shortUuid",
|
key = "player$shortUuid",
|
||||||
factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore),
|
factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore),
|
||||||
|
|
@ -189,7 +197,7 @@ fun PlayerScreen(
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is PlayerUiState.Loading -> PlayerLoading()
|
is PlayerUiState.Loading -> PlayerLoading()
|
||||||
is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack)
|
is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack)
|
||||||
is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack)
|
is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,16 +259,19 @@ private fun PlayerSurface(
|
||||||
viewModel: PlayerViewModel,
|
viewModel: PlayerViewModel,
|
||||||
shortUuid: String,
|
shortUuid: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onOpenChannel: (String, String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val keyFocus = remember { FocusRequester() }
|
val keyFocus = remember { FocusRequester() }
|
||||||
var paused by remember { mutableStateOf(false) }
|
var paused by remember { mutableStateOf(false) }
|
||||||
var playerViewRef by remember { mutableStateOf<PlayerView?>(null) }
|
// True while the player hasn't reached STATE_READY yet (initial buffering OR
|
||||||
|
// a re-buffer after seeking) — shows an indicator until media really plays
|
||||||
|
// (issue #8: the Loading screen ended too early and the rest was black).
|
||||||
|
var buffering by remember { mutableStateOf(true) }
|
||||||
// Key press that was consumed at ACTION_DOWN — its trailing ACTION_UP must not
|
// 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.
|
// re-reveal the native controller right after a play/pause toggle.
|
||||||
var consumedDownKey by remember { mutableStateOf(0) }
|
var consumedDownKey by remember { mutableStateOf(0) }
|
||||||
// Overlay focus/position state for the paused controls (#4).
|
// Overlay focus/position state for the controls (#4).
|
||||||
val playPauseFocus = remember { FocusRequester() }
|
|
||||||
val seekBarFocus = remember { FocusRequester() }
|
val seekBarFocus = remember { FocusRequester() }
|
||||||
val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) }
|
val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) }
|
||||||
|
|
||||||
|
|
@ -290,12 +301,12 @@ private fun PlayerSurface(
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pause: move focus into the overlay (the Play button, like SmartTube); resume:
|
// Pause: move focus into the overlay (the seek bar — the only control); resume:
|
||||||
// back to the plain key handler so the next OK pauses again.
|
// back to the plain key handler so the next OK pauses again.
|
||||||
LaunchedEffect(paused) {
|
LaunchedEffect(paused) {
|
||||||
if (paused) {
|
if (paused) {
|
||||||
positionMs.longValue = player.currentPosition
|
positionMs.longValue = player.currentPosition
|
||||||
runCatching { playPauseFocus.requestFocus() }
|
runCatching { seekBarFocus.requestFocus() }
|
||||||
} else {
|
} else {
|
||||||
runCatching { keyFocus.requestFocus() }
|
runCatching { keyFocus.requestFocus() }
|
||||||
}
|
}
|
||||||
|
|
@ -349,11 +360,12 @@ private fun PlayerSurface(
|
||||||
player.addListener(object : Player.Listener {
|
player.addListener(object : Player.Listener {
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
paused = !isPlaying
|
paused = !isPlaying
|
||||||
if (!isPlaying) playerViewRef?.hideController() // overlay replaces the bar
|
}
|
||||||
|
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
buffering = playbackState != Player.STATE_READY
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// Controller does not flash at start; only UP/DOWN/other keys reveal it.
|
|
||||||
playerViewRef?.hideController()
|
|
||||||
onDispose {
|
onDispose {
|
||||||
viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt())
|
viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt())
|
||||||
player.release()
|
player.release()
|
||||||
|
|
@ -390,7 +402,10 @@ private fun PlayerSurface(
|
||||||
onBack()
|
onBack()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
||||||
|
// Keyboard users: Space/Enter must pause/resume like the remote OK.
|
||||||
|
KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_ENTER,
|
||||||
|
KeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
||||||
player.playWhenReady = !player.playWhenReady
|
player.playWhenReady = !player.playWhenReady
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
@ -407,21 +422,38 @@ private fun PlayerSurface(
|
||||||
} else false
|
} else false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The native PlayerView controller is fully disabled (useController = false,
|
||||||
|
// mouse hover must NOT reveal it) — our own overlay is the only controls UI:
|
||||||
|
// persistent while paused, and transient (5 s) on any non-media key while
|
||||||
|
// playing.
|
||||||
|
var controlsVisible by remember { mutableStateOf(false) }
|
||||||
|
var surfaceFocused by remember { mutableStateOf(true) }
|
||||||
|
LaunchedEffect(controlsVisible, paused) {
|
||||||
|
if (controlsVisible && !paused) {
|
||||||
|
kotlinx.coroutines.delay(5000)
|
||||||
|
controlsVisible = false
|
||||||
|
runCatching { keyFocus.requestFocus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
.focusRequester(keyFocus)
|
.focusRequester(keyFocus)
|
||||||
|
.onFocusChanged { surfaceFocused = it.isFocused }
|
||||||
.focusable()
|
.focusable()
|
||||||
.onPreviewKeyEvent { ke ->
|
.onPreviewKeyEvent { ke ->
|
||||||
val code = ke.nativeKeyEvent.keyCode
|
val code = ke.nativeKeyEvent.keyCode
|
||||||
when {
|
when {
|
||||||
paused && (code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE) -> {
|
// BACK/Esc leave the player in any state, from any focus.
|
||||||
|
code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE -> {
|
||||||
onBack(); true
|
onBack(); true
|
||||||
}
|
}
|
||||||
// While paused the overlay handles arrows + CENTER (#4); unhandled
|
// While paused our overlay has focus and handles everything.
|
||||||
// keys must not wake the native bar either.
|
|
||||||
paused -> false
|
paused -> false
|
||||||
|
// Transient controls hold focus while playing: let them move it.
|
||||||
|
controlsVisible && !surfaceFocused -> false
|
||||||
else -> {
|
else -> {
|
||||||
val handled = handleKey(ke.nativeKeyEvent)
|
val handled = handleKey(ke.nativeKeyEvent)
|
||||||
if (!handled) {
|
if (!handled) {
|
||||||
|
|
@ -430,15 +462,17 @@ private fun PlayerSurface(
|
||||||
) {
|
) {
|
||||||
consumedDownKey = 0 // trailing UP of a handled press
|
consumedDownKey = 0 // trailing UP of a handled press
|
||||||
} else {
|
} else {
|
||||||
// UP/DOWN etc. reveal the controls briefly
|
// UP/DOWN etc. reveal OUR controls, focus the Play button.
|
||||||
playerViewRef?.showController()
|
controlsVisible = true
|
||||||
|
if (surfaceFocused) {
|
||||||
|
runCatching { seekBarFocus.requestFocus() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
if (ke.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) {
|
if (ke.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) {
|
||||||
consumedDownKey = ke.nativeKeyEvent.keyCode
|
consumedDownKey = ke.nativeKeyEvent.keyCode
|
||||||
}
|
}
|
||||||
if (player.playWhenReady) playerViewRef?.hideController()
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -452,27 +486,41 @@ private fun PlayerSurface(
|
||||||
// screen regardless of the letterbox geometry (issue #1).
|
// screen regardless of the letterbox geometry (issue #1).
|
||||||
(LayoutInflater.from(ctx)
|
(LayoutInflater.from(ctx)
|
||||||
.inflate(R.layout.player_video_view, null) as PlayerView).apply {
|
.inflate(R.layout.player_video_view, null) as PlayerView).apply {
|
||||||
useController = true
|
// Native controller must never appear (issue: hovering with the
|
||||||
|
// mouse showed the old play UI) — we own the controls entirely.
|
||||||
|
useController = false
|
||||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
this.player = player
|
this.player = player
|
||||||
isFocusable = false
|
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
|
descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
|
||||||
playerViewRef = this
|
// A click (mouse) reveals our overlay instead.
|
||||||
|
setOnClickListener { controlsVisible = true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
// Paused → SmartTube-style controls: seek bar over transport buttons over info.
|
// Cover the picture with the loading indicator until the player is really
|
||||||
if (paused) {
|
// ready to render — metadata resolution alone (the Loading state) is not
|
||||||
|
// enough (issue #8).
|
||||||
|
if (buffering) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
.pointerInput(Unit) {}, // swallow taps/clicks while covering
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
}
|
||||||
|
// Controls: SmartTube-style (title + channel above, then the seek bar).
|
||||||
|
// Persistent when paused, transient while playing.
|
||||||
|
if (paused || controlsVisible) {
|
||||||
PausedControls(
|
PausedControls(
|
||||||
detail = s.detail,
|
detail = s.detail,
|
||||||
player = player,
|
player = player,
|
||||||
positionMs = positionMs,
|
positionMs = positionMs,
|
||||||
playPauseFocus = playPauseFocus,
|
playing = !paused,
|
||||||
seekBarFocus = seekBarFocus,
|
seekBarFocus = seekBarFocus,
|
||||||
|
onOpenChannel = onOpenChannel,
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -492,88 +540,138 @@ private fun seekOverlay(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SmartTube-style paused controls (#4): a big always-visible seek bar ABOVE the info
|
* SmartTube-style controls (#4): channel icon + title + channel line above the
|
||||||
* bar (it used to hide behind the semi-transparent info panel), transport buttons the
|
* seek bar, position/duration below it; on a gradient so the video stays visible.
|
||||||
* D-pad can actually select (arrows pick, OK activates), then the info panel.
|
* Persistent when paused, transient while playing (any non-media key reveals it,
|
||||||
|
* auto-hides after 5 s).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun PausedControls(
|
private fun PausedControls(
|
||||||
detail: VideoDetailDto,
|
detail: VideoDetailDto,
|
||||||
player: ExoPlayer,
|
player: ExoPlayer,
|
||||||
positionMs: androidx.compose.runtime.MutableLongState,
|
positionMs: androidx.compose.runtime.MutableLongState,
|
||||||
playPauseFocus: FocusRequester,
|
// True while play runs (transient overlay): keeps the times ticking;
|
||||||
|
// false = persistent paused overlay.
|
||||||
|
playing: Boolean,
|
||||||
seekBarFocus: FocusRequester,
|
seekBarFocus: FocusRequester,
|
||||||
|
// (handle, displayName) — opens the channel's videos page; exits the player.
|
||||||
|
onOpenChannel: (String, String) -> Unit = { _, _ -> },
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val durationMs = detail.duration * 1000L
|
val durationMs = detail.duration * 1000L
|
||||||
|
// While playing, the position text must keep counting.
|
||||||
|
LaunchedEffect(playing) {
|
||||||
|
while (playing) {
|
||||||
|
positionMs.longValue = player.currentPosition
|
||||||
|
kotlinx.coroutines.delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SmartTube-style: controls sit on a gradient over the video (the picture
|
||||||
|
// stays visible above), no opaque slab, no boxed buttons, no label text.
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(Color(0xDC000000))
|
.background(
|
||||||
.padding(horizontal = 24.dp, vertical = 18.dp),
|
Brush.verticalGradient(
|
||||||
|
0f to Color(0x00000000),
|
||||||
|
0.25f to Color(0xCC000000),
|
||||||
|
1f to Color(0xF0000000),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 14.dp),
|
||||||
) {
|
) {
|
||||||
|
// Channel icon: a button — opens the channel's videos page (its avatar is
|
||||||
|
// optional: federated channels may have none).
|
||||||
|
var channelFocused by remember { mutableStateOf(false) }
|
||||||
|
val channelFocus = remember { FocusRequester() }
|
||||||
|
var channelModifier: Modifier = Modifier.size(48.dp)
|
||||||
|
channelModifier = channelModifier
|
||||||
|
.focusRequester(channelFocus)
|
||||||
|
.onFocusChanged { channelFocused = it.isFocused }
|
||||||
|
.onPreviewKeyEvent { ke ->
|
||||||
|
if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
|
when (ke.nativeKeyEvent.keyCode) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||||
|
runCatching { seekBarFocus.requestFocus() }; true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dpadCenterActivatable {
|
||||||
|
onOpenChannel(
|
||||||
|
detail.channel.name + "@" + (detail.channel.host ?: ""),
|
||||||
|
detail.channel.displayName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.focusable()
|
||||||
|
.then(if (channelFocused) Modifier.border(3.dp, Color.White, CircleShape) else Modifier)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
val avatar = detail.channel.avatars.maxByOrNull { it.width }
|
||||||
|
if (avatar != null) {
|
||||||
|
coil.compose.AsyncImage(
|
||||||
|
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
|
||||||
|
contentDescription = "Open channel",
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = channelModifier
|
||||||
|
.clip(CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.padding(start = if (avatar != null) 14.dp else 0.dp)) {
|
||||||
|
Text(
|
||||||
|
text = detail.name,
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
maxLines = 2,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = detail.channel.displayName + " · " + formatViews(detail.views) +
|
||||||
|
" · " + formatPublished(detail.publishedAt),
|
||||||
|
color = Color(0xFFCCCCCC),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The seek bar is the only control: focused = scrub, OK = play/pause.
|
||||||
SeekBarRow(
|
SeekBarRow(
|
||||||
positionMs = positionMs,
|
positionMs = positionMs,
|
||||||
durationMs = durationMs,
|
durationMs = durationMs,
|
||||||
seekBarFocus = seekBarFocus,
|
seekBarFocus = seekBarFocus,
|
||||||
playPauseFocus = playPauseFocus,
|
|
||||||
player = player,
|
player = player,
|
||||||
|
// UP climbs to the channel icon when it exists.
|
||||||
|
channelFocus = if (detail.channel.avatars.isNotEmpty()) channelFocus else null,
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
)
|
)
|
||||||
Row(
|
// Position / duration under the bar, right-aligned.
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
Text(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
text = formatDuration((positionMs.longValue / 1000).toInt()) + " / " +
|
||||||
modifier = Modifier.padding(top = 14.dp),
|
formatDuration((durationMs / 1000).toInt()),
|
||||||
) {
|
color = Color(0xFFCCCCCC),
|
||||||
PauseControl(
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
icon = Icons.Filled.Replay10,
|
modifier = Modifier
|
||||||
label = "Back 10 s",
|
.align(Alignment.End)
|
||||||
seekBarFocus = seekBarFocus,
|
.padding(top = 8.dp),
|
||||||
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. */
|
/** Focusable + seekable progress bar: arrows scrub ±10 s, OK toggles playback. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SeekBarRow(
|
private fun SeekBarRow(
|
||||||
positionMs: androidx.compose.runtime.MutableLongState,
|
positionMs: androidx.compose.runtime.MutableLongState,
|
||||||
durationMs: Long,
|
durationMs: Long,
|
||||||
seekBarFocus: FocusRequester,
|
seekBarFocus: FocusRequester,
|
||||||
playPauseFocus: FocusRequester,
|
|
||||||
player: ExoPlayer,
|
player: ExoPlayer,
|
||||||
|
channelFocus: FocusRequester? = null, // UP target: the channel icon (or stay)
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var barFocused by remember { mutableStateOf(false) }
|
var barFocused by remember { mutableStateOf(false) }
|
||||||
val fraction =
|
val fraction =
|
||||||
if (durationMs > 0) (positionMs.longValue.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.weight(1f)
|
.fillMaxWidth()
|
||||||
.height(44.dp)
|
.height(44.dp)
|
||||||
.focusRequester(seekBarFocus)
|
.focusRequester(seekBarFocus)
|
||||||
.onFocusChanged { barFocused = it.isFocused }
|
.onFocusChanged { barFocused = it.isFocused }
|
||||||
|
|
@ -586,35 +684,29 @@ private fun SeekBarRow(
|
||||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||||
seekOverlay(player, positionMs, 10_000L, durationMs); true
|
seekOverlay(player, positionMs, 10_000L, durationMs); true
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_DPAD_UP -> true // topmost control — stay here
|
KeyEvent.KEYCODE_DPAD_UP -> channelFocus?.let { runCatching { it.requestFocus() }; true } ?: false
|
||||||
KeyEvent.KEYCODE_DPAD_DOWN -> {
|
KeyEvent.KEYCODE_DPAD_DOWN -> true
|
||||||
runCatching { playPauseFocus.requestFocus() }; true
|
|
||||||
}
|
|
||||||
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
|
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
|
||||||
KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_SPACE,
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
||||||
player.playWhenReady = true // OK on the bar starts playback
|
player.playWhenReady = !player.playWhenReady
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.focusable()
|
.focusable()
|
||||||
.background(
|
.padding(horizontal = 4.dp),
|
||||||
if (barFocused) Color(0x22FFFFFF) else Color.Transparent,
|
|
||||||
RoundedCornerShape(22.dp),
|
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
) {
|
) {
|
||||||
|
// Thin SmartTube-style track: rounded, red progress, white round knob.
|
||||||
Canvas(
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.CenterStart)
|
.align(Alignment.CenterStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(if (barFocused) 14.dp else 10.dp)
|
.height(if (barFocused) 12.dp else 6.dp)
|
||||||
// rounded ends for track and progress
|
|
||||||
.clip(RoundedCornerShape(7.dp)),
|
.clip(RoundedCornerShape(7.dp)),
|
||||||
) {
|
) {
|
||||||
drawRoundRect(color = Color(0x44FFFFFF), cornerRadius = CornerRadius(99f))
|
drawRoundRect(color = Color(0x55FFFFFF), cornerRadius = CornerRadius(99f))
|
||||||
val w = size.width * fraction
|
val w = size.width * fraction
|
||||||
if (w > 1f) {
|
if (w > 1f) {
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
|
|
@ -624,125 +716,23 @@ private fun SeekBarRow(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// White round knob on top of the track, always visible (SmartTube).
|
||||||
Text(
|
Canvas(
|
||||||
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(
|
|
||||||
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
|
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(64.dp)
|
.align(Alignment.CenterStart)
|
||||||
.clip(CircleShape),
|
.fillMaxWidth()
|
||||||
)
|
.height((if (barFocused) 18.dp else 14.dp).coerceAtLeast(14.dp)),
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(start = 16.dp),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
drawCircle(
|
||||||
text = detail.name,
|
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
radius = (if (barFocused) 9.dp else 7.dp).toPx(),
|
||||||
maxLines = 2,
|
center = Offset(fraction * size.width, size.height / 2f),
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = detail.channel.displayName,
|
|
||||||
color = Color(0xFFCCCCCC),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = formatViews(detail.views) + " · " + formatAge(detail.publishedAt),
|
|
||||||
color = Color(0xFF9A9A9A),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
|
||||||
Text(
|
|
||||||
text = formatDuration((positionMs / 1000).toInt()) + " · " + formatDuration(detail.duration),
|
|
||||||
color = Color(0xFFCCCCCC),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
Text(text = "Paused", color = Color(0xFFCE2B2B), style = MaterialTheme.typography.labelLarge)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun formatViews(views: Long): String =
|
private fun formatViews(views: Long): String =
|
||||||
when {
|
when {
|
||||||
views >= 1_000_000 -> "%.1fM views".format(views / 1_000_000.0)
|
views >= 1_000_000 -> "%.1fM views".format(views / 1_000_000.0)
|
||||||
|
|
@ -751,23 +741,23 @@ private fun formatViews(views: Long): String =
|
||||||
else -> "$views views"
|
else -> "$views views"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatAge(iso: String?): String {
|
/** Absolute published date + time in the device locale (relative age was too coarse). */
|
||||||
|
private fun formatPublished(iso: String?): String {
|
||||||
if (iso.isNullOrBlank()) return ""
|
if (iso.isNullOrBlank()) return ""
|
||||||
return try {
|
return try {
|
||||||
val age = java.time.Duration.between(java.time.Instant.parse(iso), java.time.Instant.now())
|
java.time.ZonedDateTime.ofInstant(
|
||||||
val days = age.toDays()
|
java.time.Instant.parse(iso),
|
||||||
when {
|
java.time.ZoneId.systemDefault(),
|
||||||
days >= 365 -> "${days / 365}y ago"
|
).format(
|
||||||
days >= 30 -> "${days / 30}mo ago"
|
java.time.format.DateTimeFormatter.ofPattern("d MMM yyyy, HH:mm", java.util.Locale.getDefault())
|
||||||
days >= 1 -> "${days}d ago"
|
)
|
||||||
age.toHours() >= 1 -> "${age.toHours()}h ago"
|
|
||||||
else -> "just now"
|
|
||||||
}
|
|
||||||
} catch (e: java.time.format.DateTimeParseException) {
|
} catch (e: java.time.format.DateTimeParseException) {
|
||||||
""
|
e.printStackTrace()
|
||||||
|
"" // unknown date → omit it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun formatDuration(seconds: Int): String {
|
private fun formatDuration(seconds: Int): String {
|
||||||
val h = seconds / 3600
|
val h = seconds / 3600
|
||||||
val m = (seconds % 3600) / 60
|
val m = (seconds % 3600) / 60
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue