Просмотр исходного кода

feat: 有声书封面比例设置

HapeLee 2 месяцев назад
Родитель
Сommit
9a16ca0178

+ 8 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -171,6 +171,7 @@ object PreferKey {
     const val defaultReadAloudInterface = "defaultReadAloudInterface"
     const val defaultReadAloudInterface = "defaultReadAloudInterface"
     const val readAloudPlayerBgMode = "readAloudPlayerBgMode"
     const val readAloudPlayerBgMode = "readAloudPlayerBgMode"
     const val audioPlayBgMode = "audioPlayBgMode"
     const val audioPlayBgMode = "audioPlayBgMode"
+    const val audioPlayCoverRatio = "audioPlayCoverRatio"
     const val showLastUpdateTime = "showLastUpdateTime"
     const val showLastUpdateTime = "showLastUpdateTime"
     const val showWaitUpCount = "showWaitUpCount"
     const val showWaitUpCount = "showWaitUpCount"
     const val clearWebViewData = "clearWebViewData"
     const val clearWebViewData = "clearWebViewData"
@@ -482,3 +483,10 @@ object ReadAloudBgMode {
     const val FlowingLight = 2
     const val FlowingLight = 2
     const val Transparent = 3
     const val Transparent = 3
 }
 }
+
+object CoverRatio {
+    const val Square = 0
+    const val Circle = 1
+    const val Book = 2
+    const val Unrestricted = 3
+}

+ 6 - 0
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayContract.kt

@@ -1,6 +1,7 @@
 package io.legado.app.ui.book.audio
 package io.legado.app.ui.book.audio
 
 
 import androidx.compose.runtime.Stable
 import androidx.compose.runtime.Stable
+import io.legado.app.constant.CoverRatio
 import io.legado.app.constant.ReadAloudBgMode
 import io.legado.app.constant.ReadAloudBgMode
 import io.legado.app.constant.Status
 import io.legado.app.constant.Status
 import io.legado.app.model.AudioPlay
 import io.legado.app.model.AudioPlay
