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.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,257 +540,199 @@ 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),
)
// 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
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)
.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
Box(
modifier = modifier
.fillMaxWidth()
.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
}
}
.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),
)
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
seekOverlay(player, positionMs, 10_000L, durationMs); 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_NUMPAD_ENTER, KeyEvent.KEYCODE_SPACE,
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY -> {
player.playWhenReady = !player.playWhenReady
true
}
else -> false
}
}
}
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,
modifier = Modifier
.size(64.dp)
.clip(CircleShape),
)
}
Column(
.focusable()
.padding(horizontal = 4.dp),
) {
// Thin SmartTube-style track: rounded, red progress, white round knob.
Canvas(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
.align(Alignment.CenterStart)
.fillMaxWidth()
.height(if (barFocused) 12.dp else 6.dp)
.clip(RoundedCornerShape(7.dp)),
) {
Text(
text = detail.name,
color = Color.White,
style = MaterialTheme.typography.titleLarge,
maxLines = 2,
)
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,
)
drawRoundRect(color = Color(0x55FFFFFF), cornerRadius = CornerRadius(99f))
val w = size.width * fraction
if (w > 1f) {
drawRoundRect(
color = Color(0xFFCE2B2B),
size = Size(w, size.height),
cornerRadius = CornerRadius(99f),
)
}
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = formatDuration((positionMs / 1000).toInt()) + " · " + formatDuration(detail.duration),
color = Color(0xFFCCCCCC),
style = MaterialTheme.typography.bodyMedium,
// White round knob on top of the track, always visible (SmartTube).
Canvas(
modifier = Modifier
.align(Alignment.CenterStart)
.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 =
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