From 391797d3775b4fc76c6518ab4252bf2ae6922242 Mon Sep 17 00:00:00 2001
From: beyond
Date: Sun, 6 Sep 2026 23:26:49 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=92=AD=E6=94=BE=E5=99=A8?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=A2=9E=E5=8A=A0=E6=92=AD=E6=94=BE?=
=?UTF-8?q?=E5=99=A8=E8=AE=BE=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/build.gradle.kts | 4 +-
.../com/example/myapplication/MainActivity.kt | 2 +
.../myapplication/data/PlayerSettings.kt | 82 ++++
.../myapplication/navigation/AppNavHost.kt | 6 +
.../myapplication/navigation/AppRoute.kt | 1 +
.../screens/MediaDetailScreen.kt | 30 +-
.../screens/SettingsChildScreens.kt | 278 ++++++++++++++
.../myapplication/screens/SettingsScreen.kt | 14 +-
.../screens/VideoPlayerScreen.kt | 354 ++++++++++++++----
app/src/main/res/values-en/strings.xml | 29 +-
app/src/main/res/values/strings.xml | 29 +-
11 files changed, 722 insertions(+), 107 deletions(-)
create mode 100644 app/src/main/kotlin/com/example/myapplication/data/PlayerSettings.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index af47c1d..fb0604b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -23,8 +23,8 @@ android {
applicationId = "com.example.myapplication"
minSdk = 24
targetSdk = 35
- versionCode = 1
- versionName = "1.0.0"
+ versionCode = 2
+ versionName = "1.0.1"
buildConfigField("String", "TMDB_ACCESS_TOKEN", "\"${tmdbAccessToken.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
}
diff --git a/app/src/main/kotlin/com/example/myapplication/MainActivity.kt b/app/src/main/kotlin/com/example/myapplication/MainActivity.kt
index 0869181..7b6db69 100644
--- a/app/src/main/kotlin/com/example/myapplication/MainActivity.kt
+++ b/app/src/main/kotlin/com/example/myapplication/MainActivity.kt
@@ -7,6 +7,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.example.myapplication.data.FavoriteRepository
import com.example.myapplication.data.IconLibraryRepository
+import com.example.myapplication.data.PlayerSettings
import com.example.myapplication.data.ServerRepository
import com.example.myapplication.data.WatchlistRepository
import com.example.myapplication.ui.AppLanguage
@@ -24,6 +25,7 @@ class MainActivity : ComponentActivity() {
// These are fast SharedPreferences/DB singleton inits — safe on main thread
AppLanguage.init(this)
AppTheme.init(this)
+ PlayerSettings.init(this)
IconLibraryRepository.init(this)
ServerRepository.init(this)
FavoriteRepository.init(this)
diff --git a/app/src/main/kotlin/com/example/myapplication/data/PlayerSettings.kt b/app/src/main/kotlin/com/example/myapplication/data/PlayerSettings.kt
new file mode 100644
index 0000000..9bf7099
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/data/PlayerSettings.kt
@@ -0,0 +1,82 @@
+package com.example.myapplication.data
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.setValue
+
+object PlayerSettings {
+ private const val PREF_NAME = "player_settings"
+
+ // Keys
+ private const val KEY_DEFAULT_SPEED = "default_speed"
+ private const val KEY_LONG_PRESS_SPEED = "long_press_speed"
+ private const val KEY_SEEK_SECONDS = "seek_seconds"
+ private const val KEY_AUTO_SKIP_INTRO = "auto_skip_intro"
+ private const val KEY_AUTO_SKIP_OUTRO = "auto_skip_outro"
+ private const val KEY_CONFIRM_CONTINUOUS = "confirm_before_continuous"
+ private const val KEY_PROGRESS_REPORT_INTERVAL = "progress_report_interval_ms"
+
+ // In-memory state
+ var defaultSpeed by mutableFloatStateOf(1.0f)
+ var longPressSpeed by mutableFloatStateOf(2.0f)
+ var seekSeconds by mutableIntStateOf(10)
+ var autoSkipIntro by mutableStateOf(false)
+ var autoSkipOutro by mutableStateOf(false)
+ var confirmBeforeContinuous by mutableStateOf(true)
+ var progressReportIntervalMs by mutableLongStateOf(10_000L)
+
+ private var prefs: SharedPreferences? = null
+
+ fun init(context: Context) {
+ prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ prefs?.let { p ->
+ defaultSpeed = p.getFloat(KEY_DEFAULT_SPEED, 1.0f)
+ longPressSpeed = p.getFloat(KEY_LONG_PRESS_SPEED, 2.0f)
+ seekSeconds = p.getInt(KEY_SEEK_SECONDS, 10)
+ autoSkipIntro = p.getBoolean(KEY_AUTO_SKIP_INTRO, false)
+ autoSkipOutro = p.getBoolean(KEY_AUTO_SKIP_OUTRO, false)
+ confirmBeforeContinuous = p.getBoolean(KEY_CONFIRM_CONTINUOUS, true)
+ progressReportIntervalMs = p.getLong(KEY_PROGRESS_REPORT_INTERVAL, 10_000L)
+ }
+ }
+
+ fun updateDefaultSpeed(value: Float) {
+ defaultSpeed = value
+ prefs?.edit()?.putFloat(KEY_DEFAULT_SPEED, value)?.apply()
+ }
+
+ fun updateLongPressSpeed(value: Float) {
+ longPressSpeed = value
+ prefs?.edit()?.putFloat(KEY_LONG_PRESS_SPEED, value)?.apply()
+ }
+
+ fun updateSeekSeconds(value: Int) {
+ seekSeconds = value
+ prefs?.edit()?.putInt(KEY_SEEK_SECONDS, value)?.apply()
+ }
+
+ fun updateAutoSkipIntro(value: Boolean) {
+ autoSkipIntro = value
+ prefs?.edit()?.putBoolean(KEY_AUTO_SKIP_INTRO, value)?.apply()
+ }
+
+ fun updateAutoSkipOutro(value: Boolean) {
+ autoSkipOutro = value
+ prefs?.edit()?.putBoolean(KEY_AUTO_SKIP_OUTRO, value)?.apply()
+ }
+
+ fun updateConfirmBeforeContinuous(value: Boolean) {
+ confirmBeforeContinuous = value
+ prefs?.edit()?.putBoolean(KEY_CONFIRM_CONTINUOUS, value)?.apply()
+ }
+
+ fun updateProgressReportIntervalMs(value: Long) {
+ progressReportIntervalMs = value
+ prefs?.edit()?.putLong(KEY_PROGRESS_REPORT_INTERVAL, value)?.apply()
+ }
+}
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 dd3a9b6..8826816 100644
--- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
+++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
@@ -35,6 +35,7 @@ import com.example.myapplication.R as Res
import com.example.myapplication.screens.AboutScreen
import com.example.myapplication.screens.AddServerScreen
import com.example.myapplication.screens.CacheManagementScreen
+import com.example.myapplication.screens.PlaybackSettingsScreen
import com.example.myapplication.screens.HomeScreen
import com.example.myapplication.screens.HomeLibraryAllScreen
import com.example.myapplication.screens.IconLibraryManagerScreen
@@ -201,6 +202,7 @@ fun AppNavHost(
servers = servers,
onOpenCacheDetail = { navigateOnce(AppRoute.CacheManagement.pattern) },
onOpenAbout = { navigateOnce(AppRoute.About.pattern) },
+ onOpenPlaybackSettings = { navigateOnce(AppRoute.PlaybackSettings.pattern) },
)
}
}
@@ -343,6 +345,10 @@ fun AppNavHost(
CacheManagementScreen(onBack = { navController.popBackStack() })
}
+ composable(AppRoute.PlaybackSettings.pattern) {
+ PlaybackSettingsScreen(onBack = { navController.popBackStack() })
+ }
+
composable(AppRoute.About.pattern) {
AboutScreen(onBack = { navController.popBackStack() })
}
diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt
index f2d13ff..1c8407e 100644
--- a/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt
+++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt
@@ -21,6 +21,7 @@ sealed interface AppRoute {
}
data object IconLibraryManager : AppRoute { override val pattern = "library/icons" }
data object CacheManagement : AppRoute { override val pattern = "settings/cache" }
+ data object PlaybackSettings : AppRoute { override val pattern = "settings/playback" }
data object About : AppRoute { override val pattern = "settings/about" }
data object ServerEditor : AppRoute {
override val pattern = "library/server/{serverId}"
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt
index a218640..7d44c8b 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt
@@ -629,7 +629,7 @@ fun MediaDetailScreen(
.weight(1f)
.heightIn(min = 48.dp)
.clickable(enabled = episodes.isNotEmpty()) { showAllEpisodes = true },
- contentAlignment = Alignment.Center,
+ contentAlignment = Alignment.CenterEnd,
) {
Text(
text = stringResource(Res.string.detail_view_all),
@@ -722,22 +722,15 @@ fun MediaDetailScreen(
}
}
// Pinned above the scrolling content so it stays visible while browsing episodes.
- Box(
- Modifier
+ LiquidGlassButton(
+ onClick = onClose,
+ modifier = Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
.padding(start = 14.dp, top = 10.dp)
- .size(48.dp)
- .lumaGlass(
- backdrop = LocalGlassBackdrop.current,
- radius = 24.dp,
- level = LumaGlassLevel.Floating,
- tint = Color.Black.copy(alpha = 0.30f),
- borderColor = Color.White.copy(alpha = 0.16f),
- shadowElevation = 4.dp,
- )
- .clickable(onClick = onClose),
- contentAlignment = Alignment.Center,
+ .size(48.dp),
+ cornerRadius = 24.dp,
+ fill = GlassTokens.glassFillStrong,
) {
LumaIconView(
icon = LumaIcon.Back,
@@ -1760,15 +1753,6 @@ private fun EpisodeListDialog(
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
- Text(
- text = stringResource(Res.string.detail_close),
- color = LocalDetailSecondary.current,
- fontSize = 12.sp,
- fontWeight = FontWeight.Medium,
- modifier = Modifier
- .heightIn(min = 48.dp)
- .clickable(onClick = onDismiss),
- )
}
Spacer(Modifier.height(12.dp))
LazyColumn(
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt b/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt
index db17079..4718dd4 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt
@@ -14,8 +14,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@@ -38,6 +40,7 @@ import androidx.compose.ui.unit.sp
import com.example.myapplication.R
import com.example.myapplication.BuildConfig
import com.example.myapplication.data.IconLibraryRepository
+import com.example.myapplication.data.PlayerSettings
import com.example.myapplication.ui.GlassConfirmDialog
import com.example.myapplication.ui.GlassTokens
import com.example.myapplication.ui.LocalGlassBackdrop
@@ -45,6 +48,7 @@ import com.example.myapplication.ui.design.LumaColors
import com.example.myapplication.ui.design.LumaIcon
import com.example.myapplication.ui.design.LumaIconView
import com.example.myapplication.ui.design.LumaPageTopBar
+import com.example.myapplication.ui.design.LumaSettingRow
import com.example.myapplication.ui.design.LumaShapes
import com.example.myapplication.ui.design.LumaSpacing
import com.example.myapplication.ui.design.LumaTypography
@@ -293,6 +297,275 @@ fun AboutScreen(onBack: () -> Unit) {
}
}
+@Composable
+fun PlaybackSettingsScreen(onBack: () -> Unit) {
+ var showDefaultSpeedMenu by remember { mutableStateOf(false) }
+ var showLongPressSpeedMenu by remember { mutableStateOf(false) }
+ var showSeekSecondsMenu by remember { mutableStateOf(false) }
+ var showReportIntervalMenu by remember { mutableStateOf(false) }
+
+ val defaultSpeedOptions = listOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f)
+ val longPressSpeedOptions = listOf(1.5f, 2.0f, 2.5f, 3.0f, 4.0f)
+ val seekSecondsOptions = listOf(5, 10, 15, 30)
+ val reportIntervalOptions = listOf(5_000L, 10_000L, 30_000L, 60_000L)
+
+ Box(Modifier.fillMaxSize()) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(LumaColors.background),
+ )
+ CompositionLocalProvider(LocalGlassBackdrop provides null) {
+ Column(Modifier.fillMaxSize()) {
+ LumaPageTopBar(
+ title = stringResource(R.string.settings_playback_settings),
+ onBack = onBack,
+ backContentDescription = stringResource(R.string.back),
+ )
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ // Speed section
+ item {
+ Text(
+ stringResource(R.string.settings_playback_section),
+ color = GlassTokens.subtext,
+ fontSize = LumaTypography.secondary,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(start = LumaSpacing.sm),
+ )
+ }
+ item {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .settingsModuleSurface(radius = LumaShapes.large)
+ .padding(vertical = 4.dp),
+ ) {
+ LumaSettingRow(
+ icon = LumaIcon.Play,
+ title = stringResource(R.string.settings_default_speed),
+ subtitle = "${PlayerSettings.defaultSpeed}x",
+ onClick = { showDefaultSpeedMenu = true },
+ )
+ DividerLine()
+ LumaSettingRow(
+ icon = LumaIcon.Speed,
+ title = stringResource(R.string.settings_long_press_speed),
+ subtitle = "${PlayerSettings.longPressSpeed}x",
+ onClick = { showLongPressSpeedMenu = true },
+ )
+ }
+ }
+ // Seek section
+ item {
+ Text(
+ stringResource(R.string.settings_seek_seconds),
+ color = GlassTokens.subtext,
+ fontSize = LumaTypography.secondary,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(start = LumaSpacing.sm),
+ )
+ }
+ item {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .settingsModuleSurface(radius = LumaShapes.large)
+ .padding(vertical = 4.dp),
+ ) {
+ LumaSettingRow(
+ icon = LumaIcon.Next,
+ title = stringResource(R.string.settings_seek_seconds),
+ subtitle = "${PlayerSettings.seekSeconds}s",
+ onClick = { showSeekSecondsMenu = true },
+ )
+ }
+ }
+ // Skip section
+ item {
+ Text(
+ "跳过",
+ color = GlassTokens.subtext,
+ fontSize = LumaTypography.secondary,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(start = LumaSpacing.sm),
+ )
+ }
+ item {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .settingsModuleSurface(radius = LumaShapes.large)
+ .padding(vertical = 4.dp),
+ ) {
+ SwitchSettingRow(
+ icon = LumaIcon.Next,
+ title = "自动跳过片头/片尾",
+ subtitle = "播放到标记位置时自动跳过",
+ checked = PlayerSettings.autoSkipIntro && PlayerSettings.autoSkipOutro,
+ onCheckedChange = { enabled ->
+ PlayerSettings.updateAutoSkipIntro(enabled)
+ PlayerSettings.updateAutoSkipOutro(enabled)
+ },
+ )
+ }
+ }
+ // Advanced section
+ item {
+ Text(
+ "高级",
+ color = GlassTokens.subtext,
+ fontSize = LumaTypography.secondary,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(start = LumaSpacing.sm),
+ )
+ }
+ item {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .settingsModuleSurface(radius = LumaShapes.large)
+ .padding(vertical = 4.dp),
+ ) {
+ LumaSettingRow(
+ icon = LumaIcon.Settings,
+ title = stringResource(R.string.settings_progress_report_interval),
+ subtitle = "${PlayerSettings.progressReportIntervalMs / 1000}s",
+ onClick = { showReportIntervalMenu = true },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Speed selection dialogs
+ if (showDefaultSpeedMenu) {
+ SingleChoiceDialog(
+ title = stringResource(R.string.settings_default_speed),
+ options = defaultSpeedOptions.map { "${it}x" },
+ selectedIndex = defaultSpeedOptions.indexOf(PlayerSettings.defaultSpeed).coerceAtLeast(0),
+ onSelect = { idx -> PlayerSettings.updateDefaultSpeed(defaultSpeedOptions[idx]) },
+ onDismiss = { showDefaultSpeedMenu = false },
+ )
+ }
+ if (showLongPressSpeedMenu) {
+ SingleChoiceDialog(
+ title = stringResource(R.string.settings_long_press_speed),
+ options = longPressSpeedOptions.map { "${it}x" },
+ selectedIndex = longPressSpeedOptions.indexOf(PlayerSettings.longPressSpeed).coerceAtLeast(0),
+ onSelect = { idx -> PlayerSettings.updateLongPressSpeed(longPressSpeedOptions[idx]) },
+ onDismiss = { showLongPressSpeedMenu = false },
+ )
+ }
+ if (showSeekSecondsMenu) {
+ SingleChoiceDialog(
+ title = stringResource(R.string.settings_seek_seconds),
+ options = seekSecondsOptions.map { "${it}s" },
+ selectedIndex = seekSecondsOptions.indexOf(PlayerSettings.seekSeconds).coerceAtLeast(0),
+ onSelect = { idx -> PlayerSettings.updateSeekSeconds(seekSecondsOptions[idx]) },
+ onDismiss = { showSeekSecondsMenu = false },
+ )
+ }
+ if (showReportIntervalMenu) {
+ SingleChoiceDialog(
+ title = stringResource(R.string.settings_progress_report_interval),
+ options = reportIntervalOptions.map { "${it / 1000}s" },
+ selectedIndex = reportIntervalOptions.indexOf(PlayerSettings.progressReportIntervalMs).coerceAtLeast(0),
+ onSelect = { idx -> PlayerSettings.updateProgressReportIntervalMs(reportIntervalOptions[idx]) },
+ onDismiss = { showReportIntervalMenu = false },
+ )
+ }
+ }
+}
+
+@Composable
+private fun SwitchSettingRow(
+ icon: LumaIcon,
+ title: String,
+ subtitle: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable { onCheckedChange(!checked) }
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ LumaIconView(icon, contentDescription = null, tint = LumaColors.accent, modifier = Modifier.size(21.dp))
+ Spacer(Modifier.size(12.dp))
+ Column(Modifier.weight(1f)) {
+ Text(title, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
+ Spacer(Modifier.height(2.dp))
+ Text(subtitle, color = GlassTokens.subtext, fontSize = LumaTypography.caption)
+ }
+ top.yukonga.miuix.kmp.basic.Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ )
+ }
+}
+
+@Composable
+private fun SingleChoiceDialog(
+ title: String,
+ options: List,
+ selectedIndex: Int,
+ onSelect: (Int) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
+ Box(
+ Modifier
+ .width(280.dp)
+ .settingsModuleSurface(radius = LumaShapes.large)
+ .padding(vertical = LumaSpacing.sm),
+ ) {
+ Column {
+ Text(
+ title,
+ color = GlassTokens.text,
+ fontSize = LumaTypography.title,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = LumaSpacing.md, vertical = LumaSpacing.sm),
+ )
+ options.forEachIndexed { index, label ->
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .heightIn(min = 44.dp)
+ .padding(horizontal = 4.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(if (index == selectedIndex) LumaColors.accentSoft else Color.Transparent)
+ .clickable(indication = null, interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }) {
+ onSelect(index)
+ onDismiss()
+ }
+ .padding(horizontal = 16.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ label,
+ color = if (index == selectedIndex) LumaColors.accent else LumaColors.text,
+ fontSize = LumaTypography.body,
+ fontWeight = if (index == selectedIndex) FontWeight.SemiBold else FontWeight.Normal,
+ modifier = Modifier.weight(1f),
+ )
+ if (index == selectedIndex) {
+ LumaIconView(LumaIcon.Check, contentDescription = null, tint = LumaColors.accent, modifier = Modifier.size(18.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
@Composable
private fun AboutSectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Column(
@@ -332,6 +605,11 @@ private fun AboutInfoRow(icon: LumaIcon, title: String, subtitle: String) {
}
}
+@Composable
+private fun DividerLine() {
+ Box(Modifier.fillMaxWidth().height(0.5.dp).padding(horizontal = LumaSpacing.md).background(LumaColors.divider))
+}
+
internal fun Modifier.settingsModuleSurface(radius: Dp): Modifier {
val shape = RoundedCornerShape(radius)
val fill = if (LumaColors.isDark) LumaColors.backgroundElevated else Color.White
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt
index df01731..b25f68a 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt
@@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.myapplication.R
import com.example.myapplication.data.EmbyServer
+import com.example.myapplication.data.PlayerSettings
import com.example.myapplication.ui.AppLanguage
import com.example.myapplication.ui.AppTheme
import com.example.myapplication.ui.GlassMode
@@ -78,6 +79,7 @@ fun SettingsScreen(
servers: List = emptyList(),
onOpenCacheDetail: () -> Unit = {},
onOpenAbout: () -> Unit = {},
+ onOpenPlaybackSettings: () -> Unit = {},
) {
var showLanguageDialog by remember { mutableStateOf(false) }
var languageMenuMounted by remember { mutableStateOf(false) }
@@ -154,8 +156,8 @@ fun SettingsScreen(
LumaSettingRow(
icon = LumaIcon.Play,
title = stringResource(R.string.settings_playback_row),
- subtitle = stringResource(R.string.settings_playback_row_sub),
- onClick = {},
+ subtitle = playbackSettingsLabel(),
+ onClick = { onOpenPlaybackSettings() },
)
}
@@ -406,3 +408,11 @@ private fun LanguageMenuOverlay(
private fun settingsPopupContainerColor(): Color = LumaColors.settingsPopupBg.copy(alpha = 0.72f)
private fun settingsPopupBorderColor(): Color = LumaColors.settingsPopupBorder
+
+@Composable
+private fun playbackSettingsLabel(): String {
+ val parts = mutableListOf()
+ parts.add("${PlayerSettings.defaultSpeed}x")
+ if (PlayerSettings.autoSkipIntro && PlayerSettings.autoSkipOutro) parts.add("自动跳过")
+ return parts.joinToString(" · ")
+}
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 6769c82..c015cd1 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
@@ -57,6 +58,7 @@ 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.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
@@ -105,7 +107,10 @@ import androidx.media3.ui.PlayerView
import com.example.myapplication.data.EmbyItem
import com.example.myapplication.data.EmbyChapter
import com.example.myapplication.data.EmbyRepository
+import com.example.myapplication.data.PlayerSettings
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.onGloballyPositioned
import coil.compose.AsyncImage
import com.example.myapplication.R as Res
import com.example.myapplication.ui.design.LumaIcon
@@ -118,6 +123,7 @@ import com.example.myapplication.ui.design.lumaGlass
import com.example.myapplication.ui.design.LumaShapes
import com.example.myapplication.ui.design.LumaColors
import com.example.myapplication.ui.design.LumaSpacing
+import com.example.myapplication.ui.GlassButton
import com.example.myapplication.ui.LocalGlassBackdrop
import kotlinx.coroutines.delay
import kotlinx.coroutines.CoroutineScope
@@ -222,7 +228,8 @@ internal fun calculateTenSecondSeekPosition(
durationMs: Long,
forward: Boolean,
): Long {
- val targetPositionMs = currentPositionMs + if (forward) 10_000L else -10_000L
+ val seekMs = PlayerSettings.seekSeconds * 1000L
+ val targetPositionMs = currentPositionMs + if (forward) seekMs else -seekMs
return if (durationMs > 0L) targetPositionMs.coerceIn(0L, durationMs) else targetPositionMs.coerceAtLeast(0L)
}
@@ -256,7 +263,7 @@ private enum class PlayerDoubleTapFeedback {
/** Consolidated menu state — replaces 5 separate boolean flags. */
private enum class PlayerMenu {
- None, Subtitle, Episode, Audio, PlaybackTools, NextEpisodeConfirmation,
+ None, Subtitle, Episode, Audio, Speed, PlaybackTools, NextEpisodeConfirmation,
}
private tailrec fun Context.findActivity(): Activity? = when (this) {
@@ -293,11 +300,10 @@ fun VideoPlayerScreen(
val lifecycleOwner = LocalLifecycleOwner.current
var activeMenu by remember { mutableStateOf(PlayerMenu.None) }
var showNextEpisodeConfirmation by remember { mutableStateOf(false) }
- var confirmBeforeContinuousPlayback by remember {
- val prefs = context.getSharedPreferences("player_settings", Context.MODE_PRIVATE)
- mutableStateOf(prefs.getBoolean("confirm_before_continuous", true))
- }
+ var confirmBeforeContinuousPlayback by remember { mutableStateOf(PlayerSettings.confirmBeforeContinuous) }
var audioDisabledByUser by remember { mutableStateOf(false) }
+ // 同步引用:onTracksChanged 回调中直接读取,不受 rememberUpdatedState 重组延迟影响
+ val audioDisabledRef = remember { object { var value = false } }
val speedMonitor = remember { SpeedMonitor() }
val transferListener = remember {
object : TransferListener {
@@ -337,7 +343,6 @@ fun VideoPlayerScreen(
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) }
@@ -373,6 +378,11 @@ fun VideoPlayerScreen(
var controlsVisible by remember { mutableStateOf(true) }
var controlsInteractionLocked by remember { mutableStateOf(false) }
+ // 按钮位置追踪,用于弹窗定位
+ var subtitleBtnBounds by remember { mutableStateOf(null) }
+ var audioBtnBounds by remember { mutableStateOf(null) }
+ var speedBtnBounds by remember { mutableStateOf(null) }
+ var toolsBtnBounds by remember { mutableStateOf(null) }
LaunchedEffect(
controlsVisible,
isPlaying,
@@ -437,7 +447,7 @@ fun VideoPlayerScreen(
if (!playbackUrl.isNullOrBlank() && player.currentMediaItem != null && player.isPlaying) {
latestOnPlaybackProgress(player.currentPosition, false, "TimeUpdate", false)
}
- delay(10_000)
+ delay(PlayerSettings.progressReportIntervalMs)
}
}
@@ -469,6 +479,7 @@ fun VideoPlayerScreen(
if (!playbackUrl.isNullOrBlank()) {
player.setMediaItem(MediaItem.fromUri(playbackUrl), initialPositionMs.coerceAtLeast(0L))
player.prepare()
+ player.setPlaybackSpeed(PlayerSettings.defaultSpeed)
player.play()
latestOnPlaybackStarted(initialPositionMs.coerceAtLeast(0L))
}
@@ -511,7 +522,7 @@ fun VideoPlayerScreen(
}
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
- if (latestAudioDisabledByUser || tracks.groups.any { it.type == C.TRACK_TYPE_AUDIO && it.isSelected }) return
+ if (audioDisabledRef.value || 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()
@@ -657,11 +668,11 @@ fun VideoPlayerScreen(
}
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
+ val targetSpeed = PlayerSettings.longPressSpeed
player.setPlaybackSpeed(targetSpeed)
if (!player.isPlaying) player.play()
speedUpVisible = true
@@ -952,6 +963,7 @@ fun VideoPlayerScreen(
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Box(Modifier.onGloballyPositioned { subtitleBtnBounds = it.boundsInRoot() }) {
LiquidSurfaceButton(
icon = LumaIcon.Subtitle,
contentDescription = stringResource(Res.string.player_subtitle),
@@ -960,6 +972,7 @@ fun VideoPlayerScreen(
) {
activeMenu = if (activeMenu == PlayerMenu.Subtitle) PlayerMenu.None else PlayerMenu.Subtitle
}
+ }
LiquidSurfaceButton(
icon = LumaIcon.Watch,
contentDescription = stringResource(Res.string.player_episode_list),
@@ -968,6 +981,7 @@ fun VideoPlayerScreen(
) {
activeMenu = if (activeMenu == PlayerMenu.Episode) PlayerMenu.None else PlayerMenu.Episode
}
+ Box(Modifier.onGloballyPositioned { audioBtnBounds = it.boundsInRoot() }) {
LiquidSurfaceButton(
icon = LumaIcon.Headphones,
contentDescription = stringResource(Res.string.player_audio_tracks),
@@ -976,6 +990,18 @@ fun VideoPlayerScreen(
) {
activeMenu = if (activeMenu == PlayerMenu.Audio) PlayerMenu.None else PlayerMenu.Audio
}
+ }
+ Box(Modifier.onGloballyPositioned { speedBtnBounds = it.boundsInRoot() }) {
+ LiquidSurfaceButton(
+ icon = LumaIcon.Speed,
+ contentDescription = stringResource(Res.string.player_speed_menu),
+ active = activeMenu == PlayerMenu.Speed,
+ size = 40.dp,
+ ) {
+ activeMenu = if (activeMenu == PlayerMenu.Speed) PlayerMenu.None else PlayerMenu.Speed
+ }
+ }
+ Box(Modifier.onGloballyPositioned { toolsBtnBounds = it.boundsInRoot() }) {
LiquidSurfaceButton(
icon = LumaIcon.More,
contentDescription = stringResource(Res.string.player_playback_tools),
@@ -984,6 +1010,7 @@ fun VideoPlayerScreen(
) {
activeMenu = if (activeMenu == PlayerMenu.PlaybackTools) PlayerMenu.None else PlayerMenu.PlaybackTools
}
+ }
}
}
if (serverName.isNotBlank()) {
@@ -1007,6 +1034,48 @@ fun VideoPlayerScreen(
}
}
+ // 自动跳过片头/片尾浮窗按钮
+ if (!isScreenLocked && controlsVisible) {
+ val introEndPos = findIntroEndPositionMs(chapters)
+ val creditsStartPos = findCreditsStartPositionMs(chapters)
+ val currentMs = player.currentPosition
+
+ // 跳过片头按钮:当前位置在片头区域内
+ if (PlayerSettings.autoSkipIntro && introEndPos != null && currentMs < introEndPos) {
+ GlassButton(
+ text = stringResource(Res.string.player_skip_intro_float),
+ onClick = {
+ player.seekTo(introEndPos)
+ latestOnPlaybackProgress(introEndPos, !player.isPlaying, "TimeUpdate", true)
+ },
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(end = 24.dp, bottom = 84.dp),
+ )
+ }
+
+ // 跳过片尾按钮:当前位置到达片尾区域
+ if (PlayerSettings.autoSkipOutro && creditsStartPos != null && currentMs >= creditsStartPos) {
+ GlassButton(
+ text = stringResource(Res.string.player_skip_outro_float),
+ onClick = {
+ val currentIndex = episodes.indexOfFirst { it.id == currentEpisodeId }
+ val nextEpisode = episodes.getOrNull(currentIndex + 1)
+ if (nextEpisode != null) {
+ onSelectEpisode(nextEpisode)
+ } else {
+ val targetMs = (player.duration - 500L).coerceAtLeast(0L)
+ player.seekTo(targetMs)
+ latestOnPlaybackProgress(targetMs, !player.isPlaying, "TimeUpdate", true)
+ }
+ },
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(end = 24.dp, bottom = 84.dp),
+ )
+ }
+ }
+
if (playbackUrl != null) {
Column(
Modifier
@@ -1135,7 +1204,7 @@ fun VideoPlayerScreen(
SpeedUpCapsule(
visible = speedUpVisible,
speed = speedUpValue,
- modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding().padding(top = 24.dp),
+ modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding().padding(top = 56.dp),
)
levelGesture?.let { kind ->
PlayerLevelControl(
@@ -1154,13 +1223,9 @@ fun VideoPlayerScreen(
DoubleTapFeedbackCapsule(
feedback = feedback,
modifier = Modifier
- .align(
- when (feedback) {
- PlayerDoubleTapFeedback.SeekBackward -> Alignment.CenterStart
- PlayerDoubleTapFeedback.SeekForward -> Alignment.CenterEnd
- else -> Alignment.Center
- }
- )
+ .align(Alignment.TopCenter)
+ .statusBarsPadding()
+ .padding(top = 56.dp)
.padding(horizontal = if (
feedback == PlayerDoubleTapFeedback.SeekBackward || feedback == PlayerDoubleTapFeedback.SeekForward
) 64.dp else 0.dp),
@@ -1169,8 +1234,10 @@ fun VideoPlayerScreen(
if (activeMenu == PlayerMenu.Subtitle) {
LiquidGlassPanel(
- title = stringResource(Res.string.player_subtitle),
+ title = "",
onDismiss = { activeMenu = PlayerMenu.None },
+ panelWidth = 255.dp,
+ buttonBounds = subtitleBtnBounds,
) {
SubtitleMenuContent(
player = player,
@@ -1209,12 +1276,17 @@ fun VideoPlayerScreen(
}
if (activeMenu == PlayerMenu.Audio) {
LiquidGlassPanel(
- title = stringResource(Res.string.player_audio_tracks),
+ title = "",
onDismiss = { activeMenu = PlayerMenu.None },
+ panelWidth = 255.dp,
+ buttonBounds = audioBtnBounds,
) {
AudioMenuContent(
player = player,
- onAudioDisabledChange = { audioDisabledByUser = it },
+ onAudioDisabledChange = { disabled ->
+ audioDisabledByUser = disabled
+ audioDisabledRef.value = disabled
+ },
onTrackChanged = { eventName ->
latestOnPlaybackProgress(player.currentPosition, !player.isPlaying, eventName, true)
@@ -1222,35 +1294,52 @@ fun VideoPlayerScreen(
)
}
}
+ if (activeMenu == PlayerMenu.Speed) {
+ LiquidGlassPanel(
+ title = "",
+ onDismiss = { activeMenu = PlayerMenu.None },
+ panelWidth = 255.dp,
+ buttonBounds = speedBtnBounds,
+ ) {
+ SpeedPanelContent(
+ currentSpeed = player.playbackParameters.speed,
+ onSpeedChange = { speed ->
+ player.setPlaybackSpeed(speed)
+ PlayerSettings.updateDefaultSpeed(speed)
+ },
+ )
+ }
+ }
if (activeMenu == PlayerMenu.PlaybackTools) {
LiquidGlassPanel(
- title = stringResource(Res.string.player_playback_tools),
+ title = "",
onDismiss = { activeMenu = PlayerMenu.None },
+ panelWidth = 255.dp,
+ buttonBounds = toolsBtnBounds,
) {
PlaybackToolsMenuContent(
chapters = chapters,
- confirmBeforeContinuousPlayback = confirmBeforeContinuousPlayback,
- onConfirmBeforeContinuousPlaybackChange = { newValue ->
- confirmBeforeContinuousPlayback = newValue
- context.getSharedPreferences("player_settings", Context.MODE_PRIVATE)
- .edit().putBoolean("confirm_before_continuous", newValue).apply()
- },
+ autoSkipIntro = PlayerSettings.autoSkipIntro,
+ onAutoSkipIntroChange = { PlayerSettings.updateAutoSkipIntro(it) },
+ autoSkipOutro = PlayerSettings.autoSkipOutro,
+ onAutoSkipOutroChange = { PlayerSettings.updateAutoSkipOutro(it) },
+ currentPositionMs = player.currentPosition,
onSeek = { targetPositionMs ->
player.seekTo(targetPositionMs)
latestOnPlaybackProgress(targetPositionMs, !player.isPlaying, "TimeUpdate", true)
-
+
},
onSkipOutro = {
val currentIndex = episodes.indexOfFirst { it.id == currentEpisodeId }
val nextEpisode = episodes.getOrNull(currentIndex + 1)
if (nextEpisode != null) {
-
+
onSelectEpisode(nextEpisode)
} else {
val targetPositionMs = (player.duration - 500L).coerceAtLeast(0L)
player.seekTo(targetPositionMs)
latestOnPlaybackProgress(targetPositionMs, !player.isPlaying, "TimeUpdate", true)
-
+
}
},
)
@@ -1408,8 +1497,22 @@ private fun LiquidGlassPanel(
title: String,
titleLogoUrl: String = "",
onDismiss: () -> Unit,
+ panelWidth: Dp = 340.dp,
+ buttonBounds: Rect? = null,
content: @Composable () -> Unit,
) {
+ val density = LocalDensity.current
+ val isButtonPanel = buttonBounds != null
+ val horizontalPadding = if (isButtonPanel) {
+ val btnCenterX = buttonBounds!!.center.x
+ val panelWidthPx = with(density) { panelWidth.toPx() }
+ val endPaddingPx = with(density) { 20.dp.toPx() }
+ val maxStartPx = with(density) { LocalConfiguration.current.screenWidthDp.dp.toPx() } - panelWidthPx - endPaddingPx
+ val startPaddingPx = (btnCenterX - panelWidthPx / 2f).coerceIn(0f, maxStartPx.coerceAtLeast(0f))
+ with(density) { startPaddingPx.toDp() }
+ } else {
+ 20.dp
+ }
Box(
Modifier
.fillMaxSize()
@@ -1422,13 +1525,13 @@ private fun LiquidGlassPanel(
)
)
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onDismiss),
- contentAlignment = Alignment.CenterEnd,
+ contentAlignment = if (isButtonPanel) Alignment.TopStart else Alignment.CenterEnd,
) {
Column(
Modifier
- .padding(end = 20.dp)
+ .then(if (isButtonPanel) Modifier.padding(start = horizontalPadding, end = 20.dp, top = 80.dp) else Modifier.padding(end = horizontalPadding))
.fillMaxHeight(0.82f)
- .width(340.dp)
+ .width(panelWidth)
.lumaGlass(
backdrop = null,
radius = 22.dp,
@@ -1453,17 +1556,19 @@ private fun LiquidGlassPanel(
)
.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)
+ if (title.isNotBlank() || titleLogoUrl.isNotBlank()) {
+ 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))
}
- Spacer(Modifier.height(14.dp))
Box(Modifier.weight(1f)) { content() }
}
}
@@ -1488,21 +1593,43 @@ private fun SubtitleMenuContent(player: ExoPlayer, onTrackChanged: (String) -> U
}
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")
+ // 手动追踪选中索引,避免依赖尚未刷新的 Tracks.Group.isTrackSelected
+ var selectedIndex by remember {
+ mutableIntStateOf(
+ textTracks.indexOfFirst { it.group.isTrackSelected(it.index) }.coerceAtLeast(0)
+ )
+ }
+ var subtitleDisabled by remember { mutableStateOf(
+ !player.currentTracks.groups.any { it.type == C.TRACK_TYPE_TEXT && it.isSelected }
+ ) }
+ if (textTracks.isEmpty()) {
+ Text(
+ text = stringResource(Res.string.player_no_subtitles),
+ color = Color.White.copy(alpha = 0.62f),
+ fontSize = 13.sp,
+ modifier = Modifier.padding(vertical = 12.dp),
+ )
+ } else {
+ LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ item {
+ LiquidMenuItem(text = stringResource(Res.string.player_disable_subtitle), selected = subtitleDisabled) {
+ subtitleDisabled = true
+ 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")
+ items(items = textTracks) { info ->
+ val index = textTracks.indexOf(info)
+ LiquidMenuItem(text = info.label, selected = !subtitleDisabled && index == selectedIndex) {
+ subtitleDisabled = false
+ selectedIndex = 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")
+ }
}
}
}
@@ -1538,12 +1665,32 @@ private fun AudioMenuContent(
}
list
}
+ // 手动追踪选中索引,避免依赖尚未刷新的 Tracks.Group.isTrackSelected
+ var selectedAudioIndex by remember {
+ mutableIntStateOf(
+ audioTracks.indexOfFirst { it.group.isTrackSelected(it.index) }.coerceAtLeast(0)
+ )
+ }
+ var audioDisabled by remember { mutableStateOf(
+ !player.currentTracks.groups.any { it.type == C.TRACK_TYPE_AUDIO && it.isSelected }
+ ) }
+ if (audioTracks.isEmpty()) {
+ Text(
+ text = stringResource(Res.string.player_no_audio),
+ color = Color.White.copy(alpha = 0.62f),
+ fontSize = 13.sp,
+ modifier = Modifier.padding(vertical = 12.dp),
+ )
+ } else {
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 },
+ selected = audioDisabled,
) {
+ audioDisabled = true
+ // 先标记 audioDisabledByUser,再执行 track 操作,
+ // 确保 onTracksChanged 的自动选择逻辑被跳过
onAudioDisabledChange(true)
player.trackSelectionParameters = player.trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, true)
@@ -1552,7 +1699,10 @@ private fun AudioMenuContent(
}
}
items(items = audioTracks) { info ->
- LiquidMenuItem(text = info.label, selected = info.group.isTrackSelected(info.index)) {
+ val index = audioTracks.indexOf(info)
+ LiquidMenuItem(text = info.label, selected = !audioDisabled && index == selectedAudioIndex) {
+ audioDisabled = false
+ selectedAudioIndex = index
onAudioDisabledChange(false)
player.trackSelectionParameters = player.trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
@@ -1563,6 +1713,7 @@ private fun AudioMenuContent(
}
}
}
+ }
}
private data class AudioTrackInfo(
@@ -1571,23 +1722,69 @@ private data class AudioTrackInfo(
val label: String,
)
+@Composable
+private fun SpeedPanelContent(
+ currentSpeed: Float,
+ onSpeedChange: (Float) -> Unit,
+) {
+ val speedOptions = listOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f)
+ // 手动追踪选中速度,player.playbackParameters 不是 Compose state
+ var selectedSpeed by remember { mutableFloatStateOf(currentSpeed) }
+ LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ items(items = speedOptions) { speed ->
+ LiquidMenuItem(
+ text = "${speed}x",
+ selected = speed == selectedSpeed,
+ ) {
+ selectedSpeed = speed
+ onSpeedChange(speed)
+ }
+ }
+ }
+}
+
@Composable
private fun PlaybackToolsMenuContent(
chapters: List,
- confirmBeforeContinuousPlayback: Boolean,
- onConfirmBeforeContinuousPlaybackChange: (Boolean) -> Unit,
+ autoSkipIntro: Boolean,
+ onAutoSkipIntroChange: (Boolean) -> Unit,
+ autoSkipOutro: Boolean,
+ onAutoSkipOutroChange: (Boolean) -> Unit,
+ currentPositionMs: Long,
onSeek: (Long) -> Unit,
onSkipOutro: () -> Unit,
) {
val introEndPositionMs = findIntroEndPositionMs(chapters)
val creditsStartPositionMs = findCreditsStartPositionMs(chapters)
+ val autoSkipEnabled = autoSkipIntro && autoSkipOutro
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ // 自动跳过片头/片尾(一个开关控制)
item {
LiquidMenuItem(
- text = stringResource(Res.string.player_confirm_before_continuous_playback),
- selected = confirmBeforeContinuousPlayback,
- ) { onConfirmBeforeContinuousPlaybackChange(!confirmBeforeContinuousPlayback) }
+ text = "自动跳过片头/尾",
+ selected = autoSkipEnabled,
+ ) {
+ val newValue = !autoSkipEnabled
+ onAutoSkipIntroChange(newValue)
+ onAutoSkipOutroChange(newValue)
+ }
}
+ // 标记片头/片尾位置
+ item {
+ LiquidMenuItem(
+ text = stringResource(Res.string.player_mark_intro),
+ subtitle = stringResource(Res.string.player_mark_intro_sub),
+ selected = false,
+ ) { /* 标记片头 */ }
+ }
+ item {
+ LiquidMenuItem(
+ text = stringResource(Res.string.player_mark_outro),
+ subtitle = stringResource(Res.string.player_mark_outro_sub),
+ selected = false,
+ ) { /* 标记片尾 */ }
+ }
+ // 手动跳过(仅当章节标记存在时显示)
if (introEndPositionMs != null) {
item {
LiquidMenuItem(text = stringResource(Res.string.player_skip_intro), selected = false) {
@@ -1632,7 +1829,7 @@ private fun PlaybackToolsMenuContent(
}
@Composable
-private fun LiquidMenuItem(text: String, selected: Boolean, onClick: () -> Unit) {
+private fun LiquidMenuItem(text: String, selected: Boolean, subtitle: String? = null, onClick: () -> Unit) {
val surface = if (selected) LumaColors.playerAccent.copy(alpha = 0.48f) else LumaColors.playerItemBg.copy(alpha = 0.72f)
Row(
Modifier
@@ -1649,7 +1846,13 @@ private fun LiquidMenuItem(text: String, selected: Boolean, onClick: () -> Unit)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
- Text(text, color = Color.White, fontSize = 14.sp, modifier = Modifier.weight(1f))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(text, color = Color.White, fontSize = 14.sp)
+ if (subtitle != null) {
+ Spacer(Modifier.height(2.dp))
+ Text(subtitle, color = Color.White.copy(alpha = 0.55f), fontSize = 11.sp)
+ }
+ }
if (selected) LumaIconView(LumaIcon.Play, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp))
}
}
@@ -1839,6 +2042,7 @@ private fun PlayerLevelControl(
@Composable
private fun DoubleTapFeedbackCapsule(
feedback: PlayerDoubleTapFeedback,
+ seekSeconds: Int = PlayerSettings.seekSeconds,
modifier: Modifier = Modifier,
) {
val bd = LocalGlassBackdrop.current
@@ -1848,14 +2052,12 @@ private fun DoubleTapFeedbackCapsule(
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
- }
- )
+ val text = when (feedback) {
+ PlayerDoubleTapFeedback.SeekBackward -> stringResource(Res.string.player_seek_backward, seekSeconds)
+ PlayerDoubleTapFeedback.SeekForward -> stringResource(Res.string.player_seek_forward, seekSeconds)
+ PlayerDoubleTapFeedback.Pause -> stringResource(Res.string.player_pause)
+ PlayerDoubleTapFeedback.Play -> stringResource(Res.string.player_play)
+ }
Row(
modifier
.lumaGlass(
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index ab4e404..e534f8f 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -234,6 +234,8 @@
Playback tools
Chapter markers
No chapter markers are available
+ No subtitles available
+ No audio tracks available
Chapter %1$s
Skip intro
Skip outro
@@ -242,12 +244,12 @@
Play next episode
Not now
Close
- Seek back 10 seconds
+ Seek back %1$d seconds
Previous episode
Play
Pause
Next episode
- Seek forward 10 seconds
+ Seek forward %1$d seconds
Playback progress
Brightness
Volume
@@ -398,4 +400,27 @@
Network error: %s
Unknown
Clear search
+
+ Playback Settings
+ Default playback speed
+ Long-press speed
+ Double-tap seek
+ Auto skip intro
+ Auto skip when reaching intro
+ Auto skip outro
+ Auto skip when reaching outro
+ Confirm before next episode
+ Show confirmation on continuous play
+ Progress report interval
+ Skip intro
+ Skip outro
+ Playback speed
+ %1$sx
+ Auto skip intro
+ Auto skip outro
+ Mark intro
+ Mark outro
+ Mark current time as intro end
+ Mark current time as outro start
+ Current: %1$s
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index efa9b3c..d5dd7ea 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -234,6 +234,8 @@
播放工具
章节标记
该媒体没有章节标记
+ 该媒体没有可用字幕
+ 该媒体没有可用音轨
章节 %1$s
跳过片头
跳过片尾
@@ -242,12 +244,12 @@
播放下一集
暂不继续播放
关闭
- 后退 10 秒
+ 后退 %1$d 秒
上一集
播放
暂停
下一集
- 前进 10 秒
+ 前进 %1$d 秒
播放进度
亮度
音量
@@ -398,4 +400,27 @@
网络错误: %s
未知
清除搜索
+
+ 播放设置
+ 默认播放速度
+ 长按倍速
+ 双击快进/快退
+ 自动跳过片头
+ 播放到片头位置时自动跳过
+ 自动跳过片尾
+ 播放到片尾位置时自动跳过
+ 播放下集前确认
+ 连播时弹出确认面板
+ 进度回报间隔
+ 跳过片头
+ 跳过片尾
+ 播放速度
+ %1$sx
+ 自动跳过片头
+ 自动跳过片尾
+ 标记片头
+ 标记片尾
+ 将当前时间标记为片头结束
+ 将当前时间标记为片尾开始
+ 当前位置: %1$s