@@ -26,6 +27,7 @@ data class AudioPlayUiState(
     val timerMinutes: Int = 0,
     val timerMinutes: Int = 0,
     val playMode: AudioPlay.PlayMode = AudioPlay.PlayMode.LIST_END_STOP,
     val playMode: AudioPlay.PlayMode = AudioPlay.PlayMode.LIST_END_STOP,
     val bgMode: Int = ReadAloudBgMode.Blur,
     val bgMode: Int = ReadAloudBgMode.Blur,
+    val coverRatio: Int = CoverRatio.Unrestricted,
     val canLogin: Boolean = false,
     val canLogin: Boolean = false,
     val wakeLockEnabled: Boolean = false,
     val wakeLockEnabled: Boolean = false,
     val mediaControlEnabled: Boolean = false,
     val mediaControlEnabled: Boolean = false,
@@ -44,6 +46,9 @@ sealed interface AudioPlaySheet {
     data object SkipCredits : AudioPlaySheet
     data object SkipCredits : AudioPlaySheet
     data object Gain : AudioPlaySheet
     data object Gain : AudioPlaySheet
     data object Log : AudioPlaySheet
     data object Log : AudioPlaySheet
+    data object CoverRatioOptions : AudioPlaySheet
+    data object Speed : AudioPlaySheet
+    data object Timer : AudioPlaySheet
 }
 }
 
 
 sealed interface AudioPlayIntent {
 sealed interface AudioPlayIntent {
@@ -62,6 +67,7 @@ sealed interface AudioPlayIntent {
     data class SetCloseCredits(val seconds: Int) : AudioPlayIntent
     data class SetCloseCredits(val seconds: Int) : AudioPlayIntent
     data class SetAudioGain(val gainMb: Int) : AudioPlayIntent
     data class SetAudioGain(val gainMb: Int) : AudioPlayIntent
     data object CycleBgMode : AudioPlayIntent
     data object CycleBgMode : AudioPlayIntent
+    data class SetCoverRatio(val value: Int) : AudioPlayIntent
     data class OpenSheet(val sheet: AudioPlaySheet) : AudioPlayIntent
     data class OpenSheet(val sheet: AudioPlaySheet) : AudioPlayIntent
     data object DismissSheet : AudioPlayIntent
     data object DismissSheet : AudioPlayIntent
     data object ChangeSource : AudioPlayIntent
     data object ChangeSource : AudioPlayIntent

+ 177 - 68
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayScreen.kt

@@ -1,6 +1,5 @@
 package io.legado.app.ui.book.audio
 package io.legado.app.ui.book.audio
 
 
-import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.gestures.Orientation
@@ -12,6 +11,7 @@ import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.asPaddingValues
 import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.navigationBars
 import androidx.compose.foundation.layout.navigationBars
@@ -20,18 +20,25 @@ import androidx.compose.foundation.layout.statusBars
 import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.foundation.pager.VerticalPager
 import androidx.compose.foundation.pager.VerticalPager
 import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
 import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
+import androidx.compose.material.icons.automirrored.filled.Login
 import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
 import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
+import androidx.compose.material.icons.filled.Book
 import androidx.compose.material.icons.filled.ContentCopy
 import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.Crop
+import androidx.compose.material.icons.filled.CropFree
+import androidx.compose.material.icons.filled.CropSquare
 import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.FastForward
 import androidx.compose.material.icons.filled.FastForward
 import androidx.compose.material.icons.filled.Headphones
 import androidx.compose.material.icons.filled.Headphones
 import androidx.compose.material.icons.filled.Info
 import androidx.compose.material.icons.filled.Info
 import androidx.compose.material.icons.filled.KeyboardArrowUp
 import androidx.compose.material.icons.filled.KeyboardArrowUp
 import androidx.compose.material.icons.filled.Lock
 import androidx.compose.material.icons.filled.Lock
-import androidx.compose.material.icons.filled.Login
+import androidx.compose.material.icons.filled.RadioButtonUnchecked
 import androidx.compose.material.icons.filled.Repeat
 import androidx.compose.material.icons.filled.Repeat
 import androidx.compose.material.icons.filled.RepeatOne
 import androidx.compose.material.icons.filled.RepeatOne
 import androidx.compose.material.icons.filled.Shuffle
 import androidx.compose.material.icons.filled.Shuffle
@@ -40,7 +47,6 @@ import androidx.compose.material.icons.filled.SkipPrevious
 import androidx.compose.material.icons.filled.Speed
 import androidx.compose.material.icons.filled.Speed
 import androidx.compose.material.icons.filled.SwapHoriz
 import androidx.compose.material.icons.filled.SwapHoriz
 import androidx.compose.material.icons.filled.Timer
 import androidx.compose.material.icons.filled.Timer
-import androidx.compose.material.icons.filled.VolumeUp
 import androidx.compose.material.icons.filled.WbTwilight
 import androidx.compose.material.icons.filled.WbTwilight
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
@@ -52,7 +58,6 @@ import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.shadow
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.graphics.vector.ImageVector
@@ -67,6 +72,7 @@ import dev.chrisbanes.haze.hazeEffect
 import dev.chrisbanes.haze.hazeSource
 import dev.chrisbanes.haze.hazeSource
 import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
 import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
 import io.legado.app.R
 import io.legado.app.R
+import io.legado.app.constant.CoverRatio
 import io.legado.app.constant.ReadAloudBgMode
 import io.legado.app.constant.ReadAloudBgMode
 import io.legado.app.constant.Status
 import io.legado.app.constant.Status
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.PlaybackTimer
@@ -84,6 +90,8 @@ import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
+import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.player.AnimatedPlayPauseButton
 import io.legado.app.ui.widget.components.player.AnimatedPlayPauseButton
 import io.legado.app.ui.widget.components.player.PlayerAdjustmentSlider
 import io.legado.app.ui.widget.components.player.PlayerAdjustmentSlider
@@ -110,11 +118,6 @@ fun AudioPlayScreenContent(
         orientation = Orientation.Vertical,
         orientation = Orientation.Vertical,
     )
     )
     val coroutineScope = rememberCoroutineScope()
     val coroutineScope = rememberCoroutineScope()
-    var activeAdjustment by remember { mutableStateOf<AudioAdjustment?>(null) }
-    var speedPreview by remember(state.speed) { mutableFloatStateOf(state.speed) }
-    var timerPreview by remember(state.timerMinutes) {
-        mutableFloatStateOf(state.timerMinutes.toFloat())
-    }
     var menuExpanded by remember { mutableStateOf(false) }
     var menuExpanded by remember { mutableStateOf(false) }
     val pagerHazeState = remember { HazeState() }
     val pagerHazeState = remember { HazeState() }
     val hazeEnabled =
     val hazeEnabled =
@@ -126,13 +129,7 @@ fun AudioPlayScreenContent(
     )
     )
     val pageContentPadding = PaddingValues(
     val pageContentPadding = PaddingValues(
         top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 88.dp,
         top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 88.dp,
-        bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + when (
-            activeAdjustment
-        ) {
-            null -> 216.dp
-            AudioAdjustment.Speed -> 264.dp
-            AudioAdjustment.Timer -> 344.dp
-        },
+        bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 216.dp,
     )
     )
     AppScaffold(
     AppScaffold(
         modifier = Modifier.fillMaxSize(),
         modifier = Modifier.fillMaxSize(),
@@ -195,7 +192,7 @@ fun AudioPlayScreenContent(
                                 RoundDropdownMenuItem(
                                 RoundDropdownMenuItem(
                                     text = stringResource(R.string.login),
                                     text = stringResource(R.string.login),
                                     leadingIcon = {
                                     leadingIcon = {
-                                        MenuItemIcon(Icons.Default.Login)
+                                        MenuItemIcon(Icons.AutoMirrored.Filled.Login)
                                     },
                                     },
                                     onClick = {
                                     onClick = {
                                         dismiss()
                                         dismiss()
@@ -236,13 +233,23 @@ fun AudioPlayScreenContent(
                             RoundDropdownMenuItem(
                             RoundDropdownMenuItem(
                                 text = stringResource(R.string.audio_play_gain),
                                 text = stringResource(R.string.audio_play_gain),
                                 leadingIcon = {
                                 leadingIcon = {
-                                    MenuItemIcon(Icons.Default.VolumeUp)
+                                    MenuItemIcon(Icons.AutoMirrored.Filled.VolumeUp)
                                 },
                                 },
                                 onClick = {
                                 onClick = {
                                     dismiss()
                                     dismiss()
                                     onIntent(AudioPlayIntent.OpenSheet(AudioPlaySheet.Gain))
                                     onIntent(AudioPlayIntent.OpenSheet(AudioPlaySheet.Gain))
                                 },
                                 },
                             )
                             )
+                            RoundDropdownMenuItem(
+                                text = stringResource(R.string.audio_play_cover_ratio),
+                                leadingIcon = {
+                                    MenuItemIcon(Icons.Default.Crop)
+                                },
+                                onClick = {
+                                    dismiss()
+                                    onIntent(AudioPlayIntent.OpenSheet(AudioPlaySheet.CoverRatioOptions))
+                                },
+                            )
                             RoundDropdownMenuItem(
                             RoundDropdownMenuItem(
                                 text = stringResource(R.string.audio_play_wake_lock),
                                 text = stringResource(R.string.audio_play_wake_lock),
                                 leadingIcon = {
                                 leadingIcon = {
@@ -416,7 +423,7 @@ fun AudioPlayScreenContent(
                         text = stringResource(R.string.audio_play_speed),
                         text = stringResource(R.string.audio_play_speed),
                         contentDescription = stringResource(R.string.audio_play_speed),
                         contentDescription = stringResource(R.string.audio_play_speed),
                         onCheckedChange = {
                         onCheckedChange = {
-                            activeAdjustment = activeAdjustment.toggle(AudioAdjustment.Speed)
+                            onIntent(AudioPlayIntent.OpenSheet(AudioPlaySheet.Speed))
                         },
                         },
                     )
                     )
                     SmallAnimatedButton(
                     SmallAnimatedButton(
@@ -426,48 +433,10 @@ fun AudioPlayScreenContent(
                         text = stringResource(R.string.set_timer),
                         text = stringResource(R.string.set_timer),
                         contentDescription = stringResource(R.string.set_timer),
                         contentDescription = stringResource(R.string.set_timer),
                         onCheckedChange = {
                         onCheckedChange = {
-                            activeAdjustment = activeAdjustment.toggle(AudioAdjustment.Timer)
+                            onIntent(AudioPlayIntent.OpenSheet(AudioPlaySheet.Timer))
                         },
                         },
                     )
                     )
                 }
                 }
-                AnimatedVisibility(activeAdjustment == AudioAdjustment.Speed) {
-                    PlayerAdjustmentSlider(
-                        title = stringResource(R.string.audio_play_speed),
-                        value = speedPreview.coerceIn(AUDIO_SPEED_MIN, AUDIO_SPEED_MAX),
-                        valueLabel = String.format(Locale.ROOT, "%.1fX", speedPreview),
-                        startLabel = "0.5X",
-                        endLabel = "5.0X",
-                        enabled = state.status != Status.STOP,
-                        onValueChange = { speedPreview = it },
-                        onValueChangeFinished = {
-                            onIntent(AudioPlayIntent.SetSpeed(speedPreview))
-                        },
-                        valueRange = AUDIO_SPEED_MIN..AUDIO_SPEED_MAX,
-                        steps = 44,
-                    )
-                }
-                AnimatedVisibility(activeAdjustment == AudioAdjustment.Timer) {
-                    PlayerAdjustmentSlider(
-                        title = stringResource(R.string.set_timer),
-                        value = timerPreview.coerceIn(
-                            PlaybackTimer.MIN_MINUTES.toFloat(),
-                            PlaybackTimer.MAX_MINUTES.toFloat(),
-                        ),
-                        valueLabel = if (timerPreview == 0f) {
-                            stringResource(R.string.close)
-                        } else {
-                            stringResource(R.string.timer_m, timerPreview.roundToInt())
-                        },
-                        startLabel = stringResource(R.string.close),
-                        endLabel = stringResource(R.string.timer_m, PlaybackTimer.MAX_MINUTES),
-                        onValueChange = { timerPreview = it.roundToInt().toFloat() },
-                        onValueChangeFinished = {
-                            onIntent(AudioPlayIntent.SetTimer(timerPreview.roundToInt()))
-                        },
-                        valueRange = PlaybackTimer.MIN_MINUTES.toFloat()..PlaybackTimer.MAX_MINUTES.toFloat(),
-                        steps = PlaybackTimer.MAX_MINUTES - PlaybackTimer.MIN_MINUTES - 1,
-                    )
-                }
             }
             }
         },
         },
     ) {
     ) {
@@ -521,6 +490,23 @@ fun AudioPlayScreenContent(
         onDismissRequest = { onIntent(AudioPlayIntent.DismissSheet) },
         onDismissRequest = { onIntent(AudioPlayIntent.DismissSheet) },
         onIntent = onIntent,
         onIntent = onIntent,
     )
     )
+    AudioCoverRatioSheet(
+        show = state.activeSheet == AudioPlaySheet.CoverRatioOptions,
+        onDismissRequest = { onIntent(AudioPlayIntent.DismissSheet) },
+        onIntent = onIntent,
+    )
+    AudioSpeedSheet(
+        show = state.activeSheet == AudioPlaySheet.Speed,
+        state = state,
+        onDismissRequest = { onIntent(AudioPlayIntent.DismissSheet) },
+        onIntent = onIntent,
+    )
+    AudioTimerSheet(
+        show = state.activeSheet == AudioPlaySheet.Timer,
+        state = state,
+        onDismissRequest = { onIntent(AudioPlayIntent.DismissSheet) },
+        onIntent = onIntent,
+    )
     AppLogSheet(
     AppLogSheet(
         show = state.activeSheet == AudioPlaySheet.Log,
         show = state.activeSheet == AudioPlaySheet.Log,
         onDismissRequest = { onIntent(AudioPlayIntent.DismissSheet) },
         onDismissRequest = { onIntent(AudioPlayIntent.DismissSheet) },
@@ -609,6 +595,120 @@ private fun AudioGainSheet(
     }
     }
 }
 }
 
 
+@Composable
+private fun AudioCoverRatioSheet(
+    show: Boolean,
+    onDismissRequest: () -> Unit,
+    onIntent: (AudioPlayIntent) -> Unit,
+) {
+    OptionSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.audio_play_cover_ratio),
+    ) {
+        OptionCard(
+            icon = Icons.Default.CropSquare,
+            text = stringResource(R.string.cover_ratio_square),
+            onClick = {
+                onDismissRequest()
+                onIntent(AudioPlayIntent.SetCoverRatio(CoverRatio.Square))
+            },
+        )
+        OptionCard(
+            icon = Icons.Default.RadioButtonUnchecked,
+            text = stringResource(R.string.cover_ratio_circle),
+            onClick = {
+                onDismissRequest()
+                onIntent(AudioPlayIntent.SetCoverRatio(CoverRatio.Circle))
+            },
+        )
+        OptionCard(
+            icon = Icons.Default.Book,
+            text = stringResource(R.string.cover_ratio_book),
+            onClick = {
+                onDismissRequest()
+                onIntent(AudioPlayIntent.SetCoverRatio(CoverRatio.Book))
+            },
+        )
+        OptionCard(
+            icon = Icons.Default.CropFree,
+            text = stringResource(R.string.cover_ratio_unrestricted),
+            onClick = {
+                onDismissRequest()
+                onIntent(AudioPlayIntent.SetCoverRatio(CoverRatio.Unrestricted))
+            },
+        )
+    }
+}
+
+@Composable
+private fun AudioSpeedSheet(
+    show: Boolean,
+    state: AudioPlayUiState,
+    onDismissRequest: () -> Unit,
+    onIntent: (AudioPlayIntent) -> Unit,
+) {
+    var speedPreview by remember(state.speed) { mutableFloatStateOf(state.speed) }
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.audio_play_speed),
+    ) {
+        PlayerAdjustmentSlider(
+            title = stringResource(R.string.audio_play_speed),
+            value = speedPreview.coerceIn(AUDIO_SPEED_MIN, AUDIO_SPEED_MAX),
+            valueLabel = String.format(Locale.ROOT, "%.1fX", speedPreview),
+            startLabel = "0.5X",
+            endLabel = "5.0X",
+            enabled = state.status != Status.STOP,
+            onValueChange = { speedPreview = it },
+            onValueChangeFinished = {
+                onIntent(AudioPlayIntent.SetSpeed(speedPreview))
+            },
+            valueRange = AUDIO_SPEED_MIN..AUDIO_SPEED_MAX,
+            steps = 44,
+        )
+    }
+}
+
+@Composable
+private fun AudioTimerSheet(
+    show: Boolean,
+    state: AudioPlayUiState,
+    onDismissRequest: () -> Unit,
+    onIntent: (AudioPlayIntent) -> Unit,
+) {
+    var timerPreview by remember(state.timerMinutes) {
+        mutableFloatStateOf(state.timerMinutes.toFloat())
+    }
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.set_timer),
+    ) {
+        PlayerAdjustmentSlider(
+            title = stringResource(R.string.set_timer),
+            value = timerPreview.coerceIn(
+                PlaybackTimer.MIN_MINUTES.toFloat(),
+                PlaybackTimer.MAX_MINUTES.toFloat(),
+            ),
+            valueLabel = if (timerPreview == 0f) {
+                stringResource(R.string.close)
+            } else {
+                stringResource(R.string.timer_m, timerPreview.roundToInt())
+            },
+            startLabel = stringResource(R.string.close),
+            endLabel = stringResource(R.string.timer_m, PlaybackTimer.MAX_MINUTES),
+            onValueChange = { timerPreview = it.roundToInt().toFloat() },
+            onValueChangeFinished = {
+                onIntent(AudioPlayIntent.SetTimer(timerPreview.roundToInt()))
+            },
+            valueRange = PlaybackTimer.MIN_MINUTES.toFloat()..PlaybackTimer.MAX_MINUTES.toFloat(),
+            steps = PlaybackTimer.MAX_MINUTES - PlaybackTimer.MIN_MINUTES - 1,
+        )
+    }
+}
+
 private fun formatGain(gainMb: Int): String {
 private fun formatGain(gainMb: Int): String {
     val dB = gainMb / 1000f
     val dB = gainMb / 1000f
     return if (dB == 0f) {
     return if (dB == 0f) {
@@ -633,21 +733,35 @@ private fun AudioCoverPage(
             .padding(horizontal = 24.dp),
             .padding(horizontal = 24.dp),
         horizontalAlignment = Alignment.CenterHorizontally,
         horizontalAlignment = Alignment.CenterHorizontally,
     ) {
     ) {
+        val coverShape = when (state.coverRatio) {
+            CoverRatio.Circle -> CircleShape
+            else -> RoundedCornerShape(8.dp)
+        }
         Box(
         Box(
             modifier = Modifier.fillMaxWidth(),
             modifier = Modifier.fillMaxWidth(),
             contentAlignment = Alignment.TopCenter,
             contentAlignment = Alignment.TopCenter,
         ) {
         ) {
+            val coverModifier = when (state.coverRatio) {
+                // 边长取 0.8×宽 与 0.55×高 的较小值:既放大封面,又保证下方文字始终有空间
+                CoverRatio.Square, CoverRatio.Circle ->
+                    Modifier.aspectRatio(1f)
+
+                CoverRatio.Book ->
+                    Modifier
+                        .fillMaxWidth(0.64f)
+                        .aspectRatio(5f / 7f)
+
+                else ->
+                    Modifier.fillMaxSize(0.64f)
+            }
             BookCoverImage(
             BookCoverImage(
                 name = state.bookName,
                 name = state.bookName,
                 author = state.author,
                 author = state.author,
                 path = state.coverPath,
                 path = state.coverPath,
                 sourceOrigin = state.sourceOrigin,
                 sourceOrigin = state.sourceOrigin,
                 modifier = Modifier
                 modifier = Modifier
-                    .fillMaxSize(0.64f)
-                    .shadow(
-                        elevation = 16.dp
-                    )
-                    .clip(RoundedCornerShape(8.dp))
+                    .then(coverModifier)
+                    .clip(coverShape)
             )
             )
         }
         }
         Column(
         Column(
@@ -689,11 +803,6 @@ private fun formatAudioTime(valueMs: Int): String {
     }
     }
 }
 }
 
 
-private enum class AudioAdjustment { Speed, Timer }
-
-private fun AudioAdjustment?.toggle(value: AudioAdjustment): AudioAdjustment? =
-    if (this == value) null else value
-
 @Composable
 @Composable
 private fun playModeContentDescription(mode: AudioPlay.PlayMode): String = when (mode) {
 private fun playModeContentDescription(mode: AudioPlay.PlayMode): String = when (mode) {
     AudioPlay.PlayMode.LIST_END_STOP -> stringResource(R.string.audio_play_mode_sequence)
     AudioPlay.PlayMode.LIST_END_STOP -> stringResource(R.string.audio_play_mode_sequence)

+ 20 - 3
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayViewModel.kt

@@ -8,6 +8,7 @@ import com.jeremyliao.liveeventbus.LiveEventBus
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.BookType
 import io.legado.app.constant.BookType
+import io.legado.app.constant.CoverRatio
 import io.legado.app.constant.EventBus
 import io.legado.app.constant.EventBus
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.ReadAloudBgMode
 import io.legado.app.constant.ReadAloudBgMode
@@ -52,13 +53,25 @@ class AudioPlayViewModel(
     val uiState = combine(
     val uiState = combine(
         coordinator.state,
         coordinator.state,
         AppConfigStore.observeInt(PreferKey.audioPlayBgMode),
         AppConfigStore.observeInt(PreferKey.audioPlayBgMode),
+        AppConfigStore.observeInt(PreferKey.audioPlayCoverRatio),
         activeSheet,
         activeSheet,
-    ) { source, bgMode, sheet ->
-        toUiState(source, bgMode ?: ReadAloudBgMode.Blur, sheet)
+    ) { source, bgMode, coverRatio, sheet ->
+        toUiState(
+            source,
+            bgMode ?: ReadAloudBgMode.Blur,
+            coverRatio ?: CoverRatio.Unrestricted,
+            sheet,
+        )
     }.stateIn(
     }.stateIn(
         scope = viewModelScope,
         scope = viewModelScope,
         started = SharingStarted.WhileSubscribed(5000),
         started = SharingStarted.WhileSubscribed(5000),
-        initialValue = toUiState(coordinator.snapshot(), readBgMode(), null),
+        initialValue = toUiState(
+            coordinator.snapshot(),
+            readBgMode(),
+            AppConfigStore.preferences.compatDsInt(PreferKey.audioPlayCoverRatio)
+                ?: CoverRatio.Unrestricted,
+            null,
+        ),
     )
     )
 
 
     private val _effects = MutableSharedFlow<AudioPlayEffect>(extraBufferCapacity = 8)
     private val _effects = MutableSharedFlow<AudioPlayEffect>(extraBufferCapacity = 8)
@@ -117,6 +130,8 @@ class AudioPlayViewModel(
             is AudioPlayIntent.SetCloseCredits -> coordinator.setCloseCredits(intent.seconds)
             is AudioPlayIntent.SetCloseCredits -> coordinator.setCloseCredits(intent.seconds)
             is AudioPlayIntent.SetAudioGain -> coordinator.setAudioGain(intent.gainMb)
             is AudioPlayIntent.SetAudioGain -> coordinator.setAudioGain(intent.gainMb)
             AudioPlayIntent.CycleBgMode -> cycleBgMode()
             AudioPlayIntent.CycleBgMode -> cycleBgMode()
+            is AudioPlayIntent.SetCoverRatio ->
+                AppConfigStore.putInt(PreferKey.audioPlayCoverRatio, intent.value)
             is AudioPlayIntent.OpenSheet -> activeSheet.value = intent.sheet
             is AudioPlayIntent.OpenSheet -> activeSheet.value = intent.sheet
             AudioPlayIntent.DismissSheet -> activeSheet.value = null
             AudioPlayIntent.DismissSheet -> activeSheet.value = null
             AudioPlayIntent.ChangeSource -> {
             AudioPlayIntent.ChangeSource -> {
@@ -271,6 +286,7 @@ class AudioPlayViewModel(
     private fun toUiState(
     private fun toUiState(
         source: AudioPlaySourceState,
         source: AudioPlaySourceState,
         bgMode: Int,
         bgMode: Int,
+        coverRatio: Int,
         sheet: AudioPlaySheet?,
         sheet: AudioPlaySheet?,
     ): AudioPlayUiState = AudioPlayUiState(
     ): AudioPlayUiState = AudioPlayUiState(
         bookUrl = source.bookUrl,
         bookUrl = source.bookUrl,
@@ -289,6 +305,7 @@ class AudioPlayViewModel(
         timerMinutes = source.timerMinutes,
         timerMinutes = source.timerMinutes,
         playMode = source.playMode,
         playMode = source.playMode,
         bgMode = bgMode,
         bgMode = bgMode,
+        coverRatio = coverRatio,
         canLogin = source.canLogin,
         canLogin = source.canLogin,
         wakeLockEnabled = source.wakeLockEnabled,
         wakeLockEnabled = source.wakeLockEnabled,
         mediaControlEnabled = source.mediaControlEnabled,
         mediaControlEnabled = source.mediaControlEnabled,

+ 8 - 0
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerContract.kt

@@ -35,8 +35,14 @@ data class ReadAloudPlayerUiState(
     val timerMinutes: Int = 0,
     val timerMinutes: Int = 0,
     val finishCurrentChapterAfterTimer: Boolean = false,
     val finishCurrentChapterAfterTimer: Boolean = false,
     val bgMode: Int = 0,
     val bgMode: Int = 0,
+    val activeSheet: ReadAloudPlayerSheet? = null,
 )
 )
 
 
+sealed interface ReadAloudPlayerSheet {
+    data object Speed : ReadAloudPlayerSheet
+    data object Timer : ReadAloudPlayerSheet
+}
+
 sealed interface ReadAloudPlayerIntent {
 sealed interface ReadAloudPlayerIntent {
     data object Refresh : ReadAloudPlayerIntent
     data object Refresh : ReadAloudPlayerIntent
     data object TogglePause : ReadAloudPlayerIntent
     data object TogglePause : ReadAloudPlayerIntent
@@ -52,6 +58,8 @@ sealed interface ReadAloudPlayerIntent {
     data class SetSpeed(val value: Int) : ReadAloudPlayerIntent
     data class SetSpeed(val value: Int) : ReadAloudPlayerIntent
     data class SetTimer(val minutes: Int) : ReadAloudPlayerIntent
     data class SetTimer(val minutes: Int) : ReadAloudPlayerIntent
     data class SetFinishCurrentChapterAfterTimer(val value: Boolean) : ReadAloudPlayerIntent
     data class SetFinishCurrentChapterAfterTimer(val value: Boolean) : ReadAloudPlayerIntent
+    data class OpenSheet(val sheet: ReadAloudPlayerSheet) : ReadAloudPlayerIntent
+    data object DismissSheet : ReadAloudPlayerIntent
     data class SeekTo(val chapterPosition: Int) : ReadAloudPlayerIntent
     data class SeekTo(val chapterPosition: Int) : ReadAloudPlayerIntent
 }
 }
 
 

+ 137 - 101
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerScreen.kt

@@ -90,9 +90,10 @@ import io.legado.app.ui.util.rememberBlurBackdrop
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
-import io.legado.app.ui.widget.components.button.series.SmallPlainButton
+import io.legado.app.ui.widget.components.button.series.SmallAnimatedButton
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.player.AnimatedPlayPauseButton
 import io.legado.app.ui.widget.components.player.AnimatedPlayPauseButton
 import io.legado.app.ui.widget.components.player.PlayerAdjustmentSlider
 import io.legado.app.ui.widget.components.player.PlayerAdjustmentSlider
@@ -129,11 +130,6 @@ fun ReadAloudPlayerScreenContent(
         orientation = Orientation.Vertical,
         orientation = Orientation.Vertical,
     )
     )
     val coroutineScope = rememberCoroutineScope()
     val coroutineScope = rememberCoroutineScope()
-    var activeAdjustment by remember { mutableStateOf<PlayerAdjustment?>(null) }
-    var speedPreview by remember(state.speed) { mutableFloatStateOf(state.speed.toFloat()) }
-    var timerPreview by remember(state.timerMinutes) {
-        mutableFloatStateOf(state.timerMinutes.toFloat())
-    }
     var isTextPageUserScrolling by remember { mutableStateOf(false) }
     var isTextPageUserScrolling by remember { mutableStateOf(false) }
     val pagerHazeState = remember { HazeState() }
     val pagerHazeState = remember { HazeState() }
     val hazeEnabled =
     val hazeEnabled =
@@ -156,13 +152,7 @@ fun ReadAloudPlayerScreenContent(
     }
     }
     val pageContentPadding = PaddingValues(
     val pageContentPadding = PaddingValues(
         top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 88.dp,
         top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 88.dp,
-        bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + when (
-            activeAdjustment
-        ) {
-            null -> 216.dp
-            PlayerAdjustment.Speed -> 264.dp
-            PlayerAdjustment.Timer -> 344.dp
-        },
+        bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 216.dp,
     )
     )
     val overlayHazeStyle = HazeLegado.ultraThinPlus(
     val overlayHazeStyle = HazeLegado.ultraThinPlus(
         containerColor = LegadoTheme.colorScheme.surface,
         containerColor = LegadoTheme.colorScheme.surface,
@@ -324,8 +314,26 @@ fun ReadAloudPlayerScreenContent(
                             .padding(vertical = 16.dp, horizontal = 24.dp),
                             .padding(vertical = 16.dp, horizontal = 24.dp),
                         horizontalArrangement = Arrangement.SpaceEvenly,
                         horizontalArrangement = Arrangement.SpaceEvenly,
                     ) {
                     ) {
-                        SmallPlainButton(
-                            onClick = {
+                        SmallAnimatedButton(
+                            containerColor = Color.Transparent,
+                            checked = false,
+                            icon = Icons.AutoMirrored.Filled.FormatListBulleted,
+                            iconChecked = Icons.Default.KeyboardArrowUp,
+                            text = stringResource(
+                                if (verticalPagerState.currentPage == 0) {
+                                    R.string.chapter_list
+                                } else {
+                                    R.string.back
+                                }
+                            ),
+                            contentDescription = stringResource(
+                                if (verticalPagerState.currentPage == 0) {
+                                    R.string.chapter_list
+                                } else {
+                                    R.string.back
+                                }
+                            ),
+                            onCheckedChange = {
                                 coroutineScope.launch {
                                 coroutineScope.launch {
                                     if (horizontalPagerState.currentPage != 0) {
                                     if (horizontalPagerState.currentPage != 0) {
                                         horizontalPagerState.animateScrollToPage(
                                         horizontalPagerState.animateScrollToPage(
@@ -345,101 +353,44 @@ fun ReadAloudPlayerScreenContent(
                                     )
                                     )
                                 }
                                 }
                             },
                             },
-                            icon = if (verticalPagerState.currentPage == 0) {
-                                Icons.AutoMirrored.Filled.FormatListBulleted
-                            } else {
-                                Icons.Default.KeyboardArrowUp
-                            },
-                            contentDescription = stringResource(
-                                if (verticalPagerState.currentPage == 0) {
-                                    R.string.chapter_list
-                                } else {
-                                    R.string.back
-                                }
-                            ),
                         )
                         )
-                        SmallPlainButton(
-                            onClick = { onIntent(ReadAloudPlayerIntent.SwitchToClassic) },
+                        SmallAnimatedButton(
+                            containerColor = Color.Transparent,
+                            checked = false,
                             icon = Icons.Default.Tune,
                             icon = Icons.Default.Tune,
+                            text = stringResource(R.string.switch_to_classic_read_aloud),
                             contentDescription = stringResource(R.string.switch_to_classic_read_aloud),
                             contentDescription = stringResource(R.string.switch_to_classic_read_aloud),
+                            onCheckedChange = { onIntent(ReadAloudPlayerIntent.SwitchToClassic) },
                         )
                         )
-                        SmallPlainButton(
-                            onClick = { onIntent(ReadAloudPlayerIntent.CycleBgMode) },
+                        SmallAnimatedButton(
+                            containerColor = Color.Transparent,
+                            checked = false,
                             icon = Icons.Default.WbTwilight,
                             icon = Icons.Default.WbTwilight,
-                            contentDescription = playerBgModeLabel(state.bgMode)
+                            text = playerBgModeLabel(state.bgMode),
+                            contentDescription = playerBgModeLabel(state.bgMode),
+                            onCheckedChange = { onIntent(ReadAloudPlayerIntent.CycleBgMode) },
                         )
                         )
-                        SmallPlainButton(
-                            onClick = {
-                                activeAdjustment = activeAdjustment.toggle(PlayerAdjustment.Speed)
-                            },
+                        SmallAnimatedButton(
+                            containerColor = Color.Transparent,
+                            checked = false,
                             icon = Icons.Default.Speed,
                             icon = Icons.Default.Speed,
+                            text = stringResource(R.string.read_aloud_adjust_speed),
                             contentDescription = stringResource(R.string.read_aloud_adjust_speed),
                             contentDescription = stringResource(R.string.read_aloud_adjust_speed),
-                            selected = activeAdjustment == PlayerAdjustment.Speed,
-                        )
-                        SmallPlainButton(
-                            onClick = {
-                                activeAdjustment = activeAdjustment.toggle(PlayerAdjustment.Timer)
+                            onCheckedChange = {
+                                onIntent(ReadAloudPlayerIntent.OpenSheet(ReadAloudPlayerSheet.Speed))
                             },
                             },
+                        )
+                        SmallAnimatedButton(
+                            containerColor = Color.Transparent,
+                            checked = false,
                             icon = Icons.Default.Timer,
                             icon = Icons.Default.Timer,
+                            text = stringResource(R.string.set_timer),
                             contentDescription = stringResource(R.string.set_timer),
                             contentDescription = stringResource(R.string.set_timer),
-                            selected = activeAdjustment == PlayerAdjustment.Timer,
-                        )
-                    }
-                    AnimatedVisibility(activeAdjustment == PlayerAdjustment.Speed) {
-                        PlayerAdjustmentSlider(
-                            title = stringResource(R.string.read_aloud_adjust_speed),
-                            value = speedPreview.coerceIn(
-                                READ_ALOUD_SPEED_MIN.toFloat(),
-                                READ_ALOUD_SPEED_MAX.toFloat(),
-                            ),
-                            valueLabel = formatReadAloudSpeedLabel(speedPreview.roundToInt()),
-                            startLabel = stringResource(R.string.fast_rewind),
-                            endLabel = stringResource(R.string.fast_forward),
-                            onValueChange = { speedPreview = it },
-                            onValueChangeFinished = {
-                                onIntent(ReadAloudPlayerIntent.SetSpeed(speedPreview.roundToInt()))
+                            onCheckedChange = {
+                                onIntent(ReadAloudPlayerIntent.OpenSheet(ReadAloudPlayerSheet.Timer))
                             },
                             },
-                            valueRange = READ_ALOUD_SPEED_MIN.toFloat()..READ_ALOUD_SPEED_MAX.toFloat(),
-                            steps = READ_ALOUD_SPEED_MAX - READ_ALOUD_SPEED_MIN - 1,
                         )
                         )
                     }
                     }
-                    AnimatedVisibility(activeAdjustment == PlayerAdjustment.Timer) {
-                        Column {
-                            PlayerAdjustmentSlider(
-                                title = stringResource(R.string.set_timer),
-                                value = timerPreview.coerceIn(0f, 180f),
-                                valueLabel = if (timerPreview == 0f) {
-                                    stringResource(R.string.close)
-                                } else {
-                                    stringResource(R.string.timer_m, timerPreview.roundToInt())
-                                },
-                                startLabel = stringResource(R.string.close),
-                                endLabel = stringResource(R.string.timer_m, 180),
-                                onValueChange = { timerPreview = (it / 10f).roundToInt() * 10f },
-                                onValueChangeFinished = {
-                                    onIntent(ReadAloudPlayerIntent.SetTimer(timerPreview.roundToInt()))
-                                },
-                                valueRange = 0f..180f,
-                                steps = 17,
-                            )
-                            TinySwitchSettingItem(
-                                title = stringResource(
-                                    R.string.finish_current_chapter_after_timer
-                                ),
-                                description = stringResource(
-                                    R.string.finish_current_chapter_after_timer_summary
-                                ),
-                                checked = state.finishCurrentChapterAfterTimer,
-                                modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
-                                color = LegadoTheme.colorScheme.surfaceContainerHigh,
-                                onCheckedChange = {
-                                    onIntent(
-                                        ReadAloudPlayerIntent.SetFinishCurrentChapterAfterTimer(it)
-                                    )
-                                },
-                            )
-                        }
-                    }
                 }
                 }
             }
             }
         },
         },
@@ -498,6 +449,19 @@ fun ReadAloudPlayerScreenContent(
             }
             }
         }
         }
     }
     }
+
+    ReadAloudSpeedSheet(
+        show = state.activeSheet == ReadAloudPlayerSheet.Speed,
+        state = state,
+        onDismissRequest = { onIntent(ReadAloudPlayerIntent.DismissSheet) },
+        onIntent = onIntent,
+    )
+    ReadAloudTimerSheet(
+        show = state.activeSheet == ReadAloudPlayerSheet.Timer,
+        state = state,
+        onDismissRequest = { onIntent(ReadAloudPlayerIntent.DismissSheet) },
+        onIntent = onIntent,
+    )
 }
 }
 
 
 
 
@@ -730,6 +694,83 @@ private fun ChapterTextPage(
     }
     }
 }
 }
 
 
