HapeLee 1 месяц назад
Родитель
Сommit
58b0e2cdbf
25 измененных файлов с 322 добавлено и 32 удалено
  1. 3 2
      app/src/main/java/io/legado/app/data/repository/ReadBookStyleConfigRepository.kt
  2. 1 0
      app/src/main/java/io/legado/app/domain/gateway/ReadStyleMutation.kt
  3. 10 0
      app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt
  4. 16 2
      app/src/main/java/io/legado/app/feature/reader/core/transition/PageCurlGeometry.kt
  5. 3 4
      app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderPageTransition.kt
  6. 48 0
      app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderPageTurnSpeed.kt
  7. 4 6
      app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderScrollState.kt
  8. 8 0
      app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt
  9. 8 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  10. 2 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  11. 1 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  12. 2 0
      app/src/main/java/io/legado/app/ui/book/read/ReadConfigUpdateDelegate.kt
  13. 50 3
      app/src/main/java/io/legado/app/ui/book/read/sheet/GlobalThemePage.kt
  14. 5 0
      app/src/main/res/values-zh-rCN/strings.xml
  15. 5 0
      app/src/main/res/values-zh-rHK/strings.xml
  16. 5 0
      app/src/main/res/values-zh-rTW/strings.xml
  17. 5 0
      app/src/main/res/values/strings.xml
  18. 28 2
      app/src/test/java/io/legado/app/feature/reader/core/transition/PageCurlGeometryTest.kt
  19. 4 2
      app/src/test/java/io/legado/app/feature/reader/core/transition/ReaderPageTransitionTest.kt
  20. 77 0
      app/src/test/java/io/legado/app/feature/reader/core/transition/ReaderPageTurnSpeedTest.kt
  21. 6 5
      app/src/test/java/io/legado/app/feature/reader/core/transition/ReaderScrollStateTest.kt
  22. 5 3
      app/src/test/java/io/legado/app/help/config/ReadConfigJsonRoundTripTest.kt
  23. 4 0
      app/src/test/java/io/legado/app/ui/book/read/ConfigUpdateActionsInvariantTest.kt
  24. 12 0
      app/src/test/java/io/legado/app/ui/book/read/LegacyConfigUpdateActionsTest.kt
  25. 10 3
      app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

+ 3 - 2
app/src/main/java/io/legado/app/data/repository/ReadBookStyleConfigRepository.kt

@@ -2,18 +2,18 @@ package io.legado.app.data.repository
 
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.PreferKey
-import io.legado.app.domain.gateway.ReadStyleGateway
 import io.legado.app.domain.gateway.ReadStyleBooleanKey
 import io.legado.app.domain.gateway.ReadStyleColorKey
 import io.legado.app.domain.gateway.ReadStyleFloatKey
+import io.legado.app.domain.gateway.ReadStyleGateway
 import io.legado.app.domain.gateway.ReadStyleIntKey
 import io.legado.app.domain.gateway.ReadStyleMutation
 import io.legado.app.domain.gateway.ReadStyleStringKey
 import io.legado.app.domain.model.settings.ReadStyleItem
 import io.legado.app.domain.model.settings.ReadStyleState
 import io.legado.app.help.DefaultData
-import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonObject
 import kotlinx.coroutines.CancellationException
