1、优化播放器界面

This commit is contained in:
beyond committed 2026-08-28 02:10:56 +08:00
1 parent 5df288ba9d
commit c8c0d0b2c2
2 files changed
+546 -74

No files matched your search

@@ -307,6 +307,9 @@ private fun PlayerDestination(
when {
target != null -> VideoPlayerScreen(
title = target.name,
titleLogoUrl = target.getLogoUrl(server?.fullUrl.orEmpty()).orEmpty(),
serverName = server?.name.orEmpty(),
serverIconUrl = server?.iconUrl.orEmpty(),
playbackUrl = playbackUrl,
initialPositionMs = target.playbackPositionMs,
isLoading = itemLoading || playbackLoading,
@@ -4,7 +4,11 @@ import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.pm.ActivityInfo
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.view.Surface
import android.view.Window
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@@ -12,7 +16,6 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -22,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
@@ -29,18 +33,27 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitLongPressOrCancellation
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SignalCellular4Bar
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.FastRewind
import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
@@ -51,6 +64,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -61,18 +75,35 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import kotlin.math.roundToInt
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
@@ -80,6 +111,8 @@ import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.ui.PlayerView
import com.example.myapplication.data.EmbyItem
import com.kyant.backdrop.Backdrop
@@ -96,13 +129,86 @@ import com.kyant.shapes.Capsule
import com.kyant.shapes.RoundedRectangle
import kotlinx.coroutines.delay
import top.yukonga.miuix.kmp.basic.Text
import coil.compose.AsyncImage
private fun formatMs(ms: Long): String {
val totalSec = ms / 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("%d:%02d:%02d", h, m, s)
}
private fun formatNetworkSpeed(bps: Long): String {
if (bps <= 0L) return "0 B/s"
return when {
bps >= 1_000_000 -> String.format("%.1f MB/s", bps / 1_000_000f)
bps >= 1_000 -> String.format("%.0f KB/s", bps / 1_000f)
else -> "$bps B/s"
}
}
private class SpeedMonitor {
private var lastTotalBytes = 0L
private var lastTotalLoadMs = 0L
private var activeRate = 0f // 最近一次真实吞吐(字节/秒)
private var displayed = 0f // 平滑后的展示值
private var lastActiveAt = 0L // 最近一次有真实下载的时刻
/** 由 AnalyticsListener.onBandwidthEstimate 触发。不用 bitrateEstimate(会固定/乱跳),
* 而是用其携带的真实“累计下载字节 / 累计加载耗时”做差分,得到瞬时真实吞吐。 */
fun onBandwidthEstimate(totalLoadTimeMs: Int, totalBytesLoaded: Long) {
val loadMs = totalLoadTimeMs.toLong()
if (loadMs < lastTotalLoadMs) {
// 换了新的 DataSource,累计值被重置,重新对齐
lastTotalLoadMs = loadMs
lastTotalBytes = totalBytesLoaded
return
}
val dLoad = loadMs - lastTotalLoadMs
val dBytes = totalBytesLoaded - lastTotalBytes
lastTotalLoadMs = loadMs
lastTotalBytes = totalBytesLoaded
if (dLoad > 0L && dBytes > 0L) {
activeRate = (dBytes * 1000L / dLoad).toFloat()
lastActiveAt = SystemClock.elapsedRealtime()
}
}
/** UI 每隔约 1s 读取一次,输出平滑稳定、又反映真实下载的实时网速(字节/秒)。 */
fun currentBytesPerSec(): Long {
val now = SystemClock.elapsedRealtime()
val idleMs = now - lastActiveAt
when {
idleMs > 8000L -> displayed *= 0.8f // 长时间空闲:平滑回落
idleMs < 2500L -> displayed = displayed + (activeRate - displayed) * 0.5f // 下载中:向真实速率收敛
// 2500~8000ms 的短暂停顿:保持最近值,避免闪 0
else -> {}
}
if (displayed < 1f) displayed = 0f
return displayed.toLong()
}
}
private fun videoInfoLine(videoFormat: androidx.media3.common.Format?): String {
if (videoFormat == null) return ""
val mime = videoFormat.sampleMimeType
val codecs = videoFormat.codecs
val name = when {
mime?.contains("avc") == true || codecs?.startsWith("avc1") == true -> "H.264"
mime?.contains("hevc") == true || codecs?.startsWith("hvc1") == true || codecs?.startsWith("hev1") == true -> "H.265"
mime?.contains("av01") == true -> "AV1"
mime?.contains("vp9") == true -> "VP9"
mime?.contains("vp8") == true -> "VP8"
else -> codecs ?: "未知"
}
val bitrate = if (videoFormat.bitrate > 0) {
if (videoFormat.bitrate >= 1_000_000) String.format("%.1f Mbps", videoFormat.bitrate / 1_000_000f)
else String.format("%.0f Kbps", videoFormat.bitrate / 1_000f)
} else "未知"
val fps = if (videoFormat.frameRate > 0) String.format("%.0f fps", videoFormat.frameRate) else "未知"
return "ExoPlayer · $name · $bitrate · $fps"
}
private tailrec fun Context.findActivity(): Activity? = when (this) {
@@ -114,9 +220,13 @@ private tailrec fun Context.findActivity(): Activity? = when (this) {
private val LocalPlayerBackdrop = staticCompositionLocalOf<Backdrop?> { null }
@androidx.annotation.OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VideoPlayerScreen(
title: String,
serverName: String = "",
serverIconUrl: String = "",
titleLogoUrl: String = "",
playbackUrl: String? = null,
initialPositionMs: Long = 0L,
isLoading: Boolean = false,
@@ -133,7 +243,20 @@ fun VideoPlayerScreen(
val lifecycleOwner = LocalLifecycleOwner.current
var showSubtitleMenu by remember { mutableStateOf(false) }
var showEpisodeMenu by remember { mutableStateOf(false) }
val player = remember { ExoPlayer.Builder(context).build() }
val speedMonitor = remember { SpeedMonitor() }
val loadControl = remember {
DefaultLoadControl.Builder()
// 最小缓冲 180s(=600s×30%,即播放到缓存条的 70% 时触发再次预加载)
.setBufferDurationsMs(180_000, 600_000, 3_000, 8_000)
// 最大缓存 100MB:堆内存仅 256MB,300MB 会直接 OOM 闪退
.setTargetBufferBytes(100 * 1024 * 1024)
.build()
}
val player = remember {
ExoPlayer.Builder(context)
.setLoadControl(loadControl)
.build()
}
val playerBackdrop = rememberLayerBackdrop()
val latestPlaybackUrl by rememberUpdatedState(playbackUrl)
val latestOnPlaybackStarted by rememberUpdatedState(onPlaybackStarted)
@@ -143,6 +266,25 @@ fun VideoPlayerScreen(
var progress by remember { mutableFloatStateOf(0f) }
var isPlaying by remember { mutableStateOf(false) }
var playerError by remember(playbackUrl) { mutableStateOf<String?>(null) }
var networkSpeed by remember { mutableLongStateOf(0L) }
var hasNetworkSample by remember { mutableStateOf(false) }
var topTimeText by remember { mutableStateOf("") }
var batteryPercent by remember { mutableIntStateOf(-1) }
var isCharging by remember { mutableStateOf(false) }
var isWifi by remember { mutableStateOf(false) }
var videoInfoText by remember { mutableStateOf("") }
var speedUpVisible by remember { mutableStateOf(false) }
var speedUpValue by remember { mutableFloatStateOf(2f) }
LaunchedEffect(Unit) {
while (true) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
isWifi = cm?.allNetworks?.any { cap ->
cm.getNetworkCapabilities(cap)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
} ?: false
delay(2000)
}
}
var controlsVisible by remember { mutableStateOf(true) }
LaunchedEffect(controlsVisible, isPlaying) {
@@ -158,10 +300,35 @@ fun VideoPlayerScreen(
progress = player.currentPosition.toFloat() / player.duration.toFloat()
}
isPlaying = player.isPlaying
videoInfoText = videoInfoLine(player.videoFormat)
delay(250)
}
}
LaunchedEffect(Unit) {
while (true) {
val speed = speedMonitor.currentBytesPerSec()
if (speed > 0L) hasNetworkSample = true
networkSpeed = speed
delay(900)
}
}
DisposableEffect(player) {
val listener = object : AnalyticsListener {
override fun onBandwidthEstimate(
eventTime: AnalyticsListener.EventTime,
totalLoadTimeMs: Int,
totalBytesLoaded: Long,
bitrateEstimate: Long
) {
speedMonitor.onBandwidthEstimate(totalLoadTimeMs, totalBytesLoaded)
}
}
player.addAnalyticsListener(listener)
onDispose { player.removeAnalyticsListener(listener) }
}
LaunchedEffect(player, playbackUrl) {
while (true) {
if (!playbackUrl.isNullOrBlank() && player.currentMediaItem != null) {
@@ -171,11 +338,44 @@ fun VideoPlayerScreen(
}
}
LaunchedEffect(Unit) {
var batteryManager: BatteryManager? = null
while (true) {
topTimeText = java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()).format(java.util.Date())
if (batteryManager == null) {
batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
}
batteryManager?.let {
batteryPercent = it.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
val status = it.getIntProperty(BatteryManager.BATTERY_PROPERTY_STATUS)
isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
}
delay(1000)
}
}
DisposableEffect(Unit) {
val activity = context.findActivity()
val prev = activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
val prevOrientation = activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
onDispose { activity?.requestedOrientation = prev }
var window: Window? = null
var prevBehavior = 0
activity?.window?.let { win ->
window = win
WindowCompat.setDecorFitsSystemWindows(win, false)
val controller = WindowInsetsControllerCompat(win, win.decorView)
prevBehavior = controller.systemBarsBehavior
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
}
onDispose {
window?.let { win ->
WindowCompat.setDecorFitsSystemWindows(win, true)
val controller = WindowInsetsControllerCompat(win, win.decorView)
controller.show(WindowInsetsCompat.Type.systemBars())
controller.systemBarsBehavior = prevBehavior
}
activity?.requestedOrientation = prevOrientation
}
}
DisposableEffect(playbackUrl) {
playerError = null
@@ -224,10 +424,43 @@ fun VideoPlayerScreen(
BackHandler(enabled = true, onBack = onClose)
CompositionLocalProvider(LocalPlayerBackdrop provides playerBackdrop) {
Box(Modifier.fillMaxSize().background(Color.Black).clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { controlsVisible = !controlsVisible }) {
Box(
Modifier
.fillMaxSize()
.background(Color.Black)
.pointerInput(player) {
val touchSlop = viewConfiguration.touchSlop
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val longPress = awaitLongPressOrCancellation(down.id)
if (longPress != null) {
// 长按:2倍速播放,并显示倍速胶囊
down.consume()
val wasPlaying = player.isPlaying
val prevSpeed = player.playbackParameters.speed
val targetSpeed = 2f
player.setPlaybackSpeed(targetSpeed)
if (!player.isPlaying) player.play()
speedUpVisible = true
speedUpValue = targetSpeed
try {
while (true) {
val event = awaitPointerEvent()
if (!event.changes.any { it.id == down.id && it.pressed }) break
}
} finally {
speedUpVisible = false
player.setPlaybackSpeed(prevSpeed)
if (!wasPlaying) player.pause()
}
} else {
// 快速点击(未拖动)切换控制栏;拖动交给子控件(进度条)处理
val stillDown = currentEvent.changes.any { it.id == down.id && it.pressed }
if (!stillDown) controlsVisible = !controlsVisible
}
}
}
) {
if (playbackUrl.isNullOrBlank()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when {
@@ -247,8 +480,7 @@ fun VideoPlayerScreen(
}
},
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = 0.999f },
.fillMaxSize(),
)
}
}
@@ -288,15 +520,11 @@ fun VideoPlayerScreen(
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LiquidSurfaceButton(icon = Icons.Filled.FastRewind, active = false, size = 48.dp) {
val pos = player.currentPosition - 10_000L
player.seekTo(maxOf(0L, pos))
}
LiquidSurfaceButton(icon = Icons.Filled.SkipPrevious, active = false, size = 56.dp) {
LiquidSurfaceButton(icon = Icons.Filled.SkipPrevious, active = false, size = 40.dp) {
val idx = episodes.indexOfFirst { it.id == currentEpisodeId }
if (idx > 0) onSelectEpisode(episodes[idx - 1])
}
LiquidSurfaceButton(icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, active = true, size = 72.dp, iconSize = 40.dp) {
LiquidSurfaceButton(icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, active = true, size = 48.dp, iconSize = 28.dp) {
if (player.isPlaying) {
player.pause()
latestOnPlaybackProgress(player.currentPosition, true)
@@ -305,28 +533,100 @@ fun VideoPlayerScreen(
latestOnPlaybackProgress(player.currentPosition, false)
}
}
LiquidSurfaceButton(icon = Icons.Filled.SkipNext, active = false, size = 56.dp) {
LiquidSurfaceButton(icon = Icons.Filled.SkipNext, active = false, size = 40.dp) {
val idx = episodes.indexOfFirst { it.id == currentEpisodeId }
if (idx >= 0 && idx < episodes.lastIndex) onSelectEpisode(episodes[idx + 1])
}
LiquidSurfaceButton(icon = Icons.Filled.FastForward, active = false, size = 48.dp) {
val pos = player.currentPosition + 10_000L
player.seekTo(minOf(pos, player.duration))
}
}
Row(
Modifier.fillMaxWidth().statusBarsPadding().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
Column(
Modifier.fillMaxWidth().statusBarsPadding().padding(top = 8.dp)
) {
Text(title, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
LiquidSurfaceButton(icon = Icons.Filled.Subtitles, active = showSubtitleMenu, size = 40.dp) {
showSubtitleMenu = !showSubtitleMenu; showEpisodeMenu = false
Row(
Modifier.fillMaxWidth(0.85f).align(Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(topTimeText, color = Color.White, fontSize = 14.sp)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
if (playbackUrl != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (isWifi) Icons.Filled.Wifi else Icons.Filled.SignalCellular4Bar,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
if (hasNetworkSample) Text(formatNetworkSpeed(networkSpeed), color = Color.White, fontSize = 14.sp)
}
}
if (batteryPercent >= 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("$batteryPercent%", color = Color.White, fontSize = 14.sp)
Spacer(Modifier.width(4.dp))
BatteryIcon(level = batteryPercent, charging = isCharging, modifier = Modifier.size(22.dp, 12.dp))
}
}
}
LiquidSurfaceButton(icon = Icons.Filled.List, active = showEpisodeMenu, size = 40.dp) {
showEpisodeMenu = !showEpisodeMenu; showSubtitleMenu = false
}
Spacer(Modifier.height(10.dp))
Row(
Modifier.fillMaxWidth(0.85f).align(Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
LiquidSurfaceButton(icon = Icons.Filled.Close, active = false, size = 40.dp) { onClose() }
Column(
Modifier.weight(1f).padding(horizontal = 12.dp),
verticalArrangement = Arrangement.Center
) {
if (titleLogoUrl.isNotBlank()) {
AsyncImage(
model = titleLogoUrl,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.height(28.dp).widthIn(max = 220.dp)
)
} else {
Text(
title,
color = Color.White,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
LiquidSurfaceButton(icon = Icons.Filled.Subtitles, active = showSubtitleMenu, size = 40.dp) {
showSubtitleMenu = !showSubtitleMenu; showEpisodeMenu = false
}
LiquidSurfaceButton(icon = Icons.Filled.List, active = showEpisodeMenu, size = 40.dp) {
showEpisodeMenu = !showEpisodeMenu; showSubtitleMenu = false
}
}
}
if (serverName.isNotBlank()) {
Row(
Modifier.fillMaxWidth(0.85f).align(Alignment.CenterHorizontally).padding(start = 52.dp, top = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.size(20.dp).clip(CircleShape), contentAlignment = Alignment.Center) {
if (serverIconUrl.isNotBlank()) {
AsyncImage(
model = serverIconUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
} else {
Icon(Icons.Outlined.Storage, null, tint = Color.White, modifier = Modifier.size(16.dp))
}
}
Spacer(Modifier.width(6.dp))
Text(serverName, color = Color.White.copy(alpha = 0.9f), fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
@@ -336,44 +636,116 @@ fun VideoPlayerScreen(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(0.85f)
.padding(bottom = 32.dp)
.drawBackdrop(
backdrop = playerBackdrop,
shape = { Capsule() },
effects = { blur(16f.dp.toPx()); vibrancy() },
onDrawSurface = { drawRect(Color.Black.copy(alpha = 0.4f)) },
)
.padding(horizontal = 20.dp, vertical = 12.dp),
.padding(bottom = 32.dp),
) {
var totalDurationMs by remember { mutableLongStateOf(0L) }
var currentPositionMs by remember { mutableLongStateOf(0L) }
var isSeeking by remember { mutableStateOf(false) }
var seekPosition by remember { mutableFloatStateOf(0f) }
if (videoInfoText.isNotBlank()) {
Text(videoInfoText, color = Color.White, fontSize = 10.sp, modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 4.dp))
}
Column(
Modifier
.fillMaxWidth()
.drawBackdrop(
backdrop = playerBackdrop,
shape = { Capsule() },
effects = { blur(16f.dp.toPx()); vibrancy() },
onDrawSurface = { drawRect(Color.Black.copy(alpha = 0.4f)) },
)
.padding(horizontal = 20.dp, vertical = 8.dp),
) {
var totalDurationMs by remember { mutableLongStateOf(0L) }
var currentPositionMs by remember { mutableLongStateOf(0L) }
var isSeeking by remember { mutableStateOf(false) }
var seekPosition by remember { mutableFloatStateOf(0f) }
var bufferProgress by remember { mutableFloatStateOf(0f) }
var trackWidthPx by remember { mutableFloatStateOf(0f) }
LaunchedEffect(player) {
while (true) {
if (player.duration > 0) {
totalDurationMs = player.duration
if (!isSeeking) {
currentPositionMs = player.currentPosition
progress = player.currentPosition.toFloat() / player.duration.toFloat()
LaunchedEffect(player) {
while (true) {
if (player.duration > 0) {
totalDurationMs = player.duration
if (!isSeeking) { val b = (player.bufferedPosition.toFloat() / player.duration).coerceIn(0f, 1f); bufferProgress = maxOf(b, progress) }
if (!isSeeking) {
currentPositionMs = player.currentPosition
progress = player.currentPosition.toFloat() / player.duration.toFloat()
}
}
delay(250)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.width(46.dp), contentAlignment = Alignment.CenterStart) {
Text(formatMs(currentPositionMs), color = Color.White, fontSize = 12.sp, maxLines = 1)
}
Box(
Modifier
.weight(1f)
.padding(horizontal = 4.dp)
.height(20.dp)
.pointerInput(player) {
awaitEachGesture {
val down = awaitFirstDown()
isSeeking = true
val startF = (down.position.x / size.width.toFloat()).coerceIn(0f, 1f)
seekPosition = startF
currentPositionMs = (startF * totalDurationMs).toLong()
drag(down.id) { change ->
change.consume()
val f = (change.position.x / size.width.toFloat()).coerceIn(0f, 1f)
seekPosition = f
currentPositionMs = (f * totalDurationMs).toLong()
}
player.seekTo((seekPosition * player.duration).toLong())
progress = seekPosition
bufferProgress = seekPosition
isSeeking = false
}
}
.onSizeChanged { trackWidthPx = it.width.toFloat() }
) {
Box(
Modifier.align(Alignment.Center).fillMaxWidth().height(6.dp)
.clip(RoundedCornerShape(50))
.background(Color.White.copy(alpha = 0.18f))
.border(1.dp, Color.White.copy(alpha = 0.55f), RoundedCornerShape(50))
) {
Box(
Modifier.align(Alignment.CenterStart).fillMaxWidth(1f).height(6.dp)
.clip(RoundedCornerShape(50))
.background(Color.White.copy(alpha = 0.25f))
)
Box(
Modifier.align(Alignment.CenterStart)
.fillMaxWidth(bufferProgress).height(6.dp)
.clip(RoundedCornerShape(50))
.background(Color.White.copy(alpha = 0.55f))
)
Box(
Modifier.align(Alignment.CenterStart)
.fillMaxWidth(if (isSeeking) seekPosition else progress).height(6.dp)
.clip(RoundedCornerShape(50))
.background(Color.White)
)
}
if (isSeeking) {
val dotSize = 12.dp
val dotSizePx = with(LocalDensity.current) { dotSize.toPx() }
Box(
Modifier
.align(Alignment.CenterStart)
.offset { IntOffset((seekPosition * trackWidthPx - dotSizePx / 2).roundToInt(), 0) }
.size(dotSize)
.clip(CircleShape)
.background(Color.White)
.border(2.dp, Color.Black.copy(alpha = 0.45f), CircleShape)
)
}
}
delay(250)
Box(Modifier.width(46.dp), contentAlignment = Alignment.CenterEnd) {
Text(formatMs(totalDurationMs), color = Color.White, fontSize = 12.sp, maxLines = 1)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(formatMs(currentPositionMs), color = Color.White, fontSize = 12.sp)
Slider(
value = if (isSeeking) seekPosition else progress,
onValueChange = { v -> isSeeking = true; seekPosition = v },
onValueChangeFinished = { player.seekTo((seekPosition * player.duration).toLong()); isSeeking = false },
modifier = Modifier.weight(1f).padding(horizontal = 12.dp),
colors = SliderDefaults.colors(thumbColor = Color.White, activeTrackColor = Color.White)
)
Text(formatMs(totalDurationMs), color = Color.White, fontSize = 12.sp)
}
}
}
}
@@ -397,6 +769,13 @@ fun VideoPlayerScreen(
}
}
}
if (speedUpVisible) {
SpeedUpCapsule(
visible = true,
speed = speedUpValue,
modifier = Modifier.align(Alignment.Center).offset(y = (-60).dp),
)
}
}
}
}
@@ -436,6 +815,94 @@ private fun LiquidSurfaceButton(
}
}
@Composable
private fun BatteryIcon(level: Int, charging: Boolean, modifier: Modifier = Modifier) {
val fillColor = when {
level <= 20 -> Color(0xFFFF3B30)
level <= 50 -> Color(0xFFFFC107)
else -> Color(0xFF34C759)
}
Canvas(modifier) {
val w = size.width
val h = size.height
val nubW = h * 0.30f
val bodyRight = w - nubW
val bodyLeft = 0f
val corner = h * 0.22f
val outlineColor = Color.White.copy(alpha = 0.55f)
drawRoundRect(
color = outlineColor,
topLeft = Offset(bodyLeft, h * 0.06f),
size = Size(bodyRight - bodyLeft, h * 0.88f),
cornerRadius = CornerRadius(corner, corner),
style = Stroke(width = h * 0.09f)
)
drawRoundRect(
color = outlineColor,
topLeft = Offset(bodyRight + h * 0.02f, h * 0.30f),
size = Size(nubW - h * 0.02f, h * 0.40f),
cornerRadius = CornerRadius(h * 0.06f, h * 0.06f)
)
val inset = h * 0.13f
val frac = (level.coerceIn(0, 100)) / 100f
val fillWidth = ((bodyRight - bodyLeft) - inset * 2f) * frac
if (fillWidth > 0f) {
drawRoundRect(
color = fillColor,
topLeft = Offset(bodyLeft + inset, h * 0.20f),
size = Size(fillWidth, h * 0.60f),
cornerRadius = CornerRadius(h * 0.10f, h * 0.10f)
)
}
if (charging) {
val cx = (bodyLeft + bodyRight) / 2f
val cy = h / 2f
val b2 = h * 0.55f
val bolt = Path().apply {
moveTo(cx, cy - 0.80f * b2)
lineTo(cx - 0.55f * b2, cy + 0.15f * b2)
lineTo(cx - 0.12f * b2, cy + 0.15f * b2)
lineTo(cx - 0.12f * b2, cy + 0.80f * b2)
lineTo(cx + 0.55f * b2, cy - 0.20f * b2)
lineTo(cx + 0.12f * b2, cy - 0.20f * b2)
close()
}
drawPath(bolt, color = Color.White)
}
}
}
@Composable
private fun SpeedUpCapsule(visible: Boolean, speed: Float, modifier: Modifier = Modifier) {
if (!visible) return
val bd = LocalPlayerBackdrop.current
val label = if (speed % 1f == 0f) speed.toInt().toString() else speed.toString()
Box(
modifier
.clip(RoundedCornerShape(50))
.then(
if (bd != null) {
Modifier.drawBackdrop(
backdrop = bd,
shape = { Capsule() },
effects = { blur(16f.dp.toPx()); vibrancy() },
onDrawSurface = { drawRect(Color.Black.copy(alpha = 0.5f)) },
)
} else {
Modifier.background(Color.Black.copy(alpha = 0.5f))
}
)
.border(0.5.dp, Color.White.copy(alpha = 0.25f), RoundedCornerShape(50))
.padding(horizontal = 14.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Icon(Icons.Filled.FastForward, null, tint = Color.White, modifier = Modifier.size(18.dp))
Text("x$label", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
}
}
@Composable
private fun LiquidGlassPanel(
title: String,
@@ -450,11 +917,12 @@ private fun LiquidGlassPanel(
.clickable(onClick = onDismiss),
contentAlignment = Alignment.CenterEnd,
) {
Column(
Box(
Modifier
.fillMaxHeight()
.width(320.dp)
.clip(RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp))
.padding(16.dp)
.clip(RoundedCornerShape(24.dp))
.then(
if (bd != null) {
Modifier.drawBackdrop(
@@ -475,11 +943,12 @@ private fun LiquidGlassPanel(
}
)
.clickable(enabled = false) {}
.padding(20.dp),
) {
Text(title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(16.dp))
Box(Modifier.weight(1f)) { content() }
Column(Modifier.fillMaxSize().padding(20.dp)) {
Text(title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(16.dp))
Box(Modifier.weight(1f)) { content() }
}
}
}
}
@@ -519,7 +988,7 @@ private fun SubtitleMenuContent(player: ExoPlayer, onDismiss: () -> Unit) {
}
private data class SubtitleTrackInfo(
@androidx.annotation.OptIn(UnstableApi::class) val group: androidx.media3.common.Tracks.Group,
@androidx.annotation.OptIn(UnstableApi::class, ExperimentalMaterial3Api::class) val group: androidx.media3.common.Tracks.Group,
val index: Int,
val label: String
)