优化播放器界面
This commit is contained in:
1 parent
29601803f5
commit
e3e8120e8b
3 files changed
+69
-30
No files matched your search
@@ -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<String?>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user