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