1059 lines
49 KiB
Kotlin
1059 lines
49 KiB
Kotlin
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)
|
||
}
|
||
}
|