From e3e8120e8b3de003d7b9e72d895ab66c6a6ff011 Mon Sep 17 00:00:00 2001 From: beyond Date: Sun, 6 Sep 2026 19:47:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=92=AD=E6=94=BE=E5=99=A8?= =?UTF-8?q?=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/VideoPlayerScreen.kt | 68 +++++++++++-------- .../myapplication/ui/design/LumaComponents.kt | 3 +- .../screens/VideoPlayerTimeFormatTest.kt | 28 ++++++++ 3 files changed, 69 insertions(+), 30 deletions(-) create mode 100644 app/src/test/kotlin/com/example/myapplication/screens/VideoPlayerTimeFormatTest.kt diff --git a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt index d867234..6769c82 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt @@ -64,9 +64,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -126,12 +129,12 @@ import top.yukonga.miuix.kmp.basic.Text import kotlin.math.abs import kotlin.math.roundToInt -private fun formatMs(ms: Long): String { - val totalSec = ms / 1000 +internal fun formatMs(ms: Long): String { + val totalSec = ms.coerceAtLeast(0L) / 1000 val h = totalSec / 3600 val m = (totalSec % 3600) / 60 val s = totalSec % 60 - return if (h > 0) String.format("%d:%02d:%02d", h, m, s) else String.format("%02d:%02d", m, s) + return String.format(java.util.Locale.ROOT, "%02d:%02d:%02d", h, m, s) } private fun formatNetworkSpeed(bps: Long): String { @@ -338,6 +341,7 @@ fun VideoPlayerScreen( var stopReported by remember(playbackUrl) { mutableStateOf(false) } var progress by remember { mutableFloatStateOf(0f) } + var isSeeking by remember { mutableStateOf(false) } var isPlaying by remember { mutableStateOf(false) } var playerError by remember(playbackUrl) { mutableStateOf(null) } var networkSpeed by remember { mutableLongStateOf(0L) } @@ -392,7 +396,7 @@ fun VideoPlayerScreen( LaunchedEffect(Unit) { while (true) { - if (player.duration > 0 && seekPreviewPositionMs == null) { + if (player.duration > 0 && !isSeeking && seekPreviewPositionMs == null) { progress = player.currentPosition.toFloat() / player.duration.toFloat() } isPlaying = player.isPlaying @@ -821,6 +825,8 @@ fun VideoPlayerScreen( ) { BoxWithConstraints(Modifier.fillMaxSize()) { val capsuleInset = maxWidth * 0.075f + 20.dp + // The 40dp button surface is centered in a 48dp touch target. + val buttonInset = maxWidth * 0.075f - 4.dp Box( Modifier .fillMaxSize() @@ -842,7 +848,7 @@ fun VideoPlayerScreen( ), active = isScreenLocked, size = 40.dp, - modifier = Modifier.align(Alignment.CenterStart).padding(start = capsuleInset), + modifier = Modifier.align(Alignment.CenterStart).padding(start = buttonInset), ) { isScreenLocked = !isScreenLocked controlsVisible = true @@ -855,7 +861,7 @@ fun VideoPlayerScreen( contentDescription = stringResource(Res.string.player_rotate_screen), active = false, size = 40.dp, - modifier = Modifier.align(Alignment.CenterEnd).padding(end = capsuleInset), + modifier = Modifier.align(Alignment.CenterEnd).padding(end = buttonInset), ) { activity?.requestedOrientation = if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT @@ -865,7 +871,7 @@ fun VideoPlayerScreen( } Row( - Modifier.align(Alignment.BottomStart).padding(start = capsuleInset, bottom = 14.dp), + Modifier.align(Alignment.BottomStart).padding(start = buttonInset, bottom = 14.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -923,7 +929,7 @@ fun VideoPlayerScreen( Spacer(Modifier.height(2.dp)) // 第二排:关闭按钮(左)|Emby 艺术字(紧跟关闭按钮右侧)|字幕/剧集按钮(右) Row( - Modifier.fillMaxWidth().padding(horizontal = capsuleInset), + Modifier.fillMaxWidth().padding(horizontal = buttonInset), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -1030,7 +1036,7 @@ fun VideoPlayerScreen( borderColor = Color.White.copy(alpha = 0.22f), shadowElevation = 4.dp, ) - .padding(horizontal = 12.dp, vertical = 0.dp), + .padding(horizontal = 8.dp, vertical = 0.dp), ) { var totalDurationMs by remember(player) { mutableLongStateOf(player.duration.coerceAtLeast(0L)) @@ -1038,21 +1044,8 @@ fun VideoPlayerScreen( var currentPositionMs by remember(player) { mutableLongStateOf(player.currentPosition.coerceAtLeast(0L)) } - var isSeeking by remember { mutableStateOf(false) } var seekPosition by remember { mutableFloatStateOf(0f) } var bufferProgress by remember { mutableFloatStateOf(0f) } - var seekCompletedVersion by remember { mutableIntStateOf(0) } - - // Reset isSeeking after seek completes to prevent jitter - LaunchedEffect(seekCompletedVersion) { - if (seekCompletedVersion > 0) { - delay(300) - // Sync currentPositionMs to the seek target before resuming polling - // to prevent a visual jump when player position hasn't caught up yet - currentPositionMs = (progress * totalDurationMs).toLong() - isSeeking = false - } - } LaunchedEffect(player) { while (true) { @@ -1068,9 +1061,20 @@ fun VideoPlayerScreen( } } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 2.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { val swipePositionMs = seekPreviewPositionMs - Text(formatMs(swipePositionMs ?: currentPositionMs), color = Color.White, fontSize = 11.sp) + // Reserve space independently of the digits: device fonts may ignore monospace. + val timeWidth = with(LocalDensity.current) { + (formatMs(totalDurationMs).length * 7).sp.toDp() + } + Text( + formatMs(swipePositionMs ?: currentPositionMs), + modifier = Modifier.width(timeWidth), + color = Color.White, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + maxLines = 1, + ) LumaSeekBar( value = when { isSeeking -> seekPosition @@ -1088,13 +1092,11 @@ fun VideoPlayerScreen( player.seekTo(targetPosition) progress = finalValue currentPositionMs = targetPosition + isSeeking = false controlsInteractionLocked = false latestOnPlaybackProgress(targetPosition, !player.isPlaying, "TimeUpdate", true) - // Keep isSeeking true briefly to prevent polling loop from - // overwriting currentPositionMs before player catches up - seekCompletedVersion++ }, - modifier = Modifier.weight(1f).padding(horizontal = 12.dp), + modifier = Modifier.weight(1f).height(28.dp), progressContentDescription = stringResource(Res.string.player_progress), activeColor = Color.White, trackColor = Color.White.copy(alpha = 0.12f), @@ -1102,7 +1104,15 @@ fun VideoPlayerScreen( bufferedValue = bufferProgress, thumbColor = Color.White, ) - Text(formatMs(totalDurationMs), color = Color.White, fontSize = 11.sp) + Text( + formatMs(totalDurationMs), + modifier = Modifier.width(timeWidth), + color = Color.White, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.End, + maxLines = 1, + ) } } } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt index 440383a..451c003 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt @@ -509,6 +509,7 @@ fun LumaSeekBar( .pointerInput(Unit) { awaitEachGesture { val down = awaitFirstDown() + down.consume() val horizontalInset = 7.dp.toPx() val usableWidth = (size.width - horizontalInset * 2f).coerceAtLeast(1f) var gestureValue = ((down.position.x - horizontalInset) / usableWidth).coerceIn(0f, 1f) @@ -521,8 +522,8 @@ fun LumaSeekBar( if (change.pressed) { gestureValue = ((change.position.x - horizontalInset) / usableWidth).coerceIn(0f, 1f) currentOnValueChange(gestureValue) - change.consume() } + change.consume() } dragging = false currentOnValueChangeFinished(gestureValue) diff --git a/app/src/test/kotlin/com/example/myapplication/screens/VideoPlayerTimeFormatTest.kt b/app/src/test/kotlin/com/example/myapplication/screens/VideoPlayerTimeFormatTest.kt new file mode 100644 index 0000000..eeaf580 --- /dev/null +++ b/app/src/test/kotlin/com/example/myapplication/screens/VideoPlayerTimeFormatTest.kt @@ -0,0 +1,28 @@ +package com.example.myapplication.screens + +import org.junit.Assert.assertEquals +import org.junit.Test + +class VideoPlayerTimeFormatTest { + @Test + fun alwaysDisplaysHoursMinutesAndSeconds() { + assertEquals("00:00:00", formatMs(0L)) + assertEquals("00:00:09", formatMs(9_999L)) + assertEquals("00:01:00", formatMs(60_000L)) + assertEquals("00:59:59", formatMs(3_599_999L)) + assertEquals("01:00:00", formatMs(3_600_000L)) + assertEquals("12:34:56", formatMs(45_296_000L)) + } + + @Test + fun unknownDurationDisplaysZero() { + assertEquals("00:00:00", formatMs(-1L)) + assertEquals("00:00:00", formatMs(Long.MIN_VALUE + 1L)) + } + + @Test + fun hoursDoNotWrapAfterOneDay() { + assertEquals("25:00:00", formatMs(90_000_000L)) + assertEquals("100:00:00", formatMs(360_000_000L)) + } +}