+@Composable
+private fun ReadAloudSpeedSheet(
+    show: Boolean,
+    state: ReadAloudPlayerUiState,
+    onDismissRequest: () -> Unit,
+    onIntent: (ReadAloudPlayerIntent) -> Unit,
+) {
+    var speedPreview by remember(state.speed) { mutableFloatStateOf(state.speed.toFloat()) }
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.read_aloud_adjust_speed),
+    ) {
+        PlayerAdjustmentSlider(
+            title = stringResource(R.string.read_aloud_adjust_speed),
+            value = speedPreview.coerceIn(
+                READ_ALOUD_SPEED_MIN.toFloat(),
+                READ_ALOUD_SPEED_MAX.toFloat(),
+            ),
+            valueLabel = formatReadAloudSpeedLabel(speedPreview.roundToInt()),
+            startLabel = stringResource(R.string.fast_rewind),
+            endLabel = stringResource(R.string.fast_forward),
+            onValueChange = { speedPreview = it },
+            onValueChangeFinished = {
+                onIntent(ReadAloudPlayerIntent.SetSpeed(speedPreview.roundToInt()))
+            },
+            valueRange = READ_ALOUD_SPEED_MIN.toFloat()..READ_ALOUD_SPEED_MAX.toFloat(),
+            steps = READ_ALOUD_SPEED_MAX - READ_ALOUD_SPEED_MIN - 1,
+        )
+    }
+}
+
+@Composable
+private fun ReadAloudTimerSheet(
+    show: Boolean,
+    state: ReadAloudPlayerUiState,
+    onDismissRequest: () -> Unit,
+    onIntent: (ReadAloudPlayerIntent) -> Unit,
+) {
+    var timerPreview by remember(state.timerMinutes) {
+        mutableFloatStateOf(state.timerMinutes.toFloat())
+    }
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.set_timer),
+    ) {
+        PlayerAdjustmentSlider(
+            title = stringResource(R.string.set_timer),
+            value = timerPreview.coerceIn(0f, 180f),
+            valueLabel = if (timerPreview == 0f) {
+                stringResource(R.string.close)
+            } else {
+                stringResource(R.string.timer_m, timerPreview.roundToInt())
+            },
+            startLabel = stringResource(R.string.close),
+            endLabel = stringResource(R.string.timer_m, 180),
+            onValueChange = { timerPreview = (it / 10f).roundToInt() * 10f },
+            onValueChangeFinished = {
+                onIntent(ReadAloudPlayerIntent.SetTimer(timerPreview.roundToInt()))
+            },
+            valueRange = 0f..180f,
+            steps = 17,
+        )
+        TinySwitchSettingItem(
+            title = stringResource(R.string.finish_current_chapter_after_timer),
+            description = stringResource(R.string.finish_current_chapter_after_timer_summary),
+            checked = state.finishCurrentChapterAfterTimer,
+            modifier = Modifier.padding(vertical = 4.dp),
+            color = LegadoTheme.colorScheme.surfaceContainerHigh,
+            onCheckedChange = {
+                onIntent(ReadAloudPlayerIntent.SetFinishCurrentChapterAfterTimer(it))
+            },
+        )
+    }
+}
+
 @Composable
 @Composable
 private fun formatPosition(value: Int): String = if (value < 1000) {
 private fun formatPosition(value: Int): String = if (value < 1000) {
     stringResource(R.string.read_aloud_position_chars, value)
     stringResource(R.string.read_aloud_position_chars, value)
@@ -737,13 +778,8 @@ private fun formatPosition(value: Int): String = if (value < 1000) {
     stringResource(R.string.read_aloud_position_kchars, value / 1000f)
     stringResource(R.string.read_aloud_position_kchars, value / 1000f)
 }
 }
 
 
-private enum class PlayerAdjustment { Speed, Timer }
-
 private const val BOTTOM_BAR_RESTORE_DELAY_MILLIS = 650L
 private const val BOTTOM_BAR_RESTORE_DELAY_MILLIS = 650L
 
 
-private fun PlayerAdjustment?.toggle(value: PlayerAdjustment): PlayerAdjustment? =
-    if (this == value) null else value
-
 @Composable
 @Composable
 private fun flowingTextBlend(): List<BlendColorEntry> {
 private fun flowingTextBlend(): List<BlendColorEntry> {
     val isDark = LegadoTheme.isDark
     val isDark = LegadoTheme.isDark

+ 11 - 3
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerViewModel.kt

@@ -9,6 +9,7 @@ import io.legado.app.help.config.compatDsInt
 import io.legado.app.ui.widget.components.player.PlayerChapterUi
 import io.legado.app.ui.widget.components.player.PlayerChapterUi
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.combine
@@ -19,15 +20,18 @@ class ReadAloudPlayerViewModel(
     private val coordinator: ReadAloudPlayerCoordinator,
     private val coordinator: ReadAloudPlayerCoordinator,
 ) : ViewModel() {
 ) : ViewModel() {
 
 
+    private val activeSheet = MutableStateFlow<ReadAloudPlayerSheet?>(null)
+
     val uiState = combine(
     val uiState = combine(
         coordinator.state,
         coordinator.state,
         AppConfigStore.observeInt(PreferKey.readAloudPlayerBgMode),
         AppConfigStore.observeInt(PreferKey.readAloudPlayerBgMode),
-    ) { source, bgMode ->
-        toUiState(source, bgMode ?: ReadAloudBgMode.Blur)
+        activeSheet,
+    ) { source, bgMode, sheet ->
+        toUiState(source, bgMode ?: ReadAloudBgMode.Blur, sheet)
     }.stateIn(
     }.stateIn(
         scope = viewModelScope,
         scope = viewModelScope,
         started = SharingStarted.WhileSubscribed(5000),
         started = SharingStarted.WhileSubscribed(5000),
-        initialValue = toUiState(coordinator.snapshot(), readBgMode()),
+        initialValue = toUiState(coordinator.snapshot(), readBgMode(), null),
     )
     )
 
 
     private val _effects = MutableSharedFlow<ReadAloudPlayerEffect>(extraBufferCapacity = 8)
     private val _effects = MutableSharedFlow<ReadAloudPlayerEffect>(extraBufferCapacity = 8)
@@ -59,6 +63,8 @@ class ReadAloudPlayerViewModel(
                 viewModelScope.launch {
                 viewModelScope.launch {
                     coordinator.setFinishCurrentChapterAfterTimer(intent.value)
                     coordinator.setFinishCurrentChapterAfterTimer(intent.value)
                 }
                 }
+            is ReadAloudPlayerIntent.OpenSheet -> activeSheet.value = intent.sheet
+            ReadAloudPlayerIntent.DismissSheet -> activeSheet.value = null
             is ReadAloudPlayerIntent.SeekTo -> coordinator.seekTo(
             is ReadAloudPlayerIntent.SeekTo -> coordinator.seekTo(
                 chapterPosition = intent.chapterPosition,
                 chapterPosition = intent.chapterPosition,
                 chapterLength = uiState.value.chapterLength,
                 chapterLength = uiState.value.chapterLength,
@@ -79,6 +85,7 @@ class ReadAloudPlayerViewModel(
     private fun toUiState(
     private fun toUiState(
         source: ReadAloudPlayerSourceState,
         source: ReadAloudPlayerSourceState,
         bgMode: Int,
         bgMode: Int,
+        sheet: ReadAloudPlayerSheet?,
     ): ReadAloudPlayerUiState {
     ): ReadAloudPlayerUiState {
         val activeIndex = source.textLines.indexOfLast {
         val activeIndex = source.textLines.indexOfLast {
             it.chapterPosition <= source.chapterPosition
             it.chapterPosition <= source.chapterPosition
@@ -114,6 +121,7 @@ class ReadAloudPlayerViewModel(
             timerMinutes = source.timerMinutes,
             timerMinutes = source.timerMinutes,
             finishCurrentChapterAfterTimer = source.finishCurrentChapterAfterTimer,
             finishCurrentChapterAfterTimer = source.finishCurrentChapterAfterTimer,
             bgMode = bgMode,
             bgMode = bgMode,
+            activeSheet = sheet,
         )
         )
     }
     }
 
 

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/player/PlayerAdjustmentSlider.kt

@@ -34,7 +34,7 @@ fun PlayerAdjustmentSlider(
     NormalCard(
     NormalCard(
         modifier = Modifier
         modifier = Modifier
             .fillMaxWidth()
             .fillMaxWidth()
-            .padding(horizontal = 16.dp, vertical = 4.dp),
+            .padding(vertical = 4.dp),
         cornerRadius = 24.dp,
         cornerRadius = 24.dp,
         containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
         containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
     ) {
     ) {

+ 5 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -204,6 +204,11 @@
     <string name="audio_play_open_credits">片头</string>
     <string name="audio_play_open_credits">片头</string>
     <string name="audio_play_close_credits">片尾</string>
     <string name="audio_play_close_credits">片尾</string>
     <string name="audio_play_gain">音频增益</string>
     <string name="audio_play_gain">音频增益</string>
+    <string name="audio_play_cover_ratio">封面比例</string>
+    <string name="cover_ratio_square">正方形</string>
+    <string name="cover_ratio_circle">圆形</string>
+    <string name="cover_ratio_book">书籍比例</string>
+    <string name="cover_ratio_unrestricted">不限制</string>
     <string name="seconds_m">%d 秒</string>
     <string name="seconds_m">%d 秒</string>
     <string name="more">更多</string>
     <string name="more">更多</string>
     <string name="book_add_failed">添加书籍失败</string>
     <string name="book_add_failed">添加书籍失败</string>

+ 5 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -159,6 +159,11 @@
     <string name="audio_play_open_credits">片頭</string>
     <string name="audio_play_open_credits">片頭</string>
     <string name="audio_play_close_credits">片尾</string>
     <string name="audio_play_close_credits">片尾</string>
     <string name="audio_play_gain">音頻增益</string>
     <string name="audio_play_gain">音頻增益</string>
+    <string name="audio_play_cover_ratio">封面比例</string>
+    <string name="cover_ratio_square">正方形</string>
+    <string name="cover_ratio_circle">圓形</string>
+    <string name="cover_ratio_book">書籍比例</string>
+    <string name="cover_ratio_unrestricted">不限制</string>
     <string name="seconds_m">%d 秒</string>
     <string name="seconds_m">%d 秒</string>
     <string name="more">更多</string>
     <string name="more">更多</string>
     <string name="book_add_failed">添加書籍失敗</string>
     <string name="book_add_failed">添加書籍失敗</string>

+ 5 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -161,6 +161,11 @@
     <string name="audio_play_open_credits">片頭</string>
     <string name="audio_play_open_credits">片頭</string>
     <string name="audio_play_close_credits">片尾</string>
     <string name="audio_play_close_credits">片尾</string>
     <string name="audio_play_gain">音頻增益</string>
     <string name="audio_play_gain">音頻增益</string>
+    <string name="audio_play_cover_ratio">封面比例</string>
+    <string name="cover_ratio_square">正方形</string>
+    <string name="cover_ratio_circle">圓形</string>
+    <string name="cover_ratio_book">書籍比例</string>
+    <string name="cover_ratio_unrestricted">不限制</string>
     <string name="seconds_m">%d 秒</string>
     <string name="seconds_m">%d 秒</string>
     <string name="more">更多</string>
     <string name="more">更多</string>
     <string name="book_add_failed">添加書籍失敗</string>
     <string name="book_add_failed">添加書籍失敗</string>

+ 5 - 0
app/src/main/res/values/strings.xml

@@ -234,6 +234,11 @@
     <string name="audio_play_open_credits">Intro</string>
     <string name="audio_play_open_credits">Intro</string>
     <string name="audio_play_close_credits">Outro</string>
     <string name="audio_play_close_credits">Outro</string>
     <string name="audio_play_gain">Audio gain</string>
     <string name="audio_play_gain">Audio gain</string>
+    <string name="audio_play_cover_ratio">Cover ratio</string>
+    <string name="cover_ratio_square">Square</string>
+    <string name="cover_ratio_circle">Circle</string>
+    <string name="cover_ratio_book">Book ratio</string>
+    <string name="cover_ratio_unrestricted">Unrestricted</string>
     <string name="seconds_m">%d s</string>
     <string name="seconds_m">%d s</string>
     <string name="more">More</string>
     <string name="more">More</string>
     <string name="book_add_failed">Failed to add book</string>
     <string name="book_add_failed">Failed to add book</string>

+ 70 - 0
app/src/test/java/io/legado/app/model/analyzeRule/AnalyzeRuleFastPathReproTest.kt

@@ -0,0 +1,70 @@
+package io.legado.app.model.analyzeRule
+
+import com.script.rhino.RhinoScriptEngine
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * 复现:书源校验时,`@js:` 书源列表规则产生的 JS 对象条目(Rhino NativeObject)
+ * 在 AnalyzeRule.getString/getStringList 的“单规则快捷路径”上被错误处理。
+ *
+ * 上游 legado(legadoteam/legado,commit 7dc8d809c / 6eea19643 修复后)在 NativeObject
+ * 快捷路径中保留了 Mode.Js / Mode.Json 分支:
+ *
+ *   result = when {
+ *       sourceRule.mode == Mode.Js    -> evalJS(sourceRule.rule, result)
+ *       sourceRule.mode == Mode.Json  -> getAnalyzeByJSonPath(result).getString(sourceRule.rule)
+ *       sourceRule.getParamSize() > 1 -> sourceRule.rule
+ *       else -> result[sourceRule.rule]
+ *   }
+ *
+ * 本仓库(legado-with-MD3)在 AnalyzeRule.kt 中把该快捷路径简化成了:
+ *
+ *   result = if (sourceRule.getParamSize() > 1) {
+ *       sourceRule.rule
+ *   } else {
+ *       result[sourceRule.rule]?.toString()   // 把 "$.name" / "@js:xxx" 当作字面量键名
+ *   }
+ *
+ * 导致 `$.name`(Mode.Json)与 `@js:xxx`(Mode.Js)规则在 JS 对象条目上返回空字符串。
+ * BookList.getSearchItem 会丢弃书名为空的条目 -> 搜索/发现结果为空 ->
+ * 校验时 `BookSourceCheckRepository.checkSource` 判定 “搜索失效/发现失效” -> 书源被判为失效。
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class AnalyzeRuleFastPathReproTest {
+
+    /** 模拟 `ruleBookList: @js:xxx` 返回的 JS 对象数组中的单个条目(NativeObject) */
+    private fun jsObjectItem(): Any {
+        return RhinoScriptEngine.eval(
+            "({name:'测试书名', author:'作者', authors:['甲','乙']})"
+        )!!
+    }
+
+    @Test
+    fun `Mode Json rule on JS object item returns the field value`() {
+        val analyzeRule = AnalyzeRule(RuleData(), null)
+        analyzeRule.setContent(jsObjectItem())
+        // 上游返回 "测试书名";本仓库的快捷路径把 "$.name" 当字面量键名,返回 ""
+        assertEquals("测试书名", analyzeRule.getString("$.name"))
+    }
+
+    @Test
+    fun `Mode Js rule on JS object item is evaluated`() {
+        val analyzeRule = AnalyzeRule(RuleData(), null)
+        analyzeRule.setContent(jsObjectItem())
+        // 上游返回 "测试书名";本仓库的快捷路径不执行 JS,返回 ""
+        assertEquals("测试书名", analyzeRule.getString("@js:result.name"))
+    }
+
+    @Test
+    fun `Mode Json getStringList on JS object item returns list`() {
+        val analyzeRule = AnalyzeRule(RuleData(), null)
+        analyzeRule.setContent(jsObjectItem())
+        // 上游返回 ["甲","乙"];本仓库的快捷路径返回 null
+        assertEquals(listOf("甲", "乙"), analyzeRule.getStringList("$.authors"))
+    }
+}