优化播放器功能,增加播放器设置
This commit is contained in:
1 parent
e3e8120e8b
commit
391797d377
11 files changed
+722
-107
No files matched your search
@@ -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("\"", "\\\"")}\"")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String>,
|
||||
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
|
||||
|
||||
@@ -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<EmbyServer> = 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<String>()
|
||||
parts.add("${PlayerSettings.defaultSpeed}x")
|
||||
if (PlayerSettings.autoSkipIntro && PlayerSettings.autoSkipOutro) parts.add("自动跳过")
|
||||
return parts.joinToString(" · ")
|
||||
}
|
||||
@@ -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<Boolean>(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<Rect?>(null) }
|
||||
var audioBtnBounds by remember { mutableStateOf<Rect?>(null) }
|
||||
var speedBtnBounds by remember { mutableStateOf<Rect?>(null) }
|
||||
var toolsBtnBounds by remember { mutableStateOf<Rect?>(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<EmbyChapter>,
|
||||
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(
|
||||
|
||||
@@ -234,6 +234,8 @@
|
||||
<string name="player_playback_tools">Playback tools</string>
|
||||
<string name="player_chapters">Chapter markers</string>
|
||||
<string name="player_no_chapters">No chapter markers are available</string>
|
||||
<string name="player_no_subtitles">No subtitles available</string>
|
||||
<string name="player_no_audio">No audio tracks available</string>
|
||||
<string name="player_chapter_at">Chapter %1$s</string>
|
||||
<string name="player_skip_intro">Skip intro</string>
|
||||
<string name="player_skip_outro">Skip outro</string>
|
||||
@@ -242,12 +244,12 @@
|
||||
<string name="player_play_next_episode">Play next episode</string>
|
||||
<string name="player_cancel_continuous_playback">Not now</string>
|
||||
<string name="player_close">Close</string>
|
||||
<string name="player_seek_backward">Seek back 10 seconds</string>
|
||||
<string name="player_seek_backward">Seek back %1$d seconds</string>
|
||||
<string name="player_previous_episode">Previous episode</string>
|
||||
<string name="player_play">Play</string>
|
||||
<string name="player_pause">Pause</string>
|
||||
<string name="player_next_episode">Next episode</string>
|
||||
<string name="player_seek_forward">Seek forward 10 seconds</string>
|
||||
<string name="player_seek_forward">Seek forward %1$d seconds</string>
|
||||
<string name="player_progress">Playback progress</string>
|
||||
<string name="player_brightness">Brightness</string>
|
||||
<string name="player_volume">Volume</string>
|
||||
@@ -398,4 +400,27 @@
|
||||
<string name="error_network">Network error: %s</string>
|
||||
<string name="error_unknown">Unknown</string>
|
||||
<string name="clear_search">Clear search</string>
|
||||
<!-- Playback Settings -->
|
||||
<string name="settings_playback_settings">Playback Settings</string>
|
||||
<string name="settings_default_speed">Default playback speed</string>
|
||||
<string name="settings_long_press_speed">Long-press speed</string>
|
||||
<string name="settings_seek_seconds">Double-tap seek</string>
|
||||
<string name="settings_auto_skip_intro">Auto skip intro</string>
|
||||
<string name="settings_auto_skip_intro_sub">Auto skip when reaching intro</string>
|
||||
<string name="settings_auto_skip_outro">Auto skip outro</string>
|
||||
<string name="settings_auto_skip_outro_sub">Auto skip when reaching outro</string>
|
||||
<string name="settings_confirm_continuous">Confirm before next episode</string>
|
||||
<string name="settings_confirm_continuous_sub">Show confirmation on continuous play</string>
|
||||
<string name="settings_progress_report_interval">Progress report interval</string>
|
||||
<string name="player_skip_intro_float">Skip intro</string>
|
||||
<string name="player_skip_outro_float">Skip outro</string>
|
||||
<string name="player_speed_menu">Playback speed</string>
|
||||
<string name="player_speed_value">%1$sx</string>
|
||||
<string name="player_auto_skip_intro">Auto skip intro</string>
|
||||
<string name="player_auto_skip_outro">Auto skip outro</string>
|
||||
<string name="player_mark_intro">Mark intro</string>
|
||||
<string name="player_mark_outro">Mark outro</string>
|
||||
<string name="player_mark_intro_sub">Mark current time as intro end</string>
|
||||
<string name="player_mark_outro_sub">Mark current time as outro start</string>
|
||||
<string name="player_mark_position">Current: %1$s</string>
|
||||
</resources>
|
||||
@@ -234,6 +234,8 @@
|
||||
<string name="player_playback_tools">播放工具</string>
|
||||
<string name="player_chapters">章节标记</string>
|
||||
<string name="player_no_chapters">该媒体没有章节标记</string>
|
||||
<string name="player_no_subtitles">该媒体没有可用字幕</string>
|
||||
<string name="player_no_audio">该媒体没有可用音轨</string>
|
||||
<string name="player_chapter_at">章节 %1$s</string>
|
||||
<string name="player_skip_intro">跳过片头</string>
|
||||
<string name="player_skip_outro">跳过片尾</string>
|
||||
@@ -242,12 +244,12 @@
|
||||
<string name="player_play_next_episode">播放下一集</string>
|
||||
<string name="player_cancel_continuous_playback">暂不继续播放</string>
|
||||
<string name="player_close">关闭</string>
|
||||
<string name="player_seek_backward">后退 10 秒</string>
|
||||
<string name="player_seek_backward">后退 %1$d 秒</string>
|
||||
<string name="player_previous_episode">上一集</string>
|
||||
<string name="player_play">播放</string>
|
||||
<string name="player_pause">暂停</string>
|
||||
<string name="player_next_episode">下一集</string>
|
||||
<string name="player_seek_forward">前进 10 秒</string>
|
||||
<string name="player_seek_forward">前进 %1$d 秒</string>
|
||||
<string name="player_progress">播放进度</string>
|
||||
<string name="player_brightness">亮度</string>
|
||||
<string name="player_volume">音量</string>
|
||||
@@ -398,4 +400,27 @@
|
||||
<string name="error_network">网络错误: %s</string>
|
||||
<string name="error_unknown">未知</string>
|
||||
<string name="clear_search">清除搜索</string>
|
||||
<!-- 播放设置 -->
|
||||
<string name="settings_playback_settings">播放设置</string>
|
||||
<string name="settings_default_speed">默认播放速度</string>
|
||||
<string name="settings_long_press_speed">长按倍速</string>
|
||||
<string name="settings_seek_seconds">双击快进/快退</string>
|
||||
<string name="settings_auto_skip_intro">自动跳过片头</string>
|
||||
<string name="settings_auto_skip_intro_sub">播放到片头位置时自动跳过</string>
|
||||
<string name="settings_auto_skip_outro">自动跳过片尾</string>
|
||||
<string name="settings_auto_skip_outro_sub">播放到片尾位置时自动跳过</string>
|
||||
<string name="settings_confirm_continuous">播放下集前确认</string>
|
||||
<string name="settings_confirm_continuous_sub">连播时弹出确认面板</string>
|
||||
<string name="settings_progress_report_interval">进度回报间隔</string>
|
||||
<string name="player_skip_intro_float">跳过片头</string>
|
||||
<string name="player_skip_outro_float">跳过片尾</string>
|
||||
<string name="player_speed_menu">播放速度</string>
|
||||
<string name="player_speed_value">%1$sx</string>
|
||||
<string name="player_auto_skip_intro">自动跳过片头</string>
|
||||
<string name="player_auto_skip_outro">自动跳过片尾</string>
|
||||
<string name="player_mark_intro">标记片头</string>
|
||||
<string name="player_mark_outro">标记片尾</string>
|
||||
<string name="player_mark_intro_sub">将当前时间标记为片头结束</string>
|
||||
<string name="player_mark_outro_sub">将当前时间标记为片尾开始</string>
|
||||
<string name="player_mark_position">当前位置: %1$s</string>
|
||||
</resources>
|
||||
Reference in new issue
Block a user