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

+ 34 - 6
app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudPlaybackQueue.kt

@@ -11,10 +11,15 @@ data class ReadAloudPlaybackCue(
     val characterId: String?,
     val emotion: String = "",
     val characterPerformance: CharacterPerformanceProfile? = null,
+    val isChapterTitle: Boolean = false,
 ) {
     init {
         require(chapterStart >= 0)
-        require(chapterEnd == chapterStart + text.length)
+        if (isChapterTitle) {
+            require(chapterStart == 0 && chapterEnd == 0)
+        } else {
+            require(chapterEnd == chapterStart + text.length)
+        }
     }
 }
 
@@ -38,11 +43,16 @@ class ReadAloudPlaybackQueue private constructor(
 ) {
 
     val isEmpty: Boolean get() = cues.isEmpty()
+    val leadingTitleCueCount: Int
+        get() = cues.indexOfFirst { !it.isChapterTitle }
+            .let { if (it < 0) cues.size else it }
 
     fun cursorAt(chapterPosition: Int): ReadAloudPlaybackCursor? {
-        if (cues.isEmpty()) return null
+        val bodyStartIndex = leadingTitleCueCount
+        if (bodyStartIndex >= cues.size) return null
         val position = chapterPosition.coerceAtLeast(0)
-        val containingIndex = cues.binarySearch { cue ->
+        val bodyCues = cues.subList(bodyStartIndex, cues.size)
+        val containingIndex = bodyCues.binarySearch { cue ->
             when {
                 cue.chapterEnd <= position -> -1
                 cue.chapterStart > position -> 1
@@ -52,11 +62,12 @@ class ReadAloudPlaybackQueue private constructor(
         val index = if (containingIndex >= 0) {
             containingIndex
         } else {
-            (-containingIndex - 1).coerceAtMost(cues.lastIndex)
+            (-containingIndex - 1).coerceAtMost(bodyCues.lastIndex)
         }
-        val cue = cues[index]
+        val cueIndex = bodyStartIndex + index
+        val cue = cues[cueIndex]
         return ReadAloudPlaybackCursor(
-            cueIndex = index,
+            cueIndex = cueIndex,
             offset = (position - cue.chapterStart).coerceIn(0, cue.text.length),
         )
     }
@@ -69,6 +80,23 @@ class ReadAloudPlaybackQueue private constructor(
         (cursor.cueIndex + 1).takeIf { it in cues.indices }
             ?.let { ReadAloudPlaybackCursor(it, 0) }
 
+    fun withChapterTitle(title: String): ReadAloudPlaybackQueue {
+        val normalizedTitle = title.trim()
+        if (normalizedTitle.isEmpty() || isEmpty || cues.first().isChapterTitle) return this
+        val titleCue = ReadAloudPlaybackCue(
+            text = normalizedTitle,
+            chapterStart = 0,
+            chapterEnd = 0,
+            paragraphIndex = -1,
+            voice = null,
+            fallbackVoices = emptyList(),
+            roleType = SpeechRoleType.Narrator,
+            characterId = null,
+            isChapterTitle = true,
+        )
+        return ReadAloudPlaybackQueue(listOf(titleCue) + cues)
+    }
+
     companion object {
         val Empty = ReadAloudPlaybackQueue(emptyList())
 

+ 255 - 45
app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt

@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.magnifier
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
@@ -165,6 +166,8 @@ import kotlin.math.sin
 // 把手圆挂在行底部下方、顶端圆周与竖线末端相切;圆区域在拖动命中时视作竖线的延伸。
 private val SelectionHandleRadius = 7.dp
 private val SelectionHandleStrokeWidth = 2.dp
+private const val SelectionHandleFadeOutMillis = 90
+private const val SelectionHandleFadeInMillis = 140
 
 @Composable
 fun ReaderCanvasSurface(
@@ -310,6 +313,14 @@ fun ReaderCanvasSurface(
         mutableLongStateOf(ReaderAutoPagePolicy.pageDurationMillis(autoReadSpeedSeconds))
     }
     var textSelection by remember { mutableStateOf<ReaderSelection?>(null) }
+    // API 28+ 的平台放大镜直接采样当前 Compose View;未指定 sourceCenter 时会立即隐藏。
+    // 采样点始终是正在移动的文字端点,而不是被把手遮挡的手指位置。
+    var selectionMagnifierSource by remember { mutableStateOf<Offset?>(null) }
+    // 拖动中的圆柄属于指针反馈,不属于吸附后的文字布局。二者分离后,端点跨行
+    // 可以更新竖线和选区,而圆柄仍沿手指的连续轨迹移动。
+    var selectionDragHandleCenter by remember { mutableStateOf<Offset?>(null) }
+    var selectionDragEndpoint by remember { mutableStateOf<ReaderSelectionEndpoint?>(null) }
+    val selectionHandleRadiusPx = with(LocalDensity.current) { SelectionHandleRadius.toPx() }
     val latestSelectionPausesAutoPage by rememberUpdatedState(textSelection != null)
     var selectionMenuVisible by remember { mutableStateOf(false) }
     var selectionLayoutRevision by remember { mutableLongStateOf(current.layoutRevision) }
@@ -339,6 +350,36 @@ fun ReaderCanvasSurface(
         } else {
             ReaderPageViewportLayout.paged(window)
         }
+    fun selectionEndpointBound(
+        selection: ReaderSelection,
+        endpoint: ReaderSelectionEndpoint,
+        window: ReaderPageWindow = currentPageWindow(),
+    ) = run {
+        val bounds = pageViewportLayout(window).selectionBounds(selection).map { it.bounds }
+        val visualStart = endpoint == selection.visualStartEndpoint()
+        if (visualStart) bounds.firstOrNull() else bounds.lastOrNull()
+    }
+
+    fun selectionCursorCenter(
+        selection: ReaderSelection,
+        endpoint: ReaderSelectionEndpoint,
+        window: ReaderPageWindow = currentPageWindow(),
+        draggedHandleCenter: Offset? = null,
+    ): Offset? {
+        val bound = selectionEndpointBound(selection, endpoint, window) ?: return null
+        val visualStart = endpoint == selection.visualStartEndpoint()
+        val logicalLineCenter = Offset(
+            x = if (visualStart) bound.left else bound.right,
+            y = (bound.top + bound.bottom) / 2f,
+        )
+        val logicalHandleCenter = Offset(
+            x = logicalLineCenter.x,
+            y = bound.bottom + selectionHandleRadiusPx,
+        )
+        // 拖动时竖线与圆柄作为一个整体平移,放大镜也采样平移后的竖线中段。
+        return logicalLineCenter + ((draggedHandleCenter
+            ?: logicalHandleCenter) - logicalHandleCenter)
+    }
     fun dismissSelectionMenu() {
         selectionMenuVisible = false
         latestDismissSelectionMenu()
@@ -355,6 +396,9 @@ fun ReaderCanvasSurface(
     fun clearSelectionForPageChange(origin: ReaderPageChangeOrigin) {
         if (textSelection != null && ReaderSelectionLifecyclePolicy.shouldClearForPageChange(origin)) {
             textSelection = null
+            selectionMagnifierSource = null
+            selectionDragHandleCenter = null
+            selectionDragEndpoint = null
             dismissSelectionMenu()
         }
     }
@@ -498,9 +542,14 @@ fun ReaderCanvasSurface(
     fun tapScrollPage(direction: ReaderTurnDirection) {
         val window = currentPageWindow()
         val page = window.current ?: return
-        // “保留一行”步距基于三页合成可视内容:页底露出的下一页行也是目标行候选。
+        // “保留一行”步距基于连续相邻页合成内容:短页叠加时,下下页的行也可能已露出。
         val distance = ReaderScrollPolicy.pageStep(
-            page, scrollOffset, direction, window.previous, window.next,
+            page = page,
+            offsetPx = scrollOffset,
+            direction = direction,
+            previous = window.previous,
+            next = window.next,
+            nextPlus = window.nextPlus,
         )
         pageMotionJob?.cancel()
         val steps = ReaderGestureSettingsPolicy.scrollPageAnimationSteps(latestNoAnimationScrollPage)
@@ -589,6 +638,9 @@ fun ReaderCanvasSurface(
     LaunchedEffect(externalSelectionCancels) {
         externalSelectionCancels.collect {
             textSelection = null
+            selectionMagnifierSource = null
+            selectionDragHandleCenter = null
+            selectionDragEndpoint = null
             selectionMenuVisible = false
         }
     }
@@ -627,6 +679,9 @@ fun ReaderCanvasSurface(
             ) && selection != null && !showSelectionMenu(selection, latestPages)
         ) {
             textSelection = null
+            selectionMagnifierSource = null
+            selectionDragHandleCenter = null
+            selectionDragEndpoint = null
             dismissSelectionMenu()
         }
     }
@@ -751,6 +806,8 @@ fun ReaderCanvasSurface(
         }
         .clipToBounds()
         .background(backgroundColor)
+        // Foundation 在 API 28 以下将其降级为 no-op;这里无需另建低版本的昂贵位图快照。
+        .magnifier(sourceCenter = { selectionMagnifierSource ?: Offset.Unspecified }, zoom = 1.2f)
         .pointerInput(transitionMode, current.widthPx, current.heightPx, configuredTouchSlopPx) {
             val pageTouchSlop = ReaderGestureSettingsPolicy.touchSlopPx(
                 viewConfiguration.touchSlop,
@@ -789,6 +846,8 @@ fun ReaderCanvasSurface(
                 var grabbingStart = false
                 var grabbingEnd = false
                 var grabbedEndpoint: ReaderSelectionEndpoint? = null
+                var handleGrabOffset = Offset.Zero
+                var handleHasMoved = false
                 var suppressTap = false
                 var pointerPosition = down.position
                 val downWindow = turnedWindow ?: currentPageWindow()
@@ -798,21 +857,60 @@ fun ReaderCanvasSurface(
                 val downPageY = downPlacement?.localY(down.position.y) ?: down.position.y
                 textSelection?.let { selection ->
                     val bounds = downSelectionLayout.selectionBounds(selection).map { it.bounds }
-                    val handleRadius = 28f * density
+                    // 命中点必须是实际绘制出来的圆心,不能仍以文字行底为中心。保留较大的
+                    // 28dp 热区,让圆下方的正文也能作为把手的触控区域,但坐标仍以圆心换算。
+                    val handleHitRadius = 28f * density
                     val start = bounds.firstOrNull()
                     val end = bounds.lastOrNull()
-                    grabbingStart =
-                        start != null && Offset(start.left, start.bottom).minus(down.position)
-                            .getDistance() <= handleRadius
-                    grabbingEnd = end != null && Offset(end.right, end.bottom).minus(down.position)
-                        .getDistance() <= handleRadius
-                    grabbedEndpoint = when {
-                        grabbingStart -> selection.visualStartEndpoint()
-                        grabbingEnd -> selection.visualEndEndpoint()
-                        else -> null
+                    val startCenter = start?.let {
+                        Offset(
+                            it.left,
+                            it.bottom + selectionHandleRadiusPx,
+                        )
+                    }
+                    val endCenter = end?.let {
+                        Offset(
+                            it.right,
+                            it.bottom + selectionHandleRadiusPx,
+                        )
+                    }
+
+                    fun handleDistance(x: Float, top: Float, center: Offset): Float {
+                        // 竖线与圆视作同一个胶囊形手柄:求手指到“竖线顶端—圆心”
+                        // 中轴线的最短距离,再用统一热区判断。
+                        val nearestY = down.position.y.coerceIn(top, center.y)
+                        return Offset(x, nearestY).minus(down.position).getDistance()
+                    }
+
+                    val startDistance = if (start != null && startCenter != null) {
+                        handleDistance(start.left, start.top, startCenter)
+                    } else {
+                        Float.POSITIVE_INFINITY
                     }
+                    val endDistance = if (end != null && endCenter != null) {
+                        handleDistance(end.right, end.top, endCenter)
+                    } else {
+                        Float.POSITIVE_INFINITY
+                    }
+                    when {
+                        startDistance <= endDistance && startDistance <= handleHitRadius -> {
+                            grabbingStart = true
+                            grabbedEndpoint = selection.visualStartEndpoint()
+                            handleGrabOffset = down.position - checkNotNull(startCenter)
+                        }
+
+                        endDistance <= handleHitRadius -> {
+                            grabbingEnd = true
+                            grabbedEndpoint = selection.visualEndEndpoint()
+                            handleGrabOffset = down.position - checkNotNull(endCenter)
+                        }
+                    }
+                    selectionDragEndpoint = grabbedEndpoint
+                    selectionDragHandleCenter = null
                     if (!grabbingStart && !grabbingEnd) {
                         textSelection = null
+                        selectionMagnifierSource = null
+                        selectionDragEndpoint = null
                         dismissSelectionMenu()
                         suppressTap = true
                     } else dismissSelectionMenu()
@@ -833,6 +931,10 @@ fun ReaderCanvasSurface(
                                 ReaderSelectionPolicy.startWord(page, down.position.x, downPageY)
                                     ?.let {
                                         textSelection = it
+                                        selectionMagnifierSource = selectionCursorCenter(
+                                            it,
+                                            ReaderSelectionEndpoint.FOCUS,
+                                        )
                                         if (latestSelectionHapticsEnabled) {
                                             hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
                                         }
@@ -863,34 +965,40 @@ fun ReaderCanvasSurface(
                         }
                         if (longPressed || grabbingStart || grabbingEnd) {
                             val selection = textSelection
-                            val placement =
-                                pageViewportLayout().pageAt(change.position.x, change.position.y)
-                            if (placement != null && selection != null) {
-                                val page = placement.page
-                                // 圆把手视作竖线的延伸:手指落在把手圆区域内时按本行底边命中,
-                                // 避免圆与下一行之间的间隙把手柄拖动吸到下一行。
-                                var viewportY = change.position.y
-                                if (grabbedEndpoint != null) {
-                                    val endpointBounds =
-                                        pageViewportLayout().selectionBounds(selection)
-                                            .map { it.bounds }
-                                    val anchorBound =
-                                        if (grabbingStart) endpointBounds.firstOrNull() else endpointBounds.lastOrNull()
-                                    if (anchorBound != null) {
-                                        val zoneBottom =
-                                            anchorBound.bottom + 2 * SelectionHandleRadius.toPx()
-                                        if (viewportY > anchorBound.bottom && viewportY <= zoneBottom) {
-                                            viewportY = anchorBound.bottom
-                                        }
+                            val movingEndpoint = grabbedEndpoint ?: ReaderSelectionEndpoint.FOCUS
+                            // PointerInput 会先派发一个与 DOWN 位置相同的事件。把手尚未移动时
+                            // 不能再用行底去 hit-test,否则该边界可能直接吸附到下一行。
+                            if (grabbedEndpoint != null && !handleHasMoved) {
+                                handleHasMoved = change.position != down.position
+                                if (!handleHasMoved) {
+                                    selectionMagnifierSource = selection?.let {
+                                        selectionCursorCenter(it, movingEndpoint)
                                     }
+                                    change.consume()
+                                    continue
                                 }
-                                val pageY = placement.localY(viewportY)
+                            }
+                            if (grabbedEndpoint != null) {
+                                // 固定 DOWN 时手指相对圆心的二维偏移。跨字符、跨行后都不能
+                                // 用新的文字 bounds 重算,否则手指会从圆上滑到竖线上。
+                                selectionDragHandleCenter = change.position - handleGrabOffset
+                            }
+                            val draggedHandleCenter = selectionDragHandleCenter
+                            val cursorViewportX = draggedHandleCenter?.x ?: change.position.x
+                            val cursorViewportY = draggedHandleCenter?.let {
+                                it.y - selectionHandleRadiusPx
+                            } ?: change.position.y
+                            val placement = pageViewportLayout()
+                                .pageAt(cursorViewportX, cursorViewportY)
+                            if (placement != null && selection != null) {
+                                val page = placement.page
+                                val pageY = placement.localY(cursorViewportY)
                                 val hit =
-                                    ReaderSelectionPolicy.start(page, change.position.x, pageY)
+                                    ReaderSelectionPolicy.start(page, cursorViewportX, pageY)
                                         ?: if (grabbedEndpoint != null) {
                                             ReaderSelectionPolicy.snapToText(
                                                 page,
-                                                change.position.x,
+                                                cursorViewportX,
                                                 pageY
                                             )?.let {
                                                 ReaderSelection(
@@ -914,7 +1022,7 @@ fun ReaderCanvasSurface(
                                         else -> ReaderSelectionPolicy.extend(
                                             selection,
                                             page,
-                                            change.position.x,
+                                            cursorViewportX,
                                             pageY
                                         )
                                     }
@@ -924,6 +1032,17 @@ fun ReaderCanvasSurface(
                                             hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
                                         }
                                     }
+                                    selectionMagnifierSource = selectionCursorCenter(
+                                        updatedSelection,
+                                        movingEndpoint,
+                                        draggedHandleCenter = draggedHandleCenter,
+                                    )
+                                } else {
+                                    selectionMagnifierSource = selectionCursorCenter(
+                                        selection,
+                                        movingEndpoint,
+                                        draggedHandleCenter = draggedHandleCenter,
+                                    )
                                 }
                                 change.consume()
                             }
@@ -1036,6 +1155,8 @@ fun ReaderCanvasSurface(
                     }
                 } finally {
                     longPressJob.cancel()
+                    selectionDragHandleCenter = null
+                    selectionDragEndpoint = null
                     if (!released) {
                         bookmarkArmed = false
                         bookmarkOffset = 0f
@@ -1054,6 +1175,7 @@ fun ReaderCanvasSurface(
                 if (released) latestReaderInteraction()
                 if (longPressed || grabbingStart || grabbingEnd) {
                     val selection = textSelection
+                    selectionMagnifierSource = null
                     if (released && selection != null) {
                         val window = latestPages
                         showSelectionMenu(selection, window)
@@ -1267,6 +1389,35 @@ fun ReaderCanvasSurface(
         if (ReaderViewportLayerPolicy.usesFixedPageChrome(transitionMode)) {
             ReaderPageDecorationOverlay(current, Modifier.fillMaxSize())
         }
+        val hiddenHandleEndpoint = selectionDragEndpoint.takeIf {
+            selectionDragHandleCenter != null
+        }
+        val anchorHandleTargetAlpha =
+            if (hiddenHandleEndpoint == ReaderSelectionEndpoint.ANCHOR) 0f else 1f
+        val focusHandleTargetAlpha =
+            if (hiddenHandleEndpoint == ReaderSelectionEndpoint.FOCUS) 0f else 1f
+        val anchorHandleAlpha by animateFloatAsState(
+            targetValue = anchorHandleTargetAlpha,
+            animationSpec = tween(
+                if (anchorHandleTargetAlpha == 0f) {
+                    SelectionHandleFadeOutMillis
+                } else {
+                    SelectionHandleFadeInMillis
+                }
+            ),
+            label = "readerSelectionAnchorHandleAlpha",
+        )
+        val focusHandleAlpha by animateFloatAsState(
+            targetValue = focusHandleTargetAlpha,
+            animationSpec = tween(
+                if (focusHandleTargetAlpha == 0f) {
+                    SelectionHandleFadeOutMillis
+                } else {
+                    SelectionHandleFadeInMillis
+                }
+            ),
+            label = "readerSelectionFocusHandleAlpha",
+        )
         textSelection?.let { selection ->
             val bounds = pageViewportLayout(pages).selectionBounds(selection).map { it.bounds }
             val handleColor = selectionColor.copy(alpha = 1f)
@@ -1283,17 +1434,71 @@ fun ReaderCanvasSurface(
                     top = if (transitionMode == ReaderTransitionMode.SCROLL) current.contentTopPx else 0f,
                     bottom = if (transitionMode == ReaderTransitionMode.SCROLL) current.contentBottomPx else size.height,
                 ) {
-                    fun drawPinHandle(x: Float, top: Float, bottom: Float) {
+                    fun drawPinHandle(
+                        x: Float,
+                        top: Float,
+                        bottom: Float,
+                        endpoint: ReaderSelectionEndpoint,
+                    ) {
+                        val handleAlpha = when (endpoint) {
+                            ReaderSelectionEndpoint.ANCHOR -> anchorHandleAlpha
+                            ReaderSelectionEndpoint.FOCUS -> focusHandleAlpha
+                        }
+                        if (handleAlpha <= 0.001f) return
                         val lineWidth = SelectionHandleStrokeWidth.toPx()
                         val radius = SelectionHandleRadius.toPx()
-                        // 竖线止于行底部,圆的顶端圆周与竖线末端相切
-                        val center = Offset(x, bottom + radius)
-                        drawLine(handleColor, Offset(x, top), Offset(x, bottom), lineWidth, StrokeCap.Round)
-                        drawIntoCanvas { it.nativeCanvas.drawCircle(x, center.y, radius, handleShadowPaint) }
-                        drawCircle(handleColor, radius, center, style = Stroke(SelectionHandleStrokeWidth.toPx()))
+                        val logicalCenter = Offset(x, bottom + radius)
+                        val center = if (selectionDragEndpoint == endpoint) {
+                            selectionDragHandleCenter
+                        } else {
+                            null
+                        } ?: logicalCenter
+                        // 竖线和圆柄是同一个手柄:拖动时按相同向量整体平移,保持 DOWN
+                        // 时手指落在圆上的相对位置,而不是让竖线吸附后圆柄单独跳动。
+                        val dragDelta = center - logicalCenter
+                        val lineX = x + dragDelta.x
+                        val lineTop = top + dragDelta.y
+                        val lineBottom = bottom + dragDelta.y
+                        val animatedHandleColor = handleColor.copy(alpha = handleAlpha)
+                        drawLine(
+                            animatedHandleColor,
+                            Offset(lineX, lineTop),
+                            Offset(lineX, lineBottom),
+                            lineWidth,
+                            StrokeCap.Round,
+                        )
+                        drawIntoCanvas {
+                            handleShadowPaint.alpha = (handleAlpha * 255).roundToInt()
+                            it.nativeCanvas.drawCircle(
+                                center.x,
+                                center.y,
+                                radius,
+                                handleShadowPaint
+                            )
+                        }
+                        drawCircle(
+                            animatedHandleColor,
+                            radius,
+                            center,
+                            style = Stroke(SelectionHandleStrokeWidth.toPx()),
+                        )
+                    }
+                    bounds.firstOrNull()?.let { rect ->
+                        drawPinHandle(
+                            rect.left,
+                            rect.top,
+                            rect.bottom,
+                            selection.visualStartEndpoint(),
+                        )
+                    }
+                    bounds.lastOrNull()?.let { rect ->
+                        drawPinHandle(
+                            rect.right,
+                            rect.top,
+                            rect.bottom,
+                            selection.visualEndEndpoint(),
+                        )
                     }
-                    bounds.firstOrNull()?.let { rect -> drawPinHandle(rect.left, rect.top, rect.bottom) }
-                    bounds.lastOrNull()?.let { rect -> drawPinHandle(rect.right, rect.top, rect.bottom) }
                 }
             }
         }
@@ -1393,7 +1598,7 @@ private fun ScrollPageStack(
             .graphicsLayer { translationY = offsetYState.floatValue },
     ) {
         // draw 期快照读:页窗口(同步换窗的 pending)变化只重绘不重组;拖拽平移只更新
-        // graphicsLayer 变换,draw 块不执行。三页连排的 y 是栈坐标,整画布随层平移偏移。
+        // graphicsLayer 变换,draw 块不执行。相邻页的 y 是栈坐标,整画布随层平移偏移。
         val window = windowProvider()
         val current = window.current ?: return@Canvas
         val activeSelection = selectionProvider()
@@ -1419,7 +1624,12 @@ private fun ScrollPageStack(
         }
         window.previous?.let { drawOne(it, -it.scrollExtentPx) }
         drawOne(current, 0f)
-        window.next?.let { drawOne(it, current.scrollExtentPx) }
+        window.next?.let { next ->
+            drawOne(next, current.scrollExtentPx)
+            window.nextPlus?.let { following ->
+                drawOne(following, current.scrollExtentPx + next.scrollExtentPx)
+            }
+        }
     }
 }
 

+ 8 - 0
app/src/main/java/io/legado/app/feature/reader/core/gesture/ReaderPageViewportLayout.kt

@@ -55,6 +55,14 @@ class ReaderPageViewportLayout private constructor(
                     window.next?.let {
                         ReaderPagePlacement(it, scrollOffsetPx + current.scrollExtentPx)
                     },
+                    window.next?.let { next ->
+                        window.nextPlus?.let { following ->
+                            ReaderPagePlacement(
+                                following,
+                                scrollOffsetPx + current.scrollExtentPx + next.scrollExtentPx,
+                            )
+                        }
+                    },
                 ),
             )
         }

+ 1 - 1
app/src/main/java/io/legado/app/feature/reader/core/model/ReaderPage.kt

@@ -193,7 +193,7 @@ data class ReaderPageWindow(
     val previous: ReaderPage? = null,
     val current: ReaderPage? = null,
     val next: ReaderPage? = null,
-    /** 下下页:不参与绘制,供滚动渲染层提前预热绘制数据(对照 shutiao 的四页流)。 */
+    /** 下下页:滚动视口可露出它;分页模式仅将其作为预热页(对照 shutiao 的四页流)。 */
     val nextPlus: ReaderPage? = null,
 )
 

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

@@ -2,6 +2,7 @@ package io.legado.app.feature.reader.core.transition
 
 import io.legado.app.feature.reader.core.model.ReaderElement
 import io.legado.app.feature.reader.core.model.ReaderPage
+import io.legado.app.feature.reader.core.transition.ReaderScrollPolicy.apply
 
 enum class ReaderScrollCrossing { PREVIOUS, NEXT }
 
@@ -22,7 +23,7 @@ object ReaderScrollPolicy {
      * Click paging keeps one visible text row for context, matching ScrollPageDelegate.
      * Non-inline image pages and empty pages move by one full viewport.
      *
-     * "保留一行"的基准是三页合成可视内容(对照旧 getCurVisiblePage):页底已露出下一页
+     * "保留一行"的基准是连续的相邻页合成内容(对照旧 getCurVisiblePage):页底已露出下一页
      * 行、或页顶已露出上一页行时,目标行在邻页里,步距自然跨过页边界(跨页折算由
      * [apply] 在动画帧内完成),而不是停在当前页边缘。
      */
@@ -32,6 +33,7 @@ object ReaderScrollPolicy {
         direction: ReaderTurnDirection,
         previous: ReaderPage? = null,
         next: ReaderPage? = null,
+        nextPlus: ReaderPage? = null,
     ): Float {
         val viewport = (page.contentBottomPx - page.contentTopPx).coerceAtLeast(1f)
         data class Row(val element: ReaderElement, val stackOffset: Float)
@@ -46,7 +48,12 @@ object ReaderScrollPolicy {
             }
             previous?.let { collect(it, -it.scrollExtentPx) }
             collect(page, 0f)
-            next?.let { collect(it, page.scrollExtentPx) }
+            next?.let { nextPage ->
+                collect(nextPage, page.scrollExtentPx)
+                nextPlus?.let { following ->
+                    collect(following, page.scrollExtentPx + nextPage.scrollExtentPx)
+                }
+            }
         }
         val text = visible.filter { it.element is ReaderElement.Text }
         if (text.isEmpty() ||

+ 1 - 1
app/src/main/java/io/legado/app/model/ReadAloud.kt

@@ -174,7 +174,7 @@ object ReadAloud {
     }
 
     fun stop(context: Context) {
-        if (BaseReadAloudService.isRun) {
+        if (BaseReadAloudService.requestStop()) {
             val intent = Intent(context, aloudClass)
             intent.action = IntentAction.stop
             context.startForegroundServiceCompat(intent)

+ 79 - 22
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -37,20 +37,18 @@ import io.legado.app.constant.NotificationId
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.Status
 import io.legado.app.domain.model.PlaybackTimer
-import io.legado.app.help.MediaHelp
-import io.legado.app.domain.model.readaloud.SpeechPlanItem
 import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
-import io.legado.app.domain.model.readaloud.SpeechAnalysisMode
 import io.legado.app.domain.model.readaloud.ReadAloudPlaybackCursor
-import io.legado.app.domain.model.readaloud.ReadAloudPlaybackQueue
 import io.legado.app.domain.model.readaloud.ReadAloudPlaybackInfo
-import io.legado.app.domain.model.readaloud.resolveReadAloudStartPosition
-import io.legado.app.feature.reader.core.readaloud.ReaderReadAloudChapter
+import io.legado.app.domain.model.readaloud.ReadAloudPlaybackQueue
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
+import io.legado.app.domain.model.readaloud.SpeechAnalysisMode
+import io.legado.app.domain.model.readaloud.SpeechPlanItem
+import io.legado.app.domain.model.readaloud.resolveReadAloudStartPosition
 import io.legado.app.domain.usecase.PrepareChapterSpeechPlanUseCase
-import io.legado.app.help.config.AppConfig
+import io.legado.app.feature.reader.core.readaloud.ReaderReadAloudChapter
+import io.legado.app.help.MediaHelp
 import io.legado.app.help.config.AppConfigStore
-import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.glide.ImageLoader
 import io.legado.app.lib.permission.Permissions
@@ -59,10 +57,11 @@ import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.model.ReadBook
 import io.legado.app.receiver.MediaButtonReceiver
+import io.legado.app.service.BaseReadAloudService.Companion.speechDrivingNavigation
+import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.LogUtils
 import io.legado.app.utils.activityPendingIntent
-import io.legado.app.utils.getPrefBoolean
 import io.legado.app.utils.isNightMode
 import io.legado.app.utils.observeEvent
 import io.legado.app.utils.postEvent
@@ -91,13 +90,29 @@ abstract class BaseReadAloudService : BaseService(),
 
     companion object {
         @JvmStatic
+        @Volatile
         var isRun = false
             private set
 
         @JvmStatic
+        @Volatile
         var pause = true
             private set
 
+        @Volatile
+        private var stopRequested = false
+
+        /** 让停止意图在服务异步销毁前即可被阅读器观察到,避免重排正文时重新启动朗读。 */
+        @JvmStatic
+        @Synchronized
+        fun requestStop(): Boolean {
+            if (!isRun || stopRequested) return false
+            stopRequested = true
+            isRun = false
+            pause = true
+            return true
+        }
+
         @JvmStatic
         var timeMinute: Int = 0
             private set(value) {
@@ -187,6 +202,7 @@ abstract class BaseReadAloudService : BaseService(),
         ReadAloudPhoneStateListener()
     }
     internal var contentList = emptyList<String>()
+    private var contentChapterPositions = emptyList<Int?>()
     /** Canonical, character-aware plan for the current chapter. Playback adoption is incremental. */
     internal var speechPlan = emptyList<SpeechPlanItem>()
     internal var playbackQueue = ReadAloudPlaybackQueue.Empty
@@ -223,7 +239,10 @@ abstract class BaseReadAloudService : BaseService(),
 
     /** 当前朗读段在章节语义文本中的绝对起始位置;页内切段不引入换行符,进度必须以它为准 */
     protected fun paragraphChapterPositionAt(index: Int): Int? =
-        readerReadAloudChapter?.paragraphs(readAloudByPage)?.getOrNull(index)?.chapterPosition
+        contentChapterPositions.getOrNull(index)
+
+    protected fun isChapterTitleAt(index: Int): Boolean =
+        index in contentChapterPositions.indices && contentChapterPositions[index] == null
     protected open val useSpeechPlaybackQueue: Boolean = false
 
     /**
@@ -249,6 +268,7 @@ abstract class BaseReadAloudService : BaseService(),
     @SuppressLint("WakelockTimeout")
     override fun onCreate() {
         super.onCreate()
+        stopRequested = false
         isRun = true
         pause = false
         // 新朗读会话默认跟随当前显示页(用户手动翻页脱离后由阅读界面负责恢复)
@@ -348,11 +368,16 @@ abstract class BaseReadAloudService : BaseService(),
             IntentAction.next -> nextChapter()
             IntentAction.addTimer -> addTimer()
             IntentAction.setTimer -> setTimer(intent.getIntExtra("minute", 0))
-            IntentAction.stop -> stopSelf()
+            IntentAction.stop -> stopReadAloudService()
         }
         return super.onStartCommand(intent, flags, startId)
     }
 
+    private fun stopReadAloudService() {
+        requestStop()
+        stopSelf()
+    }
+
     private fun newReadAloud(
         play: Boolean,
         requestedPageIndex: Int,
@@ -387,22 +412,25 @@ abstract class BaseReadAloudService : BaseService(),
             val startPos = start.offsetInPage
             val preparedReadAloudByPage = ReadConfig.readAloudByPage
             var preparedReadAloudNumber = preparedChapter.pageStart(pageIndex) + startPos
-            var preparedContentList = preparedChapter.paragraphs(preparedReadAloudByPage)
+            val startsAtChapterBeginning = preparedReadAloudNumber == 0
+            val preparedParagraphs = preparedChapter.paragraphs(preparedReadAloudByPage)
+            var preparedContentList = preparedParagraphs
                 .map { it.text.replace(Regex("[袮祢꧁]"), " ") }
+            var preparedContentChapterPositions: List<Int?> =
+                preparedParagraphs.map { it.chapterPosition }
             val preparedSpeechPlan = buildSpeechPlan(
                 bookUrl = ReadBook.book?.bookUrl.orEmpty(),
                 chapterIndex = ReadBook.durChapterIndex,
                 paragraphs = preparedChapter.canonicalSpeechParagraphs(),
             )
             if (generation != prepareReadAloudGeneration) return@execute
-            val preparedPlaybackQueue = runCatching {
+            var preparedPlaybackQueue = runCatching {
                 ReadAloudPlaybackQueue.from(preparedSpeechPlan)
             }.onFailure {
                 AppLog.put("创建多角色播放队列失败,使用原朗读方式\n${it.localizedMessage}", it)
             }.getOrDefault(ReadAloudPlaybackQueue.Empty)
             var preparedPlaybackCursor = preparedPlaybackQueue.cursorAt(preparedReadAloudNumber)
             var pos = startPos
-            val preparedParagraphs = preparedChapter.paragraphs(preparedReadAloudByPage)
             val usePreparedPlaybackQueue = useSpeechPlaybackQueue && !preparedPlaybackQueue.isEmpty
             var preparedNowSpeak = preparedChapter.paragraphIndexAtOrAfter(
                 preparedReadAloudNumber + 1,
@@ -439,11 +467,33 @@ abstract class BaseReadAloudService : BaseService(),
                     preparedReadAloudNumber = preparedPlaybackQueue.cues[cursor.cueIndex].chapterStart
                 }
             }
+            val shouldReadChapterTitle = !moveToLast && startsAtChapterBeginning &&
+                    preparedChapter.title.isNotBlank()
+            if (shouldReadChapterTitle) {
+                if (usePreparedPlaybackQueue) {
+                    preparedPlaybackQueue =
+                        preparedPlaybackQueue.withChapterTitle(preparedChapter.title)
+                    preparedPlaybackCursor = ReadAloudPlaybackCursor(0, 0)
+                    preparedContentList = preparedPlaybackQueue.cues.map { it.text }
+                    preparedContentChapterPositions = preparedPlaybackQueue.cues.map { cue ->
+                        cue.chapterStart.takeUnless { cue.isChapterTitle }
+                    }
+                } else {
+                    preparedContentList = listOf(preparedChapter.title.trim()) + preparedContentList
+                    preparedContentChapterPositions = listOf(null) + preparedContentChapterPositions
+                }
+                preparedNowSpeak = 0
+                preparedParagraphStartPos = 0
+                preparedReadAloudNumber = 0
+            } else if (usePreparedPlaybackQueue) {
+                preparedContentChapterPositions = preparedPlaybackQueue.cues.map { it.chapterStart }
+            }
             if (generation != prepareReadAloudGeneration) return@execute
             this@BaseReadAloudService.pageIndex = pageIndex
             readerReadAloudChapter = preparedChapter
             readAloudByPage = preparedReadAloudByPage
             contentList = preparedContentList
+            contentChapterPositions = preparedContentChapterPositions
             speechPlan = preparedSpeechPlan
             playbackQueue = preparedPlaybackQueue
             playbackCursor = preparedPlaybackCursor
@@ -487,6 +537,7 @@ abstract class BaseReadAloudService : BaseService(),
 
     @SuppressLint("WakelockTimeout")
     open fun play() {
+        if (stopRequested) return
         if (useWakeLock) {
             wakeLock.acquire()
             wifiLock?.acquire()
@@ -656,7 +707,7 @@ abstract class BaseReadAloudService : BaseService(),
                 return
             }
             readerReadAloudChapter?.let {
-                paragraphChapterPositionAt(nowSpeak)?.let { position -> readAloudNumber = position }
+                readAloudNumber = paragraphChapterPositionAt(nowSpeak) ?: 0
                 if (readAloudNumber < it.pageStart(pageIndex)) {
                     pageIndex--
                     withSpeechNavigation { ReadBook.moveToPrevPage() }
@@ -687,7 +738,7 @@ abstract class BaseReadAloudService : BaseService(),
             paragraphStartPos = 0
             nowSpeak++
             readerReadAloudChapter?.let {
-                paragraphChapterPositionAt(nowSpeak)?.let { position -> readAloudNumber = position }
+                readAloudNumber = paragraphChapterPositionAt(nowSpeak) ?: 0
                 if (pageIndex + 1 < it.pageCount
                     && readAloudNumber >= it.pageStart(pageIndex + 1)
                 ) {
@@ -721,7 +772,11 @@ abstract class BaseReadAloudService : BaseService(),
                 pageIndex--
                 withSpeechNavigation { ReadBook.moveToPrevPage() }
             }
-            upTtsProgress(targetPosition + 1)
+            if (cue.isChapterTitle) {
+                updateReadAloudProgressSnapshot(0)
+            } else {
+                upTtsProgress(targetPosition + 1)
+            }
         }
         upMediaMetadata(showContent = true)
     }
@@ -733,7 +788,9 @@ abstract class BaseReadAloudService : BaseService(),
             chapterLength = playbackQueue.cues.lastOrNull()?.chapterEnd ?: cue.chapterEnd,
             text = cue.text,
             engineName = cue.voice?.displayName.orEmpty(),
-            characterName = speechPlan.getOrNull(cursor.cueIndex)?.segment?.characterName.orEmpty(),
+            characterName = speechPlan.getOrNull(
+                cursor.cueIndex - playbackQueue.leadingTitleCueCount
+            )?.segment?.characterName.orEmpty(),
             roleType = cue.roleType,
         ))
         refreshMediaSessionPlaybackState()
@@ -965,7 +1022,7 @@ abstract class BaseReadAloudService : BaseService(),
             }
 
             override fun onStop() {
-                stopSelf()
+                stopReadAloudService()
             }
 
             override fun onCustomAction(action: String, extras: Bundle?) {
@@ -1269,7 +1326,7 @@ abstract class BaseReadAloudService : BaseService(),
         } catch (e: Exception) {
             AppLog.put("创建朗读通知出错,${e.localizedMessage}", e, true)
             //创建通知出错不结束服务就会崩溃,服务必须绑定通知
-            stopSelf()
+            stopReadAloudService()
         }
     }
 
@@ -1289,7 +1346,7 @@ abstract class BaseReadAloudService : BaseService(),
         AppLog.putDebug("${readerReadAloudChapter?.title} 朗读结束跳转下一章并朗读")
         resumeReadAloudInternal()
         if (!withSpeechNavigation { ReadBook.moveToNextChapter(true) }) {
-            stopSelf()
+            stopReadAloudService()
         }
     }
 
@@ -1309,7 +1366,7 @@ abstract class BaseReadAloudService : BaseService(),
             when (decision.action) {
                 ChapterCompletionAction.STOP -> {
                     pause = true
-                    stopSelf()
+                    stopReadAloudService()
                 }
 
                 ChapterCompletionAction.ADVANCE -> {

+ 5 - 3
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -238,7 +238,7 @@ class HttpReadAloudService : BaseReadAloudService(),
         }
         readAloudNumber += contentList[nowSpeak].length + 1 - paragraphStartPos
         // 页内切段不引入换行符,累加会漂移,用段落绝对位置重算
-        paragraphChapterPositionAt(nowSpeak + 1)?.let { readAloudNumber = it }
+        readAloudNumber = paragraphChapterPositionAt(nowSpeak + 1) ?: 0
         paragraphStartPos = 0
         if (nowSpeak < contentList.lastIndex) {
             nowSpeak++
@@ -416,12 +416,13 @@ class HttpReadAloudService : BaseReadAloudService(),
             chapterIndex = chapter.index,
             paragraphs = readAloudChapter.canonicalSpeechParagraphs(),
         )
-        val queue = runCatching { ReadAloudPlaybackQueue.from(plan) }
+        val queue = runCatching { ReadAloudPlaybackQueue.from(plan).withChapterTitle(displayTitle) }
             .getOrDefault(ReadAloudPlaybackQueue.Empty)
         val contentList = if (!queue.isEmpty) {
             queue.cues.map { it.text }
         } else {
-            readAloudChapter.paragraphs(readAloudSettings.readAloudByPage)
+            listOf(displayTitle.trim()).filter { it.isNotEmpty() } +
+                    readAloudChapter.paragraphs(readAloudSettings.readAloudByPage)
                 .map { it.text.replace(Regex("[袮祢꧁]"), " ") }
         }
         return PreDownloadChapter(displayTitle, queue, contentList)
@@ -1082,6 +1083,7 @@ class HttpReadAloudService : BaseReadAloudService(),
         playIndexJob?.cancel()
         if (readerReadAloudChapter == null) return
         playIndexJob = lifecycleScope.launch {
+            if (isChapterTitleAt(nowSpeak)) return@launch
             if (exoPlayer.duration <= 0) {
                 upTtsProgress(readAloudNumber + 1)
                 return@launch

+ 6 - 1
app/src/main/java/io/legado/app/service/TTSReadAloudService.kt

@@ -359,6 +359,10 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
             LogUtils.d(TAG, "onStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$s")
             utteranceStartPos = paragraphStartPos
             utteranceStartReadAloudNumber = readAloudNumber
+            if (isChapterTitleAt(nowSpeak)) {
+                upMediaMetadata(showContent = true)
+                return
+            }
             readerReadAloudChapter?.let {
                 if (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex)) {
                     nextParagraph(naturalCompletion = true)
@@ -395,6 +399,7 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
             super.onRangeStart(utteranceId, start, end, frame)
             if (!isCurrentUtterance(utteranceId)) return
             paragraphStartPos = utteranceStartPos + start
+            if (isChapterTitleAt(nowSpeak)) return
             // 正在朗读的精确章内位置(段起点 + 段内偏移),保持 readAloudNumber 的"段起点"语义不被污染
             val position = currentRangePosition(utteranceStartReadAloudNumber, start)
             updateReadAloudProgressSnapshot(position)
@@ -449,7 +454,7 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
                 }
             } while (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex))
             // 页内切段不引入换行符,累加会漂移,用段落绝对位置重算
-            paragraphChapterPositionAt(nowSpeak)?.let { readAloudNumber = it }
+            readAloudNumber = paragraphChapterPositionAt(nowSpeak) ?: 0
             return true
         }
 

+ 29 - 19
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -349,6 +349,9 @@ class ReadBookController(
     private var directReaderAdjacentLayoutJob: Job? = null
     private var directReaderLayoutKey: String? = null
 
+    /** 排版环境不含当前章身份;用它区分普通换章和主题/规则引起的整窗重排。 */
+    private var directReaderPaginationEnvironmentKey: String? = null
+
     /** Only chapter-window changes may reuse adjacent pages; a reflow invalidates their geometry. */
     private var directReaderMayReuseAdjacentPages = false
     private var directReaderPages = emptyList<io.legado.app.feature.reader.core.model.ReaderPage>()
@@ -682,6 +685,7 @@ class ReadBookController(
         directReaderAdjacentLayoutJob?.cancel()
         directReaderAdjacentLayoutJob = null
         directReaderLayoutKey = null
+        directReaderPaginationEnvironmentKey = null
         directReaderMayReuseAdjacentPages = false
         ReadBook.clearReaderPagination()
         updateReaderPaginationError(null)
@@ -923,25 +927,22 @@ class ReadBookController(
                 contentPaddingBottomPx = contentPadding.bottom,
             )
         }
-        val key = buildString {
-            // The first readable page depends only on the current chapter. Including adjacent
-            // inputs here made their asynchronous arrival cancel and restart current-chapter
-            // measurement during opening. They remain in `chapters` for background prelayout.
-            listOf(chapter).forEach { candidate ->
-                append(LegacyReaderChapterLayoutIdentity(
-                    chapterIndex = candidate.chapter.index,
-                    chapterUrl = candidate.chapter.url,
-                    chapterBaseUrl = candidate.chapter.baseUrl,
-                    displayTitle = candidate.displayTitle,
-                    isVolume = candidate.chapter.isVolume,
-                    contentHash = candidate.contentHash,
-                    contentProcessesHash = candidate.contentProcessesHash,
-                    sourceHash = candidate.sourceHash,
-                    bookUrl = candidate.book.bookUrl,
-                    bookOrigin = candidate.book.origin,
-                    bookSourceHash = candidate.bookSourceHash,
-                )).append(',')
-            }
+        // 首屏只依赖当前章;相邻章异步到达不应重启当前章测量。环境身份则单独保存:
+        // 普通换章可复用相邻页,主题/高亮规则/排版参数变化必须废弃整窗旧页。
+        val chapterLayoutIdentity = LegacyReaderChapterLayoutIdentity(
+            chapterIndex = chapter.chapter.index,
+            chapterUrl = chapter.chapter.url,
+            chapterBaseUrl = chapter.chapter.baseUrl,
+            displayTitle = chapter.displayTitle,
+            isVolume = chapter.chapter.isVolume,
+            contentHash = chapter.contentHash,
+            contentProcessesHash = chapter.contentProcessesHash,
+            sourceHash = chapter.sourceHash,
+            bookUrl = chapter.book.bookUrl,
+            bookOrigin = chapter.book.origin,
+            bookSourceHash = chapter.bookSourceHash,
+        )
+        val paginationEnvironmentKey = buildString {
             append('|').append(width).append('x').append(height)
             append('|').append(contentPadding.left).append(',').append(contentPadding.top)
             append(',').append(contentPadding.right).append(',').append(contentPadding.bottom)
@@ -974,6 +975,7 @@ class ReadBookController(
             append('|').append(resolvedPaginationStyle.paragraphSpacing)
             append('|').append(ReadBookConfig.durConfig.highlightRules.hashCode())
         }
+        val key = "$chapterLayoutIdentity,$paginationEnvironmentKey"
         if (directReaderLayoutKey == key && directReaderPages.isNotEmpty()) {
             // upContent 语义是"按 durChapterPos 重新定位"(对照旧 View upContent 重绘):
             // 朗读跨页走 moveToNextPage → upContent,只有重定位页面才会前进;缓存下标
@@ -1005,11 +1007,19 @@ class ReadBookController(
                 )
             }
         if (directReaderLayoutKey != key) {
+            val environmentChanged = directReaderPaginationEnvironmentKey != null &&
+                    directReaderPaginationEnvironmentKey != paginationEnvironmentKey
+            if (environmentChanged) {
+                // 当前章先提交、相邻章后台预排的两阶段策略只适用于同一排版环境。
+                // 主题/高亮规则变化后继续保留相邻旧页,会在翻到该章时先显示旧几何再跳变。
+                directReaderMayReuseAdjacentPages = false
+            }
             val paginationGeneration = ReadBook.readerPaginationGeneration
             directReaderLayoutJob?.cancel()
             directReaderAdjacentLayoutJob?.cancel()
             directReaderAdjacentLayoutJob = null
             directReaderLayoutKey = key
+            directReaderPaginationEnvironmentKey = paginationEnvironmentKey
             updateReaderPaginationError(null)
             ReadBook.clearReaderPagination()
             directReaderLayoutJob = activity.lifecycleScope.launch(IO) {

+ 15 - 4
app/src/main/java/io/legado/app/ui/book/read/TextActionSelectionMenu.kt

@@ -590,19 +590,30 @@ private class TextMenuPositionProvider(
         val marginHorizontal = (16 * density).toInt()
         val marginVertical = (12 * density).toInt()
         val textMargin = (4 * density).toInt()
+        // ReaderCanvasSurface 的选择柄圆心挂在行底下 7dp,直径再占 14dp。
+        val cursorHandleClearance = (14 * density).toInt()
 
         val cardHeight = popupContentSize.height - shadowPadding * 2
         val isSpaceEnoughAtTop = startTopY > cardHeight + textMargin + marginVertical
+        // 仅阅读区域最顶部 10% 的选区优先在下方展开;其余位置遵循原本“有上方空间
+        // 就放上方”的策略,避免菜单在普通位置不必要地遮挡后文。
+        val preferBelowForTopSelection = startTopY < windowSize.height / 10
+        val isSpaceEnoughBelowSelection = windowSize.height - endBottomY >
+                cardHeight + textMargin + cursorHandleClearance + marginVertical
 
-        if (isSpaceEnoughAtTop) {
+        if (!preferBelowForTopSelection && isSpaceEnoughAtTop) {
             x = startX - shadowPadding
             y = startTopY - popupContentSize.height + shadowPadding - textMargin
-        } else if (windowSize.height - startBottomY > cardHeight + textMargin + marginVertical) {
+        } else if (isSpaceEnoughBelowSelection) {
             x = startX - shadowPadding
-            y = startBottomY + textMargin - shadowPadding
+            // 顶部选区没有空间放菜单时,必须避开整个选区及末端把手,不能只从首行下方弹出。
+            y = endBottomY + cursorHandleClearance + textMargin - shadowPadding
+        } else if (isSpaceEnoughAtTop) {
+            x = startX - shadowPadding
+            y = startTopY - popupContentSize.height + shadowPadding - textMargin
         } else {
             x = endX - shadowPadding
-            y = endBottomY + textMargin - shadowPadding
+            y = endBottomY + cursorHandleClearance + textMargin - shadowPadding
         }
 
         val minX = marginHorizontal - shadowPadding

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

@@ -64,7 +64,6 @@ import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.shadow
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.RectangleShape
@@ -491,9 +490,6 @@ private fun CoverPage(
                 modifier = Modifier
                     .fillMaxWidth(0.52f)
                     .aspectRatio(5f / 7f)
-                    .shadow(
-                        elevation = 16.dp
-                    )
                     .clip(RoundedCornerShape(8.dp))
             )
         }

+ 19 - 0
app/src/test/java/io/legado/app/domain/model/readaloud/ReadAloudPlaybackQueueTest.kt

@@ -35,6 +35,25 @@ class ReadAloudPlaybackQueueTest {
         assertNull(queue.next(ReadAloudPlaybackCursor(2, 0)))
     }
 
+    @Test
+    fun `chapter title is first playback cue but excluded from position lookup`() {
+        val titledQueue = queue.withChapterTitle(" 第 1 章 ")
+
+        assertEquals("第 1 章", titledQueue.cues.first().text)
+        assertEquals(true, titledQueue.cues.first().isChapterTitle)
+        assertEquals(1, titledQueue.leadingTitleCueCount)
+        assertEquals(ReadAloudPlaybackCursor(1, 0), titledQueue.cursorAt(10))
+        assertEquals(
+            ReadAloudPlaybackCursor(1, 0),
+            titledQueue.next(ReadAloudPlaybackCursor(0, 0)),
+        )
+    }
+
+    @Test
+    fun `empty playback queue does not become title-only queue`() {
+        assertEquals(true, ReadAloudPlaybackQueue.Empty.withChapterTitle("标题").isEmpty)
+    }
+
     private fun item(
         text: String,
         start: Int,

+ 15 - 0
app/src/test/java/io/legado/app/feature/reader/core/selection/ReaderSelectionViewportLayoutTest.kt

@@ -46,6 +46,21 @@ class ReaderSelectionViewportLayoutTest {
         )
     }
 
+    @Test
+    fun scrollLayoutExposesNextPlusAtTheCumulativeStackOffset() {
+        val current = page(0, 0, 0)
+        val next = page(0, 1, 1)
+        val nextPlus = page(0, 2, 2)
+        val layout = ReaderPageViewportLayout.scroll(
+            ReaderPageWindow(current = current, next = next, nextPlus = nextPlus),
+            scrollOffsetPx = -20f,
+        )
+
+        val placement = layout.pageAt(5f, 190f)!!
+        assertEquals(nextPlus.id, placement.page.id)
+        assertEquals(10f, placement.localY(190f))
+    }
+
     @Test fun adjacentPageElementHitUsesItsLocalCoordinates() {
         val current = page(0, 0, 0)
         val next = page(0, 1, 1)

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

@@ -1,13 +1,11 @@
 package io.legado.app.feature.reader.core.transition
 
-import io.legado.app.feature.reader.core.model.ReaderPageId
 import io.legado.app.feature.reader.core.model.ReaderElement
 import io.legado.app.feature.reader.core.model.ReaderPage
+import io.legado.app.feature.reader.core.model.ReaderPageId
 import io.legado.app.feature.reader.core.model.ReaderRect
 import io.legado.app.feature.reader.core.model.ReaderTextStyle
-
 import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
 import org.junit.Test
 
@@ -137,6 +135,24 @@ class ReaderScrollStateTest {
         assertEquals(50f, withoutPrevious, 0f)
     }
 
+    @Test
+    fun tapStepUsesRowsAlreadyVisibleInNextPlusPage() {
+        val current = scrollPage(listOf(text(0f, 20f, 0)), extent = 20f)
+        val next = scrollPage(listOf(text(0f, 20f, 1)), extent = 20f)
+        val nextPlus = scrollPage(listOf(text(10f, 30f, 2)), extent = 80f)
+
+        val distance = ReaderScrollPolicy.pageStep(
+            page = current,
+            offsetPx = 0f,
+            direction = ReaderTurnDirection.NEXT,
+            next = next,
+            nextPlus = nextPlus,
+        )
+
+        // nextPlus 的文字在栈 y=60..80,点击下一页保留其首行,移动 50px。
+        assertEquals(-50f, distance, 0f)
+    }
+
     private fun scrollPage(
         elements: List<ReaderElement>,
         inlineImages: Boolean = true,