From c8c0d0b2c26853f0b1f76825cf7d0aa0e88ca13d Mon Sep 17 00:00:00 2001
From: beyond
Date: Fri, 28 Aug 2026 02:10:56 +0800
Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E4=BC=98=E5=8C=96=E6=92=AD=E6=94=BE?=
=?UTF-8?q?=E5=99=A8=E7=95=8C=E9=9D=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../myapplication/navigation/AppNavHost.kt | 3 +
.../screens/VideoPlayerScreen.kt | 617 +++++++++++++++---
2 files changed, 546 insertions(+), 74 deletions(-)
diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
index ee43bfa..dc68a5a 100644
--- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
+++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
@@ -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,
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt
index d3f747f..61f45e1 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt
@@ -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 { 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(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
)