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:
Jeena 2026-10-13 00:05:19 +00:00
parent 15a481b7c0
commit a6dff3435d

View file

@ -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,257 +540,199 @@ 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),
)
// Position / duration under the bar, right-aligned.
Text(
text = formatDuration((positionMs.longValue / 1000).toInt()) + " / " +
formatDuration((durationMs / 1000).toInt()),
color = Color(0xFFCCCCCC),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.align(Alignment.End)
.padding(top = 8.dp),
) )
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. */ /** 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)) { Box(
Text( modifier = modifier
text = formatDuration((positionMs.longValue / 1000).toInt()), .fillMaxWidth()
color = Color.White, .height(44.dp)
style = MaterialTheme.typography.labelMedium, .focusRequester(seekBarFocus)
) .onFocusChanged { barFocused = it.isFocused }
Box( .onPreviewKeyEvent { ke ->
modifier = Modifier if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
.weight(1f) when (ke.nativeKeyEvent.keyCode) {
.height(44.dp) KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_REWIND -> {
.focusRequester(seekBarFocus) seekOverlay(player, positionMs, -10_000L, durationMs); true
.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
} }
} KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
.focusable() seekOverlay(player, positionMs, 10_000L, durationMs); true
.background( }
if (barFocused) Color(0x22FFFFFF) else Color.Transparent, KeyEvent.KEYCODE_DPAD_UP -> channelFocus?.let { runCatching { it.requestFocus() }; true } ?: false
RoundedCornerShape(22.dp), KeyEvent.KEYCODE_DPAD_DOWN -> true
) KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
.padding(horizontal = 12.dp), KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_SPACE,
) { KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY -> {
Canvas( player.playWhenReady = !player.playWhenReady
modifier = Modifier true
.align(Alignment.CenterStart) }
.fillMaxWidth() else -> false
.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),
)
} }
} }
} .focusable()
Text( .padding(horizontal = 4.dp),
text = formatDuration((durationMs / 1000).toInt()), ) {
color = Color(0xFFAAAAAA), // Thin SmartTube-style track: rounded, red progress, white round knob.
style = MaterialTheme.typography.labelMedium, Canvas(
)
}
}
/** 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
.size(64.dp)
.clip(CircleShape),
)
}
Column(
modifier = Modifier modifier = Modifier
.weight(1f) .align(Alignment.CenterStart)
.padding(start = 16.dp), .fillMaxWidth()
.height(if (barFocused) 12.dp else 6.dp)
.clip(RoundedCornerShape(7.dp)),
) { ) {
Text( drawRoundRect(color = Color(0x55FFFFFF), cornerRadius = CornerRadius(99f))
text = detail.name, val w = size.width * fraction
color = Color.White, if (w > 1f) {
style = MaterialTheme.typography.titleLarge, drawRoundRect(
maxLines = 2, color = Color(0xFFCE2B2B),
) size = Size(w, size.height),
Text( cornerRadius = CornerRadius(99f),
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) { // White round knob on top of the track, always visible (SmartTube).
Text( Canvas(
text = formatDuration((positionMs / 1000).toInt()) + " · " + formatDuration(detail.duration), modifier = Modifier
color = Color(0xFFCCCCCC), .align(Alignment.CenterStart)
style = MaterialTheme.typography.bodyMedium, .fillMaxWidth()
.height((if (barFocused) 18.dp else 14.dp).coerceAtLeast(14.dp)),
) {
drawCircle(
color = Color.White,
radius = (if (barFocused) 9.dp else 7.dp).toPx(),
center = Offset(fraction * size.width, size.height / 2f),
) )
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