1、优化播放器界面
This commit is contained in:
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
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user