From ca5ab982daa51d072db9ab2a87848a14342b29f3 Mon Sep 17 00:00:00 2001
From: beyond
Date: Sat, 29 Aug 2026 03:34:15 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=92=AD=E6=94=BE?=
=?UTF-8?q?=E5=99=A8=E6=8E=A7=E5=88=B6=E3=80=81=E5=AA=92=E4=BD=93=E8=BD=A8?=
=?UTF-8?q?=E9=81=93=E4=B8=8E=20Emby=20=E7=8A=B6=E6=80=81=E4=B8=8A?=
=?UTF-8?q?=E6=8A=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 修复播放进度圆点与轨道位置偏移
- 优化字幕、剧集、音轨及播放工具弹层
- 完善剧集图片、集数、标题和选中状态展示
- 新增音轨选择、关闭及默认音轨自动恢复
- 新增章节跳转、跳过片头片尾和连播确认
- 支持从单集入口自动加载完整剧集列表
- 调整 Emby 每 10 秒及关键操作即时状态上报
- 补充章节识别、手势和播放上报单元测试
---
.../myapplication/data/EmbyApiModels.kt | 12 +
.../myapplication/data/EmbyRepository.kt | 38 +-
.../myapplication/navigation/AppNavHost.kt | 21 +-
.../screens/VideoPlayerScreen.kt | 1117 ++++++++++++++---
.../myapplication/ui/design/LumaComponents.kt | 120 +-
.../myapplication/ui/design/LumaIcons.kt | 1 +
.../main/res/drawable/aurora_brightness.xml | 18 +
.../drawable/aurora_brightness_selected.xml | 16 +
app/src/main/res/values-en/strings.xml | 20 +
app/src/main/res/values/strings.xml | 20 +
.../data/PlaybackReportingTest.kt | 20 +
.../screens/VideoPlayerGestureTest.kt | 82 ++
12 files changed, 1224 insertions(+), 261 deletions(-)
create mode 100644 app/src/main/res/drawable/aurora_brightness.xml
create mode 100644 app/src/main/res/drawable/aurora_brightness_selected.xml
create mode 100644 app/src/test/kotlin/com/example/myapplication/data/PlaybackReportingTest.kt
create mode 100644 app/src/test/kotlin/com/example/myapplication/screens/VideoPlayerGestureTest.kt
diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt
index 988bffe..93591e0 100644
--- a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt
+++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt
@@ -57,6 +57,8 @@ data class EmbyItem(
@SerializedName("ImageTags") val imageTags: Map? = null,
@SerializedName("BackdropImageTags") val _backdropImageTags: List? = emptyList(),
@SerializedName("SeriesName") val seriesName: String? = null,
+ @SerializedName("SeriesId") val seriesId: String? = null,
+ @SerializedName("SeasonId") val seasonId: String? = null,
@SerializedName("ParentId") val parentId: String? = null,
@SerializedName("IndexNumber") val indexNumber: Int? = null,
@SerializedName("ParentIndexNumber") val parentIndexNumber: Int? = null,
@@ -65,6 +67,7 @@ data class EmbyItem(
@SerializedName("People") val _people: List? = emptyList(),
@SerializedName("Studios") val _studios: List? = emptyList(),
@SerializedName("MediaStreams") val _mediaStreams: List? = emptyList(),
+ @SerializedName("Chapters") val _chapters: List? = emptyList(),
@SerializedName("UserData") val userData: EmbyUserData? = null,
) {
val genres: List get() = _genres.orEmpty()
@@ -73,6 +76,7 @@ data class EmbyItem(
val people: List get() = _people.orEmpty()
val studios: List get() = _studios.orEmpty()
val mediaStreams: List get() = _mediaStreams.orEmpty()
+ val chapters: List get() = _chapters.orEmpty()
val playbackPositionMs: Long
get() = (userData?.playbackPositionTicks ?: 0L).coerceAtLeast(0L) / 10_000L
@@ -121,6 +125,14 @@ data class EmbyMediaStream(
@SerializedName("Width") val width: Int? = null,
)
+data class EmbyChapter(
+ @SerializedName("StartPositionTicks") val startPositionTicks: Long = 0L,
+ @SerializedName("Name") val name: String? = null,
+ @SerializedName("MarkerType") val markerType: String? = null,
+) {
+ val startPositionMs: Long get() = startPositionTicks.coerceAtLeast(0L) / 10_000L
+}
+
/* ---------- Playback Info ---------- */
data class EmbyPlaybackInfoResponse(
diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt
index 7c17047..098c9ff 100644
--- a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt
+++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt
@@ -39,6 +39,17 @@ data class EmbyPlaybackSession(
val playMethod: String,
)
+internal fun shouldSendPlaybackProgress(
+ eventName: String,
+ force: Boolean,
+ positionMs: Long,
+ lastPositionMs: Long,
+ nowMs: Long,
+ lastSentAtMs: Long,
+ intervalMs: Long = 10_000L,
+): Boolean = force || eventName != "TimeUpdate" || lastPositionMs < 0L ||
+ abs(positionMs - lastPositionMs) >= intervalMs || nowMs - lastSentAtMs >= intervalMs
+
object EmbyRepository {
private const val CLIENT_NAME = "Luma Player"
@@ -71,6 +82,7 @@ object EmbyRepository {
override val positionMs: Long,
val paused: Boolean,
val eventName: String,
+ val force: Boolean,
override val session: EmbyPlaybackSession?,
) : PlaybackCommand
@@ -319,7 +331,7 @@ object EmbyRepository {
suspend fun getItem(server: EmbyServer, itemId: String): EmbyItem? = withContext(Dispatchers.IO) {
try {
val url = Uri.parse("${server.fullUrl}/emby/Users/${server.userId}/Items/$itemId").buildUpon()
- .appendQueryParameter("Fields", "Overview,Genres,People,Studios,MediaStreams,UserData")
+ .appendQueryParameter("Fields", "Overview,Genres,People,Studios,MediaStreams,Chapters,UserData")
.appendQueryParameter("EnableImages", "true")
.appendQueryParameter("EnableUserData", "true")
.appendQueryParameter("EnableImageTypes", "Primary,Backdrop,Thumb,Logo")
@@ -393,7 +405,8 @@ object EmbyRepository {
suspend fun getEpisodes(server: EmbyServer, seriesId: String, seasonId: String): List = withContext(Dispatchers.IO) {
val dedicated = try {
val url = "${server.fullUrl}/emby/Shows/$seriesId/Episodes" +
- "?userId=${server.userId}&seasonId=$seasonId&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,UserData&EnableUserData=true"
+ "?userId=${server.userId}&seasonId=$seasonId&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,ImageTags,BackdropImageTags,UserData" +
+ "&EnableImages=true&EnableImageTypes=Primary,Thumb,Backdrop&EnableUserData=true"
val request = Request.Builder()
.url(url)
.header("X-Emby-Token", server.apiKey)
@@ -415,7 +428,8 @@ object EmbyRepository {
return@withContext try {
val url = "${server.fullUrl}/emby/Users/${server.userId}/Items" +
"?ParentId=$seasonId&IncludeItemTypes=Episode&Recursive=true&SortBy=ParentIndexNumber,IndexNumber&SortOrder=Ascending" +
- "&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,UserData&EnableUserData=true"
+ "&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,ImageTags,BackdropImageTags,UserData" +
+ "&EnableImages=true&EnableImageTypes=Primary,Thumb,Backdrop&EnableUserData=true"
val request = Request.Builder()
.url(url)
.header("X-Emby-Token", server.apiKey)
@@ -536,9 +550,10 @@ object EmbyRepository {
paused: Boolean,
eventName: String,
session: EmbyPlaybackSession?,
+ force: Boolean = false,
) {
playbackQueue.trySend(
- PlaybackCommand.Progress(server, item, positionMs, paused, eventName, session),
+ PlaybackCommand.Progress(server, item, positionMs, paused, eventName, force, session),
)
}
@@ -588,11 +603,16 @@ object EmbyRepository {
is PlaybackCommand.Progress -> {
val now = SystemClock.elapsedRealtime()
val state = playbackReportState.getOrPut(key) { PlaybackReportState() }
- val isUserInteraction = command.eventName != "TimeUpdate"
- val positionChangedEnough = state.lastPositionMs < 0L ||
- abs(command.positionMs - state.lastPositionMs) >= PLAYBACK_REPORT_INTERVAL_MS
- val intervalElapsed = now - state.lastSentAtMs >= PLAYBACK_REPORT_INTERVAL_MS
- if (!isUserInteraction && !positionChangedEnough && !intervalElapsed) return
+ if (!shouldSendPlaybackProgress(
+ eventName = command.eventName,
+ force = command.force,
+ positionMs = command.positionMs,
+ lastPositionMs = state.lastPositionMs,
+ nowMs = now,
+ lastSentAtMs = state.lastSentAtMs,
+ intervalMs = PLAYBACK_REPORT_INTERVAL_MS,
+ )
+ ) return
sendPlaybackCheckIn(
server = command.server,
diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
index f8071dc..cb65426 100644
--- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
+++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
@@ -305,12 +305,14 @@ private fun PlayerDestination(
itemLoading = false
}
- LaunchedEffect(server?.id, seriesId, seasonId) {
+ val effectiveSeriesId = seriesId ?: item?.seriesId
+ val effectiveSeasonId = seasonId ?: item?.seasonId ?: item?.parentId
+ LaunchedEffect(server?.id, effectiveSeriesId, effectiveSeasonId) {
episodes = try {
- if (server == null || seriesId.isNullOrBlank() || seasonId.isNullOrBlank()) {
+ if (server == null || effectiveSeriesId.isNullOrBlank() || effectiveSeasonId.isNullOrBlank()) {
emptyList()
} else {
- EmbyRepository.getEpisodes(server, seriesId, seasonId)
+ EmbyRepository.getEpisodes(server, effectiveSeriesId, effectiveSeasonId)
}
} catch (error: CancellationException) {
throw error
@@ -325,7 +327,13 @@ private fun PlayerDestination(
playbackLoading = true
playbackError = null
try {
- playbackSession = EmbyRepository.resolvePlaybackSession(server, target)
+ val playbackTarget = if (target.id != item?.id) {
+ EmbyRepository.getItem(server, target.id) ?: target
+ } else {
+ target
+ }
+ if (target.id != item?.id && playbackTarget !== target) selectedItem = playbackTarget
+ playbackSession = EmbyRepository.resolvePlaybackSession(server, playbackTarget)
playbackUrl = playbackSession?.streamUrl
} catch (error: CancellationException) {
throw error
@@ -344,11 +352,13 @@ private fun PlayerDestination(
titleLogoUrl = target.getLogoUrl(server?.fullUrl.orEmpty()).orEmpty(),
serverName = server?.name.orEmpty(),
serverIconUrl = server?.iconUrl.orEmpty(),
+ serverUrl = server?.fullUrl.orEmpty(),
playbackUrl = playbackUrl,
initialPositionMs = target.playbackPositionMs,
isLoading = itemLoading || playbackLoading,
errorText = playbackError,
episodes = episodes,
+ chapters = target.chapters,
currentEpisodeId = target.id,
onSelectEpisode = { episode ->
selectedItem = episode
@@ -361,7 +371,7 @@ private fun PlayerDestination(
EmbyRepository.enqueuePlaybackStarted(currentServer, target, positionMs, playbackSession)
}
},
- onPlaybackProgress = { positionMs, paused, eventName ->
+ onPlaybackProgress = { positionMs, paused, eventName, force ->
server?.let { currentServer ->
EmbyRepository.enqueuePlaybackProgress(
currentServer,
@@ -370,6 +380,7 @@ private fun PlayerDestination(
paused,
eventName,
playbackSession,
+ force,
)
}
},
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt
index 9bf6c9e..59af63f 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt
@@ -5,6 +5,7 @@ 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
@@ -62,6 +63,7 @@ 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
@@ -98,18 +100,25 @@ 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
@@ -179,11 +188,67 @@ internal class SpeedMonitor(
}
}
+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): 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): 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()
@@ -197,23 +262,31 @@ fun VideoPlayerScreen(
titleLogoUrl: String = "",
serverName: String = "",
serverIconUrl: String = "",
+ serverUrl: String = "",
playbackUrl: String? = null,
initialPositionMs: Long = 0L,
isLoading: Boolean = false,
errorText: String? = null,
episodes: List = emptyList(),
+ chapters: List = emptyList(),
currentEpisodeId: String? = null,
onSelectEpisode: (EmbyItem) -> Unit = {},
onClose: () -> Unit = {},
onPlaybackStarted: (positionMs: Long) -> Unit = {},
- onPlaybackProgress: (positionMs: Long, paused: Boolean, eventName: String) -> 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 {
@@ -249,6 +322,11 @@ fun VideoPlayerScreen(
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) }
@@ -266,6 +344,11 @@ fun VideoPlayerScreen(
var speedUpValue by remember { mutableFloatStateOf(2f) }
var levelGesture by remember { mutableStateOf(null) }
var levelGestureValue by remember { mutableFloatStateOf(0f) }
+ var seekPreviewPositionMs by remember { mutableStateOf(null) }
+ var seekPreviewDeltaMs by remember { mutableLongStateOf(0L) }
+ var isScreenLocked by remember { mutableStateOf(false) }
+ var doubleTapFeedback by remember { mutableStateOf(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
@@ -278,17 +361,35 @@ fun VideoPlayerScreen(
}
var controlsVisible by remember { mutableStateOf(true) }
- var controlsLocked by remember { mutableStateOf(false) }
- LaunchedEffect(controlsVisible, isPlaying, controlsLocked) {
- if (controlsVisible && isPlaying && !controlsLocked) {
+ 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) {
+ if (player.duration > 0 && seekPreviewPositionMs == null) {
progress = player.currentPosition.toFloat() / player.duration.toFloat()
}
isPlaying = player.isPlaying
@@ -351,7 +452,7 @@ fun VideoPlayerScreen(
LaunchedEffect(player, playbackUrl) {
while (true) {
if (!playbackUrl.isNullOrBlank() && player.currentMediaItem != null && player.isPlaying) {
- latestOnPlaybackProgress(player.currentPosition, false, "TimeUpdate")
+ latestOnPlaybackProgress(player.currentPosition, false, "TimeUpdate", false)
}
delay(10_000)
}
@@ -381,6 +482,7 @@ fun VideoPlayerScreen(
}
DisposableEffect(playbackUrl) {
playerError = null
+ showNextEpisodeConfirmation = false
if (!playbackUrl.isNullOrBlank()) {
player.setMediaItem(MediaItem.fromUri(playbackUrl), initialPositionMs.coerceAtLeast(0L))
player.prepare()
@@ -401,10 +503,44 @@ fun VideoPlayerScreen(
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
+ if (!isPlaying && player.playWhenReady) return
if (!latestPlaybackUrl.isNullOrBlank()) {
- latestOnPlaybackProgress(player.currentPosition, !isPlaying, if (isPlaying) "Unpause" else "Pause")
+ 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 {
@@ -429,16 +565,89 @@ fun VideoPlayerScreen(
lifecycleOwner.lifecycle.addObserver(obs)
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
}
- BackHandler(enabled = true, onBack = onClose)
+ 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)
- .pointerInput(player, activity, audioManager) {
+ .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(viewConfiguration.longPressTimeoutMillis) {
var detected: PointerInputChange? = null
while (detected == null) {
@@ -463,7 +672,7 @@ fun VideoPlayerScreen(
if (!player.isPlaying) player.play()
speedUpVisible = true
speedUpValue = targetSpeed
- controlsLocked = true
+ controlsInteractionLocked = true
try {
while (true) {
val event = awaitPointerEvent()
@@ -473,10 +682,65 @@ fun VideoPlayerScreen(
speedUpVisible = false
player.setPlaybackSpeed(prevSpeed)
if (!wasPlaying) player.pause()
- controlsLocked = false
+ controlsInteractionLocked = false
}
} else if (current == null || !current.pressed) {
- if (current?.isConsumed != true) controlsVisible = !controlsVisible
+ 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
@@ -493,7 +757,7 @@ fun VideoPlayerScreen(
audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxMusicVolume
}
levelGesture = kind
- controlsLocked = true
+ controlsInteractionLocked = true
var change = activeChange
try {
while (change.pressed) {
@@ -521,52 +785,46 @@ fun VideoPlayerScreen(
}
} finally {
levelGesture = null
- controlsLocked = false
+ controlsInteractionLocked = false
}
} else {
- // 横向移动交给进度条等子控件处理,不切换控制栏。
- while (currentEvent.changes.any { it.id == down.id && it.pressed }) {
- awaitPointerEvent()
+ 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
}
}
}
}
- }
- ) {
- 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 {
- Box(Modifier.fillMaxSize()) {
- 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),
- )
- }
- }
-
- AnimatedVisibility(
+ AnimatedVisibility(
visible = controlsVisible,
enter = fadeIn(),
exit = fadeOut()
@@ -587,26 +845,49 @@ fun VideoPlayerScreen(
),
)
+ 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.Center),
- horizontalArrangement = Arrangement.spacedBy(24.dp),
+ Modifier.align(Alignment.BottomStart).padding(start = capsuleInset, bottom = 14.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- LiquidSurfaceButton(
- icon = LumaIcon.Replay,
- contentDescription = stringResource(Res.string.player_seek_backward),
- active = false,
- size = 32.dp,
- ) {
- val pos = player.currentPosition - 10_000L
- player.seekTo(maxOf(0L, pos))
- latestOnPlaybackProgress(player.currentPosition, !player.isPlaying, "TimeUpdate")
- }
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])
@@ -615,8 +896,8 @@ fun VideoPlayerScreen(
icon = if (isPlaying) LumaIcon.Pause else LumaIcon.Play,
contentDescription = stringResource(if (isPlaying) Res.string.player_pause else Res.string.player_play),
active = true,
- size = 48.dp,
- iconSize = 28.dp,
+ size = 40.dp,
+ iconSize = 24.dp,
) {
if (player.isPlaying) {
player.pause()
@@ -629,19 +910,11 @@ fun VideoPlayerScreen(
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])
}
- LiquidSurfaceButton(
- icon = LumaIcon.Speed,
- contentDescription = stringResource(Res.string.player_seek_forward),
- active = false,
- size = 32.dp,
- ) {
- val pos = player.currentPosition + 10_000L
- player.seekTo(minOf(pos, player.duration))
- }
}
Column(Modifier.fillMaxWidth().statusBarsPadding().padding(top = 16.dp)) {
@@ -701,7 +974,10 @@ fun VideoPlayerScreen(
active = showSubtitleMenu,
size = 40.dp,
) {
- showSubtitleMenu = !showSubtitleMenu; showEpisodeMenu = false
+ showSubtitleMenu = !showSubtitleMenu
+ showEpisodeMenu = false
+ showAudioMenu = false
+ showPlaybackToolsMenu = false
}
LiquidSurfaceButton(
icon = LumaIcon.Watch,
@@ -709,7 +985,32 @@ fun VideoPlayerScreen(
active = showEpisodeMenu,
size = 40.dp,
) {
- showEpisodeMenu = !showEpisodeMenu; showSubtitleMenu = false
+ 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
}
}
}
@@ -740,7 +1041,7 @@ fun VideoPlayerScreen(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(0.85f)
- .padding(bottom = 32.dp),
+ .padding(bottom = 64.dp),
) {
// 技术信息行(进度胶囊上方、胶囊外部):内核|格式|码率|帧率
Row(
@@ -758,16 +1059,20 @@ fun VideoPlayerScreen(
.fillMaxWidth()
.lumaGlass(
backdrop = null,
- radius = 24.dp,
+ 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 = 6.dp),
+ .padding(horizontal = 16.dp, vertical = 2.dp),
) {
- var totalDurationMs by remember { mutableLongStateOf(0L) }
- var currentPositionMs by remember { mutableLongStateOf(0L) }
+ 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) }
@@ -776,7 +1081,7 @@ fun VideoPlayerScreen(
while (true) {
if (player.duration > 0) {
totalDurationMs = player.duration
- if (!isSeeking) {
+ if (!isSeeking && seekPreviewPositionMs == null) {
currentPositionMs = player.currentPosition
progress = player.currentPosition.toFloat() / player.duration.toFloat()
val b = (player.bufferedPosition.toFloat() / player.duration).coerceIn(0f, 1f)
@@ -788,14 +1093,19 @@ fun VideoPlayerScreen(
}
Row(verticalAlignment = Alignment.CenterVertically) {
- Text(formatMs(currentPositionMs), color = Color.White, fontSize = 12.sp)
+ val swipePositionMs = seekPreviewPositionMs
+ Text(formatMs(swipePositionMs ?: currentPositionMs), color = Color.White, fontSize = 12.sp)
LumaSeekBar(
- value = if (isSeeking) seekPosition else progress,
+ value = when {
+ isSeeking -> seekPosition
+ swipePositionMs != null && totalDurationMs > 0L -> swipePositionMs.toFloat() / totalDurationMs
+ else -> progress
+ },
onValueChange = { v ->
isSeeking = true
seekPosition = v
currentPositionMs = (v * totalDurationMs).toLong()
- controlsLocked = true
+ controlsInteractionLocked = true
},
onValueChangeFinished = { finalValue ->
val targetPosition = (finalValue * totalDurationMs).toLong()
@@ -803,8 +1113,8 @@ fun VideoPlayerScreen(
progress = finalValue
currentPositionMs = targetPosition
isSeeking = false
- controlsLocked = false
- latestOnPlaybackProgress(targetPosition, !player.isPlaying, "TimeUpdate")
+ controlsInteractionLocked = false
+ latestOnPlaybackProgress(targetPosition, !player.isPlaying, "TimeUpdate", true)
},
progressContentDescription = stringResource(Res.string.player_progress),
modifier = Modifier.weight(1f).padding(horizontal = 12.dp),
@@ -819,52 +1129,248 @@ fun VideoPlayerScreen(
}
}
}
+ }
}
}
+ 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 ->
- PlayerLevelCapsule(
+ 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 = 72.dp),
+ .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) { showSubtitleMenu = false }
+ 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) }, onDismiss = { showEpisodeMenu = false }) {
- LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
- items(items = episodes) { ep ->
- LiquidEpisodeItem(
- episode = ep,
- displayIndex = ep.indexNumber ?: 0,
- selected = ep.id == currentEpisodeId,
- ) { showEpisodeMenu = false; onSelectEpisode(ep) }
+ 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,
- contentDescription: String? = null,
active: Boolean,
+ modifier: Modifier = Modifier,
+ contentDescription: String? = null,
size: Dp = 44.dp,
iconSize: Dp = 24.dp,
onClick: () -> Unit,
@@ -881,112 +1387,109 @@ private fun LiquidSurfaceButton(
)
Box(
- Modifier
+ 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 = touchSize / 2f,
+ radius = size / 2f,
level = LumaGlassLevel.Interactive,
tint = surfaceColor,
borderColor = Color.White.copy(alpha = 0.3f),
shadowElevation = 4.dp,
- )
- .clickable(indication = null, interactionSource = interactionSource, onClick = onClick),
- contentAlignment = Alignment.Center,
- ) {
- LumaIconView(icon, contentDescription = contentDescription, tint = Color.White, modifier = Modifier.size(iconSize))
- }
-}
-
-@Composable
-private fun LiquidSurfaceButton(
- icon: LumaIcon,
- active: Boolean,
- 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.26f) else Color.White.copy(alpha = 0.09f)
- val interactionSource = remember { MutableInteractionSource() }
- val pressed by interactionSource.collectIsPressedAsState()
- val scale by animateFloatAsState(
- targetValue = if (pressed) 0.94f else 1f,
- animationSpec = tween(180),
- label = "playerIconPress",
- )
- Box(
- Modifier
- .size(touchSize)
- .graphicsLayer { scaleX = scale; scaleY = scale }
- .lumaGlass(
- backdrop = bd,
- radius = touchSize / 2f,
- level = LumaGlassLevel.Interactive,
- tint = surfaceColor,
- borderColor = Color.White.copy(alpha = 0.22f),
- shadowElevation = 4.dp,
- )
- .clickable(indication = null, interactionSource = interactionSource, onClick = onClick),
- contentAlignment = Alignment.Center,
- ) {
- LumaIconView(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(iconSize))
+ ),
+ 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,
) {
- val bd = LocalGlassBackdrop.current
Box(
Modifier
.fillMaxSize()
- .background(Color.Black.copy(alpha = 0.3f))
+ .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(top = 16.dp, bottom = 16.dp, end = 16.dp)
- .fillMaxHeight()
- .width(320.dp)
+ .padding(end = 20.dp)
+ .fillMaxHeight(0.82f)
+ .width(340.dp)
.lumaGlass(
- backdrop = bd,
- radius = 24.dp,
+ backdrop = null,
+ radius = 22.dp,
level = LumaGlassLevel.Strong,
- tint = Color.White.copy(alpha = 0.08f),
- borderColor = Color.White.copy(alpha = 0.16f),
- shadowElevation = 8.dp,
+ tint = Color(0xFF101C31).copy(alpha = 0.94f),
+ borderColor = Color.White.copy(alpha = 0.26f),
+ shadowElevation = 12.dp,
)
.clickable(
- enabled = false,
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = {},
)
- .padding(20.dp),
+ .background(
+ Brush.verticalGradient(
+ listOf(
+ Color(0xFF243A59).copy(alpha = 0.90f),
+ Color(0xFF09111F).copy(alpha = 0.95f),
+ )
+ ),
+ RoundedCornerShape(22.dp),
+ )
+ .padding(18.dp),
) {
- Text(title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold)
- Spacer(Modifier.height(16.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, onDismiss: () -> Unit) {
+private fun SubtitleMenuContent(player: ExoPlayer, onTrackChanged: (String) -> Unit) {
+ val unnamedTrack = stringResource(Res.string.player_unnamed_subtitle)
val textTracks = remember(player.currentTracks) {
val list = mutableListOf()
player.currentTracks.groups.forEach { group ->
if (group.type == C.TRACK_TYPE_TEXT) {
repeat(group.length) { i ->
val fmt = group.getTrackFormat(i)
- val label = listOfNotNull(fmt.label, fmt.language?.uppercase()).joinToString(" - ")
+ 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))
}
}
@@ -997,16 +1500,17 @@ private fun SubtitleMenuContent(player: ExoPlayer, onDismiss: () -> Unit) {
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()
- onDismiss()
+ onTrackChanged("SubtitleTrackChange")
}
}
items(items = textTracks) { info ->
- LiquidMenuItem(text = info.label, selected = info.group.isSelected) {
+ 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()
- onDismiss()
+ onTrackChanged("SubtitleTrackChange")
}
}
}
@@ -1018,19 +1522,135 @@ private data class SubtitleTrackInfo(
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()
+ 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,
+ 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 bd = LocalGlassBackdrop.current
- val surface = if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.06f)
+ val surface = if (selected) Color(0xFF4D78B8).copy(alpha = 0.48f) else Color(0xFF16243A).copy(alpha = 0.72f)
Row(
Modifier
.fillMaxWidth()
.lumaGlass(
- backdrop = bd,
+ backdrop = null,
radius = LumaShapes.small,
level = LumaGlassLevel.Interactive,
tint = surface,
- borderColor = Color.White.copy(alpha = 0.16f),
+ borderColor = Color.White.copy(alpha = if (selected) 0.34f else 0.16f),
shadowElevation = 0.dp,
)
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onClick)
@@ -1126,53 +1746,156 @@ private fun SpeedUpCapsule(visible: Boolean, speed: Float, modifier: Modifier =
}
@Composable
-private fun PlayerLevelCapsule(label: String, level: Float, modifier: Modifier = Modifier) {
+private fun PlayerLevelControl(
+ kind: PlayerLevelGesture,
+ label: String,
+ level: Float,
+ modifier: Modifier = Modifier,
+) {
val bd = LocalGlassBackdrop.current
- 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 = 16.dp, vertical = 10.dp),
- contentAlignment = Alignment.Center,
+ 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(
- text = "$label ${(level * 100).roundToInt()}%",
- color = Color.White,
- fontSize = 16.sp,
- fontWeight = FontWeight.Bold,
- )
+ 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 LiquidEpisodeItem(episode: EmbyItem, displayIndex: Int, selected: Boolean, onClick: () -> Unit) {
+private fun DoubleTapFeedbackCapsule(
+ feedback: PlayerDoubleTapFeedback,
+ modifier: Modifier = Modifier,
+) {
val bd = LocalGlassBackdrop.current
- val surface = if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.06f)
+ 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 = bd,
+ backdrop = null,
radius = LumaShapes.small,
level = LumaGlassLevel.Interactive,
tint = surface,
- borderColor = Color.White.copy(alpha = 0.16f),
+ borderColor = Color.White.copy(alpha = if (selected) 0.34f else 0.16f),
shadowElevation = 0.dp,
)
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onClick)
- .padding(12.dp),
+ .padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
- Box(Modifier.size(32.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f)), contentAlignment = Alignment.Center) {
- Text(displayIndex.toString(), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold)
+ 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))
}
- Text(episode.name, color = Color.White, fontSize = 14.sp, modifier = Modifier.weight(1f).padding(start = 12.dp), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt
index 9c5a83b..87ffd5e 100644
--- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt
+++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt
@@ -10,6 +10,8 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
@@ -29,8 +31,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.Canvas
import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Slider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -42,6 +42,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
@@ -49,6 +50,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.ColorPainter
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.ContentScale
@@ -457,13 +459,12 @@ fun LumaSectionTitle(
}
@Composable
-@OptIn(ExperimentalMaterial3Api::class)
fun LumaSeekBar(
value: Float,
onValueChange: (Float) -> Unit,
onValueChangeFinished: (Float) -> Unit,
- progressContentDescription: String? = null,
modifier: Modifier = Modifier,
+ progressContentDescription: String? = null,
activeColor: Color = LumaColors.accent,
trackColor: Color = LumaColors.divider,
thumbColor: Color = activeColor,
@@ -474,56 +475,75 @@ fun LumaSeekBar(
val currentBuffered by rememberUpdatedState(bufferedValue.coerceIn(0f, 1f))
val currentOnValueChange by rememberUpdatedState(onValueChange)
val currentOnValueChangeFinished by rememberUpdatedState(onValueChangeFinished)
- val interactionSource = remember { MutableInteractionSource() }
- val dragging by interactionSource.collectIsPressedAsState()
- var gestureValue by remember { mutableFloatStateOf(currentValue) }
+ var dragging by remember { mutableStateOf(false) }
- Slider(
- value = currentValue,
- onValueChange = { newValue ->
- gestureValue = newValue
- currentOnValueChange(newValue)
- },
- onValueChangeFinished = { currentOnValueChangeFinished(gestureValue) },
- interactionSource = interactionSource,
- modifier = modifier
- .heightIn(min = 30.dp)
+ Canvas(
+ modifier
+ .fillMaxWidth()
+ .height(32.dp)
.semantics {
if (progressContentDescription != null) contentDescription = progressContentDescription
- },
- thumb = {
- Box(
- Modifier
- .size(if (dragging) 14.dp else 8.dp)
- .background(thumbColor, CircleShape)
- )
- },
- track = { sliderState ->
- Canvas(Modifier.fillMaxWidth().height(4.dp)) {
- val trackHeight = size.height
- val progressWidth = size.width * sliderState.value.coerceIn(0f, 1f)
- val bufferedWidth = size.width * currentBuffered
- val radius = trackHeight / 2f
- drawRoundRect(
- color = trackColor,
- size = Size(size.width, trackHeight),
- cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius),
- )
- if (bufferedWidth > progressWidth) {
- drawRoundRect(
- color = bufferColor,
- size = Size(bufferedWidth, trackHeight),
- cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius),
- )
- }
- drawRoundRect(
- color = activeColor,
- size = Size(progressWidth, trackHeight),
- cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius),
- )
}
- },
- )
+ .pointerInput(Unit) {
+ awaitEachGesture {
+ val down = awaitFirstDown()
+ val horizontalInset = 7.dp.toPx()
+ val usableWidth = (size.width - horizontalInset * 2f).coerceAtLeast(1f)
+ var gestureValue = ((down.position.x - horizontalInset) / usableWidth).coerceIn(0f, 1f)
+ dragging = true
+ currentOnValueChange(gestureValue)
+ var change = down
+ while (change.pressed) {
+ val event = awaitPointerEvent()
+ change = event.changes.firstOrNull { it.id == down.id } ?: break
+ if (change.pressed) {
+ gestureValue = ((change.position.x - horizontalInset) / usableWidth).coerceIn(0f, 1f)
+ currentOnValueChange(gestureValue)
+ change.consume()
+ }
+ }
+ dragging = false
+ currentOnValueChangeFinished(gestureValue)
+ }
+ },
+ ) {
+ val trackHeight = 5.dp.toPx()
+ val horizontalInset = 7.dp.toPx()
+ val trackWidth = (size.width - horizontalInset * 2f).coerceAtLeast(0f)
+ val trackTop = (size.height - trackHeight) / 2f
+ val cornerRadius = CornerRadius(trackHeight / 2f)
+ val activeWidth = trackWidth * currentValue
+ val bufferedWidth = trackWidth * currentBuffered
+ val thumbCenter = Offset(horizontalInset + activeWidth, size.height / 2f)
+
+ drawRoundRect(
+ color = trackColor,
+ topLeft = Offset(horizontalInset, trackTop),
+ size = Size(trackWidth, trackHeight),
+ cornerRadius = cornerRadius,
+ )
+ if (bufferedWidth > activeWidth) {
+ drawRoundRect(
+ color = bufferColor,
+ topLeft = Offset(horizontalInset + activeWidth, trackTop),
+ size = Size(bufferedWidth - activeWidth, trackHeight),
+ cornerRadius = cornerRadius,
+ )
+ }
+ if (activeWidth > 0f) {
+ drawRoundRect(
+ color = activeColor,
+ topLeft = Offset(horizontalInset, trackTop),
+ size = Size(activeWidth, trackHeight),
+ cornerRadius = cornerRadius,
+ )
+ }
+ drawCircle(
+ color = thumbColor,
+ radius = (if (dragging) 7.dp else 4.dp).toPx(),
+ center = thumbCenter,
+ )
+ }
}
@Composable
diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt
index 83ad323..1a818b7 100644
--- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt
+++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt
@@ -64,6 +64,7 @@ enum class LumaIcon(
Laptop(R.drawable.aurora_laptop, R.drawable.aurora_laptop_selected),
Tv(R.drawable.aurora_tv, R.drawable.aurora_tv_selected),
Cast(R.drawable.aurora_cast, R.drawable.aurora_cast_selected),
+ Brightness(R.drawable.aurora_brightness, R.drawable.aurora_brightness_selected),
Speaker(R.drawable.aurora_speaker, R.drawable.aurora_speaker_selected),
Headphones(R.drawable.aurora_headphones, R.drawable.aurora_headphones_selected),
Remote(R.drawable.aurora_remote, R.drawable.aurora_remote_selected),
diff --git a/app/src/main/res/drawable/aurora_brightness.xml b/app/src/main/res/drawable/aurora_brightness.xml
new file mode 100644
index 0000000..5f503f6
--- /dev/null
+++ b/app/src/main/res/drawable/aurora_brightness.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/aurora_brightness_selected.xml b/app/src/main/res/drawable/aurora_brightness_selected.xml
new file mode 100644
index 0000000..f09a38a
--- /dev/null
+++ b/app/src/main/res/drawable/aurora_brightness_selected.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 81015fe..8a0f183 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -143,6 +143,21 @@
Playback failed
Subtitles
Episode list
+ No episodes are available
+ Audio tracks
+ Disable audio
+ Audio track
+ Subtitle
+ Playback tools
+ Chapter markers
+ No chapter markers are available
+ Chapter %1$s
+ Skip intro
+ Skip outro
+ Confirm before continuous playback
+ Continue playing?
+ Play next episode
+ Not now
Close
Seek back 10 seconds
Previous episode
@@ -153,6 +168,11 @@
Playback progress
Brightness
Volume
+ Lock screen
+ Unlock screen
+ Switch orientation
+ Forward
+ Rewind
Disable subtitles
This resource cannot be played
Failed to resolve playback URL
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 215dcfa..6bd8cd2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -143,6 +143,21 @@
播放失败
字幕
剧集列表
+ 未找到可用剧集
+ 音轨
+ 关闭音轨
+ 音轨
+ 字幕
+ 播放工具
+ 章节标记
+ 该媒体没有章节标记
+ 章节 %1$s
+ 跳过片头
+ 跳过片尾
+ 连播前确认
+ 连续播放确认
+ 播放下一集
+ 暂不继续播放
关闭
后退 10 秒
上一集
@@ -153,6 +168,11 @@
播放进度
亮度
音量
+ 锁定屏幕
+ 解锁屏幕
+ 切换横竖屏
+ 快进
+ 快退
关闭字幕
播放器无法播放该资源
获取播放地址失败
diff --git a/app/src/test/kotlin/com/example/myapplication/data/PlaybackReportingTest.kt b/app/src/test/kotlin/com/example/myapplication/data/PlaybackReportingTest.kt
new file mode 100644
index 0000000..66872d9
--- /dev/null
+++ b/app/src/test/kotlin/com/example/myapplication/data/PlaybackReportingTest.kt
@@ -0,0 +1,20 @@
+package com.example.myapplication.data
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PlaybackReportingTest {
+ @Test
+ fun periodicProgressWaitsForTenSecondInterval() {
+ assertFalse(shouldSendPlaybackProgress("TimeUpdate", false, 5_000L, 0L, 5_000L, 0L))
+ assertTrue(shouldSendPlaybackProgress("TimeUpdate", false, 10_000L, 0L, 10_000L, 0L))
+ }
+
+ @Test
+ fun seekAndTrackChangesBypassPeriodicThrottle() {
+ assertTrue(shouldSendPlaybackProgress("TimeUpdate", true, 1_000L, 0L, 1_000L, 0L))
+ assertTrue(shouldSendPlaybackProgress("AudioTrackChange", false, 1_000L, 0L, 1_000L, 0L))
+ assertTrue(shouldSendPlaybackProgress("SubtitleTrackChange", false, 1_000L, 0L, 1_000L, 0L))
+ }
+}
diff --git a/app/src/test/kotlin/com/example/myapplication/screens/VideoPlayerGestureTest.kt b/app/src/test/kotlin/com/example/myapplication/screens/VideoPlayerGestureTest.kt
new file mode 100644
index 0000000..9fe4ad0
--- /dev/null
+++ b/app/src/test/kotlin/com/example/myapplication/screens/VideoPlayerGestureTest.kt
@@ -0,0 +1,82 @@
+package com.example.myapplication.screens
+
+import com.example.myapplication.data.EmbyChapter
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class VideoPlayerGestureTest {
+ @Test
+ fun swipeRightSeeksForwardWithinTwoMinuteWindow() {
+ assertEquals(
+ 90_000L,
+ calculateSwipeSeekPosition(
+ startPositionMs = 30_000L,
+ durationMs = 300_000L,
+ horizontalDragPx = 500f,
+ viewportWidthPx = 1_000f,
+ ),
+ )
+ }
+
+ @Test
+ fun swipeLeftClampsAtStart() {
+ assertEquals(
+ 0L,
+ calculateSwipeSeekPosition(
+ startPositionMs = 10_000L,
+ durationMs = 300_000L,
+ horizontalDragPx = -1_000f,
+ viewportWidthPx = 1_000f,
+ ),
+ )
+ }
+
+ @Test
+ fun swipeRightClampsAtDuration() {
+ assertEquals(
+ 100_000L,
+ calculateSwipeSeekPosition(
+ startPositionMs = 90_000L,
+ durationMs = 100_000L,
+ horizontalDragPx = 1_000f,
+ viewportWidthPx = 1_000f,
+ ),
+ )
+ }
+
+ @Test
+ fun doubleTapRegionsResolveToIndependentActions() {
+ assertEquals(PlayerDoubleTapAction.SeekBackward, resolveDoubleTapAction(100f, 900f))
+ assertEquals(PlayerDoubleTapAction.TogglePlayback, resolveDoubleTapAction(450f, 900f))
+ assertEquals(PlayerDoubleTapAction.SeekForward, resolveDoubleTapAction(800f, 900f))
+ }
+
+ @Test
+ fun tenSecondSeekClampsToVideoBounds() {
+ assertEquals(0L, calculateTenSecondSeekPosition(5_000L, 60_000L, forward = false))
+ assertEquals(60_000L, calculateTenSecondSeekPosition(55_000L, 60_000L, forward = true))
+ assertEquals(30_000L, calculateTenSecondSeekPosition(20_000L, 60_000L, forward = true))
+ }
+
+ @Test
+ fun chapterMarkersResolveIntroAndCreditsPositions() {
+ val chapters = listOf(
+ EmbyChapter(startPositionTicks = 850_000_000L, name = "Intro end", markerType = "IntroEnd"),
+ EmbyChapter(startPositionTicks = 5_400_000_000L, name = "Credits", markerType = "CreditsStart"),
+ )
+
+ assertEquals(85_000L, findIntroEndPositionMs(chapters))
+ assertEquals(540_000L, findCreditsStartPositionMs(chapters))
+ }
+
+ @Test
+ fun localizedChapterNamesAreUsedWhenMarkerTypesAreMissing() {
+ val chapters = listOf(
+ EmbyChapter(startPositionTicks = 600_000_000L, name = "片头结束"),
+ EmbyChapter(startPositionTicks = 4_200_000_000L, name = "片尾"),
+ )
+
+ assertEquals(60_000L, findIntroEndPositionMs(chapters))
+ assertEquals(420_000L, findCreditsStartPositionMs(chapters))
+ }
+}