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.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.material.icons.Icons
|
||||
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.Replay10
|
||||
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.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
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.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -172,6 +177,9 @@ class PlayerViewModel(
|
|||
fun PlayerScreen(
|
||||
shortUuid: String,
|
||||
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(
|
||||
key = "player$shortUuid",
|
||||
factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore),
|
||||
|
|
@ -189,7 +197,7 @@ fun PlayerScreen(
|
|||
when (val s = state) {
|
||||
is PlayerUiState.Loading -> PlayerLoading()
|
||||
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,
|
||||
shortUuid: String,
|
||||
onBack: () -> Unit,
|
||||
onOpenChannel: (String, String) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val keyFocus = remember { FocusRequester() }
|
||||
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
|
||||
// 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() }
|
||||
// Overlay focus/position state for the controls (#4).
|
||||
val seekBarFocus = remember { FocusRequester() }
|
||||
val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) }
|
||||
|
||||
|
|
@ -290,12 +301,12 @@ private fun PlayerSurface(
|
|||
.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.
|
||||
LaunchedEffect(paused) {
|
||||
if (paused) {
|
||||
positionMs.longValue = player.currentPosition
|
||||
runCatching { playPauseFocus.requestFocus() }
|
||||
runCatching { seekBarFocus.requestFocus() }
|
||||
} else {
|
||||
runCatching { keyFocus.requestFocus() }
|
||||
}
|
||||
|
|
@ -349,11 +360,12 @@ private fun PlayerSurface(
|
|||
player.addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
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 {
|
||||
viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt())
|
||||
player.release()
|
||||
|
|
@ -390,7 +402,10 @@ private fun PlayerSurface(
|
|||
onBack()
|
||||
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
|
||||
true
|
||||
}
|
||||
|
|
@ -407,21 +422,38 @@ private fun PlayerSurface(
|
|||
} 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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.focusRequester(keyFocus)
|
||||
.onFocusChanged { surfaceFocused = it.isFocused }
|
||||
.focusable()
|
||||
.onPreviewKeyEvent { ke ->
|
||||
val code = ke.nativeKeyEvent.keyCode
|
||||
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
|
||||
}
|
||||
// While paused the overlay handles arrows + CENTER (#4); unhandled
|
||||
// keys must not wake the native bar either.
|
||||
// While paused our overlay has focus and handles everything.
|
||||
paused -> false
|
||||
// Transient controls hold focus while playing: let them move it.
|
||||
controlsVisible && !surfaceFocused -> false
|
||||
else -> {
|
||||
val handled = handleKey(ke.nativeKeyEvent)
|
||||
if (!handled) {
|
||||
|
|
@ -430,15 +462,17 @@ private fun PlayerSurface(
|
|||
) {
|
||||
consumedDownKey = 0 // trailing UP of a handled press
|
||||
} else {
|
||||
// UP/DOWN etc. reveal the controls briefly
|
||||
playerViewRef?.showController()
|
||||
// UP/DOWN etc. reveal OUR controls, focus the Play button.
|
||||
controlsVisible = true
|
||||
if (surfaceFocused) {
|
||||
runCatching { seekBarFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
true
|
||||
} else {
|
||||
if (ke.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) {
|
||||
consumedDownKey = ke.nativeKeyEvent.keyCode
|
||||
}
|
||||
if (player.playWhenReady) playerViewRef?.hideController()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
@ -452,27 +486,41 @@ private fun PlayerSurface(
|
|||
// screen regardless of the letterbox geometry (issue #1).
|
||||
(LayoutInflater.from(ctx)
|
||||
.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
|
||||
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
|
||||
// A click (mouse) reveals our overlay instead.
|
||||
setOnClickListener { controlsVisible = true }
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
// Paused → SmartTube-style controls: seek bar over transport buttons over info.
|
||||
if (paused) {
|
||||
// Cover the picture with the loading indicator until the player is really
|
||||
// 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(
|
||||
detail = s.detail,
|
||||
player = player,
|
||||
positionMs = positionMs,
|
||||
playPauseFocus = playPauseFocus,
|
||||
playing = !paused,
|
||||
seekBarFocus = seekBarFocus,
|
||||
onOpenChannel = onOpenChannel,
|
||||
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
|
||||
* 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.
|
||||
* SmartTube-style controls (#4): channel icon + title + channel line above the
|
||||
* seek bar, position/duration below it; on a gradient so the video stays visible.
|
||||
* Persistent when paused, transient while playing (any non-media key reveals it,
|
||||
* auto-hides after 5 s).
|
||||
*/
|
||||
@Composable
|
||||
private fun PausedControls(
|
||||
detail: VideoDetailDto,
|
||||
player: ExoPlayer,
|
||||
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,
|
||||
// (handle, displayName) — opens the channel's videos page; exits the player.
|
||||
onOpenChannel: (String, String) -> Unit = { _, _ -> },
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
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(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xDC000000))
|
||||
.padding(horizontal = 24.dp, vertical = 18.dp),
|
||||
.background(
|
||||
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(
|
||||
positionMs = positionMs,
|
||||
durationMs = durationMs,
|
||||
seekBarFocus = seekBarFocus,
|
||||
playPauseFocus = playPauseFocus,
|
||||
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(
|
||||
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) },
|
||||
// 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),
|
||||
)
|
||||
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
|
||||
private fun SeekBarRow(
|
||||
positionMs: androidx.compose.runtime.MutableLongState,
|
||||
durationMs: Long,
|
||||
seekBarFocus: FocusRequester,
|
||||
playPauseFocus: FocusRequester,
|
||||
player: ExoPlayer,
|
||||
channelFocus: FocusRequester? = null, // UP target: the channel icon (or stay)
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
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)
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(44.dp)
|
||||
.focusRequester(seekBarFocus)
|
||||
.onFocusChanged { barFocused = it.isFocused }
|
||||
|
|
@ -586,35 +684,29 @@ private fun SeekBarRow(
|
|||
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_UP -> channelFocus?.let { runCatching { it.requestFocus() }; true } ?: false
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> 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
|
||||
KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_SPACE,
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
||||
player.playWhenReady = !player.playWhenReady
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.background(
|
||||
if (barFocused) Color(0x22FFFFFF) else Color.Transparent,
|
||||
RoundedCornerShape(22.dp),
|
||||
)
|
||||
.padding(horizontal = 12.dp),
|
||||
.padding(horizontal = 4.dp),
|
||||
) {
|
||||
// Thin SmartTube-style track: rounded, red progress, white round knob.
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.fillMaxWidth()
|
||||
.height(if (barFocused) 14.dp else 10.dp)
|
||||
// rounded ends for track and progress
|
||||
.height(if (barFocused) 12.dp else 6.dp)
|
||||
.clip(RoundedCornerShape(7.dp)),
|
||||
) {
|
||||
drawRoundRect(color = Color(0x44FFFFFF), cornerRadius = CornerRadius(99f))
|
||||
drawRoundRect(color = Color(0x55FFFFFF), cornerRadius = CornerRadius(99f))
|
||||
val w = size.width * fraction
|
||||
if (w > 1f) {
|
||||
drawRoundRect(
|
||||
|
|
@ -624,125 +716,23 @@ private fun SeekBarRow(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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(
|
||||
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
// White round knob on top of the track, always visible (SmartTube).
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp),
|
||||
.align(Alignment.CenterStart)
|
||||
.fillMaxWidth()
|
||||
.height((if (barFocused) 18.dp else 14.dp).coerceAtLeast(14.dp)),
|
||||
) {
|
||||
Text(
|
||||
text = detail.name,
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 2,
|
||||
radius = (if (barFocused) 9.dp else 7.dp).toPx(),
|
||||
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 =
|
||||
when {
|
||||
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"
|
||||
}
|
||||
|
||||
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 ""
|
||||
return try {
|
||||
val age = java.time.Duration.between(java.time.Instant.parse(iso), java.time.Instant.now())
|
||||
val days = age.toDays()
|
||||
when {
|
||||
days >= 365 -> "${days / 365}y ago"
|
||||
days >= 30 -> "${days / 30}mo ago"
|
||||
days >= 1 -> "${days}d ago"
|
||||
age.toHours() >= 1 -> "${age.toHours()}h ago"
|
||||
else -> "just now"
|
||||
}
|
||||
java.time.ZonedDateTime.ofInstant(
|
||||
java.time.Instant.parse(iso),
|
||||
java.time.ZoneId.systemDefault(),
|
||||
).format(
|
||||
java.time.format.DateTimeFormatter.ofPattern("d MMM yyyy, HH:mm", java.util.Locale.getDefault())
|
||||
)
|
||||
} catch (e: java.time.format.DateTimeParseException) {
|
||||
""
|
||||
e.printStackTrace()
|
||||
"" // unknown date → omit it
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun formatDuration(seconds: Int): String {
|
||||
val h = seconds / 3600
|
||||
val m = (seconds % 3600) / 60
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue