优化播放器功能,增加播放器设置

This commit is contained in:
beyond committed 2026-09-06 23:26:49 +08:00
1 parent e3e8120e8b
commit 391797d377
11 files changed
+719 -104

No files matched your search

+2 -2
View File
@@ -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,19 +1294,36 @@ 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)
@@ -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(
+27 -2
View File
@@ -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>
+27 -2
View File
@@ -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>