HapeLee 1 месяц назад
Родитель
Сommit
805cc84085
18 измененных файлов с 1542 добавлено и 171 удалено
  1. 119 0
      app/src/main/java/io/legado/app/core/ui/morph/BookCoverMorphAnchors.kt
  2. 387 0
      app/src/main/java/io/legado/app/core/ui/morph/BookMorphHost.kt
  3. 300 0
      app/src/main/java/io/legado/app/core/ui/morph/BookMorphState.kt
  4. 3 1
      app/src/main/java/io/legado/app/core/ui/player/PlayerMorphAnchors.kt
  5. 50 44
      app/src/main/java/io/legado/app/core/ui/player/PlayerMorphHost.kt
  6. 69 20
      app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt
  7. 5 2
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  8. 69 36
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderRouteScreen.kt
  9. 5 1
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt
  10. 41 21
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  11. 12 8
      app/src/main/java/io/legado/app/ui/book/readaloud/morph/PlayerMorphFrames.kt
  12. 18 1
      app/src/main/java/io/legado/app/ui/book/readaloud/morph/ReadAloudMorphState.kt
  13. 39 29
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  14. 19 2
      app/src/main/java/io/legado/app/ui/widget/components/image/cover/BookshelfCover.kt
  15. 29 0
      app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt
  16. 5 0
      app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateLockedContent.kt
  17. 365 0
      app/src/test/java/io/legado/app/core/ui/morph/BookMorphTest.kt
  18. 7 6
      app/src/test/java/io/legado/app/ui/book/readaloud/morph/PlayerMorphFramesTest.kt

+ 119 - 0
app/src/main/java/io/legado/app/core/ui/morph/BookCoverMorphAnchors.kt

@@ -0,0 +1,119 @@
+package io.legado.app.core.ui.morph
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.geometry.Rect
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * 记录图书封面在全局坐标中的锚点信息,用于无缝 Morph 展开到阅读器、漫画或有声书界面。
+ */
+@Immutable
+data class BookCoverMorphAnchor(
+    val bounds: Rect,
+    val cornerRadiusPx: Float,
+    val bookName: String?,
+    val author: String?,
+    val coverPath: String?,
+    val sourceOrigin: String? = null,
+    val bookUrl: String? = null,
+    val badgeText: String? = null,
+    val showBadgeDot: Boolean = false,
+    val leftBottomText: String? = null,
+    val timestamp: Long = System.currentTimeMillis(),
+)
+
+object BookCoverMorphAnchors {
+    private val anchors = ConcurrentHashMap<String, BookCoverMorphAnchor>()
+
+    var activeMorphKey by mutableStateOf<String?>(null)
+    var activeMorphState by mutableStateOf<BookMorphState?>(null)
+
+    fun setActiveMorph(key: String?, state: BookMorphState?) {
+        activeMorphKey = key
+        activeMorphState = state
+    }
+
+    fun clearActiveMorph(key: String?) {
+        if (key == null || isMorphing(key)) {
+            activeMorphKey = null
+            activeMorphState = null
+        }
+    }
+
+    fun clearActiveMorphKey(key: String?) {
+        clearActiveMorph(key)
+    }
+
+    fun isMorphing(key: String?): Boolean {
+        val active = activeMorphKey ?: return false
+        if (key.isNullOrBlank()) return false
+        if (key == active) return true
+        val cleanActive = active.removePrefix("book-cover:")
+        val cleanKey = key.removePrefix("book-cover:")
+        return cleanActive == cleanKey ||
+                cleanActive.endsWith(":$cleanKey") ||
+                cleanKey.endsWith(":$cleanActive")
+    }
+
+    /**
+     * 判断指定书源/书架封面当前是否应当隐藏(让位给飞行封面)。
+     *
+     * 参考播放悬浮胶囊方案:在 graphicsLayer 内动态读取动画进度 progress.value。
+     * - 动画进行中 (progress > 0.001f):源封面隐藏 (alpha = 0f),避免双封面叠影;
+     * - 动画归零收回 (progress <= 0.001f):源封面在同一绘制帧瞬间显现 (alpha = 1f),
+     *   与飞行封面隐去实现零延迟接力,彻底消除等待 onDispose/popBackStack 导致的空白跳闪。
+     */
+    fun isOriginCoverHidden(key: String?): Boolean {
+        if (!isMorphing(key)) return false
+        val state = activeMorphState ?: return true
+        return state.progress.value > 0.001f
+    }
+
+    fun report(
+        key: String?,
+        bounds: Rect,
+        cornerRadiusPx: Float,
+        bookName: String? = null,
+        author: String? = null,
+        coverPath: String? = null,
+        sourceOrigin: String? = null,
+        bookUrl: String? = null,
+        badgeText: String? = null,
+        showBadgeDot: Boolean = false,
+        leftBottomText: String? = null,
+    ) {
+        if (key.isNullOrBlank() || bounds.isEmpty) return
+        anchors[key] = BookCoverMorphAnchor(
+            bounds = bounds,
+            cornerRadiusPx = cornerRadiusPx,
+            bookName = bookName,
+            author = author,
+            coverPath = coverPath,
+            sourceOrigin = sourceOrigin,
+            bookUrl = bookUrl,
+            badgeText = badgeText,
+            showBadgeDot = showBadgeDot,
+            leftBottomText = leftBottomText,
+        )
+        // 限制最大缓存容量,保留最近活跃的封面锚点
+        if (anchors.size > 80) {
+            val oldest = anchors.entries.minByOrNull { it.value.timestamp }?.key
+            if (oldest != null) anchors.remove(oldest)
+        }
+    }
+
+    fun get(key: String?): BookCoverMorphAnchor? {
+        if (key.isNullOrBlank()) return null
+        val direct = anchors[key]
+        if (direct != null) return direct
+        // 兜底策略:若键名包含 bookUrl,允许部分匹配
+        return anchors.entries.firstOrNull { it.key.contains(key) || key.contains(it.key) }?.value
+    }
+
+    fun remove(key: String?) {
+        if (key != null) anchors.remove(key)
+    }
+}

+ 387 - 0
app/src/main/java/io/legado/app/core/ui/morph/BookMorphHost.kt