@@ -225,6 +225,7 @@ class ReadBookStyleConfigRepository(
             ReadStyleIntKey.HeaderFontSize -> mutateEffective { it.copy(headerFontSize = value) }
             ReadStyleIntKey.FooterFontSize -> mutateEffective { it.copy(footerFontSize = value) }
             ReadStyleIntKey.PageAnim -> mutateEffective { it.withCurPageAnim(value) }
+            ReadStyleIntKey.PageAnimSpeed -> mutateEffective { it.withPageAnimSpeed(value) }
             ReadStyleIntKey.UnderlineHeight -> mutateEffective { it.copy(underlineHeight = value) }
             ReadStyleIntKey.UnderlinePadding -> mutateEffective { it.copy(underlinePadding = value) }
             ReadStyleIntKey.PaddingTop -> mutateEffective { it.copy(paddingTop = value) }

+ 1 - 0
app/src/main/java/io/legado/app/domain/gateway/ReadStyleMutation.kt

@@ -34,6 +34,7 @@ enum class ReadStyleIntKey {
     HeaderFontSize,
     FooterFontSize,
     PageAnim,
+    PageAnimSpeed,
     UnderlineHeight,
     UnderlinePadding,
     PaddingTop,

+ 10 - 0
app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt

@@ -136,6 +136,7 @@ import io.legado.app.feature.reader.core.transition.ReaderHorizontalDrag
 import io.legado.app.feature.reader.core.transition.ReaderPageTransform
 import io.legado.app.feature.reader.core.transition.ReaderPageTransition
 import io.legado.app.feature.reader.core.transition.ReaderPageTransitionPolicy
+import io.legado.app.feature.reader.core.transition.ReaderPageTurnSpeed
 import io.legado.app.feature.reader.core.transition.ReaderProgrammaticTurnPolicy
 import io.legado.app.feature.reader.core.transition.ReaderScrollCrossing
 import io.legado.app.feature.reader.core.transition.ReaderScrollPolicy
@@ -178,6 +179,8 @@ private const val SelectionHandleFadeInMillis = 140
 fun ReaderCanvasSurface(
     hostPages: ReaderPageWindow,
     transitionMode: ReaderTransitionMode,
+    /** 翻页动画速度挡位;只改折算基准时长,不改变动画种类与几何。 */
+    pageTurnSpeed: ReaderPageTurnSpeed,
     backgroundColor: Color,
     backgroundImage: Drawable?,
     backgroundRevision: Long,
@@ -289,6 +292,8 @@ fun ReaderCanvasSurface(
     val latestTapAction by rememberUpdatedState(onTapAction)
     val latestReaderInteraction by rememberUpdatedState(onReaderInteraction)
     val latestNoAnimationScrollPage by rememberUpdatedState(noAnimationScrollPage)
+    // 手势协程长驻,速度挡位必须现读,否则改挡后要等下次重组/手势重启才生效。
+    val latestPageTurnSpeed by rememberUpdatedState(pageTurnSpeed)
     var bookmarkOffset by remember { mutableFloatStateOf(0f) }
     var bookmarkArmed by remember { mutableStateOf(false) }
     var bookmarkWillRemove by remember { mutableStateOf(false) }
@@ -472,15 +477,19 @@ fun ReaderCanvasSurface(
         val targetCurlX = transition.direction?.let {
             ReaderCurlTouchPolicy.settledX(it, decision.commit, transition.pageExtentPx)
         } ?: curlTouchX
+        // 速度挡位只换折算基准:提交判定、目标位移与折页几何都不变。
+        val baseDurationMillis = latestPageTurnSpeed.baseDurationMillis
         val durationMillis = if (transitionMode == ReaderTransitionMode.SIMULATION) {
             ReaderCurlTouchPolicy.settleDurationMillis(
                 curlTouchX,
                 targetCurlX,
                 transition.pageExtentPx,
+                baseDurationMillis = baseDurationMillis,
             )
         } else {
             ReaderPageTransitionPolicy.settleDurationMillis(
                 transitionMode, displayOffset, decision.targetOffsetPx, transition.pageExtentPx,
+                baseDurationMillis = baseDurationMillis,
             )
         }
         if (durationMillis == 0) {
@@ -637,6 +646,7 @@ fun ReaderCanvasSurface(
                 val durationMillis = ReaderScrollPolicy.stepDurationMillis(
                     distance,
                     page.scrollViewportExtentPx(),
+                    animationSpeedMillis = latestPageTurnSpeed.baseDurationMillis,
                 )
                 var lastValue = 0f
                 Animatable(0f).animateTo(

+ 16 - 2
app/src/main/java/io/legado/app/feature/reader/core/transition/PageCurlGeometry.kt

@@ -50,18 +50,32 @@ object ReaderCurlVisualPolicy {
 }
 
 object ReaderCurlTouchPolicy {
+    /**
+     * 收尾下限占基准的比例。
+     *
+     * 下限跟着基准走而不是写死 90ms:写死会让快速挡的短距离收尾慢得不成比例
+     * (90 / 240 = 37%,而 90 / 540 只有 17%),换挡就不再是「整体快慢」。
+     * 360ms 基准下这正好是原来的 90ms,默认挡逐帧不变。
+     */
+    private const val MIN_SETTLE_FRACTION = .25f
+
+    /**
+     * 仿真折页收尾时长:按剩余折页距离折算到 [baseDurationMillis](由当前挡位给定,
+     * 见 [ReaderPageTurnSpeed])。
+     */
     fun settleDurationMillis(
         currentX: Float,
         targetX: Float,
         pageWidth: Float,
-        baseDurationMillis: Int = 300,
+        baseDurationMillis: Int = ReaderPageTurnSpeed.BASE_DURATION_MILLIS,
     ): Int {
         if (pageWidth <= 0f || baseDurationMillis <= 0) return 0
         val distance = abs(targetX - currentX)
         if (distance < .01f) return 0
         // A one- or two-pixel final travel previously rounded to 0 ms, committing the page
         // before Compose had a chance to present the final curl frame.
-        return (baseDurationMillis * distance / pageWidth).toInt().coerceAtLeast(90)
+        val minDurationMillis = (baseDurationMillis * MIN_SETTLE_FRACTION).toInt().coerceAtLeast(1)
+        return (baseDurationMillis * distance / pageWidth).toInt().coerceAtLeast(minDurationMillis)
     }
 
     /**

+ 3 - 4
app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderPageTransition.kt

@@ -86,14 +86,13 @@ data class ReaderHorizontalDrag(
 
 object ReaderPageTransitionPolicy {
     /**
-     * 翻页收尾动画的基准时长。
+     * 翻页收尾动画的基准时长,取全动画共用的 [ReaderPageTurnSpeed.BASE_DURATION_MILLIS]。
      *
      * 点击与外部按键(音量键 / 媒体键 / 空格 / D-pad)在画布层已合并到同一条收尾路径
      * (`externalPageTurns -> dispatchTapAction -> tapPageTurn`),因此两者共用这一个基准;
-     * 旧 View 中按键固定 100ms 的独立档位随之消失。360 是产品确认保留的取值,不要"回退"
-     * 成旧 View 的 300——那会让点击与按键重新分叉。
+     * 旧 View 中按键固定 100ms 的独立档位随之消失。
      */
-    private const val BASE_DURATION_MILLIS = 360
+    private const val BASE_DURATION_MILLIS = ReaderPageTurnSpeed.BASE_DURATION_MILLIS
 
     fun settleDurationMillis(
         mode: ReaderTransitionMode,

+ 48 - 0
app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderPageTurnSpeed.kt

@@ -0,0 +1,48 @@
+package io.legado.app.feature.reader.core.transition
+
+import io.legado.app.feature.reader.core.transition.ReaderPageTurnSpeed.Companion.BASE_DURATION_MILLIS
+import io.legado.app.feature.reader.core.transition.ReaderPageTurnSpeed.Companion.Default
+
+
+/**
+ * 翻页动画速度挡位。
+ *
+ * 覆盖 / 滑动 / 渐变 / 仿真折页 / 滚动翻屏共用同一套「按位移折算」规则,挡位只决定
+ * 折算的**基准时长**,也就是一整页 / 一整屏翻页从第 0 帧走完所需的时间;三条路径的
+ * policy 都以 [BASE_DURATION_MILLIS] 为默认参数。
+ *
+ * 挡位给的是绝对时长而不是倍率:倍率乘完再取整会在 300 / 360 这类比例上被截断
+ * (`360 * 0.8333f = 299.99…`),绝对时长没有这个问题。
+ *
+ * 落盘值是 [ordinal],因此**不要重排枚举顺序**——重排会改变已有设置的含义。
+ */
+enum class ReaderPageTurnSpeed(val baseDurationMillis: Int) {
+    /** 240ms:最快,几乎瞬时完成。 */
+    FASTEST(240),
+
+    /** 300ms:比默认略快,接近旧 View `defaultAnimationSpeed = 300` 的原生手感。 */
+    FAST(300),
+
+    // 枚举项初始化的早于 companion,这里不能写 BASE_DURATION_MILLIS;
+    // 两者相等由 ReaderPageTurnSpeedTest 的断言守住。
+    /** 360ms:统一基准,也是默认挡位。 */
+    MODERATE(360),
+
+    /** 540ms:明显放慢,突出折页/滑动的过程感。 */
+    ELEGANT(540);
+
+    companion object {
+        /**
+         * 全动画共用的原始基准时长(毫秒),等于 [MODERATE] 的时长。
+         *
+         * 覆盖 / 滑动 / 渐变、仿真折页、滚动翻屏一律以此为默认基准;不要再按旧 View 的
+         * `ReadView.defaultAnimationSpeed = 300` 另开一份,否则同一次翻页会在不同动画下快慢不一。
+         */
+        const val BASE_DURATION_MILLIS = 360
+
+        val Default: ReaderPageTurnSpeed = MODERATE
+
+        /** 越界(老配置缺字段、脏数据)回落到 [Default]。 */
+        fun fromValue(value: Int): ReaderPageTurnSpeed = entries.getOrNull(value) ?: Default
+    }
+}

+ 4 - 6
app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderScrollState.kt

@@ -73,8 +73,9 @@ object ReaderScrollPolicy {
 
     /**
      * 点击/按键滚动翻页的时长:对照旧 `PageDelegate.startScroll` 的
-     * `animationSpeed * |dy| / viewHeight`(`animationSpeed` 即
-     * `ReadView.defaultAnimationSpeed = 300`)。
+     * `animationSpeed * |dy| / viewHeight`;`animationSpeed` 取全动画共用的
+     * [ReaderPageTurnSpeed.BASE_DURATION_MILLIS](旧 `ReadView.defaultAnimationSpeed = 300`
+     * 已随「翻页动画统一基准」并入 360,不再单独保留)。
      *
      * 时长随步距缩放,而不是固定帧数:旧版整屏步距(图片页)与"保留一行"步距(文本页,
      * 约为一屏减一行)落在这条曲线的不同位置,固定 18 帧会把短步距拖成与整屏一样久。
@@ -82,7 +83,7 @@ object ReaderScrollPolicy {
     fun stepDurationMillis(
         distancePx: Float,
         viewportExtentPx: Float,
-        animationSpeedMillis: Int = LEGACY_ANIMATION_SPEED_MILLIS,
+        animationSpeedMillis: Int = ReaderPageTurnSpeed.BASE_DURATION_MILLIS,
     ): Int {
         if (viewportExtentPx <= 0f) return animationSpeedMillis
         val scaled = animationSpeedMillis * abs(distancePx) / viewportExtentPx
@@ -121,7 +122,4 @@ object ReaderScrollPolicy {
         }
         return ReaderScrollResult(next)
     }
-
-    /** 旧 `ReadView.defaultAnimationSpeed`:滚动与翻页动画的基准速度(ms / 一屏)。 */
-    private const val LEGACY_ANIMATION_SPEED_MILLIS = 300
 }

+ 8 - 0
app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt

@@ -550,6 +550,9 @@ object ReadBookConfig {
         private val textAccentColorEInk: String = "#000000",
         private val pageAnim: Int = 0,//翻页动画
         private val pageAnimEInk: Int = 4,
+        // 翻页动画速度挡位 0:极速(240ms) 1:快速(300ms) 2:适中(360ms,默认) 3:优雅(540ms)。
+        // 只改翻页动画的折算基准时长,不参与排版。
+        private val pageAnimSpeed: Int = 2,
         val textFont: String = "",//字体
         val titleFont: String = "",//标题字体
         val headerFont: String = "",//页眉字体
@@ -716,6 +719,7 @@ object ReadBookConfig {
             "textAccentColorIntEInk" to textAccentColorIntEInk,
             "pageAnim" to pageAnim,
             "pageAnimEInk" to pageAnimEInk,
+            "pageAnimSpeed" to pageAnimSpeed,
             "textFont" to textFont,
             "titleFont" to titleFont,
             "headerFont" to headerFont,
@@ -904,6 +908,9 @@ object ReadBookConfig {
             )
         }
 
+        /** 翻页速度挡位不随日夜/墨水屏分叉,直接写单一字段。 */
+        fun withPageAnimSpeed(speed: Int): Config = copy(pageAnimSpeed = speed)
+
         // Public getters for mode-specific values (for ReadBookStyleConfig)
         fun getDarkStatusIcon(): Boolean = darkStatusIcon
         fun getDarkStatusIconNight(): Boolean = darkStatusIconNight
@@ -913,6 +920,7 @@ object ReadBookConfig {
         fun getTextColorEInk(): String = textColorEInk
         fun getPageAnim(): Int = pageAnim
         fun getPageAnimEInk(): Int = pageAnimEInk
+        fun getPageAnimSpeed(): Int = pageAnimSpeed
 
         fun withCurBg(bgType: Int, bg: String): Config =
             ReadStyleResolver.withCurrentBackground(this, bgType, bg)

+ 8 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -95,6 +95,8 @@ data class ReadBookStyleConfig(
     // Page anim
     val pageAnim: Int = 0,
     val pageAnimEInk: Int = 4,
+    // 翻页速度挡位 0:极速 1:快速 2:适中(默认) 3:优雅,取值与时长见 ReaderPageTurnSpeed
+    val pageAnimSpeed: Int = 2,
     // Layout
     val shareLayout: Boolean = false,
     // 排版自带的阅读菜单配色,作为 DataStore 里 readMenu*Color 为 0(未自定义)时的回退
@@ -1360,6 +1362,12 @@ sealed interface ConfigUpdate {
         )
     }
 
+    // 翻页动画速度挡位只改翻页动画的折算基准时长,不改变动画种类、页高或排版,
+    // 因此没有任何渲染副作用:下一次翻页现读新挡位即可。
+    data class PageAnimSpeed(val value: Int) : ConfigUpdate {
+        override val actions = emptySet<ConfigUpdateAction>()
+    }
+
     // --- Menu colors ---
     // 菜单外观是 Compose 时代新增的项(旧 View 无对应事件码)。它们只被阅读菜单读取,菜单由
     // menuConfig 状态反应式重组,因此不驱动正文渲染副作用。唯一例外是 MenuBgColor:状态栏图标

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -75,6 +75,7 @@ import io.legado.app.feature.reader.ReaderBackgroundSurface
 import io.legado.app.feature.reader.ReaderCanvasSurface
 import io.legado.app.feature.reader.core.gesture.ReaderTapActionGrid
 import io.legado.app.feature.reader.core.model.readerBackgroundAlpha
+import io.legado.app.feature.reader.core.transition.ReaderPageTurnSpeed
 import io.legado.app.feature.reader.core.transition.ReaderTransitionMode
 import io.legado.app.feature.reader.core.transition.ReaderViewportLayerPolicy
 import io.legado.app.help.IntentHelp
@@ -726,6 +727,7 @@ fun ReadBookRouteScreen(
                 ReaderCanvasSurface(
                     hostPages = displayedReaderPageWindow ?: readerPageWindow,
                     transitionMode = readerTransitionMode,
+                    pageTurnSpeed = ReaderPageTurnSpeed.fromValue(state.styleConfig.pageAnimSpeed),
                 backgroundColor = readerSurfaceColor,
                 backgroundImage = readerBackground.drawable,
                 backgroundRevision = readerBackground.revision,

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -1934,6 +1934,7 @@ class ReadBookViewModel(
             titleFont = config.titleFont,
             pageAnim = actualConfig.getPageAnim(),
             pageAnimEInk = actualConfig.getPageAnimEInk(),
+            pageAnimSpeed = actualConfig.getPageAnimSpeed(),
             shareLayout = config.shareLayout,
             menuBgColorDay = dur.menuBgColor(isNight = false),
             menuBgColorNight = dur.menuBgColor(isNight = true),

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadConfigUpdateDelegate.kt

@@ -128,6 +128,7 @@ class ReadConfigUpdateDelegate(
                 }
             }
             is ConfigUpdate.PageAnim -> Unit
+            is ConfigUpdate.PageAnimSpeed -> Unit
 
             // --- Menu appearance ---
             is ConfigUpdate.MenuBgColor -> {
@@ -828,6 +829,7 @@ class ReadConfigUpdateDelegate(
             colorMutation(ReadStyleColorKey.TipFooterNight, color)
         is ConfigUpdate.TipDividerColor -> colorMutation(ReadStyleColorKey.TipDivider, color)
         is ConfigUpdate.PageAnim -> intMutation(ReadStyleIntKey.PageAnim, value)
+        is ConfigUpdate.PageAnimSpeed -> intMutation(ReadStyleIntKey.PageAnimSpeed, value)
         is ConfigUpdate.TextShadow -> booleanMutation(ReadStyleBooleanKey.TextShadow, value)
         is ConfigUpdate.ShadowRadius -> floatMutation(ReadStyleFloatKey.ShadowRadius, value)
         is ConfigUpdate.ShadowDx -> floatMutation(ReadStyleFloatKey.ShadowDx, value)

+ 50 - 3
app/src/main/java/io/legado/app/ui/book/read/sheet/GlobalThemePage.kt

@@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.DarkMode
 import androidx.compose.material.icons.filled.GridView
 import androidx.compose.material.icons.filled.LightMode
 import androidx.compose.material.icons.filled.SpaceBar
+import androidx.compose.material.icons.filled.Speed
 import androidx.compose.material.icons.filled.TextFields
 import androidx.compose.material.icons.filled.Visibility
 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -91,6 +92,7 @@ fun GlobalThemePage(
     // Derive values directly from styleConfig (reactive state)
     val textSize = styleConfig.textSize
     val pageAnim = styleConfig.pageAnim
+    val pageAnimSpeed = styleConfig.pageAnimSpeed
     val styleSelect = styleConfig.styleSelect
     val shareLayout = styleConfig.shareLayout
     val isNightTheme = LegadoTheme.isDark
@@ -323,9 +325,18 @@ fun GlobalThemePage(
         )
         var showPageAnimMenu by remember { mutableStateOf(false) }
         val pageAnimEntries = pageAnimOptions.map { stringResource(it) }.toTypedArray()
-        val pageAnimEntryValues = pageAnimOptions.indices.map { it.toString() }.toTypedArray()
-        val currentPageAnimDisplay =
-            pageAnimEntries.getOrNull(pageAnimEntryValues.indexOf(pageAnim.toString())) ?: ""
+        // 菜单项顺序就是 PageAnim 的常量值,直接用 pageAnim 当下标;
+        // 越界(脏配置)时留空,与原先 indexOf 得到 -1 的行为一致。
+        val currentPageAnimDisplay = pageAnimEntries.getOrNull(pageAnim) ?: ""
+
+        val pageAnimSpeedOptions = listOf(
+            R.string.page_anim_speed_fastest,
+            R.string.page_anim_speed_fast,
+            R.string.page_anim_speed_moderate,
+            R.string.page_anim_speed_elegant,
+        )
+        var showPageAnimSpeedMenu by remember { mutableStateOf(false) }
+        val pageAnimSpeedEntries = pageAnimSpeedOptions.map { stringResource(it) }.toTypedArray()
 
         Row(
             modifier = Modifier.fillMaxWidth(),
@@ -363,6 +374,42 @@ fun GlobalThemePage(
                     }
                 }
             }
+            // 翻页动画速度挡位:与右侧 SpaceBar 卡片同款方卡,点开选极速 / 快速 / 适中 / 优雅。
+            Box {
+                NormalCard(
+                    onClick = { showPageAnimSpeedMenu = true },
+                    modifier = Modifier
+                        .height(56.dp)
+                        .aspectRatio(1f),
+                    containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+                    cornerRadius = 12.dp,
+                ) {
+                    Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
+                        Icon(
+                            imageVector = Icons.Default.Speed,
+                            contentDescription = stringResource(R.string.page_anim_speed),
+                            tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                        )
+                    }
+                }
+                RoundDropdownMenu(
+                    expanded = showPageAnimSpeedMenu,
+                    onDismissRequest = { showPageAnimSpeedMenu = false },
+                ) { dismiss ->
+                    pageAnimSpeedEntries.forEachIndexed { index, display ->
+                        RoundDropdownMenuItem(
+                            text = display,
+                            onClick = {
+                                onIntent(
+                                    ReadBookIntent.UpdateConfig(ConfigUpdate.PageAnimSpeed(index))
+                                )
+                                dismiss()
+                            },
+                            isSelected = index == pageAnimSpeed,
+                        )
+                    }
+                }
+            }
             NormalCard(
                 onClick = onOpenPaddingConfig,
                 modifier = Modifier

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

@@ -546,6 +546,11 @@
     <string name="page_anim_simulation">仿真</string>
     <string name="page_anim_scroll">滚动</string>
     <string name="page_anim_none">无动画</string>
+    <string name="page_anim_speed">翻页动画速度</string>
+    <string name="page_anim_speed_fastest">极速</string>
+    <string name="page_anim_speed_fast">快速</string>
+    <string name="page_anim_speed_moderate">适中</string>
+    <string name="page_anim_speed_elegant">优雅</string>
     <string name="up_change_source_last_chapter_t">后台更新换源最新章节</string>
     <string name="up_change_source_last_chapter_s">开启则会在软件打开 1 分钟后开始更新</string>
     <string name="behavior_main_t">书架工具栏自动隐藏</string>

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

@@ -481,6 +481,11 @@
     <string name="page_anim_simulation">仿真</string>
     <string name="page_anim_scroll">滾動</string>
     <string name="page_anim_none">無動畫</string>
+    <string name="page_anim_speed">翻頁動畫速度</string>
+    <string name="page_anim_speed_fastest">極速</string>
+    <string name="page_anim_speed_fast">快速</string>
+    <string name="page_anim_speed_moderate">適中</string>
+    <string name="page_anim_speed_elegant">優雅</string>
     <string name="up_change_source_last_chapter_t">後台更新換源最新章節</string>
     <string name="up_change_source_last_chapter_s">開啟則會在軟件打開 1 分鐘後開始更新</string>
     <string name="behavior_main_t">書架 ToolBar 自動隱藏</string>

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

@@ -484,6 +484,11 @@
     <string name="page_anim_simulation">模擬</string>
     <string name="page_anim_scroll">滾動</string>
     <string name="page_anim_none">無動畫</string>
+    <string name="page_anim_speed">翻頁動畫速度</string>
+    <string name="page_anim_speed_fastest">極速</string>
+    <string name="page_anim_speed_fast">快速</string>
+    <string name="page_anim_speed_moderate">適中</string>
+    <string name="page_anim_speed_elegant">優雅</string>
     <string name="up_change_source_last_chapter_t">後台更新換源最新章節</string>
     <string name="up_change_source_last_chapter_s">開啟則會在軟體打開1分鐘後開始更新</string>
     <string name="behavior_main_t">書架ToolBar自動隱藏</string>

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

@@ -576,6 +576,11 @@
     <string name="page_anim_simulation">Simulation</string>
     <string name="page_anim_scroll">Scroll</string>
     <string name="page_anim_none">None</string>
+    <string name="page_anim_speed">Flip Speed</string>
+    <string name="page_anim_speed_fastest">Fastest</string>
+    <string name="page_anim_speed_fast">Fast</string>
+    <string name="page_anim_speed_moderate">Moderate</string>
+    <string name="page_anim_speed_elegant">Elegant</string>
     <string name="up_change_source_last_chapter_t">Update the latest chapter after changed origin in the background</string>
     <string name="up_change_source_last_chapter_s">If enabled,the update will start 1 minute later when the software is run</string>
     <string name="behavior_main_t">Auto Hide Toolbar</string>

+ 28 - 2
app/src/test/java/io/legado/app/feature/reader/core/transition/PageCurlGeometryTest.kt

@@ -81,8 +81,9 @@ class PageCurlGeometryTest {
     }
 
     @Test fun simulationSettleDurationUsesItsFullLegacyCurlTravel() {
-        assertEquals(570, ReaderCurlTouchPolicy.settleDurationMillis(900f, -1000f, 1000f))
-        assertEquals(300, ReaderCurlTouchPolicy.settleDurationMillis(0f, 1000f, 1000f))
+        // 基准已统一为 360(= 适中挡):整段折页走满 360,1.9 屏折页距离按比例放大。
+        assertEquals(684, ReaderCurlTouchPolicy.settleDurationMillis(900f, -1000f, 1000f))
+        assertEquals(360, ReaderCurlTouchPolicy.settleDurationMillis(0f, 1000f, 1000f))
         assertEquals(0, ReaderCurlTouchPolicy.settleDurationMillis(1000f, 1000f, 1000f))
     }
 
@@ -93,6 +94,31 @@ class PageCurlGeometryTest {
         assertEquals(90, ReaderCurlTouchPolicy.settleDurationMillis(999f, 1000f, 1000f))
     }
 
+    @Test
+    fun simulationSettleFloorScalesWithTheTierBase() {
+        // 下限取基准的 25%:默认 360 → 90(与改造前一致),换挡后同步缩小/放大。
+        // 写死 90ms 会让快速挡的短距离收尾慢得不成比例(90 / 240 = 37%)。
+        assertEquals(90, ReaderCurlTouchPolicy.settleDurationMillis(999f, 1000f, 1000f))
+        assertEquals(
+            60,
+            ReaderCurlTouchPolicy.settleDurationMillis(
+                999f,
+                1000f,
+                1000f,
+                baseDurationMillis = 240
+            ),
+        )
+        assertEquals(
+            135,
+            ReaderCurlTouchPolicy.settleDurationMillis(
+                999f,
+                1000f,
+                1000f,
+                baseDurationMillis = 540
+            ),
+        )
+    }
+
     @Test
     fun simulationCurlRevealsTheSafeSnapContinuouslyFromTheTouchedCorner() {
         assertEquals(

+ 4 - 2
app/src/test/java/io/legado/app/feature/reader/core/transition/ReaderPageTransitionTest.kt

@@ -15,7 +15,8 @@ class ReaderPageTransitionTest {
 
     @Test
     fun `settle duration scales with the remaining legacy scroll distance`() {
-        // 基准 360:点击与按键共用同一收尾基准(产品确认保留,不回退旧 View 的 300)。
+        // 基准 360:点击与按键共用同一收尾基准,且与仿真折页、滚动翻屏共用
+        // 同一个 ReaderPageTurnSpeed.BASE_DURATION_MILLIS。
         assertEquals(360, ReaderPageTransitionPolicy.settleDurationMillis(0f, -800f, 800f))
         assertEquals(90, ReaderPageTransitionPolicy.settleDurationMillis(-600f, -800f, 800f))
         assertEquals(270, ReaderPageTransitionPolicy.settleDurationMillis(-600f, 0f, 800f))
@@ -24,7 +25,8 @@ class ReaderPageTransitionTest {
 
     @Test
     fun `both settle overloads share the same base duration`() {
-        // 两个重载的默认基准必须一致:分叉过一次(360 / 300),测试便无法同时代表生产路径。
+        // 两个重载的默认基准必须一致(历史上分叉过一次 360 / 300),
+        // 否则任一断言都无法同时代表生产路径。
         assertEquals(
             ReaderPageTransitionPolicy.settleDurationMillis(0f, -800f, 800f),
             ReaderPageTransitionPolicy.settleDurationMillis(

+ 77 - 0
app/src/test/java/io/legado/app/feature/reader/core/transition/ReaderPageTurnSpeedTest.kt

@@ -0,0 +1,77 @@
+package io.legado.app.feature.reader.core.transition
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * 翻页动画速度挡位:四挡都是绝对基准时长(240 / 300 / **360 默认** / 540),
+ * 且每个挡位都必须整体作用于覆盖/滑动/渐变、仿真折页、滚动翻屏三条路径。
+ */
+class ReaderPageTurnSpeedTest {
+
+    @Test
+    fun `四挡时长与声明顺序一致`() {
+        assertEquals(240, ReaderPageTurnSpeed.FASTEST.baseDurationMillis)
+        assertEquals(300, ReaderPageTurnSpeed.FAST.baseDurationMillis)
+        assertEquals(360, ReaderPageTurnSpeed.MODERATE.baseDurationMillis)
+        assertEquals(540, ReaderPageTurnSpeed.ELEGANT.baseDurationMillis)
+    }
+
+    @Test
+    fun `默认挡是 360ms 且等于统一基准`() {
+        assertEquals(360, ReaderPageTurnSpeed.BASE_DURATION_MILLIS)
+        assertEquals(ReaderPageTurnSpeed.MODERATE, ReaderPageTurnSpeed.Default)
+        assertEquals(
+            ReaderPageTurnSpeed.BASE_DURATION_MILLIS,
+            ReaderPageTurnSpeed.Default.baseDurationMillis,
+        )
+    }
+
+    @Test
+    fun `三条翻页路径的默认基准就是统一基准`() {
+        // 一次整页/整屏翻页从第 0 帧起算时都应等于基准。任一路径另开一份常量,
+        // 这条断言就会红——这正是「统一基准」要防的回归。
+        assertEquals(360, ReaderPageTransitionPolicy.settleDurationMillis(0f, -800f, 800f))
+        assertEquals(360, ReaderCurlTouchPolicy.settleDurationMillis(0f, 800f, 800f))
+        assertEquals(360, ReaderScrollPolicy.stepDurationMillis(-800f, 800f))
+    }
+
+    @Test
+    fun `任一挡位都整体作用于三条路径`() {
+        ReaderPageTurnSpeed.entries.forEach { speed ->
+            val base = speed.baseDurationMillis
+            val actual = mapOf(
+                "收尾" to ReaderPageTransitionPolicy.settleDurationMillis(
+                    0f, -800f, 800f, baseDurationMillis = base,
+                ),
+                "折页" to ReaderCurlTouchPolicy.settleDurationMillis(
+                    0f, 800f, 800f, baseDurationMillis = base,
+                ),
+                "滚动" to ReaderScrollPolicy.stepDurationMillis(
+                    -800f, 800f, animationSpeedMillis = base,
+                ),
+            )
+            actual.forEach { (path, millis) ->
+                assertEquals("${speed.name} 挡的${path}时长不等于挡位基准", base, millis)
+            }
+        }
+    }
+
+    @Test
+    fun `挡位越高时长越长且互不相等`() {
+        val durations = ReaderPageTurnSpeed.entries.map { it.baseDurationMillis }
+        assertEquals("挡位顺序与时长顺序不一致:$durations", durations.sorted(), durations)
+        assertTrue("挡位之间存在重复时长:$durations", durations.toSet().size == durations.size)
+    }
+
+    @Test
+    fun `越界配置值回落到默认适中挡`() {
+        assertEquals(ReaderPageTurnSpeed.Default, ReaderPageTurnSpeed.fromValue(Int.MIN_VALUE))
+        assertEquals(ReaderPageTurnSpeed.Default, ReaderPageTurnSpeed.fromValue(Int.MAX_VALUE))
+        assertEquals(ReaderPageTurnSpeed.FASTEST, ReaderPageTurnSpeed.fromValue(0))
+        assertEquals(ReaderPageTurnSpeed.FAST, ReaderPageTurnSpeed.fromValue(1))
+        assertEquals(ReaderPageTurnSpeed.MODERATE, ReaderPageTurnSpeed.fromValue(2))
+        assertEquals(ReaderPageTurnSpeed.ELEGANT, ReaderPageTurnSpeed.fromValue(3))
+    }
+}

+ 6 - 5
app/src/test/java/io/legado/app/feature/reader/core/transition/ReaderScrollStateTest.kt

@@ -173,13 +173,14 @@ class ReaderScrollStateTest {
 
     @Test
     fun stepDurationScalesWithDistanceLikeTheLegacyScroller() {
-        // 旧 PageDelegate.startScroll:animationSpeed(300) * |dy| / viewHeight。
-        assertEquals(300, ReaderScrollPolicy.stepDurationMillis(-800f, 800f))
-        assertEquals(150, ReaderScrollPolicy.stepDurationMillis(-400f, 800f))
-        assertEquals(300, ReaderScrollPolicy.stepDurationMillis(800f, 800f))
+        // 旧 PageDelegate.startScroll:animationSpeed * |dy| / viewHeight,
+        // 其中 animationSpeed 现取全动画共用的统一基准 360(旧 ReadView 的 300 已并入)。
+        assertEquals(360, ReaderScrollPolicy.stepDurationMillis(-800f, 800f))
+        assertEquals(180, ReaderScrollPolicy.stepDurationMillis(-400f, 800f))
+        assertEquals(360, ReaderScrollPolicy.stepDurationMillis(800f, 800f))
         // 零步距与零视口都不能退化成 0 时长(tween 需要正值)。
         assertEquals(1, ReaderScrollPolicy.stepDurationMillis(0f, 800f))
-        assertEquals(300, ReaderScrollPolicy.stepDurationMillis(-800f, 0f))
+        assertEquals(360, ReaderScrollPolicy.stepDurationMillis(-800f, 0f))
     }
 
     private fun text(top: Float, bottom: Float, position: Int) = ReaderElement.Text(

+ 5 - 3
app/src/test/java/io/legado/app/help/config/ReadConfigJsonRoundTripTest.kt

@@ -52,11 +52,11 @@ class ReadConfigJsonRoundTripTest {
 
     @Test
     fun `私有的模式相关字段也能往返`() {
-        // textColor / textColorNight / pageAnim / darkStatusIcon 是 private 构造参数,
-        // 只能经由 withCurXxx 写、getXxx 读——但它们同样要落盘。
+        // textColor / textColorNight / pageAnim / pageAnimSpeed / darkStatusIcon 是 private
+        // 构造参数,只能经由 withCurXxx 写、getXxx 读——但它们同样要落盘。
         val json = """
             {"name":"x","textColor":"#112233","textColorNight":"#445566",
-             "textColorEInk":"#778899","pageAnim":3,"pageAnimEInk":1,
+             "textColorEInk":"#778899","pageAnim":3,"pageAnimEInk":1,"pageAnimSpeed":2,
              "darkStatusIcon":false,"darkStatusIconNight":true}
         """.trimIndent()
 
@@ -67,6 +67,7 @@ class ReadConfigJsonRoundTripTest {
         assertEquals("#778899", restored.getTextColorEInk())
         assertEquals(3, restored.getPageAnim())
         assertEquals(1, restored.getPageAnimEInk())
+        assertEquals(2, restored.getPageAnimSpeed())
         assertEquals(false, restored.getDarkStatusIcon())
         assertEquals(true, restored.getDarkStatusIconNight())
     }
@@ -80,6 +81,7 @@ class ReadConfigJsonRoundTripTest {
         assertEquals(100, restored.bgAlpha)
         assertEquals("  ", restored.paragraphIndent)
         assertEquals(12, restored.lineSpacingExtra)
+        assertEquals("缺字段应回落到适中挡(2 = 统一基准 360ms)", 2, restored.getPageAnimSpeed())
     }
 
     @Test

+ 4 - 0
app/src/test/java/io/legado/app/ui/book/read/ConfigUpdateActionsInvariantTest.kt

@@ -162,6 +162,10 @@ class ConfigUpdateActionsInvariantTest {
             "MaxLengthWithNoToc",
             // 只写 DataStore 的 readingAnchorEnabled,锚点栏可见性由 readPreferences 反应式驱动
             "ReadingAnchorEnabled",
+            // 翻页速度挡位只改翻页动画的折算基准时长,下一次翻页现读挡位;不改变动画种类、
+            // 页高或排版,因此没有需要命令式驱动的渲染副作用。它写的是排版配置而非 DataStore,
+            // 快照经 `ReadStyleGateway.publishState` 反应式刷新,故同样落在这里。
+            "PageAnimSpeed",
         )
     }
 }

+ 12 - 0
app/src/test/java/io/legado/app/ui/book/read/LegacyConfigUpdateActionsTest.kt

@@ -251,6 +251,18 @@ class LegacyConfigUpdateActionsTest {
             }
     }
 
+    /**
+     * 翻页动画速度是 Compose 时代新增项(旧 View 无对应事件码):只缩放收尾动画时长,
+     * 不改变动画种类、页高或排版,因此不得驱动任何正文副作用。
+     */
+    @Test
+    fun `page turn speed never repaints the reading content`() {
+        assertEquals(
+            emptySet<ConfigUpdateAction>(),
+            ConfigUpdate.PageAnimSpeed(2).actions,
+        )
+    }
+
     /**
      * 菜单颜色/边框是 Compose 时代新增项(旧 View 无对应事件码),只被阅读菜单读取。
      * 除 `MenuBgColor` 需要刷新状态栏图标外,不得驱动任何正文副作用——尤其不能带

+ 10 - 3
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -227,16 +227,23 @@ class ReadBookDomainSplitBoundaryTest {
      * 三个意图分支(各 1–2 行转发)、`SetFinishCurrentChapterAfterTimer` 因参数超长折行多出的
      * 2 行(纯格式化),以及 `stopReadAloudForClose()` 里读 `keepReadAloudOnExit` 决定是否
      * 继续后台朗读的短路(含注释)——关闭决策点只能在 VM,摘不成 delegate。
+     *
+     * 2746 → 2747:翻页动画速度挡位(极速 / 快速 / 适中 / 优雅)在 `buildStyleConfig()` 的
+     * `ReadBookStyleConfig` 快照里多一个字段,VM 侧只有这一行取值。没有摘成 delegate:
+     * 挡位要和 `pageAnim` 落进同一份快照,GlobalThemePage 才能反应式读到它,
+     * 而该快照的构造点就在 VM 的 `buildStyleConfig()`;单独拆一个类只会得到
+     * 「把一行赋值搬进新文件」的空壳。其余改动都在 `ReaderPageTurnSpeed`、
+     * `ReaderCanvasSurface` 与配置链路(`ReadBookConfig` / `ReadStyleGateway`),不占 VM 行。
      */
     @Test
-    fun `ReadBookViewModel 不超过 R2 验收的 2746 行`() {
+    fun `ReadBookViewModel 不超过 R2 验收的 2747 行`() {
         val lineCount = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt")
             .readLines().size
         assertTrue(
-            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2746。\n" +
+            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2747。\n" +
                 "新功能请摘成 io/legado/app/ui/book/read/ 下的 XxxDelegate," +
                 "并在本测试的 DOMAINS 里加一条边界。",
-            lineCount <= 2746,
+            lineCount <= 2747,
         )
     }