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 )