From a6dff3435daf60c3f4d392f98991447be76da8ef Mon Sep 17 00:00:00 2001 From: Jeena Date: Tue, 13 Oct 2026 00:05:19 +0000 Subject: [PATCH] player: Own the controls UI end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 476 +++++++++--------- 1 file changed, 233 insertions(+), 243 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt index 8254ee1..b40baf9 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -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(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