Files
lumaplayer/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt
T
2026-08-28 02:10:56 +08:00

1059 lines
49 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.example.myapplication.screens
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
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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
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.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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
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
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.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
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
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.colorControls
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow
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 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) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
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,
errorText: String? = null,
episodes: List<EmbyItem> = emptyList(),
currentEpisodeId: String? = null,
onSelectEpisode: (EmbyItem) -> Unit = {},
onClose: () -> Unit = {},
onPlaybackStarted: (positionMs: Long) -> Unit = {},
onPlaybackProgress: (positionMs: Long, paused: Boolean) -> Unit = { _, _ -> },
onPlaybackStopped: (positionMs: Long) -> Unit = {},
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var showSubtitleMenu by remember { mutableStateOf(false) }
var showEpisodeMenu by remember { mutableStateOf(false) }
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)
val latestOnPlaybackProgress by rememberUpdatedState(onPlaybackProgress)
val latestOnPlaybackStopped by rememberUpdatedState(onPlaybackStopped)
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) {
if (controlsVisible && isPlaying) {
delay(5000)
controlsVisible = false
}
}
LaunchedEffect(Unit) {
while (true) {
if (player.duration > 0) {
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) {
latestOnPlaybackProgress(player.currentPosition, !player.isPlaying)
}
delay(10_000)
}
}
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 prevOrientation = activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
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
if (!playbackUrl.isNullOrBlank()) {
player.setMediaItem(MediaItem.fromUri(playbackUrl), initialPositionMs.coerceAtLeast(0L))
player.prepare()
player.play()
latestOnPlaybackStarted(initialPositionMs.coerceAtLeast(0L))
}
onDispose { }
}
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
playerError = error.message ?: "播放器无法播放该资源"
}
}
player.addListener(listener)
onDispose { player.removeListener(listener) }
}
DisposableEffect(lifecycleOwner) {
var resumeAfterStop = false
val obs = LifecycleEventObserver { _, e ->
when (e) {
Lifecycle.Event.ON_STOP -> {
resumeAfterStop = player.isPlaying || player.playWhenReady
if (resumeAfterStop) player.pause()
}
Lifecycle.Event.ON_START -> {
if (resumeAfterStop && !latestPlaybackUrl.isNullOrBlank()) player.play()
}
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(obs)
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
}
DisposableEffect(Unit) {
onDispose {
if (!latestPlaybackUrl.isNullOrBlank()) {
latestOnPlaybackStopped(player.currentPosition)
}
player.release()
}
}
BackHandler(enabled = true, onBack = onClose)
CompositionLocalProvider(LocalPlayerBackdrop provides playerBackdrop) {
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 {
isLoading -> CircularProgressIndicator(color = Color.White)
errorText != null -> Text(text = errorText, color = Color.White, modifier = Modifier.padding(24.dp))
else -> Text(text = "正在准备播放...", color = Color.White)
}
}
} else {
Box(Modifier.fillMaxSize().layerBackdrop(playerBackdrop)) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).also { pv ->
pv.player = player
pv.useController = false
pv.resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
}
},
modifier = Modifier
.fillMaxSize(),
)
}
}
if (playerError != null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = playerError ?: "播放失败",
color = Color.White,
modifier = Modifier.padding(24.dp),
)
}
}
AnimatedVisibility(
visible = controlsVisible,
enter = fadeIn(),
exit = fadeOut()
) {
Box(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0f to Color.Black.copy(alpha = 0.40f),
0.10f to Color.Black.copy(alpha = 0.20f),
0.5f to Color.Black.copy(alpha = 0.0f),
0.90f to Color.Black.copy(alpha = 0.20f),
1f to Color.Black.copy(alpha = 0.40f),
)
),
)
Row(
Modifier.align(Alignment.Center),
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.CenterVertically,
) {
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 = 48.dp, iconSize = 28.dp) {
if (player.isPlaying) {
player.pause()
latestOnPlaybackProgress(player.currentPosition, true)
} else {
player.play()
latestOnPlaybackProgress(player.currentPosition, false)
}
}
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])
}
}
Column(
Modifier.fillMaxWidth().statusBarsPadding().padding(top = 8.dp)
) {
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))
}
}
}
}
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)
}
}
}
if (playbackUrl != null) {
Column(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(0.85f)
.padding(bottom = 32.dp),
) {
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) { 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)
)
}
}
Box(Modifier.width(46.dp), contentAlignment = Alignment.CenterEnd) {
Text(formatMs(totalDurationMs), color = Color.White, fontSize = 12.sp, maxLines = 1)
}
}
}
}
}
}
}
if (showSubtitleMenu) {
LiquidGlassPanel(title = "字幕", onDismiss = { showSubtitleMenu = false }) {
SubtitleMenuContent(player = player) { showSubtitleMenu = false }
}
}
if (showEpisodeMenu) {
LiquidGlassPanel(title = title.ifBlank { "剧集列表" }, onDismiss = { showEpisodeMenu = false }) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(items = episodes) { ep ->
LiquidEpisodeItem(
episode = ep,
displayIndex = ep.indexNumber ?: 0,
selected = ep.id == currentEpisodeId,
) { showEpisodeMenu = false; onSelectEpisode(ep) }
}
}
}
}
if (speedUpVisible) {
SpeedUpCapsule(
visible = true,
speed = speedUpValue,
modifier = Modifier.align(Alignment.Center).offset(y = (-60).dp),
)
}
}
}
}
@Composable
private fun LiquidSurfaceButton(
icon: ImageVector,
active: Boolean,
size: Dp = 44.dp,
iconSize: Dp = 24.dp,
onClick: () -> Unit,
) {
val bd = LocalPlayerBackdrop.current
val surfaceColor = if (active) Color.White.copy(alpha = 0.3f) else Color.White.copy(alpha = 0.1f)
Box(
Modifier
.size(size)
.clip(CircleShape)
.then(
if (bd != null) {
Modifier.drawBackdrop(
backdrop = bd,
shape = { Capsule() },
effects = { blur(12f.dp.toPx()); vibrancy() },
onDrawSurface = { drawRect(surfaceColor) },
)
} else {
Modifier.background(surfaceColor)
}
)
.border(0.5.dp, Color.White.copy(alpha = 0.3f), CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(icon, null, tint = Color.White, modifier = Modifier.size(iconSize))
}
}
@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,
onDismiss: () -> Unit,
content: @Composable () -> Unit,
) {
val bd = LocalPlayerBackdrop.current
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f))
.clickable(onClick = onDismiss),
contentAlignment = Alignment.CenterEnd,
) {
Box(
Modifier
.fillMaxHeight()
.width(320.dp)
.padding(16.dp)
.clip(RoundedCornerShape(24.dp))
.then(
if (bd != null) {
Modifier.drawBackdrop(
backdrop = bd,
shape = { RoundedRectangle(24.dp) },
effects = {
colorControls(brightness = 0f, saturation = 1.15f)
vibrancy()
blur(24f.dp.toPx())
lens(24f.dp.toPx(), 42f.dp.toPx(), depthEffect = true)
},
highlight = { Highlight.Plain },
shadow = { Shadow(alpha = 0.22f) },
onDrawSurface = { drawRect(Color.Black.copy(alpha = 0.5f)) },
)
} else {
Modifier.background(Color.Black.copy(alpha = 0.8f))
}
)
.clickable(enabled = false) {}
) {
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() }
}
}
}
}
@Composable
private fun SubtitleMenuContent(player: ExoPlayer, onDismiss: () -> Unit) {
val textTracks = remember(player.currentTracks) {
val list = mutableListOf<SubtitleTrackInfo>()
player.currentTracks.groups.forEach { group ->
if (group.type == C.TRACK_TYPE_TEXT) {
repeat(group.length) { i ->
val fmt = group.getTrackFormat(i)
val label = listOfNotNull(fmt.label, fmt.language?.uppercase()).joinToString(" - ")
list.add(SubtitleTrackInfo(group, i, label))
}
}
}
list
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
item {
LiquidMenuItem(text = "关闭字幕", selected = !player.currentTracks.groups.any { it.type == C.TRACK_TYPE_TEXT && it.isSelected }) {
player.trackSelectionParameters = player.trackSelectionParameters.buildUpon().setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true).build()
onDismiss()
}
}
items(items = textTracks) { info ->
LiquidMenuItem(text = info.label, selected = info.group.isSelected) {
player.trackSelectionParameters = player.trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.addOverride(TrackSelectionOverride(info.group.mediaTrackGroup, info.index))
.build()
onDismiss()
}
}
}
}
private data class SubtitleTrackInfo(
@androidx.annotation.OptIn(UnstableApi::class, ExperimentalMaterial3Api::class) val group: androidx.media3.common.Tracks.Group,
val index: Int,
val label: String
)
@Composable
private fun LiquidMenuItem(text: String, selected: Boolean, onClick: () -> Unit) {
val bd = LocalPlayerBackdrop.current
val shape = RoundedCornerShape(12.dp)
val surface = if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f)
Row(
Modifier
.fillMaxWidth()
.clip(shape)
.then(
if (bd != null) {
Modifier.drawBackdrop(
backdrop = bd,
shape = { RoundedRectangle(12.dp) },
effects = { vibrancy(); blur(8.dp.toPx()); lens(10.dp.toPx(), 16.dp.toPx()) },
onDrawSurface = { drawRect(surface) },
)
} else {
Modifier.background(surface)
}
)
.clickable(onClick = onClick)
.border(0.5.dp, Color.White.copy(alpha = 0.18f), shape)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text, color = Color.White, fontSize = 14.sp, modifier = Modifier.weight(1f))
if (selected) Icon(Icons.Filled.PlayArrow, null, tint = Color.White, modifier = Modifier.size(16.dp))
}
}
@Composable
private fun LiquidEpisodeItem(episode: EmbyItem, displayIndex: Int, selected: Boolean, onClick: () -> Unit) {
val bd = LocalPlayerBackdrop.current
val shape = RoundedCornerShape(12.dp)
val surface = if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f)
Row(
Modifier
.fillMaxWidth()
.clip(shape)
.then(
if (bd != null) {
Modifier.drawBackdrop(
backdrop = bd,
shape = { RoundedRectangle(12.dp) },
effects = { vibrancy(); blur(8.dp.toPx()); lens(10.dp.toPx(), 16.dp.toPx()) },
onDrawSurface = { drawRect(surface) },
)
} else {
Modifier.background(surface)
}
)
.clickable(onClick = onClick)
.border(0.5.dp, Color.White.copy(alpha = 0.18f), shape)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.size(32.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f)), contentAlignment = Alignment.Center) {
Text(displayIndex.toString(), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
Text(episode.name, color = Color.White, fontSize = 14.sp, modifier = Modifier.weight(1f).padding(start = 12.dp), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}