Files
lumaplayer/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt
T
beyond 3670466a65 fix: 第一轮性能优化 — R8启用、协程泄漏修复、@Immutable注解、状态提取、并行化
- Release build 启用 R8/ProGuard + 新建 proguard-rules.pro
- MediaDetailScreen: MainScope → rememberCoroutineScope
- AppNavHost: openDetail/openHome lambda 记忆化
- VideoPlayerScreen: 提取 PlayerStatusBar 独立 Composable
- LibraryScreen: nowMillis 下沉到 ServerStatusLine 内部
- 8个核心数据类添加 @Immutable 注解
- TmdbCalendar: coroutineScope+async 并行化 season 请求
- SearchScreen: derivedStateOf 正确观察 SnapshotStateList
- HomeScreen: MediaPoster/ContinueWatchingPoster ImageRequest remember
- MediaDetailScreen: cast/recommendations/resources LazyRow 添加 key
- RecordsScreen: LaunchedEffect key 用 hashCode 替代 joinToString
2026-09-06 13:30:56 +08:00

1928 lines
88 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.example.myapplication.screens
import android.app.Activity
import android.media.AudioManager
import android.content.Context
import android.content.ContextWrapper
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.provider.Settings
import android.view.Surface
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
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.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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
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.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.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
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 androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
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 android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.SystemClock
import android.os.BatteryManager
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.TransferListener
import androidx.media3.ui.PlayerView
import com.example.myapplication.data.EmbyItem
import com.example.myapplication.data.EmbyChapter
import com.example.myapplication.data.EmbyRepository
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
import com.example.myapplication.R as Res
import com.example.myapplication.ui.design.LumaIcon
import com.example.myapplication.ui.design.LumaIconView
import com.example.myapplication.ui.design.LumaLoading
import com.example.myapplication.ui.design.LumaAsyncImage
import com.example.myapplication.ui.design.LumaSeekBar
import com.example.myapplication.ui.design.LumaGlassLevel
import com.example.myapplication.ui.design.lumaGlass
import com.example.myapplication.ui.design.LumaShapes
import com.example.myapplication.ui.LocalGlassBackdrop
import kotlinx.coroutines.delay
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import top.yukonga.miuix.kmp.basic.Text
import kotlin.math.abs
import kotlin.math.roundToInt
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)
}
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 fun formatVideoCodec(mimeType: String?): String = when (mimeType) {
"video/avc" -> "H.264"
"video/hevc" -> "H.265"
"video/av01" -> "AV1"
"video/x-vnd.on2.vp9" -> "VP9"
"video/x-vnd.on2.vp8" -> "VP8"
"video/mp4v-es" -> "MPEG-4"
"video/mpeg2" -> "MPEG-2"
"video/3gpp" -> "H.263"
"video/x-ms-wmv" -> "WMV"
else -> mimeType?.substringAfterLast('/') ?: ""
}
private fun formatBitrate(bps: Int): String = when {
bps >= 1_000_000 -> String.format("%.1f Mbps", bps / 1_000_000f)
bps >= 1_000 -> String.format("%.0f Kbps", bps / 1_000f)
else -> "$bps bps"
}
internal class SpeedMonitor(
private val nowMs: () -> Long = SystemClock::elapsedRealtime,
) {
private var bytesSinceLastSample = 0L
private var lastSampleAt = nowMs()
private var displayedRate = 0f
@Synchronized
fun onBytesTransferred(bytes: Int) {
if (bytes > 0) bytesSinceLastSample += bytes
}
// 按真实传输字节的墙钟时间增量计算,缓冲停止下载时立即回到 0。
@Synchronized
fun currentBytesPerSec(): Long {
val now = nowMs()
val elapsedMs = (now - lastSampleAt).coerceAtLeast(1L)
val sampledRate = bytesSinceLastSample * 1000f / elapsedMs
bytesSinceLastSample = 0L
lastSampleAt = now
displayedRate = when {
sampledRate <= 0f -> 0f
displayedRate <= 0f -> sampledRate
else -> displayedRate + (sampledRate - displayedRate) * 0.45f
}
return displayedRate.toLong()
}
}
internal fun calculateSwipeSeekPosition(
startPositionMs: Long,
durationMs: Long,
horizontalDragPx: Float,
viewportWidthPx: Float,
): Long {
if (durationMs <= 0L || viewportWidthPx <= 0f) return startPositionMs.coerceAtLeast(0L)
val seekWindowMs = minOf(durationMs, 120_000L)
val offsetMs = (horizontalDragPx / viewportWidthPx * seekWindowMs).toLong()
return (startPositionMs + offsetMs).coerceIn(0L, durationMs)
}
internal enum class PlayerDoubleTapAction {
SeekBackward,
TogglePlayback,
SeekForward,
}
internal fun resolveDoubleTapAction(horizontalPositionPx: Float, viewportWidthPx: Float): PlayerDoubleTapAction = when {
horizontalPositionPx < viewportWidthPx / 3f -> PlayerDoubleTapAction.SeekBackward
horizontalPositionPx > viewportWidthPx * 2f / 3f -> PlayerDoubleTapAction.SeekForward
else -> PlayerDoubleTapAction.TogglePlayback
}
internal fun calculateTenSecondSeekPosition(
currentPositionMs: Long,
durationMs: Long,
forward: Boolean,
): Long {
val targetPositionMs = currentPositionMs + if (forward) 10_000L else -10_000L
return if (durationMs > 0L) targetPositionMs.coerceIn(0L, durationMs) else targetPositionMs.coerceAtLeast(0L)
}
internal fun findIntroEndPositionMs(chapters: List<EmbyChapter>): Long? = chapters
.firstOrNull { chapter ->
chapter.markerType.equals("IntroEnd", ignoreCase = true) ||
chapter.name.orEmpty().contains("片头结束") ||
chapter.name.orEmpty().contains("intro end", ignoreCase = true)
}
?.startPositionMs
internal fun findCreditsStartPositionMs(chapters: List<EmbyChapter>): Long? = chapters
.firstOrNull { chapter ->
chapter.markerType.equals("CreditsStart", ignoreCase = true) ||
chapter.name.orEmpty().contains("片尾") ||
chapter.name.orEmpty().contains("credits", ignoreCase = true)
}
?.startPositionMs
private enum class PlayerLevelGesture {
Brightness,
Volume,
}
private enum class PlayerDoubleTapFeedback {
SeekBackward,
Pause,
Play,
SeekForward,
}
private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
@androidx.annotation.OptIn(UnstableApi::class)
@Composable
fun VideoPlayerScreen(
title: String,
titleLogoUrl: String = "",
serverName: String = "",
serverIconUrl: String = "",
serverUrl: String = "",
playbackUrl: String? = null,
initialPositionMs: Long = 0L,
isLoading: Boolean = false,
errorText: String? = null,
episodes: List<EmbyItem> = emptyList(),
chapters: List<EmbyChapter> = emptyList(),
currentEpisodeId: String? = null,
onSelectEpisode: (EmbyItem) -> Unit = {},
onClose: () -> Unit = {},
onPlaybackStarted: (positionMs: Long) -> Unit = {},
onPlaybackProgress: (positionMs: Long, paused: Boolean, eventName: String, force: Boolean) -> Unit = { _, _, _, _ -> },
onPlaybackStopped: (positionMs: Long) -> Unit = {},
) {
val context = LocalContext.current
val configuration = LocalConfiguration.current
val cannotPlayMessage = stringResource(Res.string.player_cannot_play)
val lifecycleOwner = LocalLifecycleOwner.current
var showSubtitleMenu by remember { mutableStateOf(false) }
var showEpisodeMenu by remember { mutableStateOf(false) }
var showAudioMenu by remember { mutableStateOf(false) }
var showPlaybackToolsMenu by remember { mutableStateOf(false) }
var showNextEpisodeConfirmation by remember { mutableStateOf(false) }
var confirmBeforeContinuousPlayback by remember { mutableStateOf(true) }
var audioDisabledByUser by remember { mutableStateOf(false) }
val speedMonitor = remember { SpeedMonitor() }
val transferListener = remember {
object : TransferListener {
override fun onTransferInitializing(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) = Unit
override fun onTransferStart(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) = Unit
override fun onBytesTransferred(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean, bytesTransferred: Int) {
if (isNetwork) speedMonitor.onBytesTransferred(bytesTransferred)
}
override fun onTransferEnd(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) = Unit
}
}
val mediaSourceFactory = remember {
DefaultMediaSourceFactory(DefaultDataSource.Factory(context).setTransferListener(transferListener))
}
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)
.setMediaSourceFactory(mediaSourceFactory)
.build()
}
val activity = context.findActivity()
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
val maxMusicVolume = remember(audioManager) { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) }
val latestPlaybackUrl by rememberUpdatedState(playbackUrl)
val latestOnPlaybackStarted by rememberUpdatedState(onPlaybackStarted)
val latestOnPlaybackProgress by rememberUpdatedState(onPlaybackProgress)
val latestOnPlaybackStopped by rememberUpdatedState(onPlaybackStopped)
val latestEpisodes by rememberUpdatedState(episodes)
val latestCurrentEpisodeId by rememberUpdatedState(currentEpisodeId)
val latestOnSelectEpisode by rememberUpdatedState(onSelectEpisode)
val latestConfirmBeforeContinuousPlayback by rememberUpdatedState(confirmBeforeContinuousPlayback)
val latestAudioDisabledByUser by rememberUpdatedState(audioDisabledByUser)
var stopReported by remember(playbackUrl) { mutableStateOf(false) }
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 batteryPercent by remember { mutableIntStateOf(-1) }
var isCharging by remember { mutableStateOf(false) }
var isWifi by remember { mutableStateOf(false) }
var videoCodecText by remember { mutableStateOf("") }
var videoBitrateText by remember { mutableStateOf("") }
var videoFrameRateText by remember { mutableStateOf("") }
var speedUpVisible by remember { mutableStateOf(false) }
var speedUpValue by remember { mutableFloatStateOf(2f) }
var levelGesture by remember { mutableStateOf<PlayerLevelGesture?>(null) }
var levelGestureValue by remember { mutableFloatStateOf(0f) }
var seekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
var seekPreviewDeltaMs by remember { mutableLongStateOf(0L) }
var isScreenLocked by remember { mutableStateOf(false) }
var doubleTapFeedback by remember { mutableStateOf<PlayerDoubleTapFeedback?>(null) }
var doubleTapFeedbackSequence by remember { mutableIntStateOf(0) }
var brightnessLevel by remember {
val windowLevel = activity?.window?.attributes?.screenBrightness?.takeIf { it >= 0f }
val systemLevel = Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_BRIGHTNESS, 128) / 255f
mutableFloatStateOf((windowLevel ?: systemLevel).coerceIn(0.01f, 1f))
}
var volumeLevel by remember {
mutableFloatStateOf(
audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxMusicVolume
)
}
var controlsVisible by remember { mutableStateOf(true) }
var controlsInteractionLocked by remember { mutableStateOf(false) }
LaunchedEffect(
controlsVisible,
isPlaying,
controlsInteractionLocked,
showSubtitleMenu,
showEpisodeMenu,
showAudioMenu,
showPlaybackToolsMenu,
showNextEpisodeConfirmation,
) {
val menuVisible = showSubtitleMenu || showEpisodeMenu || showAudioMenu ||
showPlaybackToolsMenu || showNextEpisodeConfirmation
if (controlsVisible && isPlaying && !controlsInteractionLocked && !menuVisible) {
delay(5000)
controlsVisible = false
}
}
LaunchedEffect(doubleTapFeedbackSequence) {
if (doubleTapFeedbackSequence > 0) {
delay(800)
doubleTapFeedback = null
}
}
LaunchedEffect(Unit) {
while (true) {
if (player.duration > 0 && seekPreviewPositionMs == null) {
progress = player.currentPosition.toFloat() / player.duration.toFloat()
}
isPlaying = player.isPlaying
delay(250)
}
}
LaunchedEffect(Unit) {
while (true) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
isWifi = cm?.activeNetwork?.let { net ->
cm.getNetworkCapabilities(net)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
} ?: false
delay(2000)
}
}
LaunchedEffect(Unit) {
var batteryManager: BatteryManager? = null
while (true) {
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)
}
}
LaunchedEffect(player) {
while (true) {
val vf = player.videoFormat
if (vf != null) {
videoCodecText = formatVideoCodec(vf.sampleMimeType)
videoBitrateText = formatBitrate(vf.bitrate)
videoFrameRateText = if (vf.frameRate > 0f) String.format("%.0f fps", vf.frameRate) else ""
}
delay(1000)
}
}
LaunchedEffect(Unit) {
while (true) {
val speed = speedMonitor.currentBytesPerSec()
networkSpeed = speed
delay(1000)
}
}
LaunchedEffect(player, playbackUrl) {
while (true) {
if (!playbackUrl.isNullOrBlank() && player.currentMediaItem != null && player.isPlaying) {
latestOnPlaybackProgress(player.currentPosition, false, "TimeUpdate", false)
}
delay(10_000)
}
}
DisposableEffect(Unit) {
val prev = activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
val window = activity?.window
val prevBrightness = window?.attributes?.screenBrightness
val insetsController = window?.let { WindowCompat.getInsetsController(it, it.decorView) }
val prevBehavior = insetsController?.systemBarsBehavior
insetsController?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
insetsController?.hide(WindowInsetsCompat.Type.systemBars())
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
onDispose {
insetsController?.let { controller ->
controller.show(WindowInsetsCompat.Type.systemBars())
controller.systemBarsBehavior = prevBehavior ?: WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
}
if (window != null && prevBrightness != null) {
val attributes = window.attributes
attributes.screenBrightness = prevBrightness
window.attributes = attributes
}
activity?.requestedOrientation = prev
}
}
DisposableEffect(playbackUrl) {
playerError = null
showNextEpisodeConfirmation = false
if (!playbackUrl.isNullOrBlank()) {
player.setMediaItem(MediaItem.fromUri(playbackUrl), initialPositionMs.coerceAtLeast(0L))
player.prepare()
player.play()
latestOnPlaybackStarted(initialPositionMs.coerceAtLeast(0L))
}
onDispose {
if (!playbackUrl.isNullOrBlank() && !stopReported) {
stopReported = true
latestOnPlaybackStopped(player.currentPosition)
}
}
}
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
playerError = cannotPlayMessage
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (!isPlaying && player.playWhenReady) return
if (!latestPlaybackUrl.isNullOrBlank()) {
latestOnPlaybackProgress(
player.currentPosition,
!isPlaying,
if (isPlaying) "Unpause" else "Pause",
true,
)
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState != Player.STATE_ENDED) return
val currentIndex = latestEpisodes.indexOfFirst { it.id == latestCurrentEpisodeId }
val nextEpisode = latestEpisodes.getOrNull(currentIndex + 1) ?: return
if (latestConfirmBeforeContinuousPlayback) {
controlsVisible = true
showSubtitleMenu = false
showEpisodeMenu = false
showAudioMenu = false
showPlaybackToolsMenu = false
showNextEpisodeConfirmation = true
} else {
latestOnSelectEpisode(nextEpisode)
}
}
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
if (latestAudioDisabledByUser || tracks.groups.any { it.type == C.TRACK_TYPE_AUDIO && it.isSelected }) return
val audioGroup = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_AUDIO && it.length > 0 } ?: return
val trackIndex = 0
player.trackSelectionParameters = player.trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
.addOverride(TrackSelectionOverride(audioGroup.mediaTrackGroup, trackIndex))
.build()
latestOnPlaybackProgress(player.currentPosition, !player.isPlaying, "AudioTrackChange", true)
}
}
player.addListener(listener)
onDispose {
player.removeListener(listener)
player.release()
}
}
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) }
}
BackHandler(enabled = true) {
when {
showSubtitleMenu || showEpisodeMenu || showAudioMenu || showPlaybackToolsMenu || showNextEpisodeConfirmation -> {
showSubtitleMenu = false
showEpisodeMenu = false
showAudioMenu = false
showPlaybackToolsMenu = false
showNextEpisodeConfirmation = false
}
isScreenLocked -> {
isScreenLocked = false
controlsVisible = true
}
else -> onClose()
}
}
Box(
Modifier
.fillMaxSize()
.background(Color.Black),
) {
Box(Modifier.fillMaxSize()) {
if (playbackUrl.isNullOrBlank()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when {
isLoading -> LumaLoading(label = stringResource(Res.string.player_preparing))
errorText != null -> Text(text = errorText, color = Color.White, modifier = Modifier.padding(24.dp))
else -> Text(text = stringResource(Res.string.player_preparing), color = Color.White)
}
}
} else {
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 ?: stringResource(Res.string.player_failed),
color = Color.White,
modifier = Modifier.padding(24.dp),
)
}
}
}
Box(
Modifier
.fillMaxWidth()
.fillMaxHeight(0.70f)
.align(Alignment.TopCenter)
.pointerInput(player, activity, audioManager, isScreenLocked) {
val pointerCoroutineScope = CoroutineScope(currentCoroutineContext())
var lastTapAtMs = 0L
var lastTapAction: PlayerDoubleTapAction? = null
var pendingSingleTapJob: Job? = null
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = true)
val startPosition = down.position
val downAction = resolveDoubleTapAction(startPosition.x, size.width.toFloat())
if (downAction == lastTapAction &&
SystemClock.elapsedRealtime() - lastTapAtMs <= viewConfiguration.doubleTapTimeoutMillis
) {
pendingSingleTapJob?.cancel()
}
if (isScreenLocked) {
var change = down
while (change.pressed) {
val event = awaitPointerEvent()
change = event.changes.firstOrNull { it.id == down.id } ?: break
}
controlsVisible = true
return@awaitEachGesture
}
val firstChange: PointerInputChange? = withTimeoutOrNull<PointerInputChange>(viewConfiguration.longPressTimeoutMillis) {
var detected: PointerInputChange? = null
while (detected == null) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: continue
val dx = change.position.x - startPosition.x
val dy = change.position.y - startPosition.y
if (!change.pressed || change.isConsumed || abs(dx) > viewConfiguration.touchSlop || abs(dy) > viewConfiguration.touchSlop) {
detected = change
}
}
checkNotNull(detected)
}
val current = firstChange ?: currentEvent.changes.firstOrNull { it.id == down.id }
if (firstChange == null && current?.pressed == true && !current.isConsumed) {
// 长按:2倍速播放,并显示倍速胶囊
current.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
controlsInteractionLocked = true
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()
controlsInteractionLocked = false
}
} else if (current == null || !current.pressed) {
if (current?.isConsumed != true) {
val nowMs = SystemClock.elapsedRealtime()
val tapAction = resolveDoubleTapAction(startPosition.x, size.width.toFloat())
val isDoubleTap = tapAction == lastTapAction &&
nowMs - lastTapAtMs <= viewConfiguration.doubleTapTimeoutMillis
if (isDoubleTap) {
pendingSingleTapJob?.cancel()
when (tapAction) {
PlayerDoubleTapAction.SeekBackward -> {
val targetPosition = calculateTenSecondSeekPosition(
currentPositionMs = player.currentPosition,
durationMs = player.duration,
forward = false,
)
player.seekTo(targetPosition)
if (player.duration > 0L) progress = targetPosition.toFloat() / player.duration
latestOnPlaybackProgress(targetPosition, !player.isPlaying, "TimeUpdate", true)
doubleTapFeedback = PlayerDoubleTapFeedback.SeekBackward
}
PlayerDoubleTapAction.SeekForward -> {
val durationMs = player.duration
val targetPosition = calculateTenSecondSeekPosition(
currentPositionMs = player.currentPosition,
durationMs = durationMs,
forward = true,
)
player.seekTo(targetPosition)
if (durationMs > 0L) progress = targetPosition.toFloat() / durationMs
latestOnPlaybackProgress(targetPosition, !player.isPlaying, "TimeUpdate", true)
doubleTapFeedback = PlayerDoubleTapFeedback.SeekForward
}
PlayerDoubleTapAction.TogglePlayback -> if (player.isPlaying) {
player.pause()
doubleTapFeedback = PlayerDoubleTapFeedback.Pause
} else {
player.play()
doubleTapFeedback = PlayerDoubleTapFeedback.Play
}
}
doubleTapFeedbackSequence++
lastTapAtMs = 0L
lastTapAction = null
} else {
lastTapAtMs = nowMs
lastTapAction = tapAction
pendingSingleTapJob?.cancel()
pendingSingleTapJob = pointerCoroutineScope.launch {
delay(viewConfiguration.doubleTapTimeoutMillis)
controlsVisible = !controlsVisible
if (lastTapAtMs == nowMs) {
lastTapAtMs = 0L
lastTapAction = null
}
}
}
}
} else if (!current.isConsumed) {
val activeChange = checkNotNull(current)
val dx = activeChange.position.x - startPosition.x
val dy = activeChange.position.y - startPosition.y
if (abs(dy) > abs(dx)) {
val kind = if (startPosition.x < size.width / 2f) {
PlayerLevelGesture.Brightness
} else {
PlayerLevelGesture.Volume
}
val initialLevel = if (kind == PlayerLevelGesture.Brightness) {
brightnessLevel
} else {
audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxMusicVolume
}
levelGesture = kind
controlsInteractionLocked = true
var change = activeChange
try {
while (change.pressed) {
val level = (initialLevel + (startPosition.y - change.position.y) / size.height * 1.5f)
.coerceIn(if (kind == PlayerLevelGesture.Brightness) 0.01f else 0f, 1f)
levelGestureValue = level
if (kind == PlayerLevelGesture.Brightness) {
brightnessLevel = level
activity?.window?.let { window ->
val attributes = window.attributes
attributes.screenBrightness = level
window.attributes = attributes
}
} else {
volumeLevel = level
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
(level * maxMusicVolume).roundToInt(),
0,
)
}
change.consume()
val event = awaitPointerEvent()
change = event.changes.firstOrNull { it.id == down.id } ?: break
}
} finally {
levelGesture = null
controlsInteractionLocked = false
}
} else {
val durationMs = player.duration.takeIf { it > 0L } ?: 0L
val initialPositionMs = player.currentPosition.coerceAtLeast(0L)
var targetPositionMs = initialPositionMs
var change = activeChange
controlsInteractionLocked = true
controlsVisible = true
try {
while (change.pressed) {
targetPositionMs = calculateSwipeSeekPosition(
startPositionMs = initialPositionMs,
durationMs = durationMs,
horizontalDragPx = change.position.x - startPosition.x,
viewportWidthPx = size.width.toFloat(),
)
seekPreviewPositionMs = targetPositionMs
seekPreviewDeltaMs = targetPositionMs - initialPositionMs
change.consume()
val event = awaitPointerEvent()
change = event.changes.firstOrNull { it.id == down.id } ?: break
}
if (durationMs > 0L) {
player.seekTo(targetPositionMs)
progress = targetPositionMs.toFloat() / durationMs
latestOnPlaybackProgress(targetPositionMs, !player.isPlaying, "TimeUpdate", true)
}
} finally {
seekPreviewPositionMs = null
seekPreviewDeltaMs = 0L
controlsInteractionLocked = false
}
}
}
}
},
)
AnimatedVisibility(
visible = controlsVisible,
enter = fadeIn(),
exit = fadeOut()
) {
BoxWithConstraints(Modifier.fillMaxSize()) {
val capsuleInset = maxWidth * 0.075f + 20.dp
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),
)
),
)
LiquidSurfaceButton(
icon = if (isScreenLocked) LumaIcon.Lock else LumaIcon.Unlock,
contentDescription = stringResource(
if (isScreenLocked) Res.string.player_unlock_screen else Res.string.player_lock_screen
),
active = isScreenLocked,
size = 40.dp,
modifier = Modifier.align(Alignment.CenterStart).padding(start = capsuleInset),
) {
isScreenLocked = !isScreenLocked
controlsVisible = true
showSubtitleMenu = false
showEpisodeMenu = false
showAudioMenu = false
showPlaybackToolsMenu = false
}
if (!isScreenLocked) {
LiquidSurfaceButton(
icon = if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) LumaIcon.Phone else LumaIcon.Tablet,
contentDescription = stringResource(Res.string.player_rotate_screen),
active = false,
size = 40.dp,
modifier = Modifier.align(Alignment.CenterEnd).padding(end = capsuleInset),
) {
activity?.requestedOrientation = if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
}
Row(
Modifier.align(Alignment.BottomStart).padding(start = capsuleInset, bottom = 14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LiquidSurfaceButton(
icon = LumaIcon.Previous,
contentDescription = stringResource(Res.string.player_previous_episode),
active = false,
size = 40.dp,
iconSize = 24.dp,
) {
val idx = episodes.indexOfFirst { it.id == currentEpisodeId }
if (idx > 0) onSelectEpisode(episodes[idx - 1])
}
LiquidSurfaceButton(
icon = if (isPlaying) LumaIcon.Pause else LumaIcon.Play,
contentDescription = stringResource(if (isPlaying) Res.string.player_pause else Res.string.player_play),
active = true,
size = 40.dp,
iconSize = 24.dp,
) {
if (player.isPlaying) {
player.pause()
} else {
player.play()
}
}
LiquidSurfaceButton(
icon = LumaIcon.Next,
contentDescription = stringResource(Res.string.player_next_episode),
active = false,
size = 40.dp,
iconSize = 24.dp,
) {
val idx = episodes.indexOfFirst { it.id == currentEpisodeId }
if (idx >= 0 && idx < episodes.lastIndex) onSelectEpisode(episodes[idx + 1])
}
}
Column(Modifier.fillMaxWidth().statusBarsPadding().padding(top = 16.dp)) {
// P0: Delegated to extracted composable — only this component polls and recomposes
PlayerStatusBar(
playbackUrl = playbackUrl,
networkSpeed = networkSpeed,
isWifi = isWifi,
batteryPercent = batteryPercent,
isCharging = isCharging,
)
Spacer(Modifier.height(8.dp))
// 第二排:关闭按钮(左)|Emby 艺术字(紧跟关闭按钮右侧)|字幕/剧集按钮(右)
Row(
Modifier.fillMaxWidth().padding(horizontal = capsuleInset),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
LiquidSurfaceButton(
icon = LumaIcon.Close,
contentDescription = stringResource(Res.string.player_close),
active = false,
size = 40.dp,
) { onClose() }
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 = 18.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
LiquidSurfaceButton(
icon = LumaIcon.Subtitle,
contentDescription = stringResource(Res.string.player_subtitle),
active = showSubtitleMenu,
size = 40.dp,
) {
showSubtitleMenu = !showSubtitleMenu
showEpisodeMenu = false
showAudioMenu = false
showPlaybackToolsMenu = false
}
LiquidSurfaceButton(
icon = LumaIcon.Watch,
contentDescription = stringResource(Res.string.player_episode_list),
active = showEpisodeMenu,
size = 40.dp,
) {
showEpisodeMenu = !showEpisodeMenu
showSubtitleMenu = false
showAudioMenu = false
showPlaybackToolsMenu = false
}
LiquidSurfaceButton(
icon = LumaIcon.Headphones,
contentDescription = stringResource(Res.string.player_audio_tracks),
active = showAudioMenu,
size = 40.dp,
) {
showAudioMenu = !showAudioMenu
showSubtitleMenu = false
showEpisodeMenu = false
showPlaybackToolsMenu = false
}
LiquidSurfaceButton(
icon = LumaIcon.More,
contentDescription = stringResource(Res.string.player_playback_tools),
active = showPlaybackToolsMenu,
size = 40.dp,
) {
showPlaybackToolsMenu = !showPlaybackToolsMenu
showSubtitleMenu = false
showEpisodeMenu = false
showAudioMenu = false
}
}
}
if (serverName.isNotBlank()) {
Spacer(Modifier.height(8.dp))
// 第三排:服务器图标 + 服务器名字
Row(Modifier.fillMaxWidth().padding(start = capsuleInset + 52.dp, end = capsuleInset), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(30.dp).clip(CircleShape), contentAlignment = Alignment.Center) {
if (serverIconUrl.isNotBlank()) {
AsyncImage(
model = serverIconUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
LumaIconView(LumaIcon.Server, contentDescription = null, tint = Color.White, modifier = Modifier.size(24.dp))
}
}
Spacer(Modifier.width(8.dp))
Text(serverName, color = Color.White.copy(alpha = 0.8f), fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
if (playbackUrl != null) {
Column(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(0.85f)
.padding(bottom = 64.dp),
) {
// 技术信息行(进度胶囊上方、胶囊外部):内核|格式|码率|帧率
Row(
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("ExoPlayer", color = Color.White.copy(alpha = 0.75f), fontSize = 12.sp)
if (videoCodecText.isNotBlank()) Text(videoCodecText, color = Color.White.copy(alpha = 0.75f), fontSize = 12.sp)
if (videoBitrateText.isNotBlank()) Text(videoBitrateText, color = Color.White.copy(alpha = 0.75f), fontSize = 12.sp)
if (videoFrameRateText.isNotBlank()) Text(videoFrameRateText, color = Color.White.copy(alpha = 0.75f), fontSize = 12.sp)
}
Column(
Modifier
.fillMaxWidth()
.lumaGlass(
backdrop = null,
radius = 16.dp,
level = LumaGlassLevel.Strong,
tint = Color.Black.copy(alpha = 0.40f),
borderColor = Color.White.copy(alpha = 0.22f),
shadowElevation = 4.dp,
)
.padding(horizontal = 16.dp, vertical = 2.dp),
) {
var totalDurationMs by remember(player) {
mutableLongStateOf(player.duration.coerceAtLeast(0L))
}
var currentPositionMs by remember(player) {
mutableLongStateOf(player.currentPosition.coerceAtLeast(0L))
}
var isSeeking by remember { mutableStateOf(false) }
var seekPosition by remember { mutableFloatStateOf(0f) }
var bufferProgress by remember { mutableFloatStateOf(0f) }
LaunchedEffect(player) {
while (true) {
if (player.duration > 0) {
totalDurationMs = player.duration
if (!isSeeking && seekPreviewPositionMs == null) {
currentPositionMs = player.currentPosition
progress = player.currentPosition.toFloat() / player.duration.toFloat()
val b = (player.bufferedPosition.toFloat() / player.duration).coerceIn(0f, 1f)
bufferProgress = maxOf(b, progress)
}
}
delay(250)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
val swipePositionMs = seekPreviewPositionMs
Text(formatMs(swipePositionMs ?: currentPositionMs), color = Color.White, fontSize = 12.sp)
LumaSeekBar(
value = when {
isSeeking -> seekPosition
swipePositionMs != null && totalDurationMs > 0L -> swipePositionMs.toFloat() / totalDurationMs
else -> progress
},
onValueChange = { v ->
isSeeking = true
seekPosition = v
currentPositionMs = (v * totalDurationMs).toLong()
controlsInteractionLocked = true
},
onValueChangeFinished = { finalValue ->
val targetPosition = (finalValue * totalDurationMs).toLong()
player.seekTo(targetPosition)
progress = finalValue
currentPositionMs = targetPosition
isSeeking = false
controlsInteractionLocked = false
latestOnPlaybackProgress(targetPosition, !player.isPlaying, "TimeUpdate", true)
},
progressContentDescription = stringResource(Res.string.player_progress),
modifier = Modifier.weight(1f).padding(horizontal = 12.dp),
activeColor = Color.White,
trackColor = Color.White.copy(alpha = 0.12f),
bufferColor = Color.White.copy(alpha = 0.45f),
bufferedValue = bufferProgress,
thumbColor = Color.White,
)
Text(formatMs(totalDurationMs), color = Color.White, fontSize = 12.sp)
}
}
}
}
}
}
}
seekPreviewPositionMs?.let { targetPositionMs ->
SeekPreviewOverlay(
targetPositionMs = targetPositionMs,
durationMs = player.duration.coerceAtLeast(0L),
deltaMs = seekPreviewDeltaMs,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 132.dp),
)
}
SpeedUpCapsule(
visible = speedUpVisible,
speed = speedUpValue,
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding().padding(top = 24.dp),
)
levelGesture?.let { kind ->
PlayerLevelControl(
kind = kind,
label = stringResource(
if (kind == PlayerLevelGesture.Brightness) Res.string.player_brightness else Res.string.player_volume
),
level = levelGestureValue,
modifier = Modifier
.align(if (kind == PlayerLevelGesture.Brightness) Alignment.CenterStart else Alignment.CenterEnd)
.padding(horizontal = 48.dp),
)
}
doubleTapFeedback?.let { feedback ->
DoubleTapFeedbackCapsule(
feedback = feedback,
modifier = Modifier
.align(
when (feedback) {
PlayerDoubleTapFeedback.SeekBackward -> Alignment.CenterStart
PlayerDoubleTapFeedback.SeekForward -> Alignment.CenterEnd
else -> Alignment.Center
}
)
.padding(horizontal = if (
feedback == PlayerDoubleTapFeedback.SeekBackward || feedback == PlayerDoubleTapFeedback.SeekForward
) 64.dp else 0.dp),
)
}
if (showSubtitleMenu) {
LiquidGlassPanel(
title = stringResource(Res.string.player_subtitle),
onDismiss = { showSubtitleMenu = false },
) {
SubtitleMenuContent(
player = player,
onTrackChanged = { eventName ->
latestOnPlaybackProgress(player.currentPosition, !player.isPlaying, eventName, true)
showSubtitleMenu = false
},
)
}
}
if (showEpisodeMenu) {
LiquidGlassPanel(
title = title.ifBlank { stringResource(Res.string.player_episode_list) },
titleLogoUrl = titleLogoUrl,
onDismiss = { showEpisodeMenu = false },
) {
if (episodes.isEmpty()) {
Text(
text = stringResource(Res.string.player_no_episodes),
color = Color.White.copy(alpha = 0.68f),
fontSize = 13.sp,
modifier = Modifier.padding(vertical = 12.dp),
)
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(items = episodes) { ep ->
LiquidEpisodeItem(
episode = ep,
serverUrl = serverUrl,
selected = ep.id == currentEpisodeId,
) { showEpisodeMenu = false; onSelectEpisode(ep) }
}
}
}
}
}
if (showAudioMenu) {
LiquidGlassPanel(
title = stringResource(Res.string.player_audio_tracks),
onDismiss = { showAudioMenu = false },
) {
AudioMenuContent(
player = player,
onAudioDisabledChange = { audioDisabledByUser = it },
onTrackChanged = { eventName ->
latestOnPlaybackProgress(player.currentPosition, !player.isPlaying, eventName, true)
showAudioMenu = false
},
)
}
}
if (showPlaybackToolsMenu) {
LiquidGlassPanel(
title = stringResource(Res.string.player_playback_tools),
onDismiss = { showPlaybackToolsMenu = false },
) {
PlaybackToolsMenuContent(
chapters = chapters,
confirmBeforeContinuousPlayback = confirmBeforeContinuousPlayback,
onConfirmBeforeContinuousPlaybackChange = { confirmBeforeContinuousPlayback = it },
onSeek = { targetPositionMs ->
player.seekTo(targetPositionMs)
latestOnPlaybackProgress(targetPositionMs, !player.isPlaying, "TimeUpdate", true)
showPlaybackToolsMenu = false
},
onSkipOutro = {
val currentIndex = episodes.indexOfFirst { it.id == currentEpisodeId }
val nextEpisode = episodes.getOrNull(currentIndex + 1)
if (nextEpisode != null) {
showPlaybackToolsMenu = false
onSelectEpisode(nextEpisode)
} else {
val targetPositionMs = (player.duration - 500L).coerceAtLeast(0L)
player.seekTo(targetPositionMs)
latestOnPlaybackProgress(targetPositionMs, !player.isPlaying, "TimeUpdate", true)
showPlaybackToolsMenu = false
}
},
)
}
}
if (showNextEpisodeConfirmation) {
val currentIndex = episodes.indexOfFirst { it.id == currentEpisodeId }
val nextEpisode = episodes.getOrNull(currentIndex + 1)
LiquidGlassPanel(
title = stringResource(Res.string.player_continue_playback),
onDismiss = { showNextEpisodeConfirmation = false },
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (nextEpisode != null) {
LiquidEpisodeItem(
episode = nextEpisode,
serverUrl = serverUrl,
selected = false,
) {}
LiquidMenuItem(
text = stringResource(Res.string.player_play_next_episode),
selected = true,
) {
showNextEpisodeConfirmation = false
onSelectEpisode(nextEpisode)
}
}
LiquidMenuItem(
text = stringResource(Res.string.player_cancel_continuous_playback),
selected = false,
) { showNextEpisodeConfirmation = false }
}
}
}
}
}
@Composable
private fun SeekPreviewOverlay(
targetPositionMs: Long,
durationMs: Long,
deltaMs: Long,
modifier: Modifier = Modifier,
) {
val forward = deltaMs >= 0L
val direction = stringResource(
if (forward) Res.string.player_swipe_forward else Res.string.player_swipe_backward
)
val targetProgress = if (durationMs > 0L) targetPositionMs.toFloat() / durationMs else 0f
Column(
modifier
.width(216.dp)
.lumaGlass(
backdrop = LocalGlassBackdrop.current,
radius = 16.dp,
level = LumaGlassLevel.Strong,
tint = Color.Black.copy(alpha = 0.68f),
borderColor = Color.White.copy(alpha = 0.24f),
shadowElevation = 5.dp,
)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
LumaIconView(
icon = if (forward) LumaIcon.Speed else LumaIcon.Replay,
contentDescription = direction,
tint = Color.White,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(7.dp))
Text(
text = "$direction ${if (forward) "+" else "-"}${formatMs(abs(deltaMs))}",
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.weight(1f))
Text(
text = formatMs(targetPositionMs),
color = Color.White.copy(alpha = 0.82f),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
Canvas(Modifier.fillMaxWidth().padding(top = 8.dp).height(4.dp)) {
val radius = size.height / 2f
drawRoundRect(
color = Color.White.copy(alpha = 0.18f),
size = size,
cornerRadius = CornerRadius(radius, radius),
)
drawRoundRect(
color = Color.White.copy(alpha = 0.92f),
size = Size(size.width * targetProgress.coerceIn(0f, 1f), size.height),
cornerRadius = CornerRadius(radius, radius),
)
}
Text(
text = "${formatMs(targetPositionMs)} / ${formatMs(durationMs)}",
color = Color.White.copy(alpha = 0.62f),
fontSize = 10.sp,
modifier = Modifier.align(Alignment.End).padding(top = 4.dp),
)
}
}
@Composable
private fun LiquidSurfaceButton(
icon: LumaIcon,
active: Boolean,
modifier: Modifier = Modifier,
contentDescription: String? = null,
size: Dp = 44.dp,
iconSize: Dp = 24.dp,
onClick: () -> Unit,
) {
val bd = LocalGlassBackdrop.current
val touchSize = size.coerceAtLeast(48.dp)
val surfaceColor = if (active) Color.White.copy(alpha = 0.3f) else Color.White.copy(alpha = 0.1f)
val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.94f else 1f,
animationSpec = tween(180),
label = "playerMaterialIconPress",
)
Box(
modifier
.size(touchSize)
.clickable(indication = null, interactionSource = interactionSource, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Box(
Modifier
.size(size)
.graphicsLayer { scaleX = scale; scaleY = scale }
.lumaGlass(
backdrop = bd,
radius = size / 2f,
level = LumaGlassLevel.Interactive,
tint = surfaceColor,
borderColor = Color.White.copy(alpha = 0.3f),
shadowElevation = 4.dp,
),
contentAlignment = Alignment.Center,
) {
LumaIconView(icon, contentDescription = contentDescription, tint = Color.White, modifier = Modifier.size(iconSize))
}
}
}
@Composable
private fun LiquidGlassPanel(
title: String,
titleLogoUrl: String = "",
onDismiss: () -> Unit,
content: @Composable () -> Unit,
) {
Box(
Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
listOf(
Color.Black.copy(alpha = 0.12f),
Color(0xFF07111F).copy(alpha = 0.68f),
)
)
)
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onDismiss),
contentAlignment = Alignment.CenterEnd,
) {
Column(
Modifier
.padding(end = 20.dp)
.fillMaxHeight(0.82f)
.width(340.dp)
.lumaGlass(
backdrop = null,
radius = 22.dp,
level = LumaGlassLevel.Strong,
tint = Color(0xFF101C31).copy(alpha = 0.94f),
borderColor = Color.White.copy(alpha = 0.26f),
shadowElevation = 12.dp,
)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = {},
)
.background(
Brush.verticalGradient(
listOf(
Color(0xFF243A59).copy(alpha = 0.90f),
Color(0xFF09111F).copy(alpha = 0.95f),
)
),
RoundedCornerShape(22.dp),
)
.padding(18.dp),
) {
if (titleLogoUrl.isNotBlank()) {
AsyncImage(
model = titleLogoUrl,
contentDescription = title,
contentScale = ContentScale.Fit,
modifier = Modifier.height(32.dp).widthIn(max = 250.dp),
)
} else {
Text(title, color = Color.White, fontSize = 19.sp, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(14.dp))
Box(Modifier.weight(1f)) { content() }
}
}
}
@Composable
private fun SubtitleMenuContent(player: ExoPlayer, onTrackChanged: (String) -> Unit) {
val unnamedTrack = stringResource(Res.string.player_unnamed_subtitle)
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?.takeIf { it.isNotBlank() },
fmt.language?.uppercase()?.takeIf { it.isNotBlank() },
).joinToString(" · ").ifBlank { "$unnamedTrack ${list.size + 1}" }
list.add(SubtitleTrackInfo(group, i, label))
}
}
}
list
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
item {
LiquidMenuItem(text = stringResource(Res.string.player_disable_subtitle), 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()
onTrackChanged("SubtitleTrackChange")
}
}
items(items = textTracks) { info ->
LiquidMenuItem(text = info.label, selected = info.group.isTrackSelected(info.index)) {
player.trackSelectionParameters = player.trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.clearOverridesOfType(C.TRACK_TYPE_TEXT)
.addOverride(TrackSelectionOverride(info.group.mediaTrackGroup, info.index))
.build()
onTrackChanged("SubtitleTrackChange")
}
}
}
}
private data class SubtitleTrackInfo(
@param:androidx.annotation.OptIn(UnstableApi::class) val group: androidx.media3.common.Tracks.Group,
val index: Int,
val label: String
)
@Composable
private fun AudioMenuContent(
player: ExoPlayer,
onAudioDisabledChange: (Boolean) -> Unit,
onTrackChanged: (String) -> Unit,
) {
val unnamedTrack = stringResource(Res.string.player_unnamed_audio)
val audioTracks = remember(player.currentTracks) {
val list = mutableListOf<AudioTrackInfo>()
player.currentTracks.groups.forEach { group ->
if (group.type == C.TRACK_TYPE_AUDIO) {
repeat(group.length) { index ->
val format = group.getTrackFormat(index)
val label = listOfNotNull(
format.label?.takeIf { it.isNotBlank() },
format.language?.uppercase()?.takeIf { it.isNotBlank() },
format.codecs?.takeIf { it.isNotBlank() },
).distinct().joinToString(" · ").ifBlank { "$unnamedTrack ${list.size + 1}" }
list.add(AudioTrackInfo(group, index, label))
}
}
}
list
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
item {
LiquidMenuItem(
text = stringResource(Res.string.player_disable_audio),
selected = !player.currentTracks.groups.any { it.type == C.TRACK_TYPE_AUDIO && it.isSelected },
) {
onAudioDisabledChange(true)
player.trackSelectionParameters = player.trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, true)
.build()
onTrackChanged("AudioTrackChange")
}
}
items(items = audioTracks) { info ->
LiquidMenuItem(text = info.label, selected = info.group.isTrackSelected(info.index)) {
onAudioDisabledChange(false)
player.trackSelectionParameters = player.trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
.addOverride(TrackSelectionOverride(info.group.mediaTrackGroup, info.index))
.build()
onTrackChanged("AudioTrackChange")
}
}
}
}
private data class AudioTrackInfo(
@param:androidx.annotation.OptIn(UnstableApi::class) val group: androidx.media3.common.Tracks.Group,
val index: Int,
val label: String,
)
@Composable
private fun PlaybackToolsMenuContent(
chapters: List<EmbyChapter>,
confirmBeforeContinuousPlayback: Boolean,
onConfirmBeforeContinuousPlaybackChange: (Boolean) -> Unit,
onSeek: (Long) -> Unit,
onSkipOutro: () -> Unit,
) {
val introEndPositionMs = findIntroEndPositionMs(chapters)
val creditsStartPositionMs = findCreditsStartPositionMs(chapters)
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
item {
LiquidMenuItem(
text = stringResource(Res.string.player_confirm_before_continuous_playback),
selected = confirmBeforeContinuousPlayback,
) { onConfirmBeforeContinuousPlaybackChange(!confirmBeforeContinuousPlayback) }
}
if (introEndPositionMs != null) {
item {
LiquidMenuItem(text = stringResource(Res.string.player_skip_intro), selected = false) {
onSeek(introEndPositionMs)
}
}
}
if (creditsStartPositionMs != null) {
item {
LiquidMenuItem(text = stringResource(Res.string.player_skip_outro), selected = false, onClick = onSkipOutro)
}
}
item {
Text(
text = stringResource(Res.string.player_chapters),
color = Color.White.copy(alpha = 0.72f),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 6.dp, bottom = 2.dp),
)
}
if (chapters.isEmpty()) {
item {
Text(
text = stringResource(Res.string.player_no_chapters),
color = Color.White.copy(alpha = 0.62f),
fontSize = 13.sp,
modifier = Modifier.padding(vertical = 12.dp),
)
}
} else {
items(items = chapters) { chapter ->
val chapterName = chapter.name?.takeIf { it.isNotBlank() }
?: stringResource(Res.string.player_chapter_at, formatMs(chapter.startPositionMs))
LiquidMenuItem(
text = "$chapterName · ${formatMs(chapter.startPositionMs)}",
selected = false,
) { onSeek(chapter.startPositionMs) }
}
}
}
}
@Composable
private fun LiquidMenuItem(text: String, selected: Boolean, onClick: () -> Unit) {
val surface = if (selected) Color(0xFF4D78B8).copy(alpha = 0.48f) else Color(0xFF16243A).copy(alpha = 0.72f)
Row(
Modifier
.fillMaxWidth()
.lumaGlass(
backdrop = null,
radius = LumaShapes.small,
level = LumaGlassLevel.Interactive,
tint = surface,
borderColor = Color.White.copy(alpha = if (selected) 0.34f else 0.16f),
shadowElevation = 0.dp,
)
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onClick)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text, color = Color.White, fontSize = 14.sp, modifier = Modifier.weight(1f))
if (selected) LumaIconView(LumaIcon.Play, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp))
}
}
// P0: Extract status bar into its own composable so polling states (time/wifi/battery/speed)
// only recompose this small component instead of the entire VideoPlayerScreen.
@Composable
private fun PlayerStatusBar(
playbackUrl: String?,
networkSpeed: Long,
isWifi: Boolean,
batteryPercent: Int,
isCharging: Boolean,
capsuleInset: Dp = 18.dp,
) {
val topTimeText = remember { mutableStateOf("") }
val localNetworkSpeed by rememberUpdatedState(networkSpeed)
val localIsWifi by rememberUpdatedState(isWifi)
val localBatteryPercent by rememberUpdatedState(batteryPercent)
val localIsCharging by rememberUpdatedState(isCharging)
// Clock update — only this composable recomposes
LaunchedEffect(Unit) {
while (true) {
topTimeText.value = java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()).format(java.util.Date())
delay(1000)
}
}
Row(
Modifier.fillMaxWidth().padding(horizontal = capsuleInset),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(topTimeText.value, color = Color.White, fontSize = 13.sp)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
if (playbackUrl != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
LumaIconView(if (localIsWifi) LumaIcon.Wifi else LumaIcon.Signal, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(formatNetworkSpeed(localNetworkSpeed), color = Color.White, fontSize = 13.sp)
}
}
if (localBatteryPercent >= 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("$localBatteryPercent%", color = Color.White, fontSize = 13.sp)
Spacer(Modifier.width(4.dp))
BatteryIcon(level = localBatteryPercent, charging = localIsCharging, modifier = Modifier.size(22.dp, 12.dp))
}
}
}
}
}
@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 = LocalGlassBackdrop.current
val label = if (speed % 1f == 0f) speed.toInt().toString() else speed.toString()
Box(
modifier
.clip(RoundedCornerShape(50))
.lumaGlass(
backdrop = bd,
radius = 50.dp,
level = LumaGlassLevel.Interactive,
tint = Color.Black.copy(alpha = 0.6f),
borderColor = Color.White.copy(alpha = 0.25f),
shadowElevation = 4.dp,
)
.padding(horizontal = 14.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
LumaIconView(LumaIcon.Speed, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp))
Text("x$label", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
}
}
@Composable
private fun PlayerLevelControl(
kind: PlayerLevelGesture,
label: String,
level: Float,
modifier: Modifier = Modifier,
) {
val bd = LocalGlassBackdrop.current
val icon = if (kind == PlayerLevelGesture.Brightness) LumaIcon.Brightness else LumaIcon.Speaker
val iconOnFill = level >= 0.16f
Column(
modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(label, color = Color.White.copy(alpha = 0.82f), fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Box(
Modifier
.width(60.dp)
.height(160.dp)
.clip(RoundedCornerShape(30.dp))
.lumaGlass(
backdrop = bd,
radius = 30.dp,
level = LumaGlassLevel.Strong,
tint = Color.Black.copy(alpha = 0.62f),
borderColor = Color.White.copy(alpha = 0.28f),
shadowElevation = 6.dp,
),
) {
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.fillMaxHeight(level.coerceIn(0f, 1f))
.background(Color.White.copy(alpha = 0.92f)),
)
Text(
text = "${(level * 100).roundToInt()}%",
color = if (level >= 0.78f) Color.Black.copy(alpha = 0.72f) else Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.TopCenter).padding(top = 14.dp),
)
LumaIconView(
icon = icon,
contentDescription = label,
tint = if (iconOnFill) Color.Black.copy(alpha = 0.78f) else Color.White,
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 14.dp).size(20.dp),
)
}
}
}
@Composable
private fun DoubleTapFeedbackCapsule(
feedback: PlayerDoubleTapFeedback,
modifier: Modifier = Modifier,
) {
val bd = LocalGlassBackdrop.current
val icon = when (feedback) {
PlayerDoubleTapFeedback.SeekBackward -> LumaIcon.Replay
PlayerDoubleTapFeedback.Pause -> LumaIcon.Pause
PlayerDoubleTapFeedback.Play -> LumaIcon.Play
PlayerDoubleTapFeedback.SeekForward -> LumaIcon.Speed
}
val text = stringResource(
when (feedback) {
PlayerDoubleTapFeedback.SeekBackward -> Res.string.player_seek_backward
PlayerDoubleTapFeedback.Pause -> Res.string.player_pause
PlayerDoubleTapFeedback.Play -> Res.string.player_play
PlayerDoubleTapFeedback.SeekForward -> Res.string.player_seek_forward
}
)
Row(
modifier
.lumaGlass(
backdrop = bd,
radius = 24.dp,
level = LumaGlassLevel.Interactive,
tint = Color.Black.copy(alpha = 0.68f),
borderColor = Color.White.copy(alpha = 0.26f),
shadowElevation = 4.dp,
)
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(7.dp),
) {
LumaIconView(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
Text(text, color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
}
}
@Composable
private fun LiquidEpisodeItem(episode: EmbyItem, serverUrl: String, selected: Boolean, onClick: () -> Unit) {
val imageUrl = EmbyRepository.buildEpisodeImageUrl(serverUrl, episode, maxWidth = 360)
val episodeNumber = episode.indexNumber?.let { stringResource(Res.string.detail_episode_number, it) }
?: stringResource(Res.string.detail_episode_short)
val surface = if (selected) Color(0xFF4D78B8).copy(alpha = 0.48f) else Color(0xFF16243A).copy(alpha = 0.72f)
Row(
Modifier
.fillMaxWidth()
.lumaGlass(
backdrop = null,
radius = LumaShapes.small,
level = LumaGlassLevel.Interactive,
tint = surface,
borderColor = Color.White.copy(alpha = if (selected) 0.34f else 0.16f),
shadowElevation = 0.dp,
)
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onClick)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
Modifier
.size(width = 112.dp, height = 63.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color(0xFF0B1424)),
contentAlignment = Alignment.Center,
) {
if (imageUrl != null) {
LumaAsyncImage(
model = imageUrl,
contentDescription = episode.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Text(episodeNumber, color = Color.White.copy(alpha = 0.72f), fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(
episodeNumber,
color = if (selected) Color(0xFFBFD8FF) else Color.White.copy(alpha = 0.64f),
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(3.dp))
Text(
episode.name,
color = Color.White,
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (selected) {
LumaIconView(LumaIcon.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp))
}
}
}