- 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
1928 lines
88 KiB
Kotlin
1928 lines
88 KiB
Kotlin
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))
|
||
}
|
||
}
|
||
}
|