@@ -0,0 +1,387 @@
+package io.legado.app.core.ui.morph
+
+import android.os.Build
+import android.view.RoundedCorner
+import androidx.activity.compose.PredictiveBackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Update
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.RoundRect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.book.readaloud.morph.PlayerPanelCornerRadii
+import io.legado.app.ui.book.readaloud.morph.computeMorphPanelAlpha
+import io.legado.app.ui.book.readaloud.morph.computePredictiveMorphProgress
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.image.cover.BookCoverImage
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlin.math.roundToInt
+
+val LocalBookMorph = compositionLocalOf<BookMorphState?> { null }
+
+/**
+ * 详情页等含有目标封面的页面:上报页面封面终点,并在过渡期间让位给宿主唯一的飞行封面。
+ */
+@Composable
+fun Modifier.trackBookMorphCover(
+    radius: Dp = 4.dp,
+): Modifier {
+    val morph = LocalBookMorph.current ?: return this
+    val density = LocalDensity.current
+    val radiusPx = with(density) { radius.toPx() }
+    return this
+        .onGloballyPositioned { coordinates ->
+            morph.reportCoverEnd(
+                bounds = coordinates.boundsInRoot(),
+                cornerRadiusPx = radiusPx,
+            )
+        }
+        .graphicsLayer {
+            alpha = if (morph.expanded || morph.fadeOnlyOpening) 1f else 0f
+        }
+}
+
+/**
+ * 通用的阅读器/漫画形变宿主:从封面平滑展开到全屏,且不包含下拉收起手势。
+ */
+@Composable
+fun BookMorphHost(
+    anchorKey: String?,
+    backgroundColor: Color,
+    modifier: Modifier = Modifier,
+    backEnabled: Boolean = true,
+    predictiveBackEnabled: Boolean = true,
+    hasTargetCover: Boolean = false,
+    onDismiss: () -> Unit,
+    content: @Composable (onCollapse: () -> Unit) -> Unit,
+) {
+    val view = LocalView.current
+    val scope = rememberCoroutineScope()
+    val morph = rememberBookMorphState(anchorKey, hasTargetCover)
+    val currentDismiss by rememberUpdatedState(onDismiss)
+    val currentBackEnabled by rememberUpdatedState(backEnabled)
+    var backSettleJob by remember { mutableStateOf<Job?>(null) }
+
+    DisposableEffect(anchorKey, morph) {
+        if (!anchorKey.isNullOrBlank()) {
+            BookCoverMorphAnchors.setActiveMorph(anchorKey, morph)
+        }
+        onDispose {
+            BookCoverMorphAnchors.clearActiveMorph(anchorKey)
+        }
+    }
+
+    val refreshAnchor = {
+        val anchor = BookCoverMorphAnchors.get(anchorKey)
+        if (anchor != null && !anchor.bounds.isEmpty) {
+            morph.updateAnchor(anchor)
+        }
+    }
+
+    val collapse: () -> Unit = {
+        scope.launch {
+            if (!anchorKey.isNullOrBlank()) {
+                BookCoverMorphAnchors.setActiveMorph(anchorKey, morph)
+            }
+            refreshAnchor()
+            morph.animateTo(0f)
+            currentDismiss()
+        }
+    }
+
+    LaunchedEffect(anchorKey) {
+        val anchor = BookCoverMorphAnchors.get(anchorKey)
+        morph.setAnchor(anchor, anchorKey)
+        if (!anchorKey.isNullOrBlank()) {
+            BookCoverMorphAnchors.setActiveMorph(anchorKey, morph)
+        }
+        morph.animateTo(1f)
+    }
+
+    // 拦截返回手势,支持预测性返回跟随手指缩回原封面
+    PredictiveBackHandler(enabled = backEnabled) { events ->
+        if (!currentBackEnabled) {
+            events.collect { }
+            return@PredictiveBackHandler
+        }
+        backSettleJob?.cancel()
+        if (!anchorKey.isNullOrBlank()) {
+            BookCoverMorphAnchors.setActiveMorph(anchorKey, morph)
+        }
+        refreshAnchor()
+        morph.onPredictiveBackStart()
+        val startProgress = morph.progress.value
+        var lastProgress = startProgress
+        var lastTimeNanos = 0L
+        var releaseVelocity = 0f
+        try {
+            if (predictiveBackEnabled) {
+                morph.progress.stop()
+            }
+            events.collect { event ->
+                if (!predictiveBackEnabled) return@collect
+                val preview = computePredictiveMorphProgress(startProgress, event.progress)
+                val now = System.nanoTime()
+                if (lastTimeNanos != 0L && now > lastTimeNanos) {
+                    releaseVelocity = (preview - lastProgress) /
+                            ((now - lastTimeNanos) / 1_000_000_000f)
+                }
+                lastProgress = preview
+                lastTimeNanos = now
+                morph.progress.snapTo(preview)
+            }
+            if (!predictiveBackEnabled) morph.progress.stop()
+            backSettleJob = scope.launch {
+                morph.animateTo(0f, initialVelocity = releaseVelocity)
+                currentDismiss()
+            }
+        } catch (cancelled: CancellationException) {
+            morph.onPredictiveBackCancel()
+            if (predictiveBackEnabled) {
+                backSettleJob = scope.launch { morph.animateTo(1f) }
+            }
+            throw cancelled
+        }
+    }
+
+    CompositionLocalProvider(LocalBookMorph provides morph) {
+        Box(
+            modifier = modifier
+                .fillMaxSize()
+                .onGloballyPositioned { coordinates ->
+                    morph.reportScreenBounds(coordinates.boundsInRoot())
+                    if (Build.VERSION.SDK_INT >= 31) {
+                        val insets = view.rootWindowInsets
+                        fun radius(position: Int): Float =
+                            insets?.getRoundedCorner(position)?.radius?.toFloat() ?: 0f
+
+                        val r = maxOf(
+                            radius(RoundedCorner.POSITION_TOP_LEFT),
+                            radius(RoundedCorner.POSITION_TOP_RIGHT),
+                            radius(RoundedCorner.POSITION_BOTTOM_RIGHT),
+                            radius(RoundedCorner.POSITION_BOTTOM_LEFT),
+                        )
+                        morph.reportScreenCorners(PlayerPanelCornerRadii.all(r))
+                    } else {
+                        morph.reportScreenCorners(PlayerPanelCornerRadii.Zero)
+                    }
+                }
+        ) {
+            // 1. 动态裁剪背景层
+            Box(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .graphicsLayer {
+                        val screen = morph.screenBounds
+                        val frame = morph.panelFrame()
+                        alpha =
+                            if (frame == null) morph.veil else computeMorphPanelAlpha(morph.progress.value)
+                        clip = !morph.expanded && frame != null && !screen.isEmpty
+                        if (!clip || frame == null) return@graphicsLayer
+                        shape = MorphPanelClipShape(
+                            frame.bounds.translate(-screen.left, -screen.top),
+                            frame.cornerRadii,
+                        )
+                        shadowElevation = 0f
+                    }
+                    .background(backgroundColor)
+            )
+
+            // 2. 内容层:无几何缩放,只吃透明度与轮廓裁剪,未完全展开前清空语义和屏蔽点击
+            val expanded by remember { derivedStateOf { morph.expanded } }
+            val contentSemantics = if (expanded) Modifier else Modifier.clearAndSetSemantics {}
+            val inputGate = if (expanded || morph.progress.value <= 0.001f) {
+                Modifier
+            } else {
+                Modifier.pointerInput(Unit) {
+                    awaitPointerEventScope {
+                        while (true) {
+                            awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
+                        }
+                    }
+                }
+            }
+
+            Box(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .then(contentSemantics)
+                    .then(inputGate)
+                    .graphicsLayer {
+                        clip = !morph.expanded
+                        val frame = if (clip) morph.panelFrame() else null
+                        if (frame != null) {
+                            shape = MorphPanelClipShape(
+                                frame.bounds.translate(
+                                    -morph.screenBounds.left,
+                                    -morph.screenBounds.top
+                                ),
+                                frame.cornerRadii,
+                            )
+                        } else {
+                            clip = false
+                        }
+                        alpha = morph.veil
+                    }
+            ) {
+                content(collapse)
+            }
+
+            // 3. 飞行封面:
+            // 若页面有封面终点(如详情页),封面从起点连续飞向页面封面位置,全程保持连续;
+            // 若页面无封面终点(如小说/漫画阅读),封面随卡片展开并在前期(0.10f..0.25f)平滑淡出。
+            val coverAlpha by remember { derivedStateOf { morph.coverAlpha } }
+            val flyingCoverVisible by remember {
+                derivedStateOf { !morph.expanded && !morph.fadeOnlyOpening && coverAlpha > 0.001f }
+            }
+            if (flyingCoverVisible) {
+                val density = LocalDensity.current
+                val bounds: Rect?
+                val cornerRadiusPx: Float
+                if (morph.hasTargetCover) {
+                    if (morph.hasCoverEnd) {
+                        val frame = morph.coverFrame()
+                        bounds = frame?.bounds
+                        cornerRadiusPx = frame?.cornerRadiusPx ?: 0f
+                    } else {
+                        // 目标页面封面终点尚在测量(第 0 帧),在终点就位前稳妥锚定在起点,绝不跳变到全屏 panel
+                        val (startBounds, startRadius) = morph.targetStart()
+                        bounds = startBounds
+                        cornerRadiusPx = startRadius
+                    }
+                } else {
+                    val frame = morph.panelFrame()
+                    bounds = frame?.bounds
+                    cornerRadiusPx = frame?.cornerRadiusPx ?: 0f
+                }
+                if (bounds != null && !bounds.isEmpty) {
+                    val screen = morph.screenBounds
+                    val localBounds = bounds.translate(-screen.left, -screen.top)
+                    Box(
+                        modifier = Modifier
+                            .offset {
+                                IntOffset(
+                                    localBounds.left.roundToInt(),
+                                    localBounds.top.roundToInt()
+                                )
+                            }
+                            .size(
+                                with(density) { localBounds.width.toDp() },
+                                with(density) { localBounds.height.toDp() }
+                            )
+                            .graphicsLayer {
+                                alpha = coverAlpha
+                                clip = true
+                                shape =
+                                    RoundedCornerShape(with(density) { cornerRadiusPx.toDp() })
+                            }
+                    ) {
+                        BookCoverImage(
+                            name = morph.bookName,
+                            author = morph.author,
+                            path = morph.coverPath,
+                            sourceOrigin = morph.sourceOrigin,
+                            bookUrl = morph.bookUrl,
+                            preferCache = true,
+                            showLoadingPlaceholder = false,
+                            sharedCoverKey = morph.anchorKey,
+                            modifier = Modifier.fillMaxSize(),
+                        )
+
+                        val badgeAlpha = morph.badgeAlpha
+                        val currentBadgeText = morph.badgeText
+                        val currentLeftBottomText = morph.leftBottomText
+                        if (badgeAlpha > 0.001f && !currentBadgeText.isNullOrEmpty()) {
+                            TextCard(
+                                text = currentBadgeText,
+                                icon = if (morph.showBadgeDot) Icons.Default.Update else null,
+                                iconSize = 12.dp,
+                                modifier = Modifier
+                                    .align(Alignment.TopEnd)
+                                    .padding(2.dp)
+                                    .graphicsLayer { alpha = badgeAlpha },
+                                cornerRadius = 4.dp,
+                                horizontalPadding = 4.dp,
+                                verticalPadding = 2.dp,
+                            )
+                        }
+
+                        if (badgeAlpha > 0.001f && !currentLeftBottomText.isNullOrEmpty()) {
+                            TextCard(
+                                text = currentLeftBottomText,
+                                backgroundColor = LegadoTheme.colorScheme.cardContainer,
+                                contentColor = LegadoTheme.colorScheme.onCardContainer,
+                                modifier = Modifier
+                                    .align(Alignment.BottomStart)
+                                    .padding(2.dp)
+                                    .graphicsLayer { alpha = badgeAlpha },
+                                cornerRadius = 4.dp,
+                                horizontalPadding = 4.dp,
+                                verticalPadding = 2.dp,
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+private data class MorphPanelClipShape(
+    private val bounds: Rect,
+    private val radii: PlayerPanelCornerRadii,
+) : Shape {
+    override fun createOutline(
+        size: Size,
+        layoutDirection: LayoutDirection,
+        density: Density,
+    ): Outline = Outline.Rounded(
+        RoundRect(
+            rect = bounds,
+            topLeft = CornerRadius(radii.topLeft, radii.topLeft),
+            topRight = CornerRadius(radii.topRight, radii.topRight),
+            bottomRight = CornerRadius(radii.bottomRight, radii.bottomRight),
+            bottomLeft = CornerRadius(radii.bottomLeft, radii.bottomLeft),
+        ),
+    )
+}

+ 300 - 0
app/src/main/java/io/legado/app/core/ui/morph/BookMorphState.kt

@@ -0,0 +1,300 @@
+package io.legado.app.core.ui.morph
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.spring
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import io.legado.app.ui.book.readaloud.morph.PlayerCoverMorphFrame
+import io.legado.app.ui.book.readaloud.morph.PlayerPanelCornerRadii
+import io.legado.app.ui.book.readaloud.morph.PlayerPanelMorphFrame
+import io.legado.app.ui.book.readaloud.morph.computeCoverMorphFrame
+import io.legado.app.ui.book.readaloud.morph.computeMorphVeil
+import io.legado.app.ui.book.readaloud.morph.computePanelMorphFrame
+
+/**
+ * 驱动阅读器、漫画界面的无手势形变状态。
+ */
+@Stable
+class BookMorphState(
+    val progress: Animatable<Float, AnimationVector1D>,
+    private val density: Density,
+    val hasTargetCover: Boolean = false,
+) {
+    var screenBounds by mutableStateOf(Rect.Zero)
+        private set
+
+    var screenCornerRadii by mutableStateOf(PlayerPanelCornerRadii.Zero)
+        private set
+
+    fun reportScreenCorners(radii: PlayerPanelCornerRadii) {
+        screenCornerRadii = radii
+    }
+
+    fun reportScreenBounds(bounds: Rect) {
+        screenBounds = bounds
+    }
+
+    var startBounds by mutableStateOf(Rect.Zero)
+        private set
+    var startCornerRadiusPx by mutableFloatStateOf(0f)
+        private set
+
+    var bookName by mutableStateOf<String?>(null)
+        private set
+    var author by mutableStateOf<String?>(null)
+        private set
+    var coverPath by mutableStateOf<String?>(null)
+        private set
+    var sourceOrigin by mutableStateOf<String?>(null)
+        private set
+    var bookUrl by mutableStateOf<String?>(null)
+        private set
+
+    var badgeText by mutableStateOf<String?>(null)
+        private set
+    var showBadgeDot by mutableStateOf(false)
+        private set
+    var leftBottomText by mutableStateOf<String?>(null)
+        private set
+
+    var fadeOnlyOpening by mutableStateOf(false)
+        private set
+
+    var isCollapsing by mutableStateOf(false)
+        private set
+
+    var coverEndBounds by mutableStateOf(Rect.Zero)
+        private set
+    var coverEndCornerRadiusPx by mutableFloatStateOf(0f)
+        private set
+
+    val hasCoverEnd: Boolean get() = !coverEndBounds.isEmpty
+
+    fun reportCoverEnd(bounds: Rect, cornerRadiusPx: Float) {
+        if (bounds.isEmpty) return
+        if (coverEndBounds != bounds) coverEndBounds = bounds
+        coverEndCornerRadiusPx = cornerRadiusPx
+    }
+
+    val expanded: Boolean get() = progress.value >= 1f
+
+    val veil: Float get() = if (fadeOnlyOpening) progress.value else computeMorphVeil(progress.value)
+
+    val coverAlpha: Float
+        get() = if (hasTargetCover || hasCoverEnd) {
+            if (progress.value <= 0.001f || progress.value >= 1f) 0f else 1f
+        } else {
+            computeBookCoverAlpha(progress.value, isCollapsing)
+        }
+
+    val badgeAlpha: Float
+        get() = computeBookBadgeAlpha(progress.value, isCollapsing)
+
+    fun onPredictiveBackStart() {
+        isCollapsing = true
+    }
+
+    fun onPredictiveBackCancel() {
+        isCollapsing = false
+    }
+
+    var anchorKey by mutableStateOf<String?>(null)
+        private set
+
+    fun setAnchor(anchor: BookCoverMorphAnchor?, key: String? = null) {
+        anchorKey = key
+        isCollapsing = false
+        coverEndBounds = Rect.Zero
+        coverEndCornerRadiusPx = 0f
+        if (anchor != null && !anchor.bounds.isEmpty) {
+            startBounds = anchor.bounds
+            startCornerRadiusPx = anchor.cornerRadiusPx
+            bookName = anchor.bookName
+            author = anchor.author
+            coverPath = anchor.coverPath
+            sourceOrigin = anchor.sourceOrigin
+            bookUrl = anchor.bookUrl
+            badgeText = anchor.badgeText
+            showBadgeDot = anchor.showBadgeDot
+            leftBottomText = anchor.leftBottomText
+            fadeOnlyOpening = false
+        } else {
+            bookUrl = null
+            badgeText = null
+            showBadgeDot = false
+            leftBottomText = null
+            fadeOnlyOpening = true
+        }
+    }
+
+    fun updateAnchor(anchor: BookCoverMorphAnchor?) {
+        if (anchor != null && !anchor.bounds.isEmpty) {
+            startBounds = anchor.bounds
+            startCornerRadiusPx = anchor.cornerRadiusPx
+            if (coverPath == null) coverPath = anchor.coverPath
+            if (bookUrl == null) bookUrl = anchor.bookUrl
+            badgeText = anchor.badgeText
+            showBadgeDot = anchor.showBadgeDot
+            leftBottomText = anchor.leftBottomText
+        }
+    }
+
+    fun targetStart(): Pair<Rect, Float> = computeTargetStart()
+
+    private fun computeTargetStart(): Pair<Rect, Float> {
+        val currentAnchor = if (isCollapsing) BookCoverMorphAnchors.get(anchorKey) else null
+        if (isCollapsing) {
+            val candidateAnchorBounds = currentAnchor?.bounds?.takeIf { !it.isEmpty }
+            if (candidateAnchorBounds != null && isAnchorVisibleInScreen(
+                    candidateAnchorBounds,
+                    screenBounds
+                )
+            ) {
+                return candidateAnchorBounds to currentAnchor.cornerRadiusPx
+            } else if (!startBounds.isEmpty && isAnchorVisibleInScreen(startBounds, screenBounds)) {
+                return startBounds to startCornerRadiusPx
+            } else {
+                val safeWidth = (120f * density.density).coerceAtLeast(100f)
+                val safeHeight = safeWidth * (7f / 5f)
+                val center = screenBounds.center
+                return Rect(
+                    left = center.x - safeWidth / 2f,
+                    top = center.y - safeHeight / 2f,
+                    right = center.x + safeWidth / 2f,
+                    bottom = center.y + safeHeight / 2f,
+                ) to (8f * density.density)
+            }
+        } else {
+            return startBounds to startCornerRadiusPx
+        }
+    }
+
+    fun panelFrame(): PlayerPanelMorphFrame? {
+        if (fadeOnlyOpening || screenBounds.isEmpty) return null
+        val (targetStartBounds, targetStartRadius) = computeTargetStart()
+        if (targetStartBounds.isEmpty) return null
+        return computePanelMorphFrame(
+            progress = progress.value,
+            start = targetStartBounds,
+            end = screenBounds,
+            startCornerRadiusPx = targetStartRadius,
+            endCornerRadiusPx = screenCornerRadii.topLeft,
+            endCornerRadii = screenCornerRadii,
+        )
+    }
+
+    fun coverFrame(): PlayerCoverMorphFrame? {
+        if (!hasCoverEnd || fadeOnlyOpening || screenBounds.isEmpty) return null
+        val (targetStartBounds, targetStartRadius) = computeTargetStart()
+        if (targetStartBounds.isEmpty || coverEndBounds.isEmpty) return null
+        val targetEnd = if (isCollapsing) {
+            if (isAnchorVisibleInScreen(coverEndBounds, screenBounds)) {
+                coverEndBounds
+            } else {
+                return null
+            }
+        } else {
+            coverEndBounds
+        }
+        return computeCoverMorphFrame(
+            progress = progress.value,
+            start = targetStartBounds,
+            end = targetEnd,
+            startCornerRadiusPx = targetStartRadius,
+            endCornerRadiusPx = coverEndCornerRadiusPx,
+            imageRotationDeg = 0f,
+        )
+    }
+
+    suspend fun animateTo(target: Float, initialVelocity: Float = 0f) {
+        isCollapsing = target < 0.5f
+        progress.animateTo(
+            targetValue = target.coerceIn(0f, 1f),
+            animationSpec = spring(
+                dampingRatio = 1f,
+                stiffness = 260f,
+                visibilityThreshold = 0.0001f,
+            ),
+            initialVelocity = initialVelocity,
+        )
+    }
+}
+
+/**
+ * 小说/漫画封面过渡透明度:在动画前期(0.10f..0.25f)完成封面淡出,保持卡片精致尺寸,避免展开中途封面过大。
+ *
+ * 打开时(isCollapsing = false):封面随卡片展开,在 0.10f..0.25f 平滑淡出交接给内容。
+ * 收起时(isCollapsing = true):内容在中后期(0.65f..0.20f)渐变消失,在末段(0.25f..0.10f)封面平滑渐显,完美回到书架卡片。
+ */
+fun computeBookCoverAlpha(progress: Float, isCollapsing: Boolean): Float {
+    val t = progress.coerceIn(0f, 1f)
+    return if (isCollapsing) {
+        if (t >= 0.25f) 0f
+        else ((0.25f - t) / 0.15f).coerceIn(0f, 1f)
+    } else {
+        if (t <= 0.10f) 1f
+        else (1f - (t - 0.10f) / 0.15f).coerceIn(0f, 1f)
+    }
+}
+
+/**
+ * 书架封面角标(未读章节数、更新指示、来源标签)透明度渐变:
+ * - 打开时 (isCollapsing = false):在动画极前期 (0.0f..0.15f) 迅速平滑淡出,交接给阅读/详情界面;
+ * - 收起时 (isCollapsing = true):在收拢到书架的末段 (0.20f..0.0f) 平滑渐显,与书架原生角标零时差无缝贴合。
+ */
+fun computeBookBadgeAlpha(progress: Float, isCollapsing: Boolean): Float {
+    val t = progress.coerceIn(0f, 1f)
+    return if (isCollapsing) {
+        if (t >= 0.20f) 0f
+        else ((0.20f - t) / 0.20f).coerceIn(0f, 1f)
+    } else {
+        if (t <= 0.0f) 1f
+        else if (t >= 0.15f) 0f
+        else (1f - t / 0.15f).coerceIn(0f, 1f)
+    }
+}
+
+@Composable
+fun rememberBookMorphState(
+    anchorKey: String? = null,
+    hasTargetCover: Boolean = false,
+): BookMorphState {
+    val density = LocalDensity.current
+    return remember(density, anchorKey, hasTargetCover) {
+        BookMorphState(
+            progress = Animatable(0f),
+            density = density,
+            hasTargetCover = hasTargetCover,
+        ).apply {
+            if (!anchorKey.isNullOrBlank()) {
+                val anchor = BookCoverMorphAnchors.get(anchorKey)
+                setAnchor(anchor, anchorKey)
+            }
+        }
+    }
+}
+
+/**
+ * 判断锚点矩形是否在屏幕可视区域内有效可见(至少 25% 面积在屏幕内且坐标在有效边界内)。
+ */
+fun isAnchorVisibleInScreen(bounds: Rect, screen: Rect): Boolean {
+    if (bounds.isEmpty || screen.isEmpty) return false
+    val left = maxOf(bounds.left, screen.left)
+    val top = maxOf(bounds.top, screen.top)
+    val right = minOf(bounds.right, screen.right)
+    val bottom = minOf(bounds.bottom, screen.bottom)
+    if (right <= left || bottom <= top) return false
+    val visibleArea = (right - left) * (bottom - top)
+    val totalArea = bounds.width * bounds.height
+    return totalArea > 0f && (visibleArea / totalArea) >= 0.25f
+}
+

+ 3 - 1
app/src/main/java/io/legado/app/core/ui/player/PlayerMorphAnchors.kt

@@ -12,6 +12,8 @@ internal suspend fun awaitPlayerMorphAnchors(
     morph: ReadAloudMorphState,
     awaitCapsuleAnchor: Boolean,
 ): Boolean {
+    // 若当前已由书架封面提供精准锚点,立即就绪,无需等待胶囊
+    if (morph.isCoverAnchor && !morph.panelStartBounds.isEmpty) return true
     if (!awaitCapsuleAnchor) return false
     var previousPanel = Rect.Zero
     var previousCover = Rect.Zero
@@ -22,7 +24,7 @@ internal suspend fun awaitPlayerMorphAnchors(
         val cover = morph.coverStartBounds
         val ready = !morph.screenBounds.isEmpty &&
                 (!morph.coverPageVisible || !morph.capsuleCoverLinked || !morph.coverEndBounds.isEmpty) &&
-                morph.hasCapsuleAnchors
+                (morph.hasCapsuleAnchors || morph.isCoverAnchor)
         stableFrames =
             if (ready && panel == previousPanel && cover == previousCover) stableFrames + 1 else 0
         if (stableFrames >= 2) return true

+ 50 - 44
app/src/main/java/io/legado/app/core/ui/player/PlayerMorphHost.kt

@@ -105,12 +105,15 @@ fun PlayerMorphHost(
     awaitCapsuleAnchor: Boolean = false,
     backEnabled: Boolean = true,
     predictiveBackEnabled: Boolean = true,
+    verticalDragEnabled: Boolean = true,
+    onBeforeCollapse: (() -> Unit)? = null,
     onDismiss: () -> Unit,
     content: @Composable (onCollapse: () -> Unit) -> Unit,
 ) {
     val view = LocalView.current
     val scope = rememberCoroutineScope()
     val currentVisible by rememberUpdatedState(visible)
+    val currentBeforeCollapse by rememberUpdatedState(onBeforeCollapse)
     var backSettleJob by remember { mutableStateOf<Job?>(null) }
     // 进度每帧都在变,用 derivedStateOf 收敛成布尔,避免宿主整体重组。
     // 必须带 visible 作为 key:derivedStateOf 的 lambda 只在首次组合捕获 visible,
@@ -123,6 +126,7 @@ fun PlayerMorphHost(
 
     val collapse: (Float) -> Unit = { velocity ->
         scope.launch {
+            currentBeforeCollapse?.invoke()
             morph.animateTo(0f, initialVelocity = velocity)
             currentDismiss()
         }
@@ -153,6 +157,7 @@ fun PlayerMorphHost(
                 return@PredictiveBackHandler
             }
             backSettleJob?.cancel()
+            currentBeforeCollapse?.invoke()
             val startProgress = morph.progress.value
             var lastProgress = startProgress
             var lastTimeNanos = 0L
@@ -248,52 +253,53 @@ fun PlayerMorphHost(
                         morph.reportScreenCorners(PlayerPanelCornerRadii.Zero)
                     }
                 }
-                // 下滑收起:只接管内容没有消费掉的纵向拖动。
-                // 用指针层而不是嵌套滚动——封面页、顶栏没有可滚动子节点,
-                // 嵌套滚动收不到事件,滑动收起会「时灵时不灵」。
-                .pointerInput(morph) {
-                    // 手势速度得自己采:detectVerticalDragGestures 只给位移,
-                    // 而「甩出去」的结算必须靠速度,否则手指一抬动画就从静止重新起步。
-                    val velocityTracker = VelocityTracker()
-                    detectVerticalDragGestures(
-                        onDragStart = {
-                            velocityTracker.resetTracking()
-                            morph.dragging = true
-                        },
-                        onVerticalDrag = { change, dragAmount ->
-                            velocityTracker.addPosition(change.uptimeMillis, change.position)
-                            // 已经全展开时再往上滑不处理(该留给正文/目录滚动)。
-                            if (dragAmount < 0f && morph.progress.value >= 1f) {
-                                return@detectVerticalDragGestures
-                            }
-                            change.consume()
-                            scope.launch {
-                                morph.progress.snapTo(
-                                    (morph.progress.value - dragAmount / morph.dragRangePx())
-                                        .coerceIn(0f, 1f)
+                .then(
+                    if (verticalDragEnabled) {
+                        Modifier
+                            .pointerInput(morph) {
+                                val velocityTracker = VelocityTracker()
+                                detectVerticalDragGestures(
+                                    onDragStart = {
+                                        velocityTracker.resetTracking()
+                                        morph.dragging = true
+                                    },
+                                    onVerticalDrag = { change, dragAmount ->
+                                        velocityTracker.addPosition(
+                                            change.uptimeMillis,
+                                            change.position
+                                        )
+                                        if (dragAmount < 0f && morph.progress.value >= 1f) {
+                                            return@detectVerticalDragGestures
+                                        }
+                                        change.consume()
+                                        scope.launch {
+                                            morph.progress.snapTo(
+                                                (morph.progress.value - dragAmount / morph.dragRangePx())
+                                                    .coerceIn(0f, 1f)
+                                            )
+                                        }
+                                    },
+                                    onDragEnd = {
+                                        morph.dragging = false
+                                        settleMorph(
+                                            morph = morph,
+                                            scope = scope,
+                                            onCollapse = collapse,
+                                            progressVelocity = morphDragVelocity(
+                                                velocityTracker,
+                                                morph
+                                            ),
+                                        )
+                                    },
+                                    onDragCancel = {
+                                        morph.dragging = false
+                                        settleMorph(morph, scope, collapse, 0f)
+                                    },
                                 )
                             }
-                        },
-                        // 结束与取消都要结算,否则进度会停在中间,
-                        // 两个胶囊按进度淡出会表现为「胶囊消失」。
-                        onDragEnd = {
-                            morph.dragging = false
-                            settleMorph(
-                                morph = morph,
-                                scope = scope,
-                                onCollapse = collapse,
-                                progressVelocity = morphDragVelocity(velocityTracker, morph),
-                            )
-                        },
-                        onDragCancel = {
-                            morph.dragging = false
-                            settleMorph(morph, scope, collapse, 0f)
-                        },
-                    )
-                }
-                // 补充通道:正文 / 目录是可滚动内容,`scrollable` 会消费指针事件,
-                // 指针层收不到,只能靠嵌套滚动里「已到顶且仍在往下滑」的剩余位移。
-                .nestedScroll(morphCollapseNestedScroll(morph, scope, collapse)),
+                            .nestedScroll(morphCollapseNestedScroll(morph, scope, collapse))
+                    } else Modifier
+                ),
         ) {
             MorphPanelSurface(
                 morph = morph,

+ 69 - 20
app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt

@@ -8,6 +8,7 @@ import androidx.appcompat.app.AppCompatActivity
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
@@ -26,6 +27,7 @@ import androidx.lifecycle.lifecycleScope
 import com.script.rhino.runScriptWithContext
 import io.legado.app.R
 import io.legado.app.constant.AppLog
+import io.legado.app.core.ui.morph.BookMorphHost
 import io.legado.app.data.entities.BookGroup
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isImage
@@ -35,6 +37,7 @@ import io.legado.app.model.SourceCallBack
 import io.legado.app.ui.book.info.edit.BookInfoEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.login.SourceLoginJsExtensions
+import io.legado.app.ui.main.bookCoverSharedElementKey
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
 import io.legado.app.utils.RealPathUtil
 import io.legado.app.utils.StartActivityContract
@@ -92,6 +95,33 @@ fun BookInfoRouteScreen(
     val noPasswordHint = stringResource(R.string.private_content_no_password)
     var showSelectBooksDirSheet by remember { mutableStateOf(false) }
 
+    val canMorphBack = uiState.dialog == null &&
+            uiState.sheet == BookInfoSheet.None &&
+            !showSelectBooksDirSheet &&
+            !uiState.showAppLogSheet &&
+            !uiState.showPrivatePasswordDialog
+    val effectiveCoverKey = sharedCoverKey ?: bookCoverSharedElementKey(bookUrl)
+    var isDismissed by remember { mutableStateOf(false) }
+    var finishResultCode by remember { mutableStateOf<Int?>(null) }
+    var finishAfterTransition by remember { mutableStateOf(false) }
+    var collapseRequested by remember { mutableStateOf<(() -> Unit)?>(null) }
+
+    val dismissBookInfo: () -> Unit = {
+        if (!isDismissed) {
+            isDismissed = true
+            onFinish(finishResultCode, finishAfterTransition)
+        }
+    }
+
+    val handleBack: () -> Unit = {
+        val collapse = collapseRequested
+        if (collapse != null && canMorphBack) {
+            collapse()
+        } else {
+            dismissBookInfo()
+        }
+    }
+
     val tocActivityResult = rememberLauncherForActivityResult(TocActivityResult()) {
         viewModel.onTocResult(it)
     }
@@ -142,7 +172,14 @@ fun BookInfoRouteScreen(
             when (effect) {
                 is BookInfoEffect.ShowMessage -> context.toastOnUi(effect.message)
                 is BookInfoEffect.Finish -> {
-                    onFinish(effect.resultCode, effect.afterTransition)
+                    finishResultCode = effect.resultCode
+                    finishAfterTransition = effect.afterTransition
+                    val collapse = collapseRequested
+                    if (collapse != null && canMorphBack) {
+                        collapse()
+                    } else {
+                        dismissBookInfo()
+                    }
                 }
 
                 is BookInfoEffect.OpenBookInfoEdit -> {
@@ -249,25 +286,37 @@ fun BookInfoRouteScreen(
         }
     }
 
-    FilePickerSheet(
-        show = showSelectBooksDirSheet,
-        onDismissRequest = { showSelectBooksDirSheet = false },
-        title = stringResource(R.string.select_book_folder),
-        onSelectSysDir = {
-            showSelectBooksDirSheet = false
-            localBookTreeSelect.launch(null)
-        },
-    )
-    BookInfoScreen(
-        state = uiState,
-        groups = viewModel.allGroups
-            .collectAsStateWithLifecycle(persistentListOf<BookGroup>()).value,
-        onIntent = viewModel::onIntent,
-        onBack = onBack,
-        sharedTransitionScope = sharedTransitionScope,
-        animatedVisibilityScope = animatedVisibilityScope,
-        sharedCoverKey = sharedCoverKey,
-    )
+    BookMorphHost(
+        anchorKey = effectiveCoverKey,
+        backgroundColor = MaterialTheme.colorScheme.background,
+        backEnabled = canMorphBack,
+        predictiveBackEnabled = true,
+        hasTargetCover = true,
+        onDismiss = dismissBookInfo,
+    ) { onCollapse ->
+        LaunchedEffect(onCollapse) {
+            collapseRequested = onCollapse
+        }
+        FilePickerSheet(
+            show = showSelectBooksDirSheet,
+            onDismissRequest = { showSelectBooksDirSheet = false },
+            title = stringResource(R.string.select_book_folder),
+            onSelectSysDir = {
+                showSelectBooksDirSheet = false
+                localBookTreeSelect.launch(null)
+            },
+        )
+        BookInfoScreen(
+            state = uiState,
+            groups = viewModel.allGroups
+                .collectAsStateWithLifecycle(persistentListOf<BookGroup>()).value,
+            onIntent = viewModel::onIntent,
+            onBack = handleBack,
+            sharedTransitionScope = null,
+            animatedVisibilityScope = null,
+            sharedCoverKey = null,
+        )
+    }
 }
 
 private fun runSourceCallback(

+ 5 - 2
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -105,6 +105,7 @@ import coil3.compose.AsyncImage
 import coil3.size.Size
 import io.legado.app.R
 import io.legado.app.constant.BookType
+import io.legado.app.core.ui.morph.trackBookMorphCover
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.entities.BookSource
@@ -1205,7 +1206,8 @@ private fun BookInfoHeader(
                             bookUrl = book.bookUrl,
                             modifier = Modifier
                                 .width(112.dp)
-                                .aspectRatio(5f / 7f),
+                                .aspectRatio(5f / 7f)
+                                .trackBookMorphCover(4.dp),
                             // 同一个 key + 同一个 scope:共享元素动画在脱敏态下依然连续
                             sharedCoverKey = sharedCoverKey,
                             sharedTransitionScope = sharedTransitionScope,
@@ -1224,7 +1226,8 @@ private fun BookInfoHeader(
                             onError = onNetworkCoverLoadError,
                             modifier = Modifier
                                 .width(112.dp)
-                                .aspectRatio(5f / 7f),
+                                .aspectRatio(5f / 7f)
+                                .trackBookMorphCover(4.dp),
                             showLoadingPlaceholder = sharedCoverKey == null,
                             sharedTransitionScope = sharedTransitionScope,
                             animatedVisibilityScope = animatedVisibilityScope,

+ 69 - 36
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderRouteScreen.kt

@@ -7,12 +7,16 @@ import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.platform.LocalDensity
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleEventObserver
@@ -20,6 +24,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import dev.chrisbanes.haze.HazeState
 import io.legado.app.constant.BookType
+import io.legado.app.core.ui.morph.BookMorphHost
 import io.legado.app.model.SourceCallBack
 import io.legado.app.receiver.NetworkChangedListener
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetRoute
@@ -27,7 +32,6 @@ import io.legado.app.ui.book.read.sheet.ReaderBookSheetTab
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.main.AndroidPlatformCapabilities
 import io.legado.app.ui.main.MainActivity
-import io.legado.app.ui.main.readerSharedBounds
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.openUrl
 import io.legado.app.utils.share
@@ -76,11 +80,29 @@ fun MangaReaderRouteScreen(
         )
     }
 
-    LaunchedEffect(viewModel) {
+    var isDismissed by remember { mutableStateOf(false) }
+    var bookshelfChangedResult by remember { mutableStateOf(false) }
+    var collapseTrigger by remember { mutableStateOf<(() -> Unit)?>(null) }
+    val dismissManga: () -> Unit = {
+        if (!isDismissed) {
+            isDismissed = true
+            onFinish(bookshelfChangedResult)
+        }
+    }
+
+    LaunchedEffect(viewModel, collapseTrigger) {
         viewModel.effects.collectLatest { effect ->
             val currentState = viewModel.uiState.value
             when (effect) {
-                is MangaReaderEffect.Finish -> onFinish(effect.bookshelfChanged)
+                is MangaReaderEffect.Finish -> {
+                    bookshelfChangedResult = effect.bookshelfChanged
+                    val collapse = collapseTrigger
+                    if (collapse != null) {
+                        collapse.invoke()
+                    } else {
+                        dismissManga()
+                    }
+                }
                 MangaReaderEffect.OpenBookInfo -> {
                     if (currentState.bookUrl.isNotEmpty()) {
                         onOpenBookInfo(
@@ -208,39 +230,50 @@ fun MangaReaderRouteScreen(
         }
     }
 
-    val menuHazeState = remember { HazeState() }
-    val useMenuHaze = state.settings.menuBottomBarBlur ||
-            (!state.settings.menuBottomBarFloating &&
-                    state.settings.menuBottomBarLiquidGlass &&
-                    state.settingsCategory != null)
-    MangaReaderScreen(
-        state = state,
-        onIntent = viewModel::onIntent,
-        hazeState = if (useMenuHaze) menuHazeState else null,
-        modifier = Modifier.readerSharedBounds(
-            sharedTransitionScope,
-            animatedVisibilityScope,
-            sharedCoverKey,
-            platformCapabilities.displayCornerRadiusPx,
-            density,
-        ),
-    )
-    if (state.activeSheet == MangaReaderSheet.Catalog && state.bookUrl.isNotEmpty()) {
-        ReaderBookSheetRoute(
-            show = true,
-            bookUrl = state.bookUrl,
-            initialTab = ReaderBookSheetTab.Toc,
-            currentChapterIndex = state.pendingChapterIndex ?: state.chapterIndex,
-            onDismissRequest = { viewModel.onIntent(MangaReaderIntent.DismissSheet) },
-            onChapterClick = { chapterIndex, pageIndex ->
-                viewModel.onIntent(MangaReaderIntent.DismissSheet)
-                viewModel.onIntent(MangaReaderIntent.OpenChapter(chapterIndex, pageIndex))
-            },
-            onOpenFullBookInfo = {
-                viewModel.onIntent(MangaReaderIntent.DismissSheet)
-                onOpenBookInfo(state.bookName, state.bookAuthor, state.bookUrl)
-            },
-            onOpenFullToc = { tocLauncher.launch(state.bookUrl) },
+    val canMorphBack = state.activeDialog == null &&
+            state.activeSheet == null &&
+            state.settingsCategory == null &&
+            !state.menuVisible &&
+            !(!state.inBookshelf && state.confirmAddToShelf)
+
+    BookMorphHost(
+        anchorKey = sharedCoverKey,
+        backgroundColor = Color.Black,
+        backEnabled = canMorphBack,
+        predictiveBackEnabled = true,
+        onDismiss = dismissManga,
+    ) { onCollapse ->
+        LaunchedEffect(onCollapse) {
+            collapseTrigger = onCollapse
+        }
+        val menuHazeState = remember { HazeState() }
+        val useMenuHaze = state.settings.menuBottomBarBlur ||
+                (!state.settings.menuBottomBarFloating &&
+                        state.settings.menuBottomBarLiquidGlass &&
+                        state.settingsCategory != null)
+        MangaReaderScreen(
+            state = state,
+            onIntent = viewModel::onIntent,
+            hazeState = if (useMenuHaze) menuHazeState else null,
+            modifier = Modifier.fillMaxSize(),
         )
+        if (state.activeSheet == MangaReaderSheet.Catalog && state.bookUrl.isNotEmpty()) {
+            ReaderBookSheetRoute(
+                show = true,
+                bookUrl = state.bookUrl,
+                initialTab = ReaderBookSheetTab.Toc,
+                currentChapterIndex = state.pendingChapterIndex ?: state.chapterIndex,
+                onDismissRequest = { viewModel.onIntent(MangaReaderIntent.DismissSheet) },
+                onChapterClick = { chapterIndex, pageIndex ->
+                    viewModel.onIntent(MangaReaderIntent.DismissSheet)
+                    viewModel.onIntent(MangaReaderIntent.OpenChapter(chapterIndex, pageIndex))
+                },
+                onOpenFullBookInfo = {
+                    viewModel.onIntent(MangaReaderIntent.DismissSheet)
+                    onOpenBookInfo(state.bookName, state.bookAuthor, state.bookUrl)
+                },
+                onOpenFullToc = { tocLauncher.launch(state.bookUrl) },
+            )
+        }
     }
 }

+ 5 - 1
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt

@@ -176,7 +176,11 @@ fun MangaReaderScreen(
     imageLoader: ImageLoader = koinInject(),
     hazeState: HazeState? = null,
 ) {
-    BackHandler { onIntent(MangaReaderIntent.BackPressed) }
+    val canMorphBack = state.activeDialog == null &&
+            state.activeSheet == null &&
+            state.settingsCategory == null &&
+            !state.menuVisible
+    BackHandler(enabled = !canMorphBack) { onIntent(MangaReaderIntent.BackPressed) }
     var viewportSize by remember { mutableStateOf(IntSize.Zero) }
     var viewportOrigin by remember { mutableStateOf(Offset.Zero) }
     val aspectRatios = remember(state.bookUrl) { mutableStateMapOf<String, Float>() }

+ 41 - 21
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -70,6 +70,7 @@ import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.BookType
 import io.legado.app.constant.ReadMenuBlurMode
+import io.legado.app.core.ui.morph.BookMorphHost
 import io.legado.app.feature.reader.ReaderBackgroundSurface
 import io.legado.app.feature.reader.ReaderCanvasSurface
 import io.legado.app.feature.reader.core.gesture.ReaderTapActionGrid
@@ -90,9 +91,7 @@ import io.legado.app.ui.book.read.sheet.TextSelectMenuConfigSheet
 import io.legado.app.ui.book.searchContent.SearchContentResult
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.login.SourceLoginType
-import io.legado.app.ui.main.AndroidPlatformCapabilities
 import io.legado.app.ui.main.MainActivity
-import io.legado.app.ui.main.readerSharedBounds
 import io.legado.app.ui.replace.ReplaceEditRoute
 import io.legado.app.ui.replace.ReplaceRuleActivity
 import io.legado.app.ui.theme.LegadoTheme
@@ -158,6 +157,7 @@ fun ReadBookRouteScreen(
     onOpenVoiceCasting: (bookUrl: String) -> Unit = {},
     onOpenTtsEnginesAndVoices: () -> Unit = {},
     onOpenTtsCache: () -> Unit = {},
+    onNavigateBack: () -> Unit = {},
 ) {
     // 归因定界:与末尾 compose.screen.end 成对。若首帧 `Compose:recompose` 里出现
     // begin 之前的空档,说明那部分耗时在本屏之外(导航宿主 / 共享转场层)。
@@ -203,7 +203,13 @@ fun ReadBookRouteScreen(
                     !state.menuConfig.readMenuFloatingBottomBar &&
                             state.menuConfig.readMenuBottomBarBlurMode == ReadMenuBlurMode.LiquidGlass
                     )
-    BackHandler {
+    val canMorphBack = state.activeSheet == null &&
+            !state.isShowingSearchResult &&
+            !state.isAutoPage &&
+            !state.menuState.canNavigateBack &&
+            state.activeDialog == null
+
+    BackHandler(enabled = !canMorphBack) {
         when {
             state.activeSheet != null -> viewModel.onIntent(ReadBookIntent.DismissSheet)
             state.isShowingSearchResult -> viewModel.onIntent(ReadBookIntent.ExitSearch)
@@ -654,24 +660,37 @@ fun ReadBookRouteScreen(
             ReaderPerfTrace.marker("surface.page-ready")
         }
     }
-    // 阅读页 sharedBounds 的裁剪圆角动画:从封面源圆角渐变到设备屏幕圆角,
-    // 转场收尾时正文页与物理圆角贴合(Compose 不会自动插值两端 clip,需自行驱动)。
-    val platformCapabilities = remember(controller) { AndroidPlatformCapabilities(controller.activity) }
-    val displayConfiguration = LocalConfiguration.current
-    val displayCornerRadiusPx = remember(displayConfiguration) { platformCapabilities.displayCornerRadiusPx }
-    Box(
-        Modifier
-            .fillMaxSize()
-            .semantics { testTagsAsResourceId = true }
-            .readerSharedBounds(
-                sharedTransitionScope = sharedTransitionScope,
-                animatedVisibilityScope = animatedVisibilityScope,
-                sharedCoverKey = sharedCoverKey,
-                displayCornerRadiusPx = displayCornerRadiusPx,
-                density = density,
-            )
-            .background(readerSurfaceColor)
-    ) {
+    var isDismissed by remember { mutableStateOf(false) }
+    var closingFromController by remember { mutableStateOf(false) }
+    val dismissReader: () -> Unit = {
+        if (!isDismissed) {
+            isDismissed = true
+            if (!closingFromController) {
+                viewModel.onIntent(ReadBookIntent.CloseReadBook())
+            }
+            onNavigateBack()
+        }
+    }
+
+    BookMorphHost(
+        anchorKey = sharedCoverKey,
+        backgroundColor = readerSurfaceColor,
+        backEnabled = canMorphBack,
+        predictiveBackEnabled = true,
+        onDismiss = dismissReader,
+    ) { onCollapse ->
+        LaunchedEffect(onCollapse) {
+            controller.onClose = {
+                closingFromController = true
+                onCollapse()
+            }
+        }
+        Box(
+            Modifier
+                .fillMaxSize()
+                .semantics { testTagsAsResourceId = true }
+                .background(readerSurfaceColor)
+        ) {
         Box(
             Modifier
                 .fillMaxSize()
@@ -953,6 +972,7 @@ fun ReadBookRouteScreen(
         }
         ReaderPerfTrace.marker("compose.chrome.end")
     }
+    }
     ReaderPerfTrace.marker("compose.screen.end")
 }
 

+ 12 - 8
app/src/main/java/io/legado/app/ui/book/readaloud/morph/PlayerMorphFrames.kt

@@ -18,16 +18,18 @@ import androidx.compose.ui.geometry.Rect
  * 否则文字会被非等比缩放拉糊。
  */
 
-/** 遮罩 / 内容开始出现的进度点:过渡前段完全看不出背景在变。 */
-const val MORPH_VEIL_START = 0.85f
+/** 遮罩 / 内容开始淡入的下限点。 */
+const val MORPH_VEIL_START = 0.20f
+
+/** 遮罩 / 内容完全不透明的上限点:收起时在此点之前完全保持可见,之后在中后期渐变消失。 */
+const val MORPH_VEIL_END = 0.65f
 
 /**
  * 封面旋转归零的进度点。
  *
- * 必须早于 [MORPH_VEIL_START]:最后一段内容开始淡入,
- * 如果那时旋转还没停,交接处会看到一次「转着落地」的抖动。
+ * 必须早于内容淡入:胶囊展开前期旋转即平滑归零,交接处不产生抖动。
  */
-const val MORPH_ROTATION_SETTLE = 0.7f
+const val MORPH_ROTATION_SETTLE = 0.20f
 
 /**
  * 收起时保留播放页背景,只有进度降到最后 15% 才交还胶囊底色。
@@ -204,15 +206,17 @@ fun computeCoverMorphFrame(
  * 单独一条曲线是刻意的:内容一旦参与几何缩放就会被拉糊,所以它只吃透明度。
  * 收起时反过来用(进度越小越透明)。
  *
- * 内容可见度使用线性窗口。
+ * 内容可见度使用 smoothstep 平滑窗口,保证收起中后期优雅淡出、展开中前期自然显现。
  */
 fun computeMorphVeil(
     progress: Float,
     veilStart: Float = MORPH_VEIL_START,
+    veilEnd: Float = MORPH_VEIL_END,
 ): Float {
     val t = progress.coerceIn(0f, 1f)
-    if (veilStart >= 1f) return t
-    return ((t - veilStart) / (1f - veilStart)).coerceIn(0f, 1f)
+    if (t <= veilStart) return 0f
+    if (t >= veilEnd) return 1f
+    return smoothstep((t - veilStart) / (veilEnd - veilStart))
 }
 
 /**

+ 18 - 1
app/src/main/java/io/legado/app/ui/book/readaloud/morph/ReadAloudMorphState.kt

@@ -116,10 +116,26 @@ class ReadAloudMorphState(
 
     private var fadeOnlyOpening by mutableStateOf(false)
 
+    var isCoverAnchor by mutableStateOf(false)
+        private set
+
     fun prepareOpeningAnchors() {
         fadeOnlyOpening = false
     }
 
+    /** 上报来自书架封面的形变锚点。不受胶囊类型限制,直接作为有效起点。 */
+    fun reportBookCoverAnchor(bounds: Rect, cornerRadiusPx: Float) {
+        if (progress.value > 0f && !expanded) return
+        isCoverAnchor = true
+        fadeOnlyOpening = false
+        panelStartBounds = bounds
+        panelStartCornerRadiusPx = cornerRadiusPx
+        coverStartBounds = bounds
+        coverStartCornerRadiusPx = cornerRadiusPx
+        panelStartColor = Color.Unspecified
+        capsuleProgressRing = null
+    }
+
     /** 锚点等待失败后固定本次展开为淡入,迟到的测量不能在中途改变路径。 */
     fun useFadeOnlyOpening() {
         clearStartAnchors()
@@ -129,6 +145,7 @@ class ReadAloudMorphState(
     /** 路由/朗读会话改变后不沿用上一种胶囊的起点。 */
     fun clearStartAnchors() {
         if (progress.value > 0f && !expanded) return
+        isCoverAnchor = false
         panelStartBounds = Rect.Zero
         coverStartBounds = Rect.Zero
         panelStartCornerRadiusPx = 0f
@@ -144,7 +161,7 @@ class ReadAloudMorphState(
     private val coverAnchorMatches get() = expectedAnchorKind == null || coverAnchorKind == expectedAnchorKind
     val hasCapsuleAnchors: Boolean
         get() = !panelStartBounds.isEmpty && !coverStartBounds.isEmpty &&
-                panelAnchorMatches && coverAnchorMatches
+                (isCoverAnchor || (panelAnchorMatches && coverAnchorMatches))
 
     /** 路由交接不能用上一种胶囊的残留测量开始动画。 */
     fun expectCapsuleAnchors(kind: CapsuleAnchorKind?) {

+ 39 - 29
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -12,8 +12,6 @@ import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.tween
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
 import androidx.compose.animation.togetherWith
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
@@ -39,6 +37,8 @@ import androidx.navigation3.ui.LocalNavAnimatedContentScope
 import androidx.navigation3.ui.NavDisplay
 import io.legado.app.R
 import io.legado.app.constant.BookType
+import io.legado.app.core.ui.morph.BookCoverMorphAnchors
+import io.legado.app.core.ui.morph.isAnchorVisibleInScreen
 import io.legado.app.core.ui.player.PlayerMorphAppearance
 import io.legado.app.core.ui.player.PlayerMorphHost
 import io.legado.app.domain.model.BookSearchScope
@@ -218,20 +218,15 @@ private fun webViewEntryMetadata(predictiveBackEnabled: Boolean) = metadata {
     }
 }
 
-/** Full-screen book destinations share the same scene fade as the text reader. */
-private fun readerEntryMetadata(predictiveBackEnabled: Boolean) = metadata {
-    put(NavDisplay.TransitionKey) {
-        fadeIn(animationSpec = tween(600)) togetherWith fadeOut(animationSpec = tween(600))
-    }
-    put(NavDisplay.PopTransitionKey) {
-        fadeIn(animationSpec = tween(600)) togetherWith fadeOut(animationSpec = tween(600))
-    }
-    if (predictiveBackEnabled) {
+/** Full-screen book destinations use modal overlay scene strategy and zero nav display transitions; animations are managed by BookMorphHost. */
+private fun readerEntryMetadata(predictiveBackEnabled: Boolean) =
+    ModalOverlaySceneStrategy.modalOverlay() + metadata {
+        put(NavDisplay.TransitionKey) { EnterTransition.None togetherWith ExitTransition.None }
+        put(NavDisplay.PopTransitionKey) { EnterTransition.None togetherWith ExitTransition.None }
         put(NavDisplay.PredictivePopTransitionKey) { _ ->
-            fadeIn(animationSpec = tween(600)) togetherWith fadeOut(animationSpec = tween(600))
+            EnterTransition.None togetherWith ExitTransition.None
         }
     }
-}
 
 /**
  * 以底部弹层呈现的目的地(听书播放页、有声书播放页)。
@@ -842,6 +837,7 @@ fun MainActivity.mainEntryProvider(
                 onOpenTtsCache = {
                     onNavigateToRoute(MainRouteTtsCache)
                 },
+                onNavigateBack = { onNavigateBack() },
             )
 
             DisposableEffect(controller, lifecycleOwner, route.readAloud) {
@@ -1127,22 +1123,7 @@ fun MainActivity.mainEntryProvider(
     }
 
     entry<MainRouteBookInfo>(
-        metadata = metadata {
-            put(NavDisplay.TransitionKey) {
-                fadeIn(animationSpec = tween(300)) togetherWith
-                        fadeOut(animationSpec = tween(300))
-            }
-            put(NavDisplay.PopTransitionKey) {
-                fadeIn(animationSpec = tween(300)) togetherWith
-                        fadeOut(animationSpec = tween(300))
-            }
-            if (configuration.appShell.predictiveBackEnabled) {
-                put(NavDisplay.PredictivePopTransitionKey) { _ ->
-                    fadeIn(animationSpec = tween(300)) togetherWith
-                            fadeOut(animationSpec = tween(300))
-                }
-            }
-        }
+        metadata = readerEntryMetadata(configuration.appShell.predictiveBackEnabled)
     ) { route ->
         val bookInfoViewModel = koinViewModel<BookInfoViewModel>(key = "BookInfo:${route.bookUrl}")
         BookInfoRouteScreen(
@@ -1598,12 +1579,39 @@ internal fun MainActivity.AudioPlayerMorphOverlay(
                 }
 
                 AudioPlayEffect.Finish -> {
+                    if (!morph.hasCapsuleAnchors) {
+                        val anchor = BookCoverMorphAnchors.get(bookUrl)
+                        if (anchor != null && !anchor.bounds.isEmpty && isAnchorVisibleInScreen(
+                                anchor.bounds,
+                                morph.screenBounds
+                            )
+                        ) {
+                            morph.reportBookCoverAnchor(anchor.bounds, anchor.cornerRadiusPx)
+                        }
+                    }
                     morph.animateTo(0f)
                     finishAudioPlay()
                 }
             }
         }
     }
+    val refreshAudioAnchor = {
+        if (!morph.hasCapsuleAnchors) {
+            val anchor = BookCoverMorphAnchors.get(bookUrl)
+            if (anchor != null && !anchor.bounds.isEmpty && isAnchorVisibleInScreen(
+                    anchor.bounds,
+                    morph.screenBounds
+                )
+            ) {
+                morph.reportBookCoverAnchor(anchor.bounds, anchor.cornerRadiusPx)
+            }
+        }
+    }
+    LaunchedEffect(bookUrl, visible) {
+        if (visible && morph.progress.value == 0f) {
+            refreshAudioAnchor()
+        }
+    }
     PlayerMorphHost(
         appearance = PlayerMorphAppearance(
             uiState.bookName, uiState.author, uiState.coverPath,
@@ -1616,6 +1624,8 @@ internal fun MainActivity.AudioPlayerMorphOverlay(
         visible = visible,
         awaitCapsuleAnchor = awaitCapsuleAnchor,
         predictiveBackEnabled = predictiveBackEnabled,
+        verticalDragEnabled = true,
+        onBeforeCollapse = refreshAudioAnchor,
         backEnabled = uiState.activeSheet == null && !showAudioChangeSource,
         onDismiss = ::finishAudioPlay,
     ) { onCollapse ->

+ 19 - 2
app/src/main/java/io/legado/app/ui/widget/components/image/cover/BookshelfCover.kt

@@ -15,8 +15,10 @@ import androidx.compose.material.icons.filled.Update
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
+import io.legado.app.core.ui.morph.BookCoverMorphAnchors
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
@@ -63,6 +65,9 @@ fun BookshelfCover(
             sharedCoverKey = sharedCoverKey,
             contentBlur = contentBlur,
             overlayContent = overlayContent,
+            badgeText = badgeText,
+            showBadgeDot = showBadgeDot,
+            leftBottomText = leftBottomText,
         )
 
         // 使用 animatedVisibilityScope 的 animateEnterExit 为叠加层添加同步动画
@@ -85,7 +90,11 @@ fun BookshelfCover(
                 modifier = Modifier
                     .align(Alignment.TopEnd)
                     .padding(2.dp)
-                    .then(overlayModifier),
+                    .then(overlayModifier)
+                    .graphicsLayer {
+                        alpha =
+                            if (BookCoverMorphAnchors.isOriginCoverHidden(sharedCoverKey)) 0f else 1f
+                    },
                 cornerRadius = 4.dp,
                 horizontalPadding = 4.dp,
                 verticalPadding = 2.dp
@@ -100,7 +109,11 @@ fun BookshelfCover(
                 modifier = Modifier
                     .align(Alignment.BottomStart)
                     .padding(2.dp)
-                    .then(overlayModifier),
+                    .then(overlayModifier)
+                    .graphicsLayer {
+                        alpha =
+                            if (BookCoverMorphAnchors.isOriginCoverHidden(sharedCoverKey)) 0f else 1f
+                    },
                 cornerRadius = 4.dp,
                 horizontalPadding = 4.dp,
                 verticalPadding = 2.dp
@@ -115,6 +128,10 @@ fun BookshelfCover(
                     .padding(horizontal = 4.dp, vertical = 6.dp)
                     .height(3.dp)
                     .then(overlayModifier)
+                    .graphicsLayer {
+                        alpha =
+                            if (BookCoverMorphAnchors.isOriginCoverHidden(sharedCoverKey)) 0f else 1f
+                    }
             )
         }
     }

+ 29 - 0
app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt

@@ -39,15 +39,20 @@ import androidx.compose.ui.draw.drawWithCache
 import androidx.compose.ui.draw.shadow
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
+import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.graphics.nativeCanvas
 import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.onGloballyPositioned
 import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.core.graphics.withSave
 import coil3.compose.AsyncImage
 import coil3.request.ImageRequest
+import io.legado.app.core.ui.morph.BookCoverMorphAnchors
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LocalAppUiConfiguration
 import org.koin.compose.koinInject
@@ -266,6 +271,9 @@ fun CoilBookCover(
      * 放成兄弟节点的话转场时不会被 overlay 带走,会出现"装饰停在原地、只有封面在飞"。
      */
     overlayContent: (@Composable BoxScope.() -> Unit)? = null,
+    badgeText: String? = null,
+    showBadgeDot: Boolean = false,
+    leftBottomText: String? = null,
 ) {
     val coverSettings = LocalAppUiConfiguration.current.cover
     val isNight = LegadoTheme.isDark
@@ -307,9 +315,30 @@ fun CoilBookCover(
         Modifier
     }
 
+    val coilDensity = LocalDensity.current
     Box(
         modifier = modifier
             .aspectRatio(5f / 7f)
+            .graphicsLayer {
+                alpha = if (BookCoverMorphAnchors.isOriginCoverHidden(sharedCoverKey)) 0f else 1f
+            }
+            .onGloballyPositioned { coordinates ->
+                if (sharedCoverKey != null) {
+                    BookCoverMorphAnchors.report(
+                        key = sharedCoverKey,
+                        bounds = coordinates.boundsInRoot(),
+                        cornerRadiusPx = with(coilDensity) { transitionRadius.toPx() },
+                        bookName = name,
+                        author = author,
+                        coverPath = finalPath ?: path,
+                        sourceOrigin = sourceOrigin,
+                        bookUrl = bookUrl,
+                        badgeText = badgeText,
+                        showBadgeDot = showBadgeDot,
+                        leftBottomText = leftBottomText,
+                    )
+                }
+            }
             .then(
                 with(sharedTransitionScope) {
                     if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) {

+ 5 - 0
app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateLockedContent.kt

@@ -27,12 +27,14 @@ import androidx.compose.ui.draw.BlurredEdgeTreatment
 import androidx.compose.ui.draw.blur
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import coil3.request.allowHardware
 import coil3.request.transformations
 import coil3.size.Size
+import io.legado.app.core.ui.morph.BookCoverMorphAnchors
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.button.PrimaryButton
 import io.legado.app.ui.widget.components.icon.AppIcon
@@ -125,6 +127,9 @@ fun PrivateLockedCover(
     val useCoilBlur = blurEnabled && !RuntimeBlurSupported
     Box(
         modifier = modifier
+            .graphicsLayer {
+                alpha = if (BookCoverMorphAnchors.isOriginCoverHidden(sharedCoverKey)) 0f else 1f
+            }
             .then(
                 with(sharedTransitionScope) {
                     if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) {

+ 365 - 0
app/src/test/java/io/legado/app/core/ui/morph/BookMorphTest.kt

@@ -0,0 +1,365 @@
+package io.legado.app.core.ui.morph
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.unit.Density
+import io.legado.app.ui.book.readaloud.morph.PlayerPanelCornerRadii
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class BookMorphTest {
+
+    @Before
+    fun setUp() {
+        BookCoverMorphAnchors.remove("test-key")
+    }
+
+    @Test
+    fun bookCoverMorphAnchorsStoresAndRetrieves() {
+        val bounds = Rect(100f, 200f, 300f, 500f)
+        BookCoverMorphAnchors.report(
+            key = "book-cover:http://book.test/1",
+            bounds = bounds,
+            cornerRadiusPx = 16f,
+            bookName = "测试书名",
+            author = "测试作者",
+            coverPath = "/path/to/cover.jpg",
+            badgeText = "12",
+            showBadgeDot = true,
+            leftBottomText = "小说",
+        )
+
+        // 精确匹配
+        val exact = BookCoverMorphAnchors.get("book-cover:http://book.test/1")
+        assertNotNull(exact)
+        assertEquals(bounds, exact?.bounds)
+        assertEquals(16f, exact?.cornerRadiusPx ?: 0f, 0.001f)
+        assertEquals("测试书名", exact?.bookName)
+        assertEquals("12", exact?.badgeText)
+        assertTrue(exact?.showBadgeDot == true)
+        assertEquals("小说", exact?.leftBottomText)
+
+        // 包含关系兜底匹配(传入 url 也能找到包含此 url 的 anchor)
+        val fuzzy = BookCoverMorphAnchors.get("http://book.test/1")
+        assertNotNull(fuzzy)
+        assertEquals("测试书名", fuzzy?.bookName)
+        assertEquals("12", fuzzy?.badgeText)
+    }
+
+    @Test
+    fun bookCoverMorphAnchorsTracksActiveMorphKey() {
+        assertFalse(BookCoverMorphAnchors.isMorphing("book-cover:http://book.test/1"))
+
+        BookCoverMorphAnchors.activeMorphKey = "book-cover:http://book.test/1"
+        assertTrue(BookCoverMorphAnchors.isMorphing("book-cover:http://book.test/1"))
+        // 书架带 sourceId 的 key 也能正确识别匹配
+        assertTrue(BookCoverMorphAnchors.isMorphing("book-cover:bookshelf:0:http://book.test/1"))
+        // 其他书籍不被隐藏
+        assertFalse(BookCoverMorphAnchors.isMorphing("book-cover:http://book.test/2"))
+        assertFalse(BookCoverMorphAnchors.isMorphing(null))
+
+        BookCoverMorphAnchors.clearActiveMorphKey("book-cover:http://book.test/1")
+        assertFalse(BookCoverMorphAnchors.isMorphing("book-cover:http://book.test/1"))
+    }
+
+    @Test
+    fun bookCoverMorphAnchorsDynamicOriginHiding() = runTest {
+        val key = "book-cover:http://book.test/dynamic"
+        val state = BookMorphState(Animatable(0f), Density(1f))
+
+        // 未激活时,源封面不隐藏
+        assertFalse(BookCoverMorphAnchors.isOriginCoverHidden(key))
+
+        // 绑定活跃形变,初始 progress = 0 时源封面不隐藏
+        BookCoverMorphAnchors.setActiveMorph(key, state)
+        assertTrue(BookCoverMorphAnchors.isMorphing(key))
+        assertFalse(BookCoverMorphAnchors.isOriginCoverHidden(key))
+
+        // 动画进行中 progress > 0,源封面隐藏(让位给飞行封面)
+        state.progress.snapTo(0.01f)
+        assertTrue(BookCoverMorphAnchors.isOriginCoverHidden(key))
+        state.progress.snapTo(0.5f)
+        assertTrue(BookCoverMorphAnchors.isOriginCoverHidden(key))
+        state.progress.snapTo(1f)
+        assertTrue(BookCoverMorphAnchors.isOriginCoverHidden(key))
+
+        // 返回动画完成归零 progress = 0,源封面零延迟瞬间显现
+        state.progress.snapTo(0f)
+        assertFalse(BookCoverMorphAnchors.isOriginCoverHidden(key))
+
+        // 清理活跃状态
+        BookCoverMorphAnchors.clearActiveMorph(key)
+        assertFalse(BookCoverMorphAnchors.isMorphing(key))
+        assertFalse(BookCoverMorphAnchors.isOriginCoverHidden(key))
+    }
+
+    @Test
+    fun bookMorphStateCalculatesFramesWhenAnchorPresent() = runTest {
+        val state = BookMorphState(Animatable(0f), Density(1f))
+        state.reportScreenBounds(Rect(0f, 0f, 1080f, 2400f))
+        state.reportScreenCorners(PlayerPanelCornerRadii.all(48f))
+
+        val anchor = BookCoverMorphAnchor(
+            bounds = Rect(100f, 200f, 300f, 500f),
+            cornerRadiusPx = 16f,
+            bookName = "书名",
+            author = "作者",
+            coverPath = "path",
+        )
+        state.setAnchor(anchor)
+        assertFalse(state.fadeOnlyOpening)
+
+        // 初始状态 progress = 0
+        val frame0 = state.panelFrame()
+        assertNotNull(frame0)
+        assertEquals(100f, frame0?.bounds?.left ?: 0f, 0.01f)
+        assertEquals(200f, frame0?.bounds?.top ?: 0f, 0.01f)
+
+        // 展开到中间 progress = 0.5
+        state.progress.snapTo(0.5f)
+        val frameHalf = state.panelFrame()
+        assertNotNull(frameHalf)
+        assertTrue((frameHalf?.bounds?.width ?: 0f) > 200f)
+        assertTrue((frameHalf?.bounds?.width ?: 0f) < 1080f)
+        assertFalse(state.expanded)
+
+        // 完全展开 progress = 1.0
+        state.progress.snapTo(1f)
+        assertTrue(state.expanded)
+        assertEquals(1f, state.veil, 0.001f)
+        val frameEnd = state.panelFrame()
+        assertNotNull(frameEnd)
+        assertEquals(1080f, frameEnd?.bounds?.width ?: 0f, 0.01f)
+        assertEquals(2400f, frameEnd?.bounds?.height ?: 0f, 0.01f)
+    }
+
+    @Test
+    fun bookMorphStateFallsBackToFadeOnlyWithoutAnchor() = runTest {
+        val state = BookMorphState(Animatable(0f), Density(1f))
+        state.reportScreenBounds(Rect(0f, 0f, 1080f, 2400f))
+        state.setAnchor(null)
+
+        assertTrue(state.fadeOnlyOpening)
+        assertNull(state.panelFrame())
+
+        state.progress.snapTo(0.5f)
+        assertEquals(0.5f, state.veil, 0.001f)
+    }
+
+    @Test
+    fun bookCoverAlphaTransitionsEarlyInAnimation() {
+        // 打开时:在动画前期(0.10 -> 0.25)平滑淡出,避免封面在展开中途过大
+        assertEquals(1f, computeBookCoverAlpha(0f, isCollapsing = false), 0.001f)
+        assertEquals(1f, computeBookCoverAlpha(0.10f, isCollapsing = false), 0.001f)
+        assertEquals(0f, computeBookCoverAlpha(0.25f, isCollapsing = false), 0.001f)
+        assertEquals(0f, computeBookCoverAlpha(0.50f, isCollapsing = false), 0.001f)
+        assertEquals(0f, computeBookCoverAlpha(1f, isCollapsing = false), 0.001f)
+
+        // 收起时:在动画末段(0.25 -> 0.10)渐显回到书架卡片
+        assertEquals(0f, computeBookCoverAlpha(1f, isCollapsing = true), 0.001f)
+        assertEquals(0f, computeBookCoverAlpha(0.50f, isCollapsing = true), 0.001f)
+        assertEquals(0f, computeBookCoverAlpha(0.25f, isCollapsing = true), 0.001f)
+        assertEquals(1f, computeBookCoverAlpha(0.10f, isCollapsing = true), 0.001f)
+        assertEquals(1f, computeBookCoverAlpha(0f, isCollapsing = true), 0.001f)
+    }
+
+    @Test
+    fun bookBadgeAlphaTransitionsGradually() {
+        // 打开时:在极前期(0.0 -> 0.15)平滑淡出,交接给阅读/详情页
+        assertEquals(1f, computeBookBadgeAlpha(0f, isCollapsing = false), 0.001f)
+        assertEquals(0.5f, computeBookBadgeAlpha(0.075f, isCollapsing = false), 0.001f)
+        assertEquals(0f, computeBookBadgeAlpha(0.15f, isCollapsing = false), 0.001f)
+        assertEquals(0f, computeBookBadgeAlpha(0.5f, isCollapsing = false), 0.001f)
+        assertEquals(0f, computeBookBadgeAlpha(1f, isCollapsing = false), 0.001f)
+
+        // 收起时:在动画末段(0.20 -> 0.0)平滑渐显,与书架原生角标零时差无缝衔接
+        assertEquals(0f, computeBookBadgeAlpha(1f, isCollapsing = true), 0.001f)
+        assertEquals(0f, computeBookBadgeAlpha(0.50f, isCollapsing = true), 0.001f)
+        assertEquals(0f, computeBookBadgeAlpha(0.20f, isCollapsing = true), 0.001f)
+        assertEquals(0.5f, computeBookBadgeAlpha(0.10f, isCollapsing = true), 0.001f)
+        assertEquals(0.75f, computeBookBadgeAlpha(0.05f, isCollapsing = true), 0.001f)
+        assertEquals(1f, computeBookBadgeAlpha(0f, isCollapsing = true), 0.001f)
+    }
+
+    @Test
+    fun bookMorphStateTracksUpdatedAnchorWhenBookshelfReorders() = runTest {
+        val state = BookMorphState(Animatable(1f), Density(1f))
+        state.reportScreenBounds(Rect(0f, 0f, 1080f, 2400f))
+        state.reportScreenCorners(PlayerPanelCornerRadii.all(48f))
+
+        val key = "book-cover:http://book.test/reorder"
+        // 初始位置在第 2 个位置 (400, 300)
+        BookCoverMorphAnchors.report(
+            key = key,
+            bounds = Rect(400f, 300f, 600f, 600f),
+            cornerRadiusPx = 16f,
+            bookName = "书名",
+        )
+        state.setAnchor(BookCoverMorphAnchors.get(key), key)
+
+        // 阅读后书架重新排序,移动到第 1 个位置 (50, 100)
+        BookCoverMorphAnchors.report(
+            key = key,
+            bounds = Rect(50f, 100f, 250f, 400f),
+            cornerRadiusPx = 16f,
+            bookName = "书名",
+        )
+
+        // 开始收起
+        state.onPredictiveBackStart()
+        assertTrue(state.isCollapsing)
+        state.progress.snapTo(0f)
+
+        val frame = state.panelFrame()
+        assertNotNull(frame)
+        // 终点必须精准追随到重排后的第 1 个位置 (50, 100),而不是旧位置 (400, 300)
+        assertEquals(50f, frame?.bounds?.left ?: 0f, 0.01f)
+        assertEquals(100f, frame?.bounds?.top ?: 0f, 0.01f)
+    }
+
+    @Test
+    fun isAnchorVisibleInScreenCorrectlyIdentifiesVisibility() {
+        val screen = Rect(0f, 0f, 1080f, 2400f)
+
+        // 完全在屏幕内
+        assertTrue(isAnchorVisibleInScreen(Rect(50f, 100f, 250f, 400f), screen))
+
+        // 完全在屏幕上方之外
+        assertFalse(isAnchorVisibleInScreen(Rect(50f, -500f, 250f, -200f), screen))
+
+        // 完全在屏幕下方之外
+        assertFalse(isAnchorVisibleInScreen(Rect(50f, 2500f, 250f, 2800f), screen))
+
+        // 完全在屏幕左侧之外
+        assertFalse(isAnchorVisibleInScreen(Rect(-300f, 100f, -50f, 400f), screen))
+
+        // 大部分在屏幕内(底部露出一半以上)
+        assertTrue(isAnchorVisibleInScreen(Rect(50f, 2300f, 250f, 2500f), screen))
+
+        // 仅擦边小于 25% 面积可见
+        assertFalse(isAnchorVisibleInScreen(Rect(50f, 2380f, 250f, 2680f), screen))
+
+        // 空矩形
+        assertFalse(isAnchorVisibleInScreen(Rect.Zero, screen))
+    }
+
+    @Test
+    fun bookMorphStateNeverFliesOffScreenWhenAnchorIsOffScreen() = runTest {
+        val state = BookMorphState(Animatable(1f), Density(1f))
+        val screen = Rect(0f, 0f, 1080f, 2400f)
+        state.reportScreenBounds(screen)
+        state.reportScreenCorners(PlayerPanelCornerRadii.all(48f))
+
+        val key = "book-cover:http://book.test/offscreen"
+        // 初始位置在屏幕下方或外面
+        val initialOffscreen = Rect(50f, -800f, 250f, -500f)
+        BookCoverMorphAnchors.report(
+            key = key,
+            bounds = initialOffscreen,
+            cornerRadiusPx = 16f,
+            bookName = "屏幕外书名",
+        )
+        state.setAnchor(BookCoverMorphAnchors.get(key), key)
+
+        // 收起时,如果锚点在屏幕外(例如重排到列表顶部但当前滚动在下方)
+        state.onPredictiveBackStart()
+        assertTrue(state.isCollapsing)
+        state.progress.snapTo(0f)
+
+        val frame = state.panelFrame()
+        assertNotNull(frame)
+        // 验证收敛矩形绝对不飞出屏幕(top >= 0 且 bottom <= 2400)
+        val frameBounds = frame?.bounds ?: Rect.Zero
+        assertTrue("Frame bounds left should be inside screen", frameBounds.left >= screen.left)
+        assertTrue("Frame bounds top should be inside screen", frameBounds.top >= screen.top)
+        assertTrue("Frame bounds right should be inside screen", frameBounds.right <= screen.right)
+        assertTrue(
+            "Frame bounds bottom should be inside screen",
+            frameBounds.bottom <= screen.bottom
+        )
+    }
+
+    @Test
+    fun bookMorphStateContinuousCoverTransitionWhenTargetCoverPresent() = runTest {
+        val state = BookMorphState(Animatable(0f), Density(1f))
+        val screen = Rect(0f, 0f, 1080f, 2400f)
+        state.reportScreenBounds(screen)
+        state.reportScreenCorners(PlayerPanelCornerRadii.all(48f))
+
+        val start = Rect(100f, 300f, 300f, 580f)
+        val end = Rect(50f, 100f, 350f, 520f)
+        val anchor = BookCoverMorphAnchor(
+            bounds = start,
+            cornerRadiusPx = 16f,
+            bookName = "测试书名",
+            author = "测试作者",
+            coverPath = "/path/cover.jpg",
+        )
+        state.setAnchor(anchor)
+        state.reportCoverEnd(end, cornerRadiusPx = 12f)
+
+        assertTrue(state.hasCoverEnd)
+
+        // 初始状态 progress = 0: coverFrame 起点
+        val frame0 = state.coverFrame()
+        assertNotNull(frame0)
+        assertEquals(start.left, frame0?.bounds?.left ?: 0f, 0.01f)
+        assertEquals(start.top, frame0?.bounds?.top ?: 0f, 0.01f)
+        assertEquals(0f, state.coverAlpha, 0.001f) // 端点由页面/书架自身显示
+
+        // 动画进行中 progress = 0.5: 封面保持连续全程可见 (alpha = 1f)
+        state.progress.snapTo(0.5f)
+        val frameHalf = state.coverFrame()
+        assertNotNull(frameHalf)
+        assertTrue((frameHalf?.bounds?.left ?: 0f) in 50f..100f)
+        assertEquals(1f, state.coverAlpha, 0.001f)
+
+        // 完全展开 progress = 1: coverFrame 到达目标位置
+        state.progress.snapTo(1f)
+        val frameEnd = state.coverFrame()
+        assertNotNull(frameEnd)
+        assertEquals(end.left, frameEnd?.bounds?.left ?: 0f, 0.01f)
+        assertEquals(end.top, frameEnd?.bounds?.top ?: 0f, 0.01f)
+        assertEquals(0f, state.coverAlpha, 0.001f) // 展开完成交接给详情页封面
+    }
+
+    @Test
+    fun bookMorphStateHasTargetCoverKeepsAlphaAndTargetStartBeforeCoverEndReported() = runTest {
+        val state = BookMorphState(Animatable(0f), Density(1f), hasTargetCover = true)
+        val screen = Rect(0f, 0f, 1080f, 2400f)
+        state.reportScreenBounds(screen)
+
+        val start = Rect(100f, 300f, 300f, 580f)
+        val anchor = BookCoverMorphAnchor(
+            bounds = start,
+            cornerRadiusPx = 16f,
+            bookName = "测试书名",
+            author = "测试作者",
+            coverPath = "/path/cover.jpg",
+            bookUrl = "http://book.test/1",
+        )
+        state.setAnchor(anchor)
+
+        // 目标封面尚未上报终点(第 0 帧)
+        assertFalse(state.hasCoverEnd)
+        assertEquals("http://book.test/1", state.bookUrl)
+
+        // targetStart 稳妥锚定在起点
+        val (targetStartBounds, targetStartRadius) = state.targetStart()
+        assertEquals(start, targetStartBounds)
+        assertEquals(16f, targetStartRadius, 0.001f)
+
+        // progress = 0 时为 0f,progress > 0 即连续全程可见,不随漫画/小说渐隐
+        assertEquals(0f, state.coverAlpha, 0.001f)
+        state.progress.snapTo(0.05f)
+        assertEquals(1f, state.coverAlpha, 0.001f)
+        state.progress.snapTo(0.5f)
+        assertEquals(1f, state.coverAlpha, 0.001f)
+    }
+}

+ 7 - 6
app/src/test/java/io/legado/app/ui/book/readaloud/morph/PlayerMorphFramesTest.kt

@@ -167,9 +167,10 @@ class PlayerMorphFramesTest {
     }
 
     @Test
-    fun `veil follows reference linear window without an extra easing`() {
-        assertEquals(0.25f, computeMorphVeil(0.8875f), 0.001f)
-        assertEquals(0.75f, computeMorphVeil(0.9625f), 0.001f)
+    fun `veil transitions smoothly in mid to late phase`() {
+        assertEquals(0f, computeMorphVeil(0.20f), 0.001f)
+        assertEquals(0.5f, computeMorphVeil(0.425f), 0.001f)
+        assertEquals(1f, computeMorphVeil(0.65f), 0.001f)
     }
 
     @Test
@@ -398,11 +399,11 @@ class PlayerMorphFramesTest {
     }
 
     @Test
-    fun `veil stays hidden until the last stretch`() {
+    fun `veil stays hidden until the mid phase`() {
         assertEquals(0f, computeMorphVeil(0f), 0.001f)
         assertEquals(0f, computeMorphVeil(MORPH_VEIL_START), 0.001f)
-        assertEquals(0f, computeMorphVeil(0.5f), 0.001f)
+        assertTrue(computeMorphVeil(0.5f) in 0.6f..0.8f)
         assertEquals(1f, computeMorphVeil(1f), 0.001f)
-        assertTrue(computeMorphVeil(0.925f) in 0.4f..0.6f)
+        assertEquals(1f, computeMorphVeil(MORPH_VEIL_END), 0.001f)
     }